@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
@@ -0,0 +1,103 @@
1
+ # PR visual evidence CI
2
+
3
+ The OPG GitHub workflow captures matched dashboard screenshots for pull requests that change visual dashboard inputs. It compares live base and head checkouts; it never compares a committed demo image with a live PR.
4
+
5
+ ## Triggering
6
+
7
+ `Visual evidence capture` runs automatically when a PR changes:
8
+
9
+ - `dashboard/**`
10
+ - `dashboard-build.js`
11
+ - `docs/capture-demos.js`
12
+ - `test/seed-demo-data.js`
13
+
14
+ Add the `visual-evidence` label to opt in a visual change outside that list. Removing the label removes the opt-in. Backend, test-only, prose-only, and documentation-only PRs do not start browsers unless explicitly labeled.
15
+
16
+ ## Capture contract
17
+
18
+ The capture workflow:
19
+
20
+ 1. Resolves the exact PR, repository, base, and head tuple from the trusted `pull_request_target` event and records it with the workflow run ID and attempt. The request builder comes from the exact `github.workflow_sha` revision, so an older PR base that predates the controller records a `controller-not-on-base` opt-out instead of failing the plan.
21
+ 2. Captures and uploads BEFORE in a base-only job.
22
+ 3. Starts the separate head job only after BEFORE has been uploaded, then runs the untrusted dashboard in a capability-dropped container with a read-only root/source/controller and one writable marker-owned state mount. The trusted host owns the browser and AFTER output.
23
+ 4. Seeds marker-owned state directories with the same deterministic demo fixture.
24
+ 5. Starts each checkout on the same port and captures it with the base branch's canonical [`capture-demos.js`](capture-demos.js).
25
+ 6. Uses Chromium at 1400x900, dark theme, `en-US`, UTC, reduced motion, and a fixed browser time.
26
+ 7. Requires the same ten non-empty PNG names on both sides.
27
+
28
+ Startup, fixture, and capture logs are retained with the images. Partial output is never presented as a successful comparison: a failed AFTER side fails the run, and a failed BEFORE side degrades the run to an explicitly-labelled AFTER-only publication (see [Degraded AFTER-only runs](#degraded-after-only-runs)).
29
+
30
+ ## Artifacts and PR summary
31
+
32
+ GitHub Actions stores five artifacts keyed by PR number and full head SHA:
33
+
34
+ - capture-request metadata
35
+ - BEFORE PNG set
36
+ - AFTER PNG set
37
+ - BEFORE logs and `result.json`
38
+ - AFTER logs and `result.json`
39
+
40
+ Artifacts expire after **14 days**. Each upload replaces its same-run name on a rerun, so a later attempt refreshes all five artifacts instead of colliding with the prior attempt. The trusted publisher creates or updates one `github-actions[bot]` comment with BEFORE/AFTER artifact links. All publisher jobs serialize globally through GitHub Actions' multi-entry concurrency queue before mutating PR comments, so a newly queued publisher does not replace an already-pending publication. Each publisher also checks the authoritative workflow-run order before changing comments. Older runs and attempts are suppressed even when a newer opt-out run left no summary. Results for an obsolete head are suppressed when a current result exists; an already-posted result is marked stale when its head no longer matches. If the current PR diff no longer requests visual evidence, the old bot summary is removed.
41
+
42
+ ## Degraded AFTER-only runs
43
+
44
+ `capture-before` checks out `github.event.pull_request.base.sha` and runs the **base branch's** seed and capture code, not the pull request's. A defect already merged to `main` therefore breaks the BEFORE side of every PR opened against that base — including the PR that repairs the base, which could otherwise never show a green check.
45
+
46
+ The BEFORE side is consequently non-blocking:
47
+
48
+ - `capture-before` carries `continue-on-error: true` and is named `Capture BEFORE (base, non-blocking)`, the same convention `pr-tests.yml` uses for its non-blocking `perf-tests` job. The failure surfaces as a warning annotation plus a job summary that names the base SHA and states the run is AFTER-only; it does not present a blocking red check on the PR.
49
+ - `Upload BEFORE screenshots` uses `if-no-files-found: warn`, so a crashed capture that wrote zero PNGs is reported once by the capture step instead of raising a second hard error. `Upload BEFORE capture logs` deliberately stays `if-no-files-found: error` — the logs directory is always written and is what makes the failure diagnosable.
50
+ - `capture-after` is gated on `!cancelled() && needs.plan.outputs.capture == 'true'` instead of a successful `capture-before`, so the head-side capture still runs and uploads. The plan gate is re-asserted directly because it is no longer inherited through a green BEFORE job, and `!cancelled()` keeps a run superseded by `cancel-in-progress` from spending a full Docker and Playwright capture.
51
+
52
+ The publisher degrades in step. When the run concludes successfully and the BEFORE PNG set is the *only* missing artifact, `assessArtifacts` returns `status: 'after-only'` and the bot comment is titled **"Visual evidence: AFTER only (BEFORE capture failed)"**. It states that this is not a BEFORE/AFTER comparison, renders the BEFORE cell as `capture failed - no screenshots`, and links the BEFORE logs for diagnosis. Any other gap — a missing AFTER set, missing BEFORE logs, duplicate artifacts, or a genuinely failed run — still reports the full failure summary. A reviewer is never handed a half-capture presented as a matched pair.
53
+
54
+ Nothing about the security posture changes: the controller and base checkout still run from the trusted base ref with `persist-credentials: false`, `Verify exact base checkout` is unchanged, and untrusted head code gains no execution privileges.
55
+
56
+ ## Security boundary
57
+
58
+ The workflow definition and capture-request builder run from the exact trusted `github.workflow_sha` revision through `pull_request_target`; detection and capture execution remain pinned to the PR's trusted base SHA. If that historical base does not contain the controller, detection suppresses both browser jobs while the trusted workflow revision still records the opt-out request artifact. Neither planning path loads controller code from the untrusted head. The workflow has only `contents: read` and `pull-requests: read`, checkout credentials are not persisted, and no write-capable token is available. BEFORE and AFTER use separate runners, and AFTER cannot start until the immutable BEFORE artifact has uploaded. The head dashboard runs without the Actions process environment, host process namespace, controller checkout, screenshot output, or Docker socket; its source/controller mounts and container root are read-only. The sole writable bind mount is marker-owned `/state`, and the container runs as the host UID:GID that owns it. Secret-shaped variables and GitHub Actions control-file variables are removed from host capture child processes.
59
+
60
+ Comment publication happens later in `visual-evidence-publish.yml`, triggered by `workflow_run`. That job checks out only the repository default branch and requires a `pull_request_target` run from the trusted base repository. It downloads and parses only the trusted plan job's capture-request JSON, then binds its repository, workflow, stable run ID, PR, base, head repository, base branch, and full head SHA to the live run before writing. A request retained from an earlier attempt is valid for a selective rerun of that same run ID, while metadata claiming a future attempt is rejected. The run's `head_sha` is the captured PR head; publication does not depend on the optional `workflow_run.pull_requests` association. Screenshot and log artifacts remain link-only and are never downloaded or executed by the publisher.
61
+
62
+ ## Base-controller failure mode
63
+
64
+ Because the seeder (`test/seed-demo-data.js`) and Playwright controller (`docs/capture-demos.js`) always execute from the base checkout, a break in either one is only observable once it is on `main`: every subsequent BEFORE capture fails, for every PR, and no change on a PR branch can repair it — the PR's own copy is never executed. Both files are also visual paths, so even a PR that fixes them triggers the broken capture. Since `capture-before` is non-blocking, the visible symptom is that every PR degrades to an AFTER-only publication (see [Degraded AFTER-only runs](#degraded-after-only-runs)) until the fix lands. Recovery is to land the fix on `main` (admin bypass) and then push to the affected PRs so a fresh `pull_request_target` event picks up a base SHA that contains it. `test/unit/visual-evidence-ci.test.js` runs the seeder's full seeding body so this class of break fails PR Tests before it can reach `main`.
65
+
66
+ ## Local reproduction
67
+
68
+ Start from a trusted checkout of the base branch, never the pull request/head checkout. Resolve the exact full base and head SHAs independently, then use two disposable worktrees and keep output outside both:
69
+
70
+ ```bash
71
+ BASE_WORKTREE=../visual-base
72
+ HEAD_WORKTREE=../visual-head
73
+ OUTPUT_ROOT=../visual-output
74
+ git worktree add --detach "$BASE_WORKTREE" <full-base-sha>
75
+ git worktree add --detach "$HEAD_WORKTREE" <full-head-sha>
76
+ test "$(git -C "$BASE_WORKTREE" rev-parse HEAD)" = "<full-base-sha>"
77
+ test "$(git -C "$HEAD_WORKTREE" rev-parse HEAD)" = "<full-head-sha>"
78
+ npm --prefix "$BASE_WORKTREE" ci --ignore-scripts
79
+ npm --prefix "$BASE_WORKTREE" exec -- playwright install chromium
80
+ node "${BASE_WORKTREE}/.github/scripts/run-visual-evidence.js" \
81
+ --dashboard-runtime docker \
82
+ --controller-root "$BASE_WORKTREE" \
83
+ --base-root "$BASE_WORKTREE" \
84
+ --head-root "$HEAD_WORKTREE" \
85
+ --base-sha <full-base-sha> \
86
+ --head-sha <full-head-sha> \
87
+ --output-root "$OUTPUT_ROOT"
88
+ ```
89
+
90
+ The PNGs land in `../visual-output/{before,after}` and logs in `../visual-output/logs`. Docker is the default dashboard runtime so neither dashboard checkout executes in the host Node process. The host still executes the runner, fixture seeder, and Playwright controller, all from the exact base worktree; the runner verifies that controller checkout against `--base-sha` before mutating output. `--dashboard-runtime process` is an explicit opt-in for code you trust. The output root must be new, empty, or carry the runner's `.minions-visual-evidence-output` ownership marker, and it must not overlap either checkout or the controller in either direction. Reruns clear only the known generated subdirectories beneath a valid marker-owned root. Remove the disposable worktrees with the normal `git worktree remove` command after inspection.
91
+
92
+ ## Complementary in-description embeds (agent-authored)
93
+
94
+ The CI workflow above is one of **two** complementary visual-evidence surfaces; it is not the only one:
95
+
96
+ - **`Visual evidence capture` (this workflow)** — an automated, sandboxed BEFORE/AFTER comparison posted by `github-actions[bot]`. It is untouched by the agent flow and remains the trusted comparison of record.
97
+ - **Agent-embedded screenshots (additive, W-mrz1yc79)** — when a Minions dispatch makes a meaningful **visual/UI change** (dashboard pages, slim-ux, Settings, work-item/PR/plan views, badges, layout/CSS), the agent proactively captures screenshots and embeds them directly in the PR **description** (`![view (after)](url)`), rather than relying only on the bot. Backend-only, engine-logic, text/prose-only, analysis, and docs changes get no screenshots.
98
+
99
+ The agent embed path does **not** replace or modify the CI workflow — the two coexist. Mechanics the agent uses (full recipe in `playbooks/_pr-description-audit.md` → "Screenshot handling"):
100
+
101
+ - **GitHub:** raw PNGs are saved under `agents/<id>/screenshots/` (never `git add`ed), published as assets on a dedicated per-PR prerelease (`gh release create pr-<num>-visual … --prerelease`, or `gh release upload … --clobber` on re-dispatch), and embedded via each asset's `browser_download_url`. This works on private repos and never commits PNGs to the source branch. The upload uses a per-command `GH_TOKEN` for the repo owner's account (`opg-microsoft` → `yemishin_microsoft`, `yemi33` → `yemi33`) and never runs `gh auth switch`.
102
+ - **Azure DevOps:** `PUT /pullRequests/<id>/attachments/<filename>` with the PNG bytes, then reference the returned URL.
103
+ - **Degrade gracefully:** if the dev server won't start, the route 404s, Playwright is unavailable, or the upload fails, the agent skips without failing the work item and records the reason under `meta.descriptionAudit`.
package/docs/watches.md CHANGED
@@ -31,7 +31,7 @@ Defined in `engine/shared.js` (`WATCH_STATUS`):
31
31
  | `active` | Eligible for evaluation each tick |
32
32
  | `paused` | Skipped by `checkWatches`; persists indefinitely until resumed/deleted |
33
33
  | `triggered` | Reserved status (set on demand by callers; not auto-applied) |
34
- | `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
+ | `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:499-513`)* |
35
35
 
36
36
  Pause/resume flips the `status` field via `POST /api/watches/update` *(source: `engine/watches.js` `updateWatch`, `dashboard.js` `handleWatchesUpdate`)*.
37
37
 
@@ -44,14 +44,14 @@ Defined in `engine/shared.js` (`WATCH_CONDITION`). Conditions split into two fam
44
44
 
45
45
  `merged`, `build-fail`, `build-pass`, `completed`, `failed`, `concluded`, `approved`, `rejected`, `ready-for-merge`, `retry-limit-reached`, `all-items-done`, `item-failed-n-times`.
46
46
 
47
- 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
+ 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:499-513`)*.
48
48
 
49
49
  > **Per-target override (W-mp7hg58e000b5212):** the global `WATCH_ABSOLUTE_CONDITIONS` set is the legacy fallback. Each target type now declares its own `absoluteConditions: [...]` array in its spec; `registerTargetType` normalizes that into a `Set` that takes precedence at evaluation time. The plugin contract (see below) uses this to keep absolute-vs-change semantics local to each target type. Plugins that omit `absoluteConditions` get an empty set (all change-based).
50
50
 
51
51
  ### Change-based conditions
52
52
  `status-change`, `any`, `new-comments`, `vote-change`, `stage-complete`, `ran`, `enabled`, `disabled`, `activity-change`, plus the predicate conditions added under P-w4e2f6a1 / P-w5b8d2c9 for the `pr`, `work-item`, `plan`, and `pipeline` target types (`head-commit-change`, `mergeable-flipped`, `behind-master`, `draft-flipped`, `stalled`, `dependency-met`, `stage-advanced`, `stuck-in-stage`). See `engine/shared.js` `WATCH_CONDITION` for the canonical enum.
53
53
 
54
- 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:434, 520`)*.
54
+ 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:436-440, 558-559`)*.
55
55
 
