@yemi33/minions 0.1.2178 → 0.1.2180
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/README.md +7 -5
- package/bin/minions.js +39 -17
- package/dashboard/js/command-parser.js +1 -1
- package/dashboard/js/memory-panel.js +324 -0
- package/dashboard/js/qa.js +2 -2
- package/dashboard/js/refresh.js +19 -1
- package/dashboard/js/render-other.js +143 -2
- package/dashboard/js/render-prs.js +2 -1
- package/dashboard/js/render-schedules.js +1 -1
- package/dashboard/js/render-watches.js +1 -1
- package/dashboard/js/render-work-items.js +18 -1
- package/dashboard/js/settings.js +23 -0
- package/dashboard/pages/engine-memory-panel.html +56 -0
- package/dashboard/pages/engine.html +1 -0
- package/dashboard/pages/tools.html +8 -0
- package/dashboard/slim/js/link-pr.js +5 -5
- package/dashboard/slim/js/modals-tiles.js +44 -3
- package/dashboard/slim/js/projects.js +8 -6
- package/dashboard/slim/styles.css +20 -0
- package/dashboard-build.js +17 -2
- package/dashboard.js +693 -19
- package/docs/branch-derivation.md +13 -1
- package/docs/diagnostics-memory.md +446 -0
- package/docs/harness-propagation.md +273 -0
- package/docs/human-vs-automated.md +1 -1
- package/docs/runtime-adapters.md +5 -0
- package/engine/cli.js +24 -5
- package/engine/diagnostics-memory.js +190 -0
- package/engine/lifecycle.js +111 -1
- package/engine/preflight.js +265 -0
- package/engine/queries.js +331 -19
- package/engine/runtimes/claude.js +36 -0
- package/engine/runtimes/codex.js +19 -0
- package/engine/runtimes/copilot.js +27 -36
- package/engine/shared.js +390 -15
- package/engine/spawn-agent.js +178 -12
- package/engine/watchdog.js +6 -0
- package/engine.js +277 -4
- package/package.json +2 -2
package/engine/queries.js
CHANGED
|
@@ -734,6 +734,118 @@ function getPrs(project) {
|
|
|
734
734
|
let _prsCache = null;
|
|
735
735
|
let _prsCacheAt = 0;
|
|
736
736
|
|
|
737
|
+
// ── W-mqba5ulq000nd255 — cross-project PR de-dupe helpers ────────────────────
|
|
738
|
+
//
|
|
739
|
+
// When a dispatching agent in project A reports a PR URL that actually lives
|
|
740
|
+
// on project B's repo, the engine historically wrote the PR into BOTH project
|
|
741
|
+
// files. The A copy got stamped with `_invalidProjectScope` (a tracking
|
|
742
|
+
// breadcrumb) but was never pruned. getPullRequests' legacy first-write-wins
|
|
743
|
+
// dedupe then surfaced the wrong scope first, masking the real record. These
|
|
744
|
+
// helpers collapse those duplicates at read time, with field-merge so we
|
|
745
|
+
// never drop prdItems, sourcePlan, itemType, minionsReview, or
|
|
746
|
+
// _automationFixCauses that lived on the loser.
|
|
747
|
+
|
|
748
|
+
// Timestamps that signal "this record was touched recently". Highest of the
|
|
749
|
+
// three breaks ties when neither record has a scope advantage.
|
|
750
|
+
function _prFreshnessMs(pr) {
|
|
751
|
+
if (!pr) return 0;
|
|
752
|
+
const cands = [pr.lastPushedAt, pr.mergedAt, pr.lastPolledAt, pr.lastPolled, pr._attachedAt, pr.created];
|
|
753
|
+
let best = 0;
|
|
754
|
+
for (const c of cands) {
|
|
755
|
+
if (!c) continue;
|
|
756
|
+
const t = typeof c === 'number' ? c : Date.parse(c);
|
|
757
|
+
if (Number.isFinite(t) && t > best) best = t;
|
|
758
|
+
}
|
|
759
|
+
return best;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function _groupPrRecordsById(prs) {
|
|
763
|
+
const groups = new Map();
|
|
764
|
+
const order = [];
|
|
765
|
+
for (const pr of prs) {
|
|
766
|
+
if (!pr || !pr.id) continue;
|
|
767
|
+
if (!groups.has(pr.id)) {
|
|
768
|
+
const g = [];
|
|
769
|
+
groups.set(pr.id, g);
|
|
770
|
+
order.push(g);
|
|
771
|
+
}
|
|
772
|
+
groups.get(pr.id).push(pr);
|
|
773
|
+
}
|
|
774
|
+
return order;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Choose the winning record from a group of cross-scope duplicates. Fields
|
|
778
|
+
// the winner left empty are filled from the loser(s). Returns the winner +
|
|
779
|
+
// the dropped sibling records so the caller can log the pruning decision.
|
|
780
|
+
function _pickPrDedupeWinner(group, projects) {
|
|
781
|
+
if (group.length === 1) return { winner: group[0], dropped: [] };
|
|
782
|
+
|
|
783
|
+
// Map each project name -> its canonical scope, so we can detect when a
|
|
784
|
+
// record's _scope matches the PR url-derived scope (preference #2 below).
|
|
785
|
+
const projectScopeByName = new Map();
|
|
786
|
+
for (const p of projects || []) {
|
|
787
|
+
if (!p || !p.name) continue;
|
|
788
|
+
const sc = shared.getProjectPrScope(p);
|
|
789
|
+
if (sc) projectScopeByName.set(p.name, sc);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Resolve the PR's "correct" scope from its URL (independent of _scope).
|
|
793
|
+
// Falls back to the canonical id when the URL is absent. Group members
|
|
794
|
+
// share the same id so any non-empty url is sufficient.
|
|
795
|
+
let urlScope = '';
|
|
796
|
+
for (const pr of group) {
|
|
797
|
+
const info = shared.getPrScopeInfo(pr, pr.url || '');
|
|
798
|
+
if (info && info.scope) { urlScope = info.scope; break; }
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Score each candidate. Higher is better. Composite key:
|
|
802
|
+
// bit 2: no _invalidProjectScope stamp (best signal)
|
|
803
|
+
// bit 1: _scope matches url-derived scope
|
|
804
|
+
// bit 0: freshness rank (broken into a separate tiebreak after)
|
|
805
|
+
function scoreOf(pr) {
|
|
806
|
+
const scopeOk = !pr._invalidProjectScope ? 1 : 0;
|
|
807
|
+
const projectScope = projectScopeByName.get(pr._scope) || '';
|
|
808
|
+
const matchesUrl = urlScope && projectScope && projectScope === urlScope ? 1 : 0;
|
|
809
|
+
return (scopeOk << 1) | matchesUrl;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
let winnerIdx = 0;
|
|
813
|
+
let winnerScore = scoreOf(group[0]);
|
|
814
|
+
let winnerFresh = _prFreshnessMs(group[0]);
|
|
815
|
+
for (let i = 1; i < group.length; i++) {
|
|
816
|
+
const s = scoreOf(group[i]);
|
|
817
|
+
const f = _prFreshnessMs(group[i]);
|
|
818
|
+
if (s > winnerScore || (s === winnerScore && f > winnerFresh)) {
|
|
819
|
+
winnerIdx = i;
|
|
820
|
+
winnerScore = s;
|
|
821
|
+
winnerFresh = f;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const winner = group[winnerIdx];
|
|
826
|
+
const dropped = group.filter((_, i) => i !== winnerIdx);
|
|
827
|
+
|
|
828
|
+
// Field-merge: fill empty winner fields from any loser. Winner wins on
|
|
829
|
+
// every field it has; losers only contribute where the winner is missing
|
|
830
|
+
// the field entirely (undefined, null, '', or empty array).
|
|
831
|
+
function isEmpty(v) {
|
|
832
|
+
if (v == null) return true;
|
|
833
|
+
if (typeof v === 'string') return v === '';
|
|
834
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
const mergeKeys = ['prdItems', 'sourcePlan', 'itemType', 'minionsReview', '_automationFixCauses'];
|
|
838
|
+
for (const loser of dropped) {
|
|
839
|
+
for (const key of mergeKeys) {
|
|
840
|
+
if (isEmpty(winner[key]) && !isEmpty(loser[key])) {
|
|
841
|
+
winner[key] = loser[key];
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return { winner, dropped };
|
|
847
|
+
}
|
|
848
|
+
|
|
737
849
|
function getPullRequests(config) {
|
|
738
850
|
const now = Date.now();
|
|
739
851
|
if (_prsCache && (now - _prsCacheAt) < 1000) return _prsCache;
|
|
@@ -741,14 +853,34 @@ function getPullRequests(config) {
|
|
|
741
853
|
const projects = getProjects(config);
|
|
742
854
|
const projectByName = new Map(projects.map(p => [p.name, p]));
|
|
743
855
|
const allPrs = [];
|
|
744
|
-
const seenIds = new Set();
|
|
745
856
|
|
|
746
857
|
// SQL is the canonical (and only) PR store after Phase 9.
|
|
747
858
|
const store = require('./pull-requests-store');
|
|
748
859
|
const sqlPrs = store.readAllPullRequests() || [];
|
|
749
860
|
|
|
750
|
-
|
|
751
|
-
|
|
861
|
+
// W-mqba5ulq000nd255 — Read-side de-dupe across project scopes. When the
|
|
862
|
+
// same canonical pr.id appears under multiple scopes (e.g. an agent in
|
|
863
|
+
// project A reported a PR URL that actually lives on project B's repo,
|
|
864
|
+
// landing a stub stamped with _invalidProjectScope in A while the real
|
|
865
|
+
// record lives in B), collapse to one record using the preference order:
|
|
866
|
+
// 1. record where `_invalidProjectScope` is FALSY
|
|
867
|
+
// 2. record whose scope matches the PR url-derived scope
|
|
868
|
+
// 3. most-recently-updated (lastPushedAt / mergedAt / _attachedAt)
|
|
869
|
+
// 4. first-seen
|
|
870
|
+
// Then merge non-conflicting fields from the loser into the winner so we
|
|
871
|
+
// never lose prdItems, sourcePlan, itemType, minionsReview, or
|
|
872
|
+
// _automationFixCauses if only the loser carried them.
|
|
873
|
+
const groups = _groupPrRecordsById(sqlPrs);
|
|
874
|
+
for (const group of groups) {
|
|
875
|
+
const { winner, dropped } = _pickPrDedupeWinner(group, projects);
|
|
876
|
+
if (dropped.length > 0) {
|
|
877
|
+
try {
|
|
878
|
+
const droppedScopes = dropped.map(d => d._scope || 'unknown').join(', ');
|
|
879
|
+
shared.log('info', `[pull-requests] de-duped ${winner.id} across ${group.length} records, kept _project=${winner._scope || 'unknown'}, dropped [${droppedScopes}]`);
|
|
880
|
+
} catch { /* logging is best-effort */ }
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const pr = winner;
|
|
752
884
|
const scope = pr._scope;
|
|
753
885
|
delete pr._scope;
|
|
754
886
|
if (scope === 'central') {
|
|
@@ -769,7 +901,6 @@ function getPullRequests(config) {
|
|
|
769
901
|
// _noOpFixes themselves.
|
|
770
902
|
pr._pausedCauses = shared.getPrPausedCauses(pr);
|
|
771
903
|
allPrs.push(pr);
|
|
772
|
-
seenIds.add(pr.id);
|
|
773
904
|
}
|
|
774
905
|
allPrs.sort((a, b) => {
|
|
775
906
|
// W-mpej044m00076d63: sort by the full ISO `created` timestamp DESC so
|
|
@@ -910,6 +1041,121 @@ function _skillsCacheKeyFor(config, homeDir) {
|
|
|
910
1041
|
return JSON.stringify({ homeDir, projects });
|
|
911
1042
|
}
|
|
912
1043
|
|
|
1044
|
+
// ── Harness helpers (P-f5a91c30) ────────────────────────────────────────────
|
|
1045
|
+
//
|
|
1046
|
+
// `getUserHarnesses(homeDir)` and `getProjectHarnesses(project)` are the
|
|
1047
|
+
// shared aggregation layer over the per-runtime adapter methods
|
|
1048
|
+
// `getSkillRoots`, `getCommandRoots`, and `getMcpConfigPaths`. They iterate
|
|
1049
|
+
// every registered runtime, union the contributions, and dedup by absolute
|
|
1050
|
+
// path so the same dir (e.g. `~/.agents/skills` — exposed by Codex + Copilot)
|
|
1051
|
+
// only surfaces once. Each surviving entry tracks which runtimes contributed
|
|
1052
|
+
// it via `runtimes: [name, ...]` so diagnostics can show provenance.
|
|
1053
|
+
//
|
|
1054
|
+
// Consumers: dashboard `/api/status` skills/MCP slices (via collectSkillFiles
|
|
1055
|
+
// / collectCommandFiles refactor), the planned `/api/harness/diagnostics`
|
|
1056
|
+
// endpoint, `engine.js#spawnAgent` for `computeAddDirs` project-local asset
|
|
1057
|
+
// propagation, and `minions doctor --harness` (preflight.js still calls the
|
|
1058
|
+
// per-adapter methods directly there to label by adapter — that's intentional).
|
|
1059
|
+
//
|
|
1060
|
+
// Return shape:
|
|
1061
|
+
// { skills: HarnessEntry[], commands: HarnessEntry[], mcps: HarnessEntry[] }
|
|
1062
|
+
// where HarnessEntry =
|
|
1063
|
+
// { dir|file: string, scope: string, projectName?: string, runtimes: string[] }
|
|
1064
|
+
// `skills`/`commands` use `dir`; `mcps` uses `file`. Both keys are present on
|
|
1065
|
+
// the same shape for ergonomic destructure.
|
|
1066
|
+
|
|
1067
|
+
function _addHarnessEntry(out, byPath, runtimeName, pathValue, payload) {
|
|
1068
|
+
if (!pathValue) return;
|
|
1069
|
+
const abs = path.resolve(pathValue);
|
|
1070
|
+
const existing = byPath.get(abs);
|
|
1071
|
+
if (existing) {
|
|
1072
|
+
if (!existing.runtimes.includes(runtimeName)) existing.runtimes.push(runtimeName);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const entry = { ...payload, runtimes: [runtimeName] };
|
|
1076
|
+
byPath.set(abs, entry);
|
|
1077
|
+
out.push(entry);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function _collectHarnessesScope(scopeFilter, runtimeOpts) {
|
|
1081
|
+
const skills = [];
|
|
1082
|
+
const commands = [];
|
|
1083
|
+
const mcps = [];
|
|
1084
|
+
const skillsByPath = new Map();
|
|
1085
|
+
const commandsByPath = new Map();
|
|
1086
|
+
const mcpsByPath = new Map();
|
|
1087
|
+
let runtimeNames = [];
|
|
1088
|
+
try {
|
|
1089
|
+
const { listRuntimes, resolveRuntime } = require('./runtimes');
|
|
1090
|
+
runtimeNames = listRuntimes();
|
|
1091
|
+
for (const runtimeName of runtimeNames) {
|
|
1092
|
+
let runtime;
|
|
1093
|
+
try { runtime = resolveRuntime(runtimeName); } catch { continue; }
|
|
1094
|
+
if (!runtime) continue;
|
|
1095
|
+
|
|
1096
|
+
if (typeof runtime.getSkillRoots === 'function') {
|
|
1097
|
+
let roots = [];
|
|
1098
|
+
try { roots = runtime.getSkillRoots(runtimeOpts) || []; } catch { roots = []; }
|
|
1099
|
+
for (const root of roots) {
|
|
1100
|
+
if (!root || !root.dir) continue;
|
|
1101
|
+
if (!scopeFilter(root)) continue;
|
|
1102
|
+
_addHarnessEntry(skills, skillsByPath, runtimeName, root.dir, {
|
|
1103
|
+
dir: path.resolve(root.dir),
|
|
1104
|
+
scope: root.scope,
|
|
1105
|
+
projectName: root.projectName,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
if (typeof runtime.getCommandRoots === 'function') {
|
|
1111
|
+
let roots = [];
|
|
1112
|
+
try { roots = runtime.getCommandRoots(runtimeOpts) || []; } catch { roots = []; }
|
|
1113
|
+
for (const root of roots) {
|
|
1114
|
+
if (!root || !root.dir) continue;
|
|
1115
|
+
if (!scopeFilter(root)) continue;
|
|
1116
|
+
_addHarnessEntry(commands, commandsByPath, runtimeName, root.dir, {
|
|
1117
|
+
dir: path.resolve(root.dir),
|
|
1118
|
+
scope: root.scope,
|
|
1119
|
+
projectName: root.projectName,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (typeof runtime.getMcpConfigPaths === 'function') {
|
|
1125
|
+
let entries = [];
|
|
1126
|
+
try { entries = runtime.getMcpConfigPaths(runtimeOpts) || []; } catch { entries = []; }
|
|
1127
|
+
for (const entry of entries) {
|
|
1128
|
+
if (!entry || !entry.file) continue;
|
|
1129
|
+
if (!scopeFilter(entry)) continue;
|
|
1130
|
+
_addHarnessEntry(mcps, mcpsByPath, runtimeName, entry.file, {
|
|
1131
|
+
file: path.resolve(entry.file),
|
|
1132
|
+
scope: entry.scope,
|
|
1133
|
+
projectName: entry.projectName,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
} catch { /* runtime registry optional in partial installs */ }
|
|
1139
|
+
return { skills, commands, mcps };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function getUserHarnesses(homeDir = os.homedir()) {
|
|
1143
|
+
return _collectHarnessesScope(
|
|
1144
|
+
(entry) => entry.scope !== 'project',
|
|
1145
|
+
{ homeDir },
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function getProjectHarnesses(project) {
|
|
1150
|
+
if (!project || !project.localPath) {
|
|
1151
|
+
return { skills: [], commands: [], mcps: [] };
|
|
1152
|
+
}
|
|
1153
|
+
return _collectHarnessesScope(
|
|
1154
|
+
(entry) => entry.scope === 'project',
|
|
1155
|
+
{ homeDir: os.homedir(), project },
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
913
1159
|
function collectSkillFiles(config) {
|
|
914
1160
|
const now = Date.now();
|
|
915
1161
|
config = config || getConfig();
|
|
@@ -938,18 +1184,22 @@ function collectSkillFiles(config) {
|
|
|
938
1184
|
projectName: root.projectName,
|
|
939
1185
|
});
|
|
940
1186
|
}
|
|
941
|
-
for (const project of projects) {
|
|
942
|
-
if (!project.localPath) continue;
|
|
943
|
-
for (const root of runtime.getSkillRoots({ homeDir, project })) {
|
|
944
|
-
if (root.scope !== 'project') continue;
|
|
945
|
-
_collectNativeSkillsDir(root.dir, root.scope, seenFor(root.scope, root.projectName), skillFiles, {
|
|
946
|
-
projectName: root.projectName,
|
|
947
|
-
});
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
1187
|
}
|
|
951
1188
|
} catch { /* runtime registry optional in partial installs */ }
|
|
952
1189
|
|
|
1190
|
+
// Project-scope skill roots: aggregate across runtimes via the shared
|
|
1191
|
+
// getProjectHarnesses helper so dedup-by-absolute-path happens once
|
|
1192
|
+
// (e.g. `<repo>/.agents/skills` is exposed by both Codex and Copilot).
|
|
1193
|
+
for (const project of projects) {
|
|
1194
|
+
if (!project.localPath) continue;
|
|
1195
|
+
for (const root of getProjectHarnesses(project).skills) {
|
|
1196
|
+
if (root.scope !== 'project') continue;
|
|
1197
|
+
_collectNativeSkillsDir(root.dir, root.scope, seenFor(root.scope, root.projectName), skillFiles, {
|
|
1198
|
+
projectName: root.projectName,
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
953
1203
|
// 1b. Installed plugin skills: ~/.claude/plugins/installed_plugins.json
|
|
954
1204
|
// Plugins use commands/*.md and/or skills/<name>/SKILL.md and/or skills/SKILL.md
|
|
955
1205
|
try {
|
|
@@ -1116,7 +1366,10 @@ function collectCommandFiles(config) {
|
|
|
1116
1366
|
function addCommandDir(rootDir, scope, extra = {}) {
|
|
1117
1367
|
const root = path.resolve(rootDir);
|
|
1118
1368
|
for (const cmd of _collectMarkdownFilesRecursive(root)) {
|
|
1119
|
-
|
|
1369
|
+
// Dedup is by absolute path + relative-file so runtimes that share a
|
|
1370
|
+
// dir (none today for commands, but the shape mirrors collectSkillFiles)
|
|
1371
|
+
// don't double-list.
|
|
1372
|
+
const key = `${root}:${cmd.rel}`;
|
|
1120
1373
|
if (seen.has(key)) continue;
|
|
1121
1374
|
seen.add(key);
|
|
1122
1375
|
const commandName = cmd.rel.replace(/\.md$/, '').replace(/\\/g, '/');
|
|
@@ -1124,7 +1377,20 @@ function collectCommandFiles(config) {
|
|
|
1124
1377
|
}
|
|
1125
1378
|
}
|
|
1126
1379
|
|
|
1127
|
-
|
|
1380
|
+
// User-scope command roots from every runtime adapter (was hard-coded to
|
|
1381
|
+
// `~/.claude/commands`; Copilot exposes `~/.copilot/commands` and Codex
|
|
1382
|
+
// currently returns nothing — adapters fall in/out as their CLIs evolve).
|
|
1383
|
+
try {
|
|
1384
|
+
const { listRuntimes, resolveRuntime } = require('./runtimes');
|
|
1385
|
+
for (const runtimeName of listRuntimes()) {
|
|
1386
|
+
const runtime = resolveRuntime(runtimeName);
|
|
1387
|
+
if (typeof runtime.getCommandRoots !== 'function') continue;
|
|
1388
|
+
for (const root of runtime.getCommandRoots({ homeDir })) {
|
|
1389
|
+
if (!root || !root.dir || root.scope === 'project') continue;
|
|
1390
|
+
addCommandDir(root.dir, root.scope);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
} catch { /* runtime registry optional in partial installs */ }
|
|
1128
1394
|
|
|
1129
1395
|
try {
|
|
1130
1396
|
const pluginsFile = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
|
|
@@ -1138,9 +1404,15 @@ function collectCommandFiles(config) {
|
|
|
1138
1404
|
}
|
|
1139
1405
|
} catch { /* optional */ }
|
|
1140
1406
|
|
|
1407
|
+
// Project-scope command roots aggregated across runtimes via the shared
|
|
1408
|
+
// getProjectHarnesses helper. Dedup-by-absolute-path is handled inside the
|
|
1409
|
+
// helper so adapters that name the same dir don't double-list here.
|
|
1141
1410
|
for (const project of getProjects(config)) {
|
|
1142
1411
|
if (!project.localPath) continue;
|
|
1143
|
-
|
|
1412
|
+
for (const root of getProjectHarnesses(project).commands) {
|
|
1413
|
+
if (!root || !root.dir || root.scope !== 'project') continue;
|
|
1414
|
+
addCommandDir(root.dir, root.scope, { projectName: root.projectName || project.name });
|
|
1415
|
+
}
|
|
1144
1416
|
}
|
|
1145
1417
|
|
|
1146
1418
|
return commandFiles;
|
|
@@ -1154,10 +1426,42 @@ function _commandTitle(content, fallback) {
|
|
|
1154
1426
|
return fallback;
|
|
1155
1427
|
}
|
|
1156
1428
|
|
|
1429
|
+
// Dashboard-facing wrapper over `collectCommandFiles`. Returns a normalized
|
|
1430
|
+
// per-entry shape the tools.html "Slash commands" panel + `/api/status`
|
|
1431
|
+
// slow-state slice render directly. Includes the resolved title (from YAML
|
|
1432
|
+
// `description:` frontmatter or first `# heading`) and a `dir`-with-forward-
|
|
1433
|
+
// slashes copy so the renderer can build display strings without re-walking
|
|
1434
|
+
// the file on the client. Sort matches getCommandIndex (project → plugin →
|
|
1435
|
+
// per-runtime user) so the panel and the agent-facing index stay in lockstep.
|
|
1436
|
+
function getCommands(config) {
|
|
1437
|
+
try {
|
|
1438
|
+
const commandFiles = collectCommandFiles(config).sort((a, b) => {
|
|
1439
|
+
const priority = { project: 0, plugin: 1, 'claude-code': 2, copilot: 3, codex: 4 };
|
|
1440
|
+
return (priority[a.scope] ?? 9) - (priority[b.scope] ?? 9)
|
|
1441
|
+
|| String(a.projectName || '').localeCompare(String(b.projectName || ''))
|
|
1442
|
+
|| String(a.commandName || '').localeCompare(String(b.commandName || ''));
|
|
1443
|
+
});
|
|
1444
|
+
return commandFiles.map(({ file, dir, rel, scope, commandName, projectName, pluginName }) => {
|
|
1445
|
+
const content = safeRead(path.join(dir, file)) || '';
|
|
1446
|
+
return {
|
|
1447
|
+
commandName,
|
|
1448
|
+
title: _commandTitle(content, commandName),
|
|
1449
|
+
file,
|
|
1450
|
+
rel: rel || file,
|
|
1451
|
+
dir: dir.replace(/\\/g, '/'),
|
|
1452
|
+
scope,
|
|
1453
|
+
projectName: projectName || null,
|
|
1454
|
+
pluginName: pluginName || null,
|
|
1455
|
+
};
|
|
1456
|
+
});
|
|
1457
|
+
} catch { return []; }
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1157
1460
|
function getCommandIndex(config) {
|
|
1158
1461
|
try {
|
|
1159
1462
|
const commandFiles = collectCommandFiles(config).sort((a, b) => {
|
|
1160
|
-
|
|
1463
|
+
// Lower numbers sort first. Unknown scopes (future runtimes) fall to 9.
|
|
1464
|
+
const priority = { project: 0, plugin: 1, 'claude-code': 2, copilot: 3 };
|
|
1161
1465
|
return (priority[a.scope] ?? 9) - (priority[b.scope] ?? 9)
|
|
1162
1466
|
|| String(a.projectName || '').localeCompare(String(b.projectName || ''))
|
|
1163
1467
|
|| String(a.commandName || '').localeCompare(String(b.commandName || ''));
|
|
@@ -1174,7 +1478,7 @@ function getCommandIndex(config) {
|
|
|
1174
1478
|
? `project:${projectName || 'unknown'}`
|
|
1175
1479
|
: scope === 'plugin'
|
|
1176
1480
|
? `plugin:${pluginName || 'unknown'}`
|
|
1177
|
-
: '
|
|
1481
|
+
: scope || 'unknown';
|
|
1178
1482
|
index += `- \`/${commandName}\` (${label}) — ${title}\n`;
|
|
1179
1483
|
index += ` File: \`${dir.replace(/\\/g, '/')}/${f}\`\n`;
|
|
1180
1484
|
}
|
|
@@ -2597,12 +2901,20 @@ module.exports = {
|
|
|
2597
2901
|
|
|
2598
2902
|
// Pull requests
|
|
2599
2903
|
getPrs, getPullRequests,
|
|
2904
|
+
// W-mqba5ulq000nd255 — exported for direct unit testing of the cross-project
|
|
2905
|
+
// PR dedupe helpers.
|
|
2906
|
+
_pickPrDedupeWinner,
|
|
2907
|
+
_groupPrRecordsById,
|
|
2600
2908
|
|
|
2601
2909
|
// Skills
|
|
2602
2910
|
collectSkillFiles, getSkills, getSkillIndex, invalidateSkillsCache,
|
|
2603
2911
|
|
|
2604
2912
|
// Commands
|
|
2605
|
-
collectCommandFiles, getCommandIndex,
|
|
2913
|
+
collectCommandFiles, getCommandIndex, getCommands,
|
|
2914
|
+
|
|
2915
|
+
// Harness aggregation (P-f5a91c30) — shared union over per-runtime
|
|
2916
|
+
// getSkillRoots / getCommandRoots / getMcpConfigPaths.
|
|
2917
|
+
getUserHarnesses, getProjectHarnesses,
|
|
2606
2918
|
|
|
2607
2919
|
// Knowledge base
|
|
2608
2920
|
getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getKnowledgeBaseIndex,
|
|
@@ -800,6 +800,40 @@ function getSkillWriteTargets({ homeDir = os.homedir(), project = null } = {}) {
|
|
|
800
800
|
return targets;
|
|
801
801
|
}
|
|
802
802
|
|
|
803
|
+
// Slash-command roots. Mirrors getSkillRoots' shape so consumers can iterate
|
|
804
|
+
// runtimes generically. Plugin-installed command dirs are NOT enumerated here
|
|
805
|
+
// — those are discovered dynamically from `~/.claude/plugins/installed_plugins.json`
|
|
806
|
+
// in engine/queries.js#collectCommandFiles and stay there because they require
|
|
807
|
+
// a registry read, not a fixed dir.
|
|
808
|
+
function getCommandRoots({ homeDir = os.homedir(), project = null } = {}) {
|
|
809
|
+
const roots = [
|
|
810
|
+
{ dir: path.join(homeDir, '.claude', 'commands'), scope: 'claude-code' },
|
|
811
|
+
];
|
|
812
|
+
if (project?.localPath) {
|
|
813
|
+
const projectName = project.name || path.basename(project.localPath);
|
|
814
|
+
roots.push(
|
|
815
|
+
{ dir: path.join(project.localPath, '.claude', 'commands'), scope: 'project', projectName },
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
return roots;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// MCP config files this runtime reads. The user-scope file is `~/.claude.json`
|
|
822
|
+
// (which holds more than mcpServers but the engine only reads the mcpServers
|
|
823
|
+
// slice). Project-scope `<root>/.mcp.json` is a cross-runtime convention —
|
|
824
|
+
// Claude and Copilot both read it — so it appears in both adapters; consumers
|
|
825
|
+
// that aggregate across runtimes should dedupe by absolute path.
|
|
826
|
+
function getMcpConfigPaths({ homeDir = os.homedir(), project = null } = {}) {
|
|
827
|
+
const paths = [
|
|
828
|
+
{ file: path.join(homeDir, '.claude.json'), scope: 'claude-code' },
|
|
829
|
+
];
|
|
830
|
+
if (project?.localPath) {
|
|
831
|
+
const projectName = project.name || path.basename(project.localPath);
|
|
832
|
+
paths.push({ file: path.join(project.localPath, '.mcp.json'), scope: 'project', projectName });
|
|
833
|
+
}
|
|
834
|
+
return paths;
|
|
835
|
+
}
|
|
836
|
+
|
|
803
837
|
// Heuristic: does `model` look like a Claude model identifier? Powers the
|
|
804
838
|
// preflight "stale model after CLI switch" warning in cli.js. Returning false
|
|
805
839
|
// means "this looks wrong for Claude" — gpt-5.4 / o3-* / codex etc. Keep this
|
|
@@ -828,6 +862,8 @@ module.exports = {
|
|
|
828
862
|
getUserAssetDirs,
|
|
829
863
|
getSkillRoots,
|
|
830
864
|
getSkillWriteTargets,
|
|
865
|
+
getCommandRoots,
|
|
866
|
+
getMcpConfigPaths,
|
|
831
867
|
getResumeSessionId,
|
|
832
868
|
saveSession,
|
|
833
869
|
detectPermissionGate,
|
package/engine/runtimes/codex.js
CHANGED
|
@@ -395,6 +395,23 @@ function getSkillWriteTargets({ homeDir = os.homedir(), project = null } = {}) {
|
|
|
395
395
|
return targets;
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
// Slash-command roots. Codex CLI does not yet surface user-defined commands
|
|
399
|
+
// (per docs/harness-propagation.md adapter table). Return empty to satisfy the
|
|
400
|
+
// contract — consumers iterate runtimes and handle empty arrays gracefully.
|
|
401
|
+
// Replace with real paths once the CLI ships an official command discovery
|
|
402
|
+
// surface so the engine automatically picks them up.
|
|
403
|
+
function getCommandRoots({ homeDir = os.homedir(), project = null } = {}) {
|
|
404
|
+
return [];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// MCP config files. Codex MCP wiring is not yet a stable contract (see
|
|
408
|
+
// harness-propagation table). Return empty until the CLI ships an official
|
|
409
|
+
// config path; consumers that aggregate across runtimes will simply skip
|
|
410
|
+
// codex's contribution.
|
|
411
|
+
function getMcpConfigPaths({ homeDir = os.homedir(), project = null } = {}) {
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
|
|
398
415
|
function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
|
|
399
416
|
if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
|
|
400
417
|
const sessionPath = path.join(agentsDir, agentId, 'session.json');
|
|
@@ -835,6 +852,8 @@ module.exports = {
|
|
|
835
852
|
getUserAssetDirs,
|
|
836
853
|
getSkillRoots,
|
|
837
854
|
getSkillWriteTargets,
|
|
855
|
+
getCommandRoots,
|
|
856
|
+
getMcpConfigPaths,
|
|
838
857
|
getResumeSessionId,
|
|
839
858
|
saveSession,
|
|
840
859
|
detectPermissionGate,
|
|
@@ -526,42 +526,6 @@ function buildSpawnFlags(opts = {}) {
|
|
|
526
526
|
return flags;
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
-
function getUserAssetDirs({ homeDir = os.homedir() } = {}) {
|
|
530
|
-
return [
|
|
531
|
-
path.join(homeDir, '.copilot'),
|
|
532
|
-
path.join(homeDir, '.agents'),
|
|
533
|
-
];
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
function getSkillRoots({ homeDir = os.homedir(), project = null } = {}) {
|
|
537
|
-
const roots = [
|
|
538
|
-
{ scope: 'copilot', dir: path.join(homeDir, '.copilot', 'skills') },
|
|
539
|
-
{ scope: 'agent-skill', dir: path.join(homeDir, '.agents', 'skills') },
|
|
540
|
-
];
|
|
541
|
-
if (project?.localPath) {
|
|
542
|
-
roots.push(
|
|
543
|
-
{
|
|
544
|
-
scope: 'project',
|
|
545
|
-
projectName: project.name,
|
|
546
|
-
dir: path.join(project.localPath, '.github', 'skills'),
|
|
547
|
-
},
|
|
548
|
-
{
|
|
549
|
-
scope: 'project',
|
|
550
|
-
projectName: project.name,
|
|
551
|
-
dir: path.join(project.localPath, '.agents', 'skills'),
|
|
552
|
-
},
|
|
553
|
-
);
|
|
554
|
-
}
|
|
555
|
-
return roots;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function getSkillWriteTargets({ homeDir = os.homedir(), project = null } = {}) {
|
|
559
|
-
return {
|
|
560
|
-
personal: path.join(homeDir, '.copilot', 'skills'),
|
|
561
|
-
project: project?.localPath ? path.join(project.localPath, '.github', 'skills') : null,
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
|
|
565
529
|
// Stamped into every session.json this adapter writes so the pre-spawn resume
|
|
566
530
|
// path can detect "session was produced by a different runtime" — Copilot
|
|
567
531
|
// rejects Claude session IDs (and vice versa) with "No conversation found",
|
|
@@ -1297,6 +1261,31 @@ function getSkillWriteTargets({ homeDir = os.homedir(), project = null } = {}) {
|
|
|
1297
1261
|
return targets;
|
|
1298
1262
|
}
|
|
1299
1263
|
|
|
1264
|
+
// Slash-command roots. Copilot CLI's project-scope command discovery is not
|
|
1265
|
+
// yet a confirmed contract (the harness-propagation doc annotates the user
|
|
1266
|
+
// path as "probe"); leaving project rows empty matches that. Add them here
|
|
1267
|
+
// once the CLI ships an official path so consumers automatically pick them up.
|
|
1268
|
+
function getCommandRoots({ homeDir = os.homedir(), project = null } = {}) {
|
|
1269
|
+
return [
|
|
1270
|
+
{ dir: path.join(homeDir, '.copilot', 'commands'), scope: 'copilot' },
|
|
1271
|
+
];
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// MCP config files. The user-scope file is `~/.copilot/mcp-config.json`.
|
|
1275
|
+
// Project-scope `<root>/.mcp.json` is the cross-runtime convention Copilot
|
|
1276
|
+
// also honors; it also appears in claude.getMcpConfigPaths, so consumers that
|
|
1277
|
+
// aggregate across runtimes should dedupe by absolute path.
|
|
1278
|
+
function getMcpConfigPaths({ homeDir = os.homedir(), project = null } = {}) {
|
|
1279
|
+
const paths = [
|
|
1280
|
+
{ file: path.join(homeDir, '.copilot', 'mcp-config.json'), scope: 'copilot' },
|
|
1281
|
+
];
|
|
1282
|
+
if (project?.localPath) {
|
|
1283
|
+
const projectName = project.name || path.basename(project.localPath);
|
|
1284
|
+
paths.push({ file: path.join(project.localPath, '.mcp.json'), scope: 'project', projectName });
|
|
1285
|
+
}
|
|
1286
|
+
return paths;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1300
1289
|
module.exports = {
|
|
1301
1290
|
name: 'copilot',
|
|
1302
1291
|
capabilities,
|
|
@@ -1313,6 +1302,8 @@ module.exports = {
|
|
|
1313
1302
|
getUserAssetDirs,
|
|
1314
1303
|
getSkillRoots,
|
|
1315
1304
|
getSkillWriteTargets,
|
|
1305
|
+
getCommandRoots,
|
|
1306
|
+
getMcpConfigPaths,
|
|
1316
1307
|
getResumeSessionId,
|
|
1317
1308
|
saveSession,
|
|
1318
1309
|
detectPermissionGate,
|