@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.
Files changed (39) hide show
  1. package/README.md +7 -5
  2. package/bin/minions.js +39 -17
  3. package/dashboard/js/command-parser.js +1 -1
  4. package/dashboard/js/memory-panel.js +324 -0
  5. package/dashboard/js/qa.js +2 -2
  6. package/dashboard/js/refresh.js +19 -1
  7. package/dashboard/js/render-other.js +143 -2
  8. package/dashboard/js/render-prs.js +2 -1
  9. package/dashboard/js/render-schedules.js +1 -1
  10. package/dashboard/js/render-watches.js +1 -1
  11. package/dashboard/js/render-work-items.js +18 -1
  12. package/dashboard/js/settings.js +23 -0
  13. package/dashboard/pages/engine-memory-panel.html +56 -0
  14. package/dashboard/pages/engine.html +1 -0
  15. package/dashboard/pages/tools.html +8 -0
  16. package/dashboard/slim/js/link-pr.js +5 -5
  17. package/dashboard/slim/js/modals-tiles.js +44 -3
  18. package/dashboard/slim/js/projects.js +8 -6
  19. package/dashboard/slim/styles.css +20 -0
  20. package/dashboard-build.js +17 -2
  21. package/dashboard.js +693 -19
  22. package/docs/branch-derivation.md +13 -1
  23. package/docs/diagnostics-memory.md +446 -0
  24. package/docs/harness-propagation.md +273 -0
  25. package/docs/human-vs-automated.md +1 -1
  26. package/docs/runtime-adapters.md +5 -0
  27. package/engine/cli.js +24 -5
  28. package/engine/diagnostics-memory.js +190 -0
  29. package/engine/lifecycle.js +111 -1
  30. package/engine/preflight.js +265 -0
  31. package/engine/queries.js +331 -19
  32. package/engine/runtimes/claude.js +36 -0
  33. package/engine/runtimes/codex.js +19 -0
  34. package/engine/runtimes/copilot.js +27 -36
  35. package/engine/shared.js +390 -15
  36. package/engine/spawn-agent.js +178 -12
  37. package/engine/watchdog.js +6 -0
  38. package/engine.js +277 -4
  39. 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
- for (const pr of sqlPrs) {
751
- if (!pr?.id || seenIds.has(pr.id)) continue;
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
- const key = `${scope}:${extra.projectName || ''}:${root}:${cmd.rel}`;
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
- addCommandDir(path.join(homeDir, '.claude', 'commands'), 'claude-code');
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
- addCommandDir(path.resolve(project.localPath, '.claude', 'commands'), 'project', { projectName: project.name });
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
- const priority = { project: 0, plugin: 1, 'claude-code': 2 };
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
- : 'claude-code';
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,
@@ -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,