56
56
  ### Tick-counted conditions
57
57
  `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`). Counters (`_unchangedTicks`, `_stuckStageTicks`) are recomputed inside `_captureState` by comparing the fresh snapshot against `prevState`.
@@ -69,24 +69,27 @@ Compound state-assertion predicates (`ready-for-merge`, `retry-limit-reached`, `
69
69
 
70
70
  ## Target Types — `TARGET_TYPES` Registry
71
71
 
72
- Target-type behavior in `engine/watches.js` is **data-driven via a registry** *(source: `engine/watches.js:124-153`)*. Each spec must provide:
72
+ Target-type behavior in `engine/watches.js` is **data-driven via a registry** *(source: `engine/watches.js:122-174`)*. Each spec uses the following fields; entries are required unless marked optional:
73
73
 
74
74
  - `label` — human name shown in dashboard pickers
75
- - `description` — short help text
75
+ - `description` — **(optional)** short help text; defaults to `''` in `listTargetTypes()`
76
76
  - `conditions` — non-empty array of accepted condition keys (also acts as the per-type allowlist)
77
- - `absoluteConditions` — *(optional)* subset of `conditions` that fire-once when `stopAfter === 0`; defaults to `[]` (all conditions treated as change-based)
78
- - `fetchEntity(target, state)` — entity-or-null lookup
79
- - `captureState(entity)` — snapshot used for change-detection diffs
80
- - `evaluate(condition, entity, prevState, target)` — returns `{ triggered, message }`
81
- - `isTerminalForCondition(condition, entity, prevState)` — *(optional)* W-mqa63opd000ha836. When a watch's `condition` cannot possibly fire again because the target reached a permanent terminal state (e.g. a PR was merged/closed/abandoned and the watch is on `build-fail`/`build-pass`/`vote-change`/`new-comments`/`head-commit-change`/`mergeable-flipped`/`behind-master`/`ready-for-merge`/`draft-flipped`), return `true` and the engine auto-expires the watch instead of polling forever. Default returns `false` so existing target types keep current behavior. The main check-loop guard only invokes this when `triggerCount > 0` **or** the prev-state status already matched the current entity status — so a watch armed on an already-terminal PR with `condition: merged` still gets its one shot via the absolute-condition fire-once path *(source: `engine/watches.js:157-160,542-549`)*.
77
+ - `absoluteConditions` — *(optional)* `Set` of condition keys that fire-once when `stopAfter === 0`; defaults to empty set (all conditions treated as change-based)
78
+ - `fetchEntity(target, state)` — entity-or-null lookup; must return the fetched resource or null
79
+ - `captureState(entity, prevState)` — snapshot object used for change-detection diffs; `prevState` parameter lets counters and custom fields carry across checks
80
+ - `evaluate(condition, entity, prevState, target)` — returns `{ triggered: boolean, message: string }`; core condition-check logic
81
+ - `isTerminalForCondition(condition, entity, prevState)` — *(optional)* W-mqa63opd000ha836. When a watch's `condition` cannot possibly fire again because the target reached a permanent terminal state (e.g. a PR was merged/closed/abandoned and the watch is on `build-fail`, `build-pass`, `vote-change`, `new-comments`, `head-commit-change`, `mergeable-flipped`, `behind-master`, `ready-for-merge`, or `draft-flipped`), return `true` and the engine auto-expires the watch. Default returns `false` so existing target types keep polling forever. The main check loop invokes this only when `triggerCount > 0` **or** the prev-state status already matched current entity — so a watch armed on an already-terminal PR with `condition: merged` still fires once via the absolute-condition fire-once path *(source: `engine/watches.js:150-158, 537-550`)*.
82
+ - `contextVars(entity, prevState, newState)` — *(optional)* returns object with extra action-template variables contributed by the target type (e.g., `{ httpStatus: 200, prevExtracted: 'foo' }`)
82
83
 
83
- 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-174`)*.
84
+ 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:122-174`)*.
84
85
 
85
86
  ### User-extensible via `watches.d/` (W-mp7hg58e000b5212)
86
87
 
87
- At engine boot, every `*.js` file in `<MINIONS_DIR>/watches.d/` is auto-loaded **after** the built-in registrations *(source: `engine/watches.js:1319-1354`)*, 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.
88
+ At engine boot, every `*.js` file in `<MINIONS_DIR>/watches.d/` is auto-loaded **after** the built-in registrations *(source: `engine/watches.js:1401-1436`)*, 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.
88
89
 
89
- 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}}`).
90
+ Canonical example: `watches.d/http.js` (W-mp7i22mu00191b07) — a generic HTTP poller covering the full plugin contract including `captureState` (custom snapshot fields) and `contextVars` (custom action-template vars like `{{httpStatus}}`, `{{prevExtracted}}`).
91
+
92
+ This source checkout also includes `ado-author-prs`, `gh-author-prs`, and `teams-channel` plugins under `watches.d/`. They are dynamically loaded plugins, not members of the eight built-in target types; `GET /api/watches/target-types` is the authoritative list for the running installation.
90
93
 
