@yemi33/minions 0.1.2160 → 0.1.2162

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.
@@ -109,6 +109,12 @@ These declarations are intentional and exempt from the tripwire:
109
109
  - SVG `font-size='90'` inside the `data:image/svg+xml` favicon link in
110
110
  `dashboard/layout.html` and `dashboard/slim/layout.html` — runtime
111
111
  data URL, CSS variables don't resolve inside it.
112
- - The `--text-*` token declarations themselves in `:root` blocks.
112
+ - The `--text-*` token declarations themselves in `:root` blocks. Each
113
+ primitive is `calc(<base>px * var(--minions-text-scale))` — the
114
+ `--minions-text-scale` knob (W-mq71mqtx00060fa9) nudges the dashboard's
115
+ real text size from one place (main `1.05`, slim pinned to `1` so the
116
+ slim UI keeps its current sizing). The base px stays the source of truth
117
+ and must match between `dashboard/styles.css` and
118
+ `dashboard/slim/styles.css`.
113
119
 
114
120
  Everything else MUST go through a token.
@@ -146,7 +146,7 @@ const RENDER_VERSIONS = {
146
146
  dispatch: 2,
147
147
  engineLog: 2,
148
148
  metrics: 1,
149
- workItems: 2,
149
+ workItems: 3,
150
150
  skills: 1,
151
151
  mcpServers: 1,
152
152
  schedules: 1,
@@ -35,12 +35,13 @@ const _WI_ENRICHMENT_FIELDS = [
35
35
  //
36
36
  // Triggers:
37
37
  // * _preDispatchEval.valid === false (pre-dispatch acceptance gate rejected)
38
- // * _pendingReason in { pr_not_found, no_agent, budget_exceeded, dependency_unmet }
38
+ // * _pendingReason in { pr_not_found, no_agent, budget_exceeded }
39
39
  //
40
40
  // Does NOT trigger for transient reasons that auto-clear:
41
41
  // * cooldown / retry_cooldown — wait it out
42
42
  // * already_dispatched — engine reconciles on next tick
43
43
  // * branch_locked — wait for the holding dispatch
44
+ // * dependency_unmet — auto-clears when upstream dep finishes (engine discovery loop + DEPENDENCY_MET watch)
44
45
  //
45
46
  // Returns { kind, short, full } when the item needs attention, else null.
46
47
  // Pure helper (no DOM / escapeHtml deps) so it can be unit-tested in Node.
@@ -74,13 +75,6 @@ function needsAttentionInfo(item) {
74
75
  var budgetMsg = 'Assigned agent ' + agent + ' is over its maxBudgetUsd cap.';
75
76
  return { kind: 'budget_exceeded', short: budgetMsg, full: budgetMsg };
76
77
  }
77
- if (reason === 'dependency_unmet') {
78
- var deps = Array.isArray(item.depends_on) && item.depends_on.length
79
- ? item.depends_on.join(', ')
80
- : '<unknown>';
81
- var depMsg = 'Waiting on dependencies: ' + deps + '. (One or more are not yet done.)';
82
- return { kind: 'dependency_unmet', short: depMsg, full: depMsg };
83
- }
84
78
  return null;
85
79
  }
86
80
 
@@ -229,6 +229,23 @@ async function openSettings() {
229
229
  '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Parsed from <code>git symbolic-ref refs/remotes/origin/HEAD</code>' + (localBranch ? '. Local HEAD: <code>' + escHtml(localBranch) + '</code>' : '') + '.</div>' +
230
230
  '</div>' +
231
231
  '</div>';
232
+ // P-a3f9b207 — per-project worktreeMode dropdown + warning chip. Default
233
+ // 'isolated' (engine creates a dedicated worktree per dispatch); 'live'
234
+ // runs the agent directly in p.localPath for repos where worktrees are
235
+ // unworkable. Chip is hidden by default and toggled reactively below.
236
+ var currentWtMode = (p.worktreeMode === 'live') ? 'live' : 'isolated';
237
+ var wtModeSearch = 'worktree mode isolated live checkout dispatch';
238
+ var worktreeModeBlock =
239
+ '<div data-search="' + escHtml(wtModeSearch) + '" style="margin-bottom:6px">' +
240
+ '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Worktree mode</label>' +
241
+ '<select id="set-worktreeMode-' + escHtml(p.name) + '" data-worktree-mode-select="' + escHtml(p.name) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
242
+ '<option value="isolated"' + (currentWtMode === 'isolated' ? ' selected' : '') + '>Isolated (default)</option>' +
243
+ '<option value="live"' + (currentWtMode === 'live' ? ' selected' : '') + '>Live checkout</option>' +
244
+ '</select>' +
245
+ '<div data-worktree-mode-chip="' + escHtml(p.name) + '" style="' + (currentWtMode === 'live' ? '' : 'display:none;') + 'margin-top:6px;padding:6px 8px;background:rgba(234,179,8,0.12);border:1px solid var(--yellow);border-radius:4px;color:var(--yellow);font-size:var(--text-xs);line-height:1.4">' +
246
+ '⚠ Live mode: dispatches run directly in this repo\'s checkout. Only one mutating dispatch runs at a time. Dirty working trees block dispatch — commit or stash before running.' +
247
+ '</div>' +
248
+ '</div>';
232
249
  return '<div data-settings-project="' + escHtml(p.name) + '" data-search="project ' + escHtml(p.name.toLowerCase()) + '" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:12px">' +
233
250
  '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
234
251
  '<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
@@ -236,6 +253,7 @@ async function openSettings() {
236
253
  '</div>' +
237
254
  pathRow +
238
255
  branchGrid +
256
+ worktreeModeBlock +
239
257
  driftNote +
240
258
  '<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
241
259
  settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
@@ -563,6 +581,20 @@ async function openSettings() {
563
581
  }
564
582
  });
565
583
  });
584
+
585
+ // P-a3f9b207 — toggle the live-mode warning chip reactively when the
586
+ // operator flips the per-project worktreeMode dropdown (before save). The
587
+ // chip is rendered once with display:none for isolated projects and
588
+ // visible for already-live projects; this handler only flips the
589
+ // display style — no markup is regenerated.
590
+ document.querySelectorAll('[data-worktree-mode-select]').forEach(function(sel) {
591
+ sel.addEventListener('change', function() {
592
+ const projName = sel.getAttribute('data-worktree-mode-select');
593
+ const chip = document.querySelector('[data-worktree-mode-chip="' + (window.CSS && CSS.escape ? CSS.escape(projName) : projName) + '"]');
594
+ if (!chip) return;
595
+ chip.style.display = (sel.value === 'live') ? '' : 'none';
596
+ });
597
+ });
566
598
  }
567
599
 
