@yemi33/minions 0.1.2398 → 0.1.2400

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.
@@ -12,11 +12,15 @@
12
12
  if (ev.key === 'Escape' && modal.classList.contains('open')) close();
13
13
  });
14
14
  }
15
+ function clearTileModalBody() {
16
+ var body = document.getElementById('slim-tile-body');
17
+ if (body) body.textContent = '';
18
+ }
15
19
  // Stop the agent-detail "Working for" ticker on every dismiss path so the
16
20
  // 1s interval started in openAgentDetail can't leak after the modal closes.
17
21
  bindModalClose('slim-agent-modal', 'slim-agent-close', _stopAgentDetailRuntime);
18
22
  bindModalClose('slim-tools-modal', 'slim-tools-close');
19
- bindModalClose('slim-tile-modal', 'slim-tile-close');
23
+ bindModalClose('slim-tile-modal', 'slim-tile-close', clearTileModalBody);
20
24
 
21
25
  function tileEmpty(body, text) {
22
26
  var d = document.createElement('div');
@@ -83,16 +87,24 @@
83
87
 
84
88
  // Per-tile body renderers for the detail modal. Each reads the latest status
85
89
  // snapshot and fills the modal body; openTileModal looks them up by key.
86
- function renderEngineTileBody(body, data) {
87
- var e = data.engine || {};
88
- appendAgentRow(body, 'State', e.state || '', !e.state);
89
- appendAgentRow(body, 'Version', e.codeVersion || '—', !e.codeVersion);
90
- appendAgentRow(body, 'PID', e.pid != null ? String(e.pid) : '—', e.pid == null);
91
- appendAgentRow(body, 'Started', e.started_at ? (relTime(e.started_at) + ' · ' + new Date(e.started_at).toLocaleString()) : '—', !e.started_at);
92
- appendAgentRow(body, 'Last tick', e.lastTickAt ? relTime(e.lastTickAt) : '', !e.lastTickAt);
93
- appendAgentRow(body, 'Tick interval', e.tickInterval ? Math.round(e.tickInterval / 1000) + 's' : '—', !e.tickInterval);
94
- appendAgentRow(body, 'Worktrees', e.worktreeCount != null ? String(e.worktreeCount) : '—', e.worktreeCount == null);
95
- appendAgentRow(body, 'Heartbeat', e.heartbeatStale ? 'stale' : 'ok', false);
90
+ // Engine tile body reuses the LITERAL classic dashboard /engine screen
91
+ // instead of a slim-specific key/value readout, so there is one Engine UI, not
92
+ // two (W-mrtdnknm000med95 / "reuse, don't fork"mirrors renderQueuedWorkBody
93
+ // for the Work tile, renderPlansBody for the Plans tile, and renderPrsBody for
94
+ // the PRs tile). Slim and classic are two separate IIFE bundles with no shared
95
+ // scope, so we embed the real /engine screen in an iframe with the chrome-off
96
+ // ?embed=1 mode. The iframed page IS the classic screen the full engine
97
+ // dashboard (quick stats, engine log, managed-process panel + its log SSE
98
+ // stream, memory + diagnostics panels), backed by the same /api endpoints and
99
+ // live streams — with zero duplicated rendering logic. Classic is reachable at
100
+ // /engine even with slim-ux ON (only / is taken over). Built with createElement
101
+ // (no innerHTML) to satisfy the dashboard no-unsanitized lint gate.
102
+ function renderEngineBody(body) {
103
+ var frame = document.createElement('iframe');
104
+ frame.className = 'slim-engine-embed';
105
+ frame.src = '/engine?embed=1';
106
+ frame.title = 'Engine';
107
+ body.appendChild(frame);
96
108
  }
97
109
 
98
110
  function renderDispatchTileBody(body, data, isActive) {
@@ -184,7 +196,7 @@
184
196
 
185
197
  // tile key -> { title, render }. Adding a tile is a one-line table edit.
186
198
  var TILE_VIEWS = {
187
- engine: { title: 'Engine', render: renderEngineTileBody },
199
+ engine: { title: 'Engine', render: function(body) { renderEngineBody(body); } },
188
200
  dispatches: { title: 'Active dispatches', render: function(body, data) { renderDispatchTileBody(body, data, true); } },
189
201
  queued: { title: 'Queued work', render: function(body) { renderQueuedWorkBody(body); } },
190
202
  plans: { title: 'Plans', render: function(body) { renderPlansBody(body); } },
@@ -209,10 +221,11 @@
209
221
  // The "+ Link PR" header chip only applies to the PR list.
210
222
  var headerChip = document.getElementById('slim-tile-modal-linkpr');
211
223
  if (headerChip) headerChip.style.display = (key === 'prs') ? '' : 'none';
212
- // The queued, plans + prs tiles embed a full classic screen (/work, /plans,
213
- // /prs) in an iframe — widen the modal + drop the body padding so the
214
- // embedded screen gets real estate. All three reuse the shared wide-modal
215
- // treatment.
224
+ // The engine, queued, plans + prs tiles embed a full classic screen
225
+ // (/engine, /work, /plans, /prs) in an iframe — widen the modal + drop the
226
+ // body padding so the embedded screen gets real estate. All four reuse the
227
+ // shared wide-modal treatment.
228
+ modal.classList.toggle('tile-modal--engine', key === 'engine');
216
229
  modal.classList.toggle('tile-modal--work', key === 'queued');
217
230
  modal.classList.toggle('tile-modal--plans', key === 'plans');
218
231
  modal.classList.toggle('tile-modal--prs', key === 'prs');
@@ -251,4 +264,3 @@
251
264
  (function bindPlansTileCount() {
252
265
  if (typeof loadPlansCounts === 'function') setTimeout(loadPlansCounts, 1500);
253
266
  })();
254
-
@@ -92,7 +92,7 @@
92
92
  try { localStorage.setItem(SLIM_WELCOME_KEY, '1'); } catch (e) { /* private mode — popup re-shows next visit, acceptable */ }
93
93
  }
94
94
  function maybeShowSlimWelcome() {
95
- // Gated by the slim-ux-promo flag (off by default) — when the promo is off,
95
+ // Gated by the slim-ux-promo flag (on by default) — when the promo is off,
96
96
  // never advertise Slim UX via the welcome popup, even on a first visit.
97
97
  if (!slimFeatureOn('slim-ux-promo')) return;
98
98
  try { if (localStorage.getItem(SLIM_WELCOME_KEY) === '1') return; } catch (e) { /* private mode — show anyway */ }
@@ -677,16 +677,19 @@
677
677
  max-width: calc(100vw - 32px);
678
678
  }
679
679
 
680
- /* Cockpit-tile detail modal: list of dispatches / PRs / watches, or a
681
- key/value engine readout (reuses .agent-detail-row). */
680
+ /* Cockpit-tile detail modal: list of dispatches or watches (reuses
681
+ .agent-detail-row / .tile-item). */
682
682
  #slim-tile-modal .modal { width: 720px; max-width: calc(100vw - 32px); }
683
- /* W-mqrdggys000f94a4 / W-mr28ko750010199f / W-mr3l7y0m0007102a — the
684
- Queued-work + Plans + PRs tiles embed a full classic screen (/work,
685
- /plans, /prs) in an iframe; widen the modal + remove body padding so the
686
- embedded screen gets the full width/height. */
683
+ /* W-mqrdggys000f94a4 / W-mr28ko750010199f / W-mr3l7y0m0007102a /
684
+ W-mrtdnknm000med95 — the Engine + Queued-work + Plans + PRs tiles embed a
685
+ full classic screen (/engine, /work, /plans, /prs) in an iframe; widen the
686
+ modal + remove body padding so the embedded screen gets the full
687
+ width/height. */
688
+ #slim-tile-modal.tile-modal--engine .modal,
687
689
  #slim-tile-modal.tile-modal--work .modal,
688
690
  #slim-tile-modal.tile-modal--plans .modal,
689
691
  #slim-tile-modal.tile-modal--prs .modal { width: 1180px; }
692
+ #slim-tile-modal.tile-modal--engine .modal-body,
690
693
  #slim-tile-modal.tile-modal--work .modal-body,
691
694
  #slim-tile-modal.tile-modal--plans .modal-body,
692
695
  #slim-tile-modal.tile-modal--prs .modal-body { padding: 0; }
@@ -696,6 +699,7 @@
696
699
  full width/height. Mirrors the Queued-work/Plans/PRs tile treatment. */
697
700
  #slim-settings-modal .modal { width: 1180px; max-width: calc(100vw - 32px); }
698
701
  #slim-settings-modal .modal-body { padding: 0; }
702
+ .slim-engine-embed,
699
703
  .slim-work-embed,
700
704
  .slim-plans-embed,
701
705
  .slim-prs-embed,
package/dashboard.js CHANGED
@@ -62,6 +62,7 @@ const createPrWorktree = require('./engine/create-pr-worktree');
62
62
  const prResolve = require('./engine/pr-resolve');
63
63
  const prFixTarget = require('./engine/pr-fix-target');
64
64
  const prTrack = require('./engine/pr-track');
65
+ const resolveArea = require('./engine/resolve-area');
65
66
  const features = require('./engine/features');
66
67
  const ccWorkerPool = require('./engine/cc-worker-pool');
67
68
  const diagnosticsMemory = require('./engine/diagnostics-memory');
@@ -13246,6 +13247,33 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13246
13247
  builder: () => getDispatchQueue(),
13247
13248
  });
13248
13249
  }},