91
94
  ### Built-in target types
92
95
 
@@ -103,11 +106,11 @@ The eight built-ins are registered at module load *(source: `engine/watches.js`
103
106
  | `dispatch` | Dispatch entry id | `completed`, `failed`, `status-change`, `any` | Looks across `pending` / `active` / `completed` lists |
104
107
  | `agent` | Agent id | `activity-change`, `status-change`, `any` | `activity-change` fires only on transitions in/out of `'working'` |
105
108
 
106
- `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-371`)*.
109
+ `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:320-365`)*.
107
110
 
108
111
  ### Plugin folder (`watches.d/`) — user-extensible target types
109
112
 
110
- W-mp7hg58e000b5212 added a **plugin folder** so operators can register new target types without editing engine source. At engine boot, `engine/watches.js` scans `<MINIONS_DIR>/watches.d/*.js` *after* the eight built-ins are registered (so plugins can override a built-in by re-using its key — last-write-wins) and calls `registerTargetType()` for each export *(source: `engine/watches.js:1319-1354`)*.
113
+ W-mp7hg58e000b5212 added a **plugin folder** so operators can register new target types without editing engine source. At engine boot, `engine/watches.js` scans `<MINIONS_DIR>/watches.d/*.js` *after* the eight built-ins are registered (so plugins can override a built-in by re-using its key — last-write-wins) and calls `registerTargetType()` for each export *(source: `engine/watches.js:1401-1436`)*.
111
114
 
