@yemi33/minions 0.1.2352 → 0.1.2354

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard.js CHANGED
@@ -6993,10 +6993,13 @@ const server = http.createServer(async (req, res) => {
6993
6993
 
6994
6994
  let result = null;
6995
6995
  let agentChanged = false;
6996
- // Set inside the source-file lock below when a project move is needed;
6997
- // inserted into the target file's own lock scope afterward so we never
6998
- // hold both files' locks at once.
6996
+ // Set inside the source-file lock below when a project move is
6997
+ // needed. W-mrbbmltq000fce9f: the move itself (target push + source
6998
+ // removal) is executed AFTER this lock releases, target-write-first,
6999
+ // so a failure never straddles "spliced from source, not yet in
7000
+ // target" — see the two-phase block below.
6999
7001
  let movedItem = null;
7002
+ let needsMove = false;
7000
7003
  mutateJsonFileLocked(wiPath, (items) => {
7001
7004
  if (!Array.isArray(items)) items = [];
7002
7005
  const idx = items.findIndex(i => i.id === id);
@@ -7034,29 +7037,78 @@ const server = http.createServer(async (req, res) => {
7034
7037
  }
7035
7038
  item.updatedAt = new Date().toISOString();
7036
7039
 
7037
- // W-mrazefzz000358e3 — actually MOVE the item between work-items.json
7038
- // files when the resolved target differs from where it lives today,
7039
- // rather than just stamping item.project in place. Only trip this
7040
- // when the resolved wiPath actually changes — re-assigning the same
7041
- // project the item already lives in is a no-op move.
7040
+ // W-mrazefzz000358e3 — a cross-project move actually MOVEs the item
7041
+ // between work-items.json files when the resolved target differs
7042
+ // from where it lives today, rather than just stamping item.project
7043
+ // in place. Only trip this when the resolved wiPath actually
7044
+ // changes — re-assigning the same project the item already lives in
7045
+ // is a no-op move.
7046
+ //
7047
+ // W-mrbbmltq000fce9f — do NOT splice the item out of the source
7048
+ // array here. Doing the splice-then-push as two independent locked
7049
+ // calls (the historical bug) means a target-write failure after
7050
+ // this callback commits leaves the item spliced from source and
7051
+ // never written to target: silently lost from both stores. Instead
7052
+ // only STAGE the move — clone the (already field-edited) item with
7053
+ // its new `project` value — and leave the source array untouched.
7054
+ // The actual splice happens in a later, separate lock, only once
7055
+ // the target push below has durably committed.
7042
7056
  if (projectMove && path.resolve(projectMove.wiPath) !== path.resolve(wiPath)) {
7043
- if (projectMove.projectName) item.project = projectMove.projectName;
7044
- else delete item.project;
7045
- movedItem = item;
7046
- items.splice(idx, 1);
7057
+ needsMove = true;
7058
+ movedItem = { ...item };
7059
+ if (projectMove.projectName) movedItem.project = projectMove.projectName;
7060
+ else delete movedItem.project;
7047
7061
  }
7048
7062
 
7049
- result = { code: 200, body: { ok: true, item } };
7063
+ result = { code: 200, body: { ok: true, item: needsMove ? movedItem : item } };
7050
7064
  return items;
7051
7065
  });
7052
7066
  if (!result) return jsonReply(res, 500, { error: 'unexpected state' });
7053
7067
 
7054
- if (movedItem) {
7055
- mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
7056
- if (!Array.isArray(targetItems)) targetItems = [];
7057
- targetItems.push(movedItem);
7058
- return targetItems;
7059
- });
7068
+ if (needsMove && result.code === 200) {
7069
+ try {
7070
+ // Phase 1 of the move: durably write the item into the TARGET
7071
+ // store first. If this throws (lock timeout, storage error,
7072
+ // process crash), the source store still holds the item
7073
+ // untouched (aside from the field edits already committed above)
7074
+ // — nothing to roll back, nothing lost.
7075
+ mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
7076
+ if (!Array.isArray(targetItems)) targetItems = [];
7077
+ targetItems.push(movedItem);
7078
+ return targetItems;
7079
+ });
7080
+ } catch (targetErr) {
7081
+ return jsonReply(res, 500, {
7082
+ error: `project move failed while writing to target store; item left unchanged in its original project: ${targetErr.message}`,
7083
+ });
7084
+ }
7085
+
7086
+ try {
7087
+ // Phase 2: the target write is confirmed durable — now it's safe
7088
+ // to remove the item from the source store.
7089
+ mutateJsonFileLocked(wiPath, (items) => {
7090
+ if (!Array.isArray(items)) items = [];
7091
+ const spliceIdx = items.findIndex(i => i.id === id);
7092
+ if (spliceIdx !== -1) items.splice(spliceIdx, 1);
7093
+ return items;
7094
+ });
7095
+ } catch (spliceErr) {
7096
+ // Removing from source failed AFTER the target write already
7097
+ // committed. Compensate by removing the item from the target
7098
+ // store again, so the item ends up in exactly one store (its
7099
+ // original) instead of duplicated across two.
7100
+ try {
7101
+ mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
7102
+ if (!Array.isArray(targetItems)) targetItems = [];
7103
+ const ti = targetItems.findIndex(i => i.id === id);
7104
+ if (ti !== -1) targetItems.splice(ti, 1);
7105
+ return targetItems;
7106
+ });
7107
+ } catch { /* best-effort rollback; surfaced via the 500 below regardless */ }
7108
+ return jsonReply(res, 500, {
7109
+ error: `project move failed while removing from source store; move was rolled back: ${spliceErr.message}`,
7110
+ });
7111
+ }
7060
7112
  }
