@yemi33/minions 0.1.1961 → 0.1.1963

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/docs/README.md CHANGED
@@ -14,12 +14,18 @@ Hands-on stories and distribution guides for people running or evaluating Minion
14
14
  Architecture, design proposals, and lifecycle references for people working on the engine, dashboard, or playbooks.
15
15
 
16
16
  - [command-center.md](command-center.md) — Command Center (CC) chat panel: persistent Sonnet sessions, `--resume` semantics, system-prompt invalidation, and per-tab session storage.
17
+ - [completion-reports.md](completion-reports.md) — Canonical schema for the per-dispatch completion JSON every agent writes (status, verdict, retryability, nonce trust boundary, artifacts).
17
18
  - [copilot-cli-schema.md](copilot-cli-schema.md) — Behavior and schema reference for the GitHub Copilot CLI adapter (capability flags, stdin vs `-p`, model discovery, effort levels).
18
19
  - [design-state-storage.md](design-state-storage.md) — Design proposal evaluating five database options for replacing Minions' file-based JSON state; recommends `node:sqlite` as the medium-term target.
20
+ - [kb-sweep.md](kb-sweep.md) — Knowledge-base consolidation sweep (hash dedup → LLM batch dedup/reclassify → per-entry compress) and the detached runner that keeps it alive across `minions restart`.
19
21
  - [plan-lifecycle.md](plan-lifecycle.md) — Full plan pipeline from `/plan` through PRD materialization, dispatch with dependency gating, verify task, and human archive.
20
22
  - [pr-review-fix-loop.md](pr-review-fix-loop.md) — How the engine moves a PR from creation through review, fix dispatch, and re-review, including stale-status guards.
21
23
  - [rfc-completion-json.md](rfc-completion-json.md) — RFC for replacing stdout regex-scraping with a structured `completion.json` control-plane protocol.
24
+ - [runtime-adapters.md](runtime-adapters.md) — Adapter contract (`engine/runtimes/<name>.js`), capability flags, runtime/model resolution helpers, and the "no `runtime.name === …` branching" rule.
22
25
  - [self-improvement.md](self-improvement.md) — The six self-improvement mechanisms (learnings inbox, per-agent history, review feedback, quality metrics, etc.) that form Minions' continuous feedback loop.
26
+ - [skills.md](skills.md) — `.claude/skills/<name>/SKILL.md` format, `scope: minions` vs `scope: project`, auto-extraction from agent output, and per-runtime skill roots.
27
+ - [team-memory.md](team-memory.md) — How `pinned.md`, `notes.md`, `knowledge/`, and per-agent memory files in `knowledge/agents/` flow into every agent prompt.
28
+ - [watches.md](watches.md) — Persistent watch jobs: registry-driven target types, condition catalog, follow-up action registry, and the user-extensible `watches.d/` plugin folder.
23
29
 
24
30
  ## Operations
25
31
 
@@ -28,6 +34,7 @@ Operational runbooks for engine operators and fleet maintainers.
28
34
  - [auto-discovery.md](auto-discovery.md) — Auto-discovery and execution pipeline: the per-tick orchestration loop and the four work-discovery sources.
29
35
  - [engine-restart.md](engine-restart.md) — How agents survive an engine restart: state persistence, the 20-minute startup grace period, and orphan reattachment via PID files and `live-output.log`.
30
36
  - [human-vs-automated.md](human-vs-automated.md) — Quick reference table of which features humans start, run, decide, and recover, and the two human approval gates.
37
+ - [onboarding.md](onboarding.md) — First-run setup: installing the CLI, linking a project, the doctor preflight, and the first dispatch.
31
38
 
32
39
  ---
33
40
 
@@ -1,6 +1,6 @@
1
1
  # Auto-Discovery & Execution Pipeline
2
2
 
3
- > Last verified: 2026-05-12 against `engine.js` `tickInner()` (lines 4439-4750).
3
+ > Last verified: 2026-05-16 against `engine.js` `tickInner()` (lines 5381-5717).
4
4
 
5
5
  How the minions engine finds work and dispatches agents automatically.
6
6
 
@@ -16,14 +16,16 @@ tick()
16
16
  1c. meetingTimeouts() Advance round-based meetings whose timer fired
17
17
  2. consolidateInbox() Merge learnings into notes.md (Haiku-powered)
18
18
  2.5 runCleanup() Periodic cleanup (every 10 ticks ≈ 10min)
19
+ 2.52 sweepKeepProcesses() keep_processes TTL/dead-PID sweep (every 30 ticks)
19
20
  2.55 checkWatches() Persistent watch jobs (every 3 tick-equivalents)
20
21
  2.6 pollPrStatus() Poll ADO + GitHub for build, review, merge status (wall-clock cadence from prPollStatusEvery × tickInterval, default ≈ 12min)
21
22
  processPendingRebases() Run any rebase work queued from the previous tick
22
23
  syncPrdFromPrs() Backfill PRD item status from active PRs
23
24
  checkPlanCompletion() Mark plans completed when all features done/in-pr
24
- 2.7 pollPrHumanComments() Poll PR threads for human @minions comments (wall-clock cadence from prPollCommentsEvery × tickInterval, default ≈ 12min)
25
+ 2.7 pollPrHumanComments() Poll PR threads for human comments (wall-clock cadence from prPollCommentsEvery × tickInterval, default ≈ 12min)
25
26
  reconcilePrs() (ADO+GH) Reconciliation sweep (runs regardless of poll flags)
