@yemi33/minions 0.1.1616 → 0.1.1618

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1618 (2026-04-29)
4
+
5
+ ### Features
6
+ - fix protected-file guard (#1858)
7
+ - fix canonical active PR gate (#1857)
8
+ - document PR auto-fix trigger precedence (#1855)
9
+
10
+ ### Fixes
11
+ - guard stale build & conflict auto-fixes with live pre-dispatch check (#1851)
12
+
3
13
  ## 0.1.1616 (2026-04-29)
4
14
 
5
15
  ### Features
package/dashboard.js CHANGED
@@ -667,7 +667,7 @@ function ccSessionValid() {
667
667
  const CC_STATIC_SYSTEM_PROMPT = (() => {
668
668
  try {
669
669
  const raw = fs.readFileSync(path.join(MINIONS_DIR, 'prompts', 'cc-system.md'), 'utf8');
670
- return raw.replace(/\{\{minions_dir\}\}/g, MINIONS_DIR);
670
+ return shared.renderCcSystemPrompt(raw, { liveRoot: MINIONS_DIR });
671
671
  } catch (e) {
672
672
  console.error('Failed to load prompts/cc-system.md:', e.message);
673
673
  return 'You are the Command Center AI for Minions. Delegate work to agents.';
@@ -32,9 +32,13 @@ Before scanning, the engine materializes plans and specs into project work items
32
32
  |----------|--------|---------------|
33
33
  | Minions review pending/waiting | Queue a code review | `review` |
34
34
  | Minions review `changes-requested` | Route back to author for fixes | `fix` |
35
+ | Human feedback pending | Route back to author for fixes | `fix` |
35
36
  | `buildStatus: "failing"` | Route to any agent for build fix | `fix` |
37
+ | `_mergeConflict: true` | Route to author for conflict resolution | `fix` |
36
38
  Skips PRs where `status !== "active"`.
37
39
 
40
+ PR fix triggers are evaluated in this source order inside `discoverFromPrs()`: review feedback first (`engine.js:2166-2180`), human feedback second (`engine.js:2191-2226`), build failure third (`engine.js:2229-2271`), and merge conflict fourth (`engine.js:2299-2317`). Conflict fixes are additionally gated by `!fixDispatched` (`engine.js:2301`), so any earlier successful fix dispatch in the same PR discovery pass suppresses the conflict fix until a later pass.
41
+
38
42
  ### Source 2: PRD Gap Analysis (via `materializePlansAsWorkItems`)
39
43
 
40
44
  PRD items flow through `materializePlansAsWorkItems()`, which scans `~/.minions/prd/*.json` for PRD files with `missing` / `updated` / `planned` items and creates work items in the target project's queue.
@@ -413,4 +417,3 @@ All discovery behavior is controlled via `config.json`:
413
417
  ```
414
418
 
415
419
  To disable a work source for a project, set `"enabled": false`. To change where the engine looks for PRD or PR files, change the `path` field (resolved relative to `localPath`).
416
-
@@ -21,14 +21,23 @@ How the engine manages the lifecycle of a PR from creation through review, fix,
21
21
  - Stores `minionsReview: { reviewer, reviewedAt, note }`
22
22
  - Creates feedback file for author agent
23
23
 
24
- ## 4. Fix dispatch (3 independent triggers, at most one per tick)
24
+ ## 4. Fix dispatch trigger order
25
+
26
+ `discoverFromPrs()` evaluates PR auto-fix triggers in a fixed order during each discovery pass:
27
+
28
+ 1. Review feedback (`changes-requested`) — `engine.js:2166-2180`
29
+ 2. Human feedback (`humanFeedback.pendingFix` or coalesced feedback) — `engine.js:2191-2226`
30
+ 3. Build failure (`buildStatus === 'failing'`) — `engine.js:2229-2271`
31
+ 4. Merge conflict (`_mergeConflict`) — `engine.js:2299-2317`
32
+
33
+ When multiple problems coexist, earlier triggers get the first chance to enqueue work. The local `fixDispatched` flag is declared before the fix triggers (`engine.js:2168`) and set after review-feedback, human-feedback, and build-failure dispatches (`engine.js:2180`, `engine.js:2226`, `engine.js:2271`). Conflict fixes run last and explicitly require `!fixDispatched` (`engine.js:2301`), so any earlier successful fix dispatch suppresses the conflict fix for that PR in the same discovery pass. Build fixes are evaluated after review and human feedback, but the build-fix condition itself is not gated by `!fixDispatched` (`engine.js:2238`).
25
34
 
26
35
  ### A. Review feedback (`changes-requested`)
27
36
 
28
37
  - Gate: `reviewStatus === 'changes-requested'` + `!awaitingReReview` + not dispatched + not on cooldown
29
38
  - Routes to PR author via `_author_` routing token
30
39
  - `review_note` = reviewer's feedback
31
- - Sets `fixDispatched = true` — prevents trigger B from also firing this tick
40
+ - Sets `fixDispatched = true` — prevents human-feedback and conflict fixes from also firing this pass
32
41
 
33
42
  ### B. Human comments (`humanFeedback.pendingFix`)
34
43
 
@@ -43,6 +52,13 @@ How the engine manages the lifecycle of a PR from creation through review, fix,
43
52
  - **Grace period** (`_buildFixPushedAt`): after fix dispatches, waits `buildFixGracePeriod` (default 10min, configurable in `ENGINE_DEFAULTS`) for CI to run before re-dispatching. Cleared when poller detects build status transition (CI actually ran).
44
53
  - **Error logs**: GitHub fetches annotations (failures only, not warnings) + Actions job log (always). ADO queries builds API directly (not status checks), fetches build timeline → failed task logs (up to 10 per build, up to 10 failing pipelines).
45
54
  - **Escalation**: after 3 failed attempts, writes inbox alert, sets `buildFixEscalated = true`, stops auto-dispatch. Counter resets when build recovers.
55
+ - Sets `fixDispatched = true` after dispatch so the later conflict trigger is suppressed in the same pass.
56
+
57
+ ### D. Merge conflicts (`_mergeConflict`)
58
+
59
+ - Gate: `autoFixConflicts` + `status === 'active'` + `_mergeConflict` + `!fixDispatched`
60
+ - Routes to the PR author to resolve target-branch conflicts
61
+ - Runs after review, human, and build triggers; if any earlier trigger enqueued a fix for this PR, the conflict fix waits for a later discovery pass
46
62
 
47
63
  ## 5. Fix completes
48
64
 
@@ -71,7 +87,7 @@ How the engine manages the lifecycle of a PR from creation through review, fix,
71
87
  | Scenario | Guard |
72
88
  |---|---|
73
89
  | Simultaneous review + fix | `activePrIds` — skip PR if any dispatch in-flight |
74
- | Duplicate fix (review + human) | `fixDispatched` flag — only one fix per PR per tick |
90
+ | Duplicate fix (review + human + conflict) | `fixDispatched` flag — later human/conflict triggers skip after earlier fix dispatches in the same PR pass |
75
91
  | Branch write conflict | `isBranchActive()` mutex |
76
92
  | Fix while awaiting re-review | `awaitingReReview` (waiting + fixedAt) |
77
93
  | Build fix before CI runs | `_buildFixPushedAt` grace period (10min) |
package/engine/ado.js CHANGED
@@ -844,6 +844,77 @@ async function checkLiveReviewStatus(pr, project) {
844
844
  }
845
845
  }
846
846
 
847
+ /**
848
+ * Cheap pre-dispatch freshness check for build status and merge-conflict state.
849
+ * Mirrors checkLiveReviewStatus — fetches PR data once, classifies builds for the
850
+ * current merge commit, and reports whether ADO still considers the PR conflicted.
851
+ *
852
+ * Returns null if the check can't run (no token, no PR number, network error) so
853
+ * callers can fall back to cached state. Otherwise returns:
854
+ * {
855
+ * buildStatus: 'failing' | 'passing' | 'running' | 'none' | null,
856
+ * mergeConflict: boolean,
857
+ * }
858
+ *
859
+ * `buildStatus` is null when ADO has builds on the merge ref but none target the
860
+ * current merge commit (target-branch advance with no source-side rebuild yet —
861
+ * matches pollPrStatus's "preserve previous buildStatus" semantics from issue
862
+ * #1233; the caller must trust the cached value).
863
+ */
864
+ async function checkLiveBuildAndConflict(pr, project) {
865
+ try {
866
+ const token = await getAdoToken();
867
+ if (!token) return null;
868
+ const orgBase = shared.getAdoOrgBase(project);
869
+ const prNum = shared.getPrNumber(pr);
870
+ if (!prNum) return null;
871
+ const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}`;
872
+ const prUrl = `${repoBase}/pullrequests/${prNum}?api-version=7.1`;
873
+ // 4s timeout — same budget as checkLiveReviewStatus. This is a pre-dispatch
874
+ // gate; we'd rather miss a freshness signal and fall back to cache than
875
+ // block dispatch on a slow ADO call.
876
+ const prData = await adoFetch(prUrl, token, { timeout: 4000 });
877
+ if (!prData) return null;
878
+
879
+ // Conflict signal — ADO reports `mergeStatus: 'conflicts'` when the merge
880
+ // would conflict; anything else means clean (or recomputing).
881
+ const mergeConflict = prData.mergeStatus === 'conflicts';
882
+
883
+ // Build signal — only meaningful when the PR is still open. We replicate
884
+ // pollPrStatus's narrowing logic so the live check and the cached poll
885
+ // agree on what 'failing' / 'passing' / 'running' / 'none' mean.
886
+ let buildStatus = null;
887
+ if (prData.status === 'active') {
888
+ const mergeCommitId = prData.lastMergeCommit?.commitId;
889
+ if (mergeCommitId) {
890
+ try {
891
+ const mergeRef = encodeURIComponent(`refs/pull/${prNum}/merge`);
892
+ const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${project.repositoryId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
893
+ const buildsData = await adoFetch(buildsUrl, token, { timeout: 4000 });
894
+ const allBuilds = buildsData?.value || [];
895
+ const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
896
+ if (prBuilds.length > 0) {
897
+ buildStatus = classifyBuildStatus(prBuilds);
898
+ } else if (allBuilds.length === 0) {
899
+ buildStatus = 'none';
900
+ }
901
+ // else: merge-commit mismatch — leave buildStatus null so caller
902
+ // falls back to cached state (issue #1233).
903
+ } catch (e) { log('warn', `Live build check builds query for ${pr.id}: ${e.message}`); }
904
+ } else {
905
+ // No merge commit yet — likely conflict or fresh PR. Treat as 'none'
906
+ // so a stale 'failing' cache can be cleared by the caller.
907
+ buildStatus = 'none';
908
+ }
909
+ }
910
+
911
+ return { buildStatus, mergeConflict };
912
+ } catch (e) {
913
+ log('warn', `Live build/conflict check for ${pr.id}: ${e.message}`);
914
+ return null;
915
+ }
916
+ }
917
+
847
918
  async function fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo) {
848
919
  const token = await getAdoToken();
849
920
  if (!token) return null;
@@ -968,6 +1039,22 @@ function _setAdoThrottleForTest(state) {
968
1039
  _adoThrottle._setForTest(state);
969
1040
  }
970
1041
 
1042
+ /** Inject a token into the cache — exported for testing only.
1043
+ * Lets tests exercise functions that call getAdoToken() without invoking azureauth.
1044
+ * Pass null to force getAdoToken() to return null synchronously (no exec). */
1045
+ function _setAdoTokenForTest(token) {
1046
+ if (token == null) {
1047
+ // Clear cache AND set a future failure backoff so getAdoToken short-circuits
1048
+ // to null without spawning azureauth — otherwise tests would hang on the
1049
+ // 15s execAsync timeout or open a real auth popup.
1050
+ _adoTokenCache = { token: null, expiresAt: 0 };
1051
+ _adoTokenFailedUntil = Date.now() + 60 * 60 * 1000;
1052
+ } else {
1053
+ _adoTokenCache = { token, expiresAt: Date.now() + 30 * 60 * 1000 };
1054
+ _adoTokenFailedUntil = 0;
1055
+ }
1056
+ }
1057
+
971
1058
  module.exports = {
972
1059
  getAdoToken,
973
1060
  adoFetch,
@@ -975,6 +1062,7 @@ module.exports = {
975
1062
  pollPrHumanComments,
976
1063
  reconcilePrs,
977
1064
  checkLiveReviewStatus,
1065
+ checkLiveBuildAndConflict,
978
1066
  needsAdoPollRetry,
979
1067
  isAdoAuthError, // exported for testing
980
1068
  isAdoThrottled,
@@ -984,4 +1072,5 @@ module.exports = {
984
1072
  findOpenPrOnBranch,
985
1073
  _resetAdoThrottle, // exported for testing
986
1074
  _setAdoThrottleForTest, // exported for testing
1075
+ _setAdoTokenForTest, // exported for testing
987
1076
  };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-04-29T00:05:48.131Z"
4
+ "cachedAt": "2026-04-29T01:10:58.853Z"
5
5
  }
package/engine/github.js CHANGED
@@ -789,11 +789,79 @@ async function checkLiveReviewStatus(pr, project) {
789
789
  }
790
790
  }
791
791
 
792
+ /**
793
+ * Cheap pre-dispatch freshness check for build status and merge-conflict state.
794
+ * Mirrors checkLiveReviewStatus — fetches PR data once, classifies check-runs
795
+ * for the current head SHA, and reports whether GitHub still considers the PR
796
+ * unmergeable.
797
+ *
798
+ * Returns null if the check can't run (no slug/PR/network) so callers can fall
799
+ * back to cached state. Otherwise returns:
800
+ * {
801
+ * buildStatus: 'failing' | 'passing' | 'running' | 'none' | null,
802
+ * mergeConflict: boolean,
803
+ * }
804
+ *
805
+ * `mergeConflict` is true only when GitHub explicitly reports `mergeable: false`
806
+ * — `mergeable: null` means GitHub is still computing the merge state, so the
807
+ * caller should treat that as "no fresh signal" and trust the cache.
808
+ *
809
+ * `buildStatus` is null when we couldn't query check-runs or the PR isn't open;
810
+ * caller falls back to cached value.
811
+ */
812
+ async function checkLiveBuildAndConflict(pr, project) {
813
+ try {
814
+ const slug = getRepoSlug(project);
815
+ if (!slug) return null;
816
+ const prNum = shared.getPrNumber(pr);
817
+ if (!prNum) return null;
818
+ const prData = await ghApi(`/pulls/${prNum}`, slug);
819
+ if (!prData || prData === GH_NOT_FOUND) return null;
820
+
821
+ // Conflict signal — only treat `mergeable === false` as a positive
822
+ // conflict. `null` means "GitHub still computing" → we have no fresh
823
+ // info, so fall back to whatever the cache says.
824
+ let mergeConflict;
825
+ if (prData.mergeable === false) mergeConflict = true;
826
+ else if (prData.mergeable === true) mergeConflict = false;
827
+ else mergeConflict = !!pr._mergeConflict; // computing — preserve cached view
828
+
829
+ // Build signal — only meaningful for open PRs. Mirrors pollPrStatus's
830
+ // check-runs classification so the live read and cached poll agree on
831
+ // 'failing' / 'passing' / 'running' / 'none'.
832
+ let buildStatus = null;
833
+ if (prData.state === 'open' && prData.head?.sha) {
834
+ try {
835
+ const checksData = await ghApi(`/commits/${prData.head.sha}/check-runs`, slug);
836
+ if (checksData && Array.isArray(checksData.check_runs)) {
837
+ const runs = checksData.check_runs;
838
+ if (runs.length === 0) {
839
+ buildStatus = 'none';
840
+ } else {
841
+ const hasFailed = runs.some(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
842
+ const allDone = runs.every(r => r.status === 'completed');
843
+ const allPassed = runs.every(r => r.conclusion === 'success' || r.conclusion === 'skipped' || r.conclusion === 'neutral');
844
+ if (hasFailed) buildStatus = 'failing';
845
+ else if (allDone && allPassed) buildStatus = 'passing';
846
+ else buildStatus = 'running';
847
+ }
848
+ }
849
+ } catch (e) { log('warn', `Live build check checks query for ${pr.id}: ${e.message}`); }
850
+ }
851
+
852
+ return { buildStatus, mergeConflict };
853
+ } catch (e) {
854
+ log('warn', `Live build/conflict check for ${pr.id}: ${e.message}`);
855
+ return null;
856
+ }
857
+ }
858
+
792
859
  module.exports = {
793
860
  pollPrStatus,
794
861
  pollPrHumanComments,
795
862
  reconcilePrs,
796
863
  checkLiveReviewStatus,
864
+ checkLiveBuildAndConflict,
797
865
  isGhThrottled,
798
866
  getGhThrottleState,
799
867
  // Exported for testing
@@ -2113,7 +2113,27 @@ function classifyFailure(code, stdout = '', stderr = '') {
2113
2113
  }
2114
2114
 
2115
2115
  // Permission / trust / auth failures
2116
- if (/permission denied|access denied|unauthorized|403 forbidden|trust.*blocked|auth.*fail/i.test(combined)) {
2116
+ //
2117
+ // History (W-moja4a5qp9pj): the previous patterns `trust.*blocked` and
2118
+ // `auth.*fail` used unbounded greedy `.*`. JSONL agent init events that
2119
+ // emit the entire skill / slash-command catalogue on a single line
2120
+ // happen to contain words like `check-self-authored-...` and
2121
+ // `diagnose-build-fail-...`, which made the greedy regex match across
2122
+ // thousands of unrelated characters and silently flag healthy agents
2123
+ // as PERMISSION_BLOCKED on any non-zero exit. Use anchored phrases that
2124
+ // only match real auth/trust failure messages.
2125
+ const _PERM_PHRASES = [
2126
+ /\bpermission denied\b/i,
2127
+ /\baccess denied\b/i,
2128
+ /\bunauthorized\b/i,
2129
+ /\b403 forbidden\b/i,
2130
+ /\bauthentication (?:failed|error|failure)\b/i,
2131
+ /\bauth(?:entication)? (?:fail(?:ed|ure|s)?|denied|rejected)\b/i,
2132
+ /\btrust (?:gate|domain|zone|policy)? ?(?:is |was |has been )?(?:blocked|denied|rejected)\b/i,
2133
+ /\bcredentials? (?:rejected|invalid|expired)\b/i,
2134
+ /\btoken (?:rejected|invalid|expired|revoked)\b/i,
2135
+ ];
2136
+ if (_PERM_PHRASES.some(re => re.test(combined))) {
2117
2137
  return FAILURE_CLASS.PERMISSION_BLOCKED;
2118
2138
  }
2119
2139
 
package/engine/shared.js CHANGED
@@ -1272,6 +1272,122 @@ function getAdoOrgBase(project) {
1272
1272
 
1273
1273
  // ── Path Sanitization ───────────────────────────────────────────────────────
1274
1274
 
1275
+ /**
1276
+ * Files in the LIVE Minions checkout (MINIONS_DIR) that the Command Center
1277
+ * must never edit directly. Three flavours:
1278
+ *
1279
+ * - "basenames": exact relative paths under the live root (engine.js, dashboard.js,
1280
+ * minions.js, config.json — and the runtime state files engine/control.json
1281
+ * and engine/dispatch.json).
1282
+ * - "globs": direct-child JS files under protected live directories
1283
+ * (engine/*.js, bin/*.js).
1284
+ * - "prefixes": relative directory prefixes whose entire subtree is read-only
1285
+ * when it lives in the live root (dashboard/**).
1286
+ *
1287
+ * The list is intentionally small and explicit. It mirrors the textual rule in
1288
+ * `prompts/cc-system.md`. Source of truth lives here; the system prompt renders
1289
+ * `{{cc_protected_paths}}` from this list at startup so the two cannot drift.
1290
+ *
1291
+ * The guard is ROOT-AWARE: a path only counts as protected when its absolute
1292
+ * resolution sits inside MINIONS_DIR. The same basename inside an isolated
1293
+ * task worktree (e.g. `D:/worktrees/minions-work/W-xxx/dashboard.js`) is NOT
1294
+ * protected — agents working in those copies are free to edit them, since
1295
+ * git keeps changes inside the worktree until the agent pushes a branch.
1296
+ */
1297
+ const _CC_PROTECTED_BASENAMES = Object.freeze([
1298
+ 'engine.js',
1299
+ 'dashboard.js',
1300
+ 'minions.js',
1301
+ 'config.json',
1302
+ 'engine/control.json',
1303
+ 'engine/dispatch.json',
1304
+ ]);
1305
+ const _CC_PROTECTED_FILE_GLOBS = Object.freeze([
1306
+ 'engine/*.js',
1307
+ 'bin/*.js',
1308
+ ]);
1309
+ const _CC_PROTECTED_PREFIXES = Object.freeze([
1310
+ 'dashboard/',
1311
+ ]);
1312
+
1313
+ /**
1314
+ * Returns the literal text used by the CC system prompt for the protected-file
1315
+ * rule. Combines the basenames + prefixes above into a single sentence so the
1316
+ * authored rule and the helper that enforces it can never disagree.
1317
+ *
1318
+ * The result is anchored to a specific live root so the LLM can't conflate
1319
+ * "edits to dashboard.js" with "edits to a worktree copy of dashboard.js".
1320
+ */
1321
+ function describeCcProtectedPaths(liveRoot) {
1322
+ const root = (liveRoot && typeof liveRoot === 'string') ? liveRoot : MINIONS_DIR;
1323
+ const norm = root.replace(/\\/g, '/');
1324
+ const basenames = _CC_PROTECTED_BASENAMES.map(b => '`' + b + '`').join(', ');
1325
+ const globs = _CC_PROTECTED_FILE_GLOBS.map(g => '`' + g + '`').join(', ');
1326
+ const prefixes = _CC_PROTECTED_PREFIXES.map(p => '`' + p + '**`').join(', ');
1327
+ return `READ ONLY in the live checkout at \`${norm}\` — never write/edit: ${basenames}, ${globs}, ${prefixes}. This rule is path-scoped, not basename-scoped. Files with the same basename inside an isolated agent worktree (e.g. \`{worktreeRoot}/W-<id>/dashboard.js\`) are NOT protected — agents working in their own worktrees may edit any repository source the work item requires.`;
1328
+ }
1329
+
1330
+ function renderCcSystemPrompt(raw, opts) {
1331
+ const liveRoot = (opts && typeof opts.liveRoot === 'string') ? opts.liveRoot : MINIONS_DIR;
1332
+ return String(raw || '')
1333
+ .replace(/\{\{minions_dir\}\}/g, liveRoot)
1334
+ .replace(/\{\{cc_protected_paths\}\}/g, describeCcProtectedPaths(liveRoot));
1335
+ }
1336
+
1337
+ /**
1338
+ * Is this absolute path a CC-protected file in the LIVE Minions checkout?
1339
+ *
1340
+ * Returns true ONLY if all three hold:
1341
+ * 1. `absPath` resolves to something inside `liveRoot` (default: MINIONS_DIR).
1342
+ * 2. Its relative path matches a protected basename (e.g. `dashboard.js`)
1343
+ * OR matches a protected direct-child glob (`engine/*.js`, `bin/*.js`)
1344
+ * OR sits under a protected directory prefix (`dashboard/`).
1345
+ * 3. The input is a real string (no nullish, no non-string values).
1346
+ *
1347
+ * Returns false for:
1348
+ * - Paths outside `liveRoot` (worktrees, sibling repos, scratch dirs, etc.)
1349
+ * - Non-protected files inside `liveRoot` (notes.md, knowledge/foo.md, …)
1350
+ * - Invalid inputs (null/undefined/empty/non-string)
1351
+ *
1352
+ * Why this exists: PR W-moja4a5qp9pj. The CC system prompt previously named
1353
+ * protected files by basename only ("never write/edit dashboard.js"). Agents
1354
+ * dispatched into isolated worktrees inherited the same prose verbatim and
1355
+ * occasionally interpreted it as banning their own worktree copy of those
1356
+ * files, blocking otherwise legitimate fixes. The guard now distinguishes
1357
+ * "same path, live tree" from "same basename, worktree copy".
1358
+ */
1359
+ function isLiveCommandCenterPath(absPath, opts) {
1360
+ if (typeof absPath !== 'string' || absPath.length === 0) return false;
1361
+ if (absPath.includes('\0')) return false;
1362
+ const liveRoot = (opts && typeof opts.liveRoot === 'string') ? opts.liveRoot : MINIONS_DIR;
1363
+ const pathApi = /^[a-zA-Z]:[\\/]/.test(absPath) || /^[a-zA-Z]:[\\/]/.test(liveRoot) ? path.win32 : path;
1364
+ let resolved;
1365
+ let resolvedRoot;
1366
+ try {
1367
+ resolved = pathApi.resolve(absPath);
1368
+ resolvedRoot = pathApi.resolve(liveRoot);
1369
+ } catch { return false; }
1370
+ // Must be inside liveRoot. Compare with trailing separator to avoid the
1371
+ // sibling-prefix bug ("D:/squad-old" startsWith "D:/squad").
1372
+ const rootWithSep = resolvedRoot.endsWith(pathApi.sep) ? resolvedRoot : (resolvedRoot + pathApi.sep);
1373
+ const caseInsensitive = pathApi === path.win32 || process.platform === 'win32';
1374
+ const cmpResolved = caseInsensitive ? resolved.toLowerCase() : resolved;
1375
+ const cmpResolvedRoot = caseInsensitive ? resolvedRoot.toLowerCase() : resolvedRoot;
1376
+ const cmpRootWithSep = caseInsensitive ? rootWithSep.toLowerCase() : rootWithSep;
1377
+ if (cmpResolved !== cmpResolvedRoot && !cmpResolved.startsWith(cmpRootWithSep)) return false;
1378
+ // Compute the path relative to the live root and normalize separators so
1379
+ // the basename / prefix checks are platform-independent.
1380
+ const rel = pathApi.relative(resolvedRoot, resolved).replace(/\\/g, '/');
1381
+ if (rel === '' || rel === '.') return false; // root itself is not a "file"
1382
+ const relForMatch = rel.toLowerCase();
1383
+ if (_CC_PROTECTED_BASENAMES.includes(relForMatch)) return true;
1384
+ if (/^(?:engine|bin)\/[^/]+\.js$/.test(relForMatch)) return true;
1385
+ for (const prefix of _CC_PROTECTED_PREFIXES) {
1386
+ if (relForMatch === prefix.slice(0, -1) /* exact dir */ || relForMatch.startsWith(prefix)) return true;
1387
+ }
1388
+ return false;
1389
+ }
1390
+
1275
1391
  /**
1276
1392
  * Validate that a user-supplied filename stays within the given base directory.
1277
1393
  * Rejects path traversal (../, encoded variants), null bytes, and absolute paths.
@@ -2099,6 +2215,12 @@ module.exports = {
2099
2215
  getAdoOrgBase,
2100
2216
  sanitizePath,
2101
2217
  sanitizeBranch,
2218
+ isLiveCommandCenterPath,
2219
+ describeCcProtectedPaths,
2220
+ renderCcSystemPrompt,
2221
+ _CC_PROTECTED_BASENAMES, // exported for testing
2222
+ _CC_PROTECTED_FILE_GLOBS, // exported for testing
2223
+ _CC_PROTECTED_PREFIXES, // exported for testing
2102
2224
  isAllowedOrigin,
2103
2225
  buildSecurityHeaders,
2104
2226
  hasDangerousKey,
package/engine.js CHANGED
@@ -1581,8 +1581,8 @@ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
1581
1581
  // ─── Inbox Consolidation (extracted to engine/consolidation.js) ──────────────
1582
1582
 
1583
1583
  const { consolidateInbox } = require('./engine/consolidation');
1584
- const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, needsAdoPollRetry, getAdoToken, isAdoThrottled } = require('./engine/ado');
1585
- const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview, isGhThrottled } = require('./engine/github');
1584
+ const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, checkLiveBuildAndConflict: adoCheckLiveBuildAndConflict, needsAdoPollRetry, getAdoToken, isAdoThrottled } = require('./engine/ado');
1585
+ const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview, checkLiveBuildAndConflict: ghCheckLiveBuildAndConflict, isGhThrottled } = require('./engine/github');
1586
1586
 
1587
1587
  // ─── State Snapshot ─────────────────────────────────────────────────────────
1588
1588
 
@@ -2049,7 +2049,8 @@ async function discoverFromPrs(config, project) {
2049
2049
  for (const pr of prs) {
2050
2050
  if (pr.status !== PR_STATUS.ACTIVE || pr._contextOnly) continue;
2051
2051
  const prDisplayId = shared.getPrDisplayId(pr);
2052
- if (activePrIds.has(pr.id)) continue; // Skip PRs with active dispatch (prevent race)
2052
+ const prCanonicalId = shared.getCanonicalPrId(project, pr, pr.url || '');
2053
+ if (activePrIds.has(prCanonicalId)) continue; // Skip PRs with active dispatch (prevent race)
2053
2054
  // Branch mutex: skip if PR branch is locked by any active dispatch (cross-type collision)
2054
2055
  if (pr.branch && isBranchActive(pr.branch)) {
2055
2056
  log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${pr.branch} locked by another agent`);
@@ -2255,6 +2256,39 @@ async function discoverFromPrs(config, project) {
2255
2256
 
2256
2257
  const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
2257
2258
  if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2259
+
2260
+ // Pre-dispatch live build check — cached buildStatus may be stale: ADO can
2261
+ // recompute the merge commit when master moves and pollPrStatus deliberately
2262
+ // preserves the previous 'failing' value (issue #1233); GitHub check-runs
2263
+ // may have flipped to 'passing' minutes before the next 12-tick poll. Mirror
2264
+ // the review/re-review live-vote guard so we don't dispatch a fix for a
2265
+ // build that has already recovered.
2266
+ try {
2267
+ const checkBcFn = project.repoHost === 'github' ? ghCheckLiveBuildAndConflict : adoCheckLiveBuildAndConflict;
2268
+ const live = await checkBcFn(pr, project);
2269
+ if (live && live.buildStatus && live.buildStatus !== 'failing') {
2270
+ log('info', `Pre-dispatch build check: ${pr.id} build is ${live.buildStatus} (cached was failing) — skipping build-fix`);
2271
+ // Persist the fresh status so subsequent ticks don't re-check on every pass
2272
+ try {
2273
+ mutatePullRequests(projectPrPath(project), prs => {
2274
+ const target = shared.findPrRecord(prs, pr, project);
2275
+ if (!target) return;
2276
+ target.buildStatus = live.buildStatus;
2277
+ if (live.buildStatus === 'passing') {
2278
+ delete target.buildErrorLog;
2279
+ delete target.buildFailReason;
2280
+ delete target._buildFailNotified;
2281
+ if (target.buildFixAttempts) {
2282
+ delete target.buildFixAttempts;
2283
+ delete target.buildFixEscalated;
2284
+ }
2285
+ }
2286
+ });
2287
+ } catch {}
2288
+ continue;
2289
+ }
2290
+ } catch (e) { log('warn', `Pre-dispatch build check for ${pr.id}: ${e.message} — skipping dispatch`); continue; }
2291
+
2258
2292
  const agentId = resolveAgent('fix', config, pr.agent);
2259
2293
  if (!agentId) continue;
2260
2294
 
@@ -2306,22 +2340,47 @@ async function discoverFromPrs(config, project) {
2306
2340
  const conflictFixedAt = pr._conflictFixedAt;
2307
2341
  const withinLag = conflictFixedAt && Date.now() - new Date(conflictFixedAt).getTime() < 10 * 60 * 1000;
2308
2342
  if (!withinLag && !fixThrottled && !isAlreadyDispatched(key) && !isOnCooldown(key, cooldownMs)) {
2309
- const agentId = resolveAgent('fix', config, pr.agent);
2310
- if (agentId) {
2311
- const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2312
- pr_id: pr.id, pr_branch: pr.branch || '',
2313
- review_note: `This PR has merge conflicts with the target branch. Resolve the conflicts:\n\n1. Pull latest from main/master\n2. Resolve all conflicts (prefer PR branch changes unless main has critical fixes)\n3. Build and test after resolving\n4. Push the resolved branch`,
2314
- }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2315
- if (item) {
2316
- newWork.push(item);
2317
- setCooldown(key);
2318
- // Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
2343
+ // Pre-dispatch live conflict check — cached `_mergeConflict` may be
2344
+ // stale: ADO/GitHub recompute mergeStatus asynchronously (1–5 min lag),
2345
+ // so a successful upstream merge can leave the flag set even after the
2346
+ // conflict is gone. Mirror the review/re-review live-vote guard so we
2347
+ // don't dispatch a conflict-fix for a PR that's already clean.
2348
+ let liveSkip = false;
2349
+ try {
2350
+ const checkBcFn = project.repoHost === 'github' ? ghCheckLiveBuildAndConflict : adoCheckLiveBuildAndConflict;
2351
+ const live = await checkBcFn(pr, project);
2352
+ if (live && live.mergeConflict === false) {
2353
+ log('info', `Pre-dispatch conflict check: ${pr.id} reports clean merge (cached was conflict) — skipping conflict-fix`);
2319
2354
  try {
2320
2355
  mutatePullRequests(projectPrPath(project), prs => {
2321
2356
  const target = shared.findPrRecord(prs, pr, project);
2322
- if (target) target._conflictFixedAt = new Date().toISOString();
2357
+ if (!target) return;
2358
+ delete target._mergeConflict;
2359
+ delete target._conflictFixedAt;
2323
2360
  });
2324
- } catch (e) { log('warn', `conflict-fix timestamp: ${e.message}`); }
2361
+ } catch {}
2362
+ liveSkip = true;
2363
+ }
2364
+ } catch (e) { log('warn', `Pre-dispatch conflict check for ${pr.id}: ${e.message} — skipping dispatch`); liveSkip = true; }
2365
+
2366
+ if (!liveSkip) {
2367
+ const agentId = resolveAgent('fix', config, pr.agent);
2368
+ if (agentId) {
2369
+ const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2370
+ pr_id: pr.id, pr_branch: pr.branch || '',
2371
+ review_note: `This PR has merge conflicts with the target branch. Resolve the conflicts:\n\n1. Pull latest from main/master\n2. Resolve all conflicts (prefer PR branch changes unless main has critical fixes)\n3. Build and test after resolving\n4. Push the resolved branch`,
2372
+ }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2373
+ if (item) {
2374
+ newWork.push(item);
2375
+ setCooldown(key);
2376
+ // Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
2377
+ try {
2378
+ mutatePullRequests(projectPrPath(project), prs => {
2379
+ const target = shared.findPrRecord(prs, pr, project);
2380
+ if (target) target._conflictFixedAt = new Date().toISOString();
2381
+ });
2382
+ } catch (e) { log('warn', `conflict-fix timestamp: ${e.message}`); }
2383
+ }
2325
2384
  }
2326
2385
  }
2327
2386
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1616",
3
+ "version": "0.1.1618",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"
@@ -17,7 +17,7 @@ Codex will review your changes — make sure your implementation is thorough and
17
17
  - Leave no stone unturned when implementing or explaining. Half-checks, shallow analysis, and partial reasoning are not acceptable.
18
18
 
19
19
  ## Guardrails
20
- READ ONLY — never write/edit: `engine.js`, `engine/*.js`, `dashboard.js`, `dashboard/**`, `minions.js`, `bin/*.js`, `engine/control.json`, `engine/dispatch.json`, `config.json`.
20
+ {{cc_protected_paths}}
21
21
  CAN modify: notes, plans, knowledge, work items, pull-requests.json, routing.md, charters, skills, playbooks, project repos.
22
22
 
23
23
  ## Filesystem