@yemi33/minions 0.1.2177 → 0.1.2179

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.
Files changed (44) hide show
  1. package/bin/minions.js +24 -11
  2. package/dashboard/js/command-parser.js +1 -1
  3. package/dashboard/js/memory-panel.js +262 -0
  4. package/dashboard/js/qa.js +2 -2
  5. package/dashboard/js/refresh.js +9 -1
  6. package/dashboard/js/render-dispatch.js +92 -0
  7. package/dashboard/js/render-other.js +1 -1
  8. package/dashboard/js/render-plans.js +82 -13
  9. package/dashboard/js/render-prs.js +2 -1
  10. package/dashboard/js/render-schedules.js +1 -1
  11. package/dashboard/js/render-watches.js +1 -1
  12. package/dashboard/js/settings.js +100 -11
  13. package/dashboard/layout.html +6 -0
  14. package/dashboard/pages/engine-memory-panel.html +49 -0
  15. package/dashboard/pages/engine.html +1 -0
  16. package/dashboard/slim/js/link-pr.js +5 -5
  17. package/dashboard/slim/js/modals-tiles.js +44 -3
  18. package/dashboard/slim/js/projects.js +8 -6
  19. package/dashboard/slim/styles.css +20 -0
  20. package/dashboard/styles.css +39 -0
  21. package/dashboard-build.js +17 -2
  22. package/dashboard.js +469 -21
  23. package/docs/README.md +8 -1
  24. package/docs/auto-discovery.md +40 -0
  25. package/docs/branch-derivation.md +13 -1
  26. package/docs/cross-repo-plans.md +292 -0
  27. package/docs/deprecated.json +4 -4
  28. package/docs/pr-auto-fix-dispatch.md +64 -0
  29. package/docs/pr-review-fix-loop.md +1 -1
  30. package/docs/watches.md +1 -0
  31. package/engine/ado.js +1 -10
  32. package/engine/diagnostics-memory.js +190 -0
  33. package/engine/dispatch.js +53 -0
  34. package/engine/lifecycle.js +155 -191
  35. package/engine/meeting.js +30 -0
  36. package/engine/playbook.js +15 -0
  37. package/engine/queries.js +165 -5
  38. package/engine/runtimes/copilot.js +19 -0
  39. package/engine/shared.js +303 -3
  40. package/engine/watchdog.js +6 -0
  41. package/engine.js +576 -113
  42. package/package.json +2 -2
  43. package/playbooks/plan-to-prd.md +25 -2
  44. package/playbooks/plan.md +4 -2
package/dashboard.js CHANGED
@@ -43,6 +43,7 @@ const steeringStore = require('./engine/steering-store');
43
43
  const projectDiscovery = require('./engine/project-discovery');
44
44
  const features = require('./engine/features');
45
45
  const ccWorkerPool = require('./engine/cc-worker-pool');
46
+ const diagnosticsMemory = require('./engine/diagnostics-memory');
46
47
  const os = require('os');
47
48
 
