@yemi33/minions 0.1.1949 → 0.1.1951

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 (40) hide show
  1. package/dashboard/js/command-center.js +9 -0
  2. package/dashboard/js/modal-qa.js +10 -0
  3. package/dashboard/js/refresh.js +4 -0
  4. package/dashboard/js/render-dispatch.js +25 -0
  5. package/dashboard/js/render-other.js +109 -2
  6. package/dashboard/js/settings.js +1 -1
  7. package/dashboard/layout.html +2 -2
  8. package/dashboard/pages/engine.html +6 -0
  9. package/dashboard/slim.html +1987 -0
  10. package/dashboard/styles.css +8 -0
  11. package/dashboard.js +450 -40
  12. package/docs/completion-reports.md +25 -0
  13. package/docs/design-state-storage.md +1 -1
  14. package/docs/slim-ux/architecture-suggestions.md +467 -0
  15. package/docs/slim-ux/concepts.md +824 -0
  16. package/engine/ado-mcp-wrapper.js +33 -7
  17. package/engine/ado.js +123 -15
  18. package/engine/cc-worker-pool.js +41 -0
  19. package/engine/cleanup.js +71 -34
  20. package/engine/cli.js +37 -0
  21. package/engine/dispatch.js +32 -9
  22. package/engine/features.js +6 -0
  23. package/engine/gh-token.js +137 -0
  24. package/engine/github.js +166 -29
  25. package/engine/issues.js +29 -0
  26. package/engine/keep-process-sweep.js +397 -0
  27. package/engine/lifecycle.js +150 -33
  28. package/engine/playbook.js +17 -0
  29. package/engine/queries.js +71 -0
  30. package/engine/recovery.js +6 -0
  31. package/engine/shared.js +481 -30
  32. package/engine/spawn-agent.js +44 -2
  33. package/engine/timeout.js +34 -11
  34. package/engine/worktree-pool.js +410 -0
  35. package/engine.js +643 -119
  36. package/package.json +6 -3
  37. package/playbooks/review.md +2 -0
  38. package/playbooks/shared-rules.md +3 -1
  39. package/prompts/cc-system.md +24 -0
  40. package/engine/copilot-models.json +0 -5
@@ -921,10 +921,17 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
921
921
  kind: 'positive-signal',
922
922
  body: `Closing duplicate — ${duplicateOnBranch.id} already tracks this branch.`,
923
923
  });
924
- // Shell-quote the body `dupComment` contains only ascii safe chars
925
- // (the marker is `<!-- minions:agent=engine kind=positive-signal -->`).
926
- execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment ${JSON.stringify(dupComment)}`, { timeout: 15000 })
927
- .catch(() => {});
924
+ // P-a7c4d2e8 (F2): argv-form gh + slug validation. ghSlug is regex-
925
+ // extracted from agent stdout (untrusted) must validate. Passing
926
+ // the comment as a single argv element makes embedded `"`, backticks,
927
+ // `$()`, and newlines inert.
928
+ try {
929
+ const validatedSlug = shared.validateGhSlug(ghSlug);
930
+ shared.shellSafeGh(['pr', 'close', String(prId), '--repo', validatedSlug, '--comment', dupComment], { timeout: 15000 })
931
+ .catch(() => {});
932
+ } catch (validationErr) {
933
+ log('warn', `Skipping duplicate-PR close: invalid gh slug "${ghSlug.slice(0, 64)}": ${validationErr.message}`);
934
+ }
928
935
  }
929
936
  } catch { /* best-effort */ }
930
937
  continue;
@@ -1604,21 +1611,64 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1604
1611
  // If platform hasn't propagated the vote yet (returns 'pending'), keep current status unchanged.
1605
1612
  // The poller will pick up the real status on the next cycle (~3 min).
1606
1613
  let postReviewStatus = null; // null = don't change