568
600
  async function initRuntimeFleetUI(engineCfg, agentsCfg) {
@@ -939,9 +971,16 @@ async function saveSettings() {
939
971
  // Projects. Empty string = clear the override; the field stays optional.
940
972
  const mainBranchInput = document.getElementById('set-mainBranch-' + p.name);
941
973
  const mainBranchValue = mainBranchInput ? mainBranchInput.value.trim() : (p.mainBranch || '');
974
+ // P-a3f9b207 — per-project worktreeMode. Normalize to 'isolated' for
975
+ // any value other than 'live' so a stale DOM never POSTs garbage; the
976
+ // server-side validator (shared.validateWorktreeMode) is the
977
+ // authoritative gate for unknown values.
978
+ const wtModeInput = document.getElementById('set-worktreeMode-' + p.name);
979
+ const wtModeValue = (wtModeInput && wtModeInput.value === 'live') ? 'live' : 'isolated';
942
980
  return {
943
981
  name: p.name,
944
982
  mainBranch: mainBranchValue || null,
983
+ worktreeMode: wtModeValue,
945
984
  workSources: {
946
985
  pullRequests: { enabled: document.getElementById('set-ws-prs-' + p.name)?.checked ?? true },
947
986
  workItems: { enabled: document.getElementById('set-ws-wi-' + p.name)?.checked ?? true }
@@ -28,10 +28,17 @@
28
28
  (template-baked into slim/layout.html), so we duplicate the
29
29
  token VALUES here verbatim — see dashboard/docs/typography.md.
30
30
  A tripwire test asserts the slim and main tokens stay in
31
- sync (test/unit/dashboard-font-size-tokens.test.js). */
32
- --text-xs: 10px; --text-sm: 11px; --text-base: 12px;
33
- --text-md: 13px; --text-lg: 14px; --text-xl: 16px; --text-2xl: 18px;
34
- --text-stat: 22px; --text-stat-lg: 28px; --text-display: 32px;
31
+ sync (test/unit/dashboard-font-size-tokens.test.js).
32
+
33
+ W-mq71mqtx00060fa9 the primitive DECLARATIONS stay byte-identical
34
+ to main (`calc(Npx * var(--minions-text-scale))`), but slim pins
35
+ --minions-text-scale to 1 while main bumps it to 1.05. That keeps
36
+ the slim UI at its current text size — the global bump is excluded
37
+ here by design. Do NOT raise this value to match main. */
38
+ --minions-text-scale: 1;
39
+ --text-xs: calc(10px * var(--minions-text-scale)); --text-sm: calc(11px * var(--minions-text-scale)); --text-base: calc(12px * var(--minions-text-scale));
40
+ --text-md: calc(13px * var(--minions-text-scale)); --text-lg: calc(14px * var(--minions-text-scale)); --text-xl: calc(16px * var(--minions-text-scale)); --text-2xl: calc(18px * var(--minions-text-scale));
41
+ --text-stat: calc(22px * var(--minions-text-scale)); --text-stat-lg: calc(28px * var(--minions-text-scale)); --text-display: calc(32px * var(--minions-text-scale));
35
42
  --text-role-display: var(--text-display);
36
43
  --text-heading: var(--text-2xl);
37
44
  --text-subheading: var(--text-xl);
@@ -15,13 +15,22 @@
15
15
  --space-1: 2px; --space-2: 4px; --space-3: 6px; --space-4: 8px;
16
16
  --space-5: 10px; --space-6: 12px; --space-7: 16px; --space-8: 20px; --space-9: 24px;
17
17
 
18
- /* Typography — size primitives (raw px). Source of truth for the
18
+ /* Typography — size primitives. Source of truth for the
19
19
  ~700 callsites migrated in PR #66; do NOT introduce new raw `Npx`
20
20
  font-sizes outside this block. Tripwire:
21
- test/unit/dashboard-font-size-tokens.test.js */
22
- --text-xs: 10px; --text-sm: 11px; --text-base: 12px;
23
- --text-md: 13px; --text-lg: 14px; --text-xl: 16px; --text-2xl: 18px;
24
- --text-stat: 22px; --text-stat-lg: 28px; --text-display: 32px;
21
+ test/unit/dashboard-font-size-tokens.test.js
22
+
23
+ W-mq71mqtx00060fa9 every primitive is scaled by --minions-text-scale
24
+ so the whole dashboard's real text size can be nudged from one knob
25
+ (currently +5%, a slight bump). This is an actual font-size change,
26
+ not a zoom. The base px values below stay the source of truth and
27
+ MUST match dashboard/slim/styles.css verbatim — slim multiplies by
28
+ its own (un-scaled) factor of 1, so the slim UI is excluded from the
29
+ bump by construction. */
30
+ --minions-text-scale: 1.05;
31
+ --text-xs: calc(10px * var(--minions-text-scale)); --text-sm: calc(11px * var(--minions-text-scale)); --text-base: calc(12px * var(--minions-text-scale));
32
+ --text-md: calc(13px * var(--minions-text-scale)); --text-lg: calc(14px * var(--minions-text-scale)); --text-xl: calc(16px * var(--minions-text-scale)); --text-2xl: calc(18px * var(--minions-text-scale));
33
+ --text-stat: calc(22px * var(--minions-text-scale)); --text-stat-lg: calc(28px * var(--minions-text-scale)); --text-display: calc(32px * var(--minions-text-scale));
25
34
 
26
35
  /* Typography — role aliases (W-mq1c8og40003a7cf). Prefer these for
27
36
  NEW code: the role names survive a size-scale redesign while the
package/dashboard.js CHANGED
@@ -215,6 +215,17 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
215
215
  } else {
216
216
  delete currentProject.mainBranch;
217
217
  }
218
+ // P-a3f9b208 — mirror worktreeMode the same way: empty / unset on
219
+ // candidate clears the field so the engine falls back to "isolated".
220
+ // Without this branch the validated POST-body update (mutated on
221
+ // `candidate` in handleSettingsUpdate) is silently dropped on the way
222
+ // through mergeSettingsConfigUpdate → mutateDashboardConfig and never
223
+ // reaches disk — the endpoint would return 200 but persist nothing.
224
+ if (Object.prototype.hasOwnProperty.call(candidateProject, 'worktreeMode')) {
225
+ currentProject.worktreeMode = candidateProject.worktreeMode;
226
+ } else {
227
+ delete currentProject.worktreeMode;
228
+ }
218
229
  }
219
230
  }
220
231
  shared.pruneDefaultClaudeConfig(current);
@@ -7908,6 +7919,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7908
7919
  repoName: detected.repoName || name,
7909
7920
  mainBranch: detected.mainBranch || 'main',
7910
7921
  prUrlBase: detected.prUrlBase,
7922
+ // P-a3f9b201: thread worktreeMode from POST body. buildProjectEntry
7923
+ // validates via shared.validateWorktreeMode — unknown values bubble
7924
+ // up as HTTP 400 below.
7925
+ worktreeMode: body.worktreeMode,
7911
7926
  });
7912
7927
 
7913
7928
  // Create centralized project state files.
@@ -9380,6 +9395,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9380
9395
  name: p.name,
9381
9396
  localPath: p.localPath || null,
9382
9397
  mainBranch: p.mainBranch || null,
9398
+ // P-a3f9b207 — surface worktreeMode so the Settings UI can pre-fill
9399
+ // the per-project dropdown. null === absent (engine defaults to
9400
+ // 'isolated' downstream); 'live' opts into the live-checkout path.
9401
+ worktreeMode: p.worktreeMode || null,
9383
9402
  workSources: {
9384
9403
  pullRequests: { enabled: p.workSources?.pullRequests?.enabled !== false, cooldownMinutes: p.workSources?.pullRequests?.cooldownMinutes ?? 30 },
9385
9404
  workItems: { enabled: p.workSources?.workItems?.enabled !== false, cooldownMinutes: p.workSources?.workItems?.cooldownMinutes ?? 0 }
@@ -9747,6 +9766,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9747
9766
  if (raw) proj.mainBranch = raw;
9748
9767
  else delete proj.mainBranch;
9749
9768
  }
9769
+ // P-a3f9b201 — per-project worktreeMode enum ('isolated' default,
9770
+ // 'live' for repos where worktrees are unworkable). Empty string /
9771
+ // null clears the override (engine falls back to 'isolated'); any
9772
+ // other value flows through shared.validateWorktreeMode which throws
9773
+ // HTTP 400 on unknown values. Errors propagate to the outer catch
9774
+ // and are returned via e.statusCode/e.message.
9775
+ if (Object.prototype.hasOwnProperty.call(update, 'worktreeMode')) {
9776
+ const raw = update.worktreeMode;
9777
+ if (raw === '' || raw === null) {
9778
+ delete proj.worktreeMode;
9779
+ } else {
9780
+ const validated = shared.validateWorktreeMode(raw);
9781
+ if (validated === undefined) delete proj.worktreeMode;
9782
+ else proj.worktreeMode = validated;
9783
+ }
9784
+ }
9750
9785
  }
9751
9786
  }
9752
9787
 
@@ -0,0 +1,143 @@
1
+ # Live-checkout dispatch mode
2
+
3
+ > Per-project opt-in (`project.worktreeMode: 'live'`) that runs Minions agents directly inside the operator's project checkout instead of an engine-managed git worktree.
4
+ >
5
+ > Plan: `plan-w-mq5rmtt9000a42f9-2026-06-08`. PRD: `prd/minions-opg-2026-06-10.json` (items `P-a3f9b201` … `P-a3f9b209`). Default for every project remains `'isolated'`; the rest of this doc only applies when a project flips itself to `'live'`.
6
+
7
+ ## Motivation
8
+
9
+ The default `isolated` mode spawns each dispatch inside its own git worktree under `../worktrees/`. That works for almost every repo, but it falls over when:
10
+
11
+ - **`repo`-managed multi-project trees** (Android AOSP / Office mobile / Chromium) where the manifest pins dozens of nested repos and `git worktree add` either errors out or strands sub-projects.
12
+ - **Submodule-heavy repos** where worktrees skip `.gitmodules` configuration or leave submodules pointing at the wrong SHA.
13
+ - **Native build state** that lives next to the source (Gradle caches, Xcode `DerivedData`, CMake `build/`, sccache, `.cargo/`, Bazel `bazel-*` symlinks). A fresh worktree throws all of it away and the next build re-downloads / re-compiles.
14
+ - **Deep Windows paths** that hit MAX_PATH (260 chars) once `../worktrees/dispatch-<long-id>-<slug>/<repo-name>/<deep/nested/path>` is appended.
15
+ - **Sparse clones with custom hooks** (`.git/hooks/*`, `git lfs install --local`, `git config --local`) that the operator has set up by hand and that a fresh worktree does not inherit.
16
+
17
+ For these repos, the operator usually already has one canonical checkout that builds correctly. Live-checkout mode lets the agent dispatch into that checkout instead of cloning a side-by-side copy.
18
+
19
+ ## Contract (four guarantees)
20
+
21
+ Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has four guarantees; the engine enforces all four and fails dispatches that would violate them.
22
+
23
+ ### 1. Per-project mutating-concurrency cap of 1
24
+
25
+ Only one mutating dispatch (fix / implement / review / test / verify / decompose / docs) runs per live-mode project at a time. Read-only types (meeting / ask / explore / plan / plan-to-prd) are excluded from the cap because they never write to disk.
26
+
27
+ Implementation: `engine.js` builds a `liveProjectsInUse` set from the active dispatch list at every allocation pass and per-tick re-annotation; pending items whose project is already in the set are skipped with a `skipReason` and re-tried on the next tick. (`engine.js:7198`, `engine.js:7460`.)
28
+
29
+ ### 2. Refuse-on-dirty (engine never resets the operator tree)
30
+
31
+ Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status --porcelain` from `project.localPath`. Any output (staged, unstaged, untracked) refuses the dispatch immediately:
32
+
33
+ - Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically).
34
+ - Inbox alert written via `dispatch.writeInboxAlert('live-checkout-dirty-<wi-id>', body)`. The body lists the dirty files verbatim from `git status --porcelain`.
35
+ - Work item stamped with `_pendingReason: 'live_checkout_dirty'` so the dashboard surfaces the block.
36
+ - Completion summary: `live-checkout refused: N dirty file(s) in <localPath>`.
37
+
38
+ The engine never calls `git reset --hard`, `git clean -fd`, `git stash`, or any other state-mutating command against the operator's checkout — not at spawn, not at cleanup, not on timeout, not on engine restart. Cleanup paths (`worktreePool.returnToPool`, `worktree-gc.gcDispatchWorktreeIfOrphan`, `_quarantineDirtyWorktree`) are naturally no-ops because `worktreePath` stays `null` end-to-end (`engine.js:1219`).
39
+
40
+ ### 3. No auto-pull, no `--force`, no fast-forward
41
+
42
+ After the clean-tree check, `prepareLiveCheckout`:
43
+
44
+ 1. Best-effort `git fetch origin <mainRef>` — failure is logged at `warn` and execution continues so offline operators can resume work on an already-checked-out branch.
45
+ 2. `git rev-parse --verify <branchName>`:
46
+ - Succeeds → branch exists locally → plain `git checkout <branchName>` (no `--force`, no `-B`, no pull, no reset). The operator owns the checkout state; if they have local commits or a stale tip relative to `origin/<branchName>`, those are preserved.
47
+ - Fails → `git checkout -b <branchName> origin/<mainRef>` (new branch, tracks `origin/<mainRef>` as its starting point).
48
+
49
+ PR-source live dispatches (`meta.branch` set) and shared-branch live dispatches (`meta.branchStrategy === 'shared-branch'`) flow through the same code path with no special case. If the operator already has a PR branch checked out at a different SHA than `origin/<branch>`, the engine will not fast-forward it — that is the intended contract (see Open Q5 in the source PRD). Resolve manually with `git pull --ff-only` or commit the local divergence first.
50
+
51
+ ### 4. No worktree pool, no per-WI subdirectory isolation
52
+
53
+ Live mode shares one checkout per project. There is no pool to recycle, no quarantine directory, no per-WI subdirectory under the project root. The mutating-concurrency cap (Guarantee 1) is the only isolation mechanism: agents take turns in the same directory.
54
+
55
+ Pool short-circuits live in `engine.js:1368` and `engine/cleanup.js`; both gate on `worktreePath` truthiness and `!liveMode`, so the borrow / return / orphan-GC paths execute zero git commands when the project is live.
56
+
57
+ ## Operator workflow
58
+
59
+ ### Enabling live mode (dashboard)
60
+
61
+ 1. Open the Minions dashboard → **Settings** → **Projects** → expand the target project.
62
+ 2. Set **Worktree mode** → **Live checkout**. A yellow warning chip appears immediately:
63
+ > ⚠ Live mode: dispatches run directly in this repo's checkout. Only one mutating dispatch runs at a time. Dirty working trees block dispatch — commit or stash before running.
64
+ 3. Click **Save**. The dashboard POSTs the change through `mergeSettingsConfigUpdate`; `shared.validateWorktreeMode` rejects anything other than `'isolated'` or `'live'` with HTTP 400.
65
+
66
+ ### Enabling live mode (config.json)
67
+
68
+ ```jsonc
69
+ {
70
+ "projects": [{
71
+ "name": "android-aosp",
72
+ "localPath": "/home/yemi/aosp",
73
+ "worktreeMode": "live",
74
+ // …
75
+ }]
76
+ }
77
+ ```
78
+
79
+ Absent / `null` / `''` reads as `'isolated'` (the default) — explicit is preferred.
80
+
81
+ ### Recovering from `live_checkout_dirty` refusal
82
+
83
+ When dispatch is blocked by a dirty tree, the dashboard shows the work item as pending with `_pendingReason: 'live_checkout_dirty'` and an inbox alert lists the dirty files. From the project checkout:
84
+
85
+ ```bash
86
+ # Option A — preserve work for later
87
+ git stash push --include-untracked --message "minions: pre-dispatch stash"
88
+
89
+ # Option B — commit the changes (preferred if they are intentional)
90
+ git add -A && git commit -m "wip: hand-off to minions"
91
+
92
+ # Option C — discard the changes
93
+ git checkout -- .
94
+ git clean -fd
95
+ ```
96
+
97
+ Then re-dispatch. The next tick will re-pick the pending work item; nothing else needs clearing on the engine side (the inbox alert is informational only).
98
+
99
+ ### Branch-leak mitigation
100
+
101
+ Live mode never deletes branches it creates. Over time the project checkout accumulates `work/W-…` and `user/<login>/<wi>-<slug>` branches. Periodically prune merged branches:
102
+
103
+ ```bash
104
+ # Delete all local branches already merged into the current branch (typically main).
105
+ git checkout main && git pull --ff-only
106
+ git branch --merged | grep -E '^\s+(work/|user/)' | xargs -r git branch -d
107
+
108
+ # More aggressive (force-delete branches whose tip is gone from the remote):
109
+ git fetch --prune origin
110
+ git for-each-ref --format '%(refname:short) %(upstream:track)' refs/heads \
111
+ | awk '$2 == "[gone]" {print $1}' \
112
+ | xargs -r git branch -D
113
+ ```
114
+
115
+ The engine has no opinion about local branches; this hygiene is the operator's responsibility in live mode.
116
+
117
+ ## Non-goals
118
+
119
+ Live-checkout mode is deliberately small. These are NOT supported and will not be added:
120
+
121
+ - **No `auto` mode.** The choice between `isolated` and `live` is per-project and operator-set. The engine will not auto-detect submodules / `repo` workspaces and silently switch modes.
122
+ - **No auto-stash on dirty refusal.** The engine refuses and exits; it never `git stash`es to "make room" for a dispatch. Stashes silently mutate the operator's tree and conflate engine state with operator state.
123
+ - **No concurrent dispatches per project.** The cap is 1; raising it would require per-WI subdirectories, which live mode explicitly does not provide.
124
+ - **No per-WI subdirectory isolation.** Live mode is one-checkout-per-project by design. If you need isolation, use `worktreeMode: 'isolated'` (the default).
125
+ - **No per-WI override.** `worktreeMode` is per-project only. There is no `meta.worktreeMode` on a work item that overrides the project setting.
126
+ - **No auto-pull / no fast-forward on existing branches.** See Guarantee 3. If a PR branch is checked out locally at a different SHA than `origin/<branch>`, the operator resolves it manually.
127
+ - **No special timeout / kill handling.** Live-mode dispatches are killed by PID exactly like isolated-mode dispatches (`engine/timeout.js` header comment). The engine sends SIGTERM/SIGKILL to the tracked process and never touches the working tree on kill.
128
+
129
+ ## Related code
130
+
131
+ | File | Purpose |
132
+ |---|---|
133
+ | `engine/shared.js` — `WORKTREE_MODES`, `validateWorktreeMode` | Enum + validator (P-a3f9b201). |
134
+ | `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
135
+ | `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, best-effort fetch, branch resolution (P-a3f9b203). |
136
+ | `engine.js` — `spawnAgent` live-mode block | Calls `prepareLiveCheckout`, handles dirty / throw branches, gates `git worktree add` on `!liveMode` (P-a3f9b204). |
137
+ | `engine.js` — dispatcher `liveProjectsInUse` set | Per-project mutating-concurrency cap (P-a3f9b205). |
138
+ | `engine.js` — worktree-pool / orphan-GC short-circuits | `worktreePath===null` no-ops in live mode (P-a3f9b206). |
139
+ | `dashboard/js/settings.js` — worktreeMode dropdown + chip | Operator-facing UI (P-a3f9b207). |
140
+ | `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring}.test.js` | Wiring and contract tests (P-a3f9b208). |
141
+ | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` | Non-retryable refusal class. |
142
+ | `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY` from mechanical retry. |
143
+ | `engine/timeout.js` header comment | Confirms no special live-mode kill handling. |
package/engine/cleanup.js CHANGED
@@ -567,6 +567,12 @@ async function runCleanup(config, verbose = false) {
567
567
  // change to both locations, producing mirror-write leaks.
568
568
  // We only WARN here — removing someone else's worktree without consent could
569
569
  // destroy in-flight work. The operator runs `git worktree remove <path>`.
570
+ // P-a3f9b206: live-mode projects (worktreeMode === 'live') intentionally
571
+ // never create linked worktrees — `git worktree list` returns only the
572
+ // operator's main checkout, which `shared.isPathInside` correctly excludes
573
+ // (equal-path is not "inside"). An empty/main-only nested-worktree scan
574
+ // result is by-design for live mode; do not "fix" it by adding the main
575
+ // worktree to the warnings.
570
576
  cleaned.nestedWorktrees = 0;
571
577
  const _scannedRoots = new Set(); // dedup projects sharing localPath
572
578
  for (const project of projects) {
@@ -430,6 +430,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
430
430
  FAILURE_CLASS.INVALID_MANAGED_SPAWN, // W-mpbhxg3b000u8411 — managed-spawn.json failed validation; re-running with the same wrong file won't fix it
431
431
  FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED, // W-mpbhxg3b000u8411 — healthcheck timed out; agent must fix the spec or the service it spawned
432
432
  FAILURE_CLASS.INJECTION_FLAGGED, // F5 (W-mpeklod3000we69c) — agent spotted a prompt-injection attempt in spliced untrusted content; a human must review the source before re-dispatch
433
+ FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, // P-a3f9b204 — live-checkout refused to spawn because operator localPath is dirty; mechanical retry won't fix it (operator must commit/stash/discard)
433
434
  ]);
434
435
  if (neverRetry.has(failureClass)) return false;
435
436
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * engine/live-checkout.js — P-a3f9b203
3
+ *
4
+ * Pure helper for the live-checkout dispatch mode (plan-w-mq5rmtt9000a42f9-2026-06-08).
5
+ *
6
+ * `prepareLiveCheckout({ localPath, branchName, mainRef, gitOpts, dispatchId, wiId, log, _git })`
7
+ *
8
+ * Lifecycle (called by spawnAgent in engine.js when resolveSpawnPaths returns liveMode:true):
9
+ * 1. `git status --porcelain` from localPath. Non-empty output → bail with
10
+ * { ok:false, reason:'dirty', dirtyFiles:[…] } so spawnAgent can fail the
11
+ * dispatch non-retryably and write an inbox alert. NO mutating git calls
12
+ * after this point if dirty.
13
+ * 2. `git fetch origin <mainRef>` — BEST-EFFORT. Failure is logged via the
14
+ * injected `log` (severity 'warn') and execution continues. Offline
15
+ * operators can still resume work on a previously-checked-out branch.
16
+ * 3. Branch resolution:
17
+ * a. `git rev-parse --verify <branchName>` succeeds → branch exists
18
+ * locally → `git checkout <branchName>` (NO --force, NO auto-pull,
19
+ * NO fast-forward, NO reset). The operator owns the checkout
20
+ * state per the live-checkout contract; the engine never
21
+ * overwrites local commits.
22
+ * b. rev-parse fails → branch is new → `git checkout -b <branchName>
23
+ * origin/<mainRef>` (tracks remote main as upstream-of-creation).
24
+ * 4. Returns { ok:true, branch, created:boolean }.
25
+ *
26
+ * PURE — does NOT call completeDispatch, does NOT write inbox alerts. That
27
+ * translation belongs in spawnAgent (engine.js). Keeping this helper free of
28
+ * side effects lets unit tests exercise every branch with a mocked git
29
+ * runner and zero filesystem touching.
30
+ *
31
+ * All git invocations route through `shared.shellSafeGit` (argv-form,
32
+ * shell:false, execFile). Branch and main-ref are validated via
33
+ * `shared.validateGitRef` before any argv is constructed, so a maliciously
34
+ * crafted branch name like "--evil-flag" is rejected before reaching git.
35
+ *
36
+ * Tests inject a mock runner via the private `_git` option (signature
37
+ * `(args:string[], opts:{cwd, gitExtraArgs?:string[]}) => Promise<string>`).
38
+ * Production callers omit `_git` and the helper falls back to
39
+ * `require('./shared').shellSafeGit`.
40
+ */
41
+
42
+ 'use strict';
43
+
44
+ const shared = require('./shared');
45
+
46
+ async function prepareLiveCheckout(opts = {}) {
47
+ const {
48
+ localPath,
49
+ branchName,
50
+ mainRef,
51
+ gitOpts,
52
+ dispatchId, // accepted for caller bookkeeping; not used by the helper
53
+ wiId, // accepted for caller bookkeeping; not used by the helper
54
+ log,
55
+ _git, // private injection for testing — defaults to shared.shellSafeGit
56
+ } = opts;
57
+
58
+ // ── Required-arg guards. Throw rather than return {ok:false} so a
59
+ // caller's missing wiring fails loudly at the call site.
60
+ if (!localPath || typeof localPath !== 'string') {
61
+ throw new Error('prepareLiveCheckout: localPath is required (got ' + JSON.stringify(localPath) + ')');
62
+ }
63
+ if (!branchName || typeof branchName !== 'string') {
64
+ throw new Error('prepareLiveCheckout: branchName is required (got ' + JSON.stringify(branchName) + ')');
65
+ }
66
+ if (!mainRef || typeof mainRef !== 'string') {
67
+ throw new Error('prepareLiveCheckout: mainRef is required (got ' + JSON.stringify(mainRef) + ')');
68
+ }
69
+
70
+ // ── Validate refs before they hit git argv (defense in depth).
71
+ // shellSafeGit uses execFile (shell:false) so metacharacters are inert,
72
+ // but a leading-dash ref like "--evil" would still be parsed as a git
73
+ // flag. validateGitRef throws on those cases (and on other rev-spec
74
+ // quirks). Wrap so the caller-facing message mentions the field name.
75
+ try { shared.validateGitRef(branchName); }
76
+ catch (e) { throw new Error('prepareLiveCheckout: invalid branchName ref — ' + e.message); }
77
+ try { shared.validateGitRef(mainRef); }
78
+ catch (e) { throw new Error('prepareLiveCheckout: invalid mainRef — ' + e.message); }
79
+
80
+ const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
81
+ const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
82
+
83
+ // Small log helper that's safe even when the caller didn't pass `log`.
84
+ const warn = (msg) => {
85
+ if (typeof log !== 'function') return;
86
+ try { log(msg, 'warn'); } catch { /* never let logging crash the helper */ }
87
+ };
88
+
89
+ // ── Step 1: git status --porcelain. Bail early on dirty tree. ─────────
90
+ // Porcelain format begins each line with a two-char XY status code (e.g.
91
+ // " M file.js", "?? new.js", "MM stage+unstage.js"). The leading space on
92
+ // unstaged-only lines is SIGNIFICANT, so we split first and strip ONLY
93
+ // trailing whitespace/CR per line — outer-trimming the whole blob would
94
+ // eat the leading XY space on the first line.
95
+ const statusRaw = await git(['status', '--porcelain'], baseOpts);
96
+ const statusStr = typeof statusRaw === 'string' ? statusRaw : '';
97
+ const dirtyFiles = statusStr
98
+ .split(/\r?\n/)
99
+ .map((line) => line.replace(/\s+$/, ''))
100
+ .filter((line) => line.length > 0);
101
+ if (dirtyFiles.length > 0) {
102
+ return { ok: false, reason: 'dirty', dirtyFiles };
103
+ }
104
+
105
+ // ── Step 2: best-effort fetch of mainRef. Log + continue on failure. ──
106
+ try {
107
+ await git(['fetch', 'origin', mainRef], baseOpts);
108
+ } catch (e) {
109
+ const reason = (e && e.message) ? e.message : String(e);
110
+ warn('prepareLiveCheckout: git fetch origin ' + mainRef + ' failed (continuing): ' + reason);
111
+ }
112
+
113
+ // ── Step 3: branch resolution + checkout. ─────────────────────────────
114
+ let branchExists = false;
115
+ try {
116
+ await git(['rev-parse', '--verify', branchName], baseOpts);
117
+ branchExists = true;
118
+ } catch {
119
+ branchExists = false;
120
+ }
121
+
122
+ if (branchExists) {
123
+ // Plain checkout — NO --force, NO -B, NO pull. Operator owns local commits.
124
+ await git(['checkout', branchName], baseOpts);
125
+ return { ok: true, branch: branchName, created: false };
126
+ }
127
+
128
+ // New branch — create off of remote main.
129
+ await git(['checkout', '-b', branchName, 'origin/' + mainRef], baseOpts);
130
+ return { ok: true, branch: branchName, created: true };
131
+ }
132
+
133
+ module.exports = { prepareLiveCheckout };
@@ -479,14 +479,14 @@ function _checkBypassFlagSupported(runtimeName, adapter, helpText) {
479
479
  return {
480
480
  name,
481
481
  ok: 'warn',
482
- message: `adapter did not declare permissionBypassFlags — cannot verify ${runtimeName} CLI accepts headless bypass`,
482
+ message: `adapter did not declare permissionBypassFlags — cannot verify ${runtimeName} CLI accepts headless bypass. See docs/runtime-adapters.md.`,
483
483
  };
484
484
  }
485
485
  if (helpText == null || helpText === '') {
486
486
  return {
487
487
  name,
488
488
  ok: 'warn',
489
- message: `could not invoke ${runtimeName} --help to verify ${flags.join(' ')} support — Minions will still pass the flag(s) but you may see permission prompts if the CLI doesn't accept them`,
489
+ message: `could not invoke ${runtimeName} --help to verify ${flags.join(' ')} support — Minions will still pass the flag(s) but you may see permission prompts if the CLI doesn't accept them. See docs/runtime-adapters.md.`,
490
490
  };
491
491
  }
492
492
  const missing = flags.filter(f => !helpText.includes(f));
@@ -494,7 +494,7 @@ function _checkBypassFlagSupported(runtimeName, adapter, helpText) {
494
494
  return {
495
495
  name,
496
496
  ok: 'warn',
497
- message: `${runtimeName} --help does not list expected flag(s): ${missing.join(', ')} — your CLI may be outdated. Agents will hang on permission prompts. Update with the CLI's package manager (npm i -g @anthropic-ai/claude-code for Claude; winget upgrade Microsoft.CopilotCLI for Copilot)`,
497
+ message: `${runtimeName} --help does not list expected flag(s): ${missing.join(', ')} — your CLI may be outdated. Agents will hang on permission prompts. Update with the CLI's package manager (npm i -g @anthropic-ai/claude-code for Claude; winget upgrade Microsoft.CopilotCLI for Copilot). See docs/runtime-adapters.md.`,
498
498
  };
499
499
  }
500
500
  return {
@@ -5,6 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { execFileSync: defaultExecFileSync } = require('child_process');
8
+ const shared = require('./shared');
8
9
 
9
10
  function decodeUrlSegment(segment) {
10
11
  try { return decodeURIComponent(String(segment || '')); } catch { return String(segment || ''); }
@@ -340,11 +341,16 @@ function buildPrUrlBase({ repoHost, org, project, repoName, prUrlBase }) {
340
341
  return '';
341
342
  }
342
343
 
343
- function buildProjectEntry({ name, description, localPath, repoHost, repositoryId, org, project, repoName, mainBranch, prUrlBase }) {
344
+ function buildProjectEntry({ name, description, localPath, repoHost, repositoryId, org, project, repoName, mainBranch, prUrlBase, worktreeMode }) {
344
345
  const safeName = (name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'project';
345
346
  const host = repoHost || 'github';
346
347
  const isAdo = host === 'ado';
347
- return {
348
+ // P-a3f9b201: validateWorktreeMode returns undefined on absent/empty (so the
349
+ // field is omitted and resolves to 'isolated' downstream) and throws an HTTP
350
+ // 400 Error on unknown values. Run validation BEFORE building the entry so a
351
+ // typo at the dashboard or CLI never lands in config.json.
352
+ const resolvedWorktreeMode = shared.validateWorktreeMode(worktreeMode);
353
+ const entry = {
348
354
  name: safeName,
349
355
  description: description || '',
350
356
  localPath: (localPath || '').replace(/\\/g, '/'),
@@ -361,6 +367,8 @@ function buildProjectEntry({ name, description, localPath, repoHost, repositoryI
361
367
  workItems: { enabled: true, cooldownMinutes: 0 },
362
368
  },
363
369
  };
370
+ if (resolvedWorktreeMode !== undefined) entry.worktreeMode = resolvedWorktreeMode;
371
+ return entry;
364
372
  }
365
373
 
366
374
  function buildScanResult(repoPath, detected = {}, linked = false) {
package/engine/shared.js CHANGED
@@ -2281,6 +2281,29 @@ function classifyInboxItem(name, content) {
2281
2281
  return 'project-notes';
2282
2282
  }
2283
2283
 
2284
+ // ── Worktree Mode Enum (P-a3f9b201) ─────────────────────────────────────────
2285
+ // Per-project switch between the default `isolated` mode (engine creates a
2286
+ // dedicated worktree per dispatch under ../worktrees) and `live` mode (the
2287
+ // agent runs directly in the operator's working checkout — used for repos
2288
+ // where git worktrees are unworkable, e.g. submodules, hooks, large binary
2289
+ // caches). Default is `isolated`; absent/undefined `worktreeMode` on a
2290
+ // project entry MUST read as 'isolated' everywhere downstream. Unknown
2291
+ // values are rejected at the validators — never silently coerced — so a
2292
+ // typo in the dashboard or in config.json cannot wedge dispatch into an
2293
+ // unknown mode.
2294
+ const WORKTREE_MODES = Object.freeze({ ISOLATED: 'isolated', LIVE: 'live' });
2295
+
2296
+ function validateWorktreeMode(value) {
2297
+ if (value === undefined || value === null || value === '') return undefined;
2298
+ if (typeof value !== 'string') {
2299
+ throw _httpError(400, `Invalid worktreeMode: must be a string (got ${typeof value}). Accepted values: 'isolated', 'live'.`);
2300
+ }
2301
+ if (value !== WORKTREE_MODES.ISOLATED && value !== WORKTREE_MODES.LIVE) {
2302
+ throw _httpError(400, `Invalid worktreeMode: "${value}". Accepted values: 'isolated' (default), 'live'.`);
2303
+ }
2304
+ return value;
2305
+ }
2306
+
2284
2307
  // ── Engine Defaults ─────────────────────────────────────────────────────────
2285
2308
  // Single source of truth for engine configuration defaults.
2286
2309
  // Used by: engine.js, minions.js (init). config.template.json only has the project schema.
@@ -3724,6 +3747,7 @@ const FAILURE_CLASS = {
3724
3747
  INVALID_MANAGED_SPAWN: 'invalid-managed-spawn', // P-7a3b1c92: agents/<id>/managed-spawn.json failed validator (bad schema, broken workdir, executable/env not on allowlist, healthcheck shape wrong). Engine refuses to spawn any spec — agent must fix file; never retryable as-is.
3725
3748
  MANAGED_SPAWN_HEALTHCHECK_FAILED: 'managed-spawn-healthcheck-failed', // P-7a3b1c92: at least one managed-spawn spec was spawned but failed its healthcheck within timeout_s. Engine killed the failing PIDs; siblings stay alive. Dispatch ERROR with the failing spec name + log tail surfaced in the inbox alert.
3726
3749
  INJECTION_FLAGGED: 'injection-flagged', // F5 (W-mpeklod3000we69c): the agent set `securityFlags.injectionAttempt:true` in its completion report after spotting a prompt-injection attempt inside an <UNTRUSTED-INPUT> fence. Engine writes a security inbox note + stamps `_securityFlag` on the WI and treats the dispatch as non-retryable so a human can review the source before the agent re-runs.
3750
+ LIVE_CHECKOUT_DIRTY: 'live-checkout-dirty', // P-a3f9b204 (live-checkout dispatch mode): spawnAgent ran prepareLiveCheckout against project.localPath and `git status --porcelain` reported uncommitted changes. Engine refused to spawn (it never runs `git reset`/`git clean` against the operator tree). Inbox alert lists dirty files; WI is stamped `_pendingReason: 'live_checkout_dirty'`. Non-retryable — operator must commit/stash/discard before re-dispatch.
3727
3751
  MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
3728
3752
  WORKSPACE_MANIFEST_REPO: 'workspace-manifest-repo-forbidden', // W-mq07avbk000m5543: dispatch routed an agent to a project/repo not present in its workspace_manifest.allowed_repos. Structural — never retryable until the manifest is widened or a different agent is chosen.
3729
3753
  WORKSPACE_MANIFEST_TOOL: 'workspace-manifest-tool-forbidden', // W-mq07avbk000m5543: out-of-scope tool call (manifest enforcement at the runtime gate). Non-retryable as-is.
@@ -4948,17 +4972,50 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
4948
4972
  * field for read-only stages. Only code-mutating pipeline stages need a
4949
4973
  * worktree, and they take the normal code-mutating path below.
4950
4974
  *
4951
- * @param {{ localPath?: string|null }|null|undefined} project
4975
+ * **Live-checkout mode (P-a3f9b202).** When `project.worktreeMode === 'live'`
4976
+ * the resolver short-circuits BEFORE both branches below and returns
4977
+ * `{ cwd: <abs localPath>, worktreeRootDir: null, liveMode: true }` for ALL
4978
+ * task types — read-only and code-mutating alike. The agent runs directly
4979
+ * in the operator's working checkout (no `git worktree add`), used for
4980
+ * repos where worktrees are unworkable. Throws
4981
+ * `LIVE_CHECKOUT_NO_LOCALPATH` if `localPath` is missing/falsy — live mode
4982
+ * has no fallback because there is no neutral location to dispatch into.
4983
+ *
4984
+ * @param {{ localPath?: string|null, worktreeMode?: string|null }|null|undefined} project
4952
4985
  * @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
4953
- * @param {string} minionsDir — MINIONS_DIR fallback anchor
4954
- * @returns {{ cwd: string|null, worktreeRootDir: string|null }}
4955
- * - For read-only types: { cwd: <project dir or MINIONS_DIR>, worktreeRootDir: null }
4956
- * - For code-mutating types: { cwd: null, worktreeRootDir: <project root> }
4986
+ * @param {string} minionsDir — MINIONS_DIR fallback anchor (ignored in live mode)
4987
+ * @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean }}
4988
+ * - For live mode (any type): { cwd: <abs localPath>, worktreeRootDir: null, liveMode: true }
4989
+ * - For isolated read-only types: { cwd: <project dir or MINIONS_DIR>, worktreeRootDir: null }
4990
+ * - For isolated code-mutating types: { cwd: null, worktreeRootDir: <project root> }
4957
4991
  * (caller defaults cwd to worktreeRootDir before worktree creation)
4958
- * @throws {Error} WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT (code-mutating only)
4992
+ * The optional `liveMode` discriminator lets callers branch on one boolean
4993
+ * instead of re-reading `project.worktreeMode`.
4994
+ * @throws {Error} LIVE_CHECKOUT_NO_LOCALPATH (live mode, missing localPath),
4995
+ * WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT (isolated code-mutating),
4959
4996
  * or WORKTREE_ROOTDIR_MISSING_BASE if neither anchor present.
4960
4997
  */
4961
4998
  function resolveSpawnPaths(project, type, minionsDir) {
4999
+ // ── Live-checkout mode short-circuit (P-a3f9b202) ──────────────────────
5000
+ // Runs BEFORE the read-only / code-mutating split so live mode is the
5001
+ // single decision point regardless of task type — read-only tasks in
5002
+ // live mode still report liveMode:true so downstream callers don't have
5003
+ // to re-read project.worktreeMode to know they're running in-place.
5004
+ if (project?.worktreeMode === WORKTREE_MODES.LIVE) {
5005
+ if (!project.localPath) {
5006
+ const err = new Error(
5007
+ 'live-checkout mode requires project.localPath (worktreeMode === "live" but localPath is missing/falsy).'
5008
+ );
5009
+ err.code = 'LIVE_CHECKOUT_NO_LOCALPATH';
5010
+ throw err;
5011
+ }
5012
+ return {
5013
+ cwd: path.resolve(String(project.localPath)),
5014
+ worktreeRootDir: null,
5015
+ liveMode: true,
5016
+ };
5017
+ }
5018
+
4962
5019
  const isReadOnly = READ_ONLY_ROOT_TASK_TYPES.has(type);
4963
5020
  if (isReadOnly) {
4964
5021
  if (project?.localPath) return { cwd: path.resolve(String(project.localPath)), worktreeRootDir: null };
@@ -7558,6 +7615,8 @@ module.exports = {
7558
7615
  HAS_DANGEROUS_KEY_MAX_NODES,
7559
7616
  validateProjectName,
7560
7617
  validateProjectPath,
7618
+ WORKTREE_MODES,
7619
+ validateWorktreeMode,
7561
7620
  validatePid,
7562
7621
  PR_FIX_CAUSE,
7563
7622
  getPrFixAutomationCause,
package/engine/timeout.js CHANGED
@@ -1,5 +1,10 @@
1
1
  /**
2
2
  * engine/timeout.js — Runtime timeout, stale-orphan cleanup, steering, and idle checks.
3
+ *
4
+ * Live-checkout dispatches (project.worktreeMode === 'live') are killed by PID
5
+ * exactly like isolated-mode dispatches — no special handling, no in-place
6
+ * `git reset`/`git clean`, no working-tree cleanup. The engine only ever sends
7
+ * SIGTERM/SIGKILL to the tracked process; the operator owns the checkout.
3
8
  */
4
9
 
5
10
  const fs = require('fs');
@@ -79,11 +79,19 @@ function mutateWorktreePool(mutator) {
79
79
  */
80
80
  function getProjectPoolSize(projectName, config) {
81
81
  config = config || {};
82
+ let proj = null;
82
83
  if (projectName && Array.isArray(config.projects)) {
83
- const proj = config.projects.find(p => p && p.name === projectName);
84
- if (proj && Number.isFinite(Number(proj.worktreePoolSize))) {
85
- return Math.max(0, Math.floor(Number(proj.worktreePoolSize)));
86
- }
84
+ proj = config.projects.find(p => p && p.name === projectName) || null;
85
+ }
86
+ // P-a3f9b206: short-circuit for live-mode projects. The pool is meaningless
87
+ // when dispatches run in-place in the operator's local checkout — there is
88
+ // no "warm" worktree to recycle, and a non-zero pool size could trick the
89
+ // borrow path in spawnAgent into running for a project that no longer
90
+ // wants pooled worktrees. Beats both per-project worktreePoolSize and the
91
+ // engine-wide fleet default.
92
+ if (proj && proj.worktreeMode === shared.WORKTREE_MODES.LIVE) return 0;
93
+ if (proj && Number.isFinite(Number(proj.worktreePoolSize))) {
94
+ return Math.max(0, Math.floor(Number(proj.worktreePoolSize)));
87
95
  }
88
96
  const fleet = config.engine?.worktreePoolSize ?? ENGINE_DEFAULTS.worktreePoolSize;
89
97
  if (Number.isFinite(Number(fleet))) return Math.max(0, Math.floor(Number(fleet)));
package/engine.js CHANGED
@@ -1634,9 +1634,9 @@ async function spawnAgent(dispatchItem, config) {
1634
1634
  // stages now short-circuit alongside any other read-only WI (see the gate at
1635
1635
  // `if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type))` below).
1636
1636
  const _preBranchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
1637
- let cwd, worktreeRootDir;
1637
+ let cwd, worktreeRootDir, liveMode = false;
1638
1638
  try {
1639
- ({ cwd, worktreeRootDir } = shared.resolveSpawnPaths(project, type, MINIONS_DIR));
1639
+ ({ cwd, worktreeRootDir, liveMode = false } = shared.resolveSpawnPaths(project, type, MINIONS_DIR));
1640
1640
  } catch (rootErr) {
1641
1641
  if (rootErr?.code === 'WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT' || rootErr?.code === 'WORKTREE_ROOTDIR_MISSING_BASE') {
1642
1642
  log('error', `spawnAgent: project rootDir resolution failed for ${id}: ${rootErr.message}`);
@@ -1836,7 +1836,100 @@ async function spawnAgent(dispatchItem, config) {
1836
1836
  worktreePath = null;
1837
1837
  }
1838
1838
 
1839
- if (branchName) {
1839
+ // ── Live-checkout mode handling (P-a3f9b204) ─────────────────────────────
1840
+ // When project.worktreeMode === 'live', resolveSpawnPaths returned
1841
+ // cwd = project.localPath, worktreeRootDir = null, liveMode = true.
1842
+ // Instead of creating an engine-managed worktree we run prepareLiveCheckout
1843
+ // in-place: it validates a clean tree and then checks out (or creates) the
1844
+ // target branch. On dirty refusal we write an inbox alert, stamp
1845
+ // `_pendingReason: 'live_checkout_dirty'` on the WI, complete the dispatch
1846
+ // non-retryably with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, and return — the
1847
+ // engine never runs `git reset`/`git clean` against the operator tree.
1848
+ // On success we fall through; the worktree-creation block below is gated
1849
+ // on `!liveMode`, so it never runs, and downstream cleanup paths
1850
+ // (worktreePool.returnToPool, worktree-gc.gcDispatchWorktreeIfOrphan) are
1851
+ // natural no-ops because worktreePath stays null.
1852
+ // Read-only types short-circuited above (branchName was set to null) and
1853
+ // skip this block — the agent already runs read-only in cwd=localPath in
1854
+ // both isolated and live mode, so no in-place branch checkout is needed.
1855
+ if (liveMode && branchName && !READ_ONLY_ROOT_TASK_TYPES.has(type)) {
1856
+ const _liveMainRef = sanitizeBranch(shared.resolveMainBranch(cwd, project.mainBranch));
1857
+ const _wiIdForAlert = meta?.item?.id || id;
1858
+ const _liveCheckout = require('./engine/live-checkout');
1859
+ let _liveResult;
1860
+ try {
1861
+ _liveResult = await _liveCheckout.prepareLiveCheckout({
1862
+ localPath: cwd,
1863
+ branchName,
1864
+ mainRef: _liveMainRef,
1865
+ gitOpts: _gitOpts,
1866
+ dispatchId: id,
1867
+ wiId: _wiIdForAlert,
1868
+ log: (msg, lvl) => log(lvl || 'info', msg),
1869
+ });
1870
+ } catch (liveErr) {
1871
+ log('error', `spawnAgent: live-checkout helper failed for ${id}: ${liveErr.message}`);
1872
+ _cleanupPromptFiles();
1873
+ completeDispatch(
1874
+ id,
1875
+ DISPATCH_RESULT.ERROR,
1876
+ `live-checkout failed: ${liveErr.message}`.slice(0, 800),
1877
+ 'Live-checkout helper threw before agent spawn; non-retryable until the underlying git state or invocation is fixed.',
1878
+ { failureClass: FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, agentRetryable: false },
1879
+ );
1880
+ cleanupTempAgent(agentId);
1881
+ return null;
1882
+ }
1883
+ if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'dirty') {
1884
+ const _dirtyFiles = Array.isArray(_liveResult.dirtyFiles) ? _liveResult.dirtyFiles : [];
1885
+ const _alertBody = [
1886
+ '# Live-checkout refused: dirty worktree',
1887
+ '',
1888
+ `**Project:** ${project.name || '(unknown)'}`,
1889
+ `**Local path:** ${cwd}`,
1890
+ `**Branch:** ${branchName}`,
1891
+ `**Work item:** ${_wiIdForAlert}`,
1892
+ `**Dispatch:** ${id}`,
1893
+ '',
1894
+ `The engine refused to spawn the agent because \`${cwd}\` has uncommitted changes. Live-checkout mode runs in your project directly — the engine never \`git reset\` or \`git clean\` your tree.`,
1895
+ '',
1896
+ '## Dirty files (`git status --porcelain`)',
1897
+ '',
1898
+ '```',
1899
+ ...(_dirtyFiles.length > 0 ? _dirtyFiles : ['(none reported)']),
1900
+ '```',
1901
+ '',
1902
+ 'Recovery: commit, stash, or discard the changes in the project checkout, then re-dispatch the work item.',
1903
+ ].join('\n');
1904
+ try { writeInboxAlert(`live-checkout-dirty-${_wiIdForAlert}`, _alertBody); }
1905
+ catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
1906
+ try {
1907
+ const _wiPath = resolveWorkItemPath(dispatchItem.meta);
1908
+ if (_wiPath && dispatchItem.meta?.item?.id) {
1909
+ mutateJsonFileLocked(_wiPath, (data) => {
1910
+ if (!Array.isArray(data)) return data;
1911
+ const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
1912
+ if (wi) wi._pendingReason = 'live_checkout_dirty';
1913
+ return data;
1914
+ });
1915
+ }
1916
+ } catch (e) { log('warn', `live-checkout: failed to stamp _pendingReason: ${e.message}`); }
1917
+ log('error', `spawnAgent: live-checkout refused for ${id}: ${_dirtyFiles.length} dirty file(s) in ${cwd}`);
1918
+ _cleanupPromptFiles();
1919
+ completeDispatch(
1920
+ id,
1921
+ DISPATCH_RESULT.ERROR,
1922
+ `live-checkout refused: ${_dirtyFiles.length} dirty file(s) in ${cwd}`.slice(0, 800),
1923
+ 'Live-checkout mode requires a clean tree; engine refuses to overwrite operator changes. Commit/stash/discard and re-dispatch.',
1924
+ { failureClass: FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, agentRetryable: false },
1925
+ );
1926
+ cleanupTempAgent(agentId);
1927
+ return null;
1928
+ }
1929
+ log('info', `live-checkout: ${_liveResult.created ? 'created' : 'switched to'} branch ${branchName} in ${cwd} (in-place; no worktree)`);
1930
+ }
1931
+
1932
+ if (branchName && !liveMode) {
1840
1933
  updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
1841
1934
  // W-mpwy3mp5 (#2996): track whether we ended up reusing an existing
1842
1935
  // worktree (rather than creating a fresh one) so the post-setup dirty/
@@ -1901,7 +1994,14 @@ async function spawnAgent(dispatchItem, config) {
1901
1994
  const _isSharedForPool = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
1902
1995
  const _poolProject = project.name || 'default';
1903
1996
  const _poolSize = worktreePool.getProjectPoolSize(_poolProject, config);
1904
- if (_poolSize > 0 && !_isSharedForPool && branchName) {
1997
+ // P-a3f9b206: belt-and-braces `!liveMode` gate. The borrow block lives
1998
+ // inside the `if (branchName && !liveMode)` worktree-create gate from
1999
+ // P-a3f9b204, so liveMode dispatches already skip it; this duplicates
2000
+ // the check so a future refactor that hoists the borrow out of the
2001
+ // create block still excludes live-mode projects. Combined with the
2002
+ // worktree-pool `getProjectPoolSize` short-circuit, the pool is
2003
+ // unreachable from three layers for live-mode dispatches.
2004
+ if (_poolSize > 0 && !_isSharedForPool && branchName && !liveMode) {
1905
2005
  let _branchOnRemote = true;
1906
2006
  try {
1907
2007
  await shared.shellSafeGit(
@@ -8222,6 +8322,26 @@ async function tickInner() {
8222
8322
  for (const d of (dispatch.active || [])) {
8223
8323
  if (d.meta?.branch) lockedBranches.add(sanitizeBranch(d.meta.branch));
8224
8324
  }
8325
+ // P-a3f9b205: Per-project mutating-concurrency gate for live-mode projects.
8326
+ // When project.worktreeMode === 'live', the agent runs in-place inside the
8327
+ // operator's localPath instead of a dedicated worktree, so two concurrent
8328
+ // mutating dispatches to the same project would clobber each other's
8329
+ // index / working tree. Seed `liveProjectsInUse` from dispatch.active so
8330
+ // the gate survives across ticks (not just within one allocation pass).
8331
+ // Read-only types (meeting/ask/explore/plan/plan-to-prd) never write, so
8332
+ // they are excluded from the cap. Isolated-mode projects are also
8333
+ // excluded — they get a fresh worktree per dispatch and are naturally
8334
+ // safe.
8335
+ const liveProjectsInUse = new Set();
8336
+ for (const d of (dispatch.active || [])) {
8337
+ if (READ_ONLY_ROOT_TASK_TYPES.has(d.type)) continue;
8338
+ const projName = d.project || d.meta?.project?.name || null;
8339
+ if (!projName) continue;
8340
+ const projCfg = shared.findProjectByName(shared.getProjects(config), projName);
8341
+ if (projCfg && projCfg.worktreeMode === shared.WORKTREE_MODES.LIVE) {
8342
+ liveProjectsInUse.add(projName);
8343
+ }
8344
+ }
8225
8345
  const seenPendingIds = new Set();
8226
8346
  const toDispatch = [];
8227
8347
  let generalSlots = slotsAvailable;
@@ -8367,11 +8487,38 @@ async function tickInner() {
8367
8487
  // Branch mutex: skip items targeting a branch already locked by an active or newly-dispatched task
8368
8488
  const itemBranch = item.meta?.branch ? sanitizeBranch(item.meta.branch) : null;
8369
8489
  if (itemBranch && lockedBranches.has(itemBranch)) continue;
8490
+ // P-a3f9b205: Per-project mutating-concurrency gate. Two concurrent
8491
+ // mutating dispatches against the same live-mode project would step on
8492
+ // each other in the operator's localPath, so cap at 1 per project.
8493
+ // The branch-mutex check above is the more-specific reason when both
8494
+ // apply (same project + same branch), which is why this gate runs
8495
+ // AFTER it.
8496
+ const itemProjName = item.project || item.meta?.project?.name || null;
8497
+ if (
8498
+ itemProjName
8499
+ && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
8500
+ && liveProjectsInUse.has(itemProjName)
8501
+ ) {
8502
+ item._pendingReason = 'live_checkout_busy';
8503
+ continue;
8504
+ }
8370
8505
  if (generalSlots <= 0) continue;
8371
8506
  seenPendingIds.add(item.id);
8372
8507
  toDispatch.push(item);
8373
8508
  busyAgents.add(item.agent);
8374
8509
  if (itemBranch) lockedBranches.add(itemBranch);
8510
+ // Track this project as in-use so subsequent pending items in the same
8511
+ // tick hit the gate above. The seed loop already captured cross-tick
8512
+ // active dispatches; this line covers within-tick fan-out.
8513
+ if (
8514
+ itemProjName
8515
+ && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
8516
+ ) {
8517
+ const projCfg = shared.findProjectByName(shared.getProjects(config), itemProjName);
8518
+ if (projCfg && projCfg.worktreeMode === shared.WORKTREE_MODES.LIVE) {
8519
+ liveProjectsInUse.add(itemProjName);
8520
+ }
8521
+ }
8375
8522
  generalSlots--;
8376
8523
  }
8377
8524
 
@@ -8437,6 +8584,19 @@ async function tickInner() {
8437
8584
  for (const d of (postDispatch.active || [])) {
8438
8585
  if (d.meta?.branch) postLockedBranches.add(sanitizeBranch(d.meta.branch));
8439
8586
  }
8587
+ // P-a3f9b205: Rebuild the per-project live-mode mutating set from the
8588
+ // post-dispatch active list so the annotation loop's reason is consistent
8589
+ // with whatever actually got spawned this tick.
8590
+ const postLiveProjectsInUse = new Set();
8591
+ for (const d of (postDispatch.active || [])) {
8592
+ if (READ_ONLY_ROOT_TASK_TYPES.has(d.type)) continue;
8593
+ const projName = d.project || d.meta?.project?.name || null;
8594
+ if (!projName) continue;
8595
+ const projCfg = shared.findProjectByName(shared.getProjects(config), projName);
8596
+ if (projCfg && projCfg.worktreeMode === shared.WORKTREE_MODES.LIVE) {
8597
+ postLiveProjectsInUse.add(projName);
8598
+ }
8599
+ }
8440
8600
  let skipReasonChanged = false;
8441
8601
  for (const item of (postDispatch.pending || [])) {
8442
8602
  let reason = null;
@@ -8449,6 +8609,18 @@ async function tickInner() {
8449
8609
  const pendingBranch = item.meta?.branch ? sanitizeBranch(item.meta.branch) : null;
8450
8610
  if (pendingBranch && postLockedBranches.has(pendingBranch)) {
8451
8611
  reason = 'branch_locked';
8612
+ } else {
8613
+ // P-a3f9b205: surface the per-project live-mode gate. Order matters:
8614
+ // max_concurrency / agent_busy / branch_locked are all more specific
8615
+ // and win when both apply.
8616
+ const itemProjName = item.project || item.meta?.project?.name || null;
8617
+ if (
8618
+ itemProjName
8619
+ && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
8620
+ && postLiveProjectsInUse.has(itemProjName)
8621
+ ) {
8622
+ reason = 'live_checkout_busy';
8623
+ }
8452
8624
  }
8453
8625
  }
8454
8626
  // Track when item first became blocked on a busy agent for reassignment threshold
@@ -8467,6 +8639,22 @@ async function tickInner() {
8467
8639
  item.skipReason = reason;
8468
8640
  skipReasonChanged = true;
8469
8641
  }
8642
+ // P-a3f9b205: mirror _pendingReason for live_checkout_busy so the
8643
+ // dashboard's pending-reason chip surfaces the value (it reads
8644
+ // _pendingReason, not skipReason). Only the live-mode gate flows
8645
+ // through _pendingReason here — other reasons keep their existing
8646
+ // semantics (max_concurrency / agent_busy / branch_locked already
8647
+ // have their own _pendingReason owners earlier in the tick).
8648
+ if (reason === 'live_checkout_busy') {
8649
+ if (item._pendingReason !== 'live_checkout_busy') {
8650
+ item._pendingReason = 'live_checkout_busy';
8651
+ skipReasonChanged = true;
8652
+ }
8653
+ } else if (item._pendingReason === 'live_checkout_busy') {
8654
+ // Gate cleared — drop the stale pending reason so the chip clears.
8655
+ delete item._pendingReason;
8656
+ skipReasonChanged = true;
8657
+ }
8470
8658
  }
8471
8659
  if (skipReasonChanged) {
8472
8660
  if (_isTickStale(myGeneration)) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2160",
3
+ "version": "0.1.2162",
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"