@yemi33/minions 0.1.326 → 0.1.328

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,8 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.326 (2026-04-03)
3
+ ## 0.1.328 (2026-04-03)
4
4
 
5
5
  ### Fixes
6
+ - import PR_STATUS in engine.js — was causing discoverWork to fail
7
+
8
+ ## 0.1.327 (2026-04-03)
9
+
10
+ ### Fixes
11
+ - eliminate all legacy done-status writers, add CANCELLED to WI_STATUS
6
12
  - stop writing legacy done aliases, add migration, keep read compat
7
13
 
8
14
  ## 0.1.325 (2026-04-03)
package/engine/cleanup.js CHANGED
@@ -441,7 +441,7 @@ function runCleanup(config, verbose = false) {
441
441
  let migrated = 0;
442
442
  for (const item of items) {
443
443
  if (LEGACY_DONE_ALIASES.has(item.status)) {
444
- item.status = 'done';
444
+ item.status = shared.WI_STATUS.DONE;
445
445
  delete item._pendingReason;
446
446
  migrated++;
447
447
  }
@@ -459,7 +459,7 @@ function runCleanup(config, verbose = false) {
459
459
  let migrated = 0;
460
460
  for (const item of centralItems) {
461
461
  if (LEGACY_DONE_ALIASES.has(item.status)) {
462
- item.status = 'done';
462
+ item.status = shared.WI_STATUS.DONE;
463
463
  delete item._pendingReason;
464
464
  migrated++;
465
465
  }
@@ -486,7 +486,7 @@ function runCleanup(config, verbose = false) {
486
486
  let migrated = 0;
487
487
  for (const feat of prd.missing_features) {
488
488
  if (LEGACY_DONE_ALIASES.has(feat.status)) {
489
- feat.status = 'done';
489
+ feat.status = shared.WI_STATUS.DONE;
490
490
  migrated++;
491
491
  }
492
492
  }
@@ -5,11 +5,9 @@
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
- const os = require('os');
9
8
  const shared = require('./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;
9
+ const { safeRead, safeJson, safeWrite, execSilent, projectPrPath, getPrLinks, addPrLink,
10
+ log, ts, dateStamp } = shared;
13
11
  const { trackEngineUsage } = require('./llm');
14
12
  const queries = require('./queries');
15
13
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
@@ -23,10 +21,7 @@ function checkPlanCompletion(meta, config) {
23
21
  const planPath = path.join(PRD_DIR, planFile);
24
22
  const plan = safeJson(planPath);
25
23
  if (!plan?.missing_features) return;
26
- if (plan.status === 'completed') {
27
- if (plan._completionNotified) return;
28
- // Crash recovery: status=completed but _completionNotified not set — fall through
29
- }
24
+ if (plan.status === 'completed') return;
30
25
 
31
26
  const projects = shared.getProjects(config);
32
27
 
@@ -58,7 +53,7 @@ function checkPlanCompletion(meta, config) {
58
53
  const unmaterialized = [...planFeatureIds].filter(id => {
59
54
  if (workItemById[id]) return false;
60
55
  const prdItem = (plan.missing_features || []).find(f => f.id === id);
61
- return !(prdItem && DONE_STATUSES.has(prdItem.status));
56
+ return !(prdItem && (prdItem.status === 'done' || prdItem.status === 'in-pr'));
62
57
  });
63
58
  if (unmaterialized.length > 0) {
64
59
  log('info', `Plan ${planFile}: ${unmaterialized.length}/${planFeatureIds.size} feature(s) not yet materialized as work items: ${unmaterialized.join(', ')}`);
@@ -68,17 +63,17 @@ function checkPlanCompletion(meta, config) {
68
63
  // Check 2: every feature's work item must be done (or PRD item marked done externally)
69
64
  const notDone = [...planFeatureIds].filter(id => {
70
65
  const w = workItemById[id];
71
- if (w && DONE_STATUSES.has(w.status)) return false;
66
+ if (w && (w.status === 'done' || w.status === 'in-pr')) return false; // in-pr accepted for backward compat
72
67
  const prdItem = (plan.missing_features || []).find(f => f.id === id);
73
- return !(prdItem && DONE_STATUSES.has(prdItem.status));
68
+ return !(prdItem && (prdItem.status === 'done' || prdItem.status === 'in-pr'));
74
69
  });
75
70
  if (notDone.length > 0) {
76
71
  log('info', `Plan ${planFile}: waiting for done on ${notDone.length}/${planFeatureIds.size} item(s): ${notDone.join(', ')}`);
77
72
  return;
78
73
  }
79
74
 
80
- const doneItems = planItems.filter(w => DONE_STATUSES.has(w.status));
81
- const failedItems = planItems.filter(w => w.status === WI_STATUS.FAILED);
75
+ const doneItems = planItems.filter(w => w.status === 'done' || w.status === 'in-pr');
76
+ const failedItems = planItems.filter(w => w.status === 'failed');
82
77
 
83
78
  // 1. Mark plan as completed
84
79
  plan.status = 'completed';
@@ -137,30 +132,15 @@ function checkPlanCompletion(meta, config) {
137
132
  ...uniquePrs.map(pr => `- ${pr.id}: ${pr.title || ''} ${pr.url || ''}`),
138
133
  ].filter(Boolean).join('\n');
139
134
 
140
- // Write summary to notes/inbox (slug-based dedup prevents duplicates on same day)
141
- const slug = `prd-completion-${planFile.replace('.json', '')}`;
142
- const wrote = shared.writeToInbox('engine', slug, summary);
143
- if (wrote) log('info', `PRD completion summary written to notes/inbox/`);
144
-
145
- // Persist status and _completionNotified atomically BEFORE creating work items
146
- // NOTE: Do NOT set plan._completionNotified in-memory before persist —
147
- // if persist fails, the in-memory flag would prevent retry on next tick.
148
- mutateJsonFileLocked(planPath, (data) => {
149
- data.status = 'completed';
150
- data.completedAt = plan.completedAt;
151
- data._completionNotified = true;
152
- if (plan._timing) data._timing = plan._timing;
153
- return data;
154
- });
135
+ // 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}`);
155
139
 
156
140
  // Resolve the primary project for writing new work items (PR, verify)
157
141
  const projectName = plan.project;
158
142
  const primaryProject = projectName
159
- ? projects.find(p => p.name?.toLowerCase() === projectName?.toLowerCase()) : (projects[0] || null);
160
- if (!primaryProject) {
161
- log('warn', `checkPlanCompletion: no project available (projects array ${projects.length === 0 ? 'empty' : 'no match for ' + projectName}) — skipping PR/verify creation for ${planFile}`);
162
- return;
163
- }
143
+ ? projects.find(p => p.name?.toLowerCase() === projectName?.toLowerCase()) : projects[0];
164
144
  const wiPath = primaryProject ? shared.projectWorkItemsPath(primaryProject) : null;
165
145
  const workItems = wiPath ? (safeJson(wiPath) || []) : [];
166
146
 
@@ -197,7 +177,7 @@ function checkPlanCompletion(meta, config) {
197
177
  const prs = (safeJson(shared.projectPrPath(p)) || [])
198
178
  .filter(pr => {
199
179
  const linkedId = prLinks[pr.id];
200
- return pr.status === PR_STATUS.ACTIVE && linkedId && doneItems.find(w => w.id === linkedId);
180
+ return pr.status === 'active' && linkedId && doneItems.find(w => w.id === linkedId);
201
181
  });
202
182
  if (prs.length > 0) {
203
183
  projectPrs[p.name] = { project: p, prs, mainBranch: p.mainBranch || 'main' };
@@ -206,12 +186,11 @@ function checkPlanCompletion(meta, config) {
206
186
 
207
187
  // Build per-project checkout commands: one worktree, merge all PR branches into it
208
188
  const checkoutBlocks = Object.entries(projectPrs).map(([name, { project: p, prs, mainBranch }]) => {
209
- const localPath = p.localPath.replace(/\\/g, '/');
210
- const wtPath = `${localPath}/../worktrees/verify-${name}-${planSlug}-${shared.uid()}`;
189
+ const wtPath = `${p.localPath}/../worktrees/verify-${name}-${planSlug}-${shared.uid()}`;
211
190
  const branches = prs.map(pr => pr.branch).filter(Boolean);
212
191
  const lines = [
213
192
  `# ${name} — merge ${branches.length} PR branch(es) into one worktree`,
214
- `cd "${localPath}"`,
193
+ `cd "${p.localPath}"`,
215
194
  `git fetch origin ${branches.map(b => `"${b}"`).join(' ')} "${mainBranch}"`,
216
195
  `git worktree add "${wtPath}" "origin/${mainBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${mainBranch}" && git pull origin "${mainBranch}")`,
217
196
  `cd "${wtPath}"`,
@@ -232,10 +211,9 @@ function checkPlanCompletion(meta, config) {
232
211
  ).join('\n');
233
212
 
234
213
  // List projects and their worktree paths for the agent
235
- const projectWorktrees = Object.entries(projectPrs).map(([name, { project: p }]) => {
236
- const lp = p.localPath.replace(/\\/g, '/');
237
- return `- **${name}**: see setup commands below (\`${lp}/../worktrees/verify-${name}-${planSlug}-*\`)`;
238
- }).join('\n');
214
+ const projectWorktrees = Object.entries(projectPrs).map(([name, { project: p }]) =>
215
+ `- **${name}**: \`${p.localPath}/../worktrees/verify-${planSlug}\``
216
+ ).join('\n');
239
217
 
240
218
  const description = [
241
219
  `Verification task for completed plan \`${planFile}\`.`,
@@ -280,75 +258,46 @@ function checkPlanCompletion(meta, config) {
280
258
  log('info', `Created verification work item ${verifyId} for plan ${planFile}`);
281
259
  }
282
260
 
283
- // 5. Archive deferred until verify completes (see runPostCompletionHooks).
284
- // Plan stays active until verification finishes so artifacts are visible.
285
-
286
- log('info', `PRD ${planFile} completed: ${doneItems.length} done, ${failedItems.length} failed, runtime ${runtimeMin}m`);
287
- }
288
-
289
- // ─── Plan Archiving (called after verify completes) ─────────────────────────
290
-
291
- function archivePlan(planFile, plan, projects, config) {
292
- const planPath = path.join(PRD_DIR, planFile);
293
- const projectName = plan.project || '';
294
-
295
- // Archive PRD .json to prd/archive/
261
+ // 5. Archive: move PRD .json to prd/archive/ and source .md plan to plans/archive/
296
262
  const prdArchiveDir = path.join(PRD_DIR, 'archive');
297
263
  if (!fs.existsSync(prdArchiveDir)) fs.mkdirSync(prdArchiveDir, { recursive: true });
298
- if (fs.existsSync(planPath)) {
264
+ shared.safeWrite(planPath, plan); // save completed status first
265
+ try {
266
+ fs.renameSync(planPath, path.join(prdArchiveDir, planFile));
267
+ log('info', `Archived completed PRD: prd/archive/${planFile}`);
268
+ } catch (err) {
269
+ log('warn', `Failed to archive PRD ${planFile}: ${err.message}`);
299
270
  shared.safeWrite(planPath, plan);
300
- try {
301
- fs.renameSync(planPath, path.join(prdArchiveDir, planFile));
302
- log('info', `Archived completed PRD: prd/archive/${planFile}`);
303
- } catch (err) { log('warn', `Failed to archive PRD ${planFile}: ${err.message}`); }
304
271
  }
305
272
 
306
- // Archive the source .md plan
273
+ // Also archive the source .md plan if it exists
307
274
  const planArchiveDir = path.join(PLANS_DIR, 'archive');
308
275
  if (!fs.existsSync(planArchiveDir)) fs.mkdirSync(planArchiveDir, { recursive: true });
309
- if (plan.source_plan) {
310
- const mdPath = path.join(PLANS_DIR, plan.source_plan);
311
- if (fs.existsSync(mdPath)) {
312
- try {
313
- fs.renameSync(mdPath, path.join(planArchiveDir, plan.source_plan));
314
- log('info', `Archived source plan: plans/archive/${plan.source_plan}`);
315
- } catch (err) { log('warn', `Failed to archive source plan ${plan.source_plan}: ${err.message}`); }
316
- }
317
- } else {
318
- try {
319
- const mdFiles = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
320
- for (const md of mdFiles) {
321
- const mdContent = shared.safeRead(path.join(PLANS_DIR, md)) || '';
322
- if (mdContent.includes(projectName) || mdContent.includes(plan.plan_summary?.slice(0, 40) || '___nomatch___')) {
323
- try {
324
- fs.renameSync(path.join(PLANS_DIR, md), path.join(planArchiveDir, md));
325
- log('info', `Archived source plan: plans/archive/${md}`);
326
- } catch (err) { log('warn', `Failed to archive plan ${md}: ${err.message}`); }
327
- break;
328
- }
329
- }
330
- } catch (err) { log('warn', `Plan archive scan: ${err.message}`); }
331
- }
332
-
333
- // Clean up ALL worktrees created for this plan's work items (shared-branch + per-item)
334
276
  try {
335
- let allWi = [];
336
- for (const p of projects) {
337
- try { allWi = allWi.concat(safeJson(shared.projectWorkItemsPath(p)) || []); } catch {}
338
- }
339
- const planWi = allWi.filter(w => w.sourcePlan === planFile && w.itemType !== 'verify');
340
- const allPrs = [];
341
- for (const p of projects) {
342
- try { allPrs.push(...(safeJson(shared.projectPrPath(p)) || [])); } catch {}
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;
287
+ }
343
288
  }
289
+ } catch (err) { log('warn', `Plan archive scan: ${err.message}`); }
344
290
 
291
+ // 6. Clean up ALL worktrees created for this plan's work items (shared-branch + per-item)
292
+ try {
293
+ // Collect all branch slugs: shared-branch + per-item branches + item IDs
345
294
  const branchSlugs = new Set();
346
295
  if (plan.feature_branch) branchSlugs.add(shared.sanitizeBranch(plan.feature_branch).toLowerCase());
347
- for (const w of planWi) {
296
+ for (const w of doneItems) {
348
297
  if (w.branch) branchSlugs.add(shared.sanitizeBranch(w.branch).toLowerCase());
349
298
  if (w.id) branchSlugs.add(w.id.toLowerCase());
350
299
  }
351
- for (const pr of allPrs.filter(pr => (pr.prdItems || []).some(id => planWi.find(w => w.id === id)))) {
300
+ for (const pr of uniquePrs) {
352
301
  if (pr.branch) branchSlugs.add(shared.sanitizeBranch(pr.branch).toLowerCase());
353
302
  }
354
303
 
@@ -370,8 +319,10 @@ function archivePlan(planFile, plan, projects, config) {
370
319
  }
371
320
  }
372
321
  }
373
- if (cleanedWt > 0) log('info', `Plan archive: cleaned ${cleanedWt} worktree(s)`);
322
+ if (cleanedWt > 0) log('info', `Plan completion: cleaned ${cleanedWt} worktree(s)`);
374
323
  } catch (err) { log('warn', `Worktree cleanup: ${err.message}`); }
324
+
325
+ log('info', `PRD ${planFile} completed: ${doneItems.length} done, ${failedItems.length} failed, runtime ${runtimeMin}m`);
375
326
  }
376
327
 
377
328
  // ─── Plan → PRD Chaining ─────────────────────────────────────────────────────
@@ -430,10 +381,6 @@ function chainPlanToPrd(dispatchItem, meta, config) {
430
381
 
431
382
  const projectName = meta?.item?.project || meta?.project?.name;
432
383
  const projects = shared.getProjects(config);
433
- if (projects.length === 0) {
434
- log('error', 'Plan chaining: no projects configured — cannot chain plan to PRD');
435
- return;
436
- }
437
384
  const targetProject = projectName
438
385
  ? projects.find(p => p.name === projectName) || projects[0]
439
386
  : projects[0];
@@ -446,10 +393,7 @@ function chainPlanToPrd(dispatchItem, meta, config) {
446
393
  log('info', `Plan chaining: queuing plan-to-prd for next tick (chained from ${dispatchItem.id})`);
447
394
  const wiPath = path.join(MINIONS_DIR, 'work-items.json');
448
395
  let items = [];
449
- try { items = JSON.parse(fs.readFileSync(wiPath, 'utf8')); } catch (err) {
450
- log('warn', `Failed to parse ${wiPath}: ${err.message} — creating .bak and starting fresh`);
451
- try { fs.copyFileSync(wiPath, wiPath + '.bak'); } catch {}
452
- }
396
+ try { items = JSON.parse(fs.readFileSync(wiPath, 'utf8')); } catch {}
453
397
  items.push({
454
398
  id: 'W-' + shared.uid(),
455
399
  title: `Convert plan to PRD: ${meta?.item?.title || planFile.name}`,
@@ -491,20 +435,20 @@ function updateWorkItemStatus(meta, status, reason) {
491
435
  target.agentResults[agent] = { status, completedAt: ts(), reason: reason || undefined };
492
436
 
493
437
  const results = Object.values(target.agentResults);
494
- const anySuccess = results.some(r => r.status === WI_STATUS.DONE);
438
+ const anySuccess = results.some(r => r.status === 'done');
495
439
  const allDone = Array.isArray(target.fanOutAgents) && target.fanOutAgents.length > 0 ? results.length >= target.fanOutAgents.length : false;
496
440
  const dispatchAge = target.dispatched_at ? Date.now() - new Date(target.dispatched_at).getTime() : 0;
497
441
  const timedOut = !allDone && dispatchAge > 6 * 60 * 60 * 1000 && results.length > 0;
498
442
 
499
443
  if (anySuccess) {
500
- target.status = WI_STATUS.DONE;
444
+ target.status = 'done';
501
445
  delete target.failReason;
502
446
  delete target.failedAt;
503
447
  target.completedAgents = Object.entries(target.agentResults)
504
- .filter(([, r]) => r.status === WI_STATUS.DONE)
448
+ .filter(([, r]) => r.status === 'done')
505
449
  .map(([a]) => a);
506
450
  } else if (allDone || timedOut) {
507
- target.status = WI_STATUS.FAILED;
451
+ target.status = 'failed';
508
452
  target.failReason = timedOut
509
453
  ? `Fan-out timed out: ${results.length}/${(target.fanOutAgents || []).length} agents reported (all failed)`
510
454
  : 'All fan-out agents failed';
@@ -512,11 +456,11 @@ function updateWorkItemStatus(meta, status, reason) {
512
456
  }
513
457
  } else {
514
458
  target.status = status;
515
- if (status === WI_STATUS.DONE) {
459
+ if (status === 'done') {
516
460
  delete target.failReason;
517
461
  delete target.failedAt;
518
462
  target.completedAt = ts();
519
- } else if (status === WI_STATUS.FAILED) {
463
+ } else if (status === 'failed') {
520
464
  if (reason) target.failReason = reason;
521
465
  target.failedAt = ts();
522
466
  }
@@ -594,11 +538,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
594
538
  if (prMatches.size === 0) return 0;
595
539
 
596
540
  const projects = shared.getProjects(config);
597
- if (projects.length === 0 && !meta?.project?.name) {
598
- log('warn', `syncPrsFromOutput: no projects configured and no project in meta — cannot sync PRs`);
599
- return 0;
600
- }
601
- const defaultProject = (meta?.project?.name && projects.find(p => p.name === meta.project.name)) || (projects[0] || null);
541
+ const defaultProject = (meta?.project?.name && projects.find(p => p.name === meta.project.name)) || projects[0];
602
542
  const useCentral = !defaultProject;
603
543
 
604
544
  // Match each PR to its correct project by finding which repo URL appears near the PR number in output
@@ -626,54 +566,47 @@ function syncPrsFromOutput(output, agentId, meta, config) {
626
566
  const agentName = config.agents?.[agentId]?.name || agentId;
627
567
  let added = 0;
628
568
  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 }
629
571
 
630
- // Group PR matches by target file so we take one lock per target
631
- const targetPrIds = new Map(); // targetName -> { prPath, prIds: [{ prId, fullId }] }
632
572
  for (const prId of prMatches) {
633
573
  const fullId = `PR-${prId}`;
634
574
  const targetProject = useCentral ? null : resolveProjectForPr(prId);
635
575
  const targetName = targetProject ? targetProject.name : '_central';
636
576
  const prPath = targetProject ? shared.projectPrPath(targetProject) : centralPrPath;
637
- if (!targetPrIds.has(targetName)) {
638
- targetPrIds.set(targetName, { prPath, prIds: [] });
577
+
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
+ let title = meta?.item?.title || '';
586
+ const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
587
+ if (titleMatch) title = titleMatch[1].trim();
588
+ if (title.includes('session_id') || title.includes('is_error') || title.includes('uuid') || title.length > 120) {
589
+ title = meta?.item?.title || '';
639
590
  }
640
- targetPrIds.get(targetName).prIds.push({ prId, fullId });
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 || ''
603
+ });
604
+ if (meta?.item?.id) addPrLink(fullId, meta.item.id);
605
+ added++;
641
606
  }
642
607
 
643
- // For each target, read+modify+write inside a single locked callback
644
- for (const [name, { prPath, prIds }] of targetPrIds) {
645
- mutateJsonFileLocked(prPath, (prs) => {
646
- if (!Array.isArray(prs)) prs = [];
647
- // Deduplicate any existing entries with same id (case-insensitive agent name race)
648
- const seen = new Set();
649
- prs = prs.filter(p => { const k = String(p.id); if (seen.has(k)) return false; seen.add(k); return true; });
650
- for (const { prId, fullId } of prIds) {
651
- if (prs.some(p => p.id === fullId || String(p.id) === String(prId))) continue;
652
-
653
- let title = meta?.item?.title || '';
654
- const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
655
- if (titleMatch) title = titleMatch[1].trim();
656
- if (title.includes('session_id') || title.includes('is_error') || title.includes('uuid') || title.length > 120) {
657
- title = meta?.item?.title || '';
658
- }
659
- prs.push({
660
- id: fullId,
661
- title: (title || `PR created by ${agentName}`).slice(0, 120),
662
- agent: agentName,
663
- branch: meta?.branch || '',
664
- reviewStatus: 'pending',
665
- status: 'active',
666
- created: dateStamp(),
667
- url: extractPrUrl(prId),
668
- prdItems: meta?.item?.id ? [meta.item.id] : [],
669
- sourcePlan: meta?.item?.sourcePlan || '',
670
- itemType: meta?.item?.itemType || ''
671
- });
672
- if (meta?.item?.id) addPrLink(fullId, meta.item.id);
673
- added++;
674
- }
675
- return prs;
676
- }, { defaultValue: [] });
608
+ for (const [name, entry] of dirtyTargets) {
609
+ shared.safeWrite(entry.prPath, entry.prs);
677
610
  log('info', `Synced PR(s) from ${agentName}'s output to ${name === '_central' ? 'central' : name}/pull-requests.json`);
678
611
  }
679
612
  return added;
@@ -693,7 +626,7 @@ function updatePrAfterReview(agentId, pr, project) {
693
626
  // Record the reviewer — actual verdict comes from ADO/GitHub votes via pollPrStatus.
694
627
  // Set to 'waiting' so pollPrStatus updates it with the real vote on next cycle.
695
628
  const dispatch = getDispatch();
696
- const completedEntry = (dispatch.completed || []).find(d => d.agent === agentId && d.type === WORK_TYPE.REVIEW);
629
+ const completedEntry = (dispatch.completed || []).find(d => d.agent === agentId && d.type === 'review');
697
630
 
698
631
  // Set reviewStatus to 'waiting' (single source of truth — synced from ADO/GitHub votes on next poll)
699
632
  target.reviewStatus = 'waiting';
@@ -718,7 +651,7 @@ function updatePrAfterReview(agentId, pr, project) {
718
651
  }
719
652
 
720
653
  shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
721
- log('info', `Updated ${pr.id} → minions review: ${target.reviewStatus} by ${reviewerName}`);
654
+ log('info', `Updated ${pr.id} → minions review: ${minionsVerdict} by ${reviewerName}`);
722
655
  createReviewFeedbackForAuthor(agentId, { ...pr, ...target }, config);
723
656
  }
724
657
 
@@ -789,13 +722,13 @@ async function handlePostMerge(pr, project, config, newStatus) {
789
722
  const plan = safeJson(path.join(prdDir, pf));
790
723
  if (!plan?.missing_features) continue;
791
724
  const feature = plan.missing_features.find(f => f.id === mergedItemId);
792
- if (feature && feature.status !== WI_STATUS.DONE) {
793
- feature.status = WI_STATUS.DONE;
725
+ if (feature && feature.status !== 'implemented') {
726
+ feature.status = 'implemented';
794
727
  shared.safeWrite(path.join(prdDir, pf), plan);
795
728
  updated++;
796
729
  }
797
730
  }
798
- if (updated > 0) log('info', `Post-merge: marked ${mergedItemId} as done for ${pr.id}`);
731
+ if (updated > 0) log('info', `Post-merge: marked ${mergedItemId} as implemented for ${pr.id}`);
799
732
  } catch (err) { log('warn', `Post-merge PRD update: ${err.message}`); }
800
733
 
801
734
  // Mark work item as done
@@ -809,7 +742,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
809
742
  if (item && item.status !== 'done') {
810
743
  log('info', `Post-merge: marking work item ${mergedItemId} as done (was ${item.status}) for ${pr.id}`);
811
744
  item.status = 'done';
812
- item.completedAt = ts();
745
+ item.completedAt = e.ts();
813
746
  item._mergedVia = pr.id;
814
747
  shared.safeWrite(wiPath, items);
815
748
  break;
@@ -906,7 +839,7 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config) {
906
839
  }
907
840
  } else {
908
841
  // Write in Claude Code native format: ~/.claude/skills/<name>/SKILL.md
909
- const claudeSkillsDir = path.join(os.homedir(), '.claude', 'skills');
842
+ const claudeSkillsDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'skills');
910
843
  const skillDir = path.join(claudeSkillsDir, name.replace(/[^a-z0-9-]/g, '-'));
911
844
  const skillPath = path.join(skillDir, 'SKILL.md');
912
845
  if (!fs.existsSync(skillPath)) {
@@ -985,10 +918,10 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
985
918
  m.lastTask = dispatchItem.task;
986
919
  m.lastCompleted = ts();
987
920
  if (model) m.model = model;
988
- if (result === DISPATCH_RESULT.SUCCESS) {
921
+ if (result === 'success') {
989
922
  m.tasksCompleted++;
990
923
  if (prsCreatedCount > 0) m.prsCreated = (m.prsCreated || 0) + prsCreatedCount;
991
- if (dispatchItem.type === WORK_TYPE.REVIEW) m.reviewsDone++;
924
+ if (dispatchItem.type === 'review') m.reviewsDone++;
992
925
  } else if (result === 'retry') {
993
926
  // Auto-retry: count cost but not as a final outcome
994
927
  m.tasksRetried = (m.tasksRetried || 0) + 1;
@@ -1109,7 +1042,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1109
1042
  const type = dispatchItem.type;
1110
1043
  const meta = dispatchItem.meta;
1111
1044
  const isSuccess = code === 0;
1112
- const result = isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
1045
+ const result = isSuccess ? 'success' : 'error';
1113
1046
  const { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout);
1114
1047
 
1115
1048
  // Save session for potential resume on next dispatch
@@ -1145,10 +1078,9 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1145
1078
  }
1146
1079
  } catch { /* optional */ }
1147
1080
 
1148
- const maxRetries = ENGINE_DEFAULTS.maxRetries;
1149
- if (retries < maxRetries) {
1150
- log('info', `Agent failed for ${meta.item.id} — auto-retry ${retries + 1}/${maxRetries}`);
1151
- updateWorkItemStatus(meta, WI_STATUS.PENDING, '');
1081
+ if (retries < 3) {
1082
+ log('info', `Agent failed for ${meta.item.id} — auto-retry ${retries + 1}/3`);
1083
+ updateWorkItemStatus(meta, 'pending', '');
1152
1084
  try {
1153
1085
  const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1154
1086
  ? path.join(MINIONS_DIR, 'work-items.json')
@@ -1157,14 +1089,14 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1157
1089
  const items = safeJson(wiPath) || [];
1158
1090
  const wi = items.find(i => i.id === meta.item.id);
1159
1091
  if (wi) {
1160
- wi._retryCount = retries + 1; wi.status = WI_STATUS.PENDING; delete wi.dispatched_at; delete wi.dispatched_to;
1161
- if (type === WORK_TYPE.DECOMPOSE) delete wi._decomposing;
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
1162
1094
  shared.safeWrite(wiPath, items);
1163
1095
  }
1164
1096
  }
1165
1097
  } catch (err) { log('warn', `Retry update: ${err.message}`); }
1166
1098
  } else {
1167
- updateWorkItemStatus(meta, WI_STATUS.FAILED, `Agent failed (${maxRetries} retries exhausted)`);
1099
+ updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
1168
1100
  }
1169
1101
  // Clear _decomposing flag on failure so item doesn't get permanently stuck
1170
1102
  if (type === 'decompose') {
@@ -1194,19 +1126,6 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1194
1126
  let prsCreatedCount = 0;
1195
1127
  if (isSuccess) prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config) || 0;
1196
1128
 
1197
- // Archive plan after verify task completes (AFTER PR sync so E2E PR is linked)
1198
- if (meta?.item?.itemType === 'verify' && meta?.item?.sourcePlan) {
1199
- try {
1200
- const vPlanFile = meta.item.sourcePlan;
1201
- const vPlanPath = path.join(PRD_DIR, vPlanFile);
1202
- const vPlan = safeJson(vPlanPath);
1203
- if (vPlan) {
1204
- const vProjects = shared.getProjects(config);
1205
- archivePlan(vPlanFile, vPlan, vProjects, config);
1206
- }
1207
- } catch (err) { log('warn', `Verify archive: ${err.message}`); }
1208
- }
1209
-
1210
1129
  // Clean up worktree for non-shared-branch tasks after completion
1211
1130
  if (meta?.branch && meta?.branchStrategy !== 'shared-branch') {
1212
1131
  try {
@@ -1221,7 +1140,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1221
1140
  return d.includes(branchSlug) && fs.statSync(path.join(worktreeRoot, d)).isDirectory();
1222
1141
  });
1223
1142
  // Only remove if no other active dispatch uses this branch
1224
- const dispatch = getDispatch();
1143
+ const dispatch = e.getDispatch();
1225
1144
  const otherActive = ((dispatch.active || []).concat(dispatch.pending || [])).some(d =>
1226
1145
  d.id !== dispatchItem.id && d.meta?.branch && shared.sanitizeBranch && shared.sanitizeBranch(d.meta.branch) === branchSlug
1227
1146
  );
@@ -1229,7 +1148,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1229
1148
  for (const dir of dirs) {
1230
1149
  const wtPath = path.join(worktreeRoot, dir);
1231
1150
  try {
1232
- execSilent(`git worktree remove "${wtPath}" --force`, { cwd: rootDir, timeout: 15000 });
1151
+ shared.exec(`git worktree remove "${wtPath}" --force`, { cwd: rootDir, stdio: 'pipe', timeout: 15000, windowsHide: true });
1233
1152
  log('info', `Post-completion: removed worktree ${dir}`);
1234
1153
  } catch (err) {
1235
1154
  log('warn', `Post-completion: failed to remove worktree ${dir}: ${err.message}`);
@@ -1243,7 +1162,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1243
1162
  }
1244
1163
 
1245
1164
  // Detect implement tasks that completed without creating a PR
1246
- if (isSuccess && (type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX) && prsCreatedCount === 0 && meta?.item?.id) {
1165
+ if (isSuccess && (type === 'implement' || type === 'implement:large' || type === 'fix') && prsCreatedCount === 0 && meta?.item?.id) {
1247
1166
  // Check if a PR already exists linked to this work item (from a previous attempt)
1248
1167
  const projects = shared.getProjects(config);
1249
1168
  const existingPrFound = Object.values(getPrLinks()).includes(meta.item.id);
@@ -1263,16 +1182,15 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1263
1182
  wi.noPr = true;
1264
1183
  wi.failReason = 'Completed without creating a pull request';
1265
1184
  const retries = wi._retryCount || 0;
1266
- const maxR = ENGINE_DEFAULTS.maxRetries;
1267
- if (retries < maxR) {
1268
- wi.status = WI_STATUS.PENDING;
1185
+ if (retries < 3) {
1186
+ wi.status = 'pending';
1269
1187
  wi._retryCount = retries + 1;
1270
1188
  delete wi.dispatched_at;
1271
1189
  delete wi.dispatched_to;
1272
- log('info', `Auto-retry ${retries + 1}/${maxR} for ${meta.item.id} (no PR created)`);
1190
+ e.log('info', `Auto-retry ${retries + 1}/3 for ${meta.item.id} (no PR created)`);
1273
1191
  } else {
1274
- wi.status = WI_STATUS.FAILED;
1275
- log('warn', `${meta.item.id} failed after ${maxR} retries — no PR created`);
1192
+ wi.status = 'failed';
1193
+ e.log('warn', `${meta.item.id} failed after 3 retries — no PR created`);
1276
1194
  }
1277
1195
  shared.safeWrite(wiPath, items);
1278
1196
  }
@@ -1280,8 +1198,8 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1280
1198
  }
1281
1199
  }
1282
1200
 
1283
- if (type === WORK_TYPE.REVIEW) updatePrAfterReview(agentId, meta?.pr, meta?.project);
1284
- if (type === WORK_TYPE.FIX) updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
1201
+ if (type === 'review') updatePrAfterReview(agentId, meta?.pr, meta?.project);
1202
+ if (type === 'fix') updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
1285
1203
  checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
1286
1204
  if (isSuccess) extractSkillsFromOutput(stdout, agentId, dispatchItem, config);
1287
1205
  updateAgentHistory(agentId, dispatchItem, result);
@@ -1311,14 +1229,14 @@ function syncPrdFromPrs(config) {
1311
1229
  for (const project of allProjects) {
1312
1230
  const wiPath = projectWorkItemsPath(project);
1313
1231
  const items = safeJson(wiPath) || [];
1314
- const hasPending = items.some(wi => wi.status === WI_STATUS.PENDING && !wi._pr);
1232
+ const hasPending = items.some(wi => wi.status === 'pending' && !wi._pr);
1315
1233
  if (!hasPending) continue;
1316
1234
  const reconciled = reconcileItemsWithPrs(items, allPrs);
1317
1235
  if (reconciled > 0) {
1318
1236
  safeWrite(wiPath, items);
1319
1237
  // Sync done status to PRD JSON for each newly reconciled item
1320
1238
  for (const wi of items) {
1321
- if (wi.status === WI_STATUS.DONE) syncPrdItemStatus(wi.id, 'done', wi.sourcePlan);
1239
+ if (wi.status === 'done') syncPrdItemStatus(wi.id, 'done', wi.sourcePlan);
1322
1240
  }
1323
1241
  totalReconciled += reconciled;
1324
1242
  }
@@ -1334,7 +1252,6 @@ function syncPrdFromPrs(config) {
1334
1252
 
1335
1253
  module.exports = {
1336
1254
  checkPlanCompletion,
1337
- archivePlan,
1338
1255
  updateWorkItemStatus,
1339
1256
  syncPrdItemStatus,
1340
1257
  syncPrsFromOutput,
package/engine/shared.js CHANGED
@@ -393,7 +393,8 @@ const ENGINE_DEFAULTS = {
393
393
 
394
394
  const WI_STATUS = {
395
395
  PENDING: 'pending', DISPATCHED: 'dispatched', DONE: 'done', FAILED: 'failed',
396
- PAUSED: 'paused', QUEUED: 'queued', NEEDS_REVIEW: 'needs-human-review', DECOMPOSED: 'decomposed',
396
+ PAUSED: 'paused', QUEUED: 'queued', NEEDS_REVIEW: 'needs-human-review',
397
+ DECOMPOSED: 'decomposed', CANCELLED: 'cancelled',
397
398
  };
398
399
  // Read-side: accept legacy aliases for backward compat with old data/clients.
399
400
  // Write-side: only WI_STATUS.DONE is written (cleanup.js migrates old values on each run).
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, execSilent, runFile, ENGINE_DEFAULTS: DEFAULTS,
28
- WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, DISPATCH_RESULT } = shared;
28
+ WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
29
29
  const queries = require('./engine/queries');
30
30
 
31
31
  // ─── Paths ──────────────────────────────────────────────────────────────────
@@ -1182,7 +1182,7 @@ function materializePlansAsWorkItems(config) {
1182
1182
  for (const wi of existingItems) {
1183
1183
  if (wi.status !== WI_STATUS.PENDING || wi.sourcePlan !== file) continue;
1184
1184
  if (!currentPrdIds.has(wi.id)) {
1185
- wi.status = 'cancelled';
1185
+ wi.status = WI_STATUS.CANCELLED;
1186
1186
  wi.cancelledAt = ts();
1187
1187
  wi.cancelReason = `PRD item removed from ${file}`;
1188
1188
  cancelled++;
@@ -1504,7 +1504,7 @@ function discoverFromWorkItems(config, project) {
1504
1504
  const cpCount = (item._checkpointCount || 0) + 1;
1505
1505
  if (cpCount > 3) {
1506
1506
  log('warn', `Work item ${item.id} exceeded 3 checkpoint-resumes — marking as needs-human-review`);
1507
- item.status = 'needs-human-review';
1507
+ item.status = WI_STATUS.NEEDS_REVIEW;
1508
1508
  item._checkpointCount = cpCount;
1509
1509
  needsWrite = true;
1510
1510
  continue;
@@ -1918,7 +1918,7 @@ function discoverCentralWorkItems(config) {
1918
1918
  const cpCount = (item._checkpointCount || 0) + 1;
1919
1919
  if (cpCount > 3) {
1920
1920
  log('warn', `Work item ${item.id} exceeded 3 checkpoint-resumes — marking as needs-human-review`);
1921
- item.status = 'needs-human-review';
1921
+ item.status = WI_STATUS.NEEDS_REVIEW;
1922
1922
  item._checkpointCount = cpCount;
1923
1923
  continue;
1924
1924
  }
@@ -2425,7 +2425,7 @@ async function tickInner() {
2425
2425
  const wi = items.find(i => i.id === item.meta.item.id);
2426
2426
  if (wi && wi.status === WI_STATUS.DISPATCHED) {
2427
2427
  // completeDispatch didn't update the work item — re-queue manually
2428
- wi.status = 'pending';
2428
+ wi.status = WI_STATUS.PENDING;
2429
2429
  wi._retryCount = (wi._retryCount || 0) + 1;
2430
2430
  wi._lastRetryReason = 'spawnAgent returned null';
2431
2431
  wi._lastRetryAt = ts();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.326",
3
+ "version": "0.1.328",
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"