@yemi33/minions 0.1.2353 → 0.1.2355

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
+ **changedIdx guard.** All three actions are only actioned against indices the LLM actually content-analyzed this batch (i.e. members of `changedIdx`). `remove` and `reclassify` require their own referenced `index` to be in `changedIdx`; a `duplicates` pair is actioned if the pair references at least one index in `changedIdx` (a pair where every referenced index is roster-only, `unchangedIdx`, is skipped). Roster-only entries are sent to the LLM with title/date/category only (no content — see the "existing entries" roster section), so an index outside `changedIdx` cannot have been genuinely compared/analyzed this batch; acting on it 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, W-mrc9y5y000063033).
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). |
package/engine/cli.js CHANGED
@@ -1653,6 +1653,10 @@ const commands = {
1653
1653
  return;
1654
1654
  }
1655
1655
  const targetProject = target?.project || (projects.length > 0 ? projects[0] : null);
1656
+ if (!targetProject) {
1657
+ console.log('No projects configured. Add a project before running work.');
1658
+ return;
1659
+ }
1656
1660
  const wiPath = projectWorkItemsPath(targetProject);
1657
1661
  const archivePath = wiPath.replace(/\.json$/, '-archive.json');
1658
1662
 
@@ -661,12 +661,18 @@ ${body}`;
661
661
  function _applyLlmPlan(plan, manifest, changedIdx, opts = {}) {
662
662
  let removed = 0, merged = 0, reclassified = 0;
663
663
  // changedIdx is the set of manifest indices actually content-analyzed by
664
- // the LLM this batch (see _llmBatchSweep's scannedIdx). Duplicate pairs
665
- // referencing only unanalyzed indices (LLM hallucination / stale index
666
- // mis-reference) must never be archived a pair is only trustworthy if
667
- // at least one side was actually analyzed this batch (W-mrb447th00062813).
664
+ // the LLM this batch (see _llmBatchSweep's scannedIdx). remove/reclassify/
665
+ // duplicates entries referencing only unanalyzed (roster-only) indices
666
+ // (LLM hallucination / stale index mis-reference) must never be acted on
667
+ // remove/reclassify require their referenced index to be analyzed this
668
+ // batch, and a duplicate pair is only trustworthy if at least one side was
669
+ // actually analyzed this batch (W-mrb447th00062813, W-mrc9y5y000063033).
668
670
  const changedSet = new Set(changedIdx || []);
669
671
  for (const r of (plan.remove || [])) {
672
+ if (!changedSet.has(r.index)) {
673
+ log('warn', `[kb-sweep] skipping remove for index ${r.index} — not in analyzed (changedIdx) batch, likely a hallucinated/stale LLM reference`);
674
+ continue;
675
+ }
670
676
  const entry = manifest[r.index];
671
677
  if (!entry) continue;
672
678
  if (opts.dryRun) { removed++; continue; }
@@ -686,6 +692,10 @@ function _applyLlmPlan(plan, manifest, changedIdx, opts = {}) {
686
692
  }
687
693
  }
688
694
  for (const r of (plan.reclassify || [])) {
695
+ if (!changedSet.has(r.index)) {
696
+ log('warn', `[kb-sweep] skipping reclassify for index ${r.index} — not in analyzed (changedIdx) batch, likely a hallucinated/stale LLM reference`);
697
+ continue;
698
+ }
689
699
  const entry = manifest[r.index];
690
700
  if (!entry || !shared.KB_CATEGORIES.includes(r.to)) continue;
691
701
  if (opts.dryRun) { reclassified++; continue; }
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const os = require('os');
9
9
  const shared = require('./shared');
10
10
  const { safeRead, safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
11
- log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, REVIEW_STATUS, RETRY_DELAY_MS, DISPATCH_RESULT,
11
+ log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, RETRY_DELAY_MS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { resolveRuntime } = require('./runtimes');
14
14
  const adoGitAuth = require('./ado-git-auth');
@@ -38,27 +38,6 @@ function checkPlanCompletion(meta, config) {
38
38
  // Collect work items from ALL projects + central (PRD items can be in either)
39
39
  const allWorkItems = queries.getWorkItems(config);
40
40
  const planItems = allWorkItems.filter(w => w.sourcePlan === planFile && w.itemType !== 'pr' && w.itemType !== 'verify');
41
- if (planItems.length === 0) return;
42
-
43
- // W-mqk5ld3p: verify-creation must be a pure function of work-item state,
44
- // independent of who/what set `status: completed`. We do NOT short-circuit on
45
- // the raw `_completionNotified` boolean — a PRD can land on disk pre-completed
46
- // with the flag already set (out-of-band write by the plan-to-prd / pipeline
47
- // path), and the legacy top-of-function `if (_completionNotified) return` then
48
- // permanently skipped the aggregate build/test verify gate. Instead the flag
49
- // gates ONLY the one-shot completion summary (below); control always falls
50
- // through to the verify-creation block, which re-checks for an existing verify
51
- // WI under the file lock and is therefore safe to reach every scan. The one
52
- // exception is the REOPEN sub-path (terminal verify → re-open): that is a
53
- // plan-modification concern (the dashboard clears `_completionNotified` when it
54
- // re-opens a plan), so it is gated on `!alreadyNotified` to avoid bouncing an
55
- // already-done verify on every steady-state scan.
56
- const alreadyNotified = plan.status === PLAN_STATUS.COMPLETED && !!plan._completionNotified;
57
-
58
- // Hard completion gate: every PRD feature ID must have a corresponding work item in a terminal state.
59
- const planFeatureIds = new Set((plan.missing_features || []).map(f => f.id).filter(Boolean));
60
- const workItemById = {};
61
- for (const w of planItems) { if (w.id) workItemById[w.id] = w; }
62
41
 
63
42
  // Self-heal stale completion. checkPlanCompletion only ever ADVANCED a plan to
64
43
  // `completed` — it never reverted. So a PRD that was completed and then gained
@@ -71,6 +50,8 @@ function checkPlanCompletion(meta, config) {
71
50
  // materializer recreates the missing WI and the normal completion flow resumes.
72
51
  // Idempotent: once reverted (status active, flag cleared) the guard no-ops, so
73
52
  // it cannot flap on subsequent scans while the item is genuinely incomplete.
53
+ // Defined BEFORE the `planItems.length === 0` early return (below) so the
54
+ // zero-materialized-items dead end (W-mrboq1g900096f04) can call it too.
74
55
  const reopenStaleCompletedPlan = (reason) => {
75
56
  if (plan.status !== PLAN_STATUS.COMPLETED && !plan._completionNotified) return false;
76
57
  log('warn', `Plan ${planFile}: re-opening stale completed PRD — ${reason}`);
@@ -83,6 +64,50 @@ function checkPlanCompletion(meta, config) {
83
64
  return true;
84
65
  };
85
66
 
67
+ if (planItems.length === 0) {
68
+ // W-mrboq1g900096f04: a PRD that reaches `completed` with ZERO materialized
69
+ // work items (agent authoring bug, race, or manual/API misuse writing
70
+ // status:'completed' directly instead of 'awaiting-approval') was a
71
+ // permanent, silent dead end — this function used to hard-return here
72
+ // before ever reaching reopenStaleCompletedPlan above, and
73
+ // materializePlansAsWorkItems unconditionally skips COMPLETED plans, so
74
+ // nothing ever re-triggered materialization. Self-heal: if the PRD is
75
+ // COMPLETED and still has feature IDs stuck at a non-terminal PRD-level
76
+ // status ('missing'/'updated', i.e. PRD_MATERIALIZABLE) with no
77
+ // corresponding work item, reopen it so the materializer picks it up.
78
+ // Do NOT reopen a genuinely-complete PRD (no missing_features, or every
79
+ // feature already at a terminal PRD-level status such as 'done').
80
+ if (plan.status === PLAN_STATUS.COMPLETED) {
81
+ const stuckIds = (plan.missing_features || [])
82
+ .filter(f => f?.id && PRD_MATERIALIZABLE.has(f.status))
83
+ .map(f => f.id);
84
+ if (stuckIds.length > 0) {
85
+ reopenStaleCompletedPlan(`${stuckIds.length} item(s) stuck at PRD-level status with zero materialized work items: ${stuckIds.join(', ')}`);
86
+ }
87
+ }
88
+ return;
89
+ }
90
+
91
+ // W-mqk5ld3p: verify-creation must be a pure function of work-item state,
92
+ // independent of who/what set `status: completed`. We do NOT short-circuit on
93
+ // the raw `_completionNotified` boolean — a PRD can land on disk pre-completed
94
+ // with the flag already set (out-of-band write by the plan-to-prd / pipeline
95
+ // path), and the legacy top-of-function `if (_completionNotified) return` then
96
+ // permanently skipped the aggregate build/test verify gate. Instead the flag
97
+ // gates ONLY the one-shot completion summary (below); control always falls
98
+ // through to the verify-creation block, which re-checks for an existing verify
99
+ // WI under the file lock and is therefore safe to reach every scan. The one
100
+ // exception is the REOPEN sub-path (terminal verify → re-open): that is a
101
+ // plan-modification concern (the dashboard clears `_completionNotified` when it
102
+ // re-opens a plan), so it is gated on `!alreadyNotified` to avoid bouncing an
103
+ // already-done verify on every steady-state scan.
104
+ const alreadyNotified = plan.status === PLAN_STATUS.COMPLETED && !!plan._completionNotified;
105
+
106
+ // Hard completion gate: every PRD feature ID must have a corresponding work item in a terminal state.
107
+ const planFeatureIds = new Set((plan.missing_features || []).map(f => f.id).filter(Boolean));
108
+ const workItemById = {};
109
+ for (const w of planItems) { if (w.id) workItemById[w.id] = w; }
110
+
86
111
  // Check 1: every feature must have a work item (materialized)
87
112
  // Fallback: also accept features marked done directly in the PRD JSON (resolved externally)
88
113
  const unmaterialized = [...planFeatureIds].filter(id => {
@@ -5647,9 +5672,16 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5647
5672
  const w = data.find(i => i.id === meta.item.id);
5648
5673
  if (!w) return data;
5649
5674
  const retries = w._retryCount || 0;
5675
+ // #893 gate failure reason — surfaced into the next dispatch's prompt via
5676
+ // buildWorkItemDispatchVars()'s retry_context var (W-mrbq53ra000lf2e5) so the
5677
+ // retried agent knows the PRD file was never found instead of silently
5678
+ // repeating the same mistake.
5679
+ const gateReason = `Validation gate (#893): no PRD JSON file was found on disk for planFile "${meta.item.planFile || w.planFile || '(unknown)'}" (expected "${expectedFile || '(unset)'}") after the previous attempt completed. Write the PRD JSON to the exact path specified in the playbook before finishing.`;
5650
5680
  if (retries < ENGINE_DEFAULTS.maxRetries) {
5651
5681
  w.status = WI_STATUS.PENDING;
5652
5682
  w._retryCount = retries + 1;
5683
+ w._lastRetryReason = gateReason;
5684
+ w._lastRetryAt = ts();
5653
5685
  // W-mpmwxn1j — bump per-agent counter so a planner that never
5654
5686
  // writes the PRD gets reassigned after maxRetriesPerAgent hits.
5655
5687
  const failedAgent = meta?._agentId || w.dispatched_to;
@@ -5661,6 +5693,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5661
5693
  w.status = WI_STATUS.FAILED;
5662
5694
  w.failReason = 'PRD file not written after ' + ENGINE_DEFAULTS.maxRetries + ' attempts';
5663
5695
  w.failedAt = ts();
5696
+ w._lastRetryReason = gateReason;
5664
5697
  log('warn', `plan-to-prd ${meta.item.id} failed — no PRD file after ${ENGINE_DEFAULTS.maxRetries} retries`);
5665
5698
  }
5666
5699
  return data;