112
115
  Each `watches.d/<name>.js` file must export `{ name, spec }` (or an array of those):
113
116
 
@@ -120,10 +123,11 @@ module.exports = {
120
123
  conditions: ['status-change', 'value-equals'],
121
124
  absoluteConditions: ['value-equals'], // optional, defaults to []
122
125
  fetchEntity(target, state) { /* sync entity lookup */ },
123
- captureState(entity) { /* snapshot for diffs */ },
126
+ captureState(entity, prevState) { /* snapshot for diffs */ },
124
127
  evaluate(condition, entity, prevState, target) {
125
128
  return { triggered: <bool>, message: '...' };
126
129
  },
130
+ contextVars(entity, prevState, newState) { return {}; }, // optional
127
131
  },
128
132
  };
129
133
  ```
@@ -134,7 +138,7 @@ Resolution is `path.join(shared.MINIONS_DIR, 'watches.d')` so it works in both d
134
138
 
135
139
  ## Tick Integration
136
140
 
137
- `engine.js` calls `checkWatches(config, state)` every `ENGINE_DEFAULTS.watchPollEvery` ticks (default 18 ⇒ ~3 min at the default 10s tick) inside its own `safe('checkWatches', ...)` block *(source: `engine.js:7439-7470`)*. The engine builds the state object from cached project files + module reads:
141
+ `engine.js` calls `checkWatches(config, state)` every `ENGINE_DEFAULTS.watchPollEvery` ticks (default 18 ⇒ ~3 min at the default 10s tick) inside its own `safe('checkWatches', ...)` block *(source: `engine.js:11286-11328`)*. The engine builds the state object from cached project files + module reads:
138
142
 
139
143
  ```