26
27
  2.9 stallRecovery() Auto-retry failed items blocking pending deps (every 20 ticks)
28
+ 3a. pruneStalePrDispatches() Clear pending PR dispatches whose underlying PRs no longer warrant action
27
29
  3. discoverWork() Scan ALL linked projects for new tasks
28
30
  4. updateSnapshot() Write identity/now.md
29
31
  5. dispatch Spawn agents for pending items (up to maxConcurrent)
@@ -195,6 +197,7 @@ routing.md table (see the file for the authoritative list):
195
197
  verify → dallas (fallback: ralph)
196
198
  decompose → ripley (fallback: rebecca)
197
199
  meeting → ripley (fallback: lambert)
200
+ docs → lambert (fallback: _any_)
198
201
  ```
199
202
 
200
203
  Resolution order:
@@ -1,6 +1,6 @@
1
1
  # Engine Restart & Agent Survival
2
2
 
3
- > Last verified: 2026-05-12 against `engine.js` (`engineRestartGraceUntil`, line 161) and `engine/shared.js` `ENGINE_DEFAULTS` (`restartGracePeriod: 1200000`, `heartbeatTimeout: 300000`, `agentTimeout: 18000000`).
3
+ > Last verified: 2026-05-16 against `engine.js` (`engineRestartGraceUntil`, line 168) and `engine/shared.js` `ENGINE_DEFAULTS` (`restartGracePeriod: 1200000`, `heartbeatTimeout: 300000`, `agentTimeout: 18000000`).
4
4
 
5
5
  ## The Problem
6
6
 
package/docs/watches.md CHANGED
@@ -19,40 +19,43 @@ A watch is a small JSON record persisted to `engine/watches.json`. It binds:
19
19
  | `action` | Optional follow-up action (see "Follow-Up Actions" below) |
20
20
  | `status` | `WATCH_STATUS.ACTIVE` \| `PAUSED` \| `TRIGGERED` \| `EXPIRED` |
21
21
 
22
- `createWatch()` allocates a `watch-<uid>` id, defaults the fields above, and persists atomically via `mutateJsonFileLocked` *(source: `engine/watches.js:103-145`)*.
22
+ `createWatch()` allocates a `watch-<uid>` id, defaults the fields above, and persists atomically via `mutateJsonFileLocked` *(source: `engine/watches.js:184`)*.
23
23
 
24
24
  ## Lifecycle (`WATCH_STATUS`)
25
25
 
26
- Defined in `engine/shared.js:1557`:
26
+ Defined in `engine/shared.js:1875`:
27
27
 
28
28
  | Status | Meaning |
29
29
  |-------------|-------------------------------------------------------------------------|
30
30
  | `active` | Eligible for evaluation each tick |
31
31
  | `paused` | Skipped by `checkWatches`; persists indefinitely until resumed/deleted |
32
32
  | `triggered` | Reserved status (set on demand by callers; not auto-applied) |
33
- | `expired` | Auto-set when `stopAfter` is reached, or on first trigger for absolute conditions when `stopAfter === 0`. The watch is left on disk for audit but no longer evaluated *(source: `engine/watches.js:305-310`)* |
33
+ | `expired` | Auto-set when `stopAfter` is reached, or on first trigger for absolute conditions when `stopAfter === 0`. The watch is left on disk for audit but no longer evaluated *(source: `engine/watches.js:507-508`)* |
34
34
 
35
- Pause/resume flips the `status` field via `POST /api/watches/update` *(source: `engine/watches.js:153-178`, `dashboard.js:6400-6412`)*.
35
+ Pause/resume flips the `status` field via `POST /api/watches/update` *(source: `engine/watches.js:153-178`, `dashboard.js` `handleWatchesUpdate`)*.
36
36
 
37
37
  ## Conditions (`WATCH_CONDITION`)
38
38
 
39
- Defined in `engine/shared.js:1573-1592`. Conditions split into two families:
39
+ Defined in `engine/shared.js:1891-1929`. Conditions split into two families:
40
40
 
41
41
  ### Absolute conditions (`WATCH_ABSOLUTE_CONDITIONS`)
42
- *(source: `engine/shared.js:1595-1599`)*
42
+ *(source: `engine/shared.js:1938-1954`)*
43
43
 
44
- `merged`, `build-fail`, `build-pass`, `completed`, `failed`, `concluded`, `approved`, `rejected`.
44
+ `merged`, `build-fail`, `build-pass`, `completed`, `failed`, `concluded`, `approved`, `rejected`, `ready-for-merge`, `retry-limit-reached`, `all-items-done`, `item-failed-n-times`.
45
45
 
46
- When `stopAfter === 0`, these are **fire-once** — the engine flips the watch to `expired` after the first trigger so a permanently-merged PR doesn't keep notifying *(source: `engine/watches.js:305-310`)*.
46
+ When `stopAfter === 0`, these are **fire-once** — the engine flips the watch to `expired` after the first trigger so a permanently-merged PR (or a permanently-true compound state assertion like `ready-for-merge`) doesn't keep notifying *(source: `engine/watches.js:507-508`)*.
47
47
 
48
48
  ### Change-based conditions
49
- `status-change`, `any`, `new-comments`, `vote-change`, `stage-complete`, `ran`, `enabled`, `disabled`, `activity-change`.
49
+ `status-change`, `any`, `new-comments`, `vote-change`, `stage-complete`, `ran`, `enabled`, `disabled`, `activity-change`, `head-commit-change`, `mergeable-flipped`, `behind-master`, `draft-flipped`, `dependency-met`, `stage-advanced`.
50
50
 
51
- These compare the live entity against the watch's `_lastState` snapshot and run forever when `stopAfter === 0`. Baseline `_lastState` is captured on the first check so the very next change triggers the watch *(source: `engine/watches.js:262-266`)*.
51
+ ### Tick-counted conditions
52
+ `stalled`, `stuck-in-stage` — require N consecutive unchanged captures (default `WATCH_STALLED_DEFAULT_TICKS = 12`, `WATCH_STUCK_STAGE_DEFAULT_TICKS = 12`, both in `engine/shared.js:1934-1935`). Counters (`_unchangedTicks`, `_stuckStageTicks`) are recomputed inside `_captureState` by comparing the fresh snapshot against `prevState`.
53
+
54
+ Change-based and tick-counted conditions compare the live entity against the watch's `_lastState` snapshot and run forever when `stopAfter === 0`. Baseline `_lastState` is captured on the first check so the very next change triggers the watch *(source: `engine/watches.js:434, 520`)*.
52
55
 
53
56
  ## Target Types — `TARGET_TYPES` Registry
54
57
 
55
- Target-type behavior in `engine/watches.js` is **data-driven via a registry** *(source: `engine/watches.js:50-72`)*. Each spec must provide:
58
+ Target-type behavior in `engine/watches.js` is **data-driven via a registry** *(source: `engine/watches.js:101, 124-160`)*. Each spec must provide:
56
59
 
57
60
  - `label` — human name shown in dashboard pickers
58
61
  - `description` — short help text
@@ -61,28 +64,34 @@ Target-type behavior in `engine/watches.js` is **data-driven via a registry** *(
61
64
  - `captureState(entity)` — snapshot used for change-detection diffs
62
65
  - `evaluate(condition, entity, prevState, target)` — returns `{ triggered, message }`
63
66
 
64
- The registry IS the allowlist for `createWatch` and `/api/watches/target-types`; the old hardcoded "pr or work-item" check is gone. Add a new target type at runtime with `registerTargetType(type, spec)` and look one up with `getTargetType(type)`. `listTargetTypes()` returns the serializable form used by the dashboard *(source: `engine/watches.js:62-88`)*.
67
+ The registry IS the allowlist for `createWatch` and `/api/watches/target-types`; the old hardcoded "pr or work-item" check is gone. Add a new target type at runtime with `registerTargetType(type, spec)` and look one up with `getTargetType(type)`. `listTargetTypes()` returns the serializable form used by the dashboard *(source: `engine/watches.js:124-184`)*.
68
+
69
+ ### User-extensible via `watches.d/` (W-mp7hg58e000b5212)
70
+
71
+ At engine boot, every `*.js` file in `<MINIONS_DIR>/watches.d/` is auto-loaded **after** the built-in registrations *(source: `engine/watches.js:1314-1340`)*, so plugins can both add new target types and override built-ins. A plugin file exports either `{ name, spec }` or an array of such objects. Failures are logged-and-skipped — one bad plugin must not break boot or block other plugins. Reloads require an engine restart.
72
+
73
+ Canonical example: `watches.d/http.js` (W-mp7i22mu00191b07) — a generic HTTP poller covering the full plugin contract including `extractState` (custom snapshot fields not on the entity itself) and `extendTemplateVars` (custom action-template vars like `{{httpStatus}}`, `{{prevExtracted}}`).
65
74
 
66
75
  ### Built-in target types
67
76
 
68
- The eight built-ins are registered at module load *(source: `engine/watches.js:399-748`)*. Constants live at `engine/shared.js:1563-1572` (`WATCH_TARGET_TYPE`).
77
+ The eight built-ins are registered at module load *(source: `engine/watches.js:672-1271`)*. Constants live at `engine/shared.js:1881-1890` (`WATCH_TARGET_TYPE`).
69
78
 
70
79
  | `targetType` | Target value | Conditions | Notes |
71
80
  |---------------|--------------------------------------|----------------------------------------------------------------------------|-------|
72
- | `pr` | PR number, canonical id, or display id | `merged`, `build-fail`, `build-pass`, `status-change`, `any`, `new-comments`, `vote-change` | Reads from `pull-requests.json` for any project; `new-comments` watches `humanFeedback.lastProcessedCommentDate` |
73
- | `work-item` | Work item id | `completed`, `failed`, `status-change`, `any` | `completed` matches `DONE_STATUSES`; `failed` matches `WI_STATUS.FAILED` |
81
+ | `pr` | PR number, canonical id, or display id | `merged`, `build-fail`, `build-pass`, `status-change`, `any`, `new-comments`, `vote-change`, `head-commit-change`, `mergeable-flipped`, `ready-for-merge`, `behind-master`, `draft-flipped` | Reads from `pull-requests.json` for any project; `new-comments` watches `humanFeedback.lastProcessedCommentDate`; `behind-master` requires `engine.watchesIncludeBehindBy: true` so the GH poller populates `pr.behindBy` |
82
+ | `work-item` | Work item id | `completed`, `failed`, `status-change`, `any`, `stalled`, `retry-limit-reached`, `dependency-met` | `completed` matches `DONE_STATUSES`; `failed` matches `WI_STATUS.FAILED`; `stalled` fires after N unchanged captures (default 12 ≈ 60 min); `retry-limit-reached` fires when `_retryCount >= ENGINE_DEFAULTS.maxRetries` |
74
83
  | `meeting` | Meeting id | `concluded`, `status-change`, `any` | `concluded` fires on terminal status (`completed`, `archived`) |
75
- | `plan` | PRD JSON filename or plan id | `approved`, `rejected`, `completed`, `status-change`, `any` | Looked up by `_source` (PRD file), `_sourcePlan` (.md), or `id`; uses `PLAN_STATUS` |
84
+ | `plan` | PRD JSON filename or plan id | `approved`, `rejected`, `completed`, `status-change`, `any`, `all-items-done`, `item-failed-n-times` | Looked up by `_source` (PRD file), `_sourcePlan` (.md), or `id`; uses `PLAN_STATUS` |
76
85
  | `schedule` | Schedule id | `ran`, `enabled`, `disabled`, `status-change`, `any` | `ran` fires when `lastRun` advances; `enabled`/`disabled` fire on the flip |
77
- | `pipeline` | Pipeline id (latest run is tracked) | `completed`, `failed`, `stage-complete`, `status-change`, `any` | `failed` covers `PIPELINE_STATUS.FAILED` and `STOPPED`; `stage-complete` only counts within the same `runId` |
86
+ | `pipeline` | Pipeline id (latest run is tracked) | `completed`, `failed`, `stage-complete`, `status-change`, `any`, `stage-advanced`, `stuck-in-stage` | `failed` covers `PIPELINE_STATUS.FAILED` and `STOPPED`; `stage-complete`/`stage-advanced` only count within the same `runId`; `stuck-in-stage` fires after N unchanged captures (default 12) |
78
87
  | `dispatch` | Dispatch entry id | `completed`, `failed`, `status-change`, `any` | Looks across `pending` / `active` / `completed` lists |
79
88
  | `agent` | Agent id | `activity-change`, `status-change`, `any` | `activity-change` fires only on transitions in/out of `'working'` |
80
89
 
81
- `evaluateWatch` dispatches to `tt.evaluate(...)`; unknown target types return `"Unknown target type: ..."` and unknown conditions return `"Unknown condition: ..."` — both are non-triggering *(source: `engine/watches.js:208-224`)*.
90
+ `evaluateWatch` dispatches to `tt.evaluate(...)`; unknown target types return `"Unknown target type: ..."` and unknown conditions return `"Unknown condition: ..."` — both are non-triggering *(source: `engine/watches.js:318+`)*.
82
91
 
83
92
  ## Tick Integration
84
93
 
85
- `engine.js` calls `checkWatches(config, state)` every 3 ticks (~3 min at the default 60s tick) inside its own `safe('checkWatches', ...)` block *(source: `engine.js:4480-4538`)*. The engine builds the state object from cached project files + module reads:
94
+ `engine.js` calls `checkWatches(config, state)` every 3 ticks (~3 min at the default 60s tick) inside its own `safe('checkWatches', ...)` block *(source: `engine.js:5449-5507`)*. The engine builds the state object from cached project files + module reads:
86
95
 
87
96
  ```
