@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
@@ -915,9 +915,30 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
915
915
  (projects.length === 1 ? projects[0] : null);
916
916
  const useCentral = !defaultProject;
917
917
 
918
- // Match each PR to its correct project by finding which repo URL appears near the PR number in output
918
+ // Match each PR to its correct project. W-mqba5ulq000nd255 — primary
919
+ // strategy is canonical scope: parse the evidence URL into a host scope
920
+ // (e.g. `github:opg-microsoft/minions`) and find the configured project
921
+ // whose `getProjectPrScope` matches. This routes the record into the
922
+ // correctly-scoped project file even when the dispatching agent ran
923
+ // against a different project (the cross-project PR case that previously
924
+ // produced stale `_invalidProjectScope` stubs in the wrong file). Falls
925
+ // back to the legacy substring match (handles legacy non-canonical
926
+ // configs), then to a repoName-by-_git fallback, then to the dispatching
927
+ // project (which will get the `_invalidProjectScope` stamp via
928
+ // normalizePrRecord — preserving today's tracking behavior for orphan /
929
+ // unknown-owner URLs).
919
930
  function resolveProjectForPr(prId) {
920
931
  const evidenceUrl = prEvidence.get(prId) || '';
932
+ // Scope match wins. Use the most-specific URL available: prefer the
933
+ // direct evidence URL captured alongside the PR id; fall back to the
934
+ // generic stdout scan if that's empty.
935
+ const urlScope = shared.getPrScopeInfo(null, evidenceUrl)?.scope || '';
936
+ if (urlScope) {
937
+ for (const p of projects) {
938
+ const projScope = shared.getProjectPrScope(p);
939
+ if (projScope && projScope === urlScope) return p;
940
+ }
941
+ }
921
942
  const evidenceText = `${outputText}\n${evidenceUrl}`;
922
943
  for (const p of projects) {
923
944
  if (!p.prUrlBase) continue;
@@ -1472,7 +1493,14 @@ async function findOpenPrForBranch(meta, config) {
1472
1493
  if (!meta?.branch) return null;
1473
1494
  const projectObj = resolvePrFallbackProject(meta, config);
1474
1495
  if (!projectObj) return null;
1475
- const host = projectObj.repoHost || 'ado';
1496
+ // BUG-H14 (P-h14-repohost): no silent 'ado' default — if the resolved
1497
+ // project doesn't declare repoHost, bail rather than mis-routing a GitHub
1498
+ // project's branch lookup through the ADO PR-search path.
1499
+ const host = projectObj.repoHost || null;
1500
+ if (!host) {
1501
+ log('debug', `Skipping branch PR lookup: project ${projectObj.name} has no repoHost configured`);
1502
+ return null;
1503
+ }
1476
1504
  if (host === 'github') {
1477
1505
  const ghSlug = projectObj.prUrlBase?.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1];
1478
1506
  if (!ghSlug) return null;
@@ -1981,13 +2009,26 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1981
2009
  // The poller will pick up the real status on the next cycle (~3 min).
1982
2010
  let postReviewStatus = null; // null = don't change
1983
2011
  let liveStatus = null;
2012
+ // BUG-H14 (P-h14-repohost): resolve repoHost from the PR's canonical id
2013
+ // FIRST. The previous code derived hostForChecks from the fallback project
2014
+ // (`reviewProject || getProjects(config)[0]`) and silently defaulted to
2015
+ // 'ado' on miss — so a github:* PR found in the central pull-requests.json
2016
+ // (reviewProject=null) with an ADO project as projects[0] was mis-routed to
2017
+ // ado.checkLiveReviewStatus. The fallback project is still consulted when
2018
+ // the id is unparseable, but there is no silent 'ado' default — an
2019
+ // unresolved host is observable (no live check fires).
1984
2020
  const projectObjForChecks = reviewProject || shared.getProjects(config)[0];
1985
- const hostForChecks = projectObjForChecks?.repoHost || 'ado';
1986
- const checkFn = hostForChecks === 'github'
1987
- ? require('./github').checkLiveReviewStatus
1988
- : require('./ado').checkLiveReviewStatus;
2021
+ const parsedCanonicalReviewId = shared.parseCanonicalPrId(reviewPr.id);
2022
+ const hostFromCanonical = parsedCanonicalReviewId
2023
+ ? parsedCanonicalReviewId.scope.split(':')[0]
2024
+ : null;
2025
+ const hostForChecks = hostFromCanonical || projectObjForChecks?.repoHost || null;
2026
+ let checkFn = null;
2027
+ if (hostForChecks === 'github') checkFn = require('./github').checkLiveReviewStatus;
2028
+ else if (hostForChecks === 'ado') checkFn = require('./ado').checkLiveReviewStatus;
2029
+ else log('warn', `Cannot resolve repoHost for review of ${reviewPr.id} (unparseable canonical id and no project config); skipping live review check.`);
1989
2030
  try {
1990
- if (projectObjForChecks) {
2031
+ if (projectObjForChecks && checkFn) {
1991
2032
  liveStatus = await checkFn(reviewPr, projectObjForChecks);
1992
2033
  }
1993
2034
  } catch (e) { log('warn', `Post-review status check for ${reviewPr.id}: ${e.message}`); }
@@ -2025,7 +2066,9 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2025
2066
  try {
2026
2067
  const reconcileFn = hostForChecks === 'github'
2027
2068
  ? require('./github').dismissPriorViewerChangesRequestedReviews
2028
- : require('./ado').resetReviewerNegativeVote;
2069
+ : hostForChecks === 'ado'
2070
+ ? require('./ado').resetReviewerNegativeVote
2071
+ : null;
2029
2072
  if (typeof reconcileFn === 'function') {
2030
2073
  const result = await reconcileFn(reviewPr, projectObjForChecks);
2031
2074
  const cleared = !!(result && (result.changed || result.dismissed > 0));
@@ -3395,85 +3438,6 @@ function parseAgentOutput(stdout, runtimeName) {
3395
3438
  return { resultSummary: text, taskUsage: usage, sessionId, model };
3396
3439
  }
3397
3440
 
3398
- /**
3399
- * Parse structured completion block from agent output.
3400
- * Agents produce a ```completion fenced block with key: value pairs.
3401
- * Returns parsed object or null if not found / malformed.
3402
- * If multiple blocks exist, the last one wins (agent may retry).
3403
- *
3404
- * DEPRECATED — slated for removal once telemetry shows zero hits over a 14-day
3405
- * window. Telemetry: see _engine.completionFallbacks in metrics.json (the JSON
3406
- * sidecar at MINIONS_COMPLETION_REPORT is the documented contract; this fenced
3407
- * fallback exists only to support runtimes/agents that have not yet adopted it).
3408
- * Removal is tracked by the P-c8f5e1b3 follow-up plan.
3409
- */
3410
- function parseStructuredCompletion(stdout, runtimeName) {
3411
- if (!stdout || typeof stdout !== 'string') return null;
3412
-
3413
- // Extract text from stream-json output if needed
3414
- const text = extractCompletionText(stdout, runtimeName);
3415
-
3416
- // Find all ```completion blocks, take the last one
3417
- const blockPattern = /```completion\s*\n([\s\S]*?)```/g;
3418
- let lastMatch = null;
3419
- let m;
3420
- while ((m = blockPattern.exec(text)) !== null) {
3421
- lastMatch = m[1];
3422
- }
3423
- if (!lastMatch) {
3424
- const taskCompleteSummary = extractTaskCompleteSummary(stdout);
3425
- return taskCompleteSummary ? parseCompletionKeyValues(taskCompleteSummary) : null;
3426
- }
3427
-
3428
- return parseCompletionKeyValues(lastMatch);
3429
- }
3430
-
3431
- function extractCompletionText(stdout, runtimeName) {
3432
- let text = stdout;
3433
- if (typeof stdout === 'string' && stdout.includes('"type":')) {
3434
- try {
3435
- const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
3436
- if (parsed.text) text = parsed.text;
3437
- } catch {}
3438
- }
3439
- return text;
3440
- }
3441
-
3442
- function hasCompletionFence(stdout, runtimeName) {
3443
- const text = extractCompletionText(stdout, runtimeName);
3444
- return /```completion\s*\n[\s\S]*?```/.test(text);
3445
- }
3446
-
3447
- function extractTaskCompleteSummary(stdout) {
3448
- if (!stdout || typeof stdout !== 'string') return '';
3449
- let summary = '';
3450
- for (const rawLine of stdout.split('\n')) {
3451
- const line = rawLine.trim();
3452
- if (!line || !line.startsWith('{')) continue;
3453
- let obj;
3454
- try { obj = JSON.parse(line); } catch { continue; }
3455
- if (!obj || typeof obj !== 'object') continue;
3456
- if (obj.type === 'session.task_complete') {
3457
- const value = obj.data?.summary;
3458
- if (typeof value === 'string' && value.trim()) summary = value;
3459
- continue;
3460
- }
3461
- if (obj.type === 'tool.execution_start' && obj.data?.toolName === 'task_complete') {
3462
- const value = obj.data?.arguments?.summary;
3463
- if (typeof value === 'string' && value.trim()) summary = value;
3464
- continue;
3465
- }
3466
- if (obj.type === 'assistant.message' && Array.isArray(obj.data?.toolRequests)) {
3467
- for (const tr of obj.data.toolRequests) {
3468
- if (tr?.name !== 'task_complete') continue;
3469
- const value = tr.arguments?.summary || tr.intentionSummary;
3470
- if (typeof value === 'string' && value.trim()) summary = value;
3471
- }
3472
- }
3473
- }
3474
- return summary;
3475
- }
3476
-
3477
3441
  function hasActionableFailureClass(value) {
3478
3442
  const normalized = String(value || '').trim().toLowerCase();
3479
3443
  if (!normalized) return false;
@@ -3577,57 +3541,6 @@ function handleInjectionFlag(dispatchItem, agentId, structuredCompletion, config
3577
3541
  return { description, sources, at };
3578
3542
  }
3579
3543
 
3580
- function parseCompletionKeyValues(text) {
3581
- if (!text || typeof text !== 'string') return null;
3582
- const result = {};
3583
- const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
3584
- const lines = text.trim().split('\n');
3585
- for (const line of lines) {
3586
- const normalizedLine = line.trim().replace(/^[-*]\s+/, '');
3587
- const colonIdx = normalizedLine.indexOf(':');
3588
- if (colonIdx < 1) continue;
3589
- const key = normalizedLine.slice(0, colonIdx).trim().toLowerCase();
3590
- if (allowedFields.size > 0 && !allowedFields.has(key)) continue;
3591
- const value = normalizedLine.slice(colonIdx + 1).trim();
3592
- if (key && value) result[key] = value;
3593
- }
3594
-
3595
- // Must have at least status, or an actionable failure_class that implies failure.
3596
- if (!result.status && hasActionableFailureClass(result.failure_class)) result.status = 'failed';
3597
- if (!result.status) return null;
3598
- return result;
3599
- }
3600
-
3601
- /**
3602
- * DEPRECATED — slated for removal once telemetry shows zero hits over a 14-day
3603
- * window. Telemetry: see _engine.completionFallbacks in metrics.json (the JSON
3604
- * sidecar at MINIONS_COMPLETION_REPORT is the documented contract; this prose
3605
- * fallback only fires when both the sidecar AND the fenced ```completion block
3606
- * are missing). Removal is tracked by the P-c8f5e1b3 follow-up plan.
3607
- */
3608
- function parseCompletionFieldSummary(text) {
3609
- if (!text || typeof text !== 'string') return null;
3610
-
3611
- const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
3612
- const result = {};
3613
- for (const rawLine of text.split(/\r?\n/)) {
3614
- const line = rawLine.trim().replace(/^[-*]\s+/, '');
3615
- const colonIdx = line.indexOf(':');
3616
- if (colonIdx < 1) continue;
3617
- const key = line.slice(0, colonIdx).trim().toLowerCase().replace(/[\s-]+/g, '_');
3618
- if (!allowedFields.has(key)) continue;
3619
- const value = line.slice(colonIdx + 1).trim().replace(/^["'`]+|["'`]+$/g, '');
3620
- if (value) result[key] = value;
3621
- }
3622
-
3623
- if (!result.status) return null;
3624
- const fieldCount = Object.keys(result).length;
3625
- const status = normalizeCompletionStatus(result.status);
3626
- const explicitlyFailed = status.startsWith('fail') || status === 'error';
3627
- if (fieldCount < 2 && !explicitlyFailed) return null;
3628
- return result;
3629
- }
3630
-
3631
3544
  function parseCompletionReportFile(dispatchItem, opts = {}) {
3632
3545
  const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
3633
3546
  if (!reportPath || !fs.existsSync(reportPath)) {
@@ -3820,29 +3733,6 @@ function promoteCompletionArtifacts(meta, agentId, dispatchId, structuredComplet
3820
3733
  return { artifacts, notes, agentId, dispatchId };
3821
3734
  }
3822
3735
 
3823
- function persistCompletionReport(dispatchItem, completion, source = 'fallback') {
3824
- if (!dispatchItem?.id || !completion || typeof completion !== 'object') return completion;
3825
- const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem.id);
3826
- if (!reportPath) return completion;
3827
- const report = {
3828
- ...completion,
3829
- status: completion.status || completion.outcome || 'unknown',
3830
- _source: source,
3831
- _path: reportPath,
3832
- dispatchId: dispatchItem.id,
3833
- agent: dispatchItem.agent || null,
3834
- type: dispatchItem.type || null,
3835
- completedAt: ts(),
3836
- };
3837
- try {
3838
- safeWrite(reportPath, report);
3839
- log('info', `Persisted ${source} completion report for ${dispatchItem.id}: ${reportPath}`);
3840
- } catch (err) {
3841
- log('warn', `Persist fallback completion report for ${dispatchItem.id}: ${err.message}`);
3842
- }
3843
- return report;
3844
- }
3845
-
3846
3736
  function normalizeCompletionStatus(status) {
3847
3737
  return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
3848
3738
  }
@@ -4487,6 +4377,12 @@ function handleHarnessIterationResult(stdout, structuredCompletion, meta, config
4487
4377
  * - Returns silently when the PR is no longer active, or when its current
4488
4378
  * reviewStatus is not in {changes-requested, waiting}. An already-approved
4489
4379
  * PR doesn't need a re-review.
4380
+ * - P-e8b1c4d2: Returns silently when engine.autoReReviewPrs === false. This
4381
+ * mirrors the discovery-driven gate in engine.js:discoverFromPrs so the
4382
+ * toggle is a single source of truth across BOTH re-review paths
4383
+ * (open-loop / closure-loop). Without this gate the dashboard switch only
4384
+ * muted the discovery path, leaving the closure-loop free to keep
4385
+ * dispatching re-reviews — defeating the operator's intent.
4490
4386
  * - Idempotent: getPrDispatchDedupeKey + addToDispatch dedup already skips a
4491
4387
  * second review dispatch for the same (PR, type). No second WI ever lands.
4492
4388
  * - Soft agent preference: agents whose charter advertises code-review or
@@ -4504,6 +4400,14 @@ function dispatchReReviewForFix(fixDispatchItem, meta, config) {
4504
4400
  if (!addressesReviewWi) return null;
4505
4401
  const pr = meta?.pr;
4506
4402
  if (!pr?.id) return null;
4403
+ // P-e8b1c4d2 — honor the autoReReviewPrs kill-switch in the closure-loop too.
4404
+ // The open-loop discovery path in engine.js gates on the same flag; without
4405
+ // this guard the closure-loop silently bypassed the operator's toggle.
4406
+ const autoReReviewPrs = config?.engine?.autoReReviewPrs ?? shared.ENGINE_DEFAULTS.autoReReviewPrs;
4407
+ if (autoReReviewPrs === false) {
4408
+ log('info', `Re-review skipped for ${pr.id}: engine.autoReReviewPrs is off (closure-loop kill-switch)`);
4409
+ return null;
4410
+ }
4507
4411
  // Re-read live PR state so we don't queue a re-review against an already
4508
4412
  // merged/abandoned PR. The post-completion fix-update may have just flipped
4509
4413
  // reviewStatus to 'waiting'; consult that fresh state via the central read.
@@ -4672,33 +4576,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
4672
4576
  reportCompletion = null;
4673
4577
  }
4674
4578
 
4675
- const fencedCompletion = (nonceMismatch || reportCompletion) ? null : parseStructuredCompletion(stdout, runtimeName);
4676
- const summaryCompletion = (nonceMismatch || reportCompletion || fencedCompletion) ? null : parseCompletionFieldSummary(resultSummary);
4677
- const fallbackCompletion = fencedCompletion || summaryCompletion;
4678
- const fallbackSource = fencedCompletion && hasCompletionFence(stdout, runtimeName) ? 'fenced-completion' : 'summary-completion';
4679
- // P-c8f5e1b3 — telemetry: completion-fallback. Emit a grep-able log + bump a
4680
- // counter in metrics.json whenever the fenced or summary fallback fires (i.e.
4681
- // the documented sidecar contract was missing). Used to confirm zero adoption
4682
- // gaps before removing parseStructuredCompletion / parseCompletionFieldSummary
4683
- // in a follow-up. Sidecar (happy) path is silent — no log, no increment.
4684
- if (!reportCompletion && fallbackCompletion) {
4685
- const counterKey = fallbackSource === 'fenced-completion' ? 'fenced' : 'summary';
4686
- const wiId = dispatchItem.meta?.item?.id || null;
4687
- log('info', `telemetry: completion-fallback agent=${agentId || 'unknown'} wi=${wiId || 'N/A'} runtime=${runtimeName} source=${fallbackSource}`);
4688
- try {
4689
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
4690
- mutateJsonFileLocked(metricsPath, (metrics) => {
4691
- metrics = metrics || {};
4692
- if (!metrics._engine) metrics._engine = {};
4693
- if (!metrics._engine.completionFallbacks) metrics._engine.completionFallbacks = { fenced: 0, summary: 0 };
4694
- metrics._engine.completionFallbacks[counterKey] = (metrics._engine.completionFallbacks[counterKey] || 0) + 1;
4695
- return metrics;
4696
- });
4697
- } catch (err) {
4698
- log('warn', `telemetry: completion-fallback metrics write failed: ${err.message}`);
4699
- }
4700
- }
4701
- const structuredCompletion = reportCompletion || persistCompletionReport(dispatchItem, fallbackCompletion, fallbackSource);
4579
+ const structuredCompletion = reportCompletion;
4702
4580
  if (structuredCompletion) {
4703
4581
  if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
4704
4582
  log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
@@ -5617,6 +5495,94 @@ function diagnoseEmptyOutput(failureClass, code, elapsedMs) {
5617
5495
  return `[empty-output: process exited in ${elapsedMs}ms \u2014 possible causes: machine sleep, network unavailability, auth failure]`;
5618
5496
  }
5619
5497
 
5498
+ // W-mqba5ulq000nd255 — Reconciliation sweep: delete PR records flagged with
5499
+ // `_invalidProjectScope: { reason: "pr_scope_mismatch" }` IFF a sibling
5500
+ // record for the same canonical pr.id exists in another project whose scope
5501
+ // matches the PR URL (i.e. the correctly-scoped project owns the canonical
5502
+ // record). Sibling-less mismatches are preserved as tracking. Runs once per
5503
+ // tick from engine.js after the ADO/GitHub reconcile polls finish.
5504
+ //
5505
+ // Returns { pruned, scanned } so the engine tick can log a summary line.
5506
+ function pruneScopeMismatchDuplicatePrs(config) {
5507
+ config = config || getConfig();
5508
+ const projects = shared.getProjects(config) || [];
5509
+ if (projects.length === 0) return { pruned: 0, scanned: 0 };
5510
+
5511
+ // Map project name -> canonical scope so we can find which project IS the
5512
+ // correctly-scoped owner for a given mismatch record.
5513
+ const scopeByProject = new Map();
5514
+ const projectByScope = new Map();
5515
+ for (const p of projects) {
5516
+ if (!p || !p.name) continue;
5517
+ const sc = shared.getProjectPrScope(p);
5518
+ if (!sc) continue;
5519
+ scopeByProject.set(p.name, sc);
5520
+ projectByScope.set(sc, p);
5521
+ }
5522
+
5523
+ // Index all PRs by canonical id across all projects (post-_scope decoration).
5524
+ const store = require('./pull-requests-store');
5525
+ const allPrs = store.readAllPullRequests() || [];
5526
+ const byId = new Map();
5527
+ for (const pr of allPrs) {
5528
+ if (!pr || !pr.id) continue;
5529
+ if (!byId.has(pr.id)) byId.set(pr.id, []);
5530
+ byId.get(pr.id).push(pr);
5531
+ }
5532
+
5533
+ // Build per-project delete sets keyed by id, so we batch one mutation per
5534
+ // affected project file.
5535
+ const deletesByProject = new Map(); // projectName -> Set(prId)
5536
+ let scanned = 0;
5537
+ let pruned = 0;
5538
+
5539
+ for (const records of byId.values()) {
5540
+ if (records.length < 2) continue;
5541
+ for (const rec of records) {
5542
+ scanned++;
5543
+ if (!rec._invalidProjectScope || rec._invalidProjectScope.reason !== 'pr_scope_mismatch') continue;
5544
+ const correctScope = rec._invalidProjectScope.prScope;
5545
+ if (!correctScope) continue;
5546
+ const correctProject = projectByScope.get(correctScope);
5547
+ if (!correctProject) continue; // no configured project owns the URL — keep as tracking
5548
+ // Sibling check: is there a record under the correct scope for the same id?
5549
+ const sibling = records.find(r => r !== rec && r._scope === correctProject.name);
5550
+ if (!sibling) continue;
5551
+ // Safe to prune.
5552
+ const owningScope = rec._scope;
5553
+ if (!owningScope || owningScope === 'central') continue;
5554
+ if (!deletesByProject.has(owningScope)) deletesByProject.set(owningScope, new Set());
5555
+ deletesByProject.get(owningScope).add(rec.id);
5556
+ }
5557
+ }
5558
+
5559
+ if (deletesByProject.size === 0) return { pruned: 0, scanned };
5560
+
5561
+ for (const [projectName, idsToDelete] of deletesByProject) {
5562
+ const project = projects.find(p => p.name === projectName);
5563
+ if (!project) continue;
5564
+ const prPath = projectPrPath(project);
5565
+ try {
5566
+ shared.mutatePullRequests(prPath, (prs) => {
5567
+ const before = prs.length;
5568
+ const next = prs.filter(p => !idsToDelete.has(p?.id));
5569
+ const deleted = before - next.length;
5570
+ if (deleted > 0) {
5571
+ pruned += deleted;
5572
+ for (const id of idsToDelete) {
5573
+ log('info', `[pull-requests] pruned scope-mismatch duplicate ${id} from project=${projectName} (sibling exists in correctly-scoped project)`);
5574
+ }
5575
+ }
5576
+ return next;
5577
+ });
5578
+ } catch (err) {
5579
+ log('warn', `pruneScopeMismatchDuplicatePrs: failed to mutate ${projectName}: ${err?.message || err}`);
5580
+ }
5581
+ }
5582
+
5583
+ return { pruned, scanned };
5584
+ }
5585
+
5620
5586
  module.exports = {
5621
5587
  checkPlanCompletion,
5622
5588
  archivePlan,
@@ -5625,6 +5591,7 @@ module.exports = {
5625
5591
  syncPrdItemStatus,
5626
5592
  reconcilePrdStatuses,
5627
5593
  syncPrsFromOutput,
5594
+ pruneScopeMismatchDuplicatePrs,
5628
5595
  updatePrAfterReview,
5629
5596
  updatePrAfterFix,
5630
5597
  updatePrAfterFixError,
@@ -5638,8 +5605,6 @@ module.exports = {
5638
5605
  parseAgentOutput,
5639
5606
  parseReviewVerdict,
5640
5607
  isReviewBailout,
5641
- parseStructuredCompletion,
5642
- parseCompletionFieldSummary,
5643
5608
  parseCompletionNoop,
5644
5609
  detectNonTerminalResultSummary,
5645
5610
  deferNonTerminalCompletion,
@@ -5651,7 +5616,6 @@ module.exports = {
5651
5616
  completionArtifactToNoteEntry,
5652
5617
  mergeArtifactNotes,
5653
5618
  promoteCompletionArtifacts,
5654
- persistCompletionReport,
5655
5619
  runPostCompletionHooks,
5656
5620
  syncPrdFromPrs,
5657
5621
  resolveWorkItemPath,
package/engine/meeting.js CHANGED
@@ -916,6 +916,36 @@ function checkMeetingTimeouts(config) {
916
916
  const elapsed = Date.now() - roundStartedMs;
917
917
  if (elapsed < timeout) continue;
918
918
 
919
+ // BUG-H11: when a hard timeout will fire, kill in-flight meeting
920
+ // dispatches BEFORE entering the meeting lock so they stop consuming
921
+ // worker slots / LLM tokens immediately rather than draining over the
922
+ // next tens of minutes. _killMeetingDispatches takes the dispatch.json
923
+ // lock and shells out to kill processes — per CLAUDE.md and the
924
+ // advanceMeetingRound/endMeeting/deleteMeeting pattern at L834, that
925
+ // MUST run outside the meeting lock. We pre-evaluate eligibility from
926
+ // the snapshot using the same predicate the mutate callback below
927
+ // re-checks under lock; if the timeout has already advanced/cleared,
928
+ // the kill is a no-op on dispatch.json (no matching meetingId entries).
929
+ const snapshotRoundName = snapshot.status === 'investigating'
930
+ ? 'investigate'
931
+ : snapshot.status === 'debating'
932
+ ? 'debate'
933
+ : snapshot.status === 'concluding'
934
+ ? 'conclude'
935
+ : null;
936
+ const snapshotHardTimeoutWillFire = elapsed >= hardTimeout && (
937
+ // Round-timeout branch: non-conclude round + not all participants done
938
+ (
939
+ (snapshotRoundName === 'investigate' || snapshotRoundName === 'debate')
940
+ && !allParticipantsFinishedRound(snapshot, snapshotRoundName, snapshot.round)
941
+ )
942
+ // Conclusion-timeout branch: concluding status
943
+ || snapshot.status === 'concluding'
944
+ );
945
+ if (snapshotHardTimeoutWillFire) {
946
+ _killMeetingDispatches(snapshot.id);
947
+ }
948
+
919
949
  // Re-evaluate the timeout transition under the file lock to avoid lost
920
950
  // updates if an agent finalised mid-tick. Helpers (advanceMeetingIfRoundComplete
921
951
  // etc.) operate on the locked-and-rehydrated meeting object.
@@ -298,6 +298,11 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
298
298
  'existing_prd_json', // only set when re-running plan-to-prd over an existing PRD
299
299
  'branch_strategy_hint', // only set for shared-branch plans
300
300
  'review_note', // only set on fix/review tasks tied to a comment
301
+ // P-4d6e2af3 — comma-separated list of target projects for cross-repo
302
+ // plans. Only set in engine.js's WORK_TYPE.PLAN branch when the WI carries
303
+ // item._targetProjects (≥2 entries from dashboard.js#buildPlanWorkItem).
304
+ // Single-project plans legitimately resolve to ''.
305
+ 'target_projects',
301
306
  // PR-context vars on non-PR tasks (implement/explore/etc.)
302
307
  'pr_id', 'pr_number', 'pr_title', 'pr_branch', 'pr_author', 'pr_url',
303
308
  'reviewer',
@@ -868,6 +873,16 @@ function renderPlaybook(type, vars) {
868
873
  return (val && String(val).trim()) ? block : '';
869
874
  });
870
875
 
876
+ // P-c1f87a92 — negative conditional blocks: {{^key}}...{{/key}} — include
877
+ // block only if key is FALSY / absent / whitespace-only. Mirror the
878
+ // positive-block trim() check above so single-value semantics line up
879
+ // (vars.target_projects = '' or undefined renders the {{^}} branch, the
880
+ // same way it omits the {{#}} branch).
881
+ content = content.replace(/\{\{\^(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_, key, block) => {
882
+ const val = allVars[key];
883
+ return (val && String(val).trim()) ? '' : block;
884
+ });
885
+
871
886
  // Substitute variables
872
887
  for (const [key, val] of Object.entries(allVars)) {
873
888
  content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));