140
144
  {
@@ -150,16 +154,16 @@ Resolution is `path.join(shared.MINIONS_DIR, 'watches.d')` so it works in both d
150
154
  1. Skips paused/expired watches and any watch checked within its `interval`.
151
155
  2. Captures a baseline `_lastState` on first check (so change conditions have something to diff).
152
156
  3. Calls `evaluateWatch(watch, state)`.
153
- 4. On trigger: increments `triggerCount`, sets `last_triggered`, queues an inbox notification (if `notify === 'inbox'`), and snapshots an action task for any configured `watch.action`.
157
+ 4. On trigger: increments `triggerCount`, sets `last_triggered`, and snapshots an effective action when notification or follow-up work is configured. When `notify === 'inbox'` and the watch has an owner, an implicit `{ type: 'notify' }` action step is prepended before any configured `watch.action`; without that implicit step, a single explicit action keeps its object shape.
154
158
  5. Applies fire-once / `stopAfter` expiration.
155
159
  6. On non-trigger: writes a per-poll inbox note when `onNotMet === 'notify'`.
156
160
  7. Refreshes `_lastState` for the next check.
157
161
 
158
- 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:562+` `_runActionTask`)*.
162
+ I/O happens **outside the lock**. Trigger actions, including the implicit inbox notification, run through `_runActionTask`. Only `onNotMet === 'notify'` polling notes use the direct notification queue and `writeToInbox`. Each action result is persisted back onto the watch as `_lastActionResult` in a follow-up locked write. An implicit notify makes the effective action array-shaped, so its result uses the `type: 'chain'` / `steps[]` shape; an explicit action without an implicit notify preserves the legacy single-action result shape *(source: `engine/watches.js:459-495,515-521,601-684`)*.
159
163
 
160
164
  ## Follow-Up Actions on Trigger
161
165
 
162
- `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:184-248` `createWatch`, `engine/watch-actions.js:56` `registerActionType`)*. `GET /api/watches/action-types` returns the live list for dashboard pickers.
166
+ `watch.action` is an optional structured action (or action chain). When the implicit inbox notification is enabled, configured actions follow it in the same effective chain. Action types live in a sibling registry in `engine/watch-actions.js` and are validated at create/update time *(source: `engine/watches.js:189-245` `createWatch`, `engine/watch-actions.js:95-105` `registerActionType`)*. `GET /api/watches/action-types` returns the live list for dashboard pickers.
163
167
 
164
168
  ### Built-in actions
165
169
 
@@ -167,10 +171,10 @@ I/O happens **outside the lock**: notifications via `writeToInbox`, follow-up ac
167
171
  |------------------------|-----------------------------------------------------------------------------------------------|
168
172
  | `notify` | Explicit inbox write; lets you customize `owner`/`body` instead of the default trigger string |
169
173
  | `dispatch-work-item` | Add a new project or central work item with `createdBy: "watch:<id>"` |
170
- | `run-skill` | Wrapper around `dispatch-work-item` that asks the agent to run a specific `.claude` skill |
174
+ | `run-skill` | Wrapper around `dispatch-work-item` that asks the agent to run a named runtime-native skill |
171
175
  | `webhook` | `http`/`https` request to an arbitrary URL (10s safety timeout, JSON or string body) |
172
176
  | `minions-api` | Loopback call to the in-process dashboard at `http://127.0.0.1:${shared.readDashboardPortFile(MINIONS_DIR)?.port \|\| 7331}` (reads the bound-port beacon file, not the `MINIONS_PORT` env, so it still resolves after an `EADDRINUSE` port scan) — `endpoint` must start with `/api/`; sets `X-Minions-Internal: 1`; returns `{ok, status, summary, response}` for chain-step templating |
173
- | `cancel-work-item` | Flip a WI to `WI_STATUS.CANCELLED` across all known work-items files |
177
+ | `cancel-work-item` | Flip a WI to `WI_STATUS.CANCELLED` across central and configured-project SQL scopes |
174
178
  | `trigger-pipeline` | Start a new pipeline run (skipped if the pipeline already has an active run) |
175
179
  | `archive-plan` | Set PRD `status="archived"` + `archivedAt` |
176
180
  | `resume-plan` | Set PRD `status=PLAN_STATUS.ACTIVE` and clear `planStale` |
@@ -180,7 +184,7 @@ Constants live in `WATCH_ACTION_TYPE` (`engine/shared.js`); handlers in `engine/
180
184
 
181
185
  ### Templating
182
186
 
183
- Action params support `{{var}}` substitution from the trigger context *(source: `engine/watch-actions.js:145-220`)*. Built-in vars from `buildTriggerContext`:
187
+ Action params support `{{var}}` substitution from the trigger context *(source: `engine/watch-actions.js:133-230`)*. Built-in vars from `buildTriggerContext`:
184
188
 
185
189
  - Always: `target`, `targetType`, `condition`, `watchId`, `triggerCount`, `message`
186
190
  - All scalar fields from `newState` (e.g. `status`, `buildStatus`, `reviewStatus`, `lastRun`)
@@ -190,13 +194,13 @@ Unknown vars are left as `{{var}}` so callers can detect them downstream.
190
194
 
191
195
  ### Failure isolation
192
196
 
193
- 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>}}`.
197
+ Action handlers must return `{ ok: false, summary }` rather than throw — `runWatchAction` wraps them in try/catch as a backstop *(source: `engine/watch-actions.js:262-293`)*. 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:310-355`)* — each step's `{ok, summary, result}` is templated into the next step's params as `{{steps.N.result.<field>}}`.
194
198
 
195
199
  ## Dashboard Surface
196
200
 
197
201
  | Route | Method | Handler | Purpose |
198
202
  |--------------------------------|--------|-------------------------------|-------------------------------------------------------------------------|
199
- | `/watches` | GET | `dashboard/pages/watches.html` | Watches page (sidebar link in `dashboard/layout.html:75`) |
203
+ | `/watches` | GET | `dashboard/pages/watches.html` | Watches page (sidebar link in `dashboard/layout.html:111`) |
200
204
  | `/api/watches` | GET | `handleWatchesList` | List all watches |
201
205
  | `/api/watches/target-types` | GET | `handleWatchesTargetTypes` | Live registry from `listTargetTypes()` — used by the create-watch modal |
202
206
  | `/api/watches/action-types` | GET | `handleWatchesActionTypes` | Live registry from `listActionTypes()` |
@@ -212,7 +216,7 @@ The CC state preamble injects a `Watches: <count>` line via `getWatches()` so th
212
216
 
213
217
  ## Command Center Integration
214
218
 
215
- CC creates, pauses, resumes, and deletes watches by calling the REST API directly via its `Bash` tool — the older `===ACTIONS===` `create-watch`/`delete-watch`/`pause-watch`/`resume-watch` actions have been retired *(source: `test/unit.test.js:16976-17008`, `test/unit/watches-module.test.js:673`)*. To use a watch from CC chat, ask it something like:
219
+ CC creates, pauses, resumes, and deletes watches by calling the REST API directly via its `Bash` tool — the older `===ACTIONS===` `create-watch`/`delete-watch`/`pause-watch`/`resume-watch` actions have been retired *(source: `test/unit.test.js:21048-21052`, `test/unit/watches-module.test.js:1699`)*. To use a watch from CC chat, ask it something like:
216
220
 
217
221
  > "Watch PR 1234 for build-pass and dispatch a verify work-item when it fires."
218
222
 
@@ -223,9 +227,10 @@ CC will discover the endpoints from `GET /api/routes` and `POST /api/watches` wi
223
227
  When a watch fires:
224
228
 
225
229
  - The SQL watch record gets `triggerCount++`, `last_triggered=<ISO>`, `_lastTriggerMessage=<message>`, and a refreshed `_lastState`.
226
- - An inbox file lands at `notes/inbox/<owner>/watch-<watchId>-<n>.md` (when `notify === 'inbox'`).
227
- - If `action` is set, a follow-up runs (work item, webhook POST, plan archive, ...) and writes `_lastActionResult` (`{ type, ok, summary, dispatchedItemId?, at }`) back onto the watch.
228
- - The SQL engine log gets `Watch triggered: <id> <message>` and `Watch <id> action <type>: <summary>`.
230
+ - When `notify === 'inbox'`, the engine prepends an implicit `{type: 'notify'}` action to the action chain (if present) or runs notify standalone, so the inbox alert participates in action-chain execution and `_lastActionResult` *(source: `engine/watches.js:459-496`)*. This is the **trigger-time notify**, paired with an optional explicit `action`.
231
+ - When `onNotMet === 'notify'` (and the condition has NOT yet fired), the engine instead queues a direct poll notification outside the action chain independent of any `action` field *(source: `engine/watches.js:515-521`)*. This is a separate **onNotMet-notify**, useful for "still watching, condition not met yet" progress updates.
232
+ - If an action runs, `_lastActionResult` stores either the single-action shape (`{ type, ok, summary, dispatchedItemId, at }`) or the chain shape (`{ type: "chain", ok, summary, aborted, steps, at }`).
233
+ - The SQL engine log gets `Watch triggered: <id> — <message>` and `Watch <id> action <type-or-chain[N]>: <summary>`.
229
234
 
230
235
  Absolute conditions firing under `stopAfter === 0` flip `status` to `expired`; `stopAfter > 0` flips it to `expired` once `triggerCount >= stopAfter`.
231
236
 
@@ -242,7 +247,7 @@ Absolute conditions firing under `stopAfter === 0` flip `status` to `expired`; `
242
247
  | `_lastActionResult.ok === false` with `"unknown action type"` | The `action.type` isn't registered. List with `listActionTypes()` / `GET /api/watches/action-types` |
243
248
  | 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)* |