1614
+ let liveStatus = null;
1615
+ const projectObjForChecks = reviewProject || shared.getProjects(config)[0];
1616
+ const hostForChecks = projectObjForChecks?.repoHost || 'ado';
1617
+ const checkFn = hostForChecks === 'github'
1618
+ ? require('./github').checkLiveReviewStatus
1619
+ : require('./ado').checkLiveReviewStatus;
1607
1620
  try {
1608
- const projectObj = reviewProject || shared.getProjects(config)[0];
1609
- if (projectObj) {
1610
- const host = projectObj.repoHost || 'ado';
1611
- const checkFn = host === 'github'
1612
- ? require('./github').checkLiveReviewStatus
1613
- : require('./ado').checkLiveReviewStatus;
1614
- const liveStatus = await checkFn(reviewPr, projectObj);
1615
- if (liveStatus && liveStatus !== 'pending') postReviewStatus = liveStatus;
1621
+ if (projectObjForChecks) {
1622
+ liveStatus = await checkFn(reviewPr, projectObjForChecks);
1616
1623
  }
1617
1624
  } catch (e) { log('warn', `Post-review status check for ${reviewPr.id}: ${e.message}`); }
1618
1625
 
1626
+ // Capture the agent's verdict early so we can decide whether to reconcile
1627
+ // the platform vote BEFORE we lock in postReviewStatus.
1628
+ const verdictRaw = reviewVerdictFromCompletion(structuredCompletion) || parseReviewVerdict(resultSummary);
1629
+ const reviewerLower = String(agentId || '').toLowerCase();
1630
+ const authorLower = String(reviewPr.agent || '').toLowerCase();
1631
+ const isSelfReview = !!(reviewerLower && authorLower && reviewerLower === authorLower);
1632
+
1633
+ // W-mp7b1g8q000fea45 — Reconcile stale own negative vote/review on verdict flip.
1634
+ // When the agent flips request_changes → approved, the prior platform vote
1635
+ // (ADO -5/-10 or GH CHANGES_REQUESTED review) is NOT auto-cleared; the live
1636
+ // check above would still report 'changes-requested' / 'waiting' and override
1637
+ // the agent's approved verdict, leaving humans with a misleading red badge.
1638
+ // Actively clear the agent's prior negative on flip, then re-check live so we
1639
+ // never claim approved if some OTHER reviewer (human, different minion
1640
+ // account) still has a negative vote/review on the PR.
1641
+ const prevReviewStatus = reviewPr?.reviewStatus || '';
1642
+ const wasNegative = prevReviewStatus === 'changes-requested' || prevReviewStatus === 'waiting'
1643
+ || liveStatus === 'changes-requested' || liveStatus === 'waiting';
1644
+ if (verdictRaw === 'approved' && !isSelfReview && wasNegative && projectObjForChecks) {
1645
+ try {
1646
+ const reconcileFn = hostForChecks === 'github'
1647
+ ? require('./github').dismissPriorViewerChangesRequestedReviews
1648
+ : require('./ado').resetReviewerNegativeVote;
1649
+ if (typeof reconcileFn === 'function') {
1650
+ const result = await reconcileFn(reviewPr, projectObjForChecks);
1651
+ const cleared = !!(result && (result.changed || result.dismissed > 0));
1652
+ if (cleared) {
1653
+ // Re-check live so a remaining negative from another reviewer still
1654
+ // wins. If recheck fails, keep the prior liveStatus (conservative).
1655
+ try {
1656
+ const recheck = await checkFn(reviewPr, projectObjForChecks);
1657
+ if (recheck != null) liveStatus = recheck;
1658
+ } catch (e) { log('warn', `Post-reset live re-check for ${reviewPr.id}: ${e.message}`); }
1659
+ log('info', `PR ${reviewPr.id}: cleared stale ${hostForChecks} negative vote/review by ${reviewerName} on flip → live now ${liveStatus}`);
1660
+ }
1661
+ }
1662
+ } catch (e) {
1663
+ log('warn', `Vote reconciliation for ${reviewPr.id}: ${e.message}`);
1664
+ }
1665
+ }
1666
+
1667
+ if (liveStatus && liveStatus !== 'pending') postReviewStatus = liveStatus;
1668
+
1619
1669
  // Fallback: if live check returned pending (e.g., GitHub self-approval blocked), use the agent's completion report.
1620
1670
  if (!postReviewStatus) {
1621
- const verdict = reviewVerdictFromCompletion(structuredCompletion) || parseReviewVerdict(resultSummary);
1671
+ const verdict = verdictRaw;
1622
1672
  if (verdict) {
1623
1673
  // P-e1c8a9d2 — Refuse self-review APPROVE: an agent approving their own PR
1624
1674
  // is meaningless self-promotion (PR #1867/#1868/#2253 all shipped this way).
@@ -1628,9 +1678,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1628
1678
  // the verdict and skips the no-verdict retry path, so _retryCount stays
1629
1679
  // unchanged. REQUEST_CHANGES from a self-author is still accepted — a
1630
1680
  // self-author flagging issues on their own PR is harmless and useful.
1631
- const reviewerLower = String(agentId || '').toLowerCase();
1632
- const authorLower = String(reviewPr.agent || '').toLowerCase();
1633
- if (verdict === 'approved' && reviewerLower && authorLower && reviewerLower === authorLower) {
1681
+ if (verdict === 'approved' && isSelfReview) {
1634
1682
  log('warn', `review verdict rejected: self-review (reviewer=${reviewerName}, author=${reviewPr.agent})`);
1635
1683
  } else {
1636
1684
  postReviewStatus = verdict;
@@ -2038,6 +2086,16 @@ async function rebaseBranchOntoMain(pr, project, config) {
2038
2086
  const root = path.resolve(project.localPath);
2039
2087
  const mainBranch = shared.resolveMainBranch(root, project.mainBranch);
2040
2088
  const branch = pr.branch;
2089
+ // P-a7c4d2e8 (F3): defense-in-depth — validate refs before any git command
2090
+ // even though sanitizeBranch ran upstream. Older state may carry poisoned refs.
2091
+ let validatedBranch, validatedMain;
2092
+ try {
2093
+ validatedBranch = shared.validateGitRef(branch);
2094
+ validatedMain = shared.validateGitRef(mainBranch);
2095
+ } catch (refErr) {
2096
+ log('warn', `Post-merge rebase: refusing invalid ref (${refErr.message})`);
2097
+ return { success: false, error: refErr.message };
2098
+ }
2041
2099
  const wtRoot = path.resolve(root, config.engine?.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot);
2042
2100
  const tmpWt = path.join(wtRoot, `rebase-${shared.sanitizeBranch(branch)}-${Date.now()}`).replace(/\\/g, '/');
2043
2101
  const _gitOpts = { cwd: root, timeout: 30000, windowsHide: true };
@@ -2054,34 +2112,34 @@ async function rebaseBranchOntoMain(pr, project, config) {
2054
2112
  }
2055
2113
 
2056
2114
  try {
2057
- await execAsync(`git fetch origin "${mainBranch}" "${branch}"`, _gitOpts);
2115
+ await shared.shellSafeGit(['fetch', 'origin', validatedMain, validatedBranch], _gitOpts);
2058
2116
  try {
2059
- await execAsync(`git worktree add "${tmpWt}" "${branch}"`, { ..._gitOpts, timeout: 60000 });
2117
+ await shared.shellSafeGit(['worktree', 'add', tmpWt, validatedBranch], { ..._gitOpts, timeout: 60000 });
2060
2118
  } catch (wtErr) {
2061
2119
  // Branch may already be checked out in a stale worktree — prune and retry once
2062
2120
  if (String(wtErr.message || wtErr).includes('already checked out')) {
2063
- await execAsync(`git worktree prune`, _gitOpts);
2064
- await execAsync(`git worktree add "${tmpWt}" "${branch}"`, { ..._gitOpts, timeout: 60000 });
2121
+ await shared.shellSafeGit(['worktree', 'prune'], _gitOpts);
2122
+ await shared.shellSafeGit(['worktree', 'add', tmpWt, validatedBranch], { ..._gitOpts, timeout: 60000 });
2065
2123
  } else { throw wtErr; }
2066
2124
  }
2067
2125
  } catch (err) {
2068
2126
  log('warn', `Post-merge rebase: setup failed for ${branch}: ${err.message}`);
2069
- try { await execAsync(`git worktree remove "${tmpWt}" --force`, _gitOpts); } catch {}
2127
+ try { await shared.shellSafeGit(['worktree', 'remove', tmpWt, '--force'], _gitOpts); } catch {}
2070
2128
  return { success: false, error: err.message };
2071
2129
  }
2072
2130
 
2073
2131
  try {
2074
- await execAsync(`git rebase "origin/${mainBranch}"`, { cwd: tmpWt, timeout: 120000, windowsHide: true });
2075
- await execAsync(`git push --force-with-lease origin "${branch}"`, { cwd: tmpWt, timeout: 30000, windowsHide: true });
2132
+ await shared.shellSafeGit(['rebase', `origin/${validatedMain}`], { cwd: tmpWt, timeout: 120000, windowsHide: true });
2133
+ await shared.shellSafeGit(['push', '--force-with-lease', 'origin', validatedBranch], { cwd: tmpWt, timeout: 30000, windowsHide: true });
2076
2134
  log('info', `Post-merge rebase: rebased ${branch} onto ${mainBranch} and force-pushed`);
2077
2135
  return { success: true };
2078
2136
  } catch (err) {
2079
- try { await execAsync(`git rebase --abort`, { cwd: tmpWt, timeout: 10000, windowsHide: true }); } catch {}
2137
+ try { await shared.shellSafeGit(['rebase', '--abort'], { cwd: tmpWt, timeout: 10000, windowsHide: true }); } catch {}
2080
2138
  log('warn', `Post-merge rebase failed for ${branch}: ${err.message}`);
2081
2139
  return { success: false, error: err.message };
2082
2140
  } finally {
2083
2141
  try { shared.removeWorktree(tmpWt, root, wtRoot); } catch {}
2084
- try { await execAsync(`git worktree prune`, _gitOpts); } catch {}
2142
+ try { await shared.shellSafeGit(['worktree', 'prune'], _gitOpts); } catch {}
2085
2143
  }
2086
2144
  }
2087
2145
 
@@ -3194,9 +3252,60 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
3194
3252
  let { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout, runtimeName);
3195
3253
 
3196
3254
  // Prefer the sidecar completion report; keep fenced output as a compatibility fallback.
3197
- const reportCompletion = parseCompletionReportFile(dispatchItem, { warnIfMissing: true });
3198
- const fencedCompletion = reportCompletion ? null : parseStructuredCompletion(stdout, runtimeName);
3199
- const summaryCompletion = reportCompletion || fencedCompletion ? null : parseCompletionFieldSummary(resultSummary);
3255
+ let reportCompletion = parseCompletionReportFile(dispatchItem, { warnIfMissing: true });
3256
+
3257
+ // P-d2a8f6c1 (agent trust boundary F8): validate the per-spawn nonce. The
3258
+ // engine injects MINIONS_COMPLETION_NONCE per spawn and stamps it on the
3259
+ // active-process record; here we compare it against the JSON report's
3260
+ // `nonce` field. A mismatch means the report was forged (e.g. a prompt-
3261
+ // injected agent wrote into a sibling agent's completion path) — discard
3262
+ // every signal it carries (PR attachment, noop, status, retryable, …) and
3263
+ // mark the dispatch failed with the dedicated failure_class. A missing
3264
+ // nonce degrades to a warning by default for one release; flip
3265
+ // ENGINE_DEFAULTS.completionNonceRequired (or engine.completionNonceRequired
3266
+ // in config.json) to true to hard-fail missing nonces too. Mismatched
3267
+ // nonces always hard-fail regardless of the flag. The fenced/summary
3268
+ // fallbacks share the same trust boundary — once the JSON sidecar is
3269
+ // proven untrusted, we don't fall back to stdout-derived completions
3270
+ // either, since they cannot prove provenance.
3271
+ let nonceMismatch = null;
3272
+ const expectedNonce = (opts && typeof opts.expectedNonce === 'string' && opts.expectedNonce) ? opts.expectedNonce : null;
3273
+ const completionNonceRequired = !!(opts && opts.completionNonceRequired);
3274
+ if (expectedNonce && reportCompletion) {
3275
+ const reportNonce = typeof reportCompletion.nonce === 'string' ? reportCompletion.nonce : null;
3276
+ const wiId = dispatchItem.meta?.item?.id || 'N/A';
3277
+ if (!reportNonce) {
3278
+ if (completionNonceRequired) {
3279
+ const reason = `Completion report missing required nonce for dispatch ${dispatchItem.id}`;
3280
+ log('error', `[security] completion-nonce-missing dispatch=${dispatchItem.id} agent=${agentId || 'unknown'} wi=${wiId} required=true`);
3281
+ nonceMismatch = {
3282
+ severity: 'hard',
3283
+ failureClass: shared.FAILURE_CLASS.COMPLETION_NONCE_MISMATCH,
3284
+ reason,
3285
+ kind: 'missing',
3286
+ };
3287
+ } else {
3288
+ log('warn', `[security] completion-nonce-missing dispatch=${dispatchItem.id} agent=${agentId || 'unknown'} wi=${wiId} required=false (degraded — report honored)`);
3289
+ }
3290
+ } else if (reportNonce !== expectedNonce) {
3291
+ const reason = `Completion report nonce mismatch for dispatch ${dispatchItem.id} — treating completion as untrusted`;
3292
+ const expectedShort = expectedNonce.slice(0, 8);
3293
+ const gotShort = String(reportNonce).slice(0, 8);
3294
+ log('error', `[security] completion-nonce-mismatch dispatch=${dispatchItem.id} agent=${agentId || 'unknown'} wi=${wiId} expected=${expectedShort} got=${gotShort}`);
3295
+ nonceMismatch = {
3296
+ severity: 'hard',
3297
+ failureClass: shared.FAILURE_CLASS.COMPLETION_NONCE_MISMATCH,
3298
+ reason,
3299
+ kind: 'mismatch',
3300
+ };
3301
+ }
3302
+ }
3303
+ if (nonceMismatch) {
3304
+ reportCompletion = null;
3305
+ }
3306
+
3307
+ const fencedCompletion = (nonceMismatch || reportCompletion) ? null : parseStructuredCompletion(stdout, runtimeName);
3308
+ const summaryCompletion = (nonceMismatch || reportCompletion || fencedCompletion) ? null : parseCompletionFieldSummary(resultSummary);
3200
3309
  const fallbackCompletion = fencedCompletion || summaryCompletion;
3201
3310
  const fallbackSource = fencedCompletion && hasCompletionFence(stdout, runtimeName) ? 'fenced-completion' : 'summary-completion';
3202
3311
  // P-c8f5e1b3 — telemetry: completion-fallback. Emit a grep-able log + bump a
@@ -3259,19 +3368,27 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
3259
3368
 
3260
3369
  const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
3261
3370
  const agentNeedsRerun = parseCompletionBoolean(structuredCompletion?.needs_rerun ?? structuredCompletion?.needsRerun) === true;
3262
- const agentReportedFailure = completionStatus.startsWith('fail')
3371
+ // P-d2a8f6c1: a nonce mismatch always counts as an agent-reported failure
3372
+ // (regardless of what the discarded report claimed). This forces both the
3373
+ // effectiveSuccess gate below and the dispatch result in engine.js to ERROR.
3374
+ const agentReportedFailure = !!nonceMismatch
3375
+ || completionStatus.startsWith('fail')
3263
3376
  || completionStatus === 'error'
3264
3377
  || hasActionableFailureClass(structuredCompletion?.failure_class)
3265
3378
  || agentNeedsRerun;
3266
- const agentRetryable = parseCompletionBoolean(structuredCompletion?.retryable);
3379
+ // Untrusted completions cannot be honored as "retryable" by the agent — its
3380
+ // retryable claim was discarded with the rest of the report. Force false.
3381
+ const agentRetryable = nonceMismatch ? false : parseCompletionBoolean(structuredCompletion?.retryable);
3267
3382
 
3268
3383
  // Auto-recover: if a failed implement/fix/test agent created PRs, it likely succeeded before the failure surfaced.
3384
+ // P-d2a8f6c1: skip auto-recovery for nonce-mismatched dispatches — a forged
3385
+ // report shouldn't be able to ride PRs into a "done" status.
3269
3386
  const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX || type === WORK_TYPE.TEST;
3270
- const autoRecovered = !agentReportedFailure && !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
3387
+ const autoRecovered = !nonceMismatch && !agentReportedFailure && !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
3271
3388
  if (autoRecovered) {
3272
3389
  log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
3273
3390
  }
3274
- const effectiveSuccess = (isSuccess && !agentReportedFailure) || autoRecovered;
3391
+ const effectiveSuccess = !nonceMismatch && ((isSuccess && !agentReportedFailure) || autoRecovered);
3275
3392
 
3276
3393
  let nonCleanReportWritten = false;
3277
3394
  if (completionStatus.startsWith('partial') || autoRecovered || (agentReportedFailure && isSuccess)) {
@@ -3696,7 +3813,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
3696
3813
  const metricsResult = isAutoRetry ? 'retry' : finalResult;
3697
3814
  updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
3698
3815
 
3699
- return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable };
3816
+ return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable, nonceMismatch };
3700
3817
  }
3701
3818
 
3702
3819
  // ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
@@ -451,6 +451,23 @@ function renderPlaybook(type, vars) {
451
451
  }
452
452
  }
453
453
 
454
+ // W-mp68q6ke0010de68 — opt-in keep_processes hint. Injected only when the
455
+ // dispatcher set vars.keep_processes (truthy) from the work item's
456
+ // `meta.keep_processes`. Built via the keep-process-sweep module so the
457
+ // copy stays in sync with the engine-side caps it actually enforces.
458
+ if (vars.keep_processes) {
459
+ try {
460
+ const keepProcessSweep = require('./keep-process-sweep');
461
+ const hint = keepProcessSweep.buildKeepProcessesHint({
462
+ agentId: vars.agent_id,
463
+ workItemId: vars.item_id || vars.task_id,
464
+ ttlMinutes: vars.keep_processes_ttl_minutes,
465
+ minionsDir: MINIONS_DIR,
466
+ });
467
+ if (hint) inertAppendices.push(hint);
468
+ } catch (e) { log('warn', `keep_processes hint render failed: ${e.message}`); }
469
+ }
470
+
454
471
  // Inject KB guardrail
455
472
  content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
456
473
  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`;
package/engine/queries.js CHANGED
@@ -1526,6 +1526,75 @@ function resetPrdInfoCache() {
1526
1526
  _prdResultInputHash = '';
1527
1527
  }
1528
1528
 
1529
+ // ── Project git status (current branch + dirty/detached) ──────────────────
1530
+ // Cached per resolved localPath with a 30s TTL so /api/status doesn't shell
1531
+ // out to git on every poll. Mirrors the cache shape used by getDiskVersion in
1532
+ // dashboard.js (TTL + cached map). All git invocations pipe stderr to suppress
1533
+ // the `fatal: not a git repository` noise on non-git project paths — same
1534
+ // requirement enforced for install/boot paths in
1535
+ // test/unit/runtime-fleet-helpers.test.js:465.
1536
+ const _projectGitStatusCache = new Map();
1537
+ const PROJECT_GIT_STATUS_TTL = 30000; // 30s
1538
+
1539
+ function _gitExec(localPath, args) {
1540
+ const { execFileSync } = require('child_process');
1541
+ return execFileSync('git', ['-C', localPath, ...args], {
1542
+ encoding: 'utf8',
1543
+ timeout: 10000,
1544
+ windowsHide: true,
1545
+ stdio: ['pipe', 'pipe', 'pipe'],
1546
+ });
1547
+ }
1548
+
1549
+ function getProjectGitStatus(localPath) {
1550
+ const key = String(localPath || '').replace(/\\/g, '/');
1551
+ if (!key) return { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' };
1552
+ const now = Date.now();
1553
+ const cached = _projectGitStatusCache.get(key);
1554
+ if (cached && (now - cached.ts) < PROJECT_GIT_STATUS_TTL) return cached.value;
1555
+
1556
+ let value = { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' };
1557
+ try {
1558
+ if (!fs.existsSync(localPath)) {
1559
+ // missing — cache the negative result
1560
+ } else {
1561
+ let isRepo = false;
1562
+ try {
1563
+ const out = _gitExec(localPath, ['rev-parse', '--is-inside-work-tree']).trim();
1564
+ isRepo = out === 'true';
1565
+ } catch { isRepo = false; }
1566
+ if (!isRepo) {
1567
+ value = { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'non-git' };
1568
+ } else {
1569
+ let branch = null;
1570
+ let detached = false;
1571
+ try {
1572
+ branch = _gitExec(localPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim() || null;
1573
+ } catch { branch = null; }
1574
+ if (branch === 'HEAD') {
1575
+ detached = true;
1576
+ try { branch = _gitExec(localPath, ['rev-parse', '--short', 'HEAD']).trim() || null; }
1577
+ catch { branch = null; }
1578
+ }
1579
+ let dirty = false;
1580
+ try {
1581
+ const status = _gitExec(localPath, ['status', '--porcelain', '--untracked-files=no']);
1582
+ dirty = status.length > 0;
1583
+ } catch { dirty = false; }
1584
+ value = { gitBranch: branch, gitDetached: detached, gitDirty: dirty, gitState: 'ok' };
1585
+ }
1586
+ }
1587
+ } catch {
1588
+ // Defensive — never let a git probe failure break /api/status
1589
+ value = { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' };
1590
+ }
1591
+ _projectGitStatusCache.set(key, { ts: now, value });
1592
+ return value;
1593
+ }
1594
+
1595
+ /** Reset project git-status cache — exported for testing */
1596
+ function resetProjectGitStatusCache() { _projectGitStatusCache.clear(); }
1597
+
1529
1598
  // ── Exports ─────────────────────────────────────────────────────────────────
1530
1599
 
1531
1600
  module.exports = {
@@ -1538,7 +1607,9 @@ module.exports = {
1538
1607
  readHeadTail, // exported for testing
1539
1608
  detectInFlightTool, // exported for testing
1540
1609
  resetPrdInfoCache,
1610
+ resetProjectGitStatusCache,
1541
1611
  invalidateKnowledgeBaseCache,
1612
+ getProjectGitStatus,
1542
1613
 
1543
1614
  // Core state
1544
1615
  getConfig, getControl, getDispatch, getDispatchQueue, getDispatchCompletionReport, invalidateDispatchCache,
@@ -76,6 +76,12 @@ const RECOVERY_RECIPES = new Map([
76
76
  freshSession: true,
77
77
  description: 'Context exhausted — retry with fresh session, flag if repeated',
78
78
  }],
79
+ [FAILURE_CLASS.WORKTREE_PREFLIGHT, {
80
+ maxAttempts: 0,
81
+ escalation: ESCALATION_POLICY.NO_RETRY,
82
+ freshSession: false,
83
+ description: 'Worktree preflight rejected — same inputs will recompute to the same rejection (drive-root rootDir, nested-in-project worktree). Fix the dispatch (attach a project, move MINIONS_DIR, or override engine.worktreeRoot) before retrying.',
84
+ }],
79
85
  [FAILURE_CLASS.UNKNOWN, {
80
86
  maxAttempts: null, // null = fall back to ENGINE_DEFAULTS.maxRetries
81
87
  escalation: ESCALATION_POLICY.AUTO,