@yemi33/minions 0.1.1650 → 0.1.1652

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.
@@ -94,7 +94,7 @@ function parseSpawnArgs(argv) {
94
94
  * Returns:
95
95
  * {
96
96
  * bin, leadingArgs, args, // → spawn(execPath OR bin, [bin?, ...leadingArgs, ...args])
97
- * deliveryMode, // 'stdin' | 'arg' (per runtime.capabilities.promptViaArg)
97
+ * deliveryMode, // 'stdin' | 'arg' (runtime adapter decision)
98
98
  * finalPrompt, // adapter-built prompt text (may include sysprompt for some runtimes)
99
99
  * usingNodeShim, // true → runtime returned a non-native binary (Claude cli.js)
100
100
  * }
@@ -103,9 +103,10 @@ function buildSpawnInvocation({ runtime, resolved, promptText, sysPromptText, op
103
103
  const finalPrompt = runtime.buildPrompt(promptText, sysPromptText);
104
104
  const adapterOpts = { ...opts };
105
105
  if (Array.isArray(addDirs) && addDirs.length) adapterOpts.addDirs = addDirs;
106
- // When the adapter delivers the prompt via argv, hand it the prompt so it
107
- // can splice `--prompt <text>` into its args.
108
- if (runtime.capabilities && runtime.capabilities.promptViaArg) {
106
+ const deliveryMode = typeof runtime.getPromptDeliveryMode === 'function'
107
+ ? runtime.getPromptDeliveryMode(adapterOpts)
108
+ : (runtime.capabilities && runtime.capabilities.promptViaArg ? 'arg' : 'stdin');
109
+ if (deliveryMode === 'arg') {
109
110
  adapterOpts.prompt = finalPrompt;
110
111
  }
111
112
  const adapterArgs = runtime.buildArgs(adapterOpts);
@@ -115,7 +116,7 @@ function buildSpawnInvocation({ runtime, resolved, promptText, sysPromptText, op
115
116
  native,
116
117
  leadingArgs,
117
118
  args: [...adapterArgs, ...(passthrough || [])],
118
- deliveryMode: runtime.capabilities && runtime.capabilities.promptViaArg ? 'arg' : 'stdin',
119
+ deliveryMode,
119
120
  finalPrompt,
120
121
  usingNodeShim: !native,
121
122
  };
@@ -159,7 +160,9 @@ function main() {
159
160
  // already merged inside `runtime.buildPrompt(prompt, sys)`.
160
161
  const isResume = opts.sessionId != null;
161
162
  const sysTmpPath = sysPromptFile + '.tmp';
162
- const wantsSystemPromptFile = !isResume && runtime.capabilities && runtime.capabilities.systemPromptFile;
163
+ const wantsSystemPromptFile = typeof runtime.usesSystemPromptFile === 'function'
164
+ ? runtime.usesSystemPromptFile({ isResume, opts })
165
+ : (!isResume && runtime.capabilities && runtime.capabilities.systemPromptFile);
163
166
  if (wantsSystemPromptFile) {
164
167
  fs.writeFileSync(sysTmpPath, sysPromptText);
165
168
  opts.sysPromptFile = sysTmpPath;
@@ -162,6 +162,27 @@ function _processEvidenceTimes(rawOutput, observedAtMs) {
162
162
  return times;
163
163
  }
164
164
 
165
+ function sessionIdFromEvent(obj) {
166
+ if (!obj || typeof obj !== 'object') return null;
167
+ const candidates = [
168
+ obj.session_id,
169
+ obj.sessionId,
170
+ obj.data?.session_id,
171
+ obj.data?.sessionId,
172
+ ];
173
+ for (const value of candidates) {
174
+ if (typeof value === 'string' && value.trim()) return value.trim();
175
+ }
176
+ if (obj.raw && obj.raw !== obj) return sessionIdFromEvent(obj.raw);
177
+ return null;
178
+ }
179
+
180
+ function sessionIdFromOutputLine(line) {
181
+ const trimmed = String(line || '').trim();
182
+ if (!trimmed.startsWith('{')) return null;
183
+ try { return sessionIdFromEvent(JSON.parse(trimmed)); } catch { return null; }
184
+ }
185
+
165
186
  function ackProcessedSteeringMessages(agentId, pendingEntries, rawOutput, opts = {}) {
166
187
  const entries = Array.isArray(pendingEntries) ? pendingEntries : [];
167
188
  if (entries.length === 0) return [];
@@ -183,5 +204,7 @@ module.exports = {
183
204
  writeSteeringMessage,
184
205
  listUnreadSteeringMessages,
185
206
  buildPendingSteeringPrompt,
207
+ sessionIdFromEvent,
208
+ sessionIdFromOutputLine,
186
209
  ackProcessedSteeringMessages,
187
210
  };
package/engine.js CHANGED
@@ -141,7 +141,7 @@ const { renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS,
141
141
  const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconcilePrdStatuses, handlePostMerge, checkPlanCompletion,
142
142
  syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
143
143
  updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs,
144
- isItemCompleted, classifyFailure, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemPath } = require('./engine/lifecycle');
144
+ isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemPath } = require('./engine/lifecycle');
145
145
 
146
146
  // ─── Agent Spawner ──────────────────────────────────────────────────────────
147
147
 
@@ -232,62 +232,27 @@ async function preflightMergeSimulation(deps, mainRef, gitOpts, cwd) {
232
232
  return { ok: true };
233
233
  }
234
234
 
235
- const _FAST_WORK_TYPES = new Set([WORK_TYPE.EXPLORE, WORK_TYPE.ASK, WORK_TYPE.REVIEW]);
236
- const _MAX_TURNS_BY_TYPE = {
237
- [WORK_TYPE.EXPLORE]: 30, [WORK_TYPE.ASK]: 20, [WORK_TYPE.REVIEW]: 30,
238
- [WORK_TYPE.DECOMPOSE]: 15, [WORK_TYPE.PLAN]: 30, [WORK_TYPE.PLAN_TO_PRD]: 35,
239
- [WORK_TYPE.MEETING]: 30,
240
- [WORK_TYPE.IMPLEMENT]: 75, [WORK_TYPE.IMPLEMENT_LARGE]: 75, [WORK_TYPE.FIX]: 75,
241
- [WORK_TYPE.TEST]: 50, [WORK_TYPE.VERIFY]: 100, [WORK_TYPE.DOCS]: 30,
242
- };
243
- function _maxTurnsForType(type, engineConfig) {
244
- // Priority: per-type config override → global config override → built-in per-type default → global default
235
+ function _maxTurnsForType(type, engineConfig = {}) {
236
+ // Engine keeps only explicit operator caps; runtime/playbook owns task-level pacing.
245
237
  const perType = engineConfig.maxTurnsByType || {};
246
238
  if (perType[type]) return perType[type];
247
- const globalOverride = engineConfig.maxTurns && engineConfig.maxTurns !== ENGINE_DEFAULTS.maxTurns ? engineConfig.maxTurns : null;
248
- return globalOverride || _MAX_TURNS_BY_TYPE[type] || ENGINE_DEFAULTS.maxTurns;
239
+ return engineConfig.maxTurns || ENGINE_DEFAULTS.maxTurns;
249
240
  }
250
241
 
251
242
  // ─── Runtime adapter integration (P-2a6d9c4f) ────────────────────────────────
252
- //
253
- // _buildAgentSpawnFlags translates a resolved opts bag into the named CLI
254
- // flags consumed by `engine/spawn-agent.js`. spawn-agent.js parses these back
255
- // into an opts object and calls `runtime.buildArgs(opts)` once — keeping the
256
- // adapter as the single source of truth for CLI-level flag formatting and
257
- // avoiding double-emission.
258
- //
259
- // Capability gating happens HERE (in engine.js), not in the adapter:
260
- // - Runtimes that don't support a feature (Copilot has no budgetCap, no
261
- // bareMode, no fallbackModel) never see the flag. The adapter doesn't
262
- // have to silently drop opts it can't honor.
263
- // - Truly cross-runtime opts (model, maxTurns, allowedTools, effort,
264
- // sessionId) are emitted whenever set; capability flags filter the
265
- // ones that are runtime-specific.
266
- // - Copilot-specific opts (`stream`, `disableBuiltinMcps`,
267
- // `suppressAgentsMd`, `reasoningSummaries`) are emitted unconditionally;
268
- // the Claude adapter ignores them via the "tolerate unknown opts" rule.
269
243
 
270
244
  function _buildAgentSpawnFlags(runtime, opts = {}) {
245
+ if (runtime && typeof runtime.buildSpawnFlags === 'function') return runtime.buildSpawnFlags(opts);
271
246
  const caps = (runtime && runtime.capabilities) || {};
272
247
  const flags = ['--runtime', String(runtime?.name || 'claude')];
273
-
274
- // Always-applicable: every runtime understands these.
275
248
  if (opts.maxTurns != null) flags.push('--max-turns', String(opts.maxTurns));
276
249
  if (opts.model) flags.push('--model', String(opts.model));
277
250
  if (opts.allowedTools) flags.push('--allowedTools', String(opts.allowedTools));
278
-
279
- // Capability-gated. The first three (effort/resume) gate on whether the
280
- // runtime exposes the feature at all; the next three gate on whether the
281
- // runtime's CLI surface actually accepts the flag.
282
251
  if (caps.effortLevels && opts.effort) flags.push('--effort', String(opts.effort));
283
252
  if (caps.sessionResume && opts.sessionId) flags.push('--resume', String(opts.sessionId));
284
253
  if (caps.budgetCap && opts.maxBudget != null) flags.push('--max-budget-usd', String(opts.maxBudget));
285
254
  if (caps.bareMode && opts.bare === true) flags.push('--bare');
286
255
  if (caps.fallbackModel && opts.fallbackModel) flags.push('--fallback-model', String(opts.fallbackModel));
287
-
288
- // Copilot-specific opts. Always emitted when set; the Claude adapter
289
- // silently ignores them (per the "tolerate unknown opts" rule from
290
- // P-7e3a8b1c). Engine code never branches on `runtime.name`.
291
256
  if (opts.stream != null && opts.stream !== '') flags.push('--stream', String(opts.stream));
292
257
  if (opts.disableBuiltinMcps === true) flags.push('--disable-builtin-mcps');
293
258
  if (opts.suppressAgentsMd === true) flags.push('--no-custom-instructions');
@@ -296,8 +261,29 @@ function _buildAgentSpawnFlags(runtime, opts = {}) {
296
261
  return flags;
297
262
  }
298
263
 
264
+ function _runtimeLogger() {
265
+ return {
266
+ info: (msg) => log('info', msg),
267
+ warn: (msg) => log('warn', msg),
268
+ };
269
+ }
270
+
271
+ function _classifyAgentFailure(runtime, code, stdout, stderr) {
272
+ if (runtime && typeof runtime.classifyFailure === 'function') {
273
+ const classified = runtime.classifyFailure({
274
+ code,
275
+ stdout,
276
+ stderr,
277
+ fallback: classifyFailureFallback,
278
+ });
279
+ if (classified && classified.failureClass) return classified;
280
+ }
281
+ return { failureClass: classifyFailureFallback(code, stdout, stderr) };
282
+ }
283
+
299
284
  function ackPendingSteeringFiles(agentId, procInfo, rawOutput, observedAtMs = Date.now()) {
300
285
  if (!procInfo?._pendingSteeringFiles?.length || !rawOutput) return;
286
+ if (procInfo._steeringMessage || procInfo._steeringNoSession) return;
301
287
  const acked = steering.ackProcessedSteeringMessages(agentId, procInfo._pendingSteeringFiles, rawOutput, { observedAtMs });
302
288
  if (acked.length === 0) return;
303
289
 
@@ -307,6 +293,45 @@ function ackPendingSteeringFiles(agentId, procInfo, rawOutput, observedAtMs = Da
307
293
  log('info', `Steering: ACKed ${acked.length} processed message(s) for ${agentId}`);
308
294
  }
309
295
 
296
+ function captureSessionIdFromStdoutChunk(agentId, dispatchId, branchName, runtime, procInfo, chunk, state) {
297
+ if (!procInfo || procInfo.sessionId || !chunk) return;
298
+ const text = String(state.sessionLineBuffer || '') + String(chunk);
299
+ const lines = text.split('\n');
300
+ state.sessionLineBuffer = /[\r\n]$/.test(text) ? '' : (lines.pop() || '');
301
+ if (state.sessionLineBuffer.length > 65536) state.sessionLineBuffer = '';
302
+
303
+ for (const line of lines) {
304
+ const sessionId = steering.sessionIdFromOutputLine(line);
305
+ if (!sessionId) continue;
306
+ procInfo.sessionId = sessionId;
307
+ if (runtime && typeof runtime.saveSession === 'function') {
308
+ runtime.saveSession({
309
+ agentId,
310
+ dispatchId,
311
+ branch: branchName,
312
+ sessionId,
313
+ agentsDir: AGENTS_DIR,
314
+ logger: _runtimeLogger(),
315
+ });
316
+ }
317
+ return;
318
+ }
319
+ }
320
+
321
+ function mergePendingSteeringEntries(...groups) {
322
+ const merged = [];
323
+ const seen = new Set();
324
+ for (const group of groups) {
325
+ const entries = Array.isArray(group) ? group : (group ? [group] : []);
326
+ for (const entry of entries) {
327
+ if (!entry?.path || seen.has(entry.path)) continue;
328
+ seen.add(entry.path);
329
+ merged.push(entry);
330
+ }
331
+ }
332
+ return merged;
333
+ }
334
+
310
335
  // Resolve dependency plan item IDs to their PR branches
311
336
  function resolveDependencyBranches(depIds, sourcePlan, project, config) {
312
337
  const results = []; // [{ branch, prId }]
@@ -449,12 +474,31 @@ async function spawnAgent(dispatchItem, config) {
449
474
  const systemPrompt = buildSystemPrompt(agentId, config, project);
450
475
  const agentContext = buildAgentContext(agentId, config, project);
451
476
  const pendingSteering = steering.buildPendingSteeringPrompt(agentId);
477
+ const completionReportPath = shared.dispatchCompletionReportPath(id);
478
+ if (completionReportPath) {
479
+ try {
480
+ fs.mkdirSync(path.dirname(completionReportPath), { recursive: true });
481
+ safeUnlink(completionReportPath);
482
+ } catch (e) { log('warn', `completion report setup: ${e.message}`); }
483
+ }
484
+ const completionReportInstruction = completionReportPath ? [
485
+ '## Completion Report',
486
+ '',
487
+ `Before exiting, write a JSON completion report to: \`${completionReportPath}\``,
488
+ '',
489
+ 'Use this shape: {"status":"success|partial|failed","summary":"...","verdict":"approved|changes-requested|null","pr":"PR URL or id if relevant","failure_class":"...","retryable":true|false,"needs_rerun":true|false}.',
490
+ 'This report is the primary completion signal; fenced completion blocks are only a fallback.',
491
+ '',
492
+ ].join('\n') : '';
452
493
  const taskPromptWithSteering = pendingSteering.prompt
453
494
  ? `${pendingSteering.prompt}\n\n---\n\n${taskPrompt}`
454
495
  : taskPrompt;
455
- const fullTaskPrompt = agentContext
456
- ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPromptWithSteering}`
496
+ const taskPromptWithReport = completionReportInstruction
497
+ ? `${taskPromptWithSteering}\n\n---\n\n${completionReportInstruction}`
457
498
  : taskPromptWithSteering;
499
+ const fullTaskPrompt = agentContext
500
+ ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPromptWithReport}`
501
+ : taskPromptWithReport;
458
502
  const tmpDir = path.join(ENGINE_DIR, 'tmp');
459
503
  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
460
504
  const safeId = id.replace(/[:\\/*?"<>|]/g, '-');
@@ -890,32 +934,18 @@ async function spawnAgent(dispatchItem, config) {
890
934
  const resolvedMaxBudget = shared.resolveAgentMaxBudget(agentConfig, engineConfig);
891
935
  const resolvedBare = shared.resolveAgentBareMode(agentConfig, engineConfig);
892
936
 
893
- // Effort: use 'low' for fast work types unless configured otherwise.
894
- // The capability gate happens inside _buildAgentSpawnFlags — runtimes
895
- // without effortLevels never see the flag.
896
- const requestedEffort = engineConfig.agentEffort || (_FAST_WORK_TYPES.has(type) ? 'low' : null);
937
+ const requestedEffort = engineConfig.agentEffort || null;
897
938
 
898
- // Session resume: gated on `runtime.capabilities.sessionResume`. Same branch
899
- // means the agent is continuing work on the same PR/feature (e.g., author
900
- // fixing their own build failure).
901
939
  let cachedSessionId = null;
902
- if (runtime.capabilities.sessionResume && !agentId.startsWith('temp-')) {
903
- try {
904
- const sessionFile = safeJson(path.join(AGENTS_DIR, agentId, 'session.json'));
905
- if (sessionFile?.sessionId && sessionFile.savedAt) {
906
- const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
907
- const sameBranch = branchName && sessionFile.branch && sessionFile.branch === branchName;
908
- if (sessionAge < 2 * 60 * 60 * 1000 && sameBranch) {
909
- cachedSessionId = sessionFile.sessionId;
910
- log('info', `Resuming session ${sessionFile.sessionId} for ${agentId} on branch ${branchName} (age: ${Math.round(sessionAge / 60000)}min)`);
911
- }
912
- }
913
- } catch (e) { log('warn', 'session resume lookup: ' + e.message); }
940
+ if (runtime && typeof runtime.getResumeSessionId === 'function') {
941
+ cachedSessionId = runtime.getResumeSessionId({
942
+ agentId,
943
+ branchName,
944
+ agentsDir: AGENTS_DIR,
945
+ logger: _runtimeLogger(),
946
+ });
914
947
  }
915
948
 
916
- // Build the spawn-agent.js flag bag. spawn-agent parses the named flags
917
- // back into an opts object and calls runtime.buildArgs(opts) — so the
918
- // adapter is the single source of truth for the actual CLI args.
919
949
  const args = _buildAgentSpawnFlags(runtime, {
920
950
  model: resolvedModel,
921
951
  maxTurns: _maxTurnsForType(type, engineConfig),
@@ -941,6 +971,7 @@ async function spawnAgent(dispatchItem, config) {
941
971
 
942
972
  // Spawn the claude process
943
973
  const childEnv = shared.cleanChildEnv();
974
+ if (completionReportPath) childEnv.MINIONS_COMPLETION_REPORT = completionReportPath;
944
975
 
945
976
  // Inject cached ADO token so agents skip re-authentication (#998)
946
977
  // getAdoToken() returns cached token (30-min TTL) or null — never blocks on browser auth
@@ -1014,6 +1045,7 @@ async function spawnAgent(dispatchItem, config) {
1014
1045
  const MAX_OUTPUT = 1024 * 1024; // 1MB
1015
1046
  let stdout = '';
1016
1047
  let stderr = '';
1048
+ const sessionCaptureState = { sessionLineBuffer: '' };
1017
1049
  let _trustCheckDone = false;
1018
1050
  const _spawnTime = Date.now();
1019
1051
 
@@ -1025,8 +1057,10 @@ async function spawnAgent(dispatchItem, config) {
1025
1057
 
1026
1058
  // Trust gate detection: check first 30s of output for trust/permission prompts
1027
1059
  if (!_trustCheckDone && (Date.now() - _spawnTime) <= 30000) {
1028
- const lower = chunk.toLowerCase();
1029
- if (/\b(trust this|do you trust|allow access|grant permission|approve tools?|permission prompt)\b/.test(lower)) {
1060
+ const blockedOnPermission = runtime && typeof runtime.detectPermissionGate === 'function'
1061
+ ? runtime.detectPermissionGate(chunk)
1062
+ : false;
1063
+ if (blockedOnPermission) {
1030
1064
  _trustCheckDone = true;
1031
1065
  updateAgentStatus(id, AGENT_STATUS.TRUST_BLOCKED, 'Agent appears to be waiting for trust approval');
1032
1066
  log('warn', `Trust gate detected for ${agentId} (${id}) — agent may be blocked on a permission prompt`);
@@ -1035,23 +1069,10 @@ async function spawnAgent(dispatchItem, config) {
1035
1069
  _trustCheckDone = true; // past 30s window
1036
1070
  }
1037
1071
 
1038
- // Capture sessionId early for mid-session steering
1072
+ // Capture sessionId early for mid-session steering. Claude emits session_id;
1073
+ // Copilot emits sessionId, so use the runtime-neutral steering helper.
1039
1074
  const procInfo = activeProcesses.get(id);
1040
- if (procInfo && !procInfo.sessionId && chunk.includes('session_id')) {
1041
- try {
1042
- for (const line of chunk.split('\n')) {
1043
- if (!line.trim() || !line.startsWith('{')) continue;
1044
- const obj = JSON.parse(line);
1045
- if (obj.session_id) {
1046
- procInfo.sessionId = obj.session_id;
1047
- safeWrite(path.join(AGENTS_DIR, agentId, 'session.json'), {
1048
- sessionId: obj.session_id, dispatchId: id, savedAt: ts(), branch: branchName
1049
- });
1050
- break;
1051
- }
1052
- }
1053
- } catch { /* JSON parse — output may not be valid JSON */ }
1054
- }
1075
+ captureSessionIdFromStdoutChunk(agentId, id, branchName, runtime, procInfo, chunk, sessionCaptureState);
1055
1076
 
1056
1077
  ackPendingSteeringFiles(agentId, procInfo, chunk);
1057
1078
  });
@@ -1112,8 +1133,12 @@ async function spawnAgent(dispatchItem, config) {
1112
1133
  }
1113
1134
  }
1114
1135
 
1115
- // Write new prompt with steering message
1116
- const steerPrompt = `Message from your human teammate:\n\n${steerMsg}\n\nRespond to this, then continue working on your current task.`;
1136
+ // Write new prompt with all unACKed steering messages. This keeps delivery
1137
+ // durable if the killed process had older pending messages that never
1138
+ // produced processing evidence before the resume.
1139
+ const pendingForResume = steering.buildPendingSteeringPrompt(agentId);
1140
+ const steerPromptBody = pendingForResume.prompt || steerMsg;
1141
+ const steerPrompt = `Message from your human teammate:\n\n${steerPromptBody}\n\nRespond to this, then continue working on your current task.`;
1117
1142
  const steerPromptPath = path.join(ENGINE_DIR, 'tmp', `prompt-steer-${safeId}.md`);
1118
1143
  try { safeWrite(steerPromptPath, steerPrompt); } catch (e) {
1119
1144
  log('warn', `Steering: failed to write prompt for ${agentId}: ${e.message}`);
@@ -1123,19 +1148,6 @@ async function spawnAgent(dispatchItem, config) {
1123
1148
  return;
1124
1149
  }
1125
1150
 
1126
- // Steering resume — gated on `runtime.capabilities.sessionResume`. If the
1127
- // runtime can't resume sessions, fail fast: nothing to spawn here.
1128
- if (!runtime.capabilities.sessionResume) {
1129
- log('warn', `Steering: runtime ${runtime.name} does not support session resume — skipping for ${agentId}`);
1130
- try { fs.appendFileSync(liveOutputPath, `\n[steering-failed] Runtime ${runtime.name} does not support session resume. Message was: ${steerMsg}\n`); } catch {}
1131
- try { fs.unlinkSync(steerPromptPath); } catch {}
1132
- activeProcesses.delete(id);
1133
- completeDispatch(id, DISPATCH_RESULT.SUCCESS, 'Steering not supported by runtime', '', { processWorkItemFailure: false });
1134
- return;
1135
- }
1136
-
1137
- // Reuse the same flag-builder used by the main spawn so capability gates
1138
- // and runtime-specific opts stay consistent across the two paths.
1139
1151
  const resumeArgs = _buildAgentSpawnFlags(runtime, {
1140
1152
  model: resolvedModel,
1141
1153
  maxTurns: engineConfig?.maxTurns || ENGINE_DEFAULTS.maxTurns,
@@ -1149,9 +1161,18 @@ async function spawnAgent(dispatchItem, config) {
1149
1161
  suppressAgentsMd: engineConfig?.copilotSuppressAgentsMd,
1150
1162
  reasoningSummaries: engineConfig?.copilotReasoningSummaries,
1151
1163
  });
1164
+ if (!resumeArgs.includes('--resume')) {
1165
+ log('warn', `Steering: runtime ${runtime.name} did not accept session resume — skipping for ${agentId}`);
1166
+ try { fs.appendFileSync(liveOutputPath, `\n[steering-failed] Runtime ${runtime.name} does not support session resume. Message was: ${steerMsg}\n`); } catch {}
1167
+ try { fs.unlinkSync(steerPromptPath); } catch {}
1168
+ activeProcesses.delete(id);
1169
+ completeDispatch(id, DISPATCH_RESULT.SUCCESS, 'Steering not supported by runtime', '', { processWorkItemFailure: false });
1170
+ return;
1171
+ }
1152
1172
 
1153
1173
  const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
1154
1174
  const childEnv = shared.cleanChildEnv();
1175
+ if (completionReportPath) childEnv.MINIONS_COMPLETION_REPORT = completionReportPath;
1155
1176
  // Inject cached ADO token for steering session too (#998)
1156
1177
  try {
1157
1178
  const adoToken = await getAdoToken();
@@ -1184,19 +1205,26 @@ async function spawnAgent(dispatchItem, config) {
1184
1205
  startedAt: procInfo.startedAt,
1185
1206
  sessionId: steerSessionId,
1186
1207
  lastRealOutputAt: Date.now(),
1187
- _pendingSteeringFiles: steerEntry ? [steerEntry] : (procInfo._pendingSteeringFiles || []),
1208
+ _pendingSteeringFiles: mergePendingSteeringEntries(
1209
+ procInfo._pendingSteeringFiles,
1210
+ pendingForResume.entries,
1211
+ steerEntry,
1212
+ ),
1188
1213
  });
1189
1214
 
1190
1215
  // Reset output buffers so post-completion parsing only sees the resumed session
1191
1216
  stdout = '';
1192
1217
  stderr = '';
1218
+ sessionCaptureState.sessionLineBuffer = '';
1193
1219
  // Re-wire stdout/stderr handlers (same as original)
1194
1220
  resumeProc.stdout.on('data', (data) => {
1195
1221
  const chunk = data.toString();
1196
1222
  realActivityMap.set(id, Date.now());
1197
1223
  if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
1198
1224
  try { fs.appendFileSync(liveOutputPath, chunk); } catch { /* optional */ }
1199
- ackPendingSteeringFiles(agentId, activeProcesses.get(id), chunk);
1225
+ const resumeInfo = activeProcesses.get(id);
1226
+ captureSessionIdFromStdoutChunk(agentId, id, branchName, runtime, resumeInfo, chunk, sessionCaptureState);
1227
+ ackPendingSteeringFiles(agentId, resumeInfo, chunk);
1200
1228
  });
1201
1229
  resumeProc.stderr.on('data', (data) => {
1202
1230
  const chunk = data.toString();
@@ -1284,11 +1312,13 @@ async function spawnAgent(dispatchItem, config) {
1284
1312
  safeWrite(latestPath, outputContent); // overwrite latest for dashboard compat
1285
1313
 
1286
1314
  // Classify failure for non-zero exits
1287
- const failureClass = code !== 0 ? classifyFailure(code, stdout, stderr) : undefined;
1315
+ const failureInfo = code !== 0 ? _classifyAgentFailure(runtime, code, stdout, stderr) : {};
1316
+ const failureClass = failureInfo.failureClass;
1288
1317
 
1289
- // Detect configuration errors (e.g. Claude CLI not found) — fail immediately with clear message
1318
+ // Detect configuration errors (e.g. missing runtime CLI) — fail immediately with clear message
1290
1319
  if (code === 78) {
1291
- const errMsg = stderr.includes('claude-code') ? stderr.trim() : 'Configuration error — Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code';
1320
+ const runtimeHint = runtime.installHint ? ` ${runtime.installHint}` : '';
1321
+ const errMsg = stderr.trim() || `Configuration error — ${runtimeName} runtime unavailable.${runtimeHint}`;
1292
1322
  log('error', `Agent ${agentId} (${id}) failed: ${errMsg} [${failureClass}]`);
1293
1323
  completeDispatch(id, DISPATCH_RESULT.ERROR, errMsg, '', { failureClass });
1294
1324
  try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
@@ -1298,18 +1328,27 @@ async function spawnAgent(dispatchItem, config) {
1298
1328
  }
1299
1329
 
1300
1330
  // Parse output and run all post-completion hooks
1301
- const { resultSummary, autoRecovered, completionContractFailure } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
1331
+ const { resultSummary, autoRecovered, completionContractFailure, structuredCompletion, agentReportedFailure, agentRetryable } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
1332
+ const retryableDecision = typeof agentRetryable === 'boolean' ? agentRetryable : failureInfo.retryable;
1302
1333
 
1303
1334
  // Move from active to completed in dispatch (single source of truth for agent status)
1304
1335
  // autoRecovered: agent failed after creating PRs — treat as success
1305
- const effectiveResult = completionContractFailure ? DISPATCH_RESULT.ERROR : ((code === 0 || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
1306
- const completeOpts = completionContractFailure
1336
+ const hardContractFail = completionContractFailure?.severity === 'hard'
1337
+ || completionContractFailure?.nonTerminal === true;
1338
+ const effectiveResult = hardContractFail ? DISPATCH_RESULT.ERROR : (((code === 0 && !agentReportedFailure) || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
1339
+ const completeOpts = hardContractFail
1307
1340
  ? { processWorkItemFailure: false }
1308
- : (effectiveResult === DISPATCH_RESULT.ERROR && failureClass ? { failureClass } : {});
1341
+ : (effectiveResult === DISPATCH_RESULT.ERROR ? {
1342
+ ...(failureClass ? { failureClass } : {}),
1343
+ ...(typeof retryableDecision === 'boolean' ? { agentRetryable: retryableDecision } : {}),
1344
+ ...(structuredCompletion?.failure_class ? { failureClass: structuredCompletion.failure_class } : {}),
1345
+ } : {});
1309
1346
  // Extract last 5 non-empty stderr lines as error context when exit code is non-zero
1310
1347
  let errorReason = '';
1311
- if (completionContractFailure) {
1348
+ if (hardContractFail) {
1312
1349
  errorReason = completionContractFailure.reason || 'PR attachment contract failed';
1350
+ } else if (agentReportedFailure && structuredCompletion?.summary) {
1351
+ errorReason = String(structuredCompletion.summary).slice(0, 300);
1313
1352
  } else if (effectiveResult === DISPATCH_RESULT.ERROR) {
1314
1353
  errorReason = stderr.split('\n').filter(l => l.trim()).slice(-5).join(' | ').trim().slice(0, 300);
1315
1354
  // W-mo3zul9pirjb — when claude CLI exits in <3s with code 1 and no output (the
@@ -2177,24 +2216,10 @@ async function discoverFromPrs(config, project) {
2177
2216
  // The poller holds reviewStatus at 'waiting' until the reviewer acts on the new code.
2178
2217
  const awaitingReReview = reviewStatus === 'waiting' && !!pr.minionsReview?.fixedAt;
2179
2218
 
2180
- // Review→fix cycle cap — stop review/fix dispatch after N iterations, but allow build fixes and conflict fixes
2181
- const evalMax = config.engine?.evalMaxIterations ?? ENGINE_DEFAULTS.evalMaxIterations;
2182
- const evalCycles = pr._reviewFixCycles || 0;
2183
- const evalEscalated = evalCycles >= evalMax;
2184
- if (evalEscalated && !pr._evalEscalated) {
2185
- try {
2186
- mutatePullRequests(projectPrPath(project), prs => {
2187
- const target = shared.findPrRecord(prs, pr, project);
2188
- if (target) target._evalEscalated = true;
2189
- });
2190
- } catch (e) { log('warn', 'mark eval escalated: ' + e.message); }
2191
- log('warn', `PR ${pr.id}: review→fix escalated after ${evalCycles} cycles — suspending review/re-review and review-fix dispatch; build/conflict fixes may continue`);
2192
- }
2193
-
2194
2219
  // PRs needing review: evalLoop gates the entire review+fix cycle; pollEnabled ensures reviewStatus is fresh
2195
2220
  const reviewEnabled = evalLoopEnabled && pollEnabled;
2196
2221
  const alreadyReviewed = pr.lastReviewedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.lastReviewedAt);
2197
- const needsReview = reviewEnabled && reviewStatus === 'pending' && !alreadyReviewed && !evalEscalated;
2222
+ const needsReview = reviewEnabled && reviewStatus === 'pending' && !alreadyReviewed;
2198
2223
  if (needsReview) {
2199
2224
  const key = `review-${project?.name || 'default'}-${prDisplayId}`;
2200
2225
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
@@ -2266,6 +2291,7 @@ async function discoverFromPrs(config, project) {
2266
2291
  const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
2267
2292
  if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
2268
2293
  }
2294
+ reviewNote = `New PR comments were observed. Read the full PR thread, decide whether the comments require code/documentation/test changes, make only necessary changes, and push if action is needed.\n\n${reviewNote}`;
2269
2295
 
2270
2296
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2271
2297
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
@@ -2280,7 +2306,7 @@ async function discoverFromPrs(config, project) {
2280
2306
  const fixedAfterReview = !!(pr.minionsReview?.fixedAt &&
2281
2307
  (!pr.lastReviewedAt || pr.minionsReview.fixedAt > pr.lastReviewedAt));
2282
2308
  const needsReReview = reviewEnabled && reviewStatus === 'waiting' &&
2283
- fixedAfterReview && !evalEscalated && !fixDispatched;
2309
+ fixedAfterReview && !fixDispatched;
2284
2310
  if (needsReReview) {
2285
2311
  const key = `rereview-${project?.name || 'default'}-${prDisplayId}`;
2286
2312
  // Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
@@ -2320,7 +2346,7 @@ async function discoverFromPrs(config, project) {
2320
2346
 
2321
2347
  // PRs with changes requested → route back to author for fix
2322
2348
  // Gate on evalLoopEnabled — the review→fix cycle is the eval loop
2323
- if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated && !fixDispatched) {
2349
+ if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !fixDispatched) {
2324
2350
  const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
2325
2351
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2326
2352
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
@@ -2334,13 +2360,6 @@ async function discoverFromPrs(config, project) {
2334
2360
  }, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2335
2361
  if (item) {
2336
2362
  newWork.push(item); setCooldown(key); fixDispatched = true;
2337
- // Increment review→fix cycle counter
2338
- try {
2339
- mutatePullRequests(projectPrPath(project), prs => {
2340
- const target = shared.findPrRecord(prs, pr, project);
2341
- if (target) target._reviewFixCycles = (target._reviewFixCycles || 0) + 1;
2342
- });
2343
- } catch (e) { log('warn', 'increment review-fix cycles: ' + e.message); }
2344
2363
  }
2345
2364
  }
2346
2365
 
@@ -2354,23 +2373,6 @@ async function discoverFromPrs(config, project) {
2354
2373
  const autoFixBuilds = config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds;
2355
2374
  const fixThrottled = isAdoProject ? isAdoThrottled() : isGhThrottled();
2356
2375
  if (autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing') {
2357
- const maxBuildFix = config.engine?.maxBuildFixAttempts ?? ENGINE_DEFAULTS.maxBuildFixAttempts;
2358
-
2359
- // Check if max retry cap reached — escalate to human instead of dispatching another fix
2360
- if ((pr.buildFixAttempts || 0) >= maxBuildFix) {
2361
- if (!pr.buildFixEscalated) {
2362
- try {
2363
- const prPath = projectPrPath(project);
2364
- mutatePullRequests(prPath, prs => {
2365
- const target = shared.findPrRecord(prs, pr, project);
2366
- if (target) target.buildFixEscalated = true;
2367
- });
2368
- } catch (e) { log('warn', 'mark build fix escalated: ' + e.message); }
2369
- log('warn', `PR ${pr.id}: build fix escalated after ${pr.buildFixAttempts} attempts — suspending auto-dispatch`);
2370
- }
2371
- continue;
2372
- }
2373
-
2374
2376
  const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
2375
2377
  if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2376
2378
 
@@ -2423,10 +2425,11 @@ async function discoverFromPrs(config, project) {
2423
2425
  const prBranch = ensurePrBranchForDispatch(project, pr, 'build-fix');
2424
2426
  if (!prBranch) continue;
2425
2427
 
2426
- let reviewNote = `Build is failing: ${pr.buildFailReason || 'Check CI pipeline for details'}. Fix the build errors and push.`;
2427
- if (pr.buildErrorLog) {
2428
- reviewNote += `\n\n## Build Error Log\n\n\`\`\`\n${pr.buildErrorLog}\n\`\`\``;
2429
- }
2428
+ const reviewNote = [
2429
+ `Build is failing: ${pr.buildFailReason || 'check CI pipeline for details'}.`,
2430
+ 'Inspect the live PR checks/build logs yourself, decide the root cause, fix it, run the relevant local validation, and push.',
2431
+ pr.url ? `PR URL: ${pr.url}` : '',
2432
+ ].filter(Boolean).join('\n');
2430
2433
 
2431
2434
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2432
2435
  pr_id: pr.id, pr_branch: prBranch,
@@ -2434,17 +2437,15 @@ async function discoverFromPrs(config, project) {
2434
2437
  }, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2435
2438
  if (item) {
2436
2439
  newWork.push(item); setCooldown(key); fixDispatched = true;
2437
- // Increment build fix attempts counter
2438
2440
  try {
2439
2441
  const prPath = projectPrPath(project);
2440
2442
  mutatePullRequests(prPath, prs => {
2441
2443
  const target = shared.findPrRecord(prs, pr, project);
2442
2444
  if (target) {
2443
- target.buildFixAttempts = (target.buildFixAttempts || 0) + 1;
2444
2445
  target._buildFixPushedAt = ts();
2445
2446
  }
2446
2447
  });
2447
- } catch (e) { log('warn', 'increment build fix attempts: ' + e.message); }
2448
+ } catch (e) { log('warn', 'mark build fix dispatched: ' + e.message); }
2448
2449
  }
2449
2450
 
2450
2451
  if (pr.agent && !pr._buildFailNotified) {
@@ -2501,7 +2502,7 @@ async function discoverFromPrs(config, project) {
2501
2502
  if (!prBranch) continue;
2502
2503
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2503
2504
  pr_id: pr.id, pr_branch: prBranch,
2504
- review_note: `This PR has merge conflicts with the target branch. Resolve the conflicts:\n\n1. Pull latest from main/master\n2. Resolve all conflicts (prefer PR branch changes unless main has critical fixes)\n3. Build and test after resolving\n4. Push the resolved branch`,
2505
+ review_note: `This PR has merge conflicts with the target branch. Inspect the live PR and repository history, choose the safest merge/rebase/update strategy, resolve all conflicts, validate the result, and push the branch.`,
2505
2506
  }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2506
2507
  if (item) {
2507
2508
  newWork.push(item);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1650",
3
+ "version": "0.1.1652",
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"
package/playbooks/fix.md CHANGED
@@ -80,7 +80,7 @@ Your task is complete when each review finding has either been fixed or answered
80
80
 
81
81
  ## Completion
82
82
 
83
- After finishing, output a structured completion block so the engine can parse your results:
83
+ After finishing, write the JSON completion report described in the shared rules. Also output this structured completion block as a compatibility fallback:
84
84
 
85
85
  ```completion
86
86
  status: done | partial | failed
@@ -91,4 +91,4 @@ failure_class: N/A
91
91
  pending: <any remaining work, or none>
92
92
  ```
93
93
 
94
- Replace the values with your actual results. This block MUST appear in your final output.
94
+ Replace the values with your actual results.