244
249
  | Trigger fires but follow-up `dispatch-work-item` is missing | Check the engine log for `Watch <id> action <type>: <summary>`. Common reasons: missing `title`, a project-scope write failed, or the WI landed in the central scope because no project was specified |
245
- | Watch `_lastActionResult` shows `"timeout"` for webhook | Webhooks have a 10s safety timeout to keep the watches tick fast *(source: `engine/watch-actions.js:482-484`)* |
250
+ | Watch `_lastActionResult` shows `"timeout"` for webhook | Webhooks have a 10s safety timeout to keep the watches tick fast *(source: `engine/watch-actions.js:550-553`)* |
246
251
  | `checkWatches` block crashes silently | Wrapped in `safe('checkWatches', ...)` so one failure doesn't abort the tick. Inspect the engine log for `Watch check error (<id>)` lines. Regression #1088: the block must use `getProjects(config)`, never the long-removed `PROJECTS` constant |
247
252
 
248
253
  ## See Also
@@ -9,7 +9,7 @@ Lifecycle keeps the cross-cutting invariants; the detail lives here.
9
9
  > Source of truth: `engine/worktree-pool.js`, `engine/shared.js#removeWorktree`
10
10
  > + `_retryFsOp`, `engine.js` (`_quarantineDirtyWorktree`,
11
11
  > `_killGitDescendantsForWorktree`, `pruneOrphanWorktrees*`, `gcDispatchWorktreeIfOrphan`),
12
- > `engine/cleanup.js`. Last verified: 2026-06-09.
12
+ > `engine/cleanup.js`. Last verified: 2026-07-23.
13
13
 
14
14
  ## Operator-checkout boundary
15
15
 
@@ -30,6 +30,17 @@ legacy processes are terminated and retried. Worktree setup may update shared Gi
30
30
  but it never checks out, resets, cleans, writes files, or removes
31
31
  `.git/index.lock` in the operator checkout.
32
32
 
33
+ Before allocating a branch-bearing worktree-mode dispatch, the engine compares
34
+ the target branch's `git worktree list --porcelain` holder against every
35
+ configured `localPath`, including overlapping nested-project layouts. A match
36
+ keeps the dispatch and source work item pending with
37
+ `_pendingReason: "worktree_branch_held"` and `_worktreeBranchHeld`
38
+ branch/path diagnostics; it consumes no retry and is re-checked each tick.
39
+ Releasing the branch clears the diagnostic and dispatches automatically.
40
+ `spawnAgent` repeats the probe, and the `git worktree add` error handler applies
41
+ the same deferral if the checkout acquires the branch in the remaining race
42
+ window. Neither path runs an agent in or changes the operator checkout.
43
+
33
44
  Worktrees that host live `managed_spawn` or `keep_processes` cwd records are
34
45
  anchored across dispatch-end, periodic, boot, in-root, and out-of-root GC.
35
46
  Anchor comparisons use canonical filesystem paths so junction/case aliases do
@@ -38,17 +49,28 @@ not make a live service's worktree look orphaned.
38
49
  ## Live-worktree guard (W-mq5rwwss000f30a7)
39
50
 
40
51
  **Invariant:** every code path that wipes, renames, or recycles a worktree
41
- MUST call `shared.isWorktreePathLive(path, { db?, excludeDispatchId? })`
52
+ MUST call `shared.isWorktreePathLive(path, { db?, excludeDispatchId?, excludeSpawnLeaseId? })`
42
53
  first and skip on `true`. Originally added after W-mq5n1zx5000hcfb5 — an
43
54
  agent's worktree was wiped 4× consecutively by the reaper while the
44
55
  dispatch was still active.
45
56
 
46
57
  - **Backed by SQL** against `dispatches` (status IN ('pending','active')),
47
58
  reading `json_extract(data, '$.worktreePath')` with a `data.meta.worktreePath`
48
- fallback. The pending active transition persists `item.worktreePath`
49
- on the dispatch row so the guard has a path to correlate.
59
+ fallback. `_claimSpawnAttemptLease` first persists `_spawnLeaseId`, owner PID,
60
+ and claim time on the pending row; `_persistSpawnWorktreePath` then records
61
+ the path before runtime activation. Setup is protected while still pending,
62
+ and leased pending rows count against dispatch capacity.
50
63
  - **Fails OPEN** (returns `true`) when SQLite is unreachable or the query
51
64
  throws — better to leak a worktree than nuke an agent's unpushed work.
65
+ - **Scoped exclusions:** normal owner cleanup may pass `excludeDispatchId`.
66
+ Spawn-time quarantine also passes `excludeSpawnLeaseId`; the dispatch row is
67
+ excluded only when its persisted lease matches. A stale tick carrying an old
68
+ lease therefore sees a successor lease as live and cannot rename its
69
+ worktree.
70
+ - **Generation check:** `_isSpawnAttemptCurrent` revalidates both
71
+ `tickGeneration` and the persisted lease throughout worktree setup.
72
+ `_quarantineDirtyWorktree` repeats that check before destructive stages and
73
+ returns `{ skipped: true, staleLease: true }` after ownership changes.
52
74
  - **Wired sites:** `shared.removeWorktree` (accepts `{ excludeDispatchId }`
53
75
  forwarded to the guard); pool-return chain in `engine.js`
54
76
  (`excludeDispatchId: id` so the dispatch can clean up its own worktree);
@@ -85,6 +107,32 @@ that intentionally preserve an existing branch continue using that branch
85
107
  unchanged. Warm-pool borrowing keeps its existing fetch-and-checkout of
86
108
  `origin/<mainBranch>`.
87
109
 
110
+ ## Creation integrity gate (#905)
111
+
112
+ `engine.js#runWorktreeAdd` treats every nonzero `git worktree add` result as a
113
+ potentially corrupt partial checkout. Before retrying, it checks the live-path
114
+ guard, removes locked/initializing Git metadata with double force, removes any
115
+ remaining directory through the shared Windows file-lock retry helper, prunes,
116
+ and proves that neither the path nor its worktree registration remains. A retry
117
+ uses a generation-suffixed candidate path rather than the failed path.
118
+ An unregistered candidate directory is cleaned before fresh creation only when
119
+ it carries the engine ownership marker. An unmarked pre-existing directory is
120
+ treated as foreign, preserved, and bypassed with a generation-suffixed candidate
121
+ path. Periodic GC treats generation-suffixed candidates as owned by the live
122
+ dispatch until the exact path is persisted.
123
+
124
+ A zero exit code is necessary but not sufficient. Before the candidate is
125
+ persisted as the dispatch worktree or an agent can start, it must:
126
+
127
+ - appear at the intended canonical path in `git worktree list --porcelain`;
128
+ - resolve its own top level and HEAD consistently with that registration;
129
+ - match the requested branch (or detached state) and pre-resolved target commit;
130
+ - return a clean `git --no-optional-locks status --porcelain`.
131
+
132
+ Validation failures follow the same cleanup-and-fresh-retry path. The former
133
+ `recoverPartialWorktree` fallback was removed: a command that returned nonzero
134
+ is never accepted merely because the leftover directory answers `rev-parse`.
135
+
88
136
  ## Ownership marker is never "dirty" (#284)