88
97
  {
@@ -93,7 +102,7 @@ The eight built-ins are registered at module load *(source: `engine/watches.js:3
93
102
  }
94
103
  ```
95
104
 
96
- `checkWatches` walks every active watch and, inside a single `mutateJsonFileLocked` callback *(source: `engine/watches.js:241-345`)*:
105
+ `checkWatches` walks every active watch and, inside a single `mutateJsonFileLocked` callback *(source: `engine/watches.js:410+`)*:
97
106
 
98
107
  1. Skips paused/expired watches and any watch checked within its `interval`.
99
108
  2. Captures a baseline `_lastState` on first check (so change conditions have something to diff).
@@ -103,11 +112,11 @@ The eight built-ins are registered at module load *(source: `engine/watches.js:3
103
112
  6. On non-trigger: writes a per-poll inbox note when `onNotMet === 'notify'`.
104
113
  7. Refreshes `_lastState` for the next check.
105
114
 
106
- I/O happens **outside the lock**: notifications via `writeToInbox`, follow-up actions via `_runActionTask` (`Promise` per action, failures isolated). Each action's result is persisted back onto the watch as `_lastActionResult` in a follow-up locked write *(source: `engine/watches.js:330-377`)*.
115
+ I/O happens **outside the lock**: notifications via `writeToInbox`, follow-up actions via `_runActionTask` (`Promise` per action, failures isolated). Each action's result is persisted back onto the watch as `_lastActionResult` in a follow-up locked write *(source: `engine/watches.js:410+`)*.
107
116
 
108
117
  ## Follow-Up Actions on Trigger
109
118
 
110
- `watch.action` is an optional structured action that runs after the inbox notification fires. Action types live in a sibling registry in `engine/watch-actions.js` and are validated at create/update time *(source: `engine/watches.js:112-115`, `engine/watch-actions.js:50-73`)*. `GET /api/watches/action-types` returns the live list for dashboard pickers.
119
+ `watch.action` is an optional structured action that runs after the inbox notification fires. Action types live in a sibling registry in `engine/watch-actions.js` and are validated at create/update time *(source: `engine/watches.js:112-115`, `engine/watch-actions.js:223-330`)*. `GET /api/watches/action-types` returns the live list for dashboard pickers.
111
120
 
112
121
  ### Built-in actions
113
122
 
@@ -117,16 +126,17 @@ I/O happens **outside the lock**: notifications via `writeToInbox`, follow-up ac
117
126
  | `dispatch-work-item` | Append a new WI to the project (or central) `work-items.json` with `createdBy: "watch:<id>"` |
118
127
  | `run-skill` | Wrapper around `dispatch-work-item` that asks the agent to run a specific `.claude` skill |
119
128
  | `webhook` | `http`/`https` request to an arbitrary URL (10s safety timeout, JSON or string body) |
129
+ | `minions-api` | Loopback call to the in-process dashboard at `http://127.0.0.1:${MINIONS_PORT \|\| 7331}` — `endpoint` must start with `/api/`; sets `X-Minions-Internal: 1`; returns `{ok, status, summary, response}` for chain-step templating |
120
130
  | `cancel-work-item` | Flip a WI to `WI_STATUS.CANCELLED` across all known work-items files |
121
131
  | `trigger-pipeline` | Start a new pipeline run (skipped if the pipeline already has an active run) |
122
132
  | `archive-plan` | Set PRD `status="archived"` + `archivedAt` |
123
133
  | `resume-plan` | Set PRD `status=PLAN_STATUS.ACTIVE` and clear `planStale` |
124
134
 
125
- Constants live in `WATCH_ACTION_TYPE` (`engine/shared.js:1605-1616`); handlers in `engine/watch-actions.js:185-464`.
135
+ Constants live in `WATCH_ACTION_TYPE` (`engine/shared.js:1960+`); handlers in `engine/watch-actions.js:332-700`.
126
136
 
127
137
  ### Templating
128
138
 
129
- Action params support `{{var}}` substitution from the trigger context *(source: `engine/watch-actions.js:83-99`)*. Built-in vars from `buildTriggerContext` *(source: `engine/watch-actions.js:107-154`)*:
139
+ Action params support `{{var}}` substitution from the trigger context *(source: `engine/watch-actions.js:145-220`)*. Built-in vars from `buildTriggerContext`:
130
140
 
131
141
  - Always: `target`, `targetType`, `condition`, `watchId`, `triggerCount`, `message`
132
142
  - All scalar fields from `newState` (e.g. `status`, `buildStatus`, `reviewStatus`, `lastRun`)
@@ -136,7 +146,7 @@ Unknown vars are left as `{{var}}` so callers can detect them downstream.
136
146
 
137
147
  ### Failure isolation
138
148
 
139
- Action handlers must return `{ ok: false, summary }` rather than throw — `runWatchAction` wraps them in try/catch as a backstop *(source: `engine/watch-actions.js:160-178`)*. A bad action never blocks other watches in the same tick, and the failed result is persisted onto the watch as `_lastActionResult` for debugging.
149
+ Action handlers must return `{ ok: false, summary }` rather than throw — `runWatchAction` wraps them in try/catch as a backstop *(source: `engine/watch-actions.js:223-270`)*. A bad action never blocks other watches in the same tick, and the failed result is persisted onto the watch as `_lastActionResult` for debugging. Multi-step action chains run via `runWatchActionChain` *(source: `engine/watch-actions.js:271+`)* — each step's `{ok, summary, result}` is templated into the next step's params as `{{steps.N.result.<field>}}`.
140
150
 
141
151
  ## Dashboard Surface
142
152
 
@@ -150,11 +160,11 @@ Action handlers must return `{ ok: false, summary }` rather than throw — `runW
150
160
  | `/api/watches/update` | POST | `handleWatchesUpdate` | Pause/resume/modify (body: `id, status?, interval?, description?, notify?, stopAfter?, onNotMet?, condition?, action?`) |
151
161
  | `/api/watches/delete` | POST | `handleWatchesDelete` | Delete (body: `id`) |
152
162
 
153
- *(source: route table in `dashboard.js:7395-7400`; handlers in `dashboard.js:6370-6422`)*
163
+ *(source: route table in `dashboard.js`; handlers `handleWatchesList` / `handleWatchesTargetTypes` / `handleWatchesActionTypes` / `handleWatchesCreate` / `handleWatchesUpdate` / `handleWatchesDelete`)*
154
164
 
155
- The watches page is rendered by `dashboard/js/render-watches.js`; the **+ New** button calls `openCreateWatchModal()` which fetches `/api/watches/target-types` and `/api/watches/action-types` to populate the picker dynamically *(source: `dashboard/pages/watches.html:3`, `dashboard/js/render-watches.js:354-414`)*.
165
+ The watches page is rendered by `dashboard/js/render-watches.js`; the **+ New** button calls `openCreateWatchModal()` which fetches `/api/watches/target-types` and `/api/watches/action-types` to populate the picker dynamically *(source: `dashboard/pages/watches.html`, `dashboard/js/render-watches.js`)*.
156
166
 
157
- The CC state preamble injects a `Watches: <count>` line via `getWatches()` so the chat brain knows how many watches exist *(source: `dashboard.js:1342`)*.
167
+ The CC state preamble injects a `Watches: <count>` line via `getWatches()` so the chat brain knows how many watches exist *(source: `dashboard.js` `buildCCStatePreamble`)*.
158
168
 
159
169
  ## Command Center Integration
160
170
 
@@ -182,20 +192,20 @@ Absolute conditions firing under `stopAfter === 0` flip `status` to `expired`; `
182
192
  | Watch never fires | Check `status === 'active'`; check `last_checked` advancing each cycle; confirm engine tick is running and `interval` isn't longer than your test window |
183
193
  | `evaluateWatch` returns `"<label> <target> not found"` | `fetchEntity` got nothing back — wrong `target` (e.g. PR display id vs canonical id), the target type isn't loaded, or the underlying file (PR cache, plan PRD) doesn't exist |
184
194
  | `"Unknown target type"` / `"Unknown condition"` | The registry doesn't recognise the value. Check `GET /api/watches/target-types` to see what's registered server-side; condition must be in that target type's `conditions[]` |
185
- | Change condition fires immediately on first tick | Won't happen — baseline `_lastState` is captured on the first check before `evaluate` runs *(source: `engine/watches.js:262-266`)*. If you see this, suspect manual edits to `watches.json` |
195
+ | Change condition fires immediately on first tick | Won't happen — baseline `_lastState` is captured on the first check before `evaluate` runs *(source: `engine/watches.js:434, 520`)*. If you see this, suspect manual edits to `watches.json` |
186
196
  | Absolute watch fires forever instead of once | `stopAfter` is set to a non-zero value; only `stopAfter === 0` triggers fire-once expiration |
187
197
  | Action runs but inbox notification doesn't | `notify` field isn't `'inbox'`, or `owner` is empty. `notify` and `action` are independent — both can fire, or only one |
188
198
  | `_lastActionResult.ok === false` with `"unknown action type"` | The `action.type` isn't registered. List with `listActionTypes()` / `GET /api/watches/action-types` |
189
- | Webhook action returns `"only http/https allowed"` | URLs must use `http://` or `https://` schemes; other protocols are rejected by design *(source: `engine/watch-actions.js:297-299`)* |
199
+ | Webhook action returns `"only http/https allowed"` | URLs must use `http://` or `https://` schemes; other protocols are rejected by design *(source: `engine/watch-actions.js` `WEBHOOK` handler)* |
190
200
  | Trigger fires but follow-up `dispatch-work-item` is missing | Check the engine log for `Watch <id> action <type>: <summary>`. Common reasons: missing `title`, the project's `work-items.json` couldn't be written, or the WI landed in central `work-items.json` because no project was specified |
191
- | Watch `_lastActionResult` shows `"timeout"` for webhook | Webhooks have a 10s safety timeout to keep the watches tick fast *(source: `engine/watch-actions.js:334-337`)* |
192
- | `checkWatches` block crashes silently | Wrapped in `safe('checkWatches', ...)` so one failure doesn't abort the tick *(source: `engine.js:4484`)*. Inspect `engine/log.json` for `Watch check error (<id>)` lines. Regression #1088: the block must use `getProjects(config)`, never the long-removed `PROJECTS` constant |
201
+ | Watch `_lastActionResult` shows `"timeout"` for webhook | Webhooks have a 10s safety timeout to keep the watches tick fast *(source: `engine/watch-actions.js:482`)* |
202
+ | `checkWatches` block crashes silently | Wrapped in `safe('checkWatches', ...)` so one failure doesn't abort the tick *(source: `engine.js:5453`)*. Inspect `engine/log.json` for `Watch check error (<id>)` lines. Regression #1088: the block must use `getProjects(config)`, never the long-removed `PROJECTS` constant |
193
203
 
194
204
  ## See Also
195
205
 
196
- - `engine/shared.js:1557-1616` — `WATCH_STATUS`, `WATCH_TARGET_TYPE`, `WATCH_CONDITION`, `WATCH_ABSOLUTE_CONDITIONS`, `WATCH_ACTION_TYPE` constants
197
- - `engine/watches.js` — registry, lifecycle, tick integration
198
- - `engine/watch-actions.js` — action registry and built-in handlers
206
+ - `engine/shared.js:1875-1960` — `WATCH_STATUS`, `WATCH_TARGET_TYPE`, `WATCH_CONDITION`, `WATCH_ABSOLUTE_CONDITIONS`, `WATCH_ACTION_TYPE` constants
207
+ - `engine/watches.js` — registry, lifecycle, tick integration, `watches.d/` plugin loader
208
+ - `engine/watch-actions.js` — action registry and built-in handlers (including `minions-api`)
199
209
  - `dashboard/pages/watches.html`, `dashboard/js/render-watches.js` — dashboard UI
200
- - `test/unit/watches-module.test.js`, `test/unit/watch-actions.test.js` — module-level tests
210
+ - `test/unit/watches-module.test.js`, `test/unit/watch-actions.test.js`, `test/unit/watches-plugin-loader.test.js` — module-level tests
201
211
  - [`auto-discovery.md`](auto-discovery.md) — overall tick cycle context
@@ -8,7 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
- const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
11
+ const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, 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 } = shared;
12
12
  const routing = require('./routing');
13
13
  const http = require('http');
14
14
  const { parseCronExpr, shouldRunNow } = require('./scheduler');
@@ -388,13 +388,20 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
388
388
  const projectSlug = _pipelineProjectSlug(project);
389
389
  const id = `PL-${run.runId.slice(4, 12)}-${stage.id}-${i}${projectResolution.projects.length > 1 || project ? '-' + projectSlug : ''}`;
390
390
  const wiPath = _pipelineWorkItemsPath(project);
391
+ const wiType = routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE);
392
+ // W-mp8ho6w500034a58: read-only stages don't commit, so a pipeline
393
+ // branch label is meaningless to them — omit it entirely so the
394
+ // dispatcher's read-only fast-path runs without ceremony.
395
+ const wiBranch = READ_ONLY_ROOT_TASK_TYPES.has(wiType)
396
+ ? null
397
+ : `pipeline/${run.pipelineId}/${stage.id}`;
391
398
  mutateWorkItems(wiPath, workItems => {
392
399
  if (workItems.some(w => w.id === id)) { createdIds.push(id); return workItems; }
393
400
  workItems.push({
394
401
  id,
395
402
  title: item.title || stage.title,
396
403
  description: item.description || stage.description || '',
397
- type: routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE),
404
+ type: wiType,
398
405
  priority: item.priority || stage.priority || 'medium',
399
406
  // Agent is a soft routing hint unless agentLock/hardAgent is set.
400
407
  ...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
@@ -403,7 +410,7 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
403
410
  status: WI_STATUS.PENDING,
404
411
  created: ts(),
405
412
  createdBy: 'pipeline:' + run.pipelineId,
406
- branch: `pipeline/${run.pipelineId}/${stage.id}`,
413
+ ...(wiBranch ? { branch: wiBranch } : {}),
407
414
  _pipelineRun: run.runId,
408
415
  _pipelineStage: stage.id,
409
416
  });
@@ -430,11 +437,18 @@ function executeTaskStageLegacy(stage, stageState, run, config) {
430
437
  const item = items[i % items.length];
431
438
  const id = `PL-${run.runId.slice(4, 12)}-${stage.id}-${i}`;
432
439
  if (workItems.some(w => w.id === id)) { createdIds.push(id); continue; }
440
+ const wiType = routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE);
441
+ // W-mp8ho6w500034a58: read-only stages don't commit, so the branch
442
+ // label is meaningless — omit it so dispatch takes the read-only path
443
+ // without recomputing a worktree placement that will never be used.
444
+ const wiBranch = READ_ONLY_ROOT_TASK_TYPES.has(wiType)
445
+ ? null
446
+ : `pipeline/${run.pipelineId}/${stage.id}`;
433
447
  workItems.push({
434
448
  id,
435
449
  title: item.title || stage.title,
436
450
  description: item.description || stage.description || '',
437
- type: routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE),
451
+ type: wiType,
438
452
  priority: item.priority || stage.priority || 'medium',
439
453
  // Agent is a soft routing hint unless agentLock/hardAgent is set.
440
454
  ...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
@@ -442,7 +456,7 @@ function executeTaskStageLegacy(stage, stageState, run, config) {
442
456
  status: WI_STATUS.PENDING,
443
457
  created: ts(),
444
458
  createdBy: 'pipeline:' + run.pipelineId,
445
- branch: `pipeline/${run.pipelineId}/${stage.id}`,
459
+ ...(wiBranch ? { branch: wiBranch } : {}),
446
460
  _pipelineRun: run.runId,
447
461
  _pipelineStage: stage.id,
448
462
  });
@@ -571,7 +585,8 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
571
585
  id: wiId, title: `Convert plan to PRD: ${existingPlanFile}`,
572
586
  type: WORK_TYPE.PLAN_TO_PRD, priority: 'high', status: WI_STATUS.PENDING,
573
587
  planFile: existingPlanFile, created: ts(), createdBy: 'pipeline:' + run.pipelineId,
574
- branch: `pipeline/${run.pipelineId}/${stage.id}`, _pipelineRun: run.runId, _pipelineStage: stage.id,
588
+ // W-mp8ho6w500034a58: PLAN_TO_PRD is read-only no branch needed.
589
+ _pipelineRun: run.runId, _pipelineStage: stage.id,
575
590
  ...(project ? { project: project.name } : {}),
576
591
  });
