@yemi33/minions 0.1.727 → 0.1.728

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,11 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.727 (2026-04-09)
3
+ ## 0.1.728 (2026-04-09)
4
4
 
5
5
  ### Features
6
+ - replace raw status strings with WI_STATUS constants in dashboard.js
7
+ - Soften playbook CAPS emphasis and add context-awareness guidance
8
+ - Add structured completion protocol to PR-producing playbooks
9
+ - Create engine/recovery.js with recovery recipes
10
+ - Add FAILURE_CLASS enum and classifyFailure() function
11
+ - Add AGENT_STATUS enum and worker-state tracking
12
+ - Validate required template variables before dispatch
6
13
  - make post-verify plan archiving opt-in via autoArchive config
7
14
 
8
15
  ### Fixes
16
+ - repair 3 pre-existing test failures in source-pattern tests
17
+ - merge master, sync dashboard-build jsFiles, bump HTML size limit
18
+ - resolve 4 pre-existing test failures breaking CI
9
19
  - reject JSON/UUID fragments as PR titles, self-heal from platform
10
20
 
11
21
  ## 0.1.725 (2026-04-09)
@@ -40,7 +40,7 @@ function wiRow(item) {
40
40
  : (item.branchStrategy === 'shared-branch' && item.status === 'done')
41
41
  ? '<span style="font-size:9px;color:var(--muted)" title="Part of shared branch — aggregate PR created at verify stage">shared branch</span>'
42
42
  : '<span style="color:var(--muted)">—</span>';
43
- return '<tr style="cursor:pointer" onclick="openWorkItemDetail(\'' + escHtml(item.id) + '\')">' +
43
+ return '<tr style="cursor:pointer" onclick="if(window.getSelection().toString().length>0)return;openWorkItemDetail(\'' + escHtml(item.id) + '\')">' +
44
44
  '<td><span class="pr-id">' + escHtml(item.id || '') + '</span></td>' +
45
45
  '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml((item.title || '').slice(0, 200)) + '">' + escHtml(item.title || '') + '</td>' +
46
46
  '<td><span style="font-size:10px;color:var(--muted)">' + escHtml(item._source || '') + '</span>' +
@@ -32,7 +32,7 @@ function buildDashboardHtml() {
32
32
  'utils', 'state', 'detail-panel', 'live-stream',
33
33
  'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
34
34
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
35
- 'render-other', 'render-schedules', 'render-meetings', 'render-pinned',
35
+ 'render-other', 'render-schedules', 'render-pipelines', 'render-meetings', 'render-pinned',
36
36
  'command-parser', 'command-input', 'command-center', 'command-history',
37
37
  'modal', 'modal-qa', 'settings', 'refresh'
38
38
  ];
package/dashboard.js CHANGED
@@ -1055,12 +1055,12 @@ const server = http.createServer(async (req, res) => {
1055
1055
  const item = items.find(i => i.id === id);
1056
1056
  if (!item) return items;
1057
1057
  // Don't reset completed items unless explicitly forced
1058
- if ((item.status === 'done' || item.completedAt) && !body.force) {
1058
+ if ((DONE_STATUSES.has(item.status) || item.completedAt) && !body.force) {
1059
1059
  found = 'already_done';
1060
1060
  return items;
1061
1061
  }
1062
1062
  found = true;
1063
- item.status = 'pending';
1063
+ item.status = WI_STATUS.PENDING;
1064
1064
  item._retryCount = 0; // Reset retry counter on manual retry
1065
1065
  delete item.dispatched_at;
1066
1066
  delete item.dispatched_to;
@@ -1224,7 +1224,7 @@ const server = http.createServer(async (req, res) => {
1224
1224
  let wiPath;
1225
1225
  if (body.project) {
1226
1226
  // Write to project-specific queue
1227
- const targetProject = PROJECTS.find(p => p.name === body.project) || PROJECTS[0];
1227
+ const targetProject = PROJECTS.find(p => p.name === body.project) || (PROJECTS.length > 0 ? PROJECTS[0] : null);
1228
1228
  if (!targetProject) return jsonReply(res, 400, { error: 'No projects configured' });
1229
1229
  wiPath = shared.projectWorkItemsPath(targetProject);
1230
1230
  } else {
@@ -1324,7 +1324,7 @@ const server = http.createServer(async (req, res) => {
1324
1324
  const item = {
1325
1325
  id, title: body.title, type: 'plan',
1326
1326
  priority: body.priority || 'high', description: body.description || '',
1327
- status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard',
1327
+ status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
1328
1328
  branchStrategy: body.branch_strategy || 'parallel',
1329
1329
  };
1330
1330
  if (body.project) item.project = body.project;
@@ -1402,7 +1402,7 @@ const server = http.createServer(async (req, res) => {
1402
1402
  try {
1403
1403
  mutateWorkItems(wiPath, items => {
1404
1404
  const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
1405
- if (wi && wi.status === 'pending') {
1405
+ if (wi && wi.status === WI_STATUS.PENDING) {
1406
1406
  if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
1407
1407
  if (body.description !== undefined) wi.description = body.description;
1408
1408
  if (body.priority !== undefined) wi.priority = body.priority;
@@ -1910,7 +1910,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
1910
1910
  // Load work items to check for completed plan-to-prd conversions
1911
1911
  const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
1912
1912
  const completedPrdFiles = new Set(
1913
- centralWi.filter(w => w.type === 'plan-to-prd' && w.status === 'done' && w.planFile)
1913
+ centralWi.filter(w => w.type === 'plan-to-prd' && DONE_STATUSES.has(w.status) && w.planFile)
1914
1914
  .map(w => w.planFile)
1915
1915
  );
1916
1916
  const plans = [];
@@ -2239,14 +2239,14 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2239
2239
  mutateWorkItems(wiPath, items => {
2240
2240
  // Dedup: check if already queued
2241
2241
  const alreadyQueued = items.find(w =>
2242
- w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === 'pending' || w.status === 'dispatched')
2242
+ w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.DISPATCHED)
2243
2243
  );
2244
2244
  if (alreadyQueued) { alreadyQueuedId = alreadyQueued.id; return; }
2245
2245
  items.push({
2246
2246
  id, title: `Regenerate PRD: ${plan.plan_summary || plan.source_plan}`,
2247
2247
  type: 'plan-to-prd', priority: 'high',
2248
2248
  description: `Plan file: plans/${plan.source_plan}\nTarget PRD filename: ${body.file}\nRegeneration requested by user after plan revision.${completedContext}`,
2249
- status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:regenerate',
2249
+ status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard:regenerate',
2250
2250
  project: plan.project || '', planFile: plan.source_plan,
2251
2251
  _targetPrdFile: body.file,
2252
2252
  });
@@ -2273,13 +2273,13 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2273
2273
  mutateJsonFileLocked(centralPath, (items) => {
2274
2274
  if (!Array.isArray(items)) items = [];
2275
2275
  // Only block if actively pending/dispatched — allow re-execute after completion
2276
- const existing = items.find(w => w.type === 'plan-to-prd' && w.planFile === body.file && (w.status === 'pending' || w.status === 'dispatched'));
2276
+ const existing = items.find(w => w.type === 'plan-to-prd' && w.planFile === body.file && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.DISPATCHED));
2277
2277
  if (existing) { existingId = existing.id; return items; }
2278
2278
  items.push({
2279
2279
  id, title: 'Convert plan to PRD: ' + body.file.replace('.md', ''),
2280
2280
  type: 'plan-to-prd', priority: 'high',
2281
2281
  description: 'Plan file: plans/' + body.file,
2282
- status: 'pending', created: new Date().toISOString(),
2282
+ status: WI_STATUS.PENDING, created: new Date().toISOString(),
2283
2283
  createdBy: 'dashboard:execute', project: body.project || '',
2284
2284
  planFile: body.file,
2285
2285
  });
@@ -2334,7 +2334,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2334
2334
  for (const w of items) {
2335
2335
  if (w.sourcePlan === body.source) {
2336
2336
  materializedPlanItemIds.add(w.id);
2337
- if (w.status === 'pending' || w.status === 'failed') {
2337
+ if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.FAILED) {
2338
2338
  // Delete — will re-materialize on next tick with updated plan data
2339
2339
  reset++;
2340
2340
  deletedItemIds.push(w.id);
@@ -2419,8 +2419,8 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2419
2419
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
2420
2420
  mutateWorkItems(centralPath, items => {
2421
2421
  for (const w of items) {
2422
- if (w.type === 'plan-to-prd' && w.status === 'done' && w.planFile === prdSourcePlan) {
2423
- w.status = 'cancelled';
2422
+ if (w.type === 'plan-to-prd' && DONE_STATUSES.has(w.status) && w.planFile === prdSourcePlan) {
2423
+ w.status = WI_STATUS.CANCELLED;
2424
2424
  w._cancelledBy = 'prd-deleted';
2425
2425
  }
2426
2426
  }
@@ -2532,7 +2532,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2532
2532
  id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
2533
2533
  type: 'plan-to-prd', priority: 'high',
2534
2534
  description: 'Revision requested on plan file: ' + (body.file.endsWith('.json') ? 'prd/' : 'plans/') + body.file + '\n\nFeedback:\n' + body.feedback + '\n\nRevise the plan to address this feedback. Read the existing plan, apply the feedback, and overwrite the file with the updated version. Set status back to "awaiting-approval".',
2535
- status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:revision',
2535
+ status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard:revision',
2536
2536
  project: plan.project || '',
2537
2537
  planFile: body.file,
2538
2538
  });
@@ -2631,7 +2631,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2631
2631
  const filtered = [];
2632
2632
  for (const w of items) {
2633
2633
  if (w.sourcePlan === body.source) {
2634
- if (w.status === 'pending' || w.status === 'failed') {
2634
+ if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.FAILED) {
2635
2635
  reset++;
2636
2636
  deletedItemIds.push(w.id);
2637
2637
  } else {
@@ -2662,7 +2662,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2662
2662
  type: 'plan-to-prd',
2663
2663
  priority: 'high',
2664
2664
  description: `The source plan \`${sourcePlanFile}\` has been revised. Convert it into a fresh PRD JSON.\n\nRevision instruction: ${body.instruction}\n\nRead the revised plan, generate updated PRD items (missing_features), and write to \`prd/${body.source}\`. Set status to "approved". Include \`"source_plan": "${sourcePlanFile}"\` in the JSON root.\n\nPreserve items that are already done (status "implemented" or "complete"). Reset or replace items that were pending/failed.`,
2665
- status: 'pending',
2665
+ status: WI_STATUS.PENDING,
2666
2666
  created: new Date().toISOString(),
2667
2667
  createdBy: 'dashboard:revise-and-regenerate',
2668
2668
  project: prd.project || '',
@@ -11,7 +11,7 @@ const { setCooldownFailure } = require('./cooldown');
11
11
 
12
12
  const { safeJson, safeWrite, safeReadDir, mutateJsonFileLocked, mutateWorkItems,
13
13
  mutatePullRequests, getProjects, projectWorkItemsPath, projectPrPath, log, ts, dateStamp,
14
- WI_STATUS, DISPATCH_RESULT, ENGINE_DEFAULTS } = shared;
14
+ WI_STATUS, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS } = shared;
15
15
  const { getConfig, getDispatch, DISPATCH_PATH, INBOX_DIR } = queries;
16
16
 
17
17
  const MINIONS_DIR = shared.MINIONS_DIR;
@@ -19,6 +19,8 @@ const MINIONS_DIR = shared.MINIONS_DIR;
19
19
  // Lazy require to break circular dependency with engine.js
20
20
  let _lifecycle = null;
21
21
  function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); return _lifecycle; }
22
+ let _recovery = null;
23
+ function recovery() { if (!_recovery) _recovery = require('./recovery'); return _recovery; }
22
24
 
23
25
  // ─── Dispatch Mutation ───────────────────────────────────────────────────────
24
26
 
@@ -69,7 +71,12 @@ function addToDispatch(item) {
69
71
 
70
72
  // ─── Retryable Failure Classification ────────────────────────────────────────
71
73
 
72
- function isRetryableFailureReason(reason = '') {
74
+ function isRetryableFailureReason(reason = '', failureClass = '') {
75
+ // FAILURE_CLASS-based classification takes precedence when available
76
+ if (failureClass) {
77
+ const neverRetry = new Set([FAILURE_CLASS.CONFIG_ERROR, FAILURE_CLASS.PERMISSION_BLOCKED]);
78
+ if (neverRetry.has(failureClass)) return false;
79
+ }
73
80
  const r = String(reason || '').toLowerCase();
74
81
  if (!r) return true; // unknown error from tool exit — keep retryable
75
82
  const nonRetryable = [
@@ -89,7 +96,7 @@ function isRetryableFailureReason(reason = '') {
89
96
  // ─── Complete Dispatch ───────────────────────────────────────────────────────
90
97
 
91
98
  function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', resultSummary = '', opts = {}) {
92
- const { processWorkItemFailure = true } = opts;
99
+ const { processWorkItemFailure = true, failureClass } = opts;
93
100
  let item = null;
94
101
 
95
102
  mutateDispatch((dispatch) => {
@@ -108,6 +115,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
108
115
  item.result = result;
109
116
  if (reason) item.reason = reason;
110
117
  if (resultSummary) item.resultSummary = resultSummary;
118
+ if (failureClass && result === DISPATCH_RESULT.ERROR) item.failureClass = failureClass;
111
119
  delete item.prompt;
112
120
  if (dispatch.completed.length >= 100) {
113
121
  dispatch.completed = dispatch.completed.slice(-100);
@@ -120,7 +128,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
120
128
  log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
121
129
 
122
130
  // Update source work item status on failure + auto-retry with backoff
123
- const retryableFailure = isRetryableFailureReason(reason);
131
+ const retryableFailure = isRetryableFailureReason(reason, failureClass);
124
132
  if (result === DISPATCH_RESULT.ERROR && item.meta?.dispatchKey && retryableFailure) setCooldownFailure(item.meta.dispatchKey);
125
133
 
126
134
  if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
@@ -130,8 +138,10 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
130
138
  if (wi) retries = wi._retryCount || 0;
131
139
  } catch (e) { log('warn', 'read retry count: ' + e.message); }
132
140
  const maxRetries = ENGINE_DEFAULTS.maxRetries;
133
- if (retryableFailure && retries < maxRetries) {
134
- log('info', `Dispatch error for ${item.meta.item.id} auto-retry ${retries + 1}/${maxRetries}`);
141
+ // Use per-class retry limits from recovery.js when failureClass is available
142
+ const classAllowsRetry = failureClass ? recovery().shouldRetry(failureClass, retries) : (retries < maxRetries);
143
+ if (retryableFailure && classAllowsRetry) {
144
+ log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/${maxRetries}${failureClass ? ' [' + failureClass + ']' : ''}`);
135
145
  lifecycle().updateWorkItemStatus(item.meta, WI_STATUS.PENDING, '');
136
146
  // Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
137
147
  if (item.meta?.dispatchKey) {
@@ -227,6 +237,29 @@ function writeInboxAlert(slug, content) {
227
237
  } catch (e) { log('warn', 'write inbox alert: ' + e.message); }
228
238
  }
229
239
 
240
+ // ─── Agent Worker Status ────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * Update the worker-state fields on an active dispatch entry.
244
+ * Uses mutateDispatch() for atomic read-modify-write.
245
+ * @param {string} dispatchId — dispatch entry ID
246
+ * @param {string} status — one of AGENT_STATUS values
247
+ * @param {string} [detail] — optional human-readable detail string
248
+ */
249
+ function updateAgentStatus(dispatchId, status, detail) {
250
+ if (!dispatchId || !status) return;
251
+ mutateDispatch((dispatch) => {
252
+ const entry = (dispatch.active || []).find(d => d.id === dispatchId)
253
+ || (dispatch.pending || []).find(d => d.id === dispatchId);
254
+ if (entry) {
255
+ entry.workerState = status;
256
+ entry.workerStateAt = ts();
257
+ if (detail !== undefined) entry.workerStateDetail = detail;
258
+ }
259
+ return dispatch;
260
+ });
261
+ }
262
+
230
263
  // ─── Exports ─────────────────────────────────────────────────────────────────
231
264
 
232
265
  module.exports = {
@@ -235,4 +268,5 @@ module.exports = {
235
268
  isRetryableFailureReason,
236
269
  completeDispatch,
237
270
  writeInboxAlert,
271
+ updateAgentStatus,
238
272
  };
@@ -9,7 +9,7 @@ const os = require('os');
9
9
  const shared = require('./shared');
10
10
  const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, projectPrPath, getPrLinks, addPrLink,
11
11
  log, ts, dateStamp, WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT,
12
- ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS } = shared;
12
+ ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { trackEngineUsage } = require('./llm');
14
14
  const queries = require('./queries');
15
15
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
@@ -1101,6 +1101,49 @@ function parseAgentOutput(stdout) {
1101
1101
  return { resultSummary: text, taskUsage: usage, sessionId, model };
1102
1102
  }
1103
1103
 
1104
+ /**
1105
+ * Parse structured completion block from agent output.
1106
+ * Agents produce a ```completion fenced block with key: value pairs.
1107
+ * Returns parsed object or null if not found / malformed.
1108
+ * If multiple blocks exist, the last one wins (agent may retry).
1109
+ */
1110
+ function parseStructuredCompletion(stdout) {
1111
+ if (!stdout || typeof stdout !== 'string') return null;
1112
+
1113
+ // Extract text from stream-json output if needed
1114
+ let text = stdout;
1115
+ if (stdout.includes('"type":')) {
1116
+ try {
1117
+ const parsed = shared.parseStreamJsonOutput(stdout);
1118
+ if (parsed.text) text = parsed.text;
1119
+ } catch {}
1120
+ }
1121
+
1122
+ // Find all ```completion blocks, take the last one
1123
+ const blockPattern = /```completion\s*\n([\s\S]*?)```/g;
1124
+ let lastMatch = null;
1125
+ let m;
1126
+ while ((m = blockPattern.exec(text)) !== null) {
1127
+ lastMatch = m[1];
1128
+ }
1129
+ if (!lastMatch) return null;
1130
+
1131
+ // Parse key: value pairs
1132
+ const result = {};
1133
+ const lines = lastMatch.trim().split('\n');
1134
+ for (const line of lines) {
1135
+ const colonIdx = line.indexOf(':');
1136
+ if (colonIdx < 1) continue;
1137
+ const key = line.slice(0, colonIdx).trim().toLowerCase();
1138
+ const value = line.slice(colonIdx + 1).trim();
1139
+ if (key && value) result[key] = value;
1140
+ }
1141
+
1142
+ // Must have at least the status field to be valid
1143
+ if (!result.status) return null;
1144
+ return result;
1145
+ }
1146
+
1104
1147
  /**
1105
1148
  * Handle decomposition result — parse sub-items from agent output and create child work items.
1106
1149
  * Called from runPostCompletionHooks when type === 'decompose'.
@@ -1191,6 +1234,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1191
1234
  const result = isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
1192
1235
  const { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout);
1193
1236
 
1237
+ // Try structured completion protocol first (```completion block from agent output)
1238
+ const structuredCompletion = parseStructuredCompletion(stdout);
1239
+ if (structuredCompletion) {
1240
+ log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}`);
1241
+ }
1242
+
1194
1243
  // Save session for potential resume on next dispatch
1195
1244
  if (isSuccess && sessionId && agentId && !agentId.startsWith('temp-')) {
1196
1245
  try {
@@ -1207,6 +1256,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1207
1256
  prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config) || 0;
1208
1257
  } catch (err) { log('warn', `PR sync from output: ${err.message}`); }
1209
1258
 
1259
+ // Structured completion may report PR even when regex didn't find it
1260
+ const scHasPr = structuredCompletion && structuredCompletion.pr && structuredCompletion.pr !== 'N/A';
1261
+ if (scHasPr && prsCreatedCount === 0) {
1262
+ log('info', `Structured completion reports PR (${structuredCompletion.pr}) but regex sync found none — PR may already be tracked`);
1263
+ }
1264
+
1210
1265
  // Auto-recover: if a failed implement/fix agent created PRs, it likely succeeded before being killed (e.g. heartbeat timeout)
1211
1266
  const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX;
1212
1267
  const autoRecovered = !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
@@ -1414,7 +1469,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1414
1469
  const metricsResult = isAutoRetry ? 'retry' : finalResult;
1415
1470
  updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
1416
1471
 
1417
- return { resultSummary, taskUsage, autoRecovered };
1472
+ return { resultSummary, taskUsage, autoRecovered, structuredCompletion };
1418
1473
  }
1419
1474
 
1420
1475
  // ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
@@ -1460,6 +1515,67 @@ function syncPrdFromPrs(config) {
1460
1515
  }
1461
1516
  }
1462
1517
 
1518
+ // ─── Failure Classification ─────────────────────────────────────────────────
1519
+
1520
+ /**
1521
+ * Classify an agent failure into a FAILURE_CLASS value based on exit code and output.
1522
+ * @param {number} code — process exit code
1523
+ * @param {string} stdout — agent stdout
1524
+ * @param {string} stderr — agent stderr
1525
+ * @returns {string} — one of FAILURE_CLASS values
1526
+ */
1527
+ function classifyFailure(code, stdout = '', stderr = '') {
1528
+ const out = String(stdout || '').toLowerCase();
1529
+ const err = String(stderr || '').toLowerCase();
1530
+ const combined = out + '\n' + err;
1531
+
1532
+ // Exit code 78 — configuration error (Claude CLI not found, bad setup)
1533
+ if (code === 78) return FAILURE_CLASS.CONFIG_ERROR;
1534
+
1535
+ // Permission / trust / auth failures
1536
+ if (/permission denied|access denied|unauthorized|403 forbidden|trust.*blocked|auth.*fail/i.test(combined)) {
1537
+ return FAILURE_CLASS.PERMISSION_BLOCKED;
1538
+ }
1539
+
1540
+ // Merge conflicts
1541
+ if (/merge conflict|conflict.*merge|automatic merge failed|fix conflicts/i.test(combined)) {
1542
+ return FAILURE_CLASS.MERGE_CONFLICT;
1543
+ }
1544
+
1545
+ // Context window / max turns exhausted
1546
+ if (/context window|max.*turns.*reached|token limit|conversation.*too long|context.*length.*exceeded/i.test(combined)) {
1547
+ return FAILURE_CLASS.OUT_OF_CONTEXT;
1548
+ }
1549
+
1550
+ // Network / API errors
1551
+ if (/rate limit|429|econnrefused|enotfound|etimedout|dns.*resolution|api.*error.*5\d\d|overloaded/i.test(combined)) {
1552
+ return FAILURE_CLASS.NETWORK_ERROR;
1553
+ }
1554
+
1555
+ // Build / test / lint failures
1556
+ if (/build failed|compilation error|test.*fail|lint.*error|type.*error|error ts\d+|syntax error|npm err/i.test(combined)) {
1557
+ return FAILURE_CLASS.BUILD_FAILURE;
1558
+ }
1559
+
1560
+ // Spawn errors — process crashed immediately or couldn't start
1561
+ if (/spawn.*error|enoent|cannot find module|cannot find.*binary/i.test(combined)) {
1562
+ return FAILURE_CLASS.SPAWN_ERROR;
1563
+ }
1564
+
1565
+ // Empty output — agent produced nothing useful
1566
+ if (!stdout || stdout.trim().length < 50) {
1567
+ return FAILURE_CLASS.EMPTY_OUTPUT;
1568
+ }
1569
+
1570
+ // Timeout is classified by the caller (timeout.js), not by output pattern
1571
+ // but check for timeout markers in output as fallback
1572
+ if (/timed.?out|timeout|heartbeat.*expired/i.test(combined)) {
1573
+ return FAILURE_CLASS.TIMEOUT;
1574
+ }
1575
+
1576
+ return FAILURE_CLASS.UNKNOWN;
1577
+ }
1578
+
1463
1579
  module.exports = {
1464
1580
  checkPlanCompletion,
1465
1581
  archivePlan,
@@ -1476,9 +1592,11 @@ module.exports = {
1476
1592
  createReviewFeedbackForAuthor,
1477
1593
  updateMetrics,
1478
1594
  parseAgentOutput,
1595
+ parseStructuredCompletion,
1479
1596
  runPostCompletionHooks,
1480
1597
  syncPrdFromPrs,
1481
1598
  resolveWorkItemPath,
1482
1599
  isItemCompleted,
1600
+ classifyFailure,
1483
1601
  };
1484
1602
 
@@ -206,6 +206,51 @@ function resolveTaskContext(item, config) {
206
206
  return resolved;
207
207
  }
208
208
 
209
+ // ─── Required Template Variables ────────────────────────────────────────────
210
+ // Defines which caller-provided variables are mandatory per playbook type.
211
+ // Base vars (from buildBaseVars) and project vars (injected by renderPlaybook)
212
+ // are always present and excluded. Variables in conditional blocks ({{#key}})
213
+ // are optional by design. Only variables that make the playbook non-functional
214
+ // when absent are listed here.
215
+
216
+ const PLAYBOOK_REQUIRED_VARS = {
217
+ 'implement': ['item_id', 'item_name', 'branch_name', 'project_path'],
218
+ 'implement-shared': ['item_id', 'item_name', 'branch_name', 'worktree_path'],
219
+ 'fix': ['pr_id', 'pr_branch'],
220
+ 'review': ['pr_id', 'pr_branch'],
221
+ 'build-and-test': ['pr_id', 'pr_branch', 'project_path'],
222
+ 'explore': ['task_description'],
223
+ 'ask': ['question'],
224
+ 'plan': ['task_description', 'project_path'],
225
+ 'plan-to-prd': ['plan_content', 'plan_file', 'prd_filename', 'project_path'],
226
+ 'decompose': ['item_id', 'item_description', 'project_path'],
227
+ 'verify': ['task_description'],
228
+ 'test': ['item_name'],
229
+ 'work-item': ['item_id', 'item_name'],
230
+ 'meeting-investigate': ['meeting_title', 'agenda'],
231
+ 'meeting-debate': ['meeting_title', 'agenda'],
232
+ 'meeting-conclude': ['meeting_title', 'agenda'],
233
+ };
234
+
235
+ /**
236
+ * Validate that all required template variables for a playbook type are present
237
+ * and non-empty in the provided vars object.
238
+ * @param {string} playbookName - The playbook type name
239
+ * @param {object} vars - The template variables object
240
+ * @returns {{ valid: boolean, missing: string[] }}
241
+ */
242
+ function validatePlaybookVars(playbookName, vars) {
243
+ const required = PLAYBOOK_REQUIRED_VARS[playbookName];
244
+ if (!required) return { valid: true, missing: [] };
245
+
246
+ const missing = required.filter(key => {
247
+ const val = vars[key];
248
+ return val === undefined || val === null || String(val).trim() === '';
249
+ });
250
+
251
+ return { valid: missing.length === 0, missing };
252
+ }
253
+
209
254
  // ─── Playbook Renderer ──────────────────────────────────────────────────────
210
255
 
211
256
  function renderPlaybook(type, vars) {
@@ -292,6 +337,13 @@ function renderPlaybook(type, vars) {
292
337
  };
293
338
  const allVars = { ...projectVars, ...vars };
294
339
 
340
+ // Validate required template variables before substitution
341
+ const validation = validatePlaybookVars(type, allVars);
342
+ if (!validation.valid) {
343
+ log('error', `Playbook "${type}": missing required template variables: ${validation.missing.join(', ')} — skipping render`);
344
+ return null;
345
+ }
346
+
295
347
  // Process conditional blocks: {{#key}}...{{/key}} — include block only if key is truthy
296
348
  content = content.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_, key, block) => {
297
349
  const val = allVars[key];
@@ -496,6 +548,8 @@ function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabe
496
548
 
497
549
  module.exports = {
498
550
  renderPlaybook,
551
+ validatePlaybookVars,
552
+ PLAYBOOK_REQUIRED_VARS,
499
553
  buildSystemPrompt,
500
554
  buildAgentContext,
501
555
  selectPlaybook,
@@ -0,0 +1,112 @@
1
+ /**
2
+ * engine/recovery.js — Recovery recipes for classified agent failures.
3
+ * Maps FAILURE_CLASS values to per-class retry limits and escalation policies.
4
+ * Zero external dependencies — uses only Node.js built-ins and imports from shared.js.
5
+ */
6
+
7
+ const { FAILURE_CLASS, ESCALATION_POLICY, ENGINE_DEFAULTS } = require('./shared');
8
+
9
+ // ─── Recovery Recipes ───────────────────────────────────────────────────────
10
+
11
+ /**
12
+ * Each recipe defines:
13
+ * maxAttempts — max retries for this failure class (0 = never retry)
14
+ * escalation — ESCALATION_POLICY value
15
+ * freshSession — whether to clear session.json before retry
16
+ * description — human-readable explanation for logs/dashboard
17
+ */
18
+ const RECOVERY_RECIPES = new Map([
19
+ [FAILURE_CLASS.CONFIG_ERROR, {
20
+ maxAttempts: 0,
21
+ escalation: ESCALATION_POLICY.NO_RETRY,
22
+ freshSession: false,
23
+ description: 'Configuration error — fix config before retrying',
24
+ }],
25
+ [FAILURE_CLASS.PERMISSION_BLOCKED, {
26
+ maxAttempts: 0,
27
+ escalation: ESCALATION_POLICY.NO_RETRY,
28
+ freshSession: false,
29
+ description: 'Permission/trust gate blocked — requires human intervention',
30
+ }],
31
+ [FAILURE_CLASS.MERGE_CONFLICT, {
32
+ maxAttempts: 2,
33
+ escalation: ESCALATION_POLICY.RETRY_SAME,
34
+ freshSession: false,
35
+ description: 'Merge conflict — retry may succeed after dependency updates',
36
+ }],
37
+ [FAILURE_CLASS.BUILD_FAILURE, {
38
+ maxAttempts: 2,
39
+ escalation: ESCALATION_POLICY.RETRY_SAME,
40
+ freshSession: false,
41
+ description: 'Build/test failure — retry with same context for iterative fix',
42
+ }],
43
+ [FAILURE_CLASS.TIMEOUT, {
44
+ maxAttempts: 1,
45
+ escalation: ESCALATION_POLICY.RETRY_FRESH,
46
+ freshSession: true,
47
+ description: 'Timeout — retry with fresh session to avoid stuck state',
48
+ }],
49
+ [FAILURE_CLASS.EMPTY_OUTPUT, {
50
+ maxAttempts: 1,
51
+ escalation: ESCALATION_POLICY.HUMAN_REVIEW,
52
+ freshSession: true,
53
+ description: 'Empty output — agent produced nothing useful, flag for review',
54
+ }],
55
+ [FAILURE_CLASS.SPAWN_ERROR, {
56
+ maxAttempts: 2,
57
+ escalation: ESCALATION_POLICY.RETRY_FRESH,
58
+ freshSession: true,
59
+ description: 'Spawn error — retry with fresh session after transient failure',
60
+ }],
61
+ [FAILURE_CLASS.NETWORK_ERROR, {
62
+ maxAttempts: 3,
63
+ escalation: ESCALATION_POLICY.AUTO,
64
+ freshSession: false,
65
+ description: 'Network/API error — retry with exponential backoff',
66
+ }],
67
+ [FAILURE_CLASS.OUT_OF_CONTEXT, {
68
+ maxAttempts: 1,
69
+ escalation: ESCALATION_POLICY.HUMAN_REVIEW,
70
+ freshSession: true,
71
+ description: 'Context exhausted — retry with fresh session, flag if repeated',
72
+ }],
73
+ [FAILURE_CLASS.UNKNOWN, {
74
+ maxAttempts: null, // null = fall back to ENGINE_DEFAULTS.maxRetries
75
+ escalation: ESCALATION_POLICY.AUTO,
76
+ freshSession: false,
77
+ description: 'Unclassified failure — use default retry behavior',
78
+ }],
79
+ ]);
80
+
81
+ // ─── Public API ─────────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Get the recovery recipe for a failure class.
85
+ * @param {string} failureClass — one of FAILURE_CLASS values
86
+ * @returns {object} recipe with maxAttempts, escalation, freshSession, description
87
+ */
88
+ function getRecoveryRecipe(failureClass) {
89
+ return RECOVERY_RECIPES.get(failureClass) || RECOVERY_RECIPES.get(FAILURE_CLASS.UNKNOWN);
90
+ }
91
+
92
+ /**
93
+ * Determine whether a failed dispatch should be retried based on its failure class
94
+ * and current attempt count.
95
+ * @param {string} failureClass — one of FAILURE_CLASS values (or empty for unclassified)
96
+ * @param {number} attemptCount — how many times this item has already been retried
97
+ * @returns {boolean} true if another retry is allowed
98
+ */
99
+ function shouldRetry(failureClass, attemptCount = 0) {
100
+ const recipe = getRecoveryRecipe(failureClass || FAILURE_CLASS.UNKNOWN);
101
+ // null maxAttempts = fall back to global ENGINE_DEFAULTS.maxRetries
102
+ const limit = recipe.maxAttempts !== null ? recipe.maxAttempts : ENGINE_DEFAULTS.maxRetries;
103
+ return attemptCount < limit;
104
+ }
105
+
106
+ // ─── Exports ────────────────────────────────────────────────────────────────
107
+
108
+ module.exports = {
109
+ RECOVERY_RECIPES,
110
+ getRecoveryRecipe,
111
+ shouldRetry,
112
+ };
package/engine/shared.js CHANGED
@@ -586,6 +586,33 @@ const MEETING_STATUS = {
586
586
  INVESTIGATING: 'investigating', DEBATING: 'debating', CONCLUDING: 'concluding',
587
587
  COMPLETED: 'completed', ARCHIVED: 'archived',
588
588
  };
589
+ const AGENT_STATUS = {
590
+ SPAWNING: 'spawning', WORKTREE_SETUP: 'worktree-setup', READY: 'ready',
591
+ RUNNING: 'running', FINISHED: 'finished', FAILED: 'failed',
592
+ TRUST_BLOCKED: 'trust-blocked', TIMED_OUT: 'timed-out',
593
+ };
594
+ const FAILURE_CLASS = {
595
+ CONFIG_ERROR: 'config-error', // Exit code 78, CLI not found, bad config
596
+ PERMISSION_BLOCKED: 'permission-blocked', // Trust gate, permission denied, auth failure
597
+ MERGE_CONFLICT: 'merge-conflict', // Git merge conflict in worktree or dependency
598
+ BUILD_FAILURE: 'build-failure', // Compilation, lint, or test failure
599
+ TIMEOUT: 'timeout', // Hard timeout or heartbeat timeout
600
+ EMPTY_OUTPUT: 'empty-output', // Agent produced no meaningful output
601
+ SPAWN_ERROR: 'spawn-error', // Process failed to start or crashed immediately
602
+ NETWORK_ERROR: 'network-error', // API rate limit, DNS, connectivity
603
+ OUT_OF_CONTEXT: 'out-of-context', // Context window exhausted, max turns reached
604
+ UNKNOWN: 'unknown', // Unclassified failure
605
+ };
606
+ const ESCALATION_POLICY = {
607
+ NO_RETRY: 'no-retry', // CONFIG_ERROR, PERMISSION_BLOCKED — never retry
608
+ RETRY_SAME: 'retry-same', // MERGE_CONFLICT, BUILD_FAILURE — retry same agent
609
+ RETRY_FRESH: 'retry-fresh', // TIMEOUT, SPAWN_ERROR — retry with fresh session
610
+ HUMAN_REVIEW: 'human-review', // EMPTY_OUTPUT, OUT_OF_CONTEXT — flag for human
611
+ AUTO: 'auto', // UNKNOWN, NETWORK_ERROR — use default retry logic
612
+ };
613
+
614
+ // Structured completion protocol — fields agents must produce in ```completion blocks
615
+ const COMPLETION_FIELDS = ['status', 'files_changed', 'tests', 'pr', 'pending', 'failure_class'];
589
616
 
590
617
  const DEFAULT_AGENT_METRICS = {
591
618
  tasksCompleted: 0, tasksErrored: 0,
@@ -912,7 +939,8 @@ module.exports = {
912
939
  classifyInboxItem,
913
940
  ENGINE_DEFAULTS,
914
941
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT,
915
- PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS,
942
+ PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
943
+ FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
916
944
  DEFAULT_AGENT_METRICS,
917
945
  DEFAULT_AGENTS,
918
946
  DEFAULT_CLAUDE,
package/engine/timeout.js CHANGED
@@ -9,7 +9,7 @@ const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
11
  const { safeRead, safeWrite, safeJson, mutateJsonFileLocked, getProjects, projectWorkItemsPath, log, ts,
12
- ENGINE_DEFAULTS: DEFAULTS, WI_STATUS, DISPATCH_RESULT } = shared;
12
+ ENGINE_DEFAULTS: DEFAULTS, WI_STATUS, DISPATCH_RESULT, AGENT_STATUS } = shared;
13
13
  const { getDispatch, getAgentStatus } = queries;
14
14
  const AGENTS_DIR = queries.AGENTS_DIR;
15
15
  const MINIONS_DIR = shared.MINIONS_DIR;
@@ -138,6 +138,7 @@ function checkTimeouts(config) {
138
138
  const elapsed = Date.now() - new Date(info.startedAt).getTime();
139
139
  if (elapsed > itemTimeout) {
140
140
  log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
141
+ dispatch().updateAgentStatus(id, AGENT_STATUS.TIMED_OUT, `Hard timeout after ${Math.round(elapsed / 1000)}s`);
141
142
  shared.killGracefully(info.proc, 5000);
142
143
  }
143
144
  }
@@ -267,12 +268,14 @@ function checkTimeouts(config) {
267
268
  if (!hasProcess && silentMs > effectiveTimeout && Date.now() > engineRestartGraceUntil) {
268
269
  // No tracked process AND no recent output past effective timeout AND grace period expired → orphaned
269
270
  log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
271
+ dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, `Orphaned — no process, silent for ${silentSec}s`);
270
272
  // Clear session so retry starts fresh
271
273
  try { shared.safeUnlink(path.join(AGENTS_DIR, item.agent, 'session.json')); } catch {}
272
274
  deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
273
275
  } else if (hasProcess && silentMs > effectiveTimeout) {
274
276
  // Has process but no output past effective timeout → hung
275
277
  log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
278
+ dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, `Hung — no output for ${silentSec}s`);
276
279
  const procInfo = activeProcesses.get(item.id);
277
280
  if (procInfo) {
278
281
  shared.killGracefully(procInfo.proc, 5000);
package/engine.js CHANGED
@@ -25,7 +25,7 @@ const fs = require('fs');
25
25
  const path = require('path');
26
26
  const shared = require('./engine/shared');
27
27
  const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS: DEFAULTS,
28
- WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
28
+ WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT, AGENT_STATUS } = shared;
29
29
  const queries = require('./engine/queries');
30
30
 
31
31
  // ─── Paths ──────────────────────────────────────────────────────────────────
@@ -100,7 +100,7 @@ const withFileLock = shared.withFileLock;
100
100
  // ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
101
101
 
102
102
  const { mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch,
103
- writeInboxAlert } = require('./engine/dispatch');
103
+ writeInboxAlert, updateAgentStatus } = require('./engine/dispatch');
104
104
 
105
105
  // ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
106
106
 
@@ -125,7 +125,8 @@ const { getRouting, parseRoutingTable, getRoutingTableCached, getMonthlySpend,
125
125
 
126
126
  // ─── Playbook, system prompt, agent context (extracted to engine/playbook.js) ─
127
127
 
128
- const { renderPlaybook, buildSystemPrompt, buildAgentContext, selectPlaybook,
128
+ const { renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS,
129
+ buildSystemPrompt, buildAgentContext, selectPlaybook,
129
130
  buildBaseVars, buildPrDispatch, resolveTaskContext,
130
131
  getRepoHostLabel, getRepoHostToolRule } = require('./engine/playbook');
131
132
 
@@ -136,7 +137,7 @@ const { renderPlaybook, buildSystemPrompt, buildAgentContext, selectPlaybook,
136
137
  const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, handlePostMerge, checkPlanCompletion,
137
138
  syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
138
139
  updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs,
139
- isItemCompleted } = require('./engine/lifecycle');
140
+ isItemCompleted, classifyFailure } = require('./engine/lifecycle');
140
141
 
141
142
  // ─── Agent Spawner ──────────────────────────────────────────────────────────
142
143
 
@@ -278,6 +279,8 @@ async function spawnAgent(dispatchItem, config) {
278
279
  const engineConfig = config.engine || {};
279
280
  const startedAt = ts();
280
281
 
282
+ updateAgentStatus(id, AGENT_STATUS.SPAWNING, `Preparing ${type} task for ${agentId}`);
283
+
281
284
  // Resolve project context for this dispatch
282
285
  // meta.project has {name, localPath} — enrich with full config (mainBranch, repoHost, etc.)
283
286
  const metaProject = meta?.project || {};
@@ -311,6 +314,7 @@ async function spawnAgent(dispatchItem, config) {
311
314
  const _cleanupPromptFiles = () => { safeUnlink(promptPath); safeUnlink(sysPromptPath); };
312
315
 
313
316
  if (branchName) {
317
+ updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
314
318
  const wtSuffix = id ? id.split('-').pop() : shared.uid();
315
319
  const projectSlug = (project.name || 'default').replace(/[^a-zA-Z0-9_-]/g, '-');
316
320
  const wtDirName = `${projectSlug}-${branchName}-${wtSuffix}`;
@@ -491,6 +495,8 @@ async function spawnAgent(dispatchItem, config) {
491
495
  }
492
496
  }
493
497
 
498
+ updateAgentStatus(id, AGENT_STATUS.READY, 'Worktree ready, preparing to spawn process');
499
+
494
500
  // Safety check: warn if a write-capable task is running in the main repo without a worktree
495
501
  if (cwd === rootDir && ['implement', 'implement:large', 'fix', 'test', 'verify', 'plan-to-prd'].includes(type)) {
496
502
  log('warn', `Agent ${agentId} running ${type} task in main repo (no worktree) for ${id} — changes may land on master directly`);
@@ -558,6 +564,8 @@ async function spawnAgent(dispatchItem, config) {
558
564
  let stderr = '';
559
565
  let lastOutputAt = Date.now();
560
566
  let heartbeatTimer = null;
567
+ let _trustCheckDone = false;
568
+ const _spawnTime = Date.now();
561
569
 
562
570
  // Live output file — written as data arrives so dashboard can tail it
563
571
  const liveOutputPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
@@ -592,6 +600,25 @@ async function spawnAgent(dispatchItem, config) {
592
600
  if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
593
601
  try { fs.appendFileSync(liveOutputPath, chunk); } catch { /* optional */ }
594
602
 
603
+ // Trust gate detection: check first 30s of output for trust/permission prompts
604
+ if (!_trustCheckDone && (Date.now() - _spawnTime) <= 30000) {
605
+ const lower = chunk.toLowerCase();
606
+ if (/\b(trust this|do you trust|allow access|grant permission|approve tools?|permission prompt)\b/.test(lower)) {
607
+ _trustCheckDone = true;
608
+ updateAgentStatus(id, AGENT_STATUS.TRUST_BLOCKED, 'Agent appears to be waiting for trust approval');
609
+ log('warn', `Trust gate detected for ${agentId} (${id}) — agent may be blocked on a permission prompt`);
610
+ writeInboxAlert(`trust-blocked-${id}`,
611
+ `# Trust Gate Blocked — \`${id}\`\n\n` +
612
+ `**Agent:** ${agentId}\n` +
613
+ `**Task:** ${dispatchItem.task || type}\n\n` +
614
+ `The agent appears to be blocked on a trust/permission prompt within 30s of spawn.\n` +
615
+ `Check the agent's live output and approve the trust gate manually.\n`
616
+ );
617
+ }
618
+ } else if (!_trustCheckDone) {
619
+ _trustCheckDone = true; // past 30s window
620
+ }
621
+
595
622
  // Capture sessionId early for mid-session steering
596
623
  const procInfo = activeProcesses.get(id);
597
624
  if (procInfo && !procInfo.sessionId && chunk.includes('session_id')) {
@@ -622,6 +649,10 @@ async function spawnAgent(dispatchItem, config) {
622
649
  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
623
650
  log('info', `Agent ${agentId} (${id}) exited with code ${code}`);
624
651
 
652
+ // Emit worker-state transition: FINISHED or FAILED
653
+ updateAgentStatus(id, code === 0 ? AGENT_STATUS.FINISHED : AGENT_STATUS.FAILED,
654
+ code === 0 ? 'Agent completed successfully' : `Agent exited with code ${code}`);
655
+
625
656
  // Clear stale session if resume failed — prevents burning all retries on the same bad session
626
657
  if (code !== 0 && cachedSessionId && stderr.includes('No conversation found')) {
627
658
  log('warn', `Stale session ${cachedSessionId} for ${agentId} — clearing session.json`);
@@ -762,11 +793,14 @@ async function spawnAgent(dispatchItem, config) {
762
793
  safeWrite(archivePath, outputContent);
763
794
  safeWrite(latestPath, outputContent); // overwrite latest for dashboard compat
764
795
 
796
+ // Classify failure for non-zero exits
797
+ const failureClass = code !== 0 ? classifyFailure(code, stdout, stderr) : undefined;
798
+
765
799
  // Detect configuration errors (e.g. Claude CLI not found) — fail immediately with clear message
766
800
  if (code === 78) {
767
801
  const errMsg = stderr.includes('claude-code') ? stderr.trim() : 'Configuration error — Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code';
768
- log('error', `Agent ${agentId} (${id}) failed: ${errMsg}`);
769
- completeDispatch(id, DISPATCH_RESULT.ERROR, errMsg, '');
802
+ log('error', `Agent ${agentId} (${id}) failed: ${errMsg} [${failureClass}]`);
803
+ completeDispatch(id, DISPATCH_RESULT.ERROR, errMsg, '', { failureClass });
770
804
  try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
771
805
  try { fs.unlinkSync(promptPath); } catch { /* cleanup */ }
772
806
  try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch { /* cleanup */ }
@@ -779,7 +813,8 @@ async function spawnAgent(dispatchItem, config) {
779
813
  // Move from active to completed in dispatch (single source of truth for agent status)
780
814
  // autoRecovered: agent failed (e.g. heartbeat timeout) but created PRs — treat as success
781
815
  const effectiveResult = (code === 0 || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
782
- completeDispatch(id, effectiveResult, '', resultSummary);
816
+ const completeOpts = effectiveResult === DISPATCH_RESULT.ERROR && failureClass ? { failureClass } : {};
817
+ completeDispatch(id, effectiveResult, '', resultSummary, completeOpts);
783
818
 
784
819
  // Cleanup temp files (including PID file now that dispatch is complete)
785
820
  try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
@@ -858,6 +893,8 @@ async function spawnAgent(dispatchItem, config) {
858
893
  // Track process — even if PID isn't available yet (async on Windows)
859
894
  activeProcesses.set(id, { proc, agentId, startedAt, sessionId: cachedSessionId });
860
895
 
896
+ updateAgentStatus(id, AGENT_STATUS.RUNNING, `Process spawned for ${agentId}`);
897
+
861
898
  // Log PID and persist to registry
862
899
  if (proc.pid) {
863
900
  log('info', `Agent process started: PID ${proc.pid}`);
@@ -2919,7 +2956,7 @@ module.exports = {
2919
2956
  validateConfig,
2920
2957
 
2921
2958
  // Dispatch management (re-exported from engine/dispatch.js)
2922
- mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch, writeInboxAlert,
2959
+ mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus,
2923
2960
  activeProcesses, get engineRestartGraceUntil() { return engineRestartGraceUntil; },
2924
2961
  set engineRestartGraceUntil(v) { engineRestartGraceUntil = v; },
2925
2962
 
@@ -2934,7 +2971,7 @@ module.exports = {
2934
2971
  reconcileItemsWithPrs, detectDependencyCycles,
2935
2972
 
2936
2973
  // Playbooks
2937
- renderPlaybook, buildWorkItemDispatchVars,
2974
+ renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS, buildWorkItemDispatchVars,
2938
2975
 
2939
2976
  // Timeout / Steering / Idle (re-exported from engine/timeout.js)
2940
2977
  checkTimeouts, checkSteering, checkIdleThreshold,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.727",
3
+ "version": "0.1.728",
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"
@@ -143,9 +143,9 @@ Replace `<SHORT DESCRIPTION OF FAILURE>` and `<PASTE THE BUILD/TEST ERROR OUTPUT
143
143
  - Use the worktree path, NOT the main project path, for all commands
144
144
  - The worktree will persist after your process ends so the user can inspect it
145
145
 
146
- ## IMPORTANT: Do NOT clean up the worktree
146
+ ## Do not clean up the worktree
147
147
 
148
- Leave the worktree in place at `{{project_path}}/../worktrees/bt-{{pr_number}}` — the user needs it to review the running app. The engine will clean it up after the PR is merged or closed.
148
+ Leave the worktree in place at `{{project_path}}/../worktrees/bt-{{pr_number}}` — the user needs it to review the running app. The engine will clean it up automatically after the PR is merged or closed.
149
149
 
150
150
 
151
151
  ## When to Stop
@@ -10,7 +10,7 @@ A work item has been flagged as too large for a single agent dispatch. Analyze t
10
10
  ## Work Item
11
11
 
12
12
  - **ID:** {{item_id}}
13
- - **Title:** {{item_title}}
13
+ - **Title:** {{item_name}}
14
14
  - **Description:** {{item_description}}
15
15
  - **Complexity:** {{item_complexity}}
16
16
  - **Project:** {{project_name}} (`{{project_path}}`)
package/playbooks/fix.md CHANGED
@@ -66,3 +66,18 @@ Do NOT remove the worktree — the engine handles cleanup automatically.
66
66
 
67
67
  Your task is complete once you have: (1) confirmed build and tests pass, (2) pushed the fix, and (3) commented on the PR. Do NOT continue exploring unrelated code or making additional improvements. Stop immediately.
68
68
 
69
+ ## Completion
70
+
71
+ After finishing, output a structured completion block so the engine can parse your results:
72
+
73
+ ```completion
74
+ status: done | partial | failed
75
+ files_changed: <comma-separated list of key files changed>
76
+ tests: pass | fail | skipped | N/A
77
+ pr: PR-<number> or N/A
78
+ failure_class: N/A
79
+ pending: <any remaining work, or none>
80
+ ```
81
+
82
+ Replace the values with your actual results. This block MUST appear in your final output.
83
+
@@ -78,3 +78,18 @@ git push origin {{branch_name}}
78
78
  ## When to Stop
79
79
 
80
80
  Your task is complete once you have: (1) confirmed build and tests pass, and (2) pushed to the shared branch. Do NOT create a PR — the engine creates one when all plan items are done. Stop after pushing.
81
+
82
+ ## Completion
83
+
84
+ After finishing, output a structured completion block so the engine can parse your results:
85
+
86
+ ```completion
87
+ status: done | partial | failed
88
+ files_changed: <comma-separated list of key files changed>
89
+ tests: pass | fail | skipped | N/A
90
+ pr: N/A
91
+ failure_class: N/A
92
+ pending: <any remaining work, or none>
93
+ ```
94
+
95
+ Replace the values with your actual results. This block MUST appear in your final output.
@@ -1,3 +1,7 @@
1
+ ## Context Window Awareness
2
+
3
+ Your context window may be compacted or summarized mid-task by Claude's automatic context management. This is normal and expected for long-running tasks. Do NOT interpret compacted or truncated context as a signal to stop early, wrap up prematurely, or skip remaining work. Continue working toward your stated objective regardless of context window state — re-read key files if needed to recover context.
4
+
1
5
  ## Engine Rules (apply to all tasks)
2
6
 
3
7
  - Do NOT write to `agents/*/status.json` — the engine manages your status automatically.
package/playbooks/test.md CHANGED
@@ -39,7 +39,7 @@ Use subagents only for genuinely parallel, independent tasks. For building, test
39
39
  - If a build or test fails, report the error clearly — don't try to fix it unless asked
40
40
  - If running a local server, report the URL (e.g., http://localhost:3000)
41
41
 
42
- ## Run Command (IMPORTANT)
42
+ ## Run Command
43
43
 
44
44
  When the build succeeds and the task involves running a server or app, output a ready-to-paste run command using **absolute paths** so the user can launch it from any terminal. Format it exactly like this:
45
45