89
137
 
90
138
  The reused-worktree preflight (`assertCleanSharedWorktree`) builds its
@@ -123,10 +171,32 @@ quarantine fallback; transient probe, fetch, auth, and push failures leave clean
123
171
  ahead commits in place for retry. Recoverable
124
172
  `refs/minions/quarantine/*` and `refs/minions/quarantine-wip/*` refs are listed
125
173
  on Dashboard → Engine under **Quarantined Work**.
174
+ Periodic cleanup deletes these recovery refs after 10 days. This is intentionally
175
+ longer than the 24-hour quarantine-directory retention window: the refs preserve
176
+ committed HEAD and WIP snapshots after the directory itself is removed, while
177
+ still preventing recovery refs from growing without bound.
178
+
179
+ At dispatch close, an error result is inspected before orphan GC. A clean branch
180
+ that is aligned with origin (or successfully pushed there) remains removable.
181
+ Dirty files, unpushed commits that cannot be pushed, and indeterminate branch
182
+ state go through `_quarantineDirtyWorktree` first; dispatch-end GC is skipped
183
+ unless that inspection proves removal safe. The quarantine live guard receives
184
+ `excludeDispatchId` so the ending dispatch may preserve its own worktree while
185
+ any sibling live claimant still blocks rename/removal. This also covers exit
186
+ code `0` that becomes an error only after the required completion report or PR
187
+ contract is validated.
188
+
189
+ Process-anchored failed worktrees are snapshotted without moving the live
190
+ directory. If both that WIP snapshot and the protection-marker write fail, the
191
+ dispatch stays active with `_worktreeProtectionDeferred` instead of becoming
192
+ terminal. The active SQL dispatch row remains a fail-open GC claim after the
193
+ process anchor expires. Timeout recovery then retries preservation; only a
194
+ durable recovery ref or protected quarantine allows terminalization and later
195
+ periodic cleanup.
126
196
 
127
197
  When `discoverFromWorkItems` finds a worktree in a `WORKTREE_DIRTY`,
128
198
  `WORKTREE_DIVERGENT`, or post-stuck state, `_quarantineDirtyWorktree`
129
- moves it to `<root>/.quarantined/<basename>-<utc>` and lets the next tick
199
+ moves it to `<path>-quarantine-<epoch-ms>` and lets the next tick
130
200
  recreate a clean worktree. Six layered defenses cover the Windows EBUSY
131
201
  race when git status descendants still hold packfile handles