577
592
  }
@@ -665,7 +680,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
665
680
  planFile: path.basename(filePath),
666
681
  created: ts(),
667
682
  createdBy: 'pipeline:' + run.pipelineId,
668
- branch: `pipeline/${run.pipelineId}/${stage.id}`,
683
+ // W-mp8ho6w500034a58: PLAN_TO_PRD is read-only — no branch needed.
669
684
  _pipelineRun: run.runId,
670
685
  _pipelineStage: stage.id,
671
686
  ...(project ? { project: project.name } : {}),
package/engine/shared.js CHANGED
@@ -2883,10 +2883,12 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
2883
2883
  * the drive-root preflight that fires when MINIONS_DIR sits one level
2884
2884
  * below a filesystem root (resolveProjectRootDir's collapse case).
2885
2885
  *
2886
- * NOTE: Pipeline branches (engine.js `isPipelineBranchName`) override this
2887
- * they always need a worktree even for read-only types because the worktree
2888
- * IS the pipeline's isolated workspace. The caller must detect the pipeline
2889
- * branch case and recompute worktreeRootDir via `resolveProjectRootDir`.
2886
+ * NOTE (W-mp8ho6w500034a58): Pipeline branches no longer override this.
2887
+ * Read-only pipeline stages don't commit, so a `pipeline/...` branch is a
2888
+ * meaningless label for them the dispatcher short-circuits read-only WIs
2889
+ * regardless of branch name, and `engine/pipeline.js` now omits the branch
2890
+ * field for read-only stages. Only code-mutating pipeline stages need a
2891
+ * worktree, and they take the normal code-mutating path below.
2890
2892
  *
2891
2893
  * @param {{ localPath?: string|null }|null|undefined} project
2892
2894
  * @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
package/engine.js CHANGED
@@ -108,10 +108,6 @@ const CHECKPOINT_CAP_FAIL_REASON = 'Exceeded 3 checkpoint-resumes; manual interv
108
108
  // re-aliased here for the existing call sites in this file.
109
109
  const READ_ONLY_ROOT_TASK_TYPES = shared.READ_ONLY_ROOT_TASK_TYPES;
110
110
 
111
- function isPipelineBranchName(branchName) {
112
- return typeof branchName === 'string' && branchName.startsWith('pipeline/');
113
- }
114
-
115
111
  // ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
116
112
 
117
113
  const { mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch,
@@ -777,8 +773,13 @@ async function spawnAgent(dispatchItem, config) {
777
773
  // (caller defaults cwd to worktreeRootDir; drive-root collapse throws
778
774
  // WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT — same fail-fast behavior as
779
775
  // the legacy resolveProjectRootDir call this replaced).
780
- // Pipeline branches force a worktree even for read-only types — handled
781
- // immediately after the resolver call below.
776
+ // W-mp8ho6w500034a58: read-only task types (meeting/ask/explore/plan/plan-to-prd)
777
+ // never need a worktree even when carrying a pipeline branch. Pipeline branches
778
+ // on read-only stages are a no-op label; the stage doesn't commit anything, so
779
+ // the worktree had no functional purpose and was only there to absorb a drive-
780
+ // root preflight that fired against MINIONS_DIR's parent. Read-only pipeline
781
+ // stages now short-circuit alongside any other read-only WI (see the gate at
782
+ // `if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type))` below).
782
783
  const _preBranchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
783
784
  let cwd, worktreeRootDir;
784
785
  try {
@@ -799,29 +800,6 @@ async function spawnAgent(dispatchItem, config) {
799
800
  }
800
801
  throw rootErr;
801
802
  }
802
- // Pipeline branches need a worktree even for read-only types (the worktree
803
- // IS the pipeline's isolated workspace). When we detect a pipeline branch
804
- // on a read-only type, recompute worktreeRootDir so the worktree creation
805
- // block has a placement parent — and so the drive-root preflight still fires.
806
- if (worktreeRootDir === null && isPipelineBranchName(_preBranchName)) {
807
- try {
808
- worktreeRootDir = shared.resolveProjectRootDir(project.localPath, MINIONS_DIR);
809
- } catch (rootErr) {
810
- if (rootErr?.code === 'WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT' || rootErr?.code === 'WORKTREE_ROOTDIR_MISSING_BASE') {
811
- log('error', `spawnAgent: pipeline-branch rootDir resolution failed for ${id}: ${rootErr.message}`);
812
- completeDispatch(
813
- id,
814
- DISPATCH_RESULT.ERROR,
815
- rootErr.message.slice(0, 800),
816
- 'Pre-spawn worktree preflight rejected — see failure_class for the specific cause.',
817
- { failureClass: FAILURE_CLASS.WORKTREE_PREFLIGHT, agentRetryable: false },
818
- );
819
- cleanupTempAgent(agentId);
820
- return null;
821
- }
822
- throw rootErr;
823
- }
824
- }
825
803
  // Legacy local alias: downstream git ops (worktree add, prune, fetch) and
826
804
  // the `cwd === rootDir` safety warn at line ~1387 reference `rootDir`. For
827
805
  // read-only rootless tasks (no worktree, no branch) this is null — the
@@ -910,14 +888,15 @@ async function spawnAgent(dispatchItem, config) {
910
888
  };
911
889
  _phaseT.afterPrompt = Date.now();
912
890
 
913
- if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type) && !isPipelineBranchName(branchName)) {
891
+ if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type)) {
914
892
  // W-mp7havqf0007ce6b: read-only types (meeting/ask/explore/plan/plan-to-prd)
915
893
  // short-circuit BEFORE the worktree-creation block. resolveSpawnPaths returns
916
894
  // 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.
895
+ // ("paths[0] must be of type string. Received null"). Pipeline branches used
896
+ // to be exempt — they don't need to be (W-mp8ho6w500034a58): read-only stages
897
+ // never commit, so a pipeline-branch label is meaningless for them and the
898
+ // forced worktree only existed to feed the drive-root preflight that this
899
+ // short-circuit now correctly avoids.
921
900
  log('info', `${type}: read-only task with branch ${branchName} — skipping worktree, running in cwd ${cwd}`);
922
901
  branchName = null;
923
902
  worktreePath = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1961",
3
+ "version": "0.1.1963",
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"