@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 +10 -0
- package/dashboard.js +1 -1
- package/docs/auto-discovery.md +4 -1
- package/docs/pr-review-fix-loop.md +19 -3
- package/engine/ado.js +89 -0
- package/engine/copilot-models.json +1 -1
- package/engine/github.js +68 -0
- package/engine/lifecycle.js +21 -1
- package/engine/shared.js +122 -0
- package/engine.js +74 -15
- package/package.json +1 -1
- package/prompts/cc-system.md +1 -1
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
|
|
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.';
|
package/docs/auto-discovery.md
CHANGED
|
@@ -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
|
|
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
|
|
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 —
|
|
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
|
};
|
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
|
package/engine/lifecycle.js
CHANGED
|
@@ -2113,7 +2113,27 @@ function classifyFailure(code, stdout = '', stderr = '') {
|
|
|
2113
2113
|
}
|
|
2114
2114
|
|
|
2115
2115
|
// Permission / trust / auth failures
|
|
2116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
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)
|
|
2357
|
+
if (!target) return;
|
|
2358
|
+
delete target._mergeConflict;
|
|
2359
|
+
delete target._conflictFixedAt;
|
|
2323
2360
|
});
|
|
2324
|
-
} catch
|
|
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.
|
|
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"
|
package/prompts/cc-system.md
CHANGED
|
@@ -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
|
-
|
|
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
|