@yemi33/minions 0.1.2397 → 0.1.2399

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.
@@ -809,6 +809,13 @@ async function prdItemEdit(source, itemId) {
809
809
  if (!item) return;
810
810
 
811
811
  _prdDescRawCache = item.description || '';
812
+ const priorities = Array.isArray(window.MINIONS_WORK_ITEM_PRIORITIES)
813
+ ? window.MINIONS_WORK_ITEM_PRIORITIES
814
+ : [];
815
+ const priorityOptions = priorities.map(function(priority) {
816
+ const label = priority.charAt(0).toUpperCase() + priority.slice(1);
817
+ return '<option value="' + priority + '"' + (item.priority === priority ? ' selected' : '') + '>' + label + '</option>';
818
+ }).join('');
812
819
 
813
820
  // Look up work item and dispatch completion info. Issue #2949 —
814
821
  // dispatch moved off /api/status to /api/dispatch; refresh.js stashes
@@ -888,9 +895,7 @@ async function prdItemEdit(source, itemId) {
888
895
  '<div style="display:flex;gap:12px;margin-bottom:12px">' +
889
896
  '<div><label style="font-size:var(--text-base);color:var(--muted);display:block;margin-bottom:4px">Priority</label>' +
890
897
  '<select id="prd-edit-priority" style="padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text)">' +
891
- '<option value="high"' + (item.priority === 'high' ? ' selected' : '') + '>High</option>' +
892
- '<option value="medium"' + (item.priority === 'medium' ? ' selected' : '') + '>Medium</option>' +
893
- '<option value="low"' + (item.priority === 'low' ? ' selected' : '') + '>Low</option>' +
898
+ priorityOptions +
894
899
  '</select></div>' +
895
900
  '<div><label style="font-size:var(--text-base);color:var(--muted);display:block;margin-bottom:4px">Complexity</label>' +
896
901
  '<select id="prd-edit-complexity" style="padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text)">' +
@@ -431,7 +431,9 @@ function openScheduleDetail(id) {
431
431
 
432
432
  function _scheduleFormHtml(sched, isEdit) {
433
433
  const types = ['implement', 'test', 'explore', 'ask', 'review', 'fix'];
434
- const priorities = ['high', 'medium', 'low'];
434
+ const priorities = Array.isArray(window.MINIONS_WORK_ITEM_PRIORITIES)
435
+ ? window.MINIONS_WORK_ITEM_PRIORITIES
436
+ : [];
435
437
  const typeOpts = types.map(t => '<option value="' + t + '"' + ((sched.type || 'implement') === t ? ' selected' : '') + '>' + t + '</option>').join('');
436
438
  const priOpts = priorities.map(p => '<option value="' + p + '"' + ((sched.priority || 'medium') === p ? ' selected' : '') + '>' + p + '</option>').join('');
437
439
  const projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(p => '<option value="' + escHtml(p.name) + '"' + (sched.project === p.name ? ' selected' : '') + '>' + escHtml(p.displayName || p.name) + '</option>').join('');
@@ -353,7 +353,9 @@ async function editWorkItem(id, source) {
353
353
  }
354
354
  }
355
355
  const types = ['implement', 'fix', 'review', 'plan', 'verify', 'decompose', 'meeting', 'investigate', 'refactor', 'test', 'explore', 'ask', 'docs', 'setup'];
356
- const priorities = ['critical', 'high', 'medium', 'low'];
356
+ const priorities = Array.isArray(window.MINIONS_WORK_ITEM_PRIORITIES)
357
+ ? window.MINIONS_WORK_ITEM_PRIORITIES
358
+ : [];
357
359
  const agentOpts = (cmdAgents || []).map(a => '<option value="' + escapeHtml(a.id) + '"' + (item.agent === a.id ? ' selected' : '') + '>' + escapeHtml(a.name) + '</option>').join('');
358
360
  const typeOpts = types.map(t => '<option value="' + t + '"' + ((item.type || 'implement') === t ? ' selected' : '') + '>' + t + '</option>').join('');
359
361
  const priOpts = priorities.map(p => '<option value="' + p + '"' + ((item.priority || 'medium') === p ? ' selected' : '') + '>' + p + '</option>').join('');
@@ -586,7 +588,10 @@ function openCreateWorkItemModal() {
586
588
  const typeOpts = ['implement', 'fix', 'explore', 'test', 'review', 'ask', 'plan', 'verify', 'decompose', 'meeting', 'docs', 'setup'].map(t =>
587
589
  '<option value="' + t + '"' + (t === 'implement' ? ' selected' : '') + '>' + t + '</option>'
588
590
  ).join('');
589
- const priOpts = ['critical', 'high', 'medium', 'low'].map(p =>
591
+ const priorities = Array.isArray(window.MINIONS_WORK_ITEM_PRIORITIES)
592
+ ? window.MINIONS_WORK_ITEM_PRIORITIES
593
+ : [];
594
+ const priOpts = priorities.map(p =>
590
595
  '<option value="' + p + '"' + (p === 'medium' ? ' selected' : '') + '>' + p + '</option>'
591
596
  ).join('');
592
597
  const agentOpts = (typeof cmdAgents !== 'undefined' ? cmdAgents : []).map(a =>
@@ -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
-
@@ -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');
@@ -106,6 +107,8 @@ let PORT = REQUESTED_PORT;
106
107
  const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
107
108
  const PINNED_PATH = path.join(MINIONS_DIR, 'pinned.md');
108
109
  const PINNED_DEFAULT_CONTENT = '# Pinned Context\n\nCritical notes visible to all agents.';
110
+ const WORK_ITEM_PRIORITY_PARAM_HINT = `priority? (${shared.WORK_ITEM_PRIORITY_VALUES.join('|')})`;
111
+ const PIPELINE_STAGES_PARAM_HINT = `stages[] (task/item priority: ${shared.WORK_ITEM_PRIORITY_VALUES.join('|')})`;
109
112
  const KB_PINS_PATH = shared.PINNED_ITEMS_PATH;
110
113
  const DASHBOARD_BROWSER_PRESENCE_PATH = path.join(ENGINE_DIR, 'dashboard-browser.json');
111
114
  const DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS = 45000;
@@ -1187,6 +1190,8 @@ function findWorkItemsTargetById(id, source, projects = PROJECTS) {
1187
1190
 
1188
1191
  function buildPlanWorkItem(body, projects = PROJECTS, options = {}) {
1189
1192
  if (!body?.title || !String(body.title).trim()) return { error: 'title is required' };
1193
+ const priorityError = shared.validateWorkItemPriority(body.priority);
1194
+ if (priorityError) return { error: priorityError.error, priorityError };
1190
1195
 
1191
1196
  // Multi-project: body.project may be an array. Each entry must resolve to a
1192
1197
  // known project; ≥2 valid entries trigger cross-repo mode (WI lands in central,
@@ -1225,7 +1230,7 @@ function buildPlanWorkItem(body, projects = PROJECTS, options = {}) {
1225
1230
  id: options.id || ('W-' + shared.uid()),
1226
1231
  title: body.title,
1227
1232
  type: 'plan',
1228
- priority: body.priority || 'high',
1233
+ priority: body.priority ?? shared.WORK_ITEM_PRIORITY.HIGH,
1229
1234
  description,
1230
1235
  status: WI_STATUS.PENDING,
1231
1236
  created: now.toISOString(),
@@ -1240,6 +1245,8 @@ function buildPlanWorkItem(body, projects = PROJECTS, options = {}) {
1240
1245
 
1241
1246
  function buildManualPrdItemPlan(body, projects = PROJECTS, options = {}) {
1242
1247
  if (!body?.name || !String(body.name).trim()) return { error: 'name is required' };
1248
+ const priorityError = shared.validateWorkItemPriority(body.priority);
1249
+ if (priorityError) return { error: priorityError.error, priorityError };
1243
1250
  const target = resolveWorkItemsCreateTarget(body.project, projects);
1244
1251
  if (target.error) return { error: target.error };
1245
1252
 
@@ -1258,7 +1265,7 @@ function buildManualPrdItemPlan(body, projects = PROJECTS, options = {}) {
1258
1265
  id: body.id || ('M' + String(nowTime).slice(-4)),
1259
1266
  name: String(body.name).trim(),
1260
1267
  description: body.description || '',
1261
- priority: body.priority || 'medium',
1268
+ priority: body.priority ?? shared.WORK_ITEM_PRIORITY.MEDIUM,
1262
1269
  estimated_complexity: body.estimated_complexity || 'medium',
1263
1270
  status: 'missing',
1264
1271
  depends_on: [],
@@ -1325,6 +1332,24 @@ function validatePipelineProjects(pipeline, projects = PROJECTS) {
1325
1332
  return null;
1326
1333
  }
1327
1334
 
1335
+ function validatePipelineWorkItemPriorities(pipeline) {
1336
+ const visit = (value) => {
1337
+ if (!value || typeof value !== 'object') return null;
1338
+ if (Object.prototype.hasOwnProperty.call(value, 'priority')) {
1339
+ const error = shared.validateWorkItemPriority(value.priority);
1340
+ if (error) return error;
1341
+ }
1342
+ for (const key of ['stages', 'items']) {
1343
+ for (const child of Array.isArray(value[key]) ? value[key] : []) {
1344
+ const error = visit(child);
1345
+ if (error) return error;
1346
+ }
1347
+ }
1348
+ return null;
1349
+ };
1350
+ return visit(pipeline);
1351
+ }
1352
+
1328
1353
  /**
1329
1354
  * Aggregate archived work items from central and project SQL scopes.
1330
1355
  *
@@ -1817,11 +1842,12 @@ function buildDashboardHtml() {
1817
1842
  } catch { /* registry empty or config unreadable — ship empty boot state */ }
1818
1843
 
1819
1844
  const featuresBootstrap = `window.MINIONS_FEATURES = ${JSON.stringify(featuresBoot)};\n`;
1845
+ const workItemPrioritiesBootstrap = `window.MINIONS_WORK_ITEM_PRIORITIES = ${JSON.stringify(shared.WORK_ITEM_PRIORITY_VALUES)};\n`;
1820
1846
 
1821
1847
  let assembled = layout
1822
1848
  .replace('/* __CSS__ */', () => css)
1823
1849
  .replace('<!-- __PAGES__ -->', () => pageHtml)
1824
- .replace('/* __JS__ */', () => `window.__MINIONS_HOME = ${JSON.stringify(os.homedir())};\n${featuresBootstrap}${jsHtml}`)
1850
+ .replace('/* __JS__ */', () => `window.__MINIONS_HOME = ${JSON.stringify(os.homedir())};\n${featuresBootstrap}${workItemPrioritiesBootstrap}${jsHtml}`)
1825
1851
  .replace(/\{\{favicon_emoji\}\}/g, FAVICON_EMOJI)
1826
1852
  .replace(/\{\{title_suffix\}\}/g, TITLE_SUFFIX);
1827
1853
 
@@ -6629,6 +6655,8 @@ const server = http.createServer(async (req, res) => {
6629
6655
  try {
6630
6656
  const body = await readBody(req);
6631
6657
  if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
6658
+ const priorityError = shared.validateWorkItemPriority(body.priority);
6659
+ if (priorityError) return jsonReply(res, 400, priorityError);
6632
6660
  if (body.depends_on !== undefined) {
6633
6661
  if (!Array.isArray(body.depends_on)) return jsonReply(res, 400, { error: 'depends_on must be an array of strings' });
6634
6662
  if (!body.depends_on.every(s => typeof s === 'string')) return jsonReply(res, 400, { error: 'depends_on entries must be strings' });
@@ -6686,7 +6714,7 @@ const server = http.createServer(async (req, res) => {
6686
6714
  const id = 'W-' + shared.uid();
6687
6715
  const item = {
6688
6716
  id, title: body.title.trim(), type: normalizedType,
6689
- priority: body.priority || 'medium', description: body.description || '',
6717
+ priority: body.priority ?? shared.WORK_ITEM_PRIORITY.MEDIUM, description: body.description || '',
6690
6718
  status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
6691
6719
  };
6692
6720
  if (Array.isArray(body.depends_on)) {
@@ -6835,6 +6863,8 @@ const server = http.createServer(async (req, res) => {
6835
6863
  const body = await readBody(req);
6836
6864
  const { id, source, title, description, type, priority, agent } = body;
6837
6865
  if (!id) return jsonReply(res, 400, { error: 'id required' });
6866
+ const priorityError = shared.validateWorkItemPriority(priority);
6867
+ if (priorityError) return jsonReply(res, 400, priorityError);
6838
6868
  if (body.depends_on !== undefined) {
6839
6869
  if (!Array.isArray(body.depends_on)) return jsonReply(res, 400, { error: 'depends_on must be an array of strings' });
6840
6870
  if (!body.depends_on.every(s => typeof s === 'string')) return jsonReply(res, 400, { error: 'depends_on entries must be strings' });
@@ -6983,7 +7013,7 @@ const server = http.createServer(async (req, res) => {
6983
7013
  const body = await readBody(req);
6984
7014
  if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
6985
7015
  const planWorkItem = buildPlanWorkItem(body);
6986
- if (planWorkItem.error) return jsonReply(res, 400, { error: planWorkItem.error });
7016
+ if (planWorkItem.error) return jsonReply(res, 400, planWorkItem.priorityError || { error: planWorkItem.error });
6987
7017
  // Write as a work item with type 'plan' — user must explicitly execute plan-to-prd after reviewing
6988
7018
  mutateWorkItems('central', items => { items.push(planWorkItem.item); });
6989
7019
  recordCcTurnIfPresent(req, {
@@ -6998,7 +7028,7 @@ const server = http.createServer(async (req, res) => {
6998
7028
  const body = await readBody(req);
6999
7029
  if (!body.name || !body.name.trim()) return jsonReply(res, 400, { error: 'name is required' });
7000
7030
  const manualPrd = buildManualPrdItemPlan(body);
7001
- if (manualPrd.error) return jsonReply(res, 400, { error: manualPrd.error });
7031
+ if (manualPrd.error) return jsonReply(res, 400, manualPrd.priorityError || { error: manualPrd.error });
7002
7032
 
7003
7033
  const planFile = 'manual-' + shared.uid() + '.json';
7004
7034
  prdStore.writePrd(planFile, manualPrd.plan);
@@ -7011,6 +7041,8 @@ const server = http.createServer(async (req, res) => {
7011
7041
  try {
7012
7042
  const body = await readBody(req);
7013
7043
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
7044
+ const priorityError = shared.validateWorkItemPriority(body.priority);
7045
+ if (priorityError) return jsonReply(res, 400, priorityError);
7014
7046
  if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename in `source` (got `' + body.source + '`). Pass prd/<plan>.json, not plans/<plan>.md.' });
7015
7047
  const preCheck = prdStore.readPrd(body.source);
7016
7048
  if (!preCheck) return jsonReply(res, 404, { error: 'plan not found' });
@@ -10671,6 +10703,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10671
10703
  const body = await readBody(req);
10672
10704
  let { id, cron, title, type, project, agent, description, priority, enabled } = body;
10673
10705
  if (!cron || !title) return jsonReply(res, 400, { error: 'cron and title are required' });
10706
+ const priorityError = shared.validateWorkItemPriority(priority);
10707
+ if (priorityError) return jsonReply(res, 400, priorityError);
10674
10708
  reloadConfig();
10675
10709
  const projectTarget = resolveScheduleProjectValue(project, PROJECTS);
10676
10710
  if (projectTarget.error) return jsonReply(res, 400, { error: projectTarget.error });
@@ -10709,6 +10743,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10709
10743
  const body = await readBody(req);
10710
10744
  let { id, cron, title, type, project, agent, description, priority, enabled } = body;
10711
10745
  if (!id) return jsonReply(res, 400, { error: 'id required' });
10746
+ const priorityError = shared.validateWorkItemPriority(priority);
10747
+ if (priorityError) return jsonReply(res, 400, priorityError);
10712
10748
  reloadConfig();
10713
10749
  if (project !== undefined) {
10714
10750
  const projectTarget = resolveScheduleProjectValue(project, PROJECTS);
@@ -10887,6 +10923,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10887
10923
  const body = await readBody(req);
10888
10924
  const { target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action, requires } = body;
10889
10925
  if (!target) return jsonReply(res, 400, { error: 'target is required' });
10926
+ const priorityError = watchesMod.validateActionWorkItemPriority(action);
10927
+ if (priorityError) return jsonReply(res, 400, priorityError);
10890
10928
  try {
10891
10929
  const watch = watchesMod.createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action, requires });
10892
10930
  invalidateStatusCache();
@@ -10903,6 +10941,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10903
10941
  const body = await readBody(req);
10904
10942
  const { id, ...updates } = body;
10905
10943
  if (!id) return jsonReply(res, 400, { error: 'id is required' });
10944
+ const priorityError = watchesMod.validateActionWorkItemPriority(updates.action);
10945
+ if (priorityError) return jsonReply(res, 400, priorityError);
10906
10946
  try {
10907
10947
  const watch = watchesMod.updateWatch(id, updates);
10908
10948
  if (!watch) return jsonReply(res, 404, { error: 'Watch not found' });
@@ -13207,6 +13247,33 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13207
13247
  builder: () => getDispatchQueue(),
13208
13248
  });
13209
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
+ }},
13210
13277
  { method: 'GET', path: '/api/metrics', desc: 'Per-agent metrics with PR/runtime enrichment (joined against pull-requests + dispatch.completed)', handler: (req, res) => {
13211
13278
  return serveFreshJson(req, res, {
13212
13279
  tag: 'metrics',
@@ -13391,8 +13458,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13391
13458
  { method: 'POST', path: '/api/qa/runners/reload', desc: 'Clear the in-process runner registry, re-register built-ins, and re-scan qa-runners.d/ for plugin edits.', handler: handleQaRunnersReload },
13392
13459
 
13393
13460
  // Work items
13394
- { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?, meta.pr_followup?, meta.workdir?, X-Minions-Agent?, X-Minions-Origin-Wi?', handler: handleWorkItemsCreate },
13395
- { method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, project?, references?, acceptanceCriteria?, depends_on?, workdir?, meta?', handler: handleWorkItemsUpdate },
13461
+ { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: `title, type?, description?, ${WORK_ITEM_PRIORITY_PARAM_HINT}, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?, meta.pr_followup?, meta.workdir?, X-Minions-Agent?, X-Minions-Origin-Wi?`, handler: handleWorkItemsCreate },
13462
+ { method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: `id, source?, title?, description?, type?, ${WORK_ITEM_PRIORITY_PARAM_HINT}, agent?, project?, references?, acceptanceCriteria?, depends_on?, workdir?, meta?`, handler: handleWorkItemsUpdate },
13396
13463
  { method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
13397
13464
  { method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
13398
13465
  { method: 'POST', path: '/api/work-items/cancel', desc: 'Cancel a work item, kill agent, clear dispatch', params: 'id, source?, reason?', handler: handleWorkItemsCancel },
@@ -13496,7 +13563,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13496
13563
  { method: 'POST', path: '/api/notes-save', desc: 'Save edited notes.md content', params: 'content, file?', handler: handleNotesSave },
13497
13564
 
13498
13565
  // Plans
13499
- { method: 'POST', path: '/api/plan', desc: 'Create a plan work item that chains to PRD on completion', params: 'title, description?, priority?, project? (string OR array for cross-repo plans), agent?, branch_strategy? or branchStrategy?', handler: handlePlanCreate },
13566
+ { method: 'POST', path: '/api/plan', desc: 'Create a plan work item that chains to PRD on completion', params: `title, description?, ${WORK_ITEM_PRIORITY_PARAM_HINT}, project? (string OR array for cross-repo plans), agent?, branch_strategy? or branchStrategy?`, handler: handlePlanCreate },
13500
13567
  { method: 'GET', path: '/api/plans', desc: 'List plan files (.md drafts + .json PRDs)', handler: handlePlansList },
13501
13568
  { method: 'POST', path: '/api/plans/trigger-verify', desc: 'Manually trigger verification for a completed plan', params: 'file', handler: handlePlansTriggerVerify },
13502
13569
  { method: 'POST', path: '/api/plans/approve', desc: 'Approve a plan for execution', params: 'file, approvedBy?', handler: handlePlansApprove },
@@ -13513,8 +13580,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13513
13580
  { method: 'GET', path: /^\/api\/plans\/([^?]+)$/, template: '/api/plans/:file', desc: 'Read a full plan (JSON from prd/ or markdown from plans/)', handler: handlePlansRead },
13514
13581
 
13515
13582
  // PRD items
13516
- { method: 'POST', path: '/api/prd-items', desc: 'Create a PRD item as a plan file in prd/ (auto-approved)', params: 'name, description?, priority?, estimated_complexity?, project?, item_project?, id?', handler: handlePrdItemsCreate },
13517
- { method: 'POST', path: '/api/prd-items/update', desc: 'Edit a PRD item in its source plan JSON', params: 'source, itemId, name?, description?, priority?, estimated_complexity?, status?', handler: handlePrdItemsUpdate },
13583
+ { method: 'POST', path: '/api/prd-items', desc: 'Create a PRD item as a plan file in prd/ (auto-approved)', params: `name, description?, ${WORK_ITEM_PRIORITY_PARAM_HINT}, estimated_complexity?, project?, item_project?, id?`, handler: handlePrdItemsCreate },
13584
+ { method: 'POST', path: '/api/prd-items/update', desc: 'Edit a PRD item in its source plan JSON', params: `source, itemId, name?, description?, ${WORK_ITEM_PRIORITY_PARAM_HINT}, estimated_complexity?, status?`, handler: handlePrdItemsUpdate },
13518
13585
  { method: 'POST', path: '/api/prd-items/remove', desc: 'Remove a PRD item from plan + cancel materialized work item', params: 'source, itemId', handler: handlePrdItemsRemove },
13519
13586
  // /api/prd/regenerate removed — use /api/plans/approve which does diff-aware update
13520
13587
 
@@ -14323,8 +14390,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14323
14390
 
14324
14391
  // Schedules
14325
14392
  { method: 'POST', path: '/api/schedules/parse-natural', desc: 'Parse natural language schedule text into cron expression', params: 'text', handler: handleSchedulesParseNatural },
14326
- { method: 'POST', path: '/api/schedules', desc: 'Create a new schedule', params: 'cron, title, id?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesCreate },
14327
- { method: 'POST', path: '/api/schedules/update', desc: 'Update an existing schedule', params: 'id, cron?, title?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesUpdate },
14393
+ { method: 'POST', path: '/api/schedules', desc: 'Create a new schedule', params: `cron, title, id?, type?, project?, agent?, description?, ${WORK_ITEM_PRIORITY_PARAM_HINT}, enabled?`, handler: handleSchedulesCreate },
14394
+ { method: 'POST', path: '/api/schedules/update', desc: 'Update an existing schedule', params: `id, cron?, title?, type?, project?, agent?, description?, ${WORK_ITEM_PRIORITY_PARAM_HINT}, enabled?`, handler: handleSchedulesUpdate },
14328
14395
  { method: 'POST', path: '/api/schedules/delete', desc: 'Delete a schedule', params: 'id', handler: handleSchedulesDelete },
14329
14396
  { method: 'POST', path: '/api/schedules/run-now', desc: 'Manually enqueue the work item for a schedule', params: 'id', handler: handleSchedulesRunNow },
14330
14397
 
@@ -14345,9 +14412,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14345
14412
  const result = pipelines.map(p => ({ ...p, runs: (runs[p.id] || []).slice(-5) }));
14346
14413
  return jsonReply(res, 200, result);
14347
14414
  }},
14348
- { method: 'POST', path: '/api/pipelines', desc: 'Create a pipeline', params: 'id, title, stages[], trigger?, stopWhen?, monitoredResources?', handler: async (req, res) => {
14415
+ { method: 'POST', path: '/api/pipelines', desc: 'Create a pipeline', params: `id, title, ${PIPELINE_STAGES_PARAM_HINT}, trigger?, stopWhen?, monitoredResources?`, handler: async (req, res) => {
14349
14416
  const body = await readBody(req);
14350
14417
  if (!body.id || !body.title || !body.stages) return jsonReply(res, 400, { error: 'id, title, and stages required' });
14418
+ const priorityError = validatePipelineWorkItemPriorities({ stages: body.stages });
14419
+ if (priorityError) return jsonReply(res, 400, priorityError);
14351
14420
  const { savePipeline, getPipeline } = require('./engine/pipeline');
14352
14421
  if (getPipeline(body.id)) return jsonReply(res, 409, { error: 'Pipeline already exists' });
14353
14422
  const pipeline = { id: body.id, title: body.title, stages: body.stages, trigger: body.trigger || {}, enabled: body.enabled !== false };
@@ -14361,12 +14430,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14361
14430
  invalidateStatusCache();
14362
14431
  return jsonReply(res, 200, { ok: true, id: pipeline.id });
14363
14432
  }},
14364
- { method: 'POST', path: '/api/pipelines/update', desc: 'Update a pipeline', params: 'id, title?, stages?, trigger?, enabled?, stopWhen?, monitoredResources?', handler: async (req, res) => {
14433
+ { method: 'POST', path: '/api/pipelines/update', desc: 'Update a pipeline', params: `id, title?, ${PIPELINE_STAGES_PARAM_HINT.replace('stages[]', 'stages[]?')}, trigger?, enabled?, stopWhen?, monitoredResources?`, handler: async (req, res) => {
14365
14434
  const body = await readBody(req);
14366
14435
  if (!body.id) return jsonReply(res, 400, { error: 'id required' });
14367
14436
  const { getPipeline, savePipeline } = require('./engine/pipeline');
14368
14437
  const pipeline = getPipeline(body.id);
14369
14438
  if (!pipeline) return jsonReply(res, 404, { error: 'Pipeline not found' });
14439
+ const priorityError = validatePipelineWorkItemPriorities({ stages: body.stages });
14440
+ if (priorityError) return jsonReply(res, 400, priorityError);
14370
14441
  if (body.title !== undefined) pipeline.title = body.title;
14371
14442
  if (body.stages !== undefined) pipeline.stages = body.stages;
14372
14443
  if (body.trigger !== undefined) pipeline.trigger = body.trigger;
@@ -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/engine/shared.js CHANGED
@@ -4048,6 +4048,27 @@ function backfillProjectWorkSourceDefaults(config) {
4048
4048
 
4049
4049
  // ─── Status & Type Constants ─────────────────────────────────────────────────
4050
4050
 
4051
+ const WORK_ITEM_PRIORITY = Object.freeze({
4052
+ CRITICAL: 'critical',
4053
+ HIGH: 'high',
4054
+ MEDIUM: 'medium',
4055
+ LOW: 'low',
4056
+ });
4057
+ const WORK_ITEM_PRIORITY_VALUES = Object.freeze(Object.values(WORK_ITEM_PRIORITY));
4058
+ const _WORK_ITEM_PRIORITY_SET = new Set(WORK_ITEM_PRIORITY_VALUES);
4059
+ const WORK_ITEM_PRIORITY_ERROR_CODE = 'invalid-work-item-priority';
4060
+
4061
+ function validateWorkItemPriority(priority) {
4062
+ if (priority === undefined || _WORK_ITEM_PRIORITY_SET.has(priority)) return null;
4063
+ const rejected = JSON.stringify(priority);
4064
+ return {
4065
+ error: `Invalid work-item priority ${rejected}. Allowed values: ${WORK_ITEM_PRIORITY_VALUES.join(', ')}.`,
4066
+ code: WORK_ITEM_PRIORITY_ERROR_CODE,
4067
+ rejectedValue: priority,
4068
+ allowedValues: [...WORK_ITEM_PRIORITY_VALUES],
4069
+ };
4070
+ }
4071
+
4051
4072
  const WI_STATUS = {
4052
4073
  PENDING: 'pending', DISPATCHED: 'dispatched', DONE: 'done', FAILED: 'failed',
4053
4074
  PAUSED: 'paused', QUEUED: 'queued',
@@ -8781,6 +8802,7 @@ module.exports = {
8781
8802
  runtimeConfigWarnings,
8782
8803
  projectWorkSourceWarnings,
8783
8804
  backfillProjectWorkSourceDefaults,
8805
+ WORK_ITEM_PRIORITY, WORK_ITEM_PRIORITY_VALUES, WORK_ITEM_PRIORITY_ERROR_CODE, validateWorkItemPriority,
8784
8806
  WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, isPrdArchived, isDefunctPrd, WORK_TYPE, isFixLikeWorkType, WORKTREE_REQUIRING_TYPES, LIVE_VALIDATION_WORK_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject, extractPlanTargetProjects,
8785
8807
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
8786
8808
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
@@ -397,12 +397,16 @@ registerActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM, {
397
397
  { key: 'type', required: false, description: 'Work type (implement|fix|review|...). Defaults to implement.' },
398
398
  { key: 'agent', required: false, description: 'Specific agent to route to.' },
399
399
  { key: 'project', required: false, description: 'Target project (defaults to watch.project, or central).' },
400
- { key: 'priority', required: false, description: 'low|medium|high. Defaults to medium.' },
400
+ { key: 'priority', required: false, description: `${shared.WORK_ITEM_PRIORITY_VALUES.join('|')}. Defaults to medium.` },
401
401
  ],
402
402
  handler: async (watch, ctx) => {
403
403
  const p = ctx.params || {};
404
404
  const title = String(p.title || '').trim();
405
405
  if (!title) return { ok: false, summary: 'dispatch-work-item: title is required' };
406
+ const priorityError = shared.validateWorkItemPriority(p.priority);
407
+ if (priorityError) {
408
+ return { ok: false, summary: priorityError.error, error: priorityError };
409
+ }
406
410
 
407
411
  const project = p.project || watch.project || null;
408
412
  const scope = project || 'central';
@@ -412,7 +416,7 @@ registerActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM, {
412
416
  id,
413
417
  title,
414
418
  type: p.type || WORK_TYPE.IMPLEMENT,
415
- priority: p.priority || 'medium',
419
+ priority: p.priority ?? shared.WORK_ITEM_PRIORITY.MEDIUM,
416
420
  description: p.description || '',
417
421
  status: WI_STATUS.PENDING,
418
422
  created: ts(),
@@ -473,6 +477,7 @@ registerActionType(WATCH_ACTION_TYPE.RUN_SKILL, {
473
477
  { key: 'args', required: false, description: 'Args/instructions to pass to the skill.' },
474
478
  { key: 'agent', required: false, description: 'Specific agent to route to.' },
475
479
  { key: 'project', required: false, description: 'Target project.' },
480
+ { key: 'priority', required: false, description: `${shared.WORK_ITEM_PRIORITY_VALUES.join('|')}. Defaults to medium.` },
476
481
  ],
477
482
  handler: async (watch, ctx) => {
478
483
  const p = ctx.params || {};
@@ -488,7 +493,7 @@ registerActionType(WATCH_ACTION_TYPE.RUN_SKILL, {
488
493
  type: WORK_TYPE.IMPLEMENT,
489
494
  agent: p.agent,
490
495
  project: p.project,
491
- priority: p.priority || 'medium',
496
+ priority: p.priority ?? shared.WORK_ITEM_PRIORITY.MEDIUM,
492
497
  },
493
498
  });
494
499
  },
@@ -1092,6 +1097,8 @@ function validateAction(action) {
1092
1097
  return `action.params.${p.key} is required for action type ${type}`;
1093
1098
  }
1094
1099
  }
1100
+ const priorityError = validateActionWorkItemPriority(action);
1101
+ if (priorityError) return priorityError.error;
1095
1102
  // P-w7c5d8b3 — Phase 3.2: optional `guard` field. Shape is locked here
1096
1103
  // (object with a string `expr`), but the expression itself is NOT
1097
1104
  // pre-parsed at validation time — safe-expr.evaluate is the single
@@ -1178,6 +1185,23 @@ function validateAction(action) {
1178
1185
  return null;
1179
1186
  }
1180
1187
 
1188
+ function validateActionWorkItemPriority(action) {
1189
+ if (action === null || action === undefined) return null;
1190
+ if (Array.isArray(action)) {
1191
+ for (const step of action) {
1192
+ const error = validateActionWorkItemPriority(step);
1193
+ if (error) return error;
1194
+ }
1195
+ return null;
1196
+ }
1197
+ if (typeof action !== 'object') return null;
1198
+ const type = String(action.type || '').toLowerCase();
1199
+ if (type !== WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM && type !== WATCH_ACTION_TYPE.RUN_SKILL) return null;
1200
+ const priority = action.params?.priority;
1201
+ if (typeof priority === 'string' && priority.includes('{{')) return null;
1202
+ return shared.validateWorkItemPriority(priority);
1203
+ }
1204
+
1181
1205
  module.exports = {
1182
1206
  registerActionType,
1183
1207
  getActionType,
@@ -1187,6 +1211,7 @@ module.exports = {
1187
1211
  buildTriggerContext,
1188
1212
  substituteTemplate,
1189
1213
  validateAction,
1214
+ validateActionWorkItemPriority,
1190
1215
  computeWatchDedupKey, // exported for testing (BUG-H10)
1191
1216
  WATCH_DEDUP_ACTIVE_STATUSES, // exported for testing (BUG-H10)
1192
1217
  };
package/engine/watches.js CHANGED
@@ -1455,6 +1455,7 @@ module.exports = {
1455
1455
  runWatchAction: watchActions.runWatchAction,
1456
1456
  runWatchActionChain: watchActions.runWatchActionChain,
1457
1457
  validateAction: watchActions.validateAction,
1458
+ validateActionWorkItemPriority: watchActions.validateActionWorkItemPriority,
1458
1459
  _captureState, // exported for testing
1459
1460
  _runActionTask, // exported for testing — invoked by checkWatches per fired action
1460
1461
  _TARGET_TYPES: TARGET_TYPES, // exported for testing — direct registry access
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2397",
3
+ "version": "0.1.2399",
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.