7061
7113
 
7062
7114
  // Clear stale pending dispatch entries outside lock
package/docs/README.md CHANGED
@@ -30,6 +30,7 @@ Architecture, design proposals, and lifecycle references for people working on t
30
30
  - [harness-mode.md](harness-mode.md) — Tri-Agent Harness Mode (`harness_mode: "tri_agent"` on scheduled tasks): Planner → Generator → Evaluator loop that iterates a shared on-disk artifact until a rubric passes or the iteration cap fires.
31
31
  - [harness-propagation.md](harness-propagation.md) — How user-level and project-local harness assets (skills, slash-commands, MCP config, `CLAUDE.md` / `AGENTS.md`) propagate into an agent's worktree via `--add-dir`, the `harnessPropagateProjectLocal` flag, and the project-local-on-main worktree-visibility footgun.
32
32
  - [harness-transparency.md](harness-transparency.md) — The `harnessUsed` self-report contract: capture (agent reports the skills / MCPs / commands / docs it used) → ground (engine cross-checks against `_harnessPropagated` and annotates `grounded:true\|false`, never dropping) → surface (PR comment, final agent note, work-item modal).
33
+ - [kb-dedup-duplicate-pair-investigation.md](kb-dedup-duplicate-pair-investigation.md) — Investigation-only pass diffing suspected duplicate KB filename groups (content-hash dedup for agent-authored reviews/build-reports) to confirm true full-body duplication before any fix is proposed.
33
34
  - [kb-sweep.md](kb-sweep.md) — Knowledge-base consolidation sweep (hash dedup → LLM batch dedup/reclassify → per-entry compress) and the detached runner that keeps it alive across `minions restart`.
34
35
  - [keep-processes.md](keep-processes.md) — `meta.keep_processes` sidecar contract: when to use it vs managed-spawn, sidecar schema, caps, and the [`engine/keep-process-sweep.js`](../engine/keep-process-sweep.js) lifecycle.
35
36
  - [live-checkout-mode.md](live-checkout-mode.md) — Per-project opt-in `checkoutMode: 'live'`: skips `git worktree add` and dispatches in-place inside `project.localPath` for `repo`-managed trees, submodule-heavy repos, deep Windows paths, and native build state. Includes the refuse-on-dirty contract and the per-project mutating-concurrency cap of 1.
@@ -46,6 +47,7 @@ Architecture, design proposals, and lifecycle references for people working on t
46
47
  - [rfc-completion-json.md](rfc-completion-json.md) — RFC for replacing stdout regex-scraping with a structured `completion.json` control-plane protocol.
47
48
  - [runtime-adapters.md](runtime-adapters.md) — Runtime adapter contract (`engine/runtimes/*`): how the engine talks to Claude Code, Copilot CLI, and future CLIs through a single capability-flagged interface.
48
49
  - [self-improvement.md](self-improvement.md) — The six self-improvement mechanisms (learnings inbox, per-agent history, review feedback, quality metrics, etc.) that form Minions' continuous feedback loop.
50
+ - [shared-lifecycle-module-map.md](shared-lifecycle-module-map.md) — Documentation-only module-boundary audit of `engine/shared.js` / `engine/lifecycle.js`: function inventory, cross-file call graph, and a proposed split map (no code moved).
49
51
  - [skills.md](skills.md) — Skill block format: how agents emit reusable `\`\`\`skill` blocks and how the engine extracts them into native personal-skill directories.
50
52
  - [slim-ux/concepts.md](slim-ux/concepts.md) — Slim-UX design notes: simplified surface concepts driving the project picker, inline project link, and decoupled folder picker.
51
53
  - [slim-ux/architecture-suggestions.md](slim-ux/architecture-suggestions.md) — Slim-UX follow-up architecture suggestions paired with `concepts.md`.
