@yemi33/minions 0.1.2288 → 0.1.2290

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.
@@ -754,6 +754,11 @@ const _STALE_PRD_STATUSES = new Set([WI_STATUS.DISPATCHED, WI_STATUS.FAILED, WI_
754
754
  // awaiting-approval PRDs. Previously only COMPLETED was skipped.
755
755
  const _PRD_FROZEN_STATUSES = new Set([
756
756
  PLAN_STATUS.COMPLETED, PLAN_STATUS.PAUSED, PLAN_STATUS.REJECTED, PLAN_STATUS.AWAITING_APPROVAL,
757
+ // 'archived' — Phase 10 step 4: archive becomes an in-place flag/status (the
758
+ // PRD stays in prd/ with status='archived' rather than moving to prd/archive/).
759
+ // A frozen PRD's item statuses must never be mutated; an archived-in-place PRD
760
+ // previously froze only by being moved out of the dir these scanners read.
761
+ 'archived',
757
762
  ]);
758
763
  function syncPrdItemStatus(itemId, status, sourcePlan) {
759
764
  if (!itemId) return;
@@ -1316,7 +1321,7 @@ async function enrollPrFromCanonicalId(canonicalPrId, project, opts = {}) {
1316
1321
  if (adoParts.length === 3) {
1317
1322
  const ado = require('./ado');
1318
1323
  if (typeof ado.fetchAdoPrMetadata === 'function') {
1319
- const meta = await ado.fetchAdoPrMetadata(prNumber, adoParts[0], adoParts[1], adoParts[2]);
1324
+ const meta = await ado.fetchAdoPrMetadata(prNumber, adoParts[0], adoParts[1], adoParts[2], project);
1320
1325
  if (meta) {
1321
1326
  title = String(meta.title || '').slice(0, 200);
1322
1327
  branch = meta.branch || '';
@@ -3296,6 +3301,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
3296
3301
  const planPath = path.join(prdDir, pf);
3297
3302
  mutateJsonFileLocked(planPath, (plan) => {
3298
3303
  if (!plan?.missing_features) return plan;
3304
+ if (shared.isPrdArchived(plan)) return plan; // step 4: never mutate an archived (in-place) PRD's items
3299
3305
  for (const feature of plan.missing_features) {
3300
3306
  if (mergedItemSet.has(feature.id) && feature.status !== WI_STATUS.DONE) {
3301
3307
  feature.status = WI_STATUS.DONE;
@@ -5216,7 +5222,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5216
5222
  if (meta.item.planFile) {
5217
5223
  try {
5218
5224
  const prd = safeJson(path.join(PRD_DIR, expectedFile));
5219
- if (prd && prd.source_plan === meta.item.planFile) {
5225
+ if (prd && shared.prdMatchesSourcePlan(prd.source_plan, meta.item.planFile)) {
5220
5226
  prdFound = true;
5221
5227
  resolvedPrdFilename = expectedFile;
5222
5228
  } else {
@@ -5243,7 +5249,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5243
5249
  if (!f.endsWith('.json')) continue;
5244
5250
  try {
5245
5251
  const prd = safeJson(path.join(PRD_DIR, f));
5246
- if (prd && prd.source_plan === meta.item.planFile) {
5252
+ if (prd && !shared.isPrdArchived(prd) && shared.prdMatchesSourcePlan(prd.source_plan, meta.item.planFile)) {
5247
5253
  prdFound = true;
5248
5254
  resolvedPrdFilename = f;
5249
5255
  break;
@@ -62,10 +62,15 @@
62
62
  * Worktree (isolated) mode is unaffected — that path still branches from
63
63
  * origin/<mainRef> for a clean baseline elsewhere in spawnAgent.
64
64
  *
65
- * PURE — does NOT call completeDispatch, does NOT write inbox alerts. That
66
- * translation belongs in spawnAgent (engine.js). Keeping this helper free of
67
- * side effects lets unit tests exercise every branch with a mocked git
68
- * runner and zero filesystem touching.
65
+ * MOSTLY PURE — does NOT call completeDispatch. It does NOT mutate the tree or
66
+ * write inbox alerts on any default path. The ONE opt-in exception is auto-reset
67
+ * (W-mqvejug6000eeb20): when `liveCheckoutAutoReset` is enabled AND the tree is
68
+ * dirty, it runs `git fetch origin` + `git reset --hard origin/<branch>` and
69
+ * writes a single `live-checkout-autoreset-<wiId>` inbox note. Both the reset
70
+ * git ops and the note writer are injectable (`_git`, `_writeInboxNote`) and the
71
+ * decision is injectable (`autoReset` / `_resolveAutoReset`), so unit tests stay
72
+ * filesystem-free. Default (auto-reset off) keeps the helper free of side
73
+ * effects — the dirty/blocked translation belongs in spawnAgent (engine.js).
69
74
  *
70
75
  * All git invocations route through `shared.shellSafeGit` (argv-form,
71
76
  * shell:false, execFile). Branch and main-ref are validated via
@@ -86,6 +91,17 @@ const fs = require('fs');
86
91
  const path = require('path');
87
92
  const shared = require('./shared');
88
93
 
94
+ // W-mqvejug6000eeb20 — generous stdout ceiling for the read-only git probes in
95
+ // this module (`git status --porcelain`, `rev-parse`, …). Node's execFile
96
+ // default maxBuffer is 1 MB; a live-checkout tree with a very large number of
97
+ // dirty/untracked paths can blow past that on `git status --porcelain`, and the
98
+ // resulting ERR_CHILD_PROCESS_STDOUT_MAXBUFFER rejection was being mis-surfaced
99
+ // as a transient/retryable LIVE_CHECKOUT_FAILED that retry-storms to the cap.
100
+ // 50 MB is far above any realistic porcelain output (≈ thousands of files) yet
101
+ // still bounded. Threaded through `baseOpts` so every git call inherits it; the
102
+ // injected `_git` mock in unit tests ignores opts so the seam is unaffected.
103
+ const LIVE_CHECKOUT_GIT_MAX_BUFFER = 50 * 1024 * 1024;
104
+
89
105
  // PL-live-checkout-reliability-hardening — partial-clone / GVFS blob-fetch
90
106
  // signature match. On a Scalar/GVFS-managed ADO repo (blobless partial clone),
91
107
  // switching the working tree onto a branch whose tree differs from HEAD must
@@ -117,6 +133,37 @@ function _isPartialCloneBlobError(message = '') {
117
133
  );
118
134
  }
119
135
 
136
+ // W-mqvejug6000eeb20 — production default for resolving the live-checkout
137
+ // auto-reset decision when the caller (engine.js spawnAgent) does not pass an
138
+ // explicit `autoReset` boolean. engine.js is intentionally not threading the
139
+ // project/engine config into prepareLiveCheckout, so we self-resolve: load
140
+ // config.json, match the project by `localPath`, and defer to
141
+ // `shared.resolveLiveCheckoutAutoReset` (per-project boolean wins, else
142
+ // engine-level fallback, else false). Runs only on the dirty path (cold, not
143
+ // hot), and fails CLOSED — any error reading config returns false so a config
144
+ // glitch can never silently trigger a destructive reset.
145
+ function _defaultResolveAutoReset(localPath) {
146
+ try {
147
+ const config = shared.safeJson(path.join(shared.MINIONS_DIR, 'config.json')) || {};
148
+ const projects = shared.getProjects(config);
149
+ const norm = (p) => path.resolve(String(p || '')).replace(/\\/g, '/').toLowerCase();
150
+ const target = norm(localPath);
151
+ const project = projects.find((p) => p && p.localPath && norm(p.localPath) === target) || null;
152
+ return shared.resolveLiveCheckoutAutoReset(project, config.engine);
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ // W-mqvejug6000eeb20 — production default inbox-note writer for the auto-reset
159
+ // audit trail. Lazily required to avoid a load-order cycle (dispatch.js does not
160
+ // require live-checkout.js, but the lazy require keeps this module importable in
161
+ // isolation / in tests that never trigger the write). Best-effort.
162
+ function _defaultWriteInboxNote(slug, content) {
163
+ // eslint-disable-next-line global-require
164
+ return require('./dispatch').writeInboxAlert(slug, content);
165
+ }
166
+
120
167
  async function prepareLiveCheckout(opts = {}) {
121
168
  const {
122
169
  localPath,
@@ -124,10 +171,16 @@ async function prepareLiveCheckout(opts = {}) {
124
171
  mainRef,
125
172
  gitOpts,
126
173
  dispatchId, // accepted for caller bookkeeping; not used by the helper
127
- wiId, // accepted for caller bookkeeping; not used by the helper
174
+ wiId, // accepted for caller bookkeeping; used in the auto-reset note slug
175
+ skipDirtyCheck, // #522: opt-out for GVFS repos where git status output exceeds maxBuffer
128
176
  log,
177
+ autoReset, // W-mqvejug6000eeb20: tri-state. `true`/`false` short-circuits the
178
+ // config-based resolution; `undefined` (the engine.js call shape)
179
+ // defers to `_resolveAutoReset`.
129
180
  _git, // private injection for testing — defaults to shared.shellSafeGit
130
181
  _exists, // private injection for testing — defaults to fs.existsSync
182
+ _resolveAutoReset, // private injection for testing — defaults to config-based resolver
183
+ _writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
131
184
  } = opts;
132
185
 
133
186
  // ── Required-arg guards. Throw rather than return {ok:false} so a
@@ -154,9 +207,20 @@ async function prepareLiveCheckout(opts = {}) {
154
207
 
155
208
  const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
156
209
  const exists = (typeof _exists === 'function') ? _exists : fs.existsSync;
157
- const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
210
+ // W-mqvejug6000eeb20 — default maxBuffer (50 MB) so a large `git status
211
+ // --porcelain` never overflows execFile's 1 MB ceiling. A caller-supplied
212
+ // gitOpts.maxBuffer still wins (spread after the default).
213
+ const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
158
214
 
159
215
  // ── Step 1: git status --porcelain=v1 -b. Bail early on dirty tree. ─────
216
+ // Skipped when `skipDirtyCheck:true` (issue #522): GVFS/VFS-for-Git repos
217
+ // report all un-hydrated virtual files as modified — this is normal GVFS
218
+ // behavior, not actual WIP. On repos with ~120k virtual dirty files the
219
+ // buffered `git status` output exceeds Node's default maxBuffer (~1MB) and
220
+ // throws before any agent spawns. Setting `skipDirtyCheck:true` via the
221
+ // project-level `skipLiveCheckoutDirtyCheck` config key bypasses the probe
222
+ // entirely so agents can dispatch normally on GVFS repos.
223
+ //
160
224
  // Porcelain v1 -b adds a `## <branch>` header line as the first output line
161
225
  // so callers get branch diagnostics for free alongside the file-status lines.
162
226
  // The file-status lines still use the two-char XY status code (e.g.
@@ -165,16 +229,94 @@ async function prepareLiveCheckout(opts = {}) {
165
229
  // trailing whitespace/CR per line — outer-trimming the whole blob would eat
166
230
  // the leading XY space on the first line. The `## ` header is separated out
167
231
  // before the dirtyFiles check so callers receive it as `branchInfo`.
168
- const statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
169
- const statusStr = typeof statusRaw === 'string' ? statusRaw : '';
170
- const statusLines = statusStr
171
- .split(/\r?\n/)
172
- .map((line) => line.replace(/\s+$/, ''))
173
- .filter((line) => line.length > 0);
174
- const branchInfo = statusLines.find((line) => line.startsWith('## ')) || '';
175
- const dirtyFiles = statusLines.filter((line) => !line.startsWith('## '));
176
- if (dirtyFiles.length > 0) {
177
- return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
232
+ if (!skipDirtyCheck) {
233
+ const statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
234
+ const statusStr = typeof statusRaw === 'string' ? statusRaw : '';
235
+ const statusLines = statusStr
236
+ .split(/\r?\n/)
237
+ .map((line) => line.replace(/\s+$/, ''))
238
+ .filter((line) => line.length > 0);
239
+ const branchInfo = statusLines.find((line) => line.startsWith('## ')) || '';
240
+ const dirtyFiles = statusLines.filter((line) => !line.startsWith('## '));
241
+ if (dirtyFiles.length > 0) {
242
+ // W-mqvejug6000eeb20 — opt-in auto-reset. Default behavior is to bail with
243
+ // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
244
+ // and alerts the operator). When auto-reset is enabled — either the caller
245
+ // passed an explicit `autoReset` boolean, or the config-based resolver says
246
+ // so — we DISCARD the dirty state via `git fetch origin` + `git reset --hard
247
+ // origin/<branch>` and continue. This is destructive (the operator's
248
+ // uncommitted work is gone), which is why it is strictly opt-in.
249
+ let wantAutoReset = false;
250
+ if (typeof autoReset === 'boolean') {
251
+ wantAutoReset = autoReset;
252
+ } else {
253
+ const resolver = (typeof _resolveAutoReset === 'function') ? _resolveAutoReset : _defaultResolveAutoReset;
254
+ try { wantAutoReset = !!resolver(localPath); } catch { wantAutoReset = false; }
255
+ }
256
+
257
+ if (!wantAutoReset) {
258
+ return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
259
+ }
260
+
261
+ if (typeof log === 'function') {
262
+ log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
263
+ }
264
+ let resetOk = true;
265
+ try {
266
+ await git(['fetch', 'origin'], baseOpts);
267
+ await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
268
+ } catch (e) {
269
+ resetOk = false;
270
+ if (typeof log === 'function') {
271
+ log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
272
+ }
273
+ }
274
+
275
+ // Re-run the porcelain preflight once. If the tree is now clean we proceed;
276
+ // otherwise (reset failed, or something is still dirty) we fall back to the
277
+ // safe dirty refusal so we never dispatch onto an unexpected tree.
278
+ let stillDirty = dirtyFiles;
279
+ if (resetOk) {
280
+ try {
281
+ const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
282
+ const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
283
+ stillDirty = recheckStr
284
+ .split(/\r?\n/)
285
+ .map((line) => line.replace(/\s+$/, ''))
286
+ .filter((line) => line.length > 0 && !line.startsWith('## '));
287
+ } catch (e) {
288
+ resetOk = false;
289
+ if (typeof log === 'function') {
290
+ log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
291
+ }
292
+ }
293
+ }
294
+
295
+ if (!resetOk || stillDirty.length > 0) {
296
+ return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
297
+ }
298
+
299
+ // Audit trail: record the discarded paths so the operator can recover from
300
+ // reflog / understand why their tree changed. Best-effort, never throws.
301
+ try {
302
+ const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
303
+ const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
304
+ const body = [
305
+ `# Live-checkout auto-reset on '${branchName}'`,
306
+ '',
307
+ `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
308
+ '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
309
+ `\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
310
+ '(recover from `git reflog` / `git fsck --lost-found` if needed):',
311
+ '',
312
+ '```',
313
+ ...dirtyFiles,
314
+ '```',
315
+ ].join('\n');
316
+ writeNote(slug, body);
317
+ } catch { /* best-effort audit note */ }
318
+ // Fall through — tree is now clean, continue with normal preparation.
319
+ }
178
320
  }
179
321
 
180
322
  // ── Step 2: mid-operation / detached-HEAD preflight (P-b2e8d4a6). ──────
@@ -397,6 +539,7 @@ async function prepareLiveCheckout(opts = {}) {
397
539
  * @param {boolean} [opts.isTerminalFailure] true on error/timeout/crash
398
540
  * @param {string} [opts.resultLabel] short status word for the alert body
399
541
  * @param {object} [opts.gitOpts] extra execFile opts merged into cwd
542
+ * @param {boolean} [opts.skipDirtyCheck] #522: skip the self-healing dirty probe (GVFS repos)
400
543
  * @param {function} [opts.log] (level, msg) => void
401
544
  * @param {function} opts.writeInboxAlert (slug, body) => any (false = deduped)
402
545
  * @param {function} [opts._git] test seam; defaults to shellSafeGit
@@ -413,6 +556,7 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
413
556
  isTerminalFailure,
414
557
  resultLabel,
415
558
  gitOpts,
559
+ skipDirtyCheck, // #522: opt-out for GVFS repos — same flag as prepareLiveCheckout
416
560
  log,
417
561
  writeInboxAlert,
418
562
  _git, // private injection for testing — defaults to shared.shellSafeGit
@@ -423,7 +567,7 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
423
567
  const alert = (typeof writeInboxAlert === 'function') ? writeInboxAlert : () => {};
424
568
  const proj = projectName || 'project';
425
569
  const did = dispatchId || '(unknown)';
426
- const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
570
+ const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
427
571
 
428
572
  const result = { restored: false, reason: null, failureAlerted: false, fallbackAlerted: false };
429
573
 
@@ -502,7 +646,16 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
502
646
  // never reaches here (git status is empty → not dirty). Best-effort: a
503
647
  // failed commit falls through to the plain checkout, which refuses and
504
648
  // writes the existing manual-recovery alert — no worse than before.
505
- if (branchName && branchName !== originalRef) {
649
+ //
650
+ // Skipped when `skipDirtyCheck:true` (#522 / GVFS repos): on GVFS/VFS-for-Git
651
+ // trees, `git status` reports ~120k un-hydrated virtual files as modified even
652
+ // when the operator's tree is effectively clean. Since prepareLiveCheckout
653
+ // also skips the dirty probe on GVFS repos, the precondition "tree was verified
654
+ // clean before the agent ran" doesn't hold — we cannot distinguish real
655
+ // agent-authored dirt from GVFS virtual noise. Skipping the self-heal here
656
+ // is safe: the plain `git checkout <originalRef>` below will succeed because
657
+ // GVFS's virtual dirty state does NOT block git checkout.
658
+ if (!skipDirtyCheck && branchName && branchName !== originalRef) {
506
659
  let dirty = false;
507
660
  try {
508
661
  const statusRaw = await git(['status', '--porcelain'], baseOpts);
@@ -432,6 +432,9 @@ const PLAYBOOK_REQUIRED_VARS = {
432
432
  'meeting-investigate': ['meeting_title', 'agenda'],
433
433
  'meeting-debate': ['meeting_title', 'agenda'],
434
434
  'meeting-conclude': ['meeting_title', 'agenda'],
435
+ // M005 — SHERLOC two-phase build-fix. Same required vars as fix (PR-context
436
+ // dispatch), but routed to build-fix-complex.md for multi-file failures.
437
+ 'build-fix-complex': ['pr_id', 'pr_branch'],
435
438
  };
436
439
 
437
440
  /**
@@ -878,6 +881,7 @@ function renderPlaybook(type, vars) {
878
881
  if (block) inertAppendices.push(block);
879
882
  } catch (e) { log('warn', `handoff-context inject failed: ${e.message}`); }
880
883
  }
884
+
881
885
  // Inject KB guardrail
882
886
  content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
883
887
  content += `**Never delete, move, or overwrite files in \`knowledge/\`.** The sweep (consolidation engine) is the only process that writes to \`knowledge/\`. If you think a KB file is wrong, note it in your learnings file — do not touch \`knowledge/\` directly.\n`;
@@ -1306,6 +1310,7 @@ function selectPlaybook(workType, item) {
1306
1310
  if (workType === WORK_TYPE.FIX && hasPrContext) {
1307
1311
  return 'fix';
1308
1312
  }
1313
+ // M005 — SHERLOC: complex build-fix variant, always PR-context (mirrors FIX routing).
1309
1314
  if (workType === WORK_TYPE.BUILD_FIX_COMPLEX && hasPrContext) {
1310
1315
  return 'build-fix-complex';
1311
1316
  }
package/engine/queries.js CHANGED
@@ -2158,11 +2158,18 @@ function getPrdInfo(config) {
2158
2158
  // two paths is proven by test/unit/db-phase10-read-flip.test.js.
2159
2159
  const processPrd = (pf, archived, plan, mtimeMs) => {
2160
2160
  if (!plan || !plan.missing_features) return;
2161
+ // Phase 10 step 4.2: archived-ness is the FLAG, not the directory. `archived`
2162
+ // is the physical-location signal (prd/ vs prd/archive/); a PRD archived
2163
+ // IN PLACE (stays in prd/ with archived:true / status:'archived') must still
2164
+ // render as archived. Derive the logical value here — the single funnel both
2165
+ // the disk-scan and SQL-mirror sources pass through. Equivalent for current
2166
+ // data (nothing is archived-in-place yet → isPrdArchived is false → ===archived).
2167
+ const isArch = archived || shared.isPrdArchived(plan);
2161
2168
  // Staleness: gate on actual source-plan content change rather than mtime
2162
2169
  // alone (W-mqfevwr60018bd09). Pure path / pure mtime drifts return
2163
2170
  // stale=false so a repointed source_plan doesn't masquerade as a revision.
2164
2171
  let planStale = false;
2165
- if (!archived && plan.source_plan) {
2172
+ if (!isArch && plan.source_plan) {
2166
2173
  try { planStale = shared.isSourcePlanContentStale(PLANS_DIR, plan).stale; } catch { /* optional */ }
2167
2174
  }
2168
2175
  existingPrds.push({
@@ -2170,7 +2177,7 @@ function getPrdInfo(config) {
2170
2177
  status: plan.status || 'active',
2171
2178
  planStale: planStale || plan.planStale || false,
2172
2179
  completedAt: plan.completedAt || '',
2173
- _archived: archived,
2180
+ _archived: isArch,
2174
2181
  });
2175
2182
  if (Array.isArray(plan.verifyPrs) && plan.verifyPrs.length > 0) {
2176
2183
  verifyPrsByPlan[pf] = plan.verifyPrs.map(r => ({
@@ -2183,7 +2190,7 @@ function getPrdInfo(config) {
2183
2190
  allPrdItems.push({
2184
2191
  ...f, _source: pf, _planStatus: plan.status || 'active',
2185
2192
  _planSummary: plan.plan_summary || pf, _planProject: plan.project || '',
2186
- _archived: archived, _sourcePlan: plan.source_plan || '',
2193
+ _archived: isArch, _sourcePlan: plan.source_plan || '',
2187
2194
  _branchStrategy: plan.branch_strategy || 'parallel',
2188
2195
  _planStale: planStale || plan.planStale || false, _lastSyncedFromPlan: plan.lastSyncedFromPlan || null,
2189
2196
  _prdUpdatedAt: new Date(mtimeMs).toISOString(),
package/engine/shared.js CHANGED
@@ -744,17 +744,46 @@ function safeReadDir(dir) {
744
744
  // also fall back to plans/archive/<basename> so a silently-relocated
745
745
  // source plan no longer spams ENOENT warnings on every tick.
746
746
 
747
+ /**
748
+ * Canonical association key for a plan/PRD source reference: the bare basename,
749
+ * stripped of any directory AND the legacy `plans/` (or `plans/archive/`) prefix
750
+ * some PRDs carry in their `source_plan` field. This is THE single key for
751
+ * matching a plan markdown file to the PRD(s) that point at it.
752
+ *
753
+ * It exists because `source_plan` is written inconsistently — sometimes a bare
754
+ * `x.md`, sometimes a prefixed `plans/x.md` (pre-#415). Comparing the raw
755
+ * strings (`prd.source_plan === planFile`) silently fails across the two forms,
756
+ * which let an archived plan keep a LIVE PRD — the plan↔PRD status desync behind
757
+ * the "old PRDs came back" resurrection. Always compare/derive via this helper.
758
+ */
759
+ function sourcePlanKey(ref) {
760
+ return path.basename(String(ref || ''));
761
+ }
762
+
763
+ /**
764
+ * True when a PRD's `source_plan` references the given plan file, tolerant of
765
+ * the `plans/` prefix + directory differences. Use instead of raw `===`.
766
+ */
767
+ function prdMatchesSourcePlan(sourcePlanRef, planFile) {
768
+ const a = sourcePlanKey(sourcePlanRef);
769
+ const b = sourcePlanKey(planFile);
770
+ return !!a && a === b;
771
+ }
772
+
747
773
  /**
748
774
  * Resolve a PRD's `source_plan` field to an absolute file path under
749
775
  * `plansDir`. Tries the direct join first; falls back to
750
- * plansDir/archive/<basename(source_plan)> when the direct path is
751
- * missing. Returns null when neither location resolves to a regular file.
776
+ * plansDir/archive/<basename> when the direct path is missing. The direct join
777
+ * uses the canonical key so a `plans/`-prefixed source_plan resolves correctly
778
+ * (a raw join would yield plansDir/plans/x.md). Returns null when neither
779
+ * location resolves to a regular file.
752
780
  */
753
781
  function resolveSourcePlanPath(plansDir, sourcePlan) {
754
782
  if (!plansDir || !sourcePlan || typeof sourcePlan !== 'string') return null;
755
- const direct = path.join(plansDir, sourcePlan);
783
+ const key = sourcePlanKey(sourcePlan);
784
+ const direct = path.join(plansDir, key);
756
785
  try { if (fs.statSync(direct).isFile()) return direct; } catch { /* miss */ }
757
- const archived = path.join(plansDir, 'archive', path.basename(sourcePlan));
786
+ const archived = path.join(plansDir, 'archive', key);
758
787
  try { if (fs.statSync(archived).isFile()) return archived; } catch { /* miss */ }
759
788
  return null;
760
789
  }
@@ -2677,6 +2706,26 @@ function isLiveCheckoutProject(project) {
2677
2706
  return resolveCheckoutMode(project) === CHECKOUT_MODES.LIVE;
2678
2707
  }
2679
2708
 
2709
+ // W-mqvejug6000eeb20 — resolve the effective live-checkout auto-reset decision.
2710
+ // Precedence: an explicit per-project boolean (`project.liveCheckoutAutoReset`)
2711
+ // wins; otherwise the fleet-wide `engine.liveCheckoutAutoReset` applies;
2712
+ // otherwise `false`. Only an explicit boolean counts as "set" at either level —
2713
+ // anything else (undefined / null / string) is treated as unset so the next
2714
+ // tier (or the `false` default) decides. When ON and a live-checkout tree is
2715
+ // dirty, `engine/live-checkout.js#prepareLiveCheckout` runs
2716
+ // `git fetch origin` + `git reset --hard origin/<branch>` instead of refusing
2717
+ // with LIVE_CHECKOUT_DIRTY (the reset DISCARDS the operator's uncommitted work,
2718
+ // so it is opt-in). Sibling of `resolveCheckoutMode` — pure, no I/O.
2719
+ function resolveLiveCheckoutAutoReset(project, engine) {
2720
+ if (project && typeof project === 'object' && typeof project.liveCheckoutAutoReset === 'boolean') {
2721
+ return project.liveCheckoutAutoReset;
2722
+ }
2723
+ if (engine && typeof engine === 'object' && typeof engine.liveCheckoutAutoReset === 'boolean') {
2724
+ return engine.liveCheckoutAutoReset;
2725
+ }
2726
+ return false;
2727
+ }
2728
+
2680
2729
  function validateCheckoutMode(value) {
2681
2730
  if (value === undefined || value === null || value === '') return undefined;
2682
2731
  if (typeof value !== 'string') {
@@ -3143,6 +3192,15 @@ const ENGINE_DEFAULTS = {
3143
3192
  // itself ALWAYS runs on every orphan-sweep escalation (the flag only gates
3144
3193
  // the kill); holder details are appended to the inbox note either way.
3145
3194
  autoReapOrphanWorktreeHolders: false,
3195
+ // W-mqvejug6000eeb20 — fleet-wide fallback for live-checkout auto-reset.
3196
+ // When ON, a dirty/broken live-checkout tree is `git fetch origin` +
3197
+ // `git reset --hard origin/<branch>`'d before dispatch instead of failing
3198
+ // LIVE_CHECKOUT_DIRTY. Per-project `project.liveCheckoutAutoReset` overrides
3199
+ // this. Default OFF — auto-reset is destructive (it discards the operator's
3200
+ // uncommitted changes), so it is strictly opt-in. Resolution precedence lives
3201
+ // in `resolveLiveCheckoutAutoReset`. Fires ONLY on confirmed dirty-tree
3202
+ // detection, never on mid-operation / blob-fetch / tooling failures.
3203
+ liveCheckoutAutoReset: false,
3146
3204
  orphanHolderScanTimeoutMs: 5000, // 5s ceiling for the cross-platform holder scan (PowerShell / /proc walk / lsof)
3147
3205
  ccMaxTurns: 50, // max tool-use turns per CC/doc-chat call before CLI stops (per response, not per session)
3148
3206
  ccTurnTimeoutMs: 300000, // W-mpmwxni2000c25c7-b/-d: 5min per-turn no-progress watchdog. The window resets on every liveness signal — token chunk, tool-call notification, tool-update — so an actively-streaming CC/doc-chat turn (long shell command, deep search, sub-agent loop) survives indefinitely up to the outer CC_CALL_TIMEOUT_MS (~1h) ceiling. Only true silence past this window with no progress fires the cancel: the in-flight LLM call is aborted and the handler surfaces `{code:'cc-turn-timeout', retryable:true}` via the typed error envelope so the UI can stop the spinner and offer Retry. Clamped to [10000, 3600000] in the settings POST handler. Independent of CC_CALL_TIMEOUT_MS. Non-streaming doc-chat is the lone wall-clock exception (no progress hooks); see _raceCcDocChatTimeout in dashboard.js for the dual factory/promise shape.
@@ -3804,6 +3862,44 @@ const DONE_STATUSES = new Set([WI_STATUS.DONE, 'in-pr', 'implemented', 'complete
3804
3862
  // Terminal statuses for plan completion — item won't progress further (done, failed, cancelled).
3805
3863
  // Used by checkPlanCompletion to unblock the gate when items are in an unrecoverable state.
3806
3864
  const PLAN_TERMINAL_STATUSES = new Set([...DONE_STATUSES, WI_STATUS.FAILED, WI_STATUS.CANCELLED]);
3865
+
3866
+ // True when a PRD is archived — by an explicit flag or the 'archived' top-level
3867
+ // status. (Distinct from the directory: a PRD JSON can carry this regardless of
3868
+ // whether it physically sits in prd/ or prd/archive/.)
3869
+ function isPrdArchived(plan) {
3870
+ return !!plan && typeof plan === 'object' && (plan.archived === true || plan.status === 'archived');
3871
+ }
3872
+
3873
+ // True when a PRD must NOT be (re)materialized or re-verified because it is
3874
+ // DEFUNCT — a dead leftover whose plan is no longer live. This is the
3875
+ // resurrection-re-execution guard (the "old PRDs came back" class): the
3876
+ // autonomous fleet keeps committing prd/*.json onto feature/cc-pr/backport
3877
+ // branches (vector 2) and checking those branches out IN the operator's live
3878
+ // checkout, which restores dead PRD files into prd/. The materializer then
3879
+ // re-creates and re-runs already-finished work — regardless of the resurrected
3880
+ // PRD's own status (awaiting-approval / undefined / completed), so a status-only
3881
+ // check is not enough.
3882
+ //
3883
+ // Definition (plan↔PRD status is meant to stay synced — see prdMatchesSourcePlan):
3884
+ // • archived flag/status → defunct; else
3885
+ // • plan markdown is LIVE in plansDir → NEVER defunct (active PRD / legit
3886
+ // re-open keeps its plan live — this is the catastrophe guard: we never flag
3887
+ // a PRD whose plan is present); else
3888
+ // • plan is gone from plansDir AND (it's positively in plansDir/archive/, OR
3889
+ // the PRD is completed) → defunct.
3890
+ // On any fs uncertainty we DON'T flag (favor not blocking real work).
3891
+ function isDefunctPrd(plan, plansDir) {
3892
+ if (isPrdArchived(plan)) return true;
3893
+ if (!plan || typeof plan !== 'object' || !plansDir) return false;
3894
+ const sp = sourcePlanKey(plan.source_plan || plan.sourcePlan);
3895
+ if (!sp) return false;
3896
+ let liveExists;
3897
+ try { liveExists = fs.existsSync(path.join(plansDir, sp)); } catch { return false; }
3898
+ if (liveExists) return false; // plan is live → active PRD, never defunct
3899
+ let archivedExists = false;
3900
+ try { archivedExists = fs.existsSync(path.join(plansDir, 'archive', sp)); } catch { /* treat as absent */ }
3901
+ return archivedExists || plan.status === 'completed';
3902
+ }
3807
3903
  const WORK_TYPE = {
3808
3904
  IMPLEMENT: 'implement', IMPLEMENT_LARGE: 'implement:large', FIX: 'fix', REVIEW: 'review',
3809
3905
  VERIFY: 'verify', PLAN: 'plan', PLAN_TO_PRD: 'plan-to-prd', DECOMPOSE: 'decompose',
@@ -8850,7 +8946,7 @@ module.exports = {
8850
8946
  runtimeConfigWarnings,
8851
8947
  projectWorkSourceWarnings,
8852
8948
  backfillProjectWorkSourceDefaults,
8853
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject, extractPlanTargetProjects,
8949
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, isPrdArchived, isDefunctPrd, WORK_TYPE, WORKTREE_REQUIRING_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject, extractPlanTargetProjects,
8854
8950
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
8855
8951
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
8856
8952
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
@@ -8927,6 +9023,8 @@ module.exports = {
8927
9023
  getAdoOrgBase,
8928
9024
  sanitizePath,
8929
9025
  resolveSourcePlanPath, // W-mqfevwr60018bd09 — PRD staleness: resolve source_plan to absolute path (archive-aware)
9026
+ sourcePlanKey, prdMatchesSourcePlan, // canonical plan↔PRD association key (prefix/dir-tolerant) — keeps plan+PRD status synced
9027
+
8930
9028
  computeSourcePlanContentHash, // W-mqfevwr60018bd09 — sha256 of source plan markdown body
8931
9029
  isSourcePlanContentStale, // W-mqfevwr60018bd09 — gate destructive PRD resync on actual content change
8932
9030
  sanitizeBranch,
@@ -8970,6 +9068,7 @@ module.exports = {
8970
9068
  validateCheckoutMode,
8971
9069
  resolveCheckoutMode,
8972
9070
  isLiveCheckoutProject,
9071
+ resolveLiveCheckoutAutoReset,
8973
9072
  validatePid,
8974
9073
  PR_FIX_CAUSE,
8975
9074
  getPrFixAutomationCause,