13250
+ // GET /api/resolve-area — reverse "component name → subproject path" resolver
13251
+ // (WI W-mrpbsbs7000ce8ac). Given a configured project and a user token
13252
+ // ("OCM", "loop"), resolve it to real subproject path(s) via the filesystem
13253
+ // (engine/resolve-area.js reuses the bounded first-level area enumeration) so
13254
+ // CC threads a resolved PATH into scope instead of guessing from PR titles.
13255
+ { method: 'GET', path: '/api/resolve-area', desc: 'Resolve a monorepo component/subproject NAME to real subproject path(s) for a configured project, via the filesystem (never PR titles). Query: project (configured project name), token (component name e.g. "OCM"). Returns {ok, project, token, area, paths, absPaths, matched:"exact"|"alias"|"none"}. matched:"none" means the caller should ask the user, not guess.', params: 'project (required), token (required)', handler: (req, res) => {
13256
+ const params = new URL(req.url, 'http://localhost').searchParams;
13257
+ const projectName = (params.get('project') || '').trim();
13258
+ const token = (params.get('token') || '').trim();
13259
+ if (!projectName) return jsonReply(res, 400, { error: 'project query param is required' }, req);
13260
+ if (!token) return jsonReply(res, 400, { error: 'token query param is required' }, req);
13261
+ const descriptor = shared.resolveProjectSource(projectName, PROJECTS, { allowCentral: false });
13262
+ const project = descriptor && descriptor.project;
13263
+ if (!project) return jsonReply(res, 404, { error: `unknown project "${projectName}"` }, req);
13264
+ if (!project.localPath) return jsonReply(res, 400, { error: `project "${project.name}" has no localPath (no checkout to resolve against)` }, req);
13265
+ const resolved = resolveArea.resolveAreaFromToken(project.localPath, token);
13266
+ const absPaths = resolved.paths.map(p => path.join(project.localPath, p).replace(/\\/g, '/'));
13267
+ return jsonReply(res, 200, {
13268
+ ok: true,
13269
+ project: project.name,
13270
+ token,
13271
+ area: resolved.area,
13272
+ paths: resolved.paths,
13273
+ absPaths,
13274
+ matched: resolved.matched,
13275
+ }, req);
13276
+ }},
13249
13277
  { method: 'GET', path: '/api/metrics', desc: 'Per-agent metrics with PR/runtime enrichment (joined against pull-requests + dispatch.completed)', handler: (req, res) => {
13250
13278
  return serveFreshJson(req, res, {
13251
13279
  tag: 'metrics',
@@ -33,13 +33,14 @@ const FEATURES = {
33
33
  expires: '2026-11-01',
34
34
  },
35
35
  // slim-ux-promo — gates the Slim UX *entry point*, not Slim UX itself. When
36
- // off (default) the "Try new Slim UX" button is hidden on the classic
37
- // dashboard header AND the first-visit welcome popup is suppressed inside
36
+ // on (default) the "Try new Slim UX" button is shown on the classic
37
+ // dashboard header AND the first-visit welcome popup is surfaced inside
38
38
  // Slim UX. Slim UX stays reachable via the `slim-ux` flag / Settings; this
39
- // only controls whether it's advertised to users.
39
+ // only controls whether it's advertised to users. Opt out via
40
+ // `config.features.slim-ux-promo: false` or MINIONS_FEATURE_SLIM_UX_PROMO=0.
40
41
  'slim-ux-promo': {
41
- description: 'Show the "Try new Slim UX" entry-point button on the classic dashboard and the first-visit welcome popup inside Slim UX. Off by default — Slim UX itself is still reachable via the slim-ux flag.',
42
- default: false,
42
+ description: 'Show the "Try new Slim UX" entry-point button on the classic dashboard and the first-visit welcome popup inside Slim UX. On by default — Slim UX itself is still reachable via the slim-ux flag.',
43
+ default: true,
43
44
  addedIn: '0.1.2057',
44
45
  expires: '2026-11-01',
45
46
  },
@@ -0,0 +1,200 @@
1
+ /**
2
+ * engine/resolve-area.js — Reverse "component name → subproject path" resolver.
3
+ *
4
+ * WI W-mrpbsbs7000ce8ac. Command Center (and other callers) frequently receive
5
+ * a monorepo component name in a natural-language request ("audit OCM changes",
6
+ * "Loop Android"). Without a way to turn that name into a real filesystem path,
7
+ * CC used to guess the subproject's location from PR titles — a documented
8
+ * incident where CC repeatedly asked the operator where OCM lived even though
9
+ * `<repo>/ocm/` exists on disk.
10
+ *
11
+ * This module is the reverse of the (now-removed) dominant-subproject detector:
12
+ * that mapped `changed paths → area`; this maps `user token → area path(s)`.
13
+ *
14
+ * NOTE ON REUSE: the original first-level area enumeration lived in
15
+ * `engine/discover-project-skills.js` as `_listAreas`. That entire module (and
16
+ * its area/skill/harness heuristics) was deliberately removed when repository
17
+ * harness discovery was delegated to the native runtimes (PR #817,
18
+ * "Delegate repository harness discovery to native runtimes"). No `_listAreas`
19
+ * remains to import, so this file carries the single, minimal, bounded
20
+ * first-level enumeration — it is NOT a parallel copy of a live helper.
21
+ *
22
+ * Design constraints (match the removed helper's conventions):
23
+ * - Bounded / deadline-guarded filesystem work (no unbounded recursion).
24
+ * - Deterministic, sorted output across OSes.
25
+ * - Cross-platform: paths are emitted as POSIX forward-slash, dir-suffixed.
26
+ * - Zero runtime deps — Node built-ins only.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+
34
+ const DEFAULTS = {
35
+ // Cap on first-level directories enumerated (monorepo roots are wide but
36
+ // still O(dozens), so this is a generous ceiling that bounds a pathological
37
+ // directory).
38
+ maxAreas: 400,
39
+ // Wall-clock budget for the whole resolve (enumeration + alias existence
40
+ // probes). Bounded like the removed helper so a slow/again-networked FS can
41
+ // never hang a caller.
42
+ walltimeMs: 750,
43
+ };
44
+
45
+ /**
46
+ * Documented alias map for known multi-path components — a single user token
47
+ * that resolves to several real subproject paths (a primary top-level area PLUS
48
+ * wrapper / native / telemetry paths scattered elsewhere in the tree).
49
+ *
50
+ * Keys are lowercase tokens. `primary` is the first-level area used for the
51
+ * returned `area`; `paths` is the full candidate set (repo-relative, POSIX,
52
+ * trailing slash). At resolve time each candidate is verified against the
53
+ * on-disk tree so a token used against a repo that is NOT the monorepo cleanly
54
+ * returns no match instead of inventing paths.
55
+ */
56
+ const AREA_ALIASES = {
57
+ // OCM (Office Copilot Mobile): the top-level `ocm/` area plus its wrapper /
58
+ // native / telemetry paths under officemobile/.
59
+ ocm: {
60
+ primary: 'ocm',
61
+ paths: [
62
+ 'ocm/',
63
+ 'officemobile/ocmnative/',
64
+ 'officemobile/android/ocmciq/',
65
+ 'officemobile/android/JavaKotlin/ocmciq/',
66
+ 'officemobile/android/hubframework/ocmsdk-wrapper/',
67
+ 'officemobile/android/JavaKotlin/hubframework/ocmsdk-wrapper/',
68
+ 'officemobile/ios/OCMWrapper/',
69
+ 'officemobile/telemetry/android/ocm/',
70
+ 'officemobile/telemetry/tml/ocm/',
71
+ ],
72
+ },
73
+ };
74
+
75
+ function _now() { return Date.now(); }
76
+
77
+ /** Normalize a repo-relative path to POSIX forward-slash with a trailing slash. */
78
+ function _toPosixDir(p) {
79
+ let s = String(p).replace(/\\/g, '/').replace(/\/+$/, '');
80
+ return s.length ? `${s}/` : s;
81
+ }
82
+
83
+ /** Bounded existence check: true iff `rel` resolves to a directory under `projectPath`. */
84
+ function _isDir(projectPath, rel) {
85
+ try {
86
+ const abs = path.join(projectPath, rel);
87
+ return fs.statSync(abs).isDirectory();
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Enumerate first-level subproject directories of `projectPath`.
95
+ *
96
+ * Deterministic (locale-sorted), bounded by `opts.maxAreas` and `deadline`,
97
+ * dot-dir and `node_modules` filtered. Returns bare directory names (no slash).
98
+ *
99
+ * @param {string} projectPath — absolute path to the project checkout / worktree
100
+ * @param {object} [opts] — { maxAreas, walltimeMs } overrides
101
+ * @param {number} [deadline] — absolute ms timestamp; enumeration stops past it
102
+ * @returns {string[]} sorted first-level directory names
103
+ */
104
+ function listAreas(projectPath, opts, deadline) {
105
+ const o = Object.assign({}, DEFAULTS, opts || {});
106
+ const stopAt = typeof deadline === 'number' ? deadline : _now() + Math.max(1, o.walltimeMs);
107
+ const out = [];
108
+ if (!projectPath || typeof projectPath !== 'string') return out;
109
+ let entries;
110
+ try {
111
+ entries = fs.readdirSync(projectPath, { withFileTypes: true });
112
+ } catch {
113
+ return out;
114
+ }
115
+ // Sort for deterministic ordering across OSes.
116
+ entries.sort((a, b) => a.name.localeCompare(b.name));
117
+ for (const ent of entries) {
118
+ if (out.length >= o.maxAreas) break;
119
+ if (_now() > stopAt) break;
120
+ if (!ent.isDirectory()) continue;
121
+ // Skip dot-dirs (.git, .github, .claude, …) and node_modules.
122
+ if (ent.name.startsWith('.')) continue;
123
+ if (ent.name === 'node_modules') continue;
124
+ out.push(ent.name);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Reverse resolver: map a user-supplied component/subproject token to the real
131
+ * subproject path(s) inside `projectPath`.
132
+ *
133
+ * Resolution order:
134
+ * 1. Alias expansion — if the token is a known multi-path component (see
135
+ * AREA_ALIASES), return every candidate path that actually exists on disk,
136
+ * with `area` set to the primary first-level area. `matched: 'alias'`.
137
+ * 2. Exact match — case-insensitive match of the token against the first-level
138
+ * areas enumerated by `listAreas`. Returns the single `<area>/` path.
139
+ * `matched: 'exact'`.
140
+ * 3. No match — `{ area: null, paths: [], matched: 'none' }`. Callers should
141
+ * then ask the user instead of guessing.
142
+ *
143
+ * All work is bounded by `opts.walltimeMs`. Output paths are deterministic
144
+ * (sorted, de-duped), POSIX forward-slash, trailing-slash directories.
145
+ *
146
+ * @param {string} projectPath — absolute path to the project checkout / worktree
147
+ * @param {string} token — user-supplied component name (e.g. "OCM", "loop")
148
+ * @param {object} [opts] — { maxAreas, walltimeMs } overrides
149
+ * @returns {{area: string|null, paths: string[], matched: 'exact'|'alias'|'none'}}
150
+ */
151
+ function resolveAreaFromToken(projectPath, token, opts) {
152
+ const result = { area: null, paths: [], matched: 'none' };
153
+ if (!projectPath || typeof projectPath !== 'string') return result;
154
+ if (typeof token !== 'string') return result;
155
+ const t = token.trim().toLowerCase();
156
+ if (!t) return result;
157
+
158
+ const o = Object.assign({}, DEFAULTS, opts || {});
159
+ const deadline = _now() + Math.max(1, o.walltimeMs);
160
+
161
+ const areas = listAreas(projectPath, o, deadline);
162
+ const areasLower = new Map(areas.map(a => [a.toLowerCase(), a]));
163
+
164
+ // 1. Alias expansion (multi-path components like OCM).
165
+ const alias = Object.prototype.hasOwnProperty.call(AREA_ALIASES, t)
166
+ ? AREA_ALIASES[t]
167
+ : null;
168
+ if (alias) {
169
+ const verified = [];
170
+ for (const rel of alias.paths) {
171
+ if (_now() > deadline) break;
172
+ if (_isDir(projectPath, rel)) verified.push(_toPosixDir(rel));
173
+ }
174
+ if (verified.length > 0) {
175
+ const primaryDir = areasLower.get(alias.primary.toLowerCase()) || alias.primary;
176
+ return {
177
+ area: primaryDir,
178
+ paths: Array.from(new Set(verified)).sort(),
179
+ matched: 'alias',
180
+ };
181
+ }
182
+ // Alias known but none of its paths exist here (token used against a repo
183
+ // that is not the monorepo). Fall through to exact / none.
184
+ }
185
+
186
+ // 2. Exact case-insensitive match against enumerated first-level areas.
187
+ const exact = areasLower.get(t);
188
+ if (exact) {
189
+ return { area: exact, paths: [_toPosixDir(`${exact}/`)], matched: 'exact' };
190
+ }
191
+
192
+ return result;
193
+ }
194
+
195
+ module.exports = {
196
+ DEFAULTS,
197
+ AREA_ALIASES,
198
+ listAreas,
199
+ resolveAreaFromToken,
200
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2398",
3
+ "version": "0.1.2400",
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"
@@ -204,6 +204,25 @@ curl -s http://localhost:{{dashboard_port}}/api/work-items
204
204
  curl -s http://localhost:{{dashboard_port}}/api/status
205
205
  ```
206
206
 
207
+ ## Resolving a monorepo component/subproject by name — GET /api/resolve-area
208
+
209
+ Monorepo projects (e.g. the Office `src` repo) contain many **subprojects** under first-level directories (`ocm/`, `loop/`, `officemobile/`, …). When a request names a component or subproject — "audit OCM changes", "Loop Android", "the LMF area" — that name refers to a **path inside the repo**, not to a PR title.
210
+
211
+ **Rule: resolve the area from the filesystem — NEVER infer a subproject's location from PR titles.** Scanning PR titles/branches to guess where a component lives is wrong (it caused a real incident: CC kept asking the operator where OCM was even though `<repo>/ocm/` exists on disk). Resolve the name to a real path first, then scope your work (`git log -- <path>`, `Glob`, dispatched WI description) to that path.
212
+
213
+ Resolve via `GET /api/resolve-area?project=<name>&token=<component>`:
214
+ ```
215
+ curl -s 'http://localhost:{{dashboard_port}}/api/resolve-area?project=src&token=OCM'
216
+ ```
217
+ Returns `{ok, project, token, area, paths, absPaths, matched}` where `matched` is:
218
+ - `"exact"` — the token is a first-level area (`paths: ["<area>/"]`).
219
+ - `"alias"` — a known multi-path component; `paths` lists every real subpath that exists on disk.
220
+ - `"none"` — no area matched. **Ask the user where the component lives — do not guess from PR titles.**
221
+
222
+ **Worked example (OCM → `ocm/` + wrapper/native paths).** `token=OCM` resolves to the top-level `ocm/` area PLUS its wrapper/native/telemetry paths (`officemobile/ocmnative/`, `officemobile/android/ocmciq/`, `officemobile/ios/OCMWrapper/`, `officemobile/telemetry/android/ocm/`, …) — only the paths that actually exist in the checkout are returned. Scope OCM audits/fixes to that full path set, not to PRs whose title happens to say "OCM".
223
+
224
+ If the endpoint returns `matched:"none"`, you may also enumerate first-level areas yourself with your `Glob`/`Bash` tools against the project's `localPath` (the reverse resolver just reuses that same first-level directory listing) — but still never fall back to PR titles.
225
+
207
226
  ## Read-only PR actions on ANY PR — POST /api/pr-action
208
227
 
209
228
  When the user asks you to **review / summarize / comment-on / triage a specific PR by URL or id** — including PRs in repos that are NOT a configured Minions project (e.g. "review this PR https://github.com/some/repo/pull/42", "summarize ado:org/proj/repo#5215493", "triage github:yemi33/minions#2702") — route it through `POST /api/pr-action` instead of dispatching a `review`/`explore` work item. This path is **projectless and read-only**: it resolves the PR, fetches its diff + metadata with NO clone/worktree, and reasons over the (untrusted-fenced) content. Use it for one-off, look-don't-touch asks on an arbitrary PR.