@yemi33/minions 0.1.2287 → 0.1.2289

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,15 @@ 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
128
175
  log,
176
+ autoReset, // W-mqvejug6000eeb20: tri-state. `true`/`false` short-circuits the
177
+ // config-based resolution; `undefined` (the engine.js call shape)
178
+ // defers to `_resolveAutoReset`.
129
179
  _git, // private injection for testing — defaults to shared.shellSafeGit
130
180
  _exists, // private injection for testing — defaults to fs.existsSync
181
+ _resolveAutoReset, // private injection for testing — defaults to config-based resolver
182
+ _writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
131
183
  } = opts;
132
184
 
133
185
  // ── Required-arg guards. Throw rather than return {ok:false} so a
@@ -154,7 +206,10 @@ async function prepareLiveCheckout(opts = {}) {
154
206
 
155
207
  const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
156
208
  const exists = (typeof _exists === 'function') ? _exists : fs.existsSync;
157
- const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
209
+ // W-mqvejug6000eeb20 default maxBuffer (50 MB) so a large `git status
210
+ // --porcelain` never overflows execFile's 1 MB ceiling. A caller-supplied
211
+ // gitOpts.maxBuffer still wins (spread after the default).
212
+ const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
158
213
 
159
214
  // ── Step 1: git status --porcelain=v1 -b. Bail early on dirty tree. ─────
160
215
  // Porcelain v1 -b adds a `## <branch>` header line as the first output line
@@ -174,7 +229,83 @@ async function prepareLiveCheckout(opts = {}) {
174
229
  const branchInfo = statusLines.find((line) => line.startsWith('## ')) || '';
175
230
  const dirtyFiles = statusLines.filter((line) => !line.startsWith('## '));
176
231
  if (dirtyFiles.length > 0) {
177
- return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
232
+ // W-mqvejug6000eeb20 opt-in auto-reset. Default behavior is to bail with
233
+ // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
234
+ // and alerts the operator). When auto-reset is enabled — either the caller
235
+ // passed an explicit `autoReset` boolean, or the config-based resolver says
236
+ // so — we DISCARD the dirty state via `git fetch origin` + `git reset --hard
237
+ // origin/<branch>` and continue. This is destructive (the operator's
238
+ // uncommitted work is gone), which is why it is strictly opt-in.
239
+ let wantAutoReset = false;
240
+ if (typeof autoReset === 'boolean') {
241
+ wantAutoReset = autoReset;
242
+ } else {
243
+ const resolver = (typeof _resolveAutoReset === 'function') ? _resolveAutoReset : _defaultResolveAutoReset;
244
+ try { wantAutoReset = !!resolver(localPath); } catch { wantAutoReset = false; }
245
+ }
246
+
247
+ if (!wantAutoReset) {
248
+ return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
249
+ }
250
+
251
+ if (typeof log === 'function') {
252
+ log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
253
+ }
254
+ let resetOk = true;
255
+ try {
256
+ await git(['fetch', 'origin'], baseOpts);
257
+ await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
258
+ } catch (e) {
259
+ resetOk = false;
260
+ if (typeof log === 'function') {
261
+ log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
262
+ }
263
+ }
264
+
265
+ // Re-run the porcelain preflight once. If the tree is now clean we proceed;
266
+ // otherwise (reset failed, or something is still dirty) we fall back to the
267
+ // safe dirty refusal so we never dispatch onto an unexpected tree.
268
+ let stillDirty = dirtyFiles;
269
+ if (resetOk) {
270
+ try {
271
+ const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
272
+ const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
273
+ stillDirty = recheckStr
274
+ .split(/\r?\n/)
275
+ .map((line) => line.replace(/\s+$/, ''))
276
+ .filter((line) => line.length > 0 && !line.startsWith('## '));
277
+ } catch (e) {
278
+ resetOk = false;
279
+ if (typeof log === 'function') {
280
+ log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
281
+ }
282
+ }
283
+ }
284
+
285
+ if (!resetOk || stillDirty.length > 0) {
286
+ return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
287
+ }
288
+
289
+ // Audit trail: record the discarded paths so the operator can recover from
290
+ // reflog / understand why their tree changed. Best-effort, never throws.
291
+ try {
292
+ const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
293
+ const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
294
+ const body = [
295
+ `# Live-checkout auto-reset on '${branchName}'`,
296
+ '',
297
+ `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
298
+ '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
299
+ `\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
300
+ '(recover from `git reflog` / `git fsck --lost-found` if needed):',
301
+ '',
302
+ '```',
303
+ ...dirtyFiles,
304
+ '```',
305
+ ].join('\n');
306
+ writeNote(slug, body);
307
+ } catch { /* best-effort audit note */ }
308
+ // Fall through — tree is now clean, continue with normal preparation.
178
309
  }
179
310
 
180
311
  // ── Step 2: mid-operation / detached-HEAD preflight (P-b2e8d4a6). ──────
@@ -423,7 +554,7 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
423
554
  const alert = (typeof writeInboxAlert === 'function') ? writeInboxAlert : () => {};
424
555
  const proj = projectName || 'project';
425
556
  const did = dispatchId || '(unknown)';
426
- const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
557
+ const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
427
558
 
428
559
  const result = { restored: false, reason: null, failureAlerted: false, fallbackAlerted: false };
429
560
 
@@ -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
  }
@@ -205,7 +205,7 @@ function reconcilePrdsFromDisk() {
205
205
  let mtime;
206
206
  try { mtime = Math.round(fs.statSync(full).mtimeMs); } catch { continue; }
207
207
  if (_reconcileMtimes.get(key) === mtime) continue; // unchanged since last seen
208
- const prd = _readJsonObj(full);
208
+ const prd = require('./shared').safeJsonNoRestore(full);
209
209
  if (!prd) continue;
210
210
  try {
211
211
  const { withTransaction } = require('./db');
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,