@yemi33/minions 0.1.1952 → 0.1.1954

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.
package/engine/cli.js CHANGED
@@ -772,6 +772,22 @@ const commands = {
772
772
  }
773
773
  })();
774
774
 
775
+ // W-mp7gox8w000n8936 — Boot reconcile for kb-sweep state: clear stale
776
+ // `in-flight`/`starting` records left over from a crashed runner (or a
777
+ // legacy pre-pid runner). Without this, the record sits there clogging
778
+ // /api/knowledge/sweep/status until someone POSTs a new sweep. Idempotent.
779
+ (function startupReconcileKbSweep() {
780
+ try {
781
+ const { reconcileSweepStateOnBoot } = require('./kb-sweep');
782
+ const stats = reconcileSweepStateOnBoot();
783
+ if (stats.released > 0) {
784
+ console.log(` KB-sweep boot reconcile: released stale ${stats.prevStatus} record (pid=${stats.prevPid}, reason=${stats.reason})`);
785
+ }
786
+ } catch (err) {
787
+ e.log('warn', `KB-sweep boot reconcile failed: ${err.message}`);
788
+ }
789
+ })();
790
+
775
791
  // Initial tick
776
792
  e.tick();
777
793
 
@@ -339,6 +339,91 @@ function readSweepLiveness(opts = {}) {
339
339
  };
340
340
  }
341
341
 