132
202
  (W-mq5n1zx5000hcfb5; PR #3156).
@@ -172,6 +242,12 @@ command when `preserved`, a `⚠️` warning when `failed`, or an explicit "noth
172
242
  to preserve" note when `clean` — so a human never has to guess which bucket a
173
243
  given quarantine landed in.
174
244
 
245
+ If either the committed backup ref or an uncommitted WIP ref cannot be created,
246
+ the quarantined directory is the only recovery artifact. The engine stamps
247
+ `.minions-quarantine-protected` into that directory, and the central
248
+ `shared.removeWorktree` primitive refuses to delete it. An operator must inspect
249
+ and remove that quarantine manually after recovering the files.
250
+
175
251
  ### Layer 1b — `--no-optional-locks` on every status probe (load-bearing)
176
252
 
177
253
  `_statusPorcelainCmd()` emits `git --no-optional-locks status …`. Skips
@@ -289,7 +365,10 @@ layers.
289
365
 
290
366
  ## Periodic prune (W-mq5o6bvy000x7191)
291
367
 
292
- `pruneWorktreesPeriodic` runs `pruneOrphanWorktrees` (in-root) +
368
+ `pruneWorktreesPeriodic` runs `reconcileStaleWorktreeDirectories`
369
+ (physical directories absent from Git's registry),
370
+ `reclaimMissingDirWorktrees` (registry entries with a missing backing
371
+ directory), `pruneOrphanWorktrees` (in-root), and
293
372
  `pruneOrphanWorktreesFromGitRegistry` (out-of-root via
294
373
  `git worktree list --porcelain`) per project at
295
374
  `ENGINE_DEFAULTS.worktreePruneIntervalTicks` cadence — catches Windows
@@ -297,6 +376,36 @@ EPERM/EBUSY stragglers that the dispatch-end GC couldn't reap and sweeps
297
376
  the `git worktree list` registry for OUT-of-root entries the in-root
298
377
  scanner is blind to.
299
378
 
379
+ The filesystem reconciliation pass is deliberately fail-closed and distinct
380
+ from dispatch-oriented GC. It lists every repo sharing a configured worktree
381
+ root before deleting anything; if any list fails, the whole shared root is
382
+ left untouched and the failure is logged/reported in sweep diagnostics. This
383
+ pass never deletes registered paths, but only `locked` / `initializing`
384
+ registrations are protected from the downstream dispatch-oriented pruner.
385
+ Unlocked registrations can therefore be removed there when unclaimed, while
386
+ non-terminal SQL claims, pool/managed-process/keep-process anchors, detected
387
+ CWD holders, protected quarantines, and unrelated infrastructure directories
388
+ remain protected. Deletion requires either `.minions-worktree` or the narrow
389
+ legacy proof used for old Windows residue: a `W-*` basename plus a `.git` file
390
+ pointing inside one of the project's common `.git/worktrees` directories.
391
+ Ordinary unregistered residue is eligible after 2h; unprotected
392
+ `-quarantine-<epoch-ms>` residue after 24h. Protected quarantine directories
393
+ remain until manual recovery because they are the only durable copy of work
394
+ whose backup ref could not be created.
395
+
396
+ `runCleanup` delegates its configured-root orphan pass to the same reconciler;
397
+ the former fail-open duplicate scan and generic age/count-cap deletion are
398
+ retired. Its remaining PR-terminal cleanup is narrower: only a currently
399
+ registered, unlocked, marker-owned worktree whose PR is still merged or
400
+ abandoned can be removed, after fresh SQL-live, process-anchor/CWD-holder, and
401
+ protected-quarantine checks.
402
+
403
+ Both removal pruners check exact persisted path ownership through
404
+ `shared.isWorktreePathLive` before calling `removeWorktree`. A live claim is
405
+ an expected `skipped` outcome, not a failed removal, and is retried on the next
406
+ sweep after the dispatch becomes terminal. Aggregate logs report mutually
407
+ meaningful `removed`, `skipped`, `preserved`, and `failed` counts.
408
+
300
409
  ### Scratch-dir skip and bounded reaper (PR #388)
301
410
 
302
411
  The worktree root (`<localPath>/../worktrees/`) is a **shared namespace**: it
@@ -331,7 +440,7 @@ When adding new code that enumerates the worktree root directory, apply
331
440
  `shared.isWorktreeRootInfraEntry(name)` to skip dot-prefixed entries before
332
441
  calling any `removeWorktree` path.
333
442
 
334
- ### Ownership marker — out-of-root GC only touches engine worktrees (W-mqecdoot)
443
+ ### Ownership marker — periodic GC only touches engine worktrees (W-mqecdoot)
335
444
 
336
445
  A project repo's `git worktree list` includes EVERY worktree registered
337
446
  against it — including ones a developer created by hand
@@ -341,14 +450,35 @@ live dispatch / pool / managed-spawn, **deleting human worktrees
341
450
  mid-edit**. The fix: the engine stamps a `.minions-worktree` ownership
342
451
  marker (`shared.WORKTREE_OWNER_MARKER`) into every worktree it creates
343
452
  (`shared.writeWorktreeOwnerMarker`, called right after `git worktree add`
344
- in `engine.js#runWorktreeAdd`). `pruneOrphanWorktreesFromGitRegistry`
345
- now requires `shared.hasWorktreeOwnerMarker(path)` before evicting an
346
- out-of-root worktree — **no marker → foreign → kept and never
453
+ in `engine.js#runWorktreeAdd`). Both in-root and out-of-root removal pruners
454
+ require `shared.hasWorktreeOwnerMarker(path)` before eviction
455
+ **no marker → foreign → kept and never
347
456
  escalated** (fail-open: leak a dir rather than nuke unpushed work). The
348
457
  trailing `git worktree prune --expire=now` (drops registry entries whose
349
458
  dirs are already gone) still runs regardless of ownership. The marker is
350
459
  gitignored so it never shows up in any worktree's `git status`.
351
460
 
461
+ `shared.removeWorktree` remembers whether the marker existed before removal.
462
+ If git and filesystem fallbacks partially delete an owned worktree but leave a
463
+ locked husk, it restores the marker so the next periodic sweep can retry.
464
+ Previously unmarked paths are never marked by this recovery.
465
+
466
+ ### Checkout free-space preflight
467
+
468
+ `engine.worktreeMinFreeSpaceBytes` defaults to 10 GiB (`0` disables) and is
469
+ editable in Dashboard -> Settings -> Worker Pool & Worktrees. Allocation and a
470
+ second spawn-time race check call `engine/worktree-preflight.js` before any
471
+ `git worktree add`. The probe uses the nearest existing ancestor when the
472
+ configured worktree root has not been created yet.
473
+
474
+ Below the threshold, the existing dispatch remains pending with
475
+ `_pendingReason: "worktree_low_disk_space"` and structured
476
+ `_worktreeDiskPreflight` fields (`requiredBytes`, `availableBytes`,
477
+ `worktreeRoot`, `probePath`, `checkedAt`, `message`). A statfs/probe error uses
478
+ `worktree_disk_probe_failed` and also fails closed. Both paths write a deduped
479
+ inbox alert, start no process or checkout, consume no retry, and are re-checked
480
+ on every allocation tick.
481
+
352
482
  ### Locked-`initializing` missing-dir reclaim (W-mqifblkf00149df5)
353
483
 
354
484
  A third pruner, `worktree-gc.js#reclaimMissingDirWorktrees`, closes a
@@ -378,6 +508,11 @@ single `-f` errors `cannot remove a locked working tree`) followed by
378
508
  (`shared.isWorktreePathLive`, fail-open: skip/leak rather than nuke); and
379
509
  3. it is not the main checkout (`path === project root`).
380
510
 
511
+ The missing-dir pass runs before either removal pruner mutates the shared
512
+ porcelain snapshot. If a concurrent cleanup wins the race anyway, a failed
513
+ remove is re-listed and treated as success only when the registration is
514
+ already absent.
515
+
381
516
  The same `remove -f -f` reaper (`engine.js#pruneStaleWorktreeForBranch`) is
382
517
  also wired into **both** spawn-time worktree-creation recovery paths —
383
518
  `_branchOnRemote === true` AND the fresh-create `else` branch — so a
@@ -434,9 +569,12 @@ Windows-only; three layers, each fail-open:
434
569
 
435
570
  1. POSIX is a no-op (the `EBUSY` race is Windows-specific).
436
571
  2. Master switch `engine.statusProbeKillDescendantsWin32` (default ON).
437
- 3. `shared.isWorktreePathLive(path, { excludeDispatchId })` must be false —
438
- fail-open: on a live claim OR on a SQL error / throw the WHOLE reap is
439
- SKIPPED (leak a worktree rather than nuke a live agent's unpushed work).
572
+ 3. `shared.isWorktreePathLive(path, { excludeDispatchId, excludeSpawnLeaseId? })`
573
+ must be false. Spawn-attempt-owned cleanup passes both exclusions, so the
574
+ dispatch row is excluded only when its persisted lease matches; a stale
575
+ attempt cannot exclude a successor row with the same dispatch id. Fail-open:
576
+ on a live claim OR on a SQL error / throw the WHOLE reap is SKIPPED (leak a
577
+ worktree rather than nuke a live agent's unpushed work).
440
578
  This check is **re-run at kill time** before Layer 2 (W-mqinlicl): the PEB
441
579
  probe can take several seconds, and liveness flips on the engine tick — a
442
580
  "not live" snapshot from the top of the function is unsafe to kill on.
@@ -303,6 +303,7 @@ class Worker {
303
303
  this.runtime = resolveWorkerRuntime(runtime);
304
304
  this.id = id;
305
305
  this.model = model;
306
+ this.currentModel = null;
306
307
  this.effort = effort;
307
308
  this.mcpServers = mcpServers || [];
308
309
  this.mcpServersHash = mcpServersHash;