48
49
  const { safeRead, safeReadOrNull, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
@@ -104,6 +105,75 @@ const KB_PINS_PATH = shared.PINNED_ITEMS_PATH;
104
105
  const DASHBOARD_BROWSER_PRESENCE_PATH = path.join(ENGINE_DIR, 'dashboard-browser.json');
105
106
  const DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS = 45000;
106
107
 
108
+ // P-c3d4e5f6 — diagnostics-memory dashboard surface.
109
+ // The engine writes its latest sample to engine/diagnostics-memory.json on
110
+ // every memoryBaselineEveryTicks ticks; the dashboard reads it for
111
+ // /api/diagnostics/memory and accumulates polled engine samples into its
112
+ // own ring buffer (separate from diagnosticsMemory's own dashboard-self
113
+ // buffer) for /api/diagnostics/memory/history?process=engine. Both rings
114
+ // are in-process — restarting the dashboard zeroes them.
115
+ const DIAGNOSTICS_MEMORY_SIDECAR_PATH = path.join(ENGINE_DIR, 'diagnostics-memory.json');
116
+ const DIAGNOSTICS_MEMORY_STALE_MS = 5 * 60 * 1000;
117
+ const DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS = 60000;
118
+ const DIAGNOSTICS_MEMORY_ENGINE_RING_CAP = 1440;
119
+ const _engineMemoryRing = [];
120
+ let _lastEngineSampleCapturedAt = 0;
121
+ let _memorySamplerStop = null;
122
+
123
+ // Pure: assemble the /api/diagnostics/memory payload from already-read
124
+ // inputs. Exported for direct unit testing — handler callers feed it the
125
+ // live dashboard sample + the sidecar contents + Date.now().
126
+ function _buildMemoryDiagnostics({ dashboardSample, engineSample, now } = {}) {
127
+ const _now = Number.isFinite(now) ? now : Date.now();
128
+ const dashboard = dashboardSample && typeof dashboardSample === 'object' ? dashboardSample : null;
129
+ // engineSample may be the literal object from the sidecar OR null/{} when
130
+ // the sidecar is missing/unparseable. Treat anything without a finite
131
+ // capturedAt as stale (covers both "file missing" and "ancient sample").
132
+ let engine = null;
133
+ let engineStale = true;
134
+ if (engineSample && typeof engineSample === 'object' && Number.isFinite(engineSample.capturedAt)) {
135
+ engine = engineSample;
136
+ engineStale = (_now - engineSample.capturedAt) > DIAGNOSTICS_MEMORY_STALE_MS;
137
+ }
138
+ return { dashboard, engine, engineStale };
139
+ }
140
+
141
+ // Pure: slice the requested ring buffer. Returns up to `limit` newest
142
+ // samples (oldest first). `limit` defaults to the full buffer when missing
143
+ // or non-positive (matches diagnosticsMemory.getHistory semantics).
144
+ function _buildMemoryHistory({ ring, limit } = {}) {
145
+ if (!Array.isArray(ring)) return [];
146
+ const n = ring.length;
147
+ if (!Number.isInteger(limit) || limit <= 0 || limit >= n) return ring.slice();
148
+ return ring.slice(n - limit);
149
+ }
150
+
151
+ // Read the engine sidecar and, when it carries a never-seen-before
152
+ // capturedAt, push it onto the engine-side ring buffer. Dedup is by
153
+ // capturedAt so re-reads between engine baseline ticks don't bloat the
154
+ // ring. Best-effort: any failure leaves the ring untouched.
155
+ function _pollAndAccumulateEngineSample() {
156
+ let sample;
157
+ try { sample = safeJsonObj(DIAGNOSTICS_MEMORY_SIDECAR_PATH); }
158
+ catch { return; }
159
+ if (!sample || typeof sample !== 'object') return;
160
+ if (!Number.isFinite(sample.capturedAt)) return;
161
+ if (sample.capturedAt === _lastEngineSampleCapturedAt) return;
162
+ _lastEngineSampleCapturedAt = sample.capturedAt;
163
+ _engineMemoryRing.push(sample);
164
+ while (_engineMemoryRing.length > DIAGNOSTICS_MEMORY_ENGINE_RING_CAP) _engineMemoryRing.shift();
165
+ }
166
+
167
+ function _resetDiagnosticsMemoryForTesting() {
168
+ _engineMemoryRing.length = 0;
169
+ _lastEngineSampleCapturedAt = 0;
170
+ if (_memorySamplerStop) {
171
+ try { _memorySamplerStop(); } catch { /* ignore */ }
172
+ _memorySamplerStop = null;
173
+ }
174
+ try { diagnosticsMemory._resetForTest(); } catch { /* ignore */ }
175
+ }
176
+
107
177
  function ensureConfiguredProjectStateFiles() {
108
178
  for (const p of PROJECTS) {
109
179
  const root = p.localPath ? path.resolve(p.localPath) : null;
@@ -182,6 +252,27 @@ function mutateDashboardConfig(mutator) {
182
252
  }, { defaultValue: { projects: [], agents: {}, engine: {} }, skipWriteIfUnchanged: true });
183
253
  }
184
254
 
255
+ // P-f3c9d0e7 — shared body for the four convenience pause/resume endpoints
256
+ // (`/api/engine/polling/{pause,resume}` and `/api/engine/auto-fix/{pause,resume}`).
257
+ // Persists `config.engine[key] = paused`, busts the in-memory CONFIG so the
258
+ // dashboard sees the new value immediately, invalidates the status cache so
259
+ // the SPA's next poll picks it up, and replies `{ paused, at: <iso> }` per
260
+ // the plan's wire contract. Idempotent — repeating the same call writes the
261
+ // same value (mutateDashboardConfig short-circuits when unchanged) and
262
+ // returns the same shape.
263
+ function _setEnginePauseFlag(res, key, paused) {
264
+ mutateDashboardConfig(config => {
265
+ if (!config.engine || typeof config.engine !== 'object' || Array.isArray(config.engine)) {
266
+ config.engine = {};
267
+ }
268
+ config.engine[key] = paused;
269
+ return config;
270
+ });
271
+ reloadConfig();
272
+ invalidateStatusCache();
273
+ return jsonReply(res, 200, { paused, at: new Date().toISOString() });
274
+ }
275
+
185
276
  function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
186
277
  if (!current || typeof current !== 'object' || Array.isArray(current)) current = {};
187
278
  if (body.engine) {
@@ -366,6 +457,25 @@ function inferActionPrRecord(action, prs, project = null) {
366
457
  }
367
458
 
368
459
  function copyWorkItemPrFields(item, input, pr = null) {
460
+ // W-mqbaby2a000pa8ee: Gate the LOOSE description/title scan in
461
+ // `shared.extractWorkItemPrRef` on `type: "fix"`. Without this gate,
462
+ // an implement/explore/test WI that merely mentions an existing PR
463
+ // in prose ("Class bug surfaced today on pull request 130") gets
464
+ // `targetPr` / `pr_id` / `prNumber` stamped, the engine then treats
465
+ // the WI as a fix against that PR, the PR-branch lookup fails, and
466
+ // the dispatch silently skips with `_pendingReason: null` forever.
467
+ //
468
+ // Structured PR pointers (`targetPr` / `pr_id` / `prUrl` / `prNumber` /
469
+ // `references[].url` / `meta.pr_followup.parent_pr_url`) are explicit
470
+ // operator intent — they stamp on EVERY type. An explicit `pr` record
471
+ // (caller already resolved the PR) also bypasses the gate. Only the
472
+ // last-resort description/title regex scan is type-gated.
473
+ if (!pr) {
474
+ const structuredRef = shared.extractStructuredWorkItemPrRef(input);
475
+ if (!structuredRef && String(item?.type || '').toLowerCase() !== WORK_TYPE.FIX) {
476
+ return;
477
+ }
478
+ }
369
479
  const prRef = getWorkItemPrRef(input);
370
480
  if (!prRef && !pr) return;
371
481
  const prNumber = pr ? shared.getPrNumber(pr) : shared.getPrNumber(prRef);
@@ -1907,6 +2017,11 @@ function _buildStatusFastState() {
1907
2017
  && hbAge > ENGINE_HEARTBEAT_STALE_MS
1908
2018
  && tickAge > tickStaleThresholdMs),
1909
2019
  tickInterval: tickInterval,
2020
+ // P-g7a2b4c5 — surface the two operator kill-switches on every status
2021
+ // payload so the cross-page sticky banner can render without a second
2022
+ // round-trip. Both default to false when unset in config.engine.
2023
+ pollingPaused: !!CONFIG?.engine?.pollingPaused,
2024
+ autoFixPaused: !!CONFIG?.engine?.autoFixPaused,
1910
2025
  },
1911
2026
  adoThrottle: ado.getAdoThrottleState(),
1912
2027
  ghThrottle: gh.getGhThrottleState(),
@@ -1932,6 +2047,7 @@ function _buildStatusSlowState() {
1932
2047
  const branchMismatch = !!(mainBranch && status.remoteDefaultBranch && mainBranch !== status.remoteDefaultBranch);
1933
2048
  return {
1934
2049
  name: p.name,
2050
+ displayName: shared.projectDisplayName(p),
1935
2051
  path: p.localPath,
1936
2052
  description: p.description || '',
1937
2053
  ...status,
@@ -3471,13 +3587,25 @@ function getWorkItemPrRef(input) {
3471
3587
  // the dispatch to the existing PR branch instead of a fresh `work/<wi-id>`
3472
3588
  // parallel branch (issue #2999 / W-mpx6i5kh000ac040).
3473
3589
  //
3474
- // ASYMMETRY (W-mq18ec6h000p7b87): the engine's pr_not_found *gate* uses
3475
- // the strict `shared.extractStructuredWorkItemPrRef` — gate uses
3476
- // structured-only; stamp uses loose. Rationale: operators creating fix
3477
- // WIs via API often paste the PR URL in description prose and expect
3478
- // `targetPr` to get auto-stamped here (best-effort, reversible). The gate
3479
- // blocks dispatch and must require explicit operator intent (a structured
3480
- // field) before doing so.
3590
+ // STRUCTURED-vs-LOOSE SPLIT (W-mq18ec6h000p7b87): two extractors exist —
3591
+ // - `shared.extractStructuredWorkItemPrRef`: structured fields +
3592
+ // `references[].url` + `meta.pr_followup.parent_pr_url`. NO scan.
3593
+ // - `shared.extractWorkItemPrRef` (this helper): structured walk + a
3594
+ // last-resort first-paragraph/title scan.
3595
+ // The engine's `pr_not_found` dispatch gate uses the structured-only
3596
+ // variant. The original design intent was that the stamp path could
3597
+ // afford to be loose because gating would still require explicit
3598
+ // operator intent.
3599
+ //
3600
+ // FIX-TYPE GATE on the stamp path (W-mqbaby2a000pa8ee): in practice
3601
+ // the asymmetry leaked — the loose stamp writes to `item.pr_id` (a
3602
+ // canonical structured field), and the strict gate later reads
3603
+ // `item.pr_id` and sees the loose stamp AS IF it were structured
3604
+ // intent. To stop that leak without changing the loose detector
3605
+ // (which has other legitimate callers), `copyWorkItemPrFields` now
3606
+ // gates the loose result on `item.type === "fix"`. Non-fix WIs only
3607
+ // get stamped from structured fields (incl. references/follow-up).
3608
+ // See the comment block on `copyWorkItemPrFields` above for details.
3481
3609
  return shared.extractWorkItemPrRef(input);
3482
3610
  }
3483
3611
 
@@ -6700,9 +6828,50 @@ const server = http.createServer(async (req, res) => {
6700
6828
  try {
6701
6829
  const plan = JSON.parse(content);
6702
6830
  const status = plan.status || 'active';
6831
+ // W-mqacrzis0003df4a Bug 2 — fresh staleness from plans/*.md mtime.
6832
+ // plan.planStale lags by an engine tick; stat the source plan here
6833
+ // so the Plans tab does not silently mirror a stale-but-cached false.
6834
+ let freshStale = false;
6835
+ if (!archived && plan.source_plan) {
6836
+ try {
6837
+ const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
6838
+ const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
6839
+ if (recorded && sourceMtime > recorded) freshStale = true;
6840
+ } catch { /* source plan may have been deleted/renamed */ }
6841
+ }
6842
+ // P-e8d49105 — _projects rollup feeds the plan-card multi-badge
6843
+ // path in dashboard/js/render-plans.js. For PRD JSONs, walk
6844
+ // missing_features[].project (order-preserving dedup) and fall
6845
+ // back to plan.project for items that omit it. Single-project
6846
+ // plans collapse to a single-element array, identical to today's
6847
+ // single `p.project` badge.
6848
+ const _projects = [];
6849
+ const _seen = new Set();
6850
+ for (const it of (plan.missing_features || [])) {
6851
+ const proj = (it && it.project) || plan.project || '';
6852
+ if (proj && !_seen.has(proj)) { _seen.add(proj); _projects.push(proj); }
6853
+ }
6854
+ if (_projects.length === 0 && plan.project) _projects.push(plan.project);
6855
+ // P-66b1faec — _perProjectProgress feeds the per-project status
6856
+ // pills in the plan card meta line (dashboard/js/render-plans.js
6857
+ // renderPlanCard). Group missing_features by item.project (with
6858
+ // fallback to plan.project) and count `status === 'done'` per
6859
+ // bucket. Keys are first-seen order so the rendered pill row is
6860
+ // stable. Always emitted on PRD JSON records (possibly empty);
6861
+ // MD drafts omit it since they carry no item-level statuses.
6862
+ const _perProjectProgress = {};
6863
+ for (const it of (plan.missing_features || [])) {
6864
+ const proj = (it && it.project) || plan.project || '';
6865
+ if (!proj) continue;
6866
+ if (!_perProjectProgress[proj]) _perProjectProgress[proj] = { complete: 0, total: 0 };
6867
+ _perProjectProgress[proj].total += 1;
6868
+ if (it && it.status === 'done') _perProjectProgress[proj].complete += 1;
6869
+ }
6703
6870
  return {
6704
6871
  file: f, format: 'prd', archived,
6705
6872
  project: plan.project || '',
6873
+ _projects,
6874
+ _perProjectProgress,
6706
6875
  summary: plan.plan_summary || '',
6707
6876
  status,
6708
6877
  branchStrategy: plan.branch_strategy || 'parallel',
@@ -6717,7 +6886,7 @@ const server = http.createServer(async (req, res) => {
6717
6886
  sourcePlan: plan.source_plan || null,
6718
6887
  archiveReady: plan._archiveReady || false,
6719
6888
  archiveReadyAt: plan._archiveReadyAt || null,
6720
- planStale: plan.planStale || false,
6889
+ planStale: freshStale || plan.planStale || false,
6721
6890
  };
6722
6891
  } catch { return null; /* JSON parse fallback */ }
6723
6892
  } else {
@@ -6726,9 +6895,19 @@ const server = http.createServer(async (req, res) => {
6726
6895
  const authorMatch = content.match(/\*\*Author:\*\*\s*(.+)/m);
6727
6896
  const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/m);
6728
6897
  const versionMatch = f.match(/-v(\d+)/);
6898
+ // P-e8d49105 — for MD drafts, the cross-repo marker / **Projects:**
6899
+ // line is the source of truth (parsed by shared.extractPlanTargetProjects).
6900
+ // If neither is present, fall back to the singular **Project:** header
6901
+ // so single-project drafts render the same badge as today.
6902
+ const declaredProject = projectMatch ? projectMatch[1].trim() : '';
6903
+ const targetProjects = shared.extractPlanTargetProjects(content);
6904
+ const _projects = targetProjects.length > 0
6905
+ ? targetProjects
6906
+ : (declaredProject ? [declaredProject] : []);
6729
6907
  return {
6730
6908
  file: f, format: 'draft', archived,
6731
- project: projectMatch ? projectMatch[1].trim() : '',
6909
+ project: declaredProject,
6910
+ _projects,
6732
6911
  summary: titleMatch ? titleMatch[1].trim() : f.replace('.md', ''),
6733
6912
  status: archived ? 'completed' : completedPrdFiles.has(f) ? 'converted' : 'draft',
6734
6913
  branchStrategy: '',
@@ -6842,6 +7021,22 @@ const server = http.createServer(async (req, res) => {
6842
7021
  return data;
6843
7022
  }, { defaultValue: {} });
6844
7023
 
7024
+ // W-mqacrzis0003df4a — Fresh source-plan staleness check. Approve is the
7025
+ // last gate before materialization, and the diff-aware regen block below
7026
+ // gates on `wasStale`. The persisted `data.planStale` flag lags by an
7027
+ // engine tick (~10s); without this fresh stat a fast user can Approve
7028
+ // within the tick window, `wasStale` stays false, the diff-aware regen
7029
+ // is silently skipped, and items materialize from the OLD PRD. Mirrors
7030
+ // the staleness logic in engine/queries.js#getPrdInfo + the /api/plans
7031
+ // handler above so all three readers agree.
7032
+ if (!wasStale && plan && plan.source_plan && plan.sourcePlanModifiedAt) {
7033
+ try {
7034
+ const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
7035
+ const recorded = new Date(plan.sourcePlanModifiedAt).getTime();
7036
+ if (recorded && sourceMtime > recorded) wasStale = true;
7037
+ } catch { /* source plan may have been deleted/renamed — fall through with wasStale=false */ }
7038
+ }
7039
+
6845
7040
  // Resume paused work items across all projects
6846
7041
  let resumed = 0;
6847
7042
  const resumedItemIds = [];
@@ -7193,7 +7388,9 @@ const server = http.createServer(async (req, res) => {
7193
7388
 
7194
7389
  let archivedSource = null;
7195
7390
  let plan = {};
7196
- let archiveWarnings = [];
7391
+ const archiveWarnings = [];
7392
+ let archivedPrd = null;
7393
+ const archivedPrds = [];
7197
7394
  if (isPrd) {
7198
7395
  const result = _archivePrdPostProcess({
7199
7396
  planFile: body.file,
@@ -7202,8 +7399,75 @@ const server = http.createServer(async (req, res) => {
7202
7399
  plansDir: PLANS_DIR,
7203
7400
  });
7204
7401
  archivedSource = result.archivedSource;
7205
- archiveWarnings = result.archiveWarnings;
7402
+ archiveWarnings.push(...result.archiveWarnings);
7206
7403
  plan = result.plan;
7404
+ } else {
7405
+ // W-mqa27r9b0004c055 — symmetric cascade: archiving a .md must also
7406
+ // archive any PRD whose `source_plan` points back at it. Without this,
7407
+ // the dashboard still renders the PRD as a status-completed plan card
7408
+ // forever (real incident: killswitches-and-granular-controls.md →
7409
+ // minions-opg-2026-06-10-2.json). The per-PRD status-flip / sidecar /
7410
+ // source-plan-move steps are delegated to _archivePrdPostProcess so
7411
+ // both branches share the per-concern try/catch granularity Dallas
7412
+ // shipped for the PRD branch in W-mqa13ulk0002def5 (PR #3222) — the
7413
+ // helper's source-plan move is a safe no-op here because the outer
7414
+ // handler already renamed body.file into plans/archive/.
7415
+ let prdFiles = [];
7416
+ try {
7417
+ prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
7418
+ } catch (e) {
7419
+ // ENOENT on prd/ is expected for projects without a PRD dir yet —
7420
+ // don't surface as a user-visible warning. Other errors (EACCES,
7421
+ // EIO) get logged + warned.
7422
+ if (e.code !== 'ENOENT') {
7423
+ const warning = `Archive could not enumerate ${PRD_DIR}: ${e.message}`;
7424
+ archiveWarnings.push(warning);
7425
+ console.warn(warning);
7426
+ }
7427
+ }
7428
+ for (const prdFile of prdFiles) {
7429
+ const prdLivePath = path.join(PRD_DIR, prdFile);
7430
+ let prd = null;
7431
+ try {
7432
+ prd = safeJsonObj(prdLivePath);
7433
+ } catch (e) {
7434
+ const warning = `Archive could not read PRD ${prdFile}: ${e.message}`;
7435
+ archiveWarnings.push(warning);
7436
+ console.warn(warning);
7437
+ continue;
7438
+ }
7439
+ if (!prd || prd.source_plan !== body.file) continue;
7440
+
7441
+ const prdArchiveDir = path.join(PRD_DIR, 'archive');
7442
+ try {
7443
+ if (!fs.existsSync(prdArchiveDir)) fs.mkdirSync(prdArchiveDir, { recursive: true });
7444
+ } catch (e) {
7445
+ const warning = `Archive could not create PRD archive dir for ${prdFile}: ${e.message}`;
7446
+ archiveWarnings.push(warning);
7447
+ console.warn(warning);
7448
+ continue;
7449
+ }
7450
+ const prdArchivePath = path.join(prdArchiveDir, prdFile);
7451
+ try {
7452
+ fs.renameSync(prdLivePath, prdArchivePath);
7453
+ } catch (e) {
7454
+ const warning = `Archive could not move PRD ${prdFile}: ${e.message}`;
7455
+ archiveWarnings.push(warning);
7456
+ console.warn(warning);
7457
+ continue;
7458
+ }
7459
+ // Delegate status-flip + sidecar cleanup + (no-op) source-plan move
7460
+ // to the shared helper so both branches stay in lockstep.
7461
+ const cascadeResult = _archivePrdPostProcess({
7462
+ planFile: prdFile,
7463
+ archivePath: prdArchivePath,
7464
+ planPath: prdLivePath,
7465
+ plansDir: PLANS_DIR,
7466
+ });
7467
+ archiveWarnings.push(...cascadeResult.archiveWarnings);
7468
+ archivedPrds.push(prdFile);
7469
+ }
7470
+ if (archivedPrds.length > 0) archivedPrd = archivedPrds[0];
7207
7471
  }
7208
7472
 
7209
7473
  // Cancel pending work items linked to this plan so the engine stops
@@ -7233,6 +7497,8 @@ const server = http.createServer(async (req, res) => {
7233
7497
  invalidateStatusCache();
7234
7498
  invalidatePlansCache();
7235
7499
  const payload = { ok: true, archived: body.file, archivedSource, cancelledItems };
7500
+ if (archivedPrd) payload.archivedPrd = archivedPrd;
7501
+ if (archivedPrds.length > 1) payload.archivedPrds = archivedPrds;
7236
7502
  if (archiveWarnings.length > 0) payload.warnings = archiveWarnings;
7237
7503
  return jsonReply(res, 200, payload);
7238
7504
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
@@ -9384,6 +9650,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9384
9650
  for (const key of Object.keys(childEnv)) {
9385
9651
  if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete childEnv[key];
9386
9652
  }
9653
+ // W-mqb9y83o — suppress auto-open in the respawned dashboard. The
9654
+ // restart endpoint is fired by CC or the dashboard UI; the operator
9655
+ // already has a tab open (that's how they hit the button), so the
9656
+ // post-restart hook must not pop a second one. Hard kill-switch read
9657
+ // by bin/minions.js#spawnFullStackAndVerify.
9658
+ childEnv.MINIONS_NO_AUTO_OPEN = '1';
9387
9659
  const proc = cpSpawn(process.execPath, [minionsBin, 'restart'], {
9388
9660
  cwd: MINIONS_DIR, stdio: 'ignore', detached: true, env: childEnv, windowsHide: true,
9389
9661
  });
@@ -9501,6 +9773,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9501
9773
  // larger models); max 1h (matches CC_CALL_TIMEOUT_MS so the watchdog
9502
9774
  // never outlives the outer abort).
9503
9775
  ccTurnTimeoutMs: [10000, 3600000],
9776
+ // W-mq9acoo800177bcb — bounded-concurrency for pre-dispatch validator.
9777
+ // 1 floor (sequential fallback) and 20 ceiling (above this the LLM
9778
+ // provider's per-second rate limits dominate; throughput gains taper).
9779
+ preDispatchEvalConcurrency: [1, 20],
9504
9780
  };
9505
9781
  for (const [key, [min, max]] of Object.entries(numericFields)) {
9506
9782
  if (e[key] !== undefined) {
@@ -9624,6 +9900,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9624
9900
  else if (valid.includes(e.copilotStreamMode)) _setEngineConfig('copilotStreamMode', e.copilotStreamMode);
9625
9901
  else _clamped.push(`copilotStreamMode: "${e.copilotStreamMode}" not in [on, off] (kept previous value)`);
9626
9902
  }
9903
+ // P-mcp-storm — MCP server names to disable for autonomous Copilot agents
9904
+ // (--disable-mcp-server). Accept an array OR a comma/whitespace string;
9905
+ // normalize to a deduped array of trimmed names. Empty clears (inherit all).
9906
+ if (e.copilotAgentDisabledMcpServers !== undefined) {
9907
+ const src = Array.isArray(e.copilotAgentDisabledMcpServers)
9908
+ ? e.copilotAgentDisabledMcpServers
9909
+ : String(e.copilotAgentDisabledMcpServers || '').split(/[\s,]+/);
9910
+ const names = [...new Set(src.map(s => String(s == null ? '' : s).trim()).filter(Boolean))];
9911
+ if (names.length) _setEngineConfig('copilotAgentDisabledMcpServers', names);
9912
+ else _deleteEngineConfig('copilotAgentDisabledMcpServers');
9913
+ }
9627
9914
  // W-mpmwxkrw000872ec — fontSize allowlist. Clamps invalid values
9628
9915
  // (rather than silently failing) so the dashboard bootstrap never
9629
9916
  // ends up with an unknown data-font-size attribute.
@@ -9942,6 +10229,58 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9942
10229
  } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
9943
10230
  }
9944
10231
 
10232
+ // P-c3d4e5f6 — /api/diagnostics/memory.
10233
+ // Returns the latest in-process dashboard sample, the latest engine
10234
+ // sample (read fresh from engine/diagnostics-memory.json via safeJsonObj),
10235
+ // and an engineStale boolean. Engine staleness is true when the sidecar
10236
+ // is missing/unparseable OR its capturedAt is > 5 min old. The handler
10237
+ // does one small JSON read and one in-process sample — < 10ms warm.
10238
+ function handleDiagnosticsMemory(req, res) {
10239
+ try {
10240
+ let dashboardSample = null;
10241
+ try { dashboardSample = diagnosticsMemory.sampleSelf({ label: 'dashboard' }); }
10242
+ catch { /* leave dashboardSample null on sampler failure */ }
10243
+ const engineSample = safeJsonObj(DIAGNOSTICS_MEMORY_SIDECAR_PATH);
10244
+ const payload = _buildMemoryDiagnostics({
10245
+ dashboardSample,
10246
+ engineSample,
10247
+ now: Date.now(),
10248
+ });
10249
+ return jsonReply(res, 200, payload, req);
10250
+ } catch (e) {
10251
+ return jsonReply(res, 500, { error: e.message }, req);
10252
+ }
10253
+ }
10254
+
10255
+ // P-c3d4e5f6 — /api/diagnostics/memory/history?process=engine|dashboard&limit=N.
10256
+ // process=dashboard returns the diagnosticsMemory module's own ring
10257
+ // buffer (populated by startPeriodicSampling on dashboard boot).
10258
+ // process=engine returns the dashboard's accumulated polled snapshots
10259
+ // of engine/diagnostics-memory.json — engine.js only persists the
10260
+ // latest sample to the sidecar, so engine-side history is rebuilt by
10261
+ // the dashboard's poller (dedup by capturedAt). Limit defaults to the
10262
+ // full buffer when missing / non-positive.
10263
+ function handleDiagnosticsMemoryHistory(req, res) {
10264
+ try {
10265
+ const u = new URL(req.url, 'http://x');
10266
+ const proc = (u.searchParams.get('process') || '').trim().toLowerCase();
10267
+ const limitRaw = u.searchParams.get('limit');
10268
+ const limitParsed = limitRaw == null || limitRaw === '' ? null : parseInt(limitRaw, 10);
10269
+ const limit = Number.isInteger(limitParsed) && limitParsed > 0 ? limitParsed : null;
10270
+ let samples;
10271
+ if (proc === 'dashboard') {
10272
+ samples = diagnosticsMemory.getHistory(limit ? { limit } : {});
10273
+ } else if (proc === 'engine') {
10274
+ samples = _buildMemoryHistory({ ring: _engineMemoryRing, limit });
10275
+ } else {
10276
+ return jsonReply(res, 400, { error: 'process must be one of: engine, dashboard' }, req);
10277
+ }
10278
+ return jsonReply(res, 200, { process: proc, count: samples.length, samples }, req);
10279
+ } catch (e) {
10280
+ return jsonReply(res, 500, { error: e.message }, req);
10281
+ }
10282
+ }
10283
+
9945
10284
  // Slim UX surface for the experimental redesigned dashboard.
9946
10285
  // The markup/CSS/JS live as fragments under dashboard/slim/ (layout.html +
9947
10286
  // styles.css + body.html + js/*.js) and are assembled by buildSlimHtml() —
@@ -11746,6 +12085,37 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11746
12085
  return jsonReply(res, 200, { ok: true, cleared: cause, prId });
11747
12086
  }},
11748
12087
 
12088
+ // ─── P-f3c9d0e7: convenience pause/resume endpoints ─────────────────────
12089
+ // Single-call wrappers over `POST /api/settings { engine: { <flag>: bool } }`
12090
+ // for the two operator kill-switches:
12091
+ // - engine.pollingPaused (P-a1f3c2d4) — pauses all PR polling
12092
+ // - engine.autoFixPaused (P-b2e5d8c7) — pauses auto-fix dispatch
12093
+ //
12094
+ // These exist so the dashboard "Emergency Stop" buttons and any future
12095
+ // `minions pause` / `minions autopause` CLI commands are one-line
12096
+ // implementations (no JSON body construction needed).
12097
+ //
12098
+ // Engine pickup contract: engine.js calls reloadConfig() each tick and
12099
+ // reads `config.engine.<flag> === true` at every gate site, so the next
12100
+ // tick observes the change. Setting the flag to `false` on resume is
12101
+ // enough — the engine has its own log-throttle reset
12102
+ // (`_resetPollingPausedLogState`, `_resetAutoFixPausedLogState`) that
12103
+ // fires automatically when the gate reads `!paused`. The engine and
12104
+ // dashboard run in separate processes, so the dashboard can't (and
12105
+ // doesn't need to) scrub engine-process module state directly.
12106
+ { method: 'POST', path: '/api/engine/polling/pause', desc: 'Pause all PR polling — sets engine.pollingPaused=true (P-f3c9d0e7 convenience over POST /api/settings)', handler: async (req, res) => {
12107
+ return _setEnginePauseFlag(res, 'pollingPaused', true);
12108
+ }},
12109
+ { method: 'POST', path: '/api/engine/polling/resume', desc: 'Resume PR polling — sets engine.pollingPaused=false', handler: async (req, res) => {
12110
+ return _setEnginePauseFlag(res, 'pollingPaused', false);
12111
+ }},
12112
+ { method: 'POST', path: '/api/engine/auto-fix/pause', desc: 'Pause auto-fix dispatch — sets engine.autoFixPaused=true', handler: async (req, res) => {
12113
+ return _setEnginePauseFlag(res, 'autoFixPaused', true);
12114
+ }},
12115
+ { method: 'POST', path: '/api/engine/auto-fix/resume', desc: 'Resume auto-fix dispatch — sets engine.autoFixPaused=false', handler: async (req, res) => {
12116
+ return _setEnginePauseFlag(res, 'autoFixPaused', false);
12117
+ }},
12118
+
11749
12119
  { method: 'POST', path: '/api/plans/create', desc: 'Create a plan from user-provided content', params: 'title, content, project?', handler: async (req, res) => {
11750
12120
  const body = await readBody(req);
11751
12121
  const { title, content, project: projectName, meetingId } = body;
@@ -11758,6 +12128,29 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11758
12128
  return jsonReply(res, 400, { error: 'Plan content must start with a markdown heading (#), bold text (**), or a list item' });
11759
12129
  }
11760
12130
 
12131
+ // P-2e9b54d1: `project` may be a string (today's behavior) OR an array
12132
+ // of project names (cross-repo plan). Normalize to a deduped, trimmed
12133
+ // string[]. Empty values are dropped; ≥2 entries triggers the cross-
12134
+ // repo plan shape (no singular `**Project:**` header, plural
12135
+ // `**Projects:**` line + `<!-- minions:targetProjects=... -->` marker).
12136
+ const rawProjects = Array.isArray(body.project)
12137
+ ? body.project
12138
+ : (body.project ? [body.project] : []);
12139
+ const projectNames = [];
12140
+ for (const raw of rawProjects) {
12141
+ const name = String(raw || '').trim();
12142
+ if (name && !projectNames.includes(name)) projectNames.push(name);
12143
+ }
12144
+ if (projectNames.length > 0) {
12145
+ reloadConfig();
12146
+ const projects = shared.getProjects(CONFIG);
12147
+ for (const name of projectNames) {
12148
+ if (!findProjectByName(projects, name)) {
12149
+ return jsonReply(res, 400, { error: formatUnknownProjectError(name, projects) });
12150
+ }
12151
+ }
12152
+ }
12153
+
11761
12154
  const plansDir = path.join(MINIONS_DIR, 'plans');
11762
12155
  if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
11763
12156
  const slug = shared.slugify(title);
@@ -11765,8 +12158,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11765
12158
  const filename = `${slug}-${date}.md`;
11766
12159
  const filePath = shared.uniquePath(path.join(plansDir, filename));
11767
12160
 
12161
+ const projectLines = projectNames.length >= 2
12162
+ ? `**Projects:** ${projectNames.join(', ')}\n` +
12163
+ `<!-- minions:targetProjects=${projectNames.join(',')} -->\n`
12164
+ : (projectNames.length === 1 ? `**Project:** ${projectNames[0]}\n` : '');
11768
12165
  const header = `# ${title}\n\n` +
11769
- (projectName ? `**Project:** ${projectName}\n` : '') +
12166
+ projectLines +
11770
12167
  (meetingId ? `**Source Meeting:** ${meetingId}\n` : '') +
11771
12168
  `**Created:** ${date}\n**By:** human teammate\n\n---\n\n`;
11772
12169
  safeWrite(filePath, header + content);
@@ -12260,6 +12657,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12260
12657
  { method: 'POST', path: '/api/diagnostics/refresh', desc: 'Append a dashboard refresh-diagnostic ring buffer batch to engine/dashboard-diagnostics.log (rotated at 1 MB)', params: 'entries[]', handler: handleDiagnosticsRefresh },
12261
12658
  // Diagnostics — per-org ADO throttle state (W-mq03l6zh0006f0a1-d).
12262
12659
  { method: 'GET', path: '/api/diagnostics/ado-throttle', desc: 'Snapshot of per-org ADO throttle tracker state — { orgs: { [orgBase]: { throttled, retryAfter, consecutiveHits } } }. Falls back to a single `global` key when running against pre-per-org engines.', handler: handleDiagnosticsAdoThrottle },
12660
+ // Diagnostics — engine + dashboard memory baseline (P-c3d4e5f6).
12661
+ { method: 'GET', path: '/api/diagnostics/memory', desc: 'Latest in-process dashboard memory sample plus the most-recent engine sample read from engine/diagnostics-memory.json. engineStale=true when the sidecar is missing or its capturedAt is > 5 min old.', handler: handleDiagnosticsMemory },
12662
+ { method: 'GET', path: '/api/diagnostics/memory/history', desc: 'In-memory ring buffer of memory samples. process=dashboard returns the dashboard\'s own collector (populated by startPeriodicSampling on boot). process=engine returns the dashboard\'s polled accumulation of engine/diagnostics-memory.json — engine.js only persists the latest sample to the sidecar, so engine-side history is rebuilt by the dashboard poller (dedup by capturedAt). Optional limit caps returned newest-N samples.', params: 'process (engine|dashboard), limit?', handler: handleDiagnosticsMemoryHistory },
12263
12663
  ];
12264
12664
 
12265
12665
  // ── Route Dispatcher ────────────────────────────────────────────────────────
@@ -12458,6 +12858,11 @@ module.exports = {
12458
12858
  refreshStatusAsync,
12459
12859
  handleStatus: _handleStatusRequest,
12460
12860
  invalidateStatusCache,
12861
+ // exported for testing — see test/unit/status-snapshot-budget.test.js (P-f2a3b4c5).
12862
+ // The slim snapshot builder is the synchronous assembler used by getStatusJson()
12863
+ // and refreshStatusAsync(); the budget test calls it directly to measure byte
12864
+ // size and rebuild-time without standing up an HTTP server.
12865
+ getStatus,
12461
12866
  // Raw state-file passthrough — exported for direct unit testing.
12462
12867
  handleStateRead,
12463
12868
  STATE_READ_ALLOWED_DIRS,
@@ -12480,6 +12885,18 @@ module.exports = {
12480
12885
  // route's `builder` closure (getWorkItems().map(slimWorkItemForList)).
12481
12886
  _slimWorkItemForList: slimWorkItemForList,
12482
12887
  _WORK_ITEMS_SLIM_DESCRIPTION_CAP: WORK_ITEMS_SLIM_DESCRIPTION_CAP,
12888
+ // P-c3d4e5f6 — diagnostics-memory dashboard surface (handlers live in
12889
+ // the request-dispatch closure; expose the pure builders + constants so
12890
+ // unit tests can exercise the staleness gate and history slicing
12891
+ // without binding a server.)
12892
+ _buildMemoryDiagnostics,
12893
+ _buildMemoryHistory,
12894
+ _pollAndAccumulateEngineSample,
12895
+ _resetDiagnosticsMemoryForTesting,
12896
+ DIAGNOSTICS_MEMORY_SIDECAR_PATH,
12897
+ DIAGNOSTICS_MEMORY_STALE_MS,
12898
+ DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS,
12899
+ DIAGNOSTICS_MEMORY_ENGINE_RING_CAP,
12483
12900
  };
12484
12901
 
12485
12902
  // Start the HTTP server only when run directly (node dashboard.js).
@@ -12570,15 +12987,19 @@ if (require.main === module) {
12570
12987
  Promise.resolve(queries.getKnowledgeBaseEntries())
12571
12988
  .catch(err => console.warn(`[dashboard] KB cache warm failed: ${err && err.message}`));
12572
12989
 
12573
- // Auto-open the browser unless suppressed. `minions restart` and the
12574
- // upgrade path set MINIONS_NO_AUTO_OPEN=1 because the CLI orchestrates the
12575
- // open itself after observing whether an existing tab reconnected.
12576
- if (!process.env.MINIONS_NO_AUTO_OPEN) {
12577
- const result = shared.openUrlInBrowser(`http://localhost:${PORT}`);
12578
- if (!result.ok) {
12579
- console.log(` Could not auto-open browser: ${result.error}`);
12580
- console.log(` Please open http://localhost:${PORT} manually.`);
12581
- }
12990
+ // Auto-open the browser. `minions restart` and the upgrade path set
12991
+ // MINIONS_NO_AUTO_OPEN=1 because the CLI orchestrates the open itself
12992
+ // after observing whether an existing tab reconnected; the primitive
12993
+ // (engine/shared.js#openUrlInBrowser) now owns the env-var check and
12994
+ // emits a debug-level SUPPRESSED log entry so we can prove the kill-
12995
+ // switch is firing.
12996
+ const result = shared.openUrlInBrowser(`http://localhost:${PORT}`, {
12997
+ reason: 'dashboard-self-open',
12998
+ callerHint: 'dashboard.js:13124',
12999
+ });
13000
+ if (!result.ok && !result.suppressed) {
13001
+ console.log(` Could not auto-open browser: ${result.error}`);
13002
+ console.log(` Please open http://localhost:${PORT} manually.`);
12582
13003
  }
12583
13004
 
12584
13005
  // Warm the CC runtime binary cache off the request path so the first CC /
@@ -12634,12 +13055,39 @@ if (require.main === module) {
12634
13055
  }
12635
13056
  }, 30000).unref();
12636
13057
  console.log(` Engine watchdog: active (checks every 30s)`);
13058
+
13059
+ // ─── Diagnostics: dashboard memory sampler (P-c3d4e5f6) ─────────────────
13060
+ // Drive the diagnosticsMemory ring buffer for /api/diagnostics/memory and
13061
+ // /api/diagnostics/memory/history?process=dashboard. The internal
13062
+ // setInterval already calls recordSample for us; the onSample callback
13063
+ // doubles as the engine-sidecar poller so /…/history?process=engine
13064
+ // accumulates one entry per fresh engine MEMORY_BASELINE write
13065
+ // (deduplicated by capturedAt).
13066
+ try {
13067
+ _memorySamplerStop = diagnosticsMemory.startPeriodicSampling({
13068
+ intervalMs: DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS,
13069
+ onSample: () => { try { _pollAndAccumulateEngineSample(); } catch { /* swallow */ } },
13070
+ });
13071
+ // Seed the engine ring immediately so the first call to
13072
+ // /api/diagnostics/memory/history?process=engine right after boot
13073
+ // already returns the latest sidecar sample without waiting a full
13074
+ // sampling interval.
13075
+ try { _pollAndAccumulateEngineSample(); } catch { /* swallow */ }
13076
+ } catch (e) {
13077
+ console.warn(`[dashboard] memory sampler failed to start: ${e && e.message}`);
13078
+ }
12637
13079
  })();
12638
13080
 
12639
13081
  // ── Graceful shutdown: flush debounced writes + clear runtime port file ──
12640
13082
  function _gracefulShutdown() {
12641
13083
  try { flushPendingDocSessions(); } catch {}
12642
13084
  try { shared.clearDashboardPortFile(MINIONS_DIR); } catch {}
13085
+ // P-c3d4e5f6 — stop the diagnostics-memory sampler cleanly so the
13086
+ // setInterval handle doesn't keep the event loop alive on SIGTERM.
13087
+ if (_memorySamplerStop) {
13088
+ try { _memorySamplerStop(); } catch { /* swallow */ }
13089
+ _memorySamplerStop = null;
13090
+ }
12643
13091
  }
12644
13092
  server.on('close', () => _gracefulShutdown());
12645
13093
  process.on('SIGTERM', () => { _gracefulShutdown(); process.exit(0); });