342
+ /**
343
+ * One-shot boot-time reconciliation for `engine/kb-sweep-state.json`.
344
+ *
345
+ * Mirrors the worktree-pool / keep-process boot reconcilers in `engine/cli.js`:
346
+ * after an engine restart we may inherit a stale `in-flight`/`starting` record
347
+ * whose runner has long since died (or — for legacy state files — never
348
+ * recorded a pid). Without proactive cleanup the record sits there clogging
349
+ * `/api/knowledge/sweep/status` until someone POSTs a new sweep (which the
350
+ * dashboard's stale-guard would then auto-release).
351
+ *
352
+ * Behavior:
353
+ * - Absent state file → no-op.
354
+ * - Terminal status (completed/failed) → no-op.
355
+ * - `starting` within 15s boot-grace → no-op (matches readSweepLiveness).
356
+ * - `in-flight` / stale `starting` with a live pid → no-op (running sweep).
357
+ * - Otherwise rewrite to `status: 'failed'` preserving the original pid for
358
+ * forensics. The original record fields are kept; `reconciliationReason`
359
+ * records why we released it.
360
+ *
361
+ * CAS guard: re-reads state immediately before the write and aborts if any of
362
+ * status/startedAt/pid/sweepToken changed since the snapshot — protects against
363
+ * a concurrent dashboard POST or runner that wrote a fresh record between our
364
+ * decision and our write.
365
+ *
366
+ * @param {object} [opts]
367
+ * @param {number} [opts.now=Date.now()] injectable clock (tests)
368
+ * @param {(pid:number)=>boolean} [opts.isPidAlive] injectable (tests)
369
+ * @returns {{ scanned:number, released:number, reason?:string,
370
+ * prevStatus?:string, prevPid?:number }}
371
+ */
372
+ function reconcileSweepStateOnBoot(opts = {}) {
373
+ const now = Number(opts.now) || Date.now();
374
+ const isPidAlive = typeof opts.isPidAlive === 'function'
375
+ ? opts.isPidAlive
376
+ : (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
377
+ const state = safeJson(KB_SWEEP_STATE_PATH);
378
+ if (!state) return { scanned: 0, released: 0 };
379
+ if (state.status !== 'in-flight' && state.status !== 'starting') {
380
+ return { scanned: 1, released: 0, reason: `terminal-status-${state.status}` };
381
+ }
382
+ if (state.status === 'starting') {
383
+ const STARTING_GRACE_MS = 15000;
384
+ const age = state.startedAt ? now - Number(state.startedAt) : Infinity;
385
+ if (age <= STARTING_GRACE_MS) {
386
+ return { scanned: 1, released: 0, reason: 'within-boot-grace' };
387
+ }
388
+ }
389
+ const pid = Number(state.pid) || 0;
390
+ const alive = pid > 0 ? !!isPidAlive(pid) : false;
391
+ if (alive) {
392
+ return { scanned: 1, released: 0, reason: 'pid-still-alive', prevPid: pid };
393
+ }
394
+
395
+ const reason = pid > 0 ? 'pid-dead-on-startup-check' : 'no-pid-recorded-on-startup-check';
396
+
397
+ // CAS guard: re-read right before the write so we don't clobber a fresh
398
+ // `starting`/`in-flight` record that a concurrent dashboard POST or runner
399
+ // wrote between our decision and our write.
400
+ const current = safeJson(KB_SWEEP_STATE_PATH);
401
+ if (!current
402
+ || current.status !== state.status
403
+ || Number(current.startedAt || 0) !== Number(state.startedAt || 0)
404
+ || Number(current.pid || 0) !== pid
405
+ || (current.sweepToken || null) !== (state.sweepToken || null)) {
406
+ return { scanned: 1, released: 0, reason: 'state-changed-before-reconcile' };
407
+ }
408
+
409
+ const failedState = {
410
+ ...state,
411
+ status: 'failed',
412
+ completedAt: now,
413
+ completedAtIso: new Date(now).toISOString(),
414
+ error: `sweep abandoned: ${reason}`,
415
+ reconciliationReason: reason,
416
+ };
417
+ // Direct safeWrite (NOT _writeSweepState) so the original pid is preserved
418
+ // for forensics — _writeSweepState would overwrite it with this process's
419
+ // pid, destroying the diagnostic value of the record.
420
+ try { safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify(failedState)); } catch { /* ignore */ }
421
+ return {
422
+ scanned: 1, released: 1, reason,
423
+ prevStatus: state.status, prevPid: pid,
424
+ };
425
+ }
426
+
342
427
  /**
343
428
  * Run the full sweep. Returns a rich summary.
344
429
  *
@@ -480,6 +565,7 @@ module.exports = {
480
565
  runKbSweep,
481
566
  staleGuardMs,
482
567
  readSweepLiveness,
568
+ reconcileSweepStateOnBoot,
483
569
  KB_SWEEP_STATE_PATH,
484
570
  KB_SWEEP_LOG_PATH,
485
571
  KB_SWEEP_RUNNER_PATH,
package/engine/watches.js CHANGED
@@ -9,18 +9,62 @@
9
9
  *
10
10
  * Target-type behavior is data-driven via a registry — see TARGET_TYPES below.
11
11
  * Each registered target type provides:
12
- * - label: human-friendly name shown in dashboard pickers
13
- * - conditions: array of condition keys this target type accepts
14
- * - fetchEntity: (target, state) => entity-or-null lookup
15
- * - captureState: (entity) => snapshot for change-detection diffing
16
- * - evaluate: (condition, entity, prevState, target) => { triggered, message }
12
+ * - label: human-friendly name shown in dashboard pickers
13
+ * - conditions: array of condition keys this target type accepts
14
+ * - absoluteConditions: (optional) array of condition keys that should
15
+ * fire-once when stopAfter=0 (compound state assertions
16
+ * like "merged" / "completed"). Normalized into a Set
17
+ * on the spec at registration time.
18
+ * - fetchEntity: (target, state) => entity-or-null lookup
19
+ * - captureState: (entity, prevState) => snapshot for change-detection
20
+ * - evaluate: (condition, entity, prevState, target) =>
21
+ * { triggered, message }
22
+ * - contextVars: (optional) (entity, prevState, newState) =>
23
+ * object of extra template vars merged into the
24
+ * action ctx after buildTriggerContext. Used by
25
+ * plugins to surface fields that aren't auto-promoted
26
+ * from newState (e.g. {{prevExtracted}}) or to alias
27
+ * scalars under nicer names.
17
28
  *
18
29
  * Built-in types: pr, work-item, meeting, plan, schedule, pipeline, dispatch,
19
30
  * agent. Additional types can be added at runtime via registerTargetType().
20
31
  *
32
+ * Canonical plugin example: `watches.d/http.js` (W-mp7i22mu00191b07) — a
33
+ * generic HTTP poller that demonstrates the full plugin contract including
34
+ * `contextVars` (exposing `{{httpStatus}}`, `{{extracted}}`, `{{prevExtracted}}`,
35
+ * etc. to action templates) and a background-fetch cache (because the engine
36
+ * tick path is synchronous; plugins that need async I/O maintain their own
37
+ * cache and return cached entities from the synchronous fetchEntity).
38
+ *
39
+ * ── Plugin folder (`watches.d/`) — W-mp7hg58e000b5212 ────────────────────────
40
+ *
41
+ * At engine boot, every `*.js` file in `<MINIONS_DIR>/watches.d/` is loaded
42
+ * and its exports are passed to registerTargetType(). This lets users add
43
+ * new watch target types without editing engine source.
44
+ *
45
+ * Each plugin file exports either:
46
+ *
47
+ * module.exports = {
48
+ * name: 'gh-workflow-run',
49
+ * spec: { label, conditions, absoluteConditions?, fetchEntity, captureState, evaluate, ... },
50
+ * };
51
+ *
52
+ * or an array of those objects for multi-target plugins.
53
+ *
54
+ * Resolution: `path.join(MINIONS_DIR, 'watches.d')` so it works in both dev
55
+ * (D:/squad) and installed (~/.minions) layouts. A missing folder is a silent
56
+ * no-op (not an error). Each file is required inside its own try/catch — one
57
+ * bad plugin must not break boot or block other plugins. Successful loads
58
+ * log at INFO; registration failures log at WARN with the file path.
59
+ *
60
+ * Security: plugins are user-trusted JS (same trust level as `playbooks/` and
61
+ * `pipelines/`); no sandboxing. Hot-reload is not supported — restart the
62
+ * engine to pick up new plugin files (matches how playbooks already work).
63
+ *
21
64
  * State stored in engine/watches.json — concurrency-safe via mutateJsonFileLocked.
22
65
  */
