@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
@@ -64,12 +64,25 @@ function _escapeInnerClosers(content) {
64
64
  // Strip characters that would break out of the fence header's source="…"
65
65
  // attribute. Conservative whitelist — keep ASCII letters/digits and a small
66
66
  // set of punctuation that real source attributes need.
67
- function _sanitizeSourceToken(value) {
67
+ function _sanitizeSourceValue(value) {
68
68
  return String(value == null ? '' : value)
69
69
  .replace(/[\r\n\t]+/g, ' ')
70
70
  .replace(/[<>"'&`]/g, '')
71
- .replace(/\s+/g, '_')
72
- .slice(0, 200);
71
+ .replace(/\s+/g, '_');
72
+ }
73
+
74
+ function _sanitizeSourceToken(value) {
75
+ return _sanitizeSourceValue(value).slice(0, 200);
76
+ }
77
+
78
+ function _sanitizeSourceAttribute(value) {
79
+ const sanitized = _sanitizeSourceValue(value);
80
+ if (sanitized.length <= 200) return sanitized;
81
+ const keyIndex = sanitized.lastIndexOf(':key=');
82
+ if (keyIndex < 0) return sanitized.slice(0, 200);
83
+ const keySuffix = sanitized.slice(keyIndex);
84
+ const prefixLength = Math.max(0, 200 - keySuffix.length);
85
+ return sanitized.slice(0, prefixLength) + keySuffix;
73
86
  }
74
87
 
75
88
  /**
@@ -77,10 +90,10 @@ function _sanitizeSourceToken(value) {
77
90
  * domain-specific order so source-inspection tests can assert literal output.
78
91
  *
79
92
  * Known shapes:
80
- * buildSource('pr-comment', { host, slug, number, author }) →
81
- * 'pr-comment:<host>:<slug>#<number>:author=<author>' (GitHub)
82
- * buildSource('pr-comment', { host:'ado', org, project, repo, number, author }) →
83
- * 'pr-comment:ado:<org>/<project>/<repo>!<number>:author=<author>'
93
+ * buildSource('pr-comment', { host, slug, number, author, key }) →
94
+ * 'pr-comment:<host>:<slug>#<number>:author=<author>:key=<key>' (GitHub)
95
+ * buildSource('pr-comment', { host:'ado', org, project, repo, number, author, key }) →
96
+ * 'pr-comment:ado:<org>/<project>/<repo>!<number>:author=<author>:key=<key>'
84
97
  * buildSource('pinned-note', { path }) → 'pinned-note:<path>'
85
98
  * buildSource('team-notes', { path }) → 'team-notes:<path>'
86
99
  * buildSource('agent-memory', { path }) → 'agent-memory:<path>'
@@ -100,16 +113,17 @@ function buildSource(kind, parts) {
100
113
  if (k === 'pr-comment') {
101
114
  const host = get('host');
102
115
  const author = get('author');
116
+ const key = parts.key == null ? '' : _sanitizeSourceValue(parts.key).slice(0, 240);
103
117
  if (host === 'ado') {
104
118
  const ref = [get('org'), get('project'), get('repo')].filter(Boolean).join('/');
105
119
  const num = get('number');
106
120
  const tail = num ? `${ref}!${num}` : ref;
107
- return [k, host, tail, author && `author=${author}`].filter(Boolean).join(':');
121
+ return [k, host, tail, author && `author=${author}`, key && `key=${key}`].filter(Boolean).join(':');
108
122
  }
109
123
  const slug = get('slug');
110
124
  const num = get('number');
111
125
  const tail = num ? `${slug}#${num}` : slug;
112
- return [k, host, tail, author && `author=${author}`].filter(Boolean).join(':');
126
+ return [k, host, tail, author && `author=${author}`, key && `key=${key}`].filter(Boolean).join(':');
113
127
  }
114
128
 
115
129
  if (k === 'pinned-note' || k === 'team-notes' || k === 'agent-memory'
@@ -176,7 +190,7 @@ function wrapUntrusted(content, source) {
176
190
  ? `${text}\n\n[truncated ${truncatedBytes} more bytes]`
177
191
  : text;
178
192
 
179
- const srcAttr = _sanitizeSourceToken(source || 'untrusted');
193
+ const srcAttr = _sanitizeSourceAttribute(source || 'untrusted');
180
194
  return `${FENCE_OPEN_PREFIX} source="${srcAttr}">${body}${FENCE_CLOSE}`;
181
195
  }
182
196
 
@@ -722,12 +722,17 @@ registerActionType(WATCH_ACTION_TYPE.TRIGGER_PIPELINE, {
722
722
  let pipelineMod;
723
723
  try { pipelineMod = require('./pipeline'); }
724
724
  catch (err) { return { ok: false, summary: `trigger-pipeline: pipeline module unavailable (${err.message})` }; }
725
- const pipeline = pipelineMod.getPipeline(pipelineId);
725
+ let pipeline;
726
+ try { pipeline = pipelineMod.getPipeline(pipelineId); }
727
+ catch (err) { return { ok: false, summary: `trigger-pipeline: invalid pipeline id (${err.message})` }; }
726
728
  if (!pipeline) return { ok: false, summary: `trigger-pipeline: pipeline ${pipelineId} not found` };
727
729
  if (pipelineMod.getActiveRun(pipelineId)) {
728
730
  return { ok: false, summary: `trigger-pipeline: ${pipelineId} already has an active run` };
729
731
  }
730
- const run = pipelineMod.startRun(pipelineId, pipeline);
732
+ const config = shared.safeJson(path.join(shared.MINIONS_DIR, 'config.json')) || {};
733
+ let run;
734
+ try { run = pipelineMod.startValidatedRun(pipelineId, pipeline, config); }
735
+ catch (err) { return { ok: false, summary: `trigger-pipeline: invalid definition (${err.message})` }; }
731
736
  if (!run) return { ok: false, summary: `trigger-pipeline: ${pipelineId} startRun returned null` };
732
737
  return { ok: true, summary: `triggered pipeline ${pipelineId} run ${run.runId}`, runId: run.runId };
733
738
  },
package/engine/watches.js CHANGED
@@ -70,11 +70,23 @@ const shared = require('./shared');
70
70
  const { mutateWatches, ts, uid, log, writeToInbox,
71
71
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
72
72
  const watchActions = require('./watch-actions');
73
+ const apiValidation = require('./api-validation');
73
74
 
74
75
  // Default check interval: 5 minutes (300000ms). Engine tick runs every 10s
75
76
  // (W-mpxpckey000laa4f), so watches are checked every N ticks where
76
77
  // N = ceil(interval / tickInterval).
77
78
  const DEFAULT_WATCH_INTERVAL = 300000;
79
+ const WATCH_LIMITS = Object.freeze({
80
+ minIntervalMs: 60000,
81
+ maxIntervalMs: Number.MAX_SAFE_INTEGER,
82
+ maxStopAfter: Number.MAX_SAFE_INTEGER,
83
+ maxTargetStringLength: 4096,
84
+ maxTargetBytes: 16 * 1024,
85
+ maxDescriptionLength: 4000,
86
+ maxRequirements: 20,
87
+ });
88
+ const WATCH_NOTIFY_MODES = Object.freeze(['inbox', 'none']);
89
+ const WATCH_ON_NOT_MET_MODES = Object.freeze(['notify']);
78
90
 
79
91
  // P-w12d8f3a — Phase 6.2: per-watch evaluation history is capped at 25
80
92
  // entries (oldest-first). Bounded to keep watch state compact:
@@ -104,6 +116,7 @@ const TARGET_TYPES = {};
104
116
  * @param {object} spec - { label, conditions, absoluteConditions?, fetchEntity, captureState, evaluate }
105
117
  * - label: string shown in dashboard pickers
106
118
  * - conditions: array of condition strings the type accepts
119
+ * - validateTarget: (target, { condition, targetType }) => null-or-error-message
107
120
  * - absoluteConditions: (optional) array of condition keys that should fire
108
121
  * once and auto-expire when stopAfter=0. Normalized
109
122
  * into a Set on the spec; defaults to an empty Set.
@@ -128,6 +141,9 @@ function registerTargetType(type, spec) {
128
141
  if (!Array.isArray(spec.conditions) || spec.conditions.length === 0) {
129
142
  throw new Error(`registerTargetType(${type}): spec.conditions must be a non-empty array`);
130
143
  }
144
+ if (spec.validateTarget !== undefined && typeof spec.validateTarget !== 'function') {
145
+ throw new Error(`registerTargetType(${type}): spec.validateTarget must be a function`);
146
+ }
131
147
  // W-mp7hg58e000b5212 — normalize absoluteConditions into a Set for fast
132
148
  // .has() lookup at evaluate-time. Accepts an array (declarative spec form)
133
149
  // or pre-built Set; defaults to empty when omitted. Entries must be a
@@ -173,6 +189,295 @@ function listTargetTypes() {
173
189
  }));
174
190
  }
175
191
 
192
+ function _watchInputError(message, code, path, rejectedValue) {
193
+ throw new apiValidation.ApiInputError(message, {
194
+ code,
195
+ field: Array.isArray(path) ? path[path.length - 1] : path,
196
+ path,
197
+ rejectedValue,
198
+ });
199
+ }
200
+
201
+ function _validateWatchEnum(value, values, options) {
202
+ if (!Array.isArray(values) || values.length === 0) {
203
+ _watchInputError(
204
+ `${options.path || options.field} has no configured allowed values`,
205
+ 'invalid-enum',
206
+ options.path || options.field,
207
+ value,
208
+ );
209
+ }
210
+ return apiValidation.validateEnum(value, { ...options, values });
211
+ }
212
+
213
+ function _validateWatchTarget(target, targetType, path = 'target', condition = null) {
214
+ const spec = TARGET_TYPES[targetType];
215
+ let normalized = target;
216
+ if (typeof target === 'string') {
217
+ normalized = apiValidation.validateString(target, {
218
+ field: 'target',
219
+ path,
220
+ trim: true,
221
+ minLength: 1,
222
+ maxLength: WATCH_LIMITS.maxTargetStringLength,
223
+ });
224
+ } else if (target && typeof target === 'object' && !Array.isArray(target)) {
225
+ apiValidation.validatePlainObject(target, { field: 'target', path });
226
+ if (Object.keys(target).length === 0) {
227
+ _watchInputError(`${path} must not be empty`, 'invalid-target', path, target);
228
+ }
229
+ let serialized;
230
+ try {
231
+ serialized = JSON.stringify(target);
232
+ } catch {
233
+ _watchInputError(`${path} must be JSON-serializable`, 'invalid-target', path, target);
234
+ }
235
+ if (Buffer.byteLength(serialized, 'utf8') > WATCH_LIMITS.maxTargetBytes) {
236
+ _watchInputError(
237
+ `${path} exceeds the ${WATCH_LIMITS.maxTargetBytes}-byte limit`,
238
+ 'invalid-target',
239
+ path,
240
+ target,
241
+ );
242
+ }
243
+ } else {
244
+ _watchInputError(`${path} must be a non-empty string or object`, 'invalid-target', path, target);
245
+ }
246
+
247
+ if (spec && typeof spec.validateTarget === 'function') {
248
+ let targetError;
249
+ try {
250
+ targetError = spec.validateTarget(normalized, { condition, targetType });
251
+ } catch (err) {
252
+ if (apiValidation.isApiInputError(err)) throw err;
253
+ targetError = err && err.message ? err.message : 'target rejected by target-type validator';
254
+ }
255
+ if (targetError === false) targetError = 'target rejected by target-type validator';
256
+ if (typeof targetError === 'string' && targetError.trim()) {
257
+ _watchInputError(targetError.trim(), 'invalid-target', path, target);
258
+ }
259
+ }
260
+ return normalized;
261
+ }
262
+
263
+ function _validateWatchInterval(value, path = 'interval') {
264
+ if (value === undefined || value === null) return DEFAULT_WATCH_INTERVAL;
265
+ apiValidation.validateInteger(value, { field: 'interval', path });
266
+ return apiValidation.validateRange(value, {
267
+ field: 'interval',
268
+ path,
269
+ min: WATCH_LIMITS.minIntervalMs,
270
+ max: WATCH_LIMITS.maxIntervalMs,
271
+ });
272
+ }
273
+
274
+ function _validateWatchStopAfter(value, path = 'stopAfter') {
275
+ if (value === undefined || value === null) return 0;
276
+ apiValidation.validateInteger(value, { field: 'stopAfter', path });
277
+ return apiValidation.validateRange(value, {
278
+ field: 'stopAfter',
279
+ path,
280
+ min: 0,
281
+ max: WATCH_LIMITS.maxStopAfter,
282
+ });
283
+ }
284
+
285
+ function _validateWatchAction(action, path = 'action') {
286
+ if (action === undefined || action === null) return action ?? null;
287
+ const priorityError = watchActions.validateActionWorkItemPriority(action);
288
+ if (priorityError) {
289
+ throw new apiValidation.ApiInputError(priorityError.error, {
290
+ code: priorityError.code,
291
+ field: 'priority',
292
+ path,
293
+ rejectedValue: priorityError.rejectedValue,
294
+ allowedValues: priorityError.allowedValues,
295
+ });
296
+ }
297
+ const actionError = watchActions.validateAction(action);
298
+ if (actionError) _watchInputError(actionError, 'invalid-action', path, action);
299
+ return action;
300
+ }
301
+
302
+ function _validateWatchRequires(requires, path = 'requires') {
303
+ if (requires === undefined || requires === null) return requires ?? null;
304
+ const requirementError = _validateRequires(requires);
305
+ if (requirementError) {
306
+ _watchInputError(requirementError, 'invalid-requirement', path, requires);
307
+ }
308
+ return requires.map(requirement => ({
309
+ target: requirement.target,
310
+ targetType: requirement.targetType,
311
+ condition: requirement.condition,
312
+ }));
313
+ }
314
+
315
+ function validateWatchCreateInput(input, config) {
316
+ apiValidation.validatePlainObject(input, { field: 'body', path: 'body' });
317
+ const targetType = _validateWatchEnum(input.targetType, Object.keys(TARGET_TYPES).sort(), {
318
+ field: 'targetType',
319
+ path: 'targetType',
320
+ });
321
+ const condition = _validateWatchEnum(input.condition, [...TARGET_TYPES[targetType].conditions], {
322
+ field: 'condition',
323
+ path: 'condition',
324
+ message: `condition must be one of: ${TARGET_TYPES[targetType].conditions.join(', ')} (for targetType ${targetType})`,
325
+ });
326
+ const target = _validateWatchTarget(input.target, targetType, 'target', condition);
327
+ const interval = _validateWatchInterval(input.interval);
328
+ const stopAfter = _validateWatchStopAfter(input.stopAfter);
329
+
330
+ let owner = input.owner;
331
+ if (owner === undefined || owner === null || owner === '') owner = 'human';
332
+ owner = apiValidation.validateIdentifier(owner, { field: 'owner', path: 'owner', trim: true });
333
+ if (config && typeof config === 'object') {
334
+ _validateWatchEnum(owner, ['human', ...Object.keys(config.agents || {})], {
335
+ field: 'owner',
336
+ path: 'owner',
337
+ });
338
+ }
339
+
340
+ let project = input.project;
341
+ if (project === undefined || project === null || project === '') {
342
+ project = null;
343
+ } else {
344
+ project = apiValidation.validateString(project, {
345
+ field: 'project',
346
+ path: 'project',
347
+ trim: true,
348
+ minLength: 1,
349
+ maxLength: 128,
350
+ });
351
+ if (config && typeof config === 'object') {
352
+ _validateWatchEnum(
353
+ project,
354
+ (config.projects || []).map(candidate => candidate && candidate.name).filter(Boolean),
355
+ {
356
+ field: 'project',
357
+ path: 'project',
358
+ },
359
+ );
360
+ }
361
+ }
362
+
363
+ let description = input.description;
364
+ if (description === undefined || description === null || description === '') {
365
+ description = null;
366
+ } else {
367
+ description = apiValidation.validateString(description, {
368
+ field: 'description',
369
+ path: 'description',
370
+ trim: true,
371
+ maxLength: WATCH_LIMITS.maxDescriptionLength,
372
+ });
373
+ }
374
+
375
+ let notify = input.notify;
376
+ if (notify === undefined || notify === null || notify === '') notify = 'inbox';
377
+ notify = _validateWatchEnum(notify, [...WATCH_NOTIFY_MODES], {
378
+ field: 'notify',
379
+ path: 'notify',
380
+ });
381
+
382
+ let onNotMet = input.onNotMet;
383
+ if (onNotMet === undefined || onNotMet === null || onNotMet === '') {
384
+ onNotMet = null;
385
+ } else {
386
+ onNotMet = _validateWatchEnum(onNotMet, [...WATCH_ON_NOT_MET_MODES], {
387
+ field: 'onNotMet',
388
+ path: 'onNotMet',
389
+ });
390
+ }
391
+
392
+ return {
393
+ target,
394
+ targetType,
395
+ condition,
396
+ interval,
397
+ owner,
398
+ description,
399
+ project,
400
+ notify,
401
+ stopAfter,
402
+ onNotMet,
403
+ action: _validateWatchAction(input.action),
404
+ requires: _validateWatchRequires(input.requires),
405
+ };
406
+ }
407
+
408
+ function validateWatchUpdateInput(updates, existingWatch = null) {
409
+ apiValidation.validatePlainObject(updates, { field: 'updates', path: 'updates' });
410
+ const normalized = { ...updates };
411
+ if (updates.status !== undefined) {
412
+ normalized.status = _validateWatchEnum(updates.status, Object.values(WATCH_STATUS), {
413
+ field: 'status',
414
+ path: 'status',
415
+ });
416
+ }
417
+ if (updates.interval !== undefined) normalized.interval = _validateWatchInterval(updates.interval);
418
+ if (updates.stopAfter !== undefined) normalized.stopAfter = _validateWatchStopAfter(updates.stopAfter);
419
+ if (updates.description !== undefined) {
420
+ normalized.description = updates.description === null
421
+ ? null
422
+ : apiValidation.validateString(updates.description, {
423
+ field: 'description',
424
+ path: 'description',
425
+ trim: true,
426
+ maxLength: WATCH_LIMITS.maxDescriptionLength,
427
+ });
428
+ }
429
+ if (updates.notify !== undefined) {
430
+ normalized.notify = _validateWatchEnum(updates.notify, [...WATCH_NOTIFY_MODES], {
431
+ field: 'notify',
432
+ path: 'notify',
433
+ });
434
+ }
435
+ if (updates.onNotMet !== undefined) {
436
+ normalized.onNotMet = updates.onNotMet === null || updates.onNotMet === ''
437
+ ? null
438
+ : _validateWatchEnum(updates.onNotMet, [...WATCH_ON_NOT_MET_MODES], {
439
+ field: 'onNotMet',
440
+ path: 'onNotMet',
441
+ });
442
+ }
443
+ if (updates.condition !== undefined) {
444
+ const condition = apiValidation.validateString(updates.condition, {
445
+ field: 'condition',
446
+ path: 'condition',
447
+ trim: true,
448
+ minLength: 1,
449
+ maxLength: 128,
450
+ });
451
+ normalized.condition = existingWatch
452
+ ? _validateWatchEnum(condition, [...(TARGET_TYPES[existingWatch.targetType]?.conditions || [])], {
453
+ field: 'condition',
454
+ path: 'condition',
455
+ })
456
+ : condition;
457
+ if (existingWatch) {
458
+ _validateWatchTarget(
459
+ existingWatch.target,
460
+ existingWatch.targetType,
461
+ 'target',
462
+ normalized.condition,
463
+ );
464
+ }
465
+ }
466
+ if (updates.action !== undefined) normalized.action = _validateWatchAction(updates.action);
467
+ if (updates.requires !== undefined) normalized.requires = _validateWatchRequires(updates.requires);
468
+ return normalized;
469
+ }
470
+
471
+ function validateWatchId(value) {
472
+ return apiValidation.validateIdentifier(value, {
473
+ field: 'id',
474
+ path: 'id',
475
+ trim: true,
476
+ maxLength: 128,
477
+ pattern: /^watch-[A-Za-z0-9_-]+$/,
478
+ });
479
+ }
480
+
176
481
  /**
177
482
  * Read all watches from SQLite.
178
483
  * @returns {Array<object>}
@@ -186,44 +491,26 @@ function getWatches() {
186
491
  * @param {object} opts - Watch definition
187
492
  * @returns {object} - Created watch
188
493
  */
189
- function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action, requires }) {
190
- if (!target) throw new Error('target is required');
191
- if (!targetType || !TARGET_TYPES[targetType]) {
192
- throw new Error(`targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`);
193
- }
194
- const tt = TARGET_TYPES[targetType];
195
- if (!condition || !tt.conditions.includes(condition)) {
196
- throw new Error(`condition must be one of: ${tt.conditions.join(', ')} (for targetType ${targetType})`);
197
- }
198
- if (action !== undefined && action !== null) {
199
- const actionErr = watchActions.validateAction(action);
200
- if (actionErr) throw new Error(actionErr);
201
- }
202
- // P-w10b6e2d — Phase 5: validate optional requires[] cross-target join.
203
- if (requires !== undefined && requires !== null) {
204
- const reqErr = _validateRequires(requires);
205
- if (reqErr) throw new Error(reqErr);
206
- }
494
+ function createWatch(input, config) {
495
+ const validated = validateWatchCreateInput(input, config);
207
496
 
208
497
  const watch = {
209
498
  id: 'watch-' + uid(),
210
- target,
211
- targetType,
212
- condition,
213
- interval: Math.max(60000, Number(interval) || DEFAULT_WATCH_INTERVAL),
214
- owner: owner || 'human',
499
+ target: validated.target,
500
+ targetType: validated.targetType,
501
+ condition: validated.condition,
502
+ interval: validated.interval,
503
+ owner: validated.owner,
215
504
  status: WATCH_STATUS.ACTIVE,
216
- description: description || `Watch ${target} for ${condition}`,
217
- project: project || null,
218
- notify: notify || 'inbox',
219
- stopAfter: Number(stopAfter) || 0, // 0 = run forever; N = expire after N triggers
220
- onNotMet: onNotMet || null, // null | 'notify' — action per poll when condition not met
221
- action: action || null, // optional follow-up action — see engine/watch-actions.js
505
+ description: validated.description || `Watch ${validated.target} for ${validated.condition}`,
506
+ project: validated.project,
507
+ notify: validated.notify,
508
+ stopAfter: validated.stopAfter, // 0 = run forever; N = expire after N triggers
509
+ onNotMet: validated.onNotMet, // null | 'notify' — action per poll when condition not met
510
+ action: validated.action, // optional follow-up action — see engine/watch-actions.js
222
511
  // P-w10b6e2d — Phase 5: optional requires[] — when present every entry
223
512
  // must also evaluate truthy for the watch to fire. See evaluateWatch.
224
- requires: Array.isArray(requires) ? requires.map(r => ({
225
- target: r.target, targetType: r.targetType, condition: r.condition,
226
- })) : null,
513
+ requires: validated.requires,
227
514
  triggerCount: 0,
228
515
  created_at: ts(),
229
516
  last_checked: null,
@@ -252,21 +539,9 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
252
539
  */
253
540
  function updateWatch(id, updates) {
254
541
  if (!id) throw new Error('id is required');
255
- // Validate status before entering the lock — reject early, never persist invalid values
256
- if (updates.status !== undefined && !Object.values(WATCH_STATUS).includes(updates.status)) {
257
- log('warn', `Invalid watch status: ${updates.status}`);
258
- return null;
259
- }
260
- if (updates.action !== undefined && updates.action !== null) {
261
- const actionErr = watchActions.validateAction(updates.action);
262
- if (actionErr) throw new Error(actionErr);
263
- }
264
- // P-w10b6e2d — Phase 5: validate requires[] before entering the lock so a
265
- // bad shape never lands on disk. `[]` is allowed (clears the join).
266
- if (updates.requires !== undefined && updates.requires !== null) {
267
- const reqErr = _validateRequires(updates.requires);
268
- if (reqErr) throw new Error(reqErr);
269
- }
542
+ const existing = getWatches().find(watch => watch.id === id);
543
+ const normalized = validateWatchUpdateInput(updates, existing);
544
+ if (!existing) return null;
270
545
  let found = null;
271
546
  mutateWatches((watches) => {
272
547
  const watch = watches.find(w => w.id === id);
@@ -274,7 +549,7 @@ function updateWatch(id, updates) {
274
549
  // Only allow safe field updates
275
550
  const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition', 'action', 'requires'];
276
551
  for (const key of allowed) {
277
- if (updates[key] !== undefined) watch[key] = updates[key];
552
+ if (normalized[key] !== undefined) watch[key] = normalized[key];
278
553
  }
279
554
  found = { ...watch };
280
555
  return watches;
@@ -376,23 +651,32 @@ function evaluateWatch(watch, state) {
376
651
  */
377
652
  function _validateRequires(requires) {
378
653
  if (!Array.isArray(requires)) return 'requires must be an array of {target, targetType, condition}';
654
+ if (requires.length > WATCH_LIMITS.maxRequirements) {
655
+ return `requires must contain at most ${WATCH_LIMITS.maxRequirements} entries`;
656
+ }
657
+ const seen = new Set();
379
658
  for (let i = 0; i < requires.length; i++) {
380
659
  const r = requires[i];
381
660
  if (!r || typeof r !== 'object' || Array.isArray(r)) {
382
661
  return `requires[${i}] must be a {target, targetType, condition} object`;
383
662
  }
384
- if (!r.target || typeof r.target !== 'string') {
385
- return `requires[${i}].target is required (non-empty string)`;
386
- }
387
663
  if (!r.targetType || !TARGET_TYPES[r.targetType]) {
388
664
  return `requires[${i}].targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`;
389
665
  }
390
666
  if (!r.condition || !TARGET_TYPES[r.targetType].conditions.includes(r.condition)) {
391
667
  return `requires[${i}].condition must be one of: ${TARGET_TYPES[r.targetType].conditions.join(', ')} (for targetType ${r.targetType})`;
392
668
  }
669
+ try {
670
+ _validateWatchTarget(r.target, r.targetType, `requires[${i}].target`, r.condition);
671
+ } catch (err) {
672
+ return err && err.message ? err.message : `requires[${i}].target is invalid`;
673
+ }
393
674
  if (r.requires !== undefined) {
394
675
  return `requires[${i}] must not contain a nested 'requires' field (depth cap is 1)`;
395
676
  }
677
+ const identity = JSON.stringify([r.targetType, r.target, r.condition]);
678
+ if (seen.has(identity)) return `requires[${i}] duplicates an earlier requirement`;
679
+ seen.add(identity);
396
680
  }
397
681
  return null;
398
682
  }
@@ -720,6 +1004,10 @@ function _captureState(watch, state) {
720
1004
 
721
1005
  // ── Built-in target type registrations ───────────────────────────────────────
722
1006
 
1007
+ function _validateStringTarget(target) {
1008
+ return typeof target === 'string' ? null : 'target must be a non-empty string identifier';
1009
+ }
1010
+
723
1011
  // PR — pull request status / build / comments / votes
724
1012
  registerTargetType(WATCH_TARGET_TYPE.PR, {
725
1013
  label: 'Pull Request',
@@ -742,6 +1030,7 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
742
1030
  WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
743
1031
  WATCH_CONDITION.READY_FOR_MERGE,
744
1032
  ],
1033
+ validateTarget: _validateStringTarget,
745
1034
  // W-mqa63opd000ha836 — zombie-watch janitor: once a PR reaches a terminal
746
1035
  // status (merged / closed / abandoned), most PR conditions can never fire
747
1036
  // again so the watch should auto-expire instead of polling forever.
@@ -894,6 +1183,7 @@ registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
894
1183
  WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
895
1184
  WATCH_CONDITION.RETRY_LIMIT_REACHED,
896
1185
  ],
1186
+ validateTarget: _validateStringTarget,
897
1187
  fetchEntity: (target, state) => (state.workItems || []).find(w => w.id === target) || null,
898
1188
  // P-w2c8d1e7 — Phase 1.2: surface retry count, pending-reason, and branch
899
1189
  // for upcoming stalled / branch-aware predicates. `retries` aliases the
@@ -984,6 +1274,7 @@ registerTargetType(WATCH_TARGET_TYPE.MEETING, {
984
1274
  description: 'Watch a meeting for conclusion or status changes',
985
1275
  conditions: [WATCH_CONDITION.CONCLUDED, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY],
986
1276
  absoluteConditions: [WATCH_CONDITION.CONCLUDED],
1277
+ validateTarget: _validateStringTarget,
987
1278
  fetchEntity: (target, state) => (state.meetings || []).find(m => m && m.id === target) || null,
988
1279
  captureState: (m) => ({ status: m.status }),
989
1280
  evaluate: (condition, m, prevState, target) => {
@@ -1058,6 +1349,7 @@ registerTargetType(WATCH_TARGET_TYPE.PLAN, {
1058
1349
  WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED, WATCH_CONDITION.COMPLETED,
1059
1350
  WATCH_CONDITION.ALL_ITEMS_DONE, WATCH_CONDITION.ITEM_FAILED_N_TIMES,
1060
1351
  ],
1352
+ validateTarget: _validateStringTarget,
1061
1353
  fetchEntity: _findPlan,
1062
1354
  // P-w2c8d1e7 — Phase 1.2: surface item progress derived from the PRD's
1063
1355
  // missing_features array so plan-level predicates (e.g. all-items-done)
@@ -1136,6 +1428,7 @@ registerTargetType(WATCH_TARGET_TYPE.SCHEDULE, {
1136
1428
  // W-mp7hg58e000b5212 — schedule has no absolute conditions today; all of
1137
1429
  // its keywords are change-based (RAN/ENABLED/DISABLED detect transitions).
1138
1430
  absoluteConditions: [],
1431
+ validateTarget: _validateStringTarget,
1139
1432
  fetchEntity: _findSchedule,
1140
1433
  // P-w2c8d1e7 — Phase 1.2: surface next_run as null. Cron parsing in
1141
1434
  // engine/scheduler.js exposes parseCronExpr/.matches() but not a
@@ -1221,6 +1514,7 @@ registerTargetType(WATCH_TARGET_TYPE.PIPELINE, {
1221
1514
  ],
1222
1515
  // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
1223
1516
  absoluteConditions: [WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED],
1517
+ validateTarget: _validateStringTarget,
1224
1518
  fetchEntity: _findPipelineLatestRun,
1225
1519
  // P-w2c8d1e7 — Phase 1.2: surface current_stage_id (first non-terminal
1226
1520
  // stage in declaration order) for stage-targeted predicates.
@@ -1315,6 +1609,7 @@ registerTargetType(WATCH_TARGET_TYPE.DISPATCH, {
1315
1609
  ],
1316
1610
  // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
1317
1611
  absoluteConditions: [WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED],
1612
+ validateTarget: _validateStringTarget,
1318
1613
  fetchEntity: _findDispatchEntry,
1319
1614
  captureState: (e) => ({ status: e.status || e._list || null, list: e._list || null }),
1320
1615
  evaluate: (condition, e, prevState, target) => {
@@ -1359,6 +1654,7 @@ registerTargetType(WATCH_TARGET_TYPE.AGENT, {
1359
1654
  // W-mp7hg58e000b5212 — agent has no absolute conditions today; all of its
1360
1655
  // keywords are change-based.
1361
1656
  absoluteConditions: [],
1657
+ validateTarget: _validateStringTarget,
1362
1658
  fetchEntity: _findAgent,
1363
1659
  // P-w2c8d1e7 — Phase 1.2: surface currentDispatchId so predicates can
1364
1660
  // distinguish "agent now working on a different dispatch" from a plain
@@ -1437,10 +1733,16 @@ _loadPluginTargetTypes();
1437
1733
 
1438
1734
  module.exports = {
1439
1735
  DEFAULT_WATCH_INTERVAL,
1736
+ WATCH_LIMITS,
1737
+ WATCH_NOTIFY_MODES,
1738
+ WATCH_ON_NOT_MET_MODES,
1440
1739
  getWatches,
1441
1740
  createWatch,
1442
1741
  updateWatch,
1443
1742
  deleteWatch,
1743
+ validateWatchCreateInput,
1744
+ validateWatchUpdateInput,
1745
+ validateWatchId,
1444
1746
  evaluateWatch,
1445
1747
  checkWatches,
1446
1748
  registerTargetType,