@@ -0,0 +1,138 @@
1
+ # KB Duplicate-Entry Investigation (P-mrb8yg9x001jc364)
2
+
3
+ Investigation-only pass (per architecture-meeting scoping) diffing suspected
4
+ duplicate KB filename groups to confirm true full-body duplication vs.
5
+ distinct events sharing a naming pattern, before any fix is proposed.
6
+
7
+ ## Background
8
+
9
+ `engine/consolidation.js:1156-1290` content-hashes and dedups
10
+ engine-authored SYSTEM ALERTS (via `shared.isEngineSystemAlert` +
11
+ `alertHash` frontmatter, see `_alertHashExists`) at **write time** —
12
+ recurring identical alerts never get a `-2`/`-3` full-body copy. Agent-authored
13
+ review/build-report notes have no equivalent write-time guard: they fall
14
+ through the plain `shared.uniquePath(...)` branch (`consolidation.js:1267`),
15
+ so any note that gets classified into `knowledge/<category>/` twice under the
16
+ same computed filename (`${date}-${agent}-${titleSlug}.md`) always produces a
17
+ full-body `-2`/`-3` copy at consolidation time.
18
+
19
+ Separately, `engine/kb-sweep.js` Pass 1 (`_hashDedup`, `kb-sweep.js:34-37,
20
+ 155-176`) content-hashes **every** KB entry regardless of category and
21
+ archives byte-identical duplicates to `knowledge/_swept/`, keeping the most
22
+ recent. This pass is category-agnostic and *would* catch true agent-authored
23
+ duplicates too — but it only runs on a schedule (`AUTO_SWEEP_INTERVAL_MS = 4h`,
24
+ `kb-sweep.js:26`, or on-demand via the dashboard), not at consolidation time.
25
+
26
+ ## Method
27
+
28
+ Diffed 3 suspected duplicate-filename groups directly on disk (byte compare
29
+ via `fc.exe /b`, line diff via `Compare-Object`, frontmatter inspection,
30
+ file timestamps, and cross-reference against `engine/kb-sweep-state.json`'s
31
+ last-completed-sweep timestamp).
32
+
33
+ ## Findings
34
+
35
+ ### Pair 1 — `knowledge/reviews/2026-07-07-ripley-review-…-740-fix-contam{,-2,-3}.md` (TRUE DUPLICATE, -2/-3 only)
36
+
37
+ - `-2.md` and `-3.md` are **byte-identical** (`fc.exe /b` → "no differences").
38
+ Both carry `source: ripley-ripley-review-mrb701ph0008506d-2026-07-07-2200.md`
39
+ in their `classifyToKnowledgeBase`-added frontmatter and wrap the same
40
+ embedded note (`id: NOTE-mrb701pz0009ed9c`).
41
+ - Created at `15:06:22` and `15:09:14` (same day, ~3 min apart) — i.e. the
42
+ *same* inbox note (`ripley-ripley-review-mrb701ph0008506d-2026-07-07-2200.md`)
43
+ was independently classified into KB twice by `classifyToKnowledgeBase`,
44
+ producing a `uniquePath` collision on the computed filename and hence a
45
+ full-body `-3` copy of `-2`.
46
+ - The base file (no suffix, `15:05:43`) is a **different, earlier, distinct
47
+ write** — different frontmatter shape entirely (`id/agent/date/pr/repo/
48
+ branch/verdict`, no `source:`/`category:`/`reusable:` fields that
49
+ `classifyToKnowledgeBase` always adds) and different body formatting
50
+ (`## Verdict: APPROVE` vs. `**Verdict:** APPROVE`, different bullet
51
+ structure). It is **not** part of the `-2`/`-3` duplicate chain — it's a
52
+ separate KB write that happens to share a title-derived filename stem.
53
+ - **Verdict: -2 and -3 are a true, confirmed full-body duplicate pair. The
54
+ base file is a distinct entry, not a duplicate.**
55
+
56
+ ### Pair 2 — `knowledge/build-reports/2026-07-07-dallas-validate-fix-contaminated-originalref-in-live-chec{,-2,-3}.md` (TRUE DUPLICATE, -2/-3 only)
57
+
58
+ - Same pattern as Pair 1: `-2.md` and `-3.md` are byte-identical
59
+ (`fc.exe /b` → "no differences"), both stamped
60
+ `source: dallas-W-mrb6tfdn000oebc8-2026-07-07-2200.md` and wrapping the
61
+ same embedded note (`id: NOTE-mrb707jk000b1903`), created `15:17:51` and
62
+ `15:19:02` (~70s apart).
63
+ - The base file (`15:16:29`, 4451 bytes vs. 5373 bytes for -2/-3) again has
64
+ the plain original-note frontmatter (`id/agent/date/task`, no
65
+ `source:`/`category:`/`reusable:`) and is shorter/differently-worded —
66
+ a distinct earlier write, not part of the duplicate chain.
67
+ - **Verdict: -2 and -3 are a true, confirmed full-body duplicate pair. The
68
+ base file is a distinct entry, not a duplicate.**
69
+
70
+ ### Pair 3 — `knowledge/project-notes/2026-07-07-engine-engine-skipped-worktree-wipe-live-dispatch-guard-f{,-2…-38}.md` (NOT DUPLICATES — confirmed distinct)
71
+
72
+ - ~40 files share the `…-live-dispatch-guard-f[-N]` filename stem. Sampled
73
+ `-f-27.md`: frontmatter carries `alertHash: 9bc818d6…` and
74
+ `source: engine-worktree-skip-live-W-mrb6tlze000pc39d-2026-07-07.md` — a
75
+ **distinct** work-item ID (`W-mrb6tlze000pc39d`) from other members of the
76
+ group (confirmed against `notes.md`/`knowledge/project-notes` entries
77
+ referencing different `W-…` IDs, e.g. `W-mrb70qnx000o59d5`,
78
+ `W-mrb7gwha001073e6`).
79
+ - These are engine-authored SYSTEM ALERTS (`shared.isEngineSystemAlert`
80
+ matches), so each is content-hashed and given a distinct `alertHash`
81
+ frontmatter value per distinct incident. `_alertHashExists`
82
+ (`consolidation.js:1188-1200`) already prevents a same-hash alert from
83
+ producing a duplicate — the large `-2…-38` filename fan-out here reflects
84
+ ~40 **genuinely distinct** live-dispatch-guard incidents (one per
85
+ worktree/dispatch), not 40 copies of one incident. `uniquePath` is doing
86
+ exactly what it should: disambiguating distinct events that happen to
87
+ share a human-readable title.
88
+ - **Verdict: confirmed NOT duplicates. The existing engine-alert dedup path
89
+ (W-mr3pi9de) is working correctly here.**
90
+
91
+ ## Root cause (confirmed, not yet fixed — out of scope for this pass)
92
+
93
+ 1. `classifyToKnowledgeBase` (`consolidation.js:1231-1298`) has **no
94
+ write-time content-hash guard for agent-authored notes** — only
95
+ `isEngineSystemAlert` entries get one. When the same inbox note is
96
+ classified into KB more than once (e.g. a PR reviewed by two
97
+ independent dispatches within the same day, each producing its own
98
+ inbox note that hashes/formats similarly — or, per the `-2`/`-3`
99
+ evidence above, the identical inbox note re-processed), each pass
100
+ writes a fresh, full-body `-N` copy via `shared.uniquePath`.
101
+ 2. `engine/kb-sweep.js` Pass 1 (`_hashDedup`) *does* eventually catch and
102
+ archive these byte-identical copies (category-agnostic, keeps most
103
+ recent) — but only on its own schedule (`AUTO_SWEEP_INTERVAL_MS = 4h`
104
+ default, `kb-sweep.js:26`) or on manual trigger, not at consolidation
105
+ time.
106
+ 3. Cross-checked against `engine/kb-sweep-state.json`: the last completed
107
+ sweep finished `2026-07-07T21:49:00Z` (`14:49` local); both confirmed
108
+ duplicate pairs above were created *after* that sweep completed
109
+ (`15:05`-`15:19` local), so neither had yet had a chance to be caught by
110
+ the next scheduled run at the time of this investigation. This is
111
+ consistent with the sweep design, not a sweep bug — but it does mean a
112
+ duplicate pair can sit in `knowledge/<category>/` (and get injected into
113
+ every agent prompt that primes from `knowledge/`) for up to ~4h before
114
+ Pass 1 removes it.
115
+
116
+ ## Recommendation (not implemented — meeting scoped this as investigation-only)
117
+
118
+ The `-2`/`-3` case is real and reproducible, but bounded by the existing
119
+ sweep's 4h cadence — a same-day double-classification costs extra disk +
120
+ prompt-priming tokens for at most one sweep interval, not indefinitely. A
121
+ future fix should extend the existing `alertHash`-style write-time
122
+ content-hash dedup in `classifyToKnowledgeBase` to non-system-alert items
123
+ too (skip the `uniquePath` full-body write when a byte-identical KB entry
124
+ already exists in the target category), rather than relying solely on the
125
+ delayed sweep pass. Distinct-event fan-out (Pair 3 pattern) must be
126
+ preserved — any fix must key on content hash, not filename/title
127
+ similarity, since many genuinely distinct events share a title stem.
128
+
129
+ ## Sources
130
+
131
+ - `engine/consolidation.js:1156-1300` (`isEngineSystemAlert`/`alertHash`
132
+ dedup, `classifyToKnowledgeBase`)
133
+ - `engine/kb-sweep.js:20-37,155-176` (`_hashEntry`, `_hashDedup`, sweep
134
+ interval constant)
135
+ - `engine/kb-sweep-state.json` (`completedAtIso: 2026-07-07T21:49:00.186Z`)
136
+ - `knowledge/reviews/2026-07-07-ripley-review-github-opg-microsoft-minions-740-fix-contam{,-2,-3}.md`
137
+ - `knowledge/build-reports/2026-07-07-dallas-validate-fix-contaminated-originalref-in-live-chec{,-2,-3}.md`
138
+ - `knowledge/project-notes/2026-07-07-engine-engine-skipped-worktree-wipe-live-dispatch-guard-f{,-2…-38}.md`
package/docs/kb-sweep.md CHANGED
@@ -58,7 +58,11 @@ Because originals are archived to `_swept/` (same 30-day retention as every othe
58
58
 
59
59
  ### Pass 2 — LLM Batch Sweep
60
60
 
61
- The remaining survivors are sent to Claude Haiku in batches of `LLM_BATCH_SIZE = 30` (source: [`engine/kb-sweep.js:29`](../engine/kb-sweep.js#L29)). Each batch prompt asks for three lists in JSON:
61
+ Survivors are split into **changed** and **unchanged** entries before anything is sent to the LLM (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_classifyLlmScanFreshness`). An entry is `unchanged` only if it carries the `_llmScannedAt` frontmatter marker (`LLM_SCAN_FLAG_KEY`) AND its `mtime` hasn't advanced past that timestamp — the same mtime-gated pattern Pass 3 uses for `_swept`. Everything else (never scanned, or edited/reclassified since its last scan) is `changed`.
62
+
63
+ Only the `changed` entries are batched to Claude Haiku in groups of `LLM_BATCH_SIZE = 30` (source: [`engine/kb-sweep.js:29`](../engine/kb-sweep.js#L29)) with **full content**. `unchanged` entries are still appended to every prompt as a lightweight roster (title/date/category only, no content) so cross-entry duplicate detection against the whole KB stays correct — a changed entry can still be flagged as a duplicate of an older, already-scanned one — without re-spending tokens re-analyzing entries nothing has touched. If there are zero `changed` entries, the pass makes no LLM calls at all. This fixed an unbounded-cost bug where every 4h sweep cycle re-sent the *entire* KB manifest regardless of what had changed since the prior cycle (source: PR #738).
64
+
65
+ Each batch prompt asks for three lists in JSON:
62
66
 
63
67
  | Field | What the LLM looks for |
64
68
  |-------|------------------------|
@@ -68,10 +72,14 @@ The remaining survivors are sent to Claude Haiku in batches of `LLM_BATCH_SIZE =
68
72
 
69
73
  Each action archives the file via the same `_archiveKbFile()` helper used by Pass 1; reclassification rewrites the `category:` frontmatter line and moves the file into the new category directory (source: [`engine/kb-sweep.js:323-340`](../engine/kb-sweep.js#L323)).
70
74
 
75
+ **Duplicate-pair guard.** `duplicates` entries are only actioned if the pair references at least one index the LLM actually content-analyzed this batch (i.e. a member of `changedIdx`); a pair where every referenced index is roster-only (`unchangedIdx`) is skipped with a warning instead of archived, since that would mean acting on a hallucinated or stale LLM reference rather than a real comparison (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_applyLlmPlan`, PR #739).
76
+
71
77
  Reclassification targets are validated against `shared.KB_CATEGORIES` (`architecture`, `conventions`, `project-notes`, `build-reports`, `reviews` — source: [`engine/shared.js`](../engine/shared.js) `KB_CATEGORIES`); unknown categories are silently dropped.
72
78
 
73
79
  If a batch returns invalid JSON or the runtime is unavailable, that batch is skipped with a warning and the rest of the sweep continues (source: [`engine/kb-sweep.js:203-215`](../engine/kb-sweep.js#L203)).
74
80
 
81
+ `_llmScannedAt` is stamped on every analyzed entry only at the very end of the whole sweep — after Pass 3's rewrite — to avoid a same-run mtime race with the rewrite pass's own content edit (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js), stamping step called from `runKbSweep`).
82
+
75
83
  ### Pass 2.5 — Age-Based TTL Expiry (secondary safety net)
76
84
 
77
85
  Cheap, deterministic. No LLM call. With Pass 1.5 now collapsing the bulk of the boilerplate structurally, this pass is a **secondary safety net**: it archives genuinely stale one-off notes that never clustered into a large enough group to be consolidated (e.g. a lone failure class that only occurred twice in a week). The dedup and rewrite passes can only reclaim *duplicate* or *empty* entries, so an age cutoff is still the backstop for long-tail one-offs.
@@ -103,6 +111,10 @@ Survivors of Passes 1, 1.5, and 2 that are still on disk get rewritten one entry
103
111
 
104
112
  After a successful rewrite, the entry's frontmatter gains a `_swept: <ISO timestamp>` key (source: [`engine/kb-sweep.js:293`](../engine/kb-sweep.js#L293)).
105
113
 
114
+ ## The `_llmScannedAt` Frontmatter Flag
115
+
116
+ Mirrors `_swept` but gates Pass 2 instead of Pass 3: an entry with `_llmScannedAt` set and an mtime that hasn't advanced past it is treated as `unchanged` and skipped from full-content LLM analysis on the next sweep (roster-only context still applies — see Pass 2 above). Editing an entry (or a category reclassify) bumps its mtime past the stamp, so the next sweep re-analyzes it. The key is exported as `LLM_SCAN_FLAG_KEY` (source: [`engine/kb-sweep.js:32`](../engine/kb-sweep.js#L32)).
117
+
106
118
  ## The `_swept` Frontmatter Flag
107
119
 
108
120
  `_swept` makes Pass 3 idempotent across runs. Semantics:
@@ -64,11 +64,13 @@ By default the dirty tree refusal (Guarantee 2) is terminal — the engine never
64
64
  3. If ON: `git fetch origin` + `git reset --hard origin/<branch>`, then **re-run the porcelain preflight once**. If the tree is now clean, dispatch proceeds normally. If the reset failed or the tree is *still* dirty, it falls back to the safe `{ ok:false, reason:'dirty' }` refusal — the engine never dispatches onto an unexpected tree.
65
65
  4. On a successful reset it writes a single `live-checkout-autoreset-<wiId>` inbox note listing **exactly which paths were discarded**, so the operator can recover them from `git reflog` / `git fsck --lost-found`.
66
66
 
67
- This is **DESTRUCTIVE** — it permanently discards the operator's uncommitted changes in the live checkout. As of W-mqzbbhn2 it is **ON by default**: discarding disposable dirt via reset-to-remote keeps live-checkout branches aligned with origin instead of accumulating `minions: auto-save agent WIP` commits on every dirty exit. Set `liveCheckoutAutoReset: false` (per-project or fleet-wide) to restore the terminal dirty-tree refusal for live checkouts whose local drift you want preserved.
67
+ This is **DESTRUCTIVE** — it permanently discards the operator's uncommitted changes in the live checkout. As of W-mqzbbhn2 it was **ON by default**; as of **W-mrawgw4q000a6bff it is OFF by default again** (`ENGINE_DEFAULTS.liveCheckoutAutoReset: false`) — the destructive-by-default behavior caused unwanted data loss when an operator had legitimate local drift they expected the engine to leave alone. Set `liveCheckoutAutoReset: true` (per-project or fleet-wide) to opt back in to reset-to-remote recovery.
68
68
 
69
69
  - **Fleet-wide:** Dashboard → Settings → `Live-checkout auto-reset (fleet-wide)` (`engine.liveCheckoutAutoReset`).
70
70
  - **Per-project override:** set `liveCheckoutAutoReset: true|false` on the project object in `config.json` (see the config snippet under *Enabling live mode*). A per-project boolean overrides the fleet-wide default for that project. *(Per-project Settings-UI persistence is deferred — the per-project override is config.json-only for now.)*
71
71
 
72
+ **Precedence when both `liveCheckoutAutoStash` and `liveCheckoutAutoReset` are enabled (W-mrawgw4q000a6bff).** `engine.js spawnAgent` now tries the **non-destructive** auto-stash first: it resolves `liveCheckoutAutoStash` up front and, if enabled, calls the initial `prepareLiveCheckout` with `autoReset` forced `false` so the destructive reset never runs before stash gets a chance. `applyLiveCheckoutAutoStash` then attempts the stash (see [2b auto-stash](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa) above) and its own internal re-preflight also forces `autoReset: false`. Only if the stash itself fails or was skipped (`outcome:'unchanged'`) **and** the tree is still confirmed dirty **and** `resolveLiveCheckoutAutoReset` resolves `true` does `spawnAgent` invoke `prepareLiveCheckout` a second time with `autoReset: true` as an explicit, last-resort destructive fallback. **Behavior change:** previously (reset-before-stash, reset ON by default) the common both-enabled case force-reset the branch to origin on every dirty dispatch; now the branch is only synced to origin when stash fails — the common case just parks the dirty changes non-destructively and leaves the branch where it was. There is deliberately **no automatic `git merge --ff-only origin/<branch>`** after a successful stash — auto-stash's contract is "park changes so dispatch proceeds," not "sync to origin"; that stronger guarantee remains `liveCheckoutAutoReset`'s job, available as an explicit opt-in fallback.
73
+
72
74
  #### 2c. Stale `.git/index.lock` self-heal (W-mr3tayu4)
73
75
 
74
76
  Before any preflight git command runs, `prepareLiveCheckout` calls the shared, age-gated `shared.removeStaleIndexLock(localPath)` to clear a leftover `.git/index.lock` from a crashed/killed git process. Live mode never resets/cleans the operator tree, so nothing else would clear it, and — since `LIVE_CHECKOUT_FAILED` is retryable — every automatic retry used to hit the same stale lock and fail identically forever until an operator deleted it by hand. The remover only touches locks older than 5 minutes, so a lock held by a currently-running git process is never removed out from under it. `engine.js`'s own worktree-mode lock cleanup now delegates to the same shared helper.
@@ -185,6 +187,7 @@ Live-mode agents run **in-place** in the operator's checkout, so when a dispatch
185
187
  - **Retry-with-backoff on the WIP auto-save commit (#608).** The `git add -A` + `git commit` above (and the equivalent self-heal commit described below) go through `_commitAgentWipWithRetry`: a bounded retry (3 attempts, linear backoff) that fires only when the commit fails with an `index.lock` / "another git process" message — i.e. a transient collision with a concurrent git invocation — never on a genuine failure like "nothing to commit". Previously a single failed attempt (e.g. a `.git/index.lock` race) silently aborted the auto-save, leaving the tree dirty on the agent branch and letting the pollution below take hold.
186
188
  - **Orphaned-agent-branch self-heal at dispatch start (#608).** If dispatch-end restore is ever skipped or interrupted (crash, forced kill, an engine restart racing the restore), the *next* dispatch for that project can start with HEAD already sitting on a stray `work/<wi-id>` branch from the previous run — and, before this fix, `prepareLiveCheckout`'s dirty-tree check ran *before* `originalRef` was even captured, so the function returned `{ reason: 'dirty' }` immediately and the branch was never noticed or cleaned, silently repeating for every subsequent dispatch. `prepareLiveCheckout` now recognizes this case: if the tree is still dirty after the existing auto-clean-artifacts recheck, and the current branch (read from the already-fetched `git status --porcelain -b` header, no extra git call) matches the `work/<id>` naming convention (`_looksLikeAgentBranch`) and differs from this dispatch's own target branch, the WIP is committed onto that stray branch (again via `_commitAgentWipWithRetry`) and HEAD is switched back to `mainRef` before the normal flow continues. This only ever touches a branch matching the engine's own naming convention — an operator's own feature branch (e.g. `feature/x`) is never mistaken for engine litter and is left untouched.
187
189
  - **AUTO-RESTORE (best-effort, never `--force`).** At dispatch-end `restoreLiveCheckoutAtDispatchEnd` issues a **plain** `git checkout <originalRef>` — no `--force`, no `-B`, no reset, no clean, no stash. It no-ops when there is nothing to restore: no captured `originalRef`, the agent branch *is* the original ref, or HEAD already sits on the original ref (matched against the branch name *or* the raw sha so the detached-HEAD case is recognized). It is strictly best-effort: every error is swallowed and logged, and a restore never alters the dispatch result.
190
+ - **Read-site trust invariant (P-mrb8yg9x001hfa9f audit).** `restoreLiveCheckoutAtDispatchEnd` never re-derives or re-validates `originalRef` itself — it does not call `_looksLikeAgentBranch` or otherwise probe for contamination; it simply checks out whatever ref it is handed. This is safe *only* because `prepareLiveCheckout`'s own originalRef-contamination invariant (W-mrb6an8300058fd8, §"Original-ref capture" above) already corrects `originalRef` to `mainRef` at capture time whenever HEAD was on a stray engine-managed branch, for both the existing-branch (4b) and new-branch/STALE-BASE-GUARD (4d) code paths — and `engine.js` persists that corrected value verbatim (`engine.js:3106-3122`) for every restore call site (`onAgentClose`, the `engine/cli.js` restart re-attach path, and the `engine/timeout.js` reaping paths) to consume as-is. If the write-side correction in `prepareLiveCheckout` ever regressed, restore would silently check the operator back out onto the contaminated branch, since it has no independent check of its own to catch that. Characterized end-to-end in `test/unit/prepare-live-checkout.test.js` (chains `prepareLiveCheckout` → `restoreLiveCheckoutAtDispatchEnd` for both the 4b and 4d paths).
188
191
  - **Fallback notify (only when a safe switch is impossible).** If git declines the plain checkout — most likely because the agent left uncommitted changes a checkout would overwrite — the refusal is **honored**: the tree is left exactly as the agent left it and a deduped `live-checkout-branch-<dispatchId>` inbox alert tells the operator how to switch back manually (`git -C <localPath> checkout <originalRef>`). The engine never forces the switch. An **unexpected** restore error (git missing, repo corruption, a GVFS blob fetch on the switch-back) now also writes this alert, so a non-refusal failure never silently strands the tree.
189
192
  - **Terminal-failure alert.** When the dispatch ends in a non-success terminal state, a deduped `live-checkout-failed-<dispatchId>` inbox alert is written so the operator knows a live-mode run failed inside their own checkout (where any partial work is visible). This is independent of the restore and fires even when the restore itself succeeds.
190
193
 
@@ -294,7 +297,7 @@ The engine has no opinion about local branches; this hygiene is the operator's r
294
297
  Live-checkout mode is deliberately small. These are NOT supported and will not be added:
295
298
 
296
299
  - **No `auto` mode.** The choice between `worktree` and `live` is per-project and operator-set. The engine will not auto-detect submodules / `repo` workspaces and silently switch modes.
297
- - **No auto-stash on dirty refusal _by default_.** Out of the box the engine refuses and exits; it never `git stash`es to "make room" for a dispatch, because that silently mutates the operator's tree and conflates engine state with operator state. The operator can **opt in** per-project (`project.liveCheckoutAutoStash`) or fleet-wide (`engine.liveCheckoutAutoStash`) to auto-stash instead of refusing — see [2b](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa). Even then the engine never **pops** the stash on the operator's behalf. (The `liveCheckoutAutoReset` escape hatch — ON by default as of W-mqzbbhn2 — **discards** rather than stashes; set it to `false` per-project / fleet-wide to keep the terminal dirty-tree refusal. See [§2b](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20).)
300
+ - **No auto-stash on dirty refusal _by default_.** Out of the box the engine refuses and exits; it never `git stash`es to "make room" for a dispatch, because that silently mutates the operator's tree and conflates engine state with operator state. The operator can **opt in** per-project (`project.liveCheckoutAutoStash`) or fleet-wide (`engine.liveCheckoutAutoStash`) to auto-stash instead of refusing — see [2b](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa). Even then the engine never **pops** the stash on the operator's behalf. (The `liveCheckoutAutoReset` escape hatch — **OFF by default** as of W-mrawgw4q000a6bff, and even when enabled now only runs as a last-resort fallback *after* auto-stash fails or is disabled — **discards** rather than stashes; set it to `true` per-project / fleet-wide to opt in. See [§2b](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20).)
298
301
  - **No concurrent dispatches per project.** The cap is 1; raising it would require per-WI subdirectories, which live mode explicitly does not provide.
299
302
  - **No per-WI subdirectory isolation.** Live mode is one-checkout-per-project by design. If you need isolation, use `checkoutMode: 'worktree'` (the default).
300
303
  - **No per-WI override.** `checkoutMode` is per-project only. There is no `meta.checkoutMode` on a work item that overrides the project setting.
@@ -306,7 +309,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
306
309
  | File | Purpose |
307
310
  |---|---|
308
311
  | `engine/shared.js` — `CHECKOUT_MODES`, `validateCheckoutMode`, `resolveCheckoutMode`, `isLiveCheckoutProject` | Enum + validator + back-compat resolver (P-a3f9b201; consolidated W-mqiaw974). |
309
- | `engine/shared.js` — `resolveLiveCheckoutAutoReset` + `ENGINE_DEFAULTS.liveCheckoutAutoReset` | Pure precedence resolver (per-project boolean > fleet-wide engine default > false) + the fleet-wide default (ON as of W-mqzbbhn2). Gates the dirty-tree auto-reset in `prepareLiveCheckout` (W-mqvejug6000eeb20). |
312
+ | `engine/shared.js` — `resolveLiveCheckoutAutoReset` + `ENGINE_DEFAULTS.liveCheckoutAutoReset` | Pure precedence resolver (per-project boolean > fleet-wide engine default > false) + the fleet-wide default (OFF as of W-mrawgw4q000a6bff — was ON as of W-mqzbbhn2). Gates the dirty-tree auto-reset in `prepareLiveCheckout` (W-mqvejug6000eeb20); in `engine.js spawnAgent` only fires as a fallback after auto-stash fails/is disabled (W-mrawgw4q000a6bff). |
310
313
  | `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
311
314
  | `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, mid-operation / detached-HEAD preflight (incl. `BISECT_LOG`; throw-on-git-dir-failure; exit-1-only detached), original-ref capture, **already-on-branch fast path**, `refs/heads/<branch>` existence check, branch resolution from HEAD (no fetch — issue #226), **no-half-switch + `blob-fetch`/`worktree-conflict` classification** for partial-clone hydration failures and cross-worktree branch conflicts, **opt-in dirty auto-reset** (`git fetch origin` + `reset --hard origin/<branch>` + re-check + `live-checkout-autoreset-<wiId>` note when `liveCheckoutAutoReset` is on), **50 MB git maxBuffer** (P-a3f9b203; preflight + capture P-b2e8d4a6; hardening PL-live-checkout-reliability-hardening; auto-reset + maxBuffer W-mqvejug6000eeb20). |
312
315
  | `engine/live-checkout.js` — `restoreLiveCheckoutAtDispatchEnd` | Dispatch-end auto-restore (plain `git checkout <originalRef>`, never `--force`/reset/clean/stash, best-effort) + **self-healing dirty recovery** (auto-commit agent WIP onto the agent branch) + `live-checkout-failed-<dispatchId>` terminal-failure alert + `live-checkout-branch-<dispatchId>` fallback notify (now also on unexpected restore errors) (P-d9e6b2c4; self-heal PL-live-checkout-reliability-hardening). |
@@ -324,7 +327,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
324
327
  | `engine/create-pr-worktree.js` — `prepareCreatePrWorktree` step-4 restore | `reset --hard HEAD` (not the index-leaking `checkout -- .`) + retried untracked removal + `liveTreeDirty` surfaced + `shared.removeWorktree` teardown (PL-live-checkout-reliability-hardening). |
325
328
  | `engine/pr-action.js` — `buildCreatePrFollowups({ …, mainBranch })` live-mode message | CC "Create PR" chip instruction. In live mode the PR is ALWAYS built on a fresh branch off `origin/<mainBranch>` (stash → fetch → `checkout -B` → pop → commit → push → PR → restore), never reusing the current branch as the base; stop-on-stash-pop-conflict. `dashboard.js` `POST /api/pr-action/offer-create-pr` resolves `mainBranch` via `shared.resolveMainBranch` and threads it in (W-mr3jbpru). |
326
329
  | `dashboard/js/settings.js` — checkoutMode dropdown + chip + `set-liveCheckoutAutoReset` fleet toggle | Operator-facing UI; the fleet-wide auto-reset toggle persists to `engine.liveCheckoutAutoReset` (per-project UI deferred — config.json only) (P-a3f9b207; auto-reset toggle W-mqvejug6000eeb20). |
327
- | `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring,live-checkout-auto-stash}.test.js` | Wiring and contract tests (P-a3f9b208; auto-stash helpers W-mqtvnnj1000357fa). |
330
+ | `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring,live-checkout-auto-stash,live-checkout-stash-before-reset}.test.js` | Wiring and contract tests (P-a3f9b208; auto-stash helpers W-mqtvnnj1000357fa; stash-before-reset ordering W-mrawgw4q000a6bff). |
328
331
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` | Non-retryable refusal class. |
329
332
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` | Non-retryable refusal class for a mid-operation / detached-HEAD operator tree (in-progress merge/rebase/cherry-pick/revert/bisect or detached HEAD), distinct from the dirty-tree class. Emitted by `spawnAgent`'s mid-op / detached-HEAD refusal block (P-a7f3c1d9; wired P-c5a1f3b8). |
330
333
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_BLOB_FETCH` | Non-retryable refusal class for an existing-branch checkout that could not hydrate the tree on a blobless GVFS partial clone (auth-less cache fetch, headless) — deterministic, so excluded from mechanical retry (PL-live-checkout-reliability-hardening). |