@yemi33/minions 0.1.328 → 0.1.330

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.330 (2026-04-03)
4
+
5
+ ### Fixes
6
+ - resolve all 25 lifecycle.js test failures
7
+ - remove orphan statuses, add validation, replace in-progress with dispatched
8
+
3
9
  ## 0.1.328 (2026-04-03)
4
10
 
5
11
  ### Fixes
@@ -83,7 +83,7 @@ function derivePlanStatus(prdFile, mdFile, prdJsonStatus, workItems) {
83
83
 
84
84
  // Derive from work item progress
85
85
  if (allDone && !hasActiveWork) return 'completed';
86
- if (hasActiveWork || hasPendingPrd) return 'in-progress';
86
+ if (hasActiveWork || hasPendingPrd) return 'dispatched';
87
87
  if (hasFailed && !hasActiveWork) return 'has-failures';
88
88
 
89
89
  if (prdJsonStatus === 'awaiting-approval' && implementWi.length === 0) return 'awaiting-approval';
@@ -192,7 +192,7 @@ function renderPlans(plans) {
192
192
  const effectiveStatus = isArchived ? 'completed' : derivePlanStatus(prdFile, p.file, prdJsonStatus, allWi);
193
193
 
194
194
  const statusLabelsMap = {
195
- 'completed': 'Completed', 'in-progress': 'In Progress', 'paused': 'Paused',
195
+ 'completed': 'Completed', 'dispatched': 'In Progress', 'paused': 'Paused',
196
196
  'awaiting-approval': 'Awaiting Approval', 'approved': 'Approved', 'rejected': 'Rejected',
197
197
  'revision-requested': 'Revision Requested', 'has-failures': 'Has Failures', 'active': 'Active'
198
198
  };
@@ -225,7 +225,7 @@ function renderPlans(plans) {
225
225
 
226
226
  const executeBtn = isDraft && (effectiveStatus === 'active' || effectiveStatus === 'draft') && !isArchived && !prdFile ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);font-weight:600" ' +
227
227
  'onclick="event.stopPropagation();planExecute(\'' + escHtml(p.file) + '\',\'' + escHtml(p.project) + '\',this)">Execute</button>' : '';
228
- const showPause = effectiveStatus === 'in-progress' && prdFile && !isArchived;
228
+ const showPause = effectiveStatus === 'dispatched' && prdFile && !isArchived;
229
229
  const showResume = (effectiveStatus === 'paused' || effectiveStatus === 'awaiting-approval') && prdFile && !isArchived;
230
230
  const verifyWi = allWi.find(w => w.itemType === 'verify' && w.sourcePlan === prdFile);
231
231
  const hasVerifyWi = !!verifyWi;
@@ -246,8 +246,8 @@ function renderPlans(plans) {
246
246
  'onclick="event.stopPropagation();planDelete(\'' + escHtml(p.file) + '\')">Delete</button>' : '';
247
247
 
248
248
  const versionBadge = p.version ? ' <span style="font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(56,139,253,0.15);color:var(--blue);vertical-align:middle">v' + p.version + '</span>' : '';
249
- const statusColors = { 'completed': 'var(--green)', 'in-progress': 'var(--blue)', 'paused': 'var(--muted)', 'awaiting-approval': 'var(--yellow)', 'approved': 'var(--green)', 'rejected': 'var(--red)', 'has-failures': 'var(--red)', 'revision-requested': 'var(--purple,#a855f7)', 'active': 'var(--muted)' };
250
- const cardClass = effectiveStatus === 'in-progress' ? 'working' : effectiveStatus === 'awaiting-approval' || effectiveStatus === 'paused' ? 'awaiting' : effectiveStatus;
249
+ const statusColors = { 'completed': 'var(--green)', 'dispatched': 'var(--blue)', 'paused': 'var(--muted)', 'awaiting-approval': 'var(--yellow)', 'approved': 'var(--green)', 'rejected': 'var(--red)', 'has-failures': 'var(--red)', 'revision-requested': 'var(--purple,#a855f7)', 'active': 'var(--muted)' };
250
+ const cardClass = effectiveStatus === 'dispatched' ? 'working' : effectiveStatus === 'awaiting-approval' || effectiveStatus === 'paused' ? 'awaiting' : effectiveStatus;
251
251
  return '<div class="plan-card ' + cardClass + '" data-file="plans/' + escHtml(p.file) + '" style="cursor:pointer' + (isArchived ? ';opacity:0.7' : '') + '" onclick="planView(\'' + escHtml(p.file) + '\')">' +
252
252
  '<div class="plan-card-header">' +
253
253
  '<div><div class="plan-card-title">' + escHtml(p.summary || p.file) + versionBadge + '</div>' +
@@ -13,8 +13,8 @@ function renderPrd(prd, prog) {
13
13
  return;
14
14
  }
15
15
 
16
- const statusColors = { 'completed': 'var(--green)', 'in-progress': 'var(--blue)', 'awaiting-approval': 'var(--yellow)', 'paused': 'var(--muted)', 'approved': 'var(--green)' };
17
- const statusLabels = { 'completed': 'Completed', 'in-progress': 'In Progress', 'awaiting-approval': 'Awaiting Approval', 'paused': 'Paused', 'approved': 'Approved' };
16
+ const statusColors = { 'completed': 'var(--green)', 'dispatched': 'var(--blue)', 'awaiting-approval': 'var(--yellow)', 'paused': 'var(--muted)', 'approved': 'var(--green)' };
17
+ const statusLabels = { 'completed': 'Completed', 'dispatched': 'In Progress', 'awaiting-approval': 'Awaiting Approval', 'paused': 'Paused', 'approved': 'Approved' };
18
18
 
19
19
  // Show per-PRD status summary in header when multiple PRDs exist
20
20
  const existing = prd.existing || [];
@@ -28,7 +28,7 @@ function renderPrd(prd, prog) {
28
28
  const hasActive = implementItems.some(w => w.status === 'pending' || w.status === 'dispatched');
29
29
  const prdFile = existing[0]?.file || '';
30
30
  const prdStatus = existing[0]?.status || '';
31
- const effectiveStatus = allDone && !hasActive ? 'completed' : hasActive ? 'in-progress' : prdStatus || 'active';
31
+ const effectiveStatus = allDone && !hasActive ? 'completed' : hasActive ? 'dispatched' : prdStatus || 'active';
32
32
 
33
33
  let actions = '';
34
34
  if (prdFile) {
@@ -37,7 +37,7 @@ function renderPrd(prd, prog) {
37
37
  } else if (effectiveStatus === 'completed') {
38
38
  actions = ' <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:4px" onclick="triggerVerify(\'' + escHtml(prdFile) + '\',this)">Verify</button>' +
39
39
  ' <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;margin-left:4px" onclick="planArchive(\'' + escHtml(prdFile) + '\',this)">Archive</button>';
40
- } else if (effectiveStatus === 'in-progress') {
40
+ } else if (effectiveStatus === 'dispatched') {
41
41
  actions = ' <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="planPause(\'' + escHtml(prdFile) + '\',this)">Pause</button>';
42
42
  } else if (effectiveStatus === 'paused') {
43
43
  actions = ' <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:4px" onclick="planApprove(\'' + escHtml(prdFile) + '\',this)">Resume</button>' +
@@ -48,13 +48,13 @@ function renderPrd(prd, prog) {
48
48
  ' <span style="color:var(--muted);font-size:10px">' + (prd.age || '') + '</span>' + actions;
49
49
  } else {
50
50
  // Multiple PRDs — show count summary, per-PRD details are in renderPrdProgress groups
51
- const counts = { completed: 0, 'in-progress': 0, 'awaiting-approval': 0, paused: 0 };
51
+ const counts = { completed: 0, 'dispatched': 0, 'awaiting-approval': 0, paused: 0 };
52
52
  for (const p of existing) {
53
53
  const items = prdItems.filter(i => i.source === p.file);
54
54
  const wiForPrd = allWi.filter(w => items.some(pi => pi.id === w.id));
55
55
  const allDone = wiForPrd.length > 0 && wiForPrd.every(w => w.status === 'done');
56
56
  const hasActive = wiForPrd.some(w => w.status === 'pending' || w.status === 'dispatched');
57
- const s = allDone && !hasActive ? 'completed' : hasActive ? 'in-progress' : p.status || 'active';
57
+ const s = allDone && !hasActive ? 'completed' : hasActive ? 'dispatched' : p.status || 'active';
58
58
  counts[s] = (counts[s] || 0) + 1;
59
59
  }
60
60
  const parts = Object.entries(counts).filter(([, n]) => n > 0).map(([s, n]) =>
@@ -83,7 +83,7 @@ function renderPrdProgress(prog) {
83
83
  const total = items.length;
84
84
  if (total === 0) return '';
85
85
  const done = items.filter(i => i.status === 'done').length;
86
- const inProgress = items.filter(i => i.status === 'in-progress').length;
86
+ const inProgress = items.filter(i => i.status === 'dispatched').length;
87
87
  const failed = items.filter(i => i.status === 'failed').length;
88
88
  const paused = items.filter(i => i.status === 'paused').length;
89
89
  const missing = items.filter(i => i.status === 'missing' || !i.status).length;
@@ -100,7 +100,7 @@ function renderPrdProgress(prog) {
100
100
 
101
101
  const bar = '<div class="prd-progress-bar">' +
102
102
  '<div class="seg complete" style="width:' + pct(done) + '%"></div>' +
103
- '<div class="seg in-progress" style="width:' + pct(inProgress) + '%"></div>' +
103
+ '<div class="seg dispatched" style="width:' + pct(inProgress) + '%"></div>' +
104
104
  '<div class="seg paused" style="width:' + pct(paused) + '%"></div>' +
105
105
  '<div class="seg missing" style="width:' + pct(missing) + '%"></div>' +
106
106
  '</div>';
@@ -108,15 +108,15 @@ function renderPrdProgress(prog) {
108
108
  return '<div style="margin:6px 0 8px 0;padding:0 8px">' + stats + '<div style="margin-top:8px">' + bar + '</div></div>';
109
109
  }
110
110
 
111
- // PRD item statuses: missing → in-progress → done
111
+ // PRD item statuses: missing → dispatched → done
112
112
  const statusBadge = (s) => {
113
113
  const styles = {
114
114
  'done': 'background:rgba(63,185,80,0.15);color:var(--green)',
115
- 'in-progress': 'background:rgba(210,153,34,0.15);color:var(--yellow);animation:wipPulse 1.5s infinite',
115
+ 'dispatched': 'background:rgba(210,153,34,0.15);color:var(--yellow);animation:wipPulse 1.5s infinite',
116
116
  'failed': 'background:rgba(248,81,73,0.15);color:var(--red)',
117
117
  'paused': 'background:rgba(139,148,158,0.15);color:var(--muted)',
118
118
  };
119
- const labels = { 'done': 'DONE', 'in-progress': 'WIP', 'failed': 'FAIL', 'paused': 'PAUSED', 'missing': '\u2014' };
119
+ const labels = { 'done': 'DONE', 'dispatched': 'WIP', 'failed': 'FAIL', 'paused': 'PAUSED', 'missing': '\u2014' };
120
120
  const style = styles[s] || 'background:var(--surface);color:var(--muted)';
121
121
  const label = labels[s] || '—';
122
122
  return '<span style="font-size:9px;font-weight:700;padding:2px 6px;border-radius:3px;letter-spacing:0.5px;white-space:nowrap;' + style + '">' + label + '</span>';
@@ -196,7 +196,7 @@ function renderPrdProgress(prog) {
196
196
 
197
197
  const renderGroupHeader = (g) => {
198
198
  const done = g.items.filter(i => i.status === 'done').length;
199
- const wip = g.items.filter(i => i.status === 'in-progress' || i.status === 'dispatched').length;
199
+ const wip = g.items.filter(i => i.status === 'dispatched' || i.status === 'dispatched').length;
200
200
  const summary = (g.summary || '').replace(/^Convert plan to PRD:\s*/i, '').slice(0, 80);
201
201
  const isAwaitingApproval = g.planStatus === 'awaiting-approval';
202
202
  const isPaused = g.planStatus === 'paused';
@@ -303,7 +303,7 @@ function renderPrdProgress(prog) {
303
303
 
304
304
  const statusColor = (s) => {
305
305
  if (s === 'done') return 'var(--green)';
306
- if (s === 'in-progress') return 'var(--yellow)';
306
+ if (s === 'dispatched') return 'var(--yellow)';
307
307
  if (s === 'failed') return 'var(--red)';
308
308
  if (s === 'paused') return 'var(--muted)';
309
309
  return 'var(--border)';
@@ -322,7 +322,7 @@ function renderPrdProgress(prog) {
322
322
  const iid = escHtml(i.id || '');
323
323
  const agent = wi[i.id]?.dispatched_to || '';
324
324
  const deps = (i.depends_on || []).join(', ');
325
- const wipAnim = i.status === 'in-progress' ? 'animation:prdWipPulse 2s infinite;' : '';
325
+ const wipAnim = i.status === 'dispatched' ? 'animation:prdWipPulse 2s infinite;' : '';
326
326
  html += '<div onclick="prdItemEdit(\'' + src + '\',\'' + iid + '\')" ' +
327
327
  'style="background:var(--surface2);border:1px solid var(--border);border-left:3px solid ' + borderColor + ';' + wipAnim +
328
328
  'border-radius:4px;padding:6px 8px;margin-bottom:6px;cursor:pointer;font-size:11px">' +
@@ -557,7 +557,7 @@ async function prdItemEdit(source, itemId) {
557
557
  let completionHtml = '';
558
558
  const isDone = item.status === 'done';
559
559
  const isFailed = item.status === 'failed';
560
- const isActive = item.status === 'in-progress' || item.status === 'dispatched';
560
+ const isActive = item.status === 'dispatched' || item.status === 'dispatched';
561
561
 
562
562
  if (isDone || isFailed || isActive) {
563
563
  const agent = wi?.dispatched_to || completedEntry?.agent || '';
@@ -30,7 +30,7 @@ function wiRetryBtn(item) {
30
30
 
31
31
  function wiRow(item) {
32
32
  const statusBadge = (s) => {
33
- const cls = s === 'failed' ? 'rejected' : s === 'needs-human-review' ? 'needs-review' : s === 'dispatched' ? 'building' : s === 'pending' || s === 'queued' ? 'active' : s === 'done' ? 'approved' : 'draft';
33
+ const cls = s === 'failed' ? 'rejected' : s === 'needs-human-review' ? 'needs-review' : s === 'dispatched' ? 'building' : s === 'pending' || s === 'queued' ? 'active' : s === 'done' ? 'approved' : s === 'decomposed' ? 'building' : 'draft';
34
34
  return '<span class="pr-badge ' + cls + '">' + escHtml(s) + '</span>';
35
35
  };
36
36
  const typeBadge = (t) => '<span class="dispatch-type ' + (t || 'implement') + '">' + escHtml(t || 'implement') + '</span>';
@@ -168,7 +168,7 @@
168
168
  .prd-progress-bar { width: 100%; height: 20px; background: var(--bg); border-radius: var(--radius-xl); overflow: hidden; display: flex; margin-bottom: var(--space-6); border: 1px solid var(--border); }
169
169
  .prd-progress-bar .seg { height: 100%; transition: width 0.5s ease; }
170
170
  .prd-progress-bar .seg.complete { background: var(--green); }
171
- .prd-progress-bar .seg.in-progress { background: var(--yellow); }
171
+ .prd-progress-bar .seg.dispatched { background: var(--yellow); }
172
172
  .prd-progress-bar .seg.paused { background: var(--muted); opacity: 0.5; }
173
173
  .prd-progress-bar .seg.missing { background: var(--border); }
174
174
  .prd-progress-pct { font-size: 22px; font-weight: 700; color: var(--green); margin-bottom: var(--space-4); }
@@ -176,7 +176,7 @@
176
176
  .prd-legend-item { display: flex; align-items: center; gap: 5px; font-size: var(--text-base); color: var(--muted); }
177
177
  .prd-legend-dot { width: 10px; height: 10px; border-radius: 2px; }
178
178
  .prd-legend-dot.complete { background: var(--green); }
179
- .prd-legend-dot.in-progress { background: var(--yellow); }
179
+ .prd-legend-dot.dispatched { background: var(--yellow); }
180
180
  .prd-legend-dot.missing { background: var(--border); }
181
181
 
182
182
  /* Pipeline Step-Progress */
@@ -193,7 +193,7 @@
193
193
  .prd-items-list { display: flex; flex-direction: column; gap: 3px; max-height: 400px; overflow-y: auto; padding: 0 8px; }
194
194
  .prd-item-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: var(--radius-sm); font-size: var(--text-base); background: var(--surface2); border: 1px solid var(--border); border-left: 3px solid var(--border); }
195
195
  .prd-item-row.st-done { border-left-color: var(--green); }
196
- .prd-item-row.st-in-progress { border-left-color: var(--yellow); animation: prdWipPulse 2s infinite; }
196
+ .prd-item-row.st-dispatched { border-left-color: var(--yellow); animation: prdWipPulse 2s infinite; }
197
197
  @keyframes prdWipPulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(210,153,34,0); } 50% { box-shadow: 0 0 0 4px rgba(210,153,34,0.2); } }
198
198
  .prd-item-row.st-failed { border-left-color: var(--red); }
199
199
  .prd-item-row.st-needs-human-review { border-left-color: var(--orange); }
package/engine/cli.js CHANGED
@@ -809,8 +809,8 @@ const commands = {
809
809
  if (exists && name === 'prd') {
810
810
  const prd = safeJson(filePath);
811
811
  if (prd) {
812
- const missing = (prd.missing_features || []).filter(f => ['missing', 'planned'].includes(f.status));
813
- console.log(` Items: ${missing.length} missing/planned features`);
812
+ const missing = (prd.missing_features || []).filter(f => f.status === 'missing' || !f.status);
813
+ console.log(` Items: ${missing.length} missing features`);
814
814
  }
815
815
  }
816
816
  if (exists && name === 'pullRequests') {
@@ -5,9 +5,11 @@
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
+ const os = require('os');
8
9
  const shared = require('./shared');
9
- const { safeRead, safeJson, safeWrite, execSilent, projectPrPath, getPrLinks, addPrLink,
10
- log, ts, dateStamp } = shared;
10
+ const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, execSilent, projectPrPath, getPrLinks, addPrLink,
11
+ log, ts, dateStamp, WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT,
12
+ ENGINE_DEFAULTS } = shared;
11
13
  const { trackEngineUsage } = require('./llm');
12
14
  const queries = require('./queries');
13
15
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
@@ -21,7 +23,9 @@ function checkPlanCompletion(meta, config) {
21
23
  const planPath = path.join(PRD_DIR, planFile);
22
24
  const plan = safeJson(planPath);
23
25
  if (!plan?.missing_features) return;
24
- if (plan.status === 'completed') return;
26
+ if (plan.status === PLAN_STATUS.COMPLETED) {
27
+ if (plan._completionNotified) return;
28
+ }
25
29
 
26
30
  const projects = shared.getProjects(config);
27
31
 
@@ -53,7 +57,7 @@ function checkPlanCompletion(meta, config) {
53
57
  const unmaterialized = [...planFeatureIds].filter(id => {
54
58
  if (workItemById[id]) return false;
55
59
  const prdItem = (plan.missing_features || []).find(f => f.id === id);
56
- return !(prdItem && (prdItem.status === 'done' || prdItem.status === 'in-pr'));
60
+ return !(prdItem && DONE_STATUSES.has(prdItem.status));
57
61
  });
58
62
  if (unmaterialized.length > 0) {
59
63
  log('info', `Plan ${planFile}: ${unmaterialized.length}/${planFeatureIds.size} feature(s) not yet materialized as work items: ${unmaterialized.join(', ')}`);
@@ -63,20 +67,20 @@ function checkPlanCompletion(meta, config) {
63
67
  // Check 2: every feature's work item must be done (or PRD item marked done externally)
64
68
  const notDone = [...planFeatureIds].filter(id => {
65
69
  const w = workItemById[id];
66
- if (w && (w.status === 'done' || w.status === 'in-pr')) return false; // in-pr accepted for backward compat
70
+ if (w && DONE_STATUSES.has(w.status)) return false;
67
71
  const prdItem = (plan.missing_features || []).find(f => f.id === id);
68
- return !(prdItem && (prdItem.status === 'done' || prdItem.status === 'in-pr'));
72
+ return !(prdItem && DONE_STATUSES.has(prdItem.status));
69
73
  });
70
74
  if (notDone.length > 0) {
71
75
  log('info', `Plan ${planFile}: waiting for done on ${notDone.length}/${planFeatureIds.size} item(s): ${notDone.join(', ')}`);
72
76
  return;
73
77
  }
74
78
 
75
- const doneItems = planItems.filter(w => w.status === 'done' || w.status === 'in-pr');
76
- const failedItems = planItems.filter(w => w.status === 'failed');
79
+ const doneItems = planItems.filter(w => DONE_STATUSES.has(w.status));
80
+ const failedItems = planItems.filter(w => w.status === WI_STATUS.FAILED);
77
81
 
78
82
  // 1. Mark plan as completed
79
- plan.status = 'completed';
83
+ plan.status = PLAN_STATUS.COMPLETED;
80
84
  plan.completedAt = ts();
81
85
 
82
86
  // Compute timing
@@ -133,16 +137,28 @@ function checkPlanCompletion(meta, config) {
133
137
  ].filter(Boolean).join('\n');
134
138
 
135
139
  // Write summary to notes/inbox
136
- const summaryFile = `prd-completion-${planFile.replace('.json', '')}-${ts().slice(0, 10)}.md`;
137
- shared.safeWrite(shared.uniquePath(path.join(MINIONS_DIR, 'notes', 'inbox', summaryFile)), summary);
138
- log('info', `PRD completion summary written to notes/inbox/${summaryFile}`);
140
+ const summarySlug = `prd-completion-${planFile.replace('.json', '')}`;
141
+ shared.writeToInbox('engine', summarySlug, summary);
142
+ log('info', `PRD completion summary written to notes/inbox/${summarySlug}`);
143
+
144
+ // Persist completed status + _completionNotified via file lock
145
+ mutateJsonFileLocked(planPath, (data) => {
146
+ data.status = PLAN_STATUS.COMPLETED;
147
+ data.completedAt = plan.completedAt;
148
+ data._completionNotified = true;
149
+ return data;
150
+ });
139
151
 
140
152
  // Resolve the primary project for writing new work items (PR, verify)
141
153
  const projectName = plan.project;
142
154
  const primaryProject = projectName
143
155
  ? projects.find(p => p.name?.toLowerCase() === projectName?.toLowerCase()) : projects[0];
144
- const wiPath = primaryProject ? shared.projectWorkItemsPath(primaryProject) : null;
145
- const workItems = wiPath ? (safeJson(wiPath) || []) : [];
156
+ if (!primaryProject) {
157
+ log('warn', `Plan ${planFile}: no primary project found skipping PR/verify creation`);
158
+ return;
159
+ }
160
+ const wiPath = shared.projectWorkItemsPath(primaryProject);
161
+ const workItems = safeJson(wiPath) || [];
146
162
 
147
163
  // 3. For shared-branch plans, create PR work item
148
164
  if (plan.branch_strategy === 'shared-branch' && plan.feature_branch && wiPath) {
@@ -156,7 +172,7 @@ function checkPlanCompletion(meta, config) {
156
172
  id, title: `Create PR for plan: ${plan.plan_summary || planFile}`,
157
173
  type: 'implement', priority: 'high',
158
174
  description: `All plan items from \`${planFile}\` are complete on branch \`${featureBranch}\`.\n\n**Branch:** \`${featureBranch}\`\n**Target:** \`${mainBranch}\`\n\n## Completed Items\n${itemSummary}`,
159
- status: 'pending', created: ts(), createdBy: 'engine:plan-completion',
175
+ status: WI_STATUS.PENDING, created: ts(), createdBy: 'engine:plan-completion',
160
176
  sourcePlan: planFile, itemType: 'pr',
161
177
  branch: featureBranch, branchStrategy: 'shared-branch', project: projectName,
162
178
  });
@@ -177,7 +193,7 @@ function checkPlanCompletion(meta, config) {
177
193
  const prs = (safeJson(shared.projectPrPath(p)) || [])
178
194
  .filter(pr => {
179
195
  const linkedId = prLinks[pr.id];
180
- return pr.status === 'active' && linkedId && doneItems.find(w => w.id === linkedId);
196
+ return pr.status === PR_STATUS.ACTIVE && linkedId && doneItems.find(w => w.id === linkedId);
181
197
  });
182
198
  if (prs.length > 0) {
183
199
  projectPrs[p.name] = { project: p, prs, mainBranch: p.mainBranch || 'main' };
@@ -190,7 +206,7 @@ function checkPlanCompletion(meta, config) {
190
206
  const branches = prs.map(pr => pr.branch).filter(Boolean);
191
207
  const lines = [
192
208
  `# ${name} — merge ${branches.length} PR branch(es) into one worktree`,
193
- `cd "${p.localPath}"`,
209
+ `cd "${p.localPath.replace(/\\/g, '/')}"`,
194
210
  `git fetch origin ${branches.map(b => `"${b}"`).join(' ')} "${mainBranch}"`,
195
211
  `git worktree add "${wtPath}" "origin/${mainBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${mainBranch}" && git pull origin "${mainBranch}")`,
196
212
  `cd "${wtPath}"`,
@@ -247,7 +263,7 @@ function checkPlanCompletion(meta, config) {
247
263
  type: 'verify',
248
264
  priority: 'high',
249
265
  description,
250
- status: 'pending',
266
+ status: WI_STATUS.PENDING,
251
267
  created: ts(),
252
268
  createdBy: 'engine:plan-verification',
253
269
  sourcePlan: planFile,
@@ -258,47 +274,94 @@ function checkPlanCompletion(meta, config) {
258
274
  log('info', `Created verification work item ${verifyId} for plan ${planFile}`);
259
275
  }
260
276
 
261
- // 5. Archive: move PRD .json to prd/archive/ and source .md plan to plans/archive/
277
+ // Archive deferred until verify completes
278
+
279
+ log('info', `PRD ${planFile} completed: ${doneItems.length} done, ${failedItems.length} failed, runtime ${runtimeMin}m`);
280
+ }
281
+
282
+ // ─── Archive Plan ───────────────────────────────────────────────────────────
283
+ function archivePlan(planFile, plan, projects, config) {
284
+ const planPath = path.join(PRD_DIR, planFile);
285
+
286
+ // Archive PRD .json to prd/archive/
262
287
  const prdArchiveDir = path.join(PRD_DIR, 'archive');
263
288
  if (!fs.existsSync(prdArchiveDir)) fs.mkdirSync(prdArchiveDir, { recursive: true });
264
- shared.safeWrite(planPath, plan); // save completed status first
265
289
  try {
266
- fs.renameSync(planPath, path.join(prdArchiveDir, planFile));
267
- log('info', `Archived completed PRD: prd/archive/${planFile}`);
290
+ if (fs.existsSync(planPath)) {
291
+ fs.renameSync(planPath, path.join(prdArchiveDir, planFile));
292
+ log('info', `Archived completed PRD: prd/archive/${planFile}`);
293
+ }
268
294
  } catch (err) {
269
295
  log('warn', `Failed to archive PRD ${planFile}: ${err.message}`);
270
- shared.safeWrite(planPath, plan);
271
296
  }
272
297
 
273
- // Also archive the source .md plan if it exists
298
+ // Archive the source .md plan if it exists
299
+ const projectName = plan.project;
274
300
  const planArchiveDir = path.join(PLANS_DIR, 'archive');
275
301
  if (!fs.existsSync(planArchiveDir)) fs.mkdirSync(planArchiveDir, { recursive: true });
276
302
  try {
277
- const mdFiles = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
278
- for (const md of mdFiles) {
279
- const mdContent = shared.safeRead(path.join(PLANS_DIR, md)) || '';
280
- // Match by project name or plan summary appearing in the .md content
281
- if (mdContent.includes(projectName) || mdContent.includes(plan.plan_summary?.slice(0, 40) || '___nomatch___')) {
282
- try {
283
- fs.renameSync(path.join(PLANS_DIR, md), path.join(planArchiveDir, md));
284
- log('info', `Archived source plan: plans/archive/${md}`);
285
- } catch (err) { log('warn', `Failed to archive plan ${md}: ${err.message}`); }
286
- break;
303
+ // Direct match by source_plan field or planFile-derived name
304
+ const sourcePlanName = plan.source_plan || planFile.replace(/\.json$/, '.md');
305
+ if (sourcePlanName && fs.existsSync(path.join(PLANS_DIR, sourcePlanName))) {
306
+ try {
307
+ fs.renameSync(path.join(PLANS_DIR, sourcePlanName), path.join(planArchiveDir, sourcePlanName));
308
+ log('info', `Archived source plan: plans/archive/${sourcePlanName}`);
309
+ } catch (err) { log('warn', `Failed to archive plan ${sourcePlanName}: ${err.message}`); }
310
+ } else {
311
+ // Fallback: match by content
312
+ const mdFiles = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
313
+ for (const md of mdFiles) {
314
+ const mdContent = shared.safeRead(path.join(PLANS_DIR, md)) || '';
315
+ if (mdContent.includes(projectName) || mdContent.includes(plan.plan_summary?.slice(0, 40) || '___nomatch___')) {
316
+ try {
317
+ fs.renameSync(path.join(PLANS_DIR, md), path.join(planArchiveDir, md));
318
+ log('info', `Archived source plan: plans/archive/${md}`);
319
+ } catch (err) { log('warn', `Failed to archive plan ${md}: ${err.message}`); }
320
+ break;
321
+ }
287
322
  }
288
323
  }
289
324
  } catch (err) { log('warn', `Plan archive scan: ${err.message}`); }
290
325
 
291
- // 6. Clean up ALL worktrees created for this plan's work items (shared-branch + per-item)
326
+ // Clean up ALL worktrees created for this plan's work items (shared-branch + per-item)
292
327
  try {
293
- // Collect all branch slugs: shared-branch + per-item branches + item IDs
294
328
  const branchSlugs = new Set();
295
329
  if (plan.feature_branch) branchSlugs.add(shared.sanitizeBranch(plan.feature_branch).toLowerCase());
330
+
331
+ // Collect work items for this plan
332
+ let allWorkItems = [];
333
+ for (const p of projects) {
334
+ try {
335
+ const wi = safeJson(shared.projectWorkItemsPath(p)) || [];
336
+ allWorkItems = allWorkItems.concat(wi);
337
+ } catch { /* optional */ }
338
+ }
339
+ try {
340
+ const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
341
+ for (const w of central) {
342
+ if (!allWorkItems.some(existing => existing.id === w.id)) allWorkItems.push(w);
343
+ }
344
+ } catch { /* optional */ }
345
+ const planItems = allWorkItems.filter(w => w.sourcePlan === planFile);
346
+ const doneItems = planItems.filter(w => DONE_STATUSES.has(w.status));
347
+
296
348
  for (const w of doneItems) {
297
349
  if (w.branch) branchSlugs.add(shared.sanitizeBranch(w.branch).toLowerCase());
298
350
  if (w.id) branchSlugs.add(w.id.toLowerCase());
299
351
  }
300
- for (const pr of uniquePrs) {
301
- if (pr.branch) branchSlugs.add(shared.sanitizeBranch(pr.branch).toLowerCase());
352
+
353
+ // Collect PR branches
354
+ for (const p of projects) {
355
+ try {
356
+ const prs = safeJson(shared.projectPrPath(p)) || [];
357
+ const prLinks = getPrLinks();
358
+ for (const pr of prs) {
359
+ const linkedId = prLinks[pr.id];
360
+ if (linkedId && doneItems.find(w => w.id === linkedId) && pr.branch) {
361
+ branchSlugs.add(shared.sanitizeBranch(pr.branch).toLowerCase());
362
+ }
363
+ }
364
+ } catch { /* optional */ }
302
365
  }
303
366
 
304
367
  let cleanedWt = 0;
@@ -319,10 +382,8 @@ function checkPlanCompletion(meta, config) {
319
382
  }
320
383
  }
321
384
  }
322
- if (cleanedWt > 0) log('info', `Plan completion: cleaned ${cleanedWt} worktree(s)`);
385
+ if (cleanedWt > 0) log('info', `Archive: cleaned ${cleanedWt} worktree(s)`);
323
386
  } catch (err) { log('warn', `Worktree cleanup: ${err.message}`); }
324
-
325
- log('info', `PRD ${planFile} completed: ${doneItems.length} done, ${failedItems.length} failed, runtime ${runtimeMin}m`);
326
387
  }
327
388
 
328
389
  // ─── Plan → PRD Chaining ─────────────────────────────────────────────────────
@@ -381,6 +442,10 @@ function chainPlanToPrd(dispatchItem, meta, config) {
381
442
 
382
443
  const projectName = meta?.item?.project || meta?.project?.name;
383
444
  const projects = shared.getProjects(config);
445
+ if (projects.length === 0) {
446
+ log('error', 'Plan chaining: no projects configured');
447
+ return;
448
+ }
384
449
  const targetProject = projectName
385
450
  ? projects.find(p => p.name === projectName) || projects[0]
386
451
  : projects[0];
@@ -393,14 +458,17 @@ function chainPlanToPrd(dispatchItem, meta, config) {
393
458
  log('info', `Plan chaining: queuing plan-to-prd for next tick (chained from ${dispatchItem.id})`);
394
459
  const wiPath = path.join(MINIONS_DIR, 'work-items.json');
395
460
  let items = [];
396
- try { items = JSON.parse(fs.readFileSync(wiPath, 'utf8')); } catch {}
461
+ try { items = JSON.parse(fs.readFileSync(wiPath, 'utf8')); } catch (err) {
462
+ log('warn', `Failed to parse ${wiPath}: ${err.message}`);
463
+ try { fs.copyFileSync(wiPath, wiPath + '.bak'); } catch {}
464
+ }
397
465
  items.push({
398
466
  id: 'W-' + shared.uid(),
399
467
  title: `Convert plan to PRD: ${meta?.item?.title || planFile.name}`,
400
468
  type: 'plan-to-prd',
401
469
  priority: meta?.item?.priority || 'high',
402
470
  description: `Plan file: plans/${planFile.name}\nChained from plan task ${dispatchItem.id}`,
403
- status: 'pending',
471
+ status: WI_STATUS.PENDING,
404
472
  created: ts(),
405
473
  createdBy: 'engine:chain',
406
474
  project: targetProject.name,
@@ -410,10 +478,15 @@ function chainPlanToPrd(dispatchItem, meta, config) {
410
478
  }
411
479
 
412
480
  // ─── Work Item Status ────────────────────────────────────────────────────────
481
+ const _VALID_WI_STATUSES = new Set(Object.values(WI_STATUS));
413
482
  function updateWorkItemStatus(meta, status, reason) {
414
483
 
415
484
  const itemId = meta.item?.id;
416
485
  if (!itemId) return;
486
+ if (!_VALID_WI_STATUSES.has(status)) {
487
+ log('warn', `Invalid work item status '${status}' for ${itemId} — ignoring`);
488
+ return;
489
+ }
417
490
 
418
491
  let wiPath;
419
492
  if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
@@ -435,20 +508,20 @@ function updateWorkItemStatus(meta, status, reason) {
435
508
  target.agentResults[agent] = { status, completedAt: ts(), reason: reason || undefined };
436
509
 
437
510
  const results = Object.values(target.agentResults);
438
- const anySuccess = results.some(r => r.status === 'done');
511
+ const anySuccess = results.some(r => r.status === WI_STATUS.DONE);
439
512
  const allDone = Array.isArray(target.fanOutAgents) && target.fanOutAgents.length > 0 ? results.length >= target.fanOutAgents.length : false;
440
513
  const dispatchAge = target.dispatched_at ? Date.now() - new Date(target.dispatched_at).getTime() : 0;
441
514
  const timedOut = !allDone && dispatchAge > 6 * 60 * 60 * 1000 && results.length > 0;
442
515
 
443
516
  if (anySuccess) {
444
- target.status = 'done';
517
+ target.status = WI_STATUS.DONE;
445
518
  delete target.failReason;
446
519
  delete target.failedAt;
447
520
  target.completedAgents = Object.entries(target.agentResults)
448
- .filter(([, r]) => r.status === 'done')
521
+ .filter(([, r]) => r.status === WI_STATUS.DONE)
449
522
  .map(([a]) => a);
450
523
  } else if (allDone || timedOut) {
451
- target.status = 'failed';
524
+ target.status = WI_STATUS.FAILED;
452
525
  target.failReason = timedOut
453
526
  ? `Fan-out timed out: ${results.length}/${(target.fanOutAgents || []).length} agents reported (all failed)`
454
527
  : 'All fan-out agents failed';
@@ -456,11 +529,11 @@ function updateWorkItemStatus(meta, status, reason) {
456
529
  }
457
530
  } else {
458
531
  target.status = status;
459
- if (status === 'done') {
532
+ if (status === WI_STATUS.DONE) {
460
533
  delete target.failReason;
461
534
  delete target.failedAt;
462
535
  target.completedAt = ts();
463
- } else if (status === 'failed') {
536
+ } else if (status === WI_STATUS.FAILED) {
464
537
  if (reason) target.failReason = reason;
465
538
  target.failedAt = ts();
466
539
  }
@@ -474,8 +547,10 @@ function updateWorkItemStatus(meta, status, reason) {
474
547
  }
475
548
  }
476
549
 
550
+ const _VALID_PRD_STATUSES = new Set([...Object.values(WI_STATUS), 'missing']);
477
551
  function syncPrdItemStatus(itemId, status, sourcePlan) {
478
552
  if (!itemId) return;
553
+ if (!_VALID_PRD_STATUSES.has(status)) return;
479
554
  try {
480
555
  const prdDir = path.join(MINIONS_DIR, 'prd');
481
556
  const files = sourcePlan ? [sourcePlan] : require('fs').readdirSync(prdDir).filter(f => f.endsWith('.json'));
@@ -538,6 +613,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
538
613
  if (prMatches.size === 0) return 0;
539
614
 
540
615
  const projects = shared.getProjects(config);
616
+ if (projects.length === 0 && !meta?.project?.name) return 0;
541
617
  const defaultProject = (meta?.project?.name && projects.find(p => p.name === meta.project.name)) || projects[0];
542
618
  const useCentral = !defaultProject;
543
619
 
@@ -566,8 +642,9 @@ function syncPrsFromOutput(output, agentId, meta, config) {
566
642
  const agentName = config.agents?.[agentId]?.name || agentId;
567
643
  let added = 0;
568
644
  const centralPrPath = path.join(MINIONS_DIR, 'pull-requests.json');
569
- // Track which PR files need writing — keyed by target name
570
- const dirtyTargets = new Map(); // name -> { prs, prPath }
645
+
646
+ // Group new PRs by target file path
647
+ const newPrsByPath = new Map(); // prPath -> [{ prId, newEntry }]
571
648
 
572
649
  for (const prId of prMatches) {
573
650
  const fullId = `PR-${prId}`;
@@ -575,38 +652,43 @@ function syncPrsFromOutput(output, agentId, meta, config) {
575
652
  const targetName = targetProject ? targetProject.name : '_central';
576
653
  const prPath = targetProject ? shared.projectPrPath(targetProject) : centralPrPath;
577
654
 
578
- // Load PRs for this target (cache per target)
579
- if (!dirtyTargets.has(targetName)) {
580
- dirtyTargets.set(targetName, { prs: safeJson(prPath) || [], prPath });
581
- }
582
- const entry = dirtyTargets.get(targetName);
583
- if (entry.prs.some(p => p.id === fullId || String(p.id).includes(prId))) continue;
584
-
585
655
  let title = meta?.item?.title || '';
586
656
  const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
587
657
  if (titleMatch) title = titleMatch[1].trim();
588
658
  if (title.includes('session_id') || title.includes('is_error') || title.includes('uuid') || title.length > 120) {
589
659
  title = meta?.item?.title || '';
590
660
  }
591
- entry.prs.push({
592
- id: fullId,
593
- title: (title || `PR created by ${agentName}`).slice(0, 120),
594
- agent: agentName,
595
- branch: meta?.branch || '',
596
- reviewStatus: 'pending',
597
- status: 'active',
598
- created: dateStamp(),
599
- url: extractPrUrl(prId),
600
- prdItems: meta?.item?.id ? [meta.item.id] : [],
601
- sourcePlan: meta?.item?.sourcePlan || '',
602
- itemType: meta?.item?.itemType || ''
661
+
662
+ if (!newPrsByPath.has(prPath)) newPrsByPath.set(prPath, { name: targetName, entries: [] });
663
+ newPrsByPath.get(prPath).entries.push({
664
+ prId, fullId,
665
+ entry: {
666
+ id: fullId,
667
+ title: (title || `PR created by ${agentName}`).slice(0, 120),
668
+ agent: agentName,
669
+ branch: meta?.branch || '',
670
+ reviewStatus: 'pending',
671
+ status: PR_STATUS.ACTIVE,
672
+ created: dateStamp(),
673
+ url: extractPrUrl(prId),
674
+ prdItems: meta?.item?.id ? [meta.item.id] : [],
675
+ sourcePlan: meta?.item?.sourcePlan || '',
676
+ itemType: meta?.item?.itemType || ''
677
+ }
603
678
  });
604
- if (meta?.item?.id) addPrLink(fullId, meta.item.id);
605
- added++;
606
679
  }
607
680
 
608
- for (const [name, entry] of dirtyTargets) {
609
- shared.safeWrite(entry.prPath, entry.prs);
681
+ for (const [prPath, { name, entries }] of newPrsByPath) {
682
+ mutateJsonFileLocked(prPath, (data) => {
683
+ const prs = Array.isArray(data) ? data : [];
684
+ for (const { prId, fullId, entry } of entries) {
685
+ if (prs.some(p => p.id === fullId || String(p.id) === String(prId))) continue;
686
+ prs.push(entry);
687
+ if (meta?.item?.id) addPrLink(fullId, meta.item.id);
688
+ added++;
689
+ }
690
+ return prs;
691
+ });
610
692
  log('info', `Synced PR(s) from ${agentName}'s output to ${name === '_central' ? 'central' : name}/pull-requests.json`);
611
693
  }
612
694
  return added;
@@ -651,7 +733,7 @@ function updatePrAfterReview(agentId, pr, project) {
651
733
  }
652
734
 
653
735
  shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
654
- log('info', `Updated ${pr.id} → minions review: ${minionsVerdict} by ${reviewerName}`);
736
+ log('info', `Updated ${pr.id} → minions review: ${target.reviewStatus} by ${reviewerName}`);
655
737
  createReviewFeedbackForAuthor(agentId, { ...pr, ...target }, config);
656
738
  }
657
739
 
@@ -703,7 +785,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
703
785
  } catch (err) { log('warn', `Post-merge worktree cleanup: ${err.message}`); }
704
786
  }
705
787
 
706
- if (newStatus !== 'merged') return;
788
+ if (newStatus !== PR_STATUS.MERGED) return;
707
789
 
708
790
  // Resolve linked work item from pr-links or PR branch name
709
791
  let mergedItemId = getPrLinks()[pr.id];
@@ -722,13 +804,13 @@ async function handlePostMerge(pr, project, config, newStatus) {
722
804
  const plan = safeJson(path.join(prdDir, pf));
723
805
  if (!plan?.missing_features) continue;
724
806
  const feature = plan.missing_features.find(f => f.id === mergedItemId);
725
- if (feature && feature.status !== 'implemented') {
726
- feature.status = 'implemented';
807
+ if (feature && feature.status !== WI_STATUS.DONE) {
808
+ feature.status = WI_STATUS.DONE;
727
809
  shared.safeWrite(path.join(prdDir, pf), plan);
728
810
  updated++;
729
811
  }
730
812
  }
731
- if (updated > 0) log('info', `Post-merge: marked ${mergedItemId} as implemented for ${pr.id}`);
813
+ if (updated > 0) log('info', `Post-merge: marked ${mergedItemId} as done for ${pr.id}`);
732
814
  } catch (err) { log('warn', `Post-merge PRD update: ${err.message}`); }
733
815
 
734
816
  // Mark work item as done
@@ -739,10 +821,10 @@ async function handlePostMerge(pr, project, config, newStatus) {
739
821
  const items = safeJson(wiPath);
740
822
  if (!items) continue;
741
823
  const item = items.find(i => i.id === mergedItemId);
742
- if (item && item.status !== 'done') {
824
+ if (item && item.status !== WI_STATUS.DONE) {
743
825
  log('info', `Post-merge: marking work item ${mergedItemId} as done (was ${item.status}) for ${pr.id}`);
744
- item.status = 'done';
745
- item.completedAt = e.ts();
826
+ item.status = WI_STATUS.DONE;
827
+ item.completedAt = ts();
746
828
  item._mergedVia = pr.id;
747
829
  shared.safeWrite(wiPath, items);
748
830
  break;
@@ -827,19 +909,19 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config) {
827
909
  if (proj) {
828
910
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
829
911
  const items = safeJson(centralPath) || [];
830
- const alreadyExists = items.some(i => i.title === `Add skill: ${name}` && i.status !== 'failed');
912
+ const alreadyExists = items.some(i => i.title === `Add skill: ${name}` && i.status !== WI_STATUS.FAILED);
831
913
  if (!alreadyExists) {
832
914
  const skillId = `SK${String(items.filter(i => i.id?.startsWith('SK')).length + 1).padStart(3, '0')}`;
833
915
  items.push({ id: skillId, type: 'implement', title: `Add skill: ${name}`,
834
916
  description: `Create project-level skill \`${filename}\` in ${project}.\n\nWrite this file to \`${proj.localPath}/.claude/skills/${filename}\` via a PR.\n\n## Skill Content\n\n\`\`\`\n${enrichedBlock}\n\`\`\``,
835
- priority: 'low', status: 'queued', created: ts(), createdBy: `engine:skill-extraction:${agentName}` });
917
+ priority: 'low', status: WI_STATUS.QUEUED, created: ts(), createdBy: `engine:skill-extraction:${agentName}` });
836
918
  shared.safeWrite(centralPath, items);
837
919
  log('info', `Queued work item ${skillId} to PR project skill "${name}" into ${project}`);
838
920
  }
839
921
  }
840
922
  } else {
841
923
  // Write in Claude Code native format: ~/.claude/skills/<name>/SKILL.md
842
- const claudeSkillsDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'skills');
924
+ const claudeSkillsDir = path.join(os.homedir(), '.claude', 'skills');
843
925
  const skillDir = path.join(claudeSkillsDir, name.replace(/[^a-z0-9-]/g, '-'));
844
926
  const skillPath = path.join(skillDir, 'SKILL.md');
845
927
  if (!fs.existsSync(skillPath)) {
@@ -918,10 +1000,10 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
918
1000
  m.lastTask = dispatchItem.task;
919
1001
  m.lastCompleted = ts();
920
1002
  if (model) m.model = model;
921
- if (result === 'success') {
1003
+ if (result === DISPATCH_RESULT.SUCCESS) {
922
1004
  m.tasksCompleted++;
923
1005
  if (prsCreatedCount > 0) m.prsCreated = (m.prsCreated || 0) + prsCreatedCount;
924
- if (dispatchItem.type === 'review') m.reviewsDone++;
1006
+ if (dispatchItem.type === WORK_TYPE.REVIEW) m.reviewsDone++;
925
1007
  } else if (result === 'retry') {
926
1008
  // Auto-retry: count cost but not as a final outcome
927
1009
  m.tasksRetried = (m.tasksRetried || 0) + 1;
@@ -1003,7 +1085,7 @@ function handleDecompositionResult(stdout, meta, config) {
1003
1085
  if (!parent) continue;
1004
1086
 
1005
1087
  // Mark parent as decomposed
1006
- parent.status = 'decomposed';
1088
+ parent.status = WI_STATUS.DECOMPOSED;
1007
1089
  parent._decomposed = true;
1008
1090
  delete parent._decomposing;
1009
1091
  parent._subItemIds = subItems.map(s => s.id);
@@ -1017,7 +1099,7 @@ function handleDecompositionResult(stdout, meta, config) {
1017
1099
  type: (sub.estimated_complexity === 'large') ? 'implement:large' : 'implement',
1018
1100
  priority: sub.priority || parent.priority || 'medium',
1019
1101
  description: sub.description || '',
1020
- status: 'pending',
1102
+ status: WI_STATUS.PENDING,
1021
1103
  complexity: sub.estimated_complexity || 'medium',
1022
1104
  depends_on: sub.depends_on || [],
1023
1105
  parent_id: parentId,
@@ -1042,7 +1124,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1042
1124
  const type = dispatchItem.type;
1043
1125
  const meta = dispatchItem.meta;
1044
1126
  const isSuccess = code === 0;
1045
- const result = isSuccess ? 'success' : 'error';
1127
+ const result = isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
1046
1128
  const { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout);
1047
1129
 
1048
1130
  // Save session for potential resume on next dispatch
@@ -1057,13 +1139,13 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1057
1139
 
1058
1140
  // Handle decomposition results — create sub-items from decompose agent output
1059
1141
  let skipDoneStatus = false;
1060
- if (type === 'decompose' && isSuccess && meta?.item?.id) {
1142
+ if (type === WORK_TYPE.DECOMPOSE && isSuccess && meta?.item?.id) {
1061
1143
  const subCount = handleDecompositionResult(stdout, meta, config);
1062
1144
  if (subCount > 0) skipDoneStatus = true; // parent already marked 'decomposed' by handler
1063
1145
  // If decomposition produced nothing, fall through to mark parent as done
1064
1146
  }
1065
1147
 
1066
- if (isSuccess && meta?.item?.id && !skipDoneStatus) updateWorkItemStatus(meta, 'done', '');
1148
+ if (isSuccess && meta?.item?.id && !skipDoneStatus) updateWorkItemStatus(meta, WI_STATUS.DONE, '');
1067
1149
  if (!isSuccess && meta?.item?.id) {
1068
1150
  // Auto-retry: read fresh _retryCount from file (not stale dispatch-time snapshot)
1069
1151
  let retries = (meta.item._retryCount || 0);
@@ -1078,9 +1160,9 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1078
1160
  }
1079
1161
  } catch { /* optional */ }
1080
1162
 
1081
- if (retries < 3) {
1082
- log('info', `Agent failed for ${meta.item.id} — auto-retry ${retries + 1}/3`);
1083
- updateWorkItemStatus(meta, 'pending', '');
1163
+ if (retries < ENGINE_DEFAULTS.maxRetries) {
1164
+ log('info', `Agent failed for ${meta.item.id} — auto-retry ${retries + 1}/${ENGINE_DEFAULTS.maxRetries}`);
1165
+ updateWorkItemStatus(meta, WI_STATUS.PENDING, '');
1084
1166
  try {
1085
1167
  const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1086
1168
  ? path.join(MINIONS_DIR, 'work-items.json')
@@ -1089,17 +1171,17 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1089
1171
  const items = safeJson(wiPath) || [];
1090
1172
  const wi = items.find(i => i.id === meta.item.id);
1091
1173
  if (wi) {
1092
- wi._retryCount = retries + 1; wi.status = 'pending'; delete wi.dispatched_at; delete wi.dispatched_to;
1093
- if (type === 'decompose') delete wi._decomposing; // clear so item can retry decomposition
1174
+ wi._retryCount = retries + 1; wi.status = WI_STATUS.PENDING; delete wi.dispatched_at; delete wi.dispatched_to;
1175
+ if (type === WORK_TYPE.DECOMPOSE) delete wi._decomposing; // clear so item can retry decomposition
1094
1176
  shared.safeWrite(wiPath, items);
1095
1177
  }
1096
1178
  }
1097
1179
  } catch (err) { log('warn', `Retry update: ${err.message}`); }
1098
1180
  } else {
1099
- updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
1181
+ updateWorkItemStatus(meta, WI_STATUS.FAILED, `Agent failed (${ENGINE_DEFAULTS.maxRetries} retries exhausted)`);
1100
1182
  }
1101
1183
  // Clear _decomposing flag on failure so item doesn't get permanently stuck
1102
- if (type === 'decompose') {
1184
+ if (type === WORK_TYPE.DECOMPOSE) {
1103
1185
  try {
1104
1186
  const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1105
1187
  ? path.join(MINIONS_DIR, 'work-items.json')
@@ -1113,7 +1195,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1113
1195
  }
1114
1196
  }
1115
1197
  // Meeting post-completion: collect findings/debate/conclusion
1116
- if (type === 'meeting' && meta?.meetingId) {
1198
+ if (type === WORK_TYPE.MEETING && meta?.meetingId) {
1117
1199
  try {
1118
1200
  const { collectMeetingFindings } = require('./meeting');
1119
1201
  collectMeetingFindings(meta.meetingId, agentId, meta.roundName, stdout);
@@ -1126,6 +1208,19 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1126
1208
  let prsCreatedCount = 0;
1127
1209
  if (isSuccess) prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config) || 0;
1128
1210
 
1211
+ // After verify completes, archive the plan
1212
+ if (isSuccess && meta?.item?.itemType === 'verify' && meta?.item?.sourcePlan) {
1213
+ try {
1214
+ const vPlanFile = meta.item.sourcePlan;
1215
+ const vPlanPath = path.join(PRD_DIR, vPlanFile);
1216
+ const vPlan = safeJson(vPlanPath);
1217
+ if (vPlan) {
1218
+ const vProjects = shared.getProjects(config);
1219
+ archivePlan(vPlanFile, vPlan, vProjects, config);
1220
+ }
1221
+ } catch (err) { log('warn', `Verify archive: ${err.message}`); }
1222
+ }
1223
+
1129
1224
  // Clean up worktree for non-shared-branch tasks after completion
1130
1225
  if (meta?.branch && meta?.branchStrategy !== 'shared-branch') {
1131
1226
  try {
@@ -1140,7 +1235,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1140
1235
  return d.includes(branchSlug) && fs.statSync(path.join(worktreeRoot, d)).isDirectory();
1141
1236
  });
1142
1237
  // Only remove if no other active dispatch uses this branch
1143
- const dispatch = e.getDispatch();
1238
+ const dispatch = getDispatch();
1144
1239
  const otherActive = ((dispatch.active || []).concat(dispatch.pending || [])).some(d =>
1145
1240
  d.id !== dispatchItem.id && d.meta?.branch && shared.sanitizeBranch && shared.sanitizeBranch(d.meta.branch) === branchSlug
1146
1241
  );
@@ -1162,7 +1257,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1162
1257
  }
1163
1258
 
1164
1259
  // Detect implement tasks that completed without creating a PR
1165
- if (isSuccess && (type === 'implement' || type === 'implement:large' || type === 'fix') && prsCreatedCount === 0 && meta?.item?.id) {
1260
+ if (isSuccess && (type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX) && prsCreatedCount === 0 && meta?.item?.id) {
1166
1261
  // Check if a PR already exists linked to this work item (from a previous attempt)
1167
1262
  const projects = shared.getProjects(config);
1168
1263
  const existingPrFound = Object.values(getPrLinks()).includes(meta.item.id);
@@ -1182,15 +1277,15 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1182
1277
  wi.noPr = true;
1183
1278
  wi.failReason = 'Completed without creating a pull request';
1184
1279
  const retries = wi._retryCount || 0;
1185
- if (retries < 3) {
1186
- wi.status = 'pending';
1280
+ if (retries < ENGINE_DEFAULTS.maxRetries) {
1281
+ wi.status = WI_STATUS.PENDING;
1187
1282
  wi._retryCount = retries + 1;
1188
1283
  delete wi.dispatched_at;
1189
1284
  delete wi.dispatched_to;
1190
- e.log('info', `Auto-retry ${retries + 1}/3 for ${meta.item.id} (no PR created)`);
1285
+ log('info', `Auto-retry ${retries + 1}/${ENGINE_DEFAULTS.maxRetries} for ${meta.item.id} (no PR created)`);
1191
1286
  } else {
1192
- wi.status = 'failed';
1193
- e.log('warn', `${meta.item.id} failed after 3 retries — no PR created`);
1287
+ wi.status = WI_STATUS.FAILED;
1288
+ log('warn', `${meta.item.id} failed after ${ENGINE_DEFAULTS.maxRetries} retries — no PR created`);
1194
1289
  }
1195
1290
  shared.safeWrite(wiPath, items);
1196
1291
  }
@@ -1198,13 +1293,13 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1198
1293
  }
1199
1294
  }
1200
1295
 
1201
- if (type === 'review') updatePrAfterReview(agentId, meta?.pr, meta?.project);
1202
- if (type === 'fix') updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
1296
+ if (type === WORK_TYPE.REVIEW) updatePrAfterReview(agentId, meta?.pr, meta?.project);
1297
+ if (type === WORK_TYPE.FIX) updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
1203
1298
  checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
1204
1299
  if (isSuccess) extractSkillsFromOutput(stdout, agentId, dispatchItem, config);
1205
1300
  updateAgentHistory(agentId, dispatchItem, result);
1206
1301
  // Don't count auto-retries as errors in metrics — only count final outcomes
1207
- const isAutoRetry = !isSuccess && meta?.item?.id && (meta.item._retryCount || 0) < 3;
1302
+ const isAutoRetry = !isSuccess && meta?.item?.id && (meta.item._retryCount || 0) < ENGINE_DEFAULTS.maxRetries;
1208
1303
  const metricsResult = isAutoRetry ? 'retry' : result;
1209
1304
  updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
1210
1305
 
@@ -1229,14 +1324,14 @@ function syncPrdFromPrs(config) {
1229
1324
  for (const project of allProjects) {
1230
1325
  const wiPath = projectWorkItemsPath(project);
1231
1326
  const items = safeJson(wiPath) || [];
1232
- const hasPending = items.some(wi => wi.status === 'pending' && !wi._pr);
1327
+ const hasPending = items.some(wi => wi.status === WI_STATUS.PENDING && !wi._pr);
1233
1328
  if (!hasPending) continue;
1234
1329
  const reconciled = reconcileItemsWithPrs(items, allPrs);
1235
1330
  if (reconciled > 0) {
1236
1331
  safeWrite(wiPath, items);
1237
1332
  // Sync done status to PRD JSON for each newly reconciled item
1238
1333
  for (const wi of items) {
1239
- if (wi.status === 'done') syncPrdItemStatus(wi.id, 'done', wi.sourcePlan);
1334
+ if (wi.status === WI_STATUS.DONE) syncPrdItemStatus(wi.id, WI_STATUS.DONE, wi.sourcePlan);
1240
1335
  }
1241
1336
  totalReconciled += reconciled;
1242
1337
  }
@@ -1252,6 +1347,7 @@ function syncPrdFromPrs(config) {
1252
1347
 
1253
1348
  module.exports = {
1254
1349
  checkPlanCompletion,
1350
+ archivePlan,
1255
1351
  updateWorkItemStatus,
1256
1352
  syncPrdItemStatus,
1257
1353
  syncPrsFromOutput,
@@ -400,19 +400,19 @@ function buildAgentContext(agentId, config, project) {
400
400
  const projects = getProjects(config);
401
401
  const allPrs = [];
402
402
  for (const p of projects) {
403
- const prs = getPrs(p).filter(pr => pr.status === PR_STATUS.ACTIVE || pr.status === 'linked');
403
+ const prs = getPrs(p).filter(pr => pr.status === PR_STATUS.ACTIVE);
404
404
  for (const pr of prs) allPrs.push({ ...pr, _project: p.name });
405
405
  }
406
406
  // Also check central pull-requests.json
407
407
  try {
408
408
  const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json')) || [];
409
- for (const pr of centralPrs.filter(pr => pr.status === PR_STATUS.ACTIVE || pr.status === 'linked')) {
409
+ for (const pr of centralPrs.filter(pr => pr.status === PR_STATUS.ACTIVE)) {
410
410
  if (!allPrs.some(p => p.id === pr.id)) allPrs.push({ ...pr, _project: 'central' });
411
411
  }
412
412
  } catch (e) { log('warn', 'read central pull-requests: ' + e.message); }
413
413
  if (allPrs.length > 0) {
414
414
  const prLines = allPrs.map(pr =>
415
- `- **${pr.id}** (${pr._project}): ${(pr.title || '').slice(0, 80)} [${pr.status === 'linked' ? 'context-only' : (pr.reviewStatus || 'pending')}${pr.buildStatus === 'failing' ? ', BUILD FAILING' : ''}]${pr.branch ? ' branch: `' + pr.branch + '`' : ''}${pr._context ? ' — ' + pr._context.slice(0, 100) : ''}`
415
+ `- **${pr.id}** (${pr._project}): ${(pr.title || '').slice(0, 80)} [${(pr.reviewStatus || 'pending')}${pr.buildStatus === 'failing' ? ', BUILD FAILING' : ''}]${pr.branch ? ' branch: `' + pr.branch + '`' : ''}${pr._context ? ' — ' + pr._context.slice(0, 100) : ''}`
416
416
  );
417
417
  context += `## Active Pull Requests\n\n${prLines.join('\n')}\n\n`;
418
418
  }
package/engine/queries.js CHANGED
@@ -177,7 +177,7 @@ function getAgentStatus(agentId) {
177
177
  const latestInFlight = allItems
178
178
  .filter(w =>
179
179
  (w.dispatched_to || '').toLowerCase() === String(agentId).toLowerCase() &&
180
- (w.status === 'dispatched' || w.status === 'in-progress')
180
+ w.status === 'dispatched'
181
181
  )
182
182
  .sort((a, b) => (b.dispatched_at || '').localeCompare(a.dispatched_at || ''))[0];
183
183
  if (latestInFlight) {
@@ -679,9 +679,9 @@ function getPrdInfo(config) {
679
679
  }
680
680
 
681
681
  // PRD JSON status is the source of truth — kept in sync with work item by syncPrdItemStatus.
682
- // Map from PRD JSON values to display values (dispatchedin-progress etc.)
682
+ // Map from PRD JSON values to display values (pendingmissing for undispatched items)
683
683
  // Augment each item with execution metadata from the work item.
684
- const statusDisplay = { dispatched: 'in-progress', pending: 'missing' };
684
+ const statusDisplay = { pending: 'missing' };
685
685
  for (const item of items) {
686
686
  const wi = wiById[item.id];
687
687
  // Work item status is source of truth when available (PRD JSON may lag behind)
@@ -697,7 +697,7 @@ function getPrdInfo(config) {
697
697
  const byStatus = {};
698
698
  items.forEach(item => { const s = item.status || 'missing'; byStatus[s] = byStatus[s] || []; byStatus[s].push(item); });
699
699
  const complete = (byStatus['done'] || []).length;
700
- const inProgress = (byStatus['in-progress'] || []).length;
700
+ const inProgress = (byStatus['dispatched'] || []).length;
701
701
  const paused = (byStatus['paused'] || []).length;
702
702
  const missing = (byStatus['missing'] || []).length;
703
703
  const donePercent = total > 0 ? Math.round((complete / total) * 100) : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.328",
3
+ "version": "0.1.330",
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"