23
66
 
67
+ const fs = require('fs');
24
68
  const path = require('path');
25
69
  const shared = require('./shared');
26
70
  const { safeJsonArr, mutateJsonFileLocked, ts, uid, log, writeToInbox,
@@ -59,12 +103,23 @@ const TARGET_TYPES = {};
59
103
  /**
60
104
  * Register a watch target type.
61
105
  * @param {string} type - Unique key (e.g. 'pr', 'meeting', 'custom-thing')
62
- * @param {object} spec - { label, conditions, fetchEntity, captureState, evaluate }
63
- * - label: string shown in dashboard pickers
64
- * - conditions: array of condition strings the type accepts
65
- * - fetchEntity: (target, state) => entity-or-null
66
- * - captureState: (entity) => snapshot object
67
- * - evaluate: (condition, entity, prevState, target) => { triggered, message }
106
+ * @param {object} spec - { label, conditions, absoluteConditions?, fetchEntity, captureState, evaluate }
107
+ * - label: string shown in dashboard pickers
108
+ * - conditions: array of condition strings the type accepts
109
+ * - absoluteConditions: (optional) array of condition keys that should fire
110
+ * once and auto-expire when stopAfter=0. Normalized
111
+ * into a Set on the spec; defaults to an empty Set.
112
+ * Each entry MUST also appear in `conditions`.
113
+ * - fetchEntity: (target, state) => entity-or-null
114
+ * - captureState: (entity, prevState) => snapshot object
115
+ * - evaluate: (condition, entity, prevState, target) =>
116
+ * { triggered, message }
117
+ * - contextVars: (optional) (entity, prevState, newState) =>
118
+ * object whose keys are merged into the action ctx
119
+ * after buildTriggerContext. Use to expose plugin-
120
+ * specific template vars that aren't auto-promoted
121
+ * scalars from newState (e.g. previousState scalars
122
+ * like {{prevExtracted}}). Errors are logged + swallowed.
68
123
  */
69
124
  function registerTargetType(type, spec) {
70
125
  if (!type || typeof type !== 'string') throw new Error('registerTargetType: type must be a non-empty string');
@@ -75,7 +130,26 @@ function registerTargetType(type, spec) {
75
130
  if (!Array.isArray(spec.conditions) || spec.conditions.length === 0) {
76
131
  throw new Error(`registerTargetType(${type}): spec.conditions must be a non-empty array`);
77
132
  }
78
- TARGET_TYPES[type] = spec;
133
+ // W-mp7hg58e000b5212 — normalize absoluteConditions into a Set for fast
134
+ // .has() lookup at evaluate-time. Accepts an array (declarative spec form)
135
+ // or pre-built Set; defaults to empty when omitted. Entries must be a
136
+ // subset of `conditions` so a typo here doesn't silently no-op.
137
+ let absoluteSet;
138
+ if (spec.absoluteConditions === undefined || spec.absoluteConditions === null) {
139
+ absoluteSet = new Set();
140
+ } else if (spec.absoluteConditions instanceof Set) {
141
+ absoluteSet = new Set(spec.absoluteConditions);
142
+ } else if (Array.isArray(spec.absoluteConditions)) {
143
+ absoluteSet = new Set(spec.absoluteConditions);
144
+ } else {
145
+ throw new Error(`registerTargetType(${type}): spec.absoluteConditions must be an array or Set`);
146
+ }
147
+ for (const c of absoluteSet) {
148
+ if (!spec.conditions.includes(c)) {
149
+ throw new Error(`registerTargetType(${type}): absoluteConditions entry '${c}' is not in conditions[]`);
150
+ }
151
+ }
152
+ TARGET_TYPES[type] = { ...spec, absoluteConditions: absoluteSet };
79
153
  }
80
154
 
81
155
  /** Returns the registered spec for a target type, or null. */
@@ -420,7 +494,14 @@ function checkWatches(config, state) {
420
494
  // Expire when stopAfter > 0 and trigger count reaches the limit.
421
495
  // Absolute conditions (merged, build-pass, etc.) auto-expire on first trigger
422
496
  // when stopAfter=0 — fire-once semantics. Change-based conditions run forever.
423
- const isAbsolute = WATCH_ABSOLUTE_CONDITIONS.has(watch.condition);
497
+ // W-mp7hg58e000b5212 resolves through the per-target-type
498
+ // absoluteConditions Set (declared in each spec); falls back to the
499
+ // legacy WATCH_ABSOLUTE_CONDITIONS union only when the watch's
500
+ // targetType has been unregistered between create and check (defensive).
501
+ const _absSpec = TARGET_TYPES[watch.targetType];
502
+ const isAbsolute = _absSpec
503
+ ? _absSpec.absoluteConditions.has(watch.condition)
504
+ : WATCH_ABSOLUTE_CONDITIONS.has(watch.condition);
424
505
  if ((watch.stopAfter > 0 && watch.triggerCount >= watch.stopAfter) ||
425
506
  (watch.stopAfter === 0 && isAbsolute)) {
426
507
  watch.status = WATCH_STATUS.EXPIRED;
@@ -485,6 +566,18 @@ async function _runActionTask(task) {
485
566
  entity: task.entity,
486
567
  message: task.message,
487
568
  });
569
+ // W-mp7i22mu00191b07 — let the registered target type contribute extra
570
+ // template vars (e.g. http plugin exposes {{httpStatus}}, {{prevExtracted}}).
571
+ // Failures are swallowed so a misbehaving plugin can't poison action dispatch.
572
+ const tt = TARGET_TYPES[task.snapshot.targetType];
573
+ if (tt && typeof tt.contextVars === 'function') {
574
+ try {
575
+ const extras = tt.contextVars(task.entity, task.previousState, task.newState) || {};
576
+ if (extras && typeof extras === 'object') Object.assign(ctx, extras);
577
+ } catch (err) {
578
+ log('warn', `${task.snapshot.targetType} contextVars failed: ${err.message}`);
579
+ }
580
+ }
488
581
  const result = await watchActions.runWatchAction(task.snapshot, ctx);
489
582
  const isChain = Array.isArray(task.snapshot.action);
490
583
  const actionLabel = isChain
@@ -591,6 +684,12 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
591
684
  WATCH_CONDITION.READY_FOR_MERGE, WATCH_CONDITION.BEHIND_MASTER,
592
685
  WATCH_CONDITION.DRAFT_FLIPPED,
593
686
  ],
687
+ // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
688
+ // Mirrors what shared.WATCH_ABSOLUTE_CONDITIONS used to centralize for PR.
689
+ absoluteConditions: [
690
+ WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
691
+ WATCH_CONDITION.READY_FOR_MERGE,
692
+ ],
594
693
  fetchEntity: (target, state) => findPrByTarget(state.pullRequests, target),
595
694
  captureState: (pr) => ({
596
695
  status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus,
@@ -708,6 +807,11 @@ registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
708
807
  WATCH_CONDITION.STALLED, WATCH_CONDITION.RETRY_LIMIT_REACHED,
709
808
  WATCH_CONDITION.DEPENDENCY_MET,
710
809
  ],
810
+ // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
811
+ absoluteConditions: [
812
+ WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
813
+ WATCH_CONDITION.RETRY_LIMIT_REACHED,
814
+ ],
711
815
  fetchEntity: (target, state) => (state.workItems || []).find(w => w.id === target) || null,
712
816
  // P-w2c8d1e7 — Phase 1.2: surface retry count, pending-reason, and branch
713
817
  // for upcoming stalled / branch-aware predicates. `retries` aliases the
@@ -797,6 +901,7 @@ registerTargetType(WATCH_TARGET_TYPE.MEETING, {
797
901
  label: 'Meeting',
798
902
  description: 'Watch a meeting for conclusion or status changes',
799
903
  conditions: [WATCH_CONDITION.CONCLUDED, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY],
904
+ absoluteConditions: [WATCH_CONDITION.CONCLUDED],
800
905
  fetchEntity: (target, state) => (state.meetings || []).find(m => m && m.id === target) || null,
801
906
  captureState: (m) => ({ status: m.status }),
802
907
  evaluate: (condition, m, prevState, target) => {
@@ -866,6 +971,11 @@ registerTargetType(WATCH_TARGET_TYPE.PLAN, {
866
971
  // from the PRD's missing_features array.
867
972
  WATCH_CONDITION.ALL_ITEMS_DONE, WATCH_CONDITION.ITEM_FAILED_N_TIMES,
868
973
  ],
974
+ // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
975
+ absoluteConditions: [
976
+ WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED, WATCH_CONDITION.COMPLETED,
977
+ WATCH_CONDITION.ALL_ITEMS_DONE, WATCH_CONDITION.ITEM_FAILED_N_TIMES,
978
+ ],
869
979
  fetchEntity: _findPlan,
870
980
  // P-w2c8d1e7 — Phase 1.2: surface item progress derived from the PRD's
871
981
  // missing_features array so plan-level predicates (e.g. all-items-done)
@@ -941,6 +1051,9 @@ registerTargetType(WATCH_TARGET_TYPE.SCHEDULE, {
941
1051
  WATCH_CONDITION.RAN, WATCH_CONDITION.ENABLED, WATCH_CONDITION.DISABLED,
942
1052
  WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
943
1053
  ],
1054
+ // W-mp7hg58e000b5212 — schedule has no absolute conditions today; all of
1055
+ // its keywords are change-based (RAN/ENABLED/DISABLED detect transitions).
1056
+ absoluteConditions: [],
944
1057
  fetchEntity: _findSchedule,
945
1058
  // P-w2c8d1e7 — Phase 1.2: surface next_run as null. Cron parsing in
946
1059
  // engine/scheduler.js exposes parseCronExpr/.matches() but not a
@@ -1024,6 +1137,8 @@ registerTargetType(WATCH_TARGET_TYPE.PIPELINE, {
1024
1137
  // counter computed inside captureState.
1025
1138
  WATCH_CONDITION.STAGE_ADVANCED, WATCH_CONDITION.STUCK_IN_STAGE,
1026
1139
  ],
1140
+ // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
1141
+ absoluteConditions: [WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED],
1027
1142
  fetchEntity: _findPipelineLatestRun,
1028
1143
  // P-w2c8d1e7 — Phase 1.2: surface current_stage_id (first non-terminal
1029
1144
  // stage in declaration order) for stage-targeted predicates.
@@ -1116,6 +1231,8 @@ registerTargetType(WATCH_TARGET_TYPE.DISPATCH, {
1116
1231
  WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
1117
1232
  WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
1118
1233
  ],
1234
+ // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
1235
+ absoluteConditions: [WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED],
1119
1236
  fetchEntity: _findDispatchEntry,
1120
1237
  captureState: (e) => ({ status: e.status || e._list || null, list: e._list || null }),
1121
1238
  evaluate: (condition, e, prevState, target) => {
@@ -1157,6 +1274,9 @@ registerTargetType(WATCH_TARGET_TYPE.AGENT, {
1157
1274
  conditions: [
1158
1275
  WATCH_CONDITION.ACTIVITY_CHANGE, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
1159
1276
  ],
1277
+ // W-mp7hg58e000b5212 — agent has no absolute conditions today; all of its
1278
+ // keywords are change-based.
1279
+ absoluteConditions: [],
1160
1280
  fetchEntity: _findAgent,
1161
1281
  // P-w2c8d1e7 — Phase 1.2: surface currentDispatchId so predicates can
1162
1282
  // distinguish "agent now working on a different dispatch" from a plain
@@ -1190,6 +1310,49 @@ registerTargetType(WATCH_TARGET_TYPE.AGENT, {
1190
1310
  },
1191
1311
  });
1192
1312
 
1313
+ // ── Plugin folder discovery (W-mp7hg58e000b5212) ─────────────────────────────
1314
+ // Auto-load `<MINIONS_DIR>/watches.d/*.js` at module load time, AFTER the 8
1315
+ // built-in registerTargetType() calls so plugins can override built-ins by
1316
+ // re-using their key (last-write-wins). Per the README block at the top of
1317
+ // this file: missing folder is silent, per-file try/catch isolates failures,
1318
+ // and INFO/WARN logs trace what loaded.
1319
+ function _loadPluginTargetTypes() {
1320
+ const dir = path.join(shared.MINIONS_DIR, 'watches.d');
1321
+ let entries;
1322
+ try {
1323
+ entries = fs.readdirSync(dir);
1324
+ } catch (err) {
1325
+ if (err && err.code === 'ENOENT') return; // silent no-op
1326
+ log('warn', `watches.d/ readdir failed: ${err.message}`);
1327
+ return;
1328
+ }
1329
+ for (const fname of entries.sort()) {
1330
+ if (!fname.endsWith('.js')) continue;
1331
+ const fp = path.join(dir, fname);
1332
+ try {
1333
+ // Bust cache so re-loads (tests, future hot-reload) pick up file edits.
1334
+ try { delete require.cache[require.resolve(fp)]; } catch {}
1335
+ const mod = require(fp);
1336
+ const list = Array.isArray(mod) ? mod : [mod];
1337
+ for (const entry of list) {
1338
+ if (!entry || !entry.name || !entry.spec) {
1339
+ log('warn', `watches.d/${fname}: export missing { name, spec } — skipping entry`);
1340
+ continue;
1341
+ }
1342
+ try {
1343
+ registerTargetType(entry.name, entry.spec);
1344
+ log('info', `Loaded watch target type '${entry.name}' from watches.d/${fname}`);
1345
+ } catch (regErr) {
1346
+ log('warn', `watches.d/${fname}: registerTargetType('${entry.name}') failed: ${regErr.message}`);
1347
+ }
1348
+ }
1349
+ } catch (err) {
1350
+ log('warn', `watches.d/${fname}: load failed: ${err.message}`);
1351
+ }
1352
+ }
1353
+ }
1354
+ _loadPluginTargetTypes();
1355
+
1193
1356
  module.exports = {
1194
1357
  DEFAULT_WATCH_INTERVAL,
1195
1358
  getWatches,
@@ -1214,4 +1377,5 @@ module.exports = {
1214
1377
  _watchesPath, // exported for testing — dynamic, respects MINIONS_TEST_DIR
1215
1378
  _runActionTask, // exported for testing — invoked by checkWatches per fired action
1216
1379
  _TARGET_TYPES: TARGET_TYPES, // exported for testing — direct registry access
1380
+ _loadPluginTargetTypes, // exported for testing — re-scans watches.d/
1217
1381
  };
package/engine.js CHANGED
@@ -910,6 +910,19 @@ async function spawnAgent(dispatchItem, config) {
910
910
  };
911
911
  _phaseT.afterPrompt = Date.now();
912
912
 
913
+ if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type) && !isPipelineBranchName(branchName)) {
914
+ // W-mp7havqf0007ce6b: read-only types (meeting/ask/explore/plan/plan-to-prd)
915
+ // short-circuit BEFORE the worktree-creation block. resolveSpawnPaths returns
916
+ // worktreeRootDir=null for read-only types, and path.resolve(null, ...) throws
917
+ // ("paths[0] must be of type string. Received null"). Pipeline branches are
918
+ // exempt — they always need a worktree (the worktree IS the pipeline workspace),
919
+ // and the recompute at lines ~806-824 ensures worktreeRootDir is non-null
920
+ // before the worktree-creation block runs for them.
921
+ log('info', `${type}: read-only task with branch ${branchName} — skipping worktree, running in cwd ${cwd}`);
922
+ branchName = null;
923
+ worktreePath = null;
924
+ }
925
+
913
926
  if (branchName) {
914
927
  updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
915
928
  const wtDirName = shared.buildWorktreeDirName({
@@ -949,12 +962,6 @@ async function spawnAgent(dispatchItem, config) {
949
962
  _phaseT.reuseSyncStart = Date.now();
950
963
  await syncReusedWorktree(rootDir, existingWt, branchName, _gitOpts);
951
964
  _phaseT.reuseSyncEnd = Date.now();
952
- } else if (READ_ONLY_ROOT_TASK_TYPES.has(type) && !isPipelineBranchName(branchName)) {
953
- // Read-only tasks — no worktree needed, run in cwd from resolveSpawnPaths
954
- // (project.localPath or MINIONS_DIR). W-mp73x32w000l143d.
955
- log('info', `${type}: read-only task, no worktree needed — running in cwd ${cwd}`);
956
- branchName = null;
957
- worktreePath = null;
958
965
  } else {
959
966
  _phaseT.createWorktreeStart = Date.now();
960
967
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1952",
3
+ "version": "0.1.1954",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"