@yemi33/minions 0.1.1642 → 0.1.1644

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1644 (2026-04-30)
4
+
5
+ ### Fixes
6
+ - harden action contracts so CC can't claim work it didn't do
7
+
3
8
  ## 0.1.1642 (2026-04-30)
4
9
 
5
10
  ### Features
@@ -686,11 +686,30 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
686
686
  if (evt.actions && evt.actions.length > 0) {
687
687
  _tagServerExecuted(evt.actions, evt.actionResults);
688
688
  for (var ai = 0; ai < evt.actions.length; ai++) { await ccExecuteAction(evt.actions[ai], activeTabId); }
689
+ // Surface per-action errors/warnings inline alongside the prose so the user can see
690
+ // exactly which actions failed or completed with caveats. Previously these only
691
+ // appeared as a small "executed" pill which gave no detail.
692
+ if (evt.actionResults && Array.isArray(evt.actionResults)) {
693
+ var failures = evt.actionResults.filter(function(r) { return r && r.error; });
694
+ var warnings = evt.actionResults.filter(function(r) { return r && r.warning; });
695
+ if (failures.length > 0) {
696
+ var failHtml = failures.map(function(r) { return '<li>' + escHtml(r.type || 'action') + ': ' + escHtml(r.error) + '</li>'; }).join('');
697
+ addMsg('system', '<div style="padding:6px 12px;font-size:11px;color:var(--red);background:var(--surface2);border-radius:6px;margin:4px 0">⚠️ ' + failures.length + ' action' + (failures.length > 1 ? 's' : '') + ' failed:<ul style="margin:4px 0 0 16px;padding:0">' + failHtml + '</ul></div>', false, activeTabId);
698
+ }
699
+ if (warnings.length > 0) {
700
+ var warnHtml = warnings.map(function(r) { return '<li>' + escHtml(r.type || 'action') + ': ' + escHtml(r.warning) + '</li>'; }).join('');
701
+ addMsg('system', '<div style="padding:6px 12px;font-size:11px;color:var(--orange);background:var(--surface2);border-radius:6px;margin:4px 0">ℹ️ ' + warnings.length + ' action' + (warnings.length > 1 ? '' : '') + ' completed with warnings:<ul style="margin:4px 0 0 16px;padding:0">' + warnHtml + '</ul></div>', false, activeTabId);
702
+ }
703
+ }
689
704
  } else if (evt.actionParseError) {
690
705
  // Issue #1834: server saw ===ACTIONS=== but couldn't parse the JSON.
691
706
  // Surface as an inline warning so the user knows actions were dropped
692
707
  // (was previously silent — appeared as "actions failed" with no signal).
693
708
  addMsg('system', '<div style="padding:6px 12px;font-size:11px;color:var(--red);background:var(--surface2);border-radius:6px;margin:4px 0">⚠️ Actions block emitted but JSON could not be parsed — no actions were executed. Resend or rephrase. (' + escHtml(String(evt.actionParseError).slice(0, 200)) + ')</div>', false, activeTabId);
709
+ } else if (evt.hallucinationWarning) {
710
+ // CC said it dispatched/queued/assigned something but emitted no actions block —
711
+ // surface the false-claim guard so the user knows nothing actually ran.
712
+ addMsg('system', '<div style="padding:6px 12px;font-size:11px;color:var(--orange);background:var(--surface2);border-radius:6px;margin:4px 0">⚠️ ' + escHtml(evt.hallucinationWarning) + '</div>', false, activeTabId);
694
713
  }
695
714
  } else if (evt.type === 'error') {
696
715
  terminalEventSeen = true;
package/dashboard.js CHANGED
@@ -28,6 +28,7 @@ const watchesMod = require('./engine/watches');
28
28
  const routing = require('./engine/routing');
29
29
  const playbook = require('./engine/playbook');
30
30
  const dispatchMod = require('./engine/dispatch');
31
+ const steering = require('./engine/steering');
31
32
  const os = require('os');
32
33
 
33
34
  const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, reopenWorkItem } = shared;
@@ -148,6 +149,21 @@ function _resolveSkillReadPath({ file, dir, source, config, skillFiles } = {}) {
148
149
  return null;
149
150
  }
150
151
 
152
+ function _agentSessionIsDraining(agentId) {
153
+ const activeForAgent = (getDispatchQueue().active || []).some(d => d.agent === agentId);
154
+ if (!activeForAgent) return false;
155
+ const liveLogPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
156
+ const tail = (safeRead(liveLogPath) || '').slice(-65536);
157
+ if (!tail) return false;
158
+ const lastSteer = tail.lastIndexOf('[human-steering]');
159
+ const terminalIdx = Math.max(
160
+ tail.lastIndexOf('[process-exit]'),
161
+ tail.lastIndexOf('"type":"session.task_complete"'),
162
+ tail.lastIndexOf('"type":"result"')
163
+ );
164
+ return terminalIdx >= 0 && terminalIdx > lastSteer;
165
+ }
166
+
151
167
  const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
152
168
  const TEAMS_INBOX_PATH = path.join(ENGINE_DIR, 'teams-inbox.json');
153
169
 
@@ -1296,17 +1312,100 @@ function _parseWatchInterval(val) {
1296
1312
  return Math.max(60000, Math.round(u === 's' ? n * 1000 : u === 'm' ? n * 60000 : n * 3600000));
1297
1313
  }
1298
1314
 
1315
+ // Required-field validator for CC actions. Returns null when valid, an error string when not.
1316
+ // Centralises field-required checks so the model can't quietly emit a malformed action and have
1317
+ // the server silently fall back to placeholder values (e.g. "Untitled"). The handler invokes this
1318
+ // before `try` to avoid filling `results` with cryptic per-handler error messages.
1319
+ function _ccValidateAction(action) {
1320
+ if (!action || typeof action !== 'object' || !action.type) return 'action is missing required field: type';
1321
+ switch (action.type) {
1322
+ case 'dispatch': case 'fix': case 'implement': case 'explore': case 'review': case 'test':
1323
+ if (!action.title || typeof action.title !== 'string' || !action.title.trim()) return `${action.type} action missing required field: title`;
1324
+ return null;
1325
+ case 'build-and-test':
1326
+ if (!action.pr) return 'build-and-test action missing required field: pr';
1327
+ return null;
1328
+ case 'note':
1329
+ if (!action.title) return 'note action missing required field: title';
1330
+ if (!action.content && !action.description) return 'note action missing required field: content (or description)';
1331
+ return null;
1332
+ case 'knowledge':
1333
+ if (!action.title) return 'knowledge action missing required field: title';
1334
+ if (!action.content) return 'knowledge action missing required field: content';
1335
+ if (!action.category) return 'knowledge action missing required field: category';
1336
+ return null;
1337
+ case 'pin-to-pinned':
1338
+ if (!action.title || !action.content) return 'pin-to-pinned action missing title or content';
1339
+ return null;
1340
+ case 'plan':
1341
+ if (!action.title) return 'plan action missing required field: title';
1342
+ return null;
1343
+ default:
1344
+ return null; // unknown types fall through to existing handler / generic fallback
1345
+ }
1346
+ }
1347
+
1348
+ // Hallucination guard: detect prose like "I dispatched ..." when no ===ACTIONS=== block was emitted.
1349
+ // The regex is intentionally narrow — we only want affirmative claims about completed work, not
1350
+ // hypotheticals like "I would dispatch this" or "consider dispatching X".
1351
+ function _detectClaimedActionWithoutBlock(displayText, actions) {
1352
+ if (Array.isArray(actions) && actions.length > 0) return null; // there are actions, no false claim
1353
+ const triggers = /\b(dispatched|enqueued|queued|created (?:a |the )?work item|assigned (?:this |it )?(?:to|for)|spun up|kicked off|i'?ll dispatch|i (?:have )?(?:just )?dispatched)\b/i;
1354
+ if (!triggers.test(displayText || '')) return null;
1355
+ return 'CC described an action ("dispatched", "assigned", etc.) but no ===ACTIONS=== block was emitted. No work was actually queued. Resend or rephrase the request.';
1356
+ }
1357
+
1299
1358
  async function executeCCActions(actions) {
1300
1359
  const results = [];
1301
1360
  for (const action of actions) {
1361
+ const validationError = _ccValidateAction(action);
1362
+ if (validationError) {
1363
+ results.push({ type: action?.type || 'unknown', error: validationError });
1364
+ continue;
1365
+ }
1302
1366
  try {
1303
1367
  switch (action.type) {
1304
1368
  case 'dispatch': case 'fix': case 'implement': case 'explore': case 'review': case 'test': {
1305
1369
  const workType = action.workType || (action.type !== 'dispatch' ? action.type : 'implement');
1306
1370
  const id = 'W-' + shared.uid();
1307
1371
  const project = action.project || '';
1308
- const targetProject = project ? PROJECTS.find(p => p.name?.toLowerCase() === project.toLowerCase()) : PROJECTS[0];
1372
+
1373
+ // Strict project resolution. Silent fallback to PROJECTS[0] when the model named an unknown
1374
+ // project caused work items to land in the wrong repo. Now: unknown name → error; ambiguous
1375
+ // (multiple projects + no field) → error; single-project deployments fall through; zero
1376
+ // projects → root-level work-items.json (orchestration system standalone use).
1377
+ let targetProject = null;
1378
+ if (project) {
1379
+ targetProject = PROJECTS.find(p => p.name?.toLowerCase() === project.toLowerCase());
1380
+ if (!targetProject) {
1381
+ const known = PROJECTS.map(p => p.name).join(', ') || '(none configured)';
1382
+ results.push({ type: action.type, error: `Project "${project}" not found. Known projects: ${known}` });
1383
+ break;
1384
+ }
1385
+ } else if (PROJECTS.length > 1) {
1386
+ results.push({ type: action.type, error: `project field is required when ${PROJECTS.length} projects are configured: ${PROJECTS.map(p => p.name).join(', ')}` });
1387
+ break;
1388
+ } else if (PROJECTS.length === 1) {
1389
+ targetProject = PROJECTS[0];
1390
+ }
1391
+ // PROJECTS.length === 0 → targetProject stays null, falls back to root work-items.json (existing behavior).
1392
+
1309
1393
  const wiPath = targetProject ? shared.projectWorkItemsPath(targetProject) : path.join(MINIONS_DIR, 'work-items.json');
1394
+
1395
+ // Promote `agent` (singular) → `agents` (array). Models emit either shape and the prior code
1396
+ // only read `action.agents`, silently dropping `agent: "lambert"` style hints.
1397
+ const agentHints = (() => {
1398
+ if (Array.isArray(action.agents) && action.agents.length > 0) return action.agents.map(String).filter(Boolean);
1399
+ if (typeof action.agent === 'string' && action.agent) return [action.agent];
1400
+ return [];
1401
+ })();
1402
+ const knownAgents = Object.keys(CONFIG.agents || {});
1403
+ const unknownAgent = agentHints.find(a => !knownAgents.includes(a));
1404
+ if (unknownAgent) {
1405
+ results.push({ type: action.type, error: `Unknown agent "${unknownAgent}". Configured agents: ${knownAgents.join(', ') || '(none)'}` });
1406
+ break;
1407
+ }
1408
+
1310
1409
  // Issue #1772: CC review/explore/test are human-initiated one-offs.
1311
1410
  // Mark oneShot so any discovered PR is tagged _contextOnly (skips eval loop).
1312
1411
  const ccOneShotTypes = new Set(['review', 'explore', 'test']);
@@ -1314,16 +1413,28 @@ async function executeCCActions(actions) {
1314
1413
  shared.mutateJsonFileLocked(wiPath, items => {
1315
1414
  if (!Array.isArray(items)) items = [];
1316
1415
  items.push({
1317
- id, title: action.title || 'Untitled', type: workType,
1416
+ id, title: action.title, type: workType,
1318
1417
  priority: action.priority || 'medium', description: action.description || '',
1319
1418
  status: WI_STATUS.PENDING, created: new Date().toISOString(),
1320
1419
  createdBy: 'command-center', project,
1321
- ...(action.agents?.length ? { preferred_agent: action.agents[0], agents: action.agents } : {}),
1420
+ ...(agentHints.length ? { preferred_agent: agentHints[0], agents: agentHints } : {}),
1322
1421
  ...(isOneShot ? { oneShot: true } : {}),
1323
1422
  });
1324
1423
  return items;
1325
1424
  }, { defaultValue: [] });
1326
1425
  results.push({ type: action.type, id, ok: true });
1426
+
1427
+ // Pre-flight routing check: warn the user if no agent is currently available so the new
1428
+ // item won't sit pending invisibly. Routing failure is non-fatal — the WI was created.
1429
+ try {
1430
+ const resolvedAgent = routing.resolveAgent(workType, CONFIG, { agentHints });
1431
+ if (!resolvedAgent) {
1432
+ const lastResult = results[results.length - 1];
1433
+ lastResult.warning = `Created ${id} but no agent is currently available to dispatch (routing returned no match for workType=${workType}${agentHints.length ? ', hints=' + agentHints.join(',') : ''}). Item will sit pending until an agent becomes available.`;
1434
+ }
1435
+ } catch (e) {
1436
+ shared.log('warn', `CC dispatch routing pre-flight: ${e.message}`);
1437
+ }
1327
1438
  break;
1328
1439
  }
1329
1440
  case 'build-and-test': {
@@ -4753,6 +4864,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4753
4864
  const { _actionParseError, ...parsedReply } = parsed;
4754
4865
  const reply = { ...parsedReply, sessionId: ccSession.sessionId, newSession: !wasResume };
4755
4866
  if (_actionParseError) reply.actionParseError = _actionParseError;
4867
+ const hallucinationWarning = _detectClaimedActionWithoutBlock(parsed.text, parsed.actions);
4868
+ if (hallucinationWarning) reply.hallucinationWarning = hallucinationWarning;
4756
4869
  if (sessionReset) reply.sessionReset = true;
4757
4870
  return jsonReply(res, 200, reply);
4758
4871
  } finally {
@@ -5036,6 +5149,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5036
5149
  // Issue #1834: surface action JSON parse failures so the UI can warn
5037
5150
  // instead of silently dropping. Client renders this as a small notice.
5038
5151
  if (_actionParseError) donePayload.actionParseError = _actionParseError;
5152
+ const hallucinationWarning = _detectClaimedActionWithoutBlock(displayText, actions);
5153
+ if (hallucinationWarning) donePayload.hallucinationWarning = hallucinationWarning;
5039
5154
  if (sessionReset) donePayload.sessionReset = true;
5040
5155
  liveState.donePayload = donePayload;
5041
5156
  if (liveState.writer) liveState.writer(donePayload);
@@ -5924,7 +6039,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5924
6039
  if (!pr.branch && prData.branch) {
5925
6040
  pr.branch = prData.branch;
5926
6041
  if (pr._branchResolutionError) delete pr._branchResolutionError;
5927
- if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
6042
+ if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
5928
6043
  }
5929
6044
  if (pr.agent === 'human' && prData.author) pr.agent = prData.author;
5930
6045
  return prs;
@@ -6002,21 +6117,30 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6002
6117
  }},
6003
6118
  { method: 'POST', path: '/api/agents/steer', desc: 'Inject steering message into a running agent', params: 'agent, message', handler: async (req, res) => {
6004
6119
  const body = await readBody(req);
6005
- const { agent: agentId, message } = body;
6006
- if (!agentId || !message) return jsonReply(res, 400, { error: 'agent and message required' });
6120
+ const { agent, message } = body;
6121
+ if (!agent || !message) return jsonReply(res, 400, { error: 'agent and message required' });
6122
+ const agentId = String(agent).replace(/[^a-zA-Z0-9_-]/g, '');
6123
+ const text = String(message).trim();
6124
+ if (!agentId || !text) return jsonReply(res, 400, { error: 'agent and message required' });
6007
6125
 
6008
- const steerPath = path.join(MINIONS_DIR, 'agents', agentId, 'steer.md');
6009
6126
  const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
6010
6127
  if (!fs.existsSync(agentDir)) return jsonReply(res, 404, { error: 'Agent not found' });
6128
+ if (_agentSessionIsDraining(agentId)) {
6129
+ return jsonReply(res, 409, { error: 'Agent session is finishing; retry when the next session starts' });
6130
+ }
6011
6131
 
6012
- // Write steering file
6013
- safeWrite(steerPath, message);
6132
+ const entry = steering.writeSteeringMessage(agentId, text);
6014
6133
 
6015
6134
  // Also append to live-output.log so it shows in the chat view
6016
6135
  const liveLogPath = path.join(agentDir, 'live-output.log');
6017
- try { fs.appendFileSync(liveLogPath, '\n[human-steering] ' + message + '\n'); } catch { /* optional */ }
6136
+ try { fs.appendFileSync(liveLogPath, '\n[human-steering] ' + text + '\n'); } catch { /* optional */ }
6018
6137
 
6019
- return jsonReply(res, 200, { ok: true, message: 'Steering message sent' });
6138
+ return jsonReply(res, 200, {
6139
+ ok: true,
6140
+ message: 'Steering message queued',
6141
+ file: entry?.file || null,
6142
+ inboxCount: steering.listUnreadSteeringMessages(agentId).length,
6143
+ });
6020
6144
  }},
6021
6145
  { method: 'POST', path: '/api/agents/cancel', desc: 'Cancel an active agent by ID or task substring', params: 'agent?, task?', handler: handleAgentsCancel },
6022
6146
  { method: 'POST', path: /^\/api\/agent\/([\w-]+)\/kill$/, desc: 'Kill a running agent: stop process, clear dispatch, reset work items to pending', handler: handleAgentKill },
@@ -6425,6 +6549,9 @@ module.exports = {
6425
6549
  _linkPullRequestForTracking: linkPullRequestForTracking,
6426
6550
  _resolveSkillReadPath,
6427
6551
  DOC_CHAT_DOCUMENT_DELIMITER,
6552
+ _ccValidateAction,
6553
+ _detectClaimedActionWithoutBlock,
6554
+ executeCCActions,
6428
6555
  };
6429
6556
 
6430
6557
  // Start the HTTP server only when run directly (node dashboard.js).
package/engine/ado.js CHANGED
@@ -268,7 +268,8 @@ async function forEachActivePr(config, token, callback) {
268
268
  if (!project.adoOrg || !project.adoProject) continue;
269
269
 
270
270
  const prs = getPrs(project);
271
- const activePrs = prs.filter(pr => shared.PR_POLLABLE_STATUSES.has(pr.status));
271
+ const activePrs = prs.filter(pr => shared.PR_POLLABLE_STATUSES.has(pr.status)
272
+ && shared.isPrCompatibleWithProject(project, pr, pr.url || ''));
272
273
  if (activePrs.length === 0) continue;
273
274
 
274
275
  const adoRepositoryId = getAdoRepositoryId(project);
@@ -798,7 +799,7 @@ async function reconcilePrs(config) {
798
799
  if (existing && !existing.branch && branch) {
799
800
  existing.branch = branch;
800
801
  if (existing._branchResolutionError) delete existing._branchResolutionError;
801
- if (existing._pendingReason === 'missing_pr_branch') delete existing._pendingReason;
802
+ if (existing._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete existing._pendingReason;
802
803
  metadataUpdated++;
803
804
  }
804
805
  // PR already tracked — write link to pr-links.json if we can extract an ID
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-04-30T20:24:03.227Z"
4
+ "cachedAt": "2026-04-30T21:06:17.577Z"
5
5
  }
package/engine/github.js CHANGED
@@ -206,7 +206,8 @@ async function forEachActiveGhPr(config, callback) {
206
206
  if (isSlugInBackoff(slug)) continue;
207
207
 
208
208
  const prs = getPrs(project);
209
- const activePrs = prs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status));
209
+ const activePrs = prs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status)
210
+ && shared.isPrCompatibleWithProject(project, pr, pr.url || ''));
210
211
  if (activePrs.length === 0) continue;
211
212
 
212
213
  // Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo
@@ -285,7 +286,7 @@ async function forEachActiveGhPr(config, callback) {
285
286
  if (!pr.branch && prData.head?.ref) {
286
287
  pr.branch = prData.head.ref;
287
288
  if (pr._branchResolutionError) delete pr._branchResolutionError;
288
- if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
289
+ if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
289
290
  }
290
291
  }
291
292
  }
@@ -332,7 +333,7 @@ async function pollPrStatus(config) {
332
333
  if (headBranch && pr.branch !== headBranch) {
333
334
  pr.branch = headBranch;
334
335
  if (pr._branchResolutionError) delete pr._branchResolutionError;
335
- if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
336
+ if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
336
337
  updated = true;
337
338
  }
338
339
 
@@ -713,7 +714,7 @@ async function reconcilePrs(config) {
713
714
  if (existing && !existing.branch && branch) {
714
715
  existing.branch = branch;
715
716
  if (existing._branchResolutionError) delete existing._branchResolutionError;
716
- if (existing._pendingReason === 'missing_pr_branch') delete existing._pendingReason;
717
+ if (existing._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete existing._pendingReason;
717
718
  metadataUpdated++;
718
719
  }
719
720
  if (confirmedItemId) {
@@ -707,11 +707,46 @@ function reconcilePrdStatuses(config) {
707
707
 
708
708
  function syncPrsFromOutput(output, agentId, meta, config) {
709
709
 
710
- const prMatches = new Set();
711
- const urlPattern = /(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+)/g;
712
- const textCreatedPattern = /(?:PR created|created PR|E2E PR)[:\s#-]*(\d{1,})/gi;
710
+ const prEvidence = new Map();
711
+ const trustedPrCreateToolIds = new Set();
712
+ const prUrlPattern = /(https?:\/\/github\.com\/[^\s"'\\)\]]+\/[^\s"'\\)\]]+\/pull\/(\d+)(?:[^\s"'\\)\]]*)?|https?:\/\/(?:dev\.azure\.com|[^/\s"'\\)\]]+\.visualstudio\.com)[^\s"'\\)\]]*?pullrequest\/(\d+)(?:[^\s"'\\)\]]*)?)/gi;
713
713
  let match;
714
714
 
715
+ function cleanPrUrl(url) {
716
+ return String(url || '').replace(/[.,;:]+$/, '');
717
+ }
718
+
719
+ function addPrUrlEvidence(text) {
720
+ if (!text) return;
721
+ prUrlPattern.lastIndex = 0;
722
+ while ((match = prUrlPattern.exec(String(text))) !== null) {
723
+ const prId = match[2] || match[3];
724
+ if (prId && !prEvidence.has(prId)) prEvidence.set(prId, cleanPrUrl(match[1]));
725
+ }
726
+ }
727
+
728
+ function addExplicitPrCreatedEvidence(text) {
729
+ if (!text) return;
730
+ const explicitPrCreatedPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request|E2E\s+PR)\s+(?:created|opened|submitted)\*{0,2}\s*[:\-]\s*([^\n]+)/gi;
731
+ let createdMatch;
732
+ while ((createdMatch = explicitPrCreatedPattern.exec(String(text))) !== null) {
733
+ addPrUrlEvidence(createdMatch[1]);
734
+ }
735
+ }
736
+
737
+ function isTrustedPrCreateToolUse(block) {
738
+ const name = String(block?.name || '');
739
+ if (/(?:create|open|submit)[_-]?(?:pull[_-]?request|pr)|(?:pull[_-]?request|pr)[_-]?(?:create|open|submit)/i.test(name)) {
740
+ return true;
741
+ }
742
+ const inputText = typeof block?.input === 'string' ? block.input : JSON.stringify(block?.input || {});
743
+ if (/\bgh(?:\.exe)?\s+pr\s+create\b/i.test(inputText)) return true;
744
+ if (/\baz(?:\.cmd|\.exe)?\s+repos\s+pr\s+create\b/i.test(inputText)) return true;
745
+ const callsAdoCreateApi = /_apis\/git\/repositories\/[^\s"'\\]+\/pullrequests\b/i.test(inputText);
746
+ const usesPost = /\bPOST\b|-X\s*POST|-Method\s+POST|method["']?\s*:\s*["']?POST/i.test(inputText);
747
+ return callsAdoCreateApi && usesPost;
748
+ }
749
+
715
750
  try {
716
751
  const lines = output.split('\n');
717
752
  for (const line of lines) {
@@ -720,60 +755,43 @@ function syncPrsFromOutput(output, agentId, meta, config) {
720
755
  const parsed = JSON.parse(line);
721
756
  const content = parsed.message?.content || [];
722
757
  for (const block of content) {
723
- // Scan tool_result blocks in user messages for PR URLs (gh pr create output lands here)
758
+ if (block.type === 'tool_use' && block.id && isTrustedPrCreateToolUse(block)) {
759
+ trustedPrCreateToolIds.add(block.id);
760
+ }
761
+ // Tool output is trusted only when tied to a known PR-create command/API call.
724
762
  if (block.type === 'tool_result' && block.content) {
725
- const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
726
- while ((match = urlPattern.exec(text)) !== null) prMatches.add(match[1] || match[2]);
763
+ if (trustedPrCreateToolIds.has(block.tool_use_id)) {
764
+ const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
765
+ addPrUrlEvidence(text);
766
+ }
727
767
  }
728
- // Also scan assistant text blocks for PR URLs and "PR created" patterns
768
+ // Assistant text must use the explicit Minions PR-created protocol line.
729
769
  if (block.type === 'text' && block.text) {
730
- while ((match = urlPattern.exec(block.text)) !== null) prMatches.add(match[1] || match[2]);
731
- textCreatedPattern.lastIndex = 0;
732
- let m2;
733
- while ((m2 = textCreatedPattern.exec(block.text)) !== null) prMatches.add(m2[1]);
770
+ addExplicitPrCreatedEvidence(block.text);
734
771
  }
735
772
  }
736
773
  if (parsed.type === 'result' && parsed.result) {
737
- const resultText = parsed.result;
738
- const createdPattern = /(?:created|opened|submitted|new PR|PR created)[^\n]*?(?:(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+))/gi;
739
- while ((match = createdPattern.exec(resultText)) !== null) prMatches.add(match[1] || match[2]);
740
- const createdIdPattern = /(?:created|opened|submitted|new)\s+PR[# -]*(\d{1,})/gi;
741
- while ((match = createdIdPattern.exec(resultText)) !== null) prMatches.add(match[1]);
774
+ const resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
775
+ addExplicitPrCreatedEvidence(resultText);
742
776
  }
743
777
  } catch {}
744
778
  }
745
779
  } catch {}
746
780
 
747
- // prId URL captured from inbox notes. Populated alongside prMatches so
748
- // extractPrUrl below has a fallback when the agent's stdout doesn't contain
749
- // the URL (the W-moljyu60wuzr / #1902 case — gh pr create ran in a sibling
750
- // dispatch and only the inbox note carries the link).
751
- const inboxUrls = new Map();
781
+ // Accept inbox fallback only when the agent wrote the explicit PR-created
782
+ // protocol line; generic PR mentions in findings/review notes are not evidence.
752
783
  const today = dateStamp();
753
784
  const inboxFiles = getInboxFiles().filter(f => f.includes(agentId) && f.includes(today));
754
785
  for (const f of inboxFiles) {
755
786
  const content = safeRead(path.join(INBOX_DIR, f));
756
787
  if (!content) continue;
757
- // Match a PR declaration line in the agent's findings note: optional bold,
758
- // optional "Pull Request" spelling, line-anchored so "see PR https://..."
759
- // mid-paragraph mentions don't trigger a false-positive. The protocol
760
- // and host prefix is optional so "PR: https://github.com/..." ,
761
- // "**PR:** github.com/...", etc. all match.
762
- const prHeaderPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request)[:\*]*\*?\s*[#-]*\s*(?:https?:\/\/)?[^\s"]*?(?:(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+))/gi;
788
+ const prHeaderPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request|E2E\s+PR)\s+(?:created|opened|submitted)\*{0,2}\s*[:\-]\s*([^\n]+)/gi;
763
789
  while ((match = prHeaderPattern.exec(content)) !== null) {
764
- const prId = match[1] || match[2];
765
- prMatches.add(prId);
766
- // Pull the URL substring out of the matched chunk so we can hand it to
767
- // extractPrUrl as a fallback. Prefer the first inbox URL we see for a
768
- // given prId — later notes don't override the canonical record.
769
- if (!inboxUrls.has(prId)) {
770
- const urlMatch = match[0].match(/https?:\/\/[^\s"\\)]+/);
771
- if (urlMatch) inboxUrls.set(prId, urlMatch[0].replace(/[.,;:]+$/, ''));
772
- }
790
+ addPrUrlEvidence(match[1]);
773
791
  }
774
792
  }
775
793
 
776
- if (prMatches.size === 0) return 0;
794
+ if (prEvidence.size === 0) return 0;
777
795
 
778
796
  const projects = shared.getProjects(config);
779
797
  if (projects.length === 0 && !meta?.project?.name) return 0;
@@ -798,13 +816,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
798
816
  // doesn't contain the link (gh pr create may have run in a sibling dispatch
799
817
  // whose stdout was rotated; the inbox note is the durable artifact).
800
818
  function extractPrUrl(prId) {
801
- // Stop at backslash in addition to whitespace/quotes — raw JSONL encodes newlines as \n (literal
802
- // backslash-n), so without this the regex would capture e.g. "pull/1804\n/usr/bin/bash".
803
- const ghMatch = output.match(new RegExp(`https?://github\\.com/[^\\s"'\\)\\]\\\\]*?/pull/${prId}(?:[^\\s"'\\)\\]\\\\]*)`, 'i'));
804
- if (ghMatch) return ghMatch[0].replace(/[.,;:]+$/, '');
805
- const adoMatch = output.match(new RegExp(`https?://(?:dev\\.azure\\.com|[^/]+\\.visualstudio\\.com)[^\\s"'\\)\\]\\\\]*?pullrequest/${prId}(?:[^\\s"'\\)\\]\\\\]*)`, 'i'));
806
- if (adoMatch) return adoMatch[0].replace(/[.,;:]+$/, '');
807
- return inboxUrls.get(prId) || '';
819
+ return prEvidence.get(prId) || '';
808
820
  }
809
821
 
810
822
  const agentName = config.agents?.[agentId]?.name || agentId;
@@ -814,7 +826,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
814
826
  // Group new PRs by target file path
815
827
  const newPrsByPath = new Map(); // prPath -> [{ prId, newEntry }]
816
828
 
817
- for (const prId of prMatches) {
829
+ for (const prId of prEvidence.keys()) {
818
830
  const targetProject = useCentral ? null : resolveProjectForPr(prId);
819
831
  const targetName = targetProject ? targetProject.name : '_central';
820
832
  const prPath = targetProject ? shared.projectPrPath(targetProject) : centralPrPath;
package/engine/queries.js CHANGED
@@ -8,6 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
10
  const shared = require('./shared');
11
+ const steering = require('./steering');
11
12
 
12
13
  const { safeRead, safeReadDir, safeJson, safeWrite, getProjects, mutateJsonFileLocked,
13
14
  projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
@@ -418,12 +419,18 @@ function getAgents(config) {
418
419
  // runtime tag next to the agent name.
419
420
  const runtime = shared.resolveAgentCli(a, config.engine || {});
420
421
  const inboxFiles = allInboxFiles.filter(f => f.includes(a.id));
422
+ let steeringInboxFiles = [];
423
+ try { steeringInboxFiles = steering.listUnreadSteeringMessages(a.id); } catch { steeringInboxFiles = []; }
421
424
  const s = getAgentStatus(a.id); // derives from dispatch.json
422
425
 
423
426
  let lastAction = 'Waiting for assignment';
424
427
  if (s.status === 'working') lastAction = s._runningToolDescription ? `Running: ${s._runningToolDescription}` : `Working: ${s.task}`;
425
428
  else if (s.status === 'done') lastAction = `Done: ${s.task}`;
426
429
  else if (s.status === 'error') lastAction = `Error: ${s.task}`;
430
+ else if (steeringInboxFiles.length > 0) {
431
+ const lastSteer = steeringInboxFiles[steeringInboxFiles.length - 1];
432
+ lastAction = `Pending steering: ${lastSteer.file} (${timeSince(lastSteer.createdAtMs)})`;
433
+ }
427
434
  else if (inboxFiles.length > 0) {
428
435
  const lastOutput = path.join(INBOX_DIR, inboxFiles[inboxFiles.length - 1]);
429
436
  try { lastAction = `Output: ${path.basename(lastOutput)} (${timeSince(fs.statSync(lastOutput).mtimeMs)})`; } catch { /* optional */ }
@@ -440,7 +447,7 @@ function getAgents(config) {
440
447
  _blockingToolCall: s._blockingToolCall || null,
441
448
  _warning: s._warning || null,
442
449
  _permissionMode: s._permissionMode || null,
443
- chartered, inboxCount: inboxFiles.length
450
+ chartered, inboxCount: inboxFiles.length + steeringInboxFiles.length
444
451
  };
445
452
  });
446
453
  }
@@ -458,6 +465,11 @@ function getAgentDetail(id) {
458
465
  const inboxContents = safeReadDir(INBOX_DIR)
459
466
  .filter(f => f.includes(id))
460
467
  .map(f => ({ name: f, content: safeRead(path.join(INBOX_DIR, f)) || '' }));
468
+ try {
469
+ for (const entry of steering.listUnreadSteeringMessages(id)) {
470
+ inboxContents.push({ name: entry.file, content: entry.raw || '', type: 'steering' });
471
+ }
472
+ } catch { /* optional */ }
461
473
 
462
474
  let recentDispatches = [];
463
475
  try {
package/engine/shared.js CHANGED
@@ -1073,6 +1073,9 @@ const PRD_MATERIALIZABLE = new Set([PRD_ITEM_STATUS.MISSING, PRD_ITEM_STATUS.UPD
1073
1073
  const PR_STATUS = { ACTIVE: 'active', MERGED: 'merged', ABANDONED: 'abandoned', CLOSED: 'closed', LINKED: 'linked' };
1074
1074
  // PRs eligible for polling (status/build/comment checks) — excludes terminal statuses
1075
1075
  const PR_POLLABLE_STATUSES = new Set([PR_STATUS.ACTIVE, PR_STATUS.LINKED]);
1076
+ const PR_PENDING_REASON = {
1077
+ MISSING_BRANCH: 'missing_pr_branch',
1078
+ };
1076
1079
 
1077
1080
  // Watch statuses — engine-level persistent watches that survive restarts
1078
1081
  const WATCH_STATUS = { ACTIVE: 'active', PAUSED: 'paused', TRIGGERED: 'triggered', EXPIRED: 'expired' };
@@ -1661,6 +1664,10 @@ function parseAdoPrUrl(url) {
1661
1664
  };
1662
1665
  }
1663
1666
 
1667
+ function parsePrUrl(url) {
1668
+ return parseGitHubPrUrl(url) || parseAdoPrUrl(url);
1669
+ }
1670
+
1664
1671
  function getProjectPrScope(project) {
1665
1672
  if (!project) return '';
1666
1673
  const host = String(project.repoHost || '').toLowerCase();
@@ -1705,16 +1712,47 @@ function getPrDisplayId(value, fallbackPrNumber = null) {
1705
1712
  return typeof value === 'object' ? String(value?.id || '') : String(value || '');
1706
1713
  }
1707
1714
 
1715
+ function getPrScopeInfo(prRef, url = '') {
1716
+ const isObjectRef = !!prRef && typeof prRef === 'object';
1717
+ const rawUrl = url || (isObjectRef ? prRef.url || '' : String(prRef || ''));
1718
+ const parsedUrl = parsePrUrl(rawUrl);
1719
+ if (parsedUrl) return { ...parsedUrl, source: 'url' };
1720
+ const rawId = isObjectRef ? (prRef.id || '') : String(prRef || '');
1721
+ const canonical = parseCanonicalPrId(rawId);
1722
+ return canonical ? { ...canonical, source: 'id' } : null;
1723
+ }
1724
+
1725
+ function getPrProjectScopeMismatch(project, prRef, url = '') {
1726
+ const projectScope = getProjectPrScope(project);
1727
+ if (!projectScope) return null;
1728
+ const refScope = getPrScopeInfo(prRef, url)?.scope || '';
1729
+ if (!refScope) return null;
1730
+ if (refScope === projectScope) return null;
1731
+ const [projectHost, projectRest = ''] = projectScope.split(':');
1732
+ const [refHost, refRest = ''] = refScope.split(':');
1733
+ if (projectHost === refHost && projectHost === 'ado' && !project.prUrlBase) {
1734
+ const projectParts = projectRest.split('/');
1735
+ const refParts = refRest.split('/');
1736
+ if (projectParts[0] === refParts[0] && projectParts[1] === refParts[1]) return null;
1737
+ }
1738
+ return { reason: 'pr_scope_mismatch', projectScope, prScope: refScope };
1739
+ }
1740
+
1741
+ function isPrCompatibleWithProject(project, prRef, url = '') {
1742
+ return !getPrProjectScopeMismatch(project, prRef, url);
1743
+ }
1744
+
1708
1745
  function getCanonicalPrId(project, prRef, url = '') {
1709
1746
  const isObjectRef = !!prRef && typeof prRef === 'object';
1710
1747
  const rawId = isObjectRef ? (prRef.id || '') : String(prRef || '');
1748
+ const rawUrl = url || (isObjectRef ? prRef.url || '' : String(prRef || ''));
1749
+ const parsedUrl = parsePrUrl(rawUrl);
1750
+ if (parsedUrl) return `${parsedUrl.scope}#${parsedUrl.prNumber}`;
1711
1751
  const canonical = parseCanonicalPrId(rawId);
1712
1752
  if (canonical) return `${canonical.scope}#${canonical.prNumber}`;
1713
- const parsedUrl = parseGitHubPrUrl(url || (isObjectRef ? prRef.url || '' : ''))
1714
- || parseAdoPrUrl(url || (isObjectRef ? prRef.url || '' : ''));
1715
1753
  const prNumber = getPrNumber(isObjectRef ? (prRef.prNumber ?? prRef.id ?? prRef.url) : prRef);
1716
1754
  if (prNumber == null) return rawId;
1717
- const scope = getProjectPrScope(project) || parsedUrl?.scope || '';
1755
+ const scope = getProjectPrScope(project) || '';
1718
1756
  return scope ? `${scope}#${prNumber}` : `PR-${prNumber}`;
1719
1757
  }
1720
1758
 
@@ -1755,6 +1793,17 @@ function normalizePrRecord(pr, project = null) {
1755
1793
  pr.id = canonicalId;
1756
1794
  changed = true;
1757
1795
  }
1796
+ const mismatch = getPrProjectScopeMismatch(project, pr, pr.url || '');
1797
+ if (mismatch) {
1798
+ const current = pr._invalidProjectScope || {};
1799
+ if (current.reason !== mismatch.reason || current.projectScope !== mismatch.projectScope || current.prScope !== mismatch.prScope) {
1800
+ pr._invalidProjectScope = mismatch;
1801
+ changed = true;
1802
+ }
1803
+ } else if (Object.prototype.hasOwnProperty.call(pr, '_invalidProjectScope')) {
1804
+ delete pr._invalidProjectScope;
1805
+ changed = true;
1806
+ }
1758
1807
  return changed;
1759
1808
  }
1760
1809
 
@@ -2255,7 +2304,7 @@ module.exports = {
2255
2304
  resolveAgentMaxBudget, resolveAgentBareMode,
2256
2305
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
2257
2306
  runtimeConfigWarnings,
2258
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd,
2307
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd,
2259
2308
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS,
2260
2309
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
2261
2310
  FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
@@ -2273,6 +2322,9 @@ module.exports = {
2273
2322
  getProjectPrScope,
2274
2323
  getPrNumber,
2275
2324
  getPrDisplayId,
2325
+ getPrScopeInfo,
2326
+ getPrProjectScopeMismatch,
2327
+ isPrCompatibleWithProject,
2276
2328
  getCanonicalPrId,
2277
2329
  findPrRecord,
2278
2330
  normalizePrRecord,
@@ -0,0 +1,187 @@
1
+ /**
2
+ * engine/steering.js — Durable agent-scoped steering inbox helpers.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const shared = require('./shared');
8
+
9
+ const AGENTS_DIR = path.join(shared.MINIONS_DIR, 'agents');
10
+
11
+ function agentInboxDir(agentId) {
12
+ return path.join(AGENTS_DIR, agentId, 'inbox');
13
+ }
14
+
15
+ function _createdAtFromPath(filePath, stat) {
16
+ const base = path.basename(filePath);
17
+ const m = base.match(/^steering-(\d+)/);
18
+ if (m) {
19
+ const n = Number(m[1]);
20
+ if (Number.isFinite(n) && n > 0) return n;
21
+ }
22
+ return stat?.mtimeMs || Date.now();
23
+ }
24
+
25
+ function _stripFrontmatter(raw) {
26
+ const text = String(raw || '');
27
+ if (!text.startsWith('---\n')) return text;
28
+ const end = text.indexOf('\n---\n', 4);
29
+ return end >= 0 ? text.slice(end + 5) : text;
30
+ }
31
+
32
+ function _frontmatterValue(raw, key) {
33
+ const text = String(raw || '');
34
+ if (!text.startsWith('---\n')) return null;
35
+ const end = text.indexOf('\n---\n', 4);
36
+ if (end < 0) return null;
37
+ const fm = text.slice(4, end).split(/\r?\n/);
38
+ const prefix = key + ':';
39
+ for (const line of fm) {
40
+ if (line.startsWith(prefix)) return line.slice(prefix.length).trim();
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function _messageFromRaw(raw) {
46
+ let body = _stripFrontmatter(raw).trim();
47
+ const forwarded = body.match(/Original steering from human:\s*([\s\S]*)$/i);
48
+ if (forwarded) body = forwarded[1].trim();
49
+ return body;
50
+ }
51
+
52
+ function _readEntry(filePath, legacy = false) {
53
+ let stat;
54
+ try { stat = fs.statSync(filePath); } catch { return null; }
55
+ const raw = shared.safeRead(filePath);
56
+ const fmCreatedAtMs = Number(_frontmatterValue(raw, 'createdAtMs'));
57
+ const createdAtMs = Number.isFinite(fmCreatedAtMs) && fmCreatedAtMs > 0
58
+ ? fmCreatedAtMs
59
+ : _createdAtFromPath(filePath, stat);
60
+ return {
61
+ path: filePath,
62
+ file: path.basename(filePath),
63
+ createdAtMs,
64
+ createdAt: new Date(createdAtMs).toISOString(),
65
+ raw,
66
+ message: _messageFromRaw(raw),
67
+ legacy,
68
+ };
69
+ }
70
+
71
+ function _uniqueSteeringPath(inboxDir, createdAtMs) {
72
+ let filePath = path.join(inboxDir, `steering-${createdAtMs}.md`);
73
+ for (let i = 1; fs.existsSync(filePath); i++) {
74
+ filePath = path.join(inboxDir, `steering-${createdAtMs}-${i}.md`);
75
+ }
76
+ return filePath;
77
+ }
78
+
79
+ function writeSteeringMessage(agentId, message, opts = {}) {
80
+ const createdAtMs = Number(opts.createdAtMs) || Date.now();
81
+ const createdAt = new Date(createdAtMs).toISOString();
82
+ const inboxDir = agentInboxDir(agentId);
83
+ fs.mkdirSync(inboxDir, { recursive: true });
84
+ const filePath = _uniqueSteeringPath(inboxDir, createdAtMs);
85
+ const body = [
86
+ '---',
87
+ `createdAt: ${createdAt}`,
88
+ `createdAtMs: ${createdAtMs}`,
89
+ `source: ${opts.source || 'human'}`,
90
+ '---',
91
+ '',
92
+ String(message || '').trim(),
93
+ '',
94
+ ].join('\n');
95
+ shared.safeWrite(filePath, body);
96
+ return _readEntry(filePath);
97
+ }
98
+
99
+ function listUnreadSteeringMessages(agentId, opts = {}) {
100
+ const includeLegacy = opts.includeLegacy !== false;
101
+ const entries = [];
102
+ const inboxDir = agentInboxDir(agentId);
103
+ for (const file of shared.safeReadDir(inboxDir)) {
104
+ if (!/^steering-.*\.md$/i.test(file)) continue;
105
+ const entry = _readEntry(path.join(inboxDir, file), false);
106
+ if (entry) entries.push(entry);
107
+ }
108
+
109
+ if (includeLegacy) {
110
+ const legacyPath = path.join(AGENTS_DIR, agentId, 'steer.md');
111
+ const legacy = _readEntry(legacyPath, true);
112
+ if (legacy) entries.push(legacy);
113
+ }
114
+
115
+ entries.sort((a, b) => (a.createdAtMs - b.createdAtMs) || a.file.localeCompare(b.file));
116
+ return entries;
117
+ }
118
+
119
+ function buildPendingSteeringPrompt(agentId) {
120
+ const entries = listUnreadSteeringMessages(agentId).filter(entry => entry.message.trim());
121
+ if (entries.length === 0) return { entries, prompt: '' };
122
+
123
+ const sections = [
124
+ '## Pending instructions from prior session',
125
+ '',
126
+ 'These human steering messages were not confirmed processed before the previous session ended. Address them before continuing with the task.',
127
+ ];
128
+ entries.forEach((entry, idx) => {
129
+ sections.push('', `### Message ${idx + 1} — ${entry.createdAt}`, '', entry.message.trim());
130
+ });
131
+ return { entries, prompt: sections.join('\n') };
132
+ }
133
+
134
+ function _eventTimestampMs(obj, observedAtMs) {
135
+ const value = obj?.timestamp || obj?.createdAt || obj?.created_at || obj?.time || obj?.data?.timestamp;
136
+ const parsed = value ? Date.parse(value) : NaN;
137
+ if (Number.isFinite(parsed)) return parsed;
138
+ return Number(observedAtMs) || Date.now();
139
+ }
140
+
141
+ function _isProcessEvidenceEvent(obj) {
142
+ if (!obj || typeof obj !== 'object') return false;
143
+ const type = String(obj.type || '');
144
+ if (type === 'assistant' || type === 'tool_use') return true;
145
+ if (type.startsWith('assistant.') || type.startsWith('tool.')) return true;
146
+ if (Array.isArray(obj.message?.content)) {
147
+ return obj.message.content.some(block => block?.type === 'text' || block?.type === 'tool_use');
148
+ }
149
+ return false;
150
+ }
151
+
152
+ function _processEvidenceTimes(rawOutput, observedAtMs) {
153
+ const times = [];
154
+ for (const line of String(rawOutput || '').split(/\r?\n/)) {
155
+ const trimmed = line.trim();
156
+ if (!trimmed.startsWith('{')) continue;
157
+ try {
158
+ const obj = JSON.parse(trimmed);
159
+ if (_isProcessEvidenceEvent(obj)) times.push(_eventTimestampMs(obj, observedAtMs));
160
+ } catch { /* ignore non-JSON output */ }
161
+ }
162
+ return times;
163
+ }
164
+
165
+ function ackProcessedSteeringMessages(agentId, pendingEntries, rawOutput, opts = {}) {
166
+ const entries = Array.isArray(pendingEntries) ? pendingEntries : [];
167
+ if (entries.length === 0) return [];
168
+ const times = _processEvidenceTimes(rawOutput, opts.observedAtMs);
169
+ if (times.length === 0) return [];
170
+
171
+ const acked = [];
172
+ for (const entry of entries) {
173
+ if (!entry?.path) continue;
174
+ if (!times.some(t => t > entry.createdAtMs)) continue;
175
+ shared.safeUnlink(entry.path);
176
+ acked.push(entry);
177
+ }
178
+ return acked;
179
+ }
180
+
181
+ module.exports = {
182
+ agentInboxDir,
183
+ writeSteeringMessage,
184
+ listUnreadSteeringMessages,
185
+ buildPendingSteeringPrompt,
186
+ ackProcessedSteeringMessages,
187
+ };
package/engine/timeout.js CHANGED
@@ -6,6 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const shared = require('./shared');
8
8
  const queries = require('./queries');
9
+ const steering = require('./steering');
9
10
 
10
11
  const { safeRead, safeWrite, safeJson, mutateJsonFileLocked, getProjects, projectWorkItemsPath, log, ts,
11
12
  ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, DISPATCH_RESULT, AGENT_STATUS } = shared;
@@ -78,25 +79,20 @@ function checkSteering(config) {
78
79
  // Skip if already being steered (prevents double-kill race)
79
80
  if (info._steeringMessage || info._steeringAt) continue;
80
81
 
81
- const steerPath = path.join(AGENTS_DIR, info.agentId, 'steer.md');
82
- let steerMtime;
83
- try { steerMtime = fs.statSync(steerPath).mtimeMs; } catch { continue; } // ENOENT = no steering message
84
-
85
- // Read and consume the message immediately — always delete to prevent stale messages
86
- const message = safeRead(steerPath);
87
- try { fs.unlinkSync(steerPath); } catch { /* cleanup */ }
88
- if (!message) continue;
82
+ const alreadyPending = new Set((info._pendingSteeringFiles || []).map(entry => entry.path || entry));
83
+ const unread = steering.listUnreadSteeringMessages(info.agentId);
84
+ for (const empty of unread.filter(entry => !entry.message.trim())) {
85
+ shared.safeUnlink(empty.path);
86
+ }
87
+ const steerEntry = unread.find(entry => entry.message.trim() && !alreadyPending.has(entry.path));
88
+ if (!steerEntry) continue; // ENOENT/no agents/<id>/inbox/steering-*.md message
89
+ const message = steerEntry.message.trim();
89
90
 
90
91
  const sessionId = info.sessionId;
91
92
  if (!sessionId) {
92
- // No session to resume — kill agent and deliver message via inbox for retry.
93
+ // No session to resume — kill agent and leave message unread in inbox for retry.
93
94
  // Previously this silently skipped for up to 5m then deleted the message (#627).
94
- log('info', `Steering: no sessionId for ${info.agentId} (${id}) — killing and forwarding message to inbox`);
95
-
96
- // Write steering message to agent inbox so it survives the retry
97
- const inboxDir = path.join(AGENTS_DIR, info.agentId, 'inbox');
98
- try { fs.mkdirSync(inboxDir, { recursive: true }); } catch {}
99
- safeWrite(path.join(inboxDir, `steering-${Date.now()}.md`), `# Steering Message (Forwarded)\n\nOriginal steering from human:\n\n${message}\n`);
95
+ log('info', `Steering: no sessionId for ${info.agentId} (${id}) — killing and keeping unread message in inbox`);
100
96
 
101
97
  // Append to live output so user sees confirmation in the dashboard
102
98
  try {
@@ -115,6 +111,7 @@ function checkSteering(config) {
115
111
  // Set steering state BEFORE kill — close event may fire synchronously on some platforms
116
112
  info._steeringMessage = message;
117
113
  info._steeringSessionId = sessionId;
114
+ info._steeringEntry = steerEntry;
118
115
  info._steeringAt = Date.now();
119
116
 
120
117
  shared.killImmediate(info.proc);
package/engine.js CHANGED
@@ -107,6 +107,7 @@ const { mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatc
107
107
  // ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
108
108
 
109
109
  const { checkTimeouts, checkSteering, checkIdleThreshold } = require('./engine/timeout');
110
+ const steering = require('./engine/steering');
110
111
 
111
112
  // ─── Cleanup (extracted to engine/cleanup.js) ────────────────────────────────
112
113
 
@@ -295,6 +296,17 @@ function _buildAgentSpawnFlags(runtime, opts = {}) {
295
296
  return flags;
296
297
  }
297
298
 
299
+ function ackPendingSteeringFiles(agentId, procInfo, rawOutput, observedAtMs = Date.now()) {
300
+ if (!procInfo?._pendingSteeringFiles?.length || !rawOutput) return;
301
+ const acked = steering.ackProcessedSteeringMessages(agentId, procInfo._pendingSteeringFiles, rawOutput, { observedAtMs });
302
+ if (acked.length === 0) return;
303
+
304
+ const ackedPaths = new Set(acked.map(entry => entry.path));
305
+ procInfo._pendingSteeringFiles = procInfo._pendingSteeringFiles.filter(entry => !ackedPaths.has(entry.path));
306
+ if (procInfo._pendingSteeringFiles.length === 0) delete procInfo._pendingSteeringFiles;
307
+ log('info', `Steering: ACKed ${acked.length} processed message(s) for ${agentId}`);
308
+ }
309
+
298
310
  // Resolve dependency plan item IDs to their PR branches
299
311
  function resolveDependencyBranches(depIds, sourcePlan, project, config) {
300
312
  const results = []; // [{ branch, prId }]
@@ -436,9 +448,13 @@ async function spawnAgent(dispatchItem, config) {
436
448
  // and this avoids blocking 200ms of file reads behind 20-60s of git operations
437
449
  const systemPrompt = buildSystemPrompt(agentId, config, project);
438
450
  const agentContext = buildAgentContext(agentId, config, project);
439
- const fullTaskPrompt = agentContext
440
- ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPrompt}`
451
+ const pendingSteering = steering.buildPendingSteeringPrompt(agentId);
452
+ const taskPromptWithSteering = pendingSteering.prompt
453
+ ? `${pendingSteering.prompt}\n\n---\n\n${taskPrompt}`
441
454
  : taskPrompt;
455
+ const fullTaskPrompt = agentContext
456
+ ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPromptWithSteering}`
457
+ : taskPromptWithSteering;
442
458
  const tmpDir = path.join(ENGINE_DIR, 'tmp');
443
459
  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
444
460
  const safeId = id.replace(/[:\\/*?"<>|]/g, '-');
@@ -1036,6 +1052,8 @@ async function spawnAgent(dispatchItem, config) {
1036
1052
  }
1037
1053
  } catch { /* JSON parse — output may not be valid JSON */ }
1038
1054
  }
1055
+
1056
+ ackPendingSteeringFiles(agentId, procInfo, chunk);
1039
1057
  });
1040
1058
 
1041
1059
  proc.stderr.on('data', (data) => {
@@ -1058,13 +1076,17 @@ async function spawnAgent(dispatchItem, config) {
1058
1076
  try { shared.safeUnlink(path.join(AGENTS_DIR, agentId, 'session.json')); } catch {}
1059
1077
  }
1060
1078
 
1061
- // Check if this was a steering kill — re-spawn with resume
1062
1079
  const procInfo = activeProcesses.get(id);
1080
+ ackPendingSteeringFiles(agentId, procInfo, stdout);
1081
+
1082
+ // Check if this was a steering kill — re-spawn with resume
1063
1083
  if (procInfo?._steeringMessage) {
1064
1084
  const steerMsg = procInfo._steeringMessage;
1065
1085
  const steerSessionId = procInfo._steeringSessionId;
1086
+ const steerEntry = procInfo._steeringEntry;
1066
1087
  delete procInfo._steeringMessage;
1067
1088
  delete procInfo._steeringSessionId;
1089
+ delete procInfo._steeringEntry;
1068
1090
 
1069
1091
  // Guard: can't resume without a session
1070
1092
  if (!steerSessionId) {
@@ -1156,7 +1178,14 @@ async function spawnAgent(dispatchItem, config) {
1156
1178
  // into the resumed process, it kills the resumed session. The kill watcher only exists
1157
1179
  // to handle cases where the original kill didn't take effect — once the process has
1158
1180
  // exited and the resume is spawned, _steeringAt must not be present.
1159
- activeProcesses.set(id, { proc: resumeProc, agentId, startedAt: procInfo.startedAt, sessionId: steerSessionId, lastRealOutputAt: Date.now() });
1181
+ activeProcesses.set(id, {
1182
+ proc: resumeProc,
1183
+ agentId,
1184
+ startedAt: procInfo.startedAt,
1185
+ sessionId: steerSessionId,
1186
+ lastRealOutputAt: Date.now(),
1187
+ _pendingSteeringFiles: steerEntry ? [steerEntry] : (procInfo._pendingSteeringFiles || []),
1188
+ });
1160
1189
 
1161
1190
  // Reset output buffers so post-completion parsing only sees the resumed session
1162
1191
  stdout = '';
@@ -1167,6 +1196,7 @@ async function spawnAgent(dispatchItem, config) {
1167
1196
  realActivityMap.set(id, Date.now());
1168
1197
  if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
1169
1198
  try { fs.appendFileSync(liveOutputPath, chunk); } catch { /* optional */ }
1199
+ ackPendingSteeringFiles(agentId, activeProcesses.get(id), chunk);
1170
1200
  });
1171
1201
  resumeProc.stderr.on('data', (data) => {
1172
1202
  const chunk = data.toString();
@@ -1370,7 +1400,13 @@ async function spawnAgent(dispatchItem, config) {
1370
1400
  // realActivityMap was already seeded immediately after runFile() returned (#W-mo25loq8kjer);
1371
1401
  // don't re-seed here — the stdout/stderr handlers above can already have updated it with
1372
1402
  // a fresher timestamp, and overwriting would clobber the real "last activity" signal.
1373
- activeProcesses.set(id, { proc, agentId, startedAt, sessionId: cachedSessionId });
1403
+ activeProcesses.set(id, {
1404
+ proc,
1405
+ agentId,
1406
+ startedAt,
1407
+ sessionId: cachedSessionId,
1408
+ _pendingSteeringFiles: pendingSteering.entries,
1409
+ });
1374
1410
 
1375
1411
  updateAgentStatus(id, AGENT_STATUS.RUNNING, `Process spawned for ${agentId}`);
1376
1412
 
@@ -1984,7 +2020,7 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
1984
2020
  } catch (e) { log('warn', 'clear pending human feedback flag: ' + e.message); }
1985
2021
  }
1986
2022
 
1987
- const PR_PENDING_MISSING_BRANCH = 'missing_pr_branch';
2023
+ const PR_PENDING_MISSING_BRANCH = shared.PR_PENDING_REASON.MISSING_BRANCH;
1988
2024
 
1989
2025
  function normalizePrBranch(value) {
1990
2026
  const raw = value == null ? '' : String(value).trim();
@@ -2117,6 +2153,7 @@ async function discoverFromPrs(config, project) {
2117
2153
  const knownAgents = new Set(Object.keys(config.agents || {}));
2118
2154
  for (const pr of prs) {
2119
2155
  if (pr.status !== PR_STATUS.ACTIVE || pr._contextOnly) continue;
2156
+ if (!shared.isPrCompatibleWithProject(project, pr, pr.url || '')) continue;
2120
2157
  const prDisplayId = shared.getPrDisplayId(pr);
2121
2158
  const prCanonicalId = shared.getCanonicalPrId(project, pr, pr.url || '');
2122
2159
  if (activePrIds.has(prCanonicalId)) continue; // Skip PRs with active dispatch (prevent race)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1642",
3
+ "version": "0.1.1644",
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"
@@ -72,8 +72,17 @@ I'll dispatch dallas to fix that bug.
72
72
 
73
73
  **Generic fallback:** For any action not listed below, include `"endpoint": "/api/..."` and `"params": {...}` to call the API directly. Example: `{"type": "custom-op", "endpoint": "/api/some/endpoint", "params": {"key": "value"}}`.
74
74
 
75
+ **Required fields per action type — server rejects with an error if missing:**
76
+
77
+ - `dispatch` (and aliases: `fix`, `implement`, `explore`, `review`, `test`): `title` is REQUIRED. `description` recommended. `project` REQUIRED when multiple projects are configured (server returns the list of known names if you guess wrong). For agent hints emit either `agents: ["dallas"]` (array, preferred) or `agent: "dallas"` (string — auto-promoted server-side). Unknown agent names error.
78
+ - `build-and-test`: `pr` REQUIRED (number, ID, or URL).
79
+ - `note`: `title` and `content` (or `description`) REQUIRED.
80
+ - `knowledge`: `title`, `content`, and `category` REQUIRED. Valid categories: architecture, conventions, project-notes, build-reports, reviews.
81
+
82
+ If you describe an action in prose ("I'll dispatch this..."), you MUST emit a matching `===ACTIONS===` block. The server detects prose claims without action blocks and surfaces a warning to the user — i.e., your false claim becomes visible. Either dispatch or don't promise to.
83
+
75
84
  Core action types:
76
- - **dispatch**: title, workType, priority (low/medium/high), agents[] (optional), project, description
85
+ - **dispatch**: title (REQUIRED), workType, priority (low/medium/high), agents[] or agent (optional — both shapes accepted), project (REQUIRED when multi-project), description
77
86
  workTypes: `explore` (research/report only, NO PR), `ask` (answer/report, NO PR), `implement` (new code, PR REQUIRED), `fix` (bug fix, PR REQUIRED), `review` (code review, NO PR), `test` (tests, PR if new), `verify` (merge/build/maintenance, NO PR)
78
87
  If the user wants a design/architecture artifact committed through a PR, dispatch `implement` or `docs` rather than `explore`.
79
88
  When the user names a specific agent ("assign this to lambert"), put exactly that one name in `agents` (e.g. `"agents": ["lambert"]`). A single-agent assignment is hard-pinned by the server — it will queue for that agent only and skip the routing table. Use multi-agent arrays only when the user names multiple agents or asks for fan-out.