@yemi33/minions 0.1.1811 → 0.1.1813

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1813 (2026-05-09)
4
+
5
+ ### Features
6
+ - improve manual PR project inference (#2242)
7
+
8
+ ## 0.1.1812 (2026-05-09)
9
+
10
+ ### Features
11
+ - stop invalid project fallback (#2245)
12
+ - harden doc-chat dispatch prompt (#2249)
13
+
3
14
  ## 0.1.1811 (2026-05-09)
4
15
 
5
16
  ### Features
@@ -1022,6 +1022,7 @@ function _tagServerExecuted(actions, actionResults) {
1022
1022
  if (r.id) actions[i]._serverId = r.id;
1023
1023
  if (r.warning) actions[i]._serverWarning = r.warning;
1024
1024
  if (r.duplicate) actions[i]._serverDuplicate = true;
1025
+ if (r.reusedFromAction !== undefined) actions[i]._serverHidden = true;
1025
1026
  } else if (r && r.error) {
1026
1027
  actions[i]._serverExecuted = true;
1027
1028
  actions[i]._serverError = r.error;
@@ -1037,15 +1038,18 @@ async function ccExecuteAction(action, targetTabId) {
1037
1038
 
1038
1039
  // Server-executed actions: just show status, don't re-fire the API
1039
1040
  if (action._serverExecuted) {
1041
+ if (action._serverHidden) return;
1040
1042
  if (action._serverError) {
1041
1043
  status.innerHTML = '✗ ' + escHtml(action.type) + ' failed: ' + escHtml(action._serverError);
1042
1044
  status.style.color = 'var(--red)';
1043
1045
  } else {
1044
1046
  var label = action._serverId ? escHtml(action._serverId) : escHtml(action.title || action.type);
1045
- status.innerHTML = '&#10003; ' + escHtml(action.type) + ': <strong>' + label + '</strong>' +
1046
- (action._serverDuplicate ? ' <span style="color:var(--orange)">already exists</span>' : '') +
1047
+ var serverActionType = action.type || '';
1048
+ var successLabel = serverActionType === 'dispatch' ? 'Dispatched' : serverActionType;
1049
+ status.innerHTML = '&#10003; ' + escHtml(successLabel) + ': <strong>' + label + '</strong>' +
1050
+ (action._serverDuplicate ? '<div style="font-size:10px;color:var(--orange);margin-top:2px">Already existed from a previous request; no duplicate work item was created.</div>' : '') +
1047
1051
  (action._serverWarning ? '<div style="font-size:10px;color:var(--muted);margin-top:2px">' + escHtml(action._serverWarning) + '</div>' : '');
1048
- status.style.color = action._serverDuplicate ? 'var(--orange)' : 'var(--green)';
1052
+ status.style.color = 'var(--green)';
1049
1053
  }
1050
1054
  ccAddMessage('action', status.outerHTML, false, targetTabId);
1051
1055
  if (['dispatch','fix','explore','review','test','create-meeting'].includes(action.type)) wakeEngine();
@@ -1336,8 +1340,10 @@ async function ccExecuteAction(action, targetTabId) {
1336
1340
  break;
1337
1341
  }
1338
1342
  case 'link-pr': {
1339
- await _ccFetch('/api/pull-requests/link', { url: action.url, title: action.title || '', project: action.project || '', autoObserve: action.autoObserve !== false });
1340
- status.innerHTML = '&#10003; PR linked: <strong>' + escHtml(action.url) + '</strong>';
1343
+ var prLinkRes = await _ccFetch('/api/pull-requests/link', { url: action.url, title: action.title || '', project: action.project || '', autoObserve: action.autoObserve !== false });
1344
+ var prLinkData = await prLinkRes.json().catch(function() { return {}; });
1345
+ status.innerHTML = '&#10003; PR linked: <strong>' + escHtml(action.url) + '</strong>' +
1346
+ (prLinkData.message ? '<div style="font-size:11px;color:var(--muted);margin-top:4px">' + escHtml(prLinkData.message) + '</div>' : '');
1341
1347
  status.style.color = 'var(--green)';
1342
1348
  break;
1343
1349
  }
@@ -367,9 +367,10 @@ function _qaBuildActionFeedbackHtml(actionFeedback) {
367
367
  return '<div class="modal-qa-action-feedback" style="' + baseStyle + 'color:var(--red)">&#10007; ' + type + ' failed: ' + escHtml(item.error) + '</div>';
368
368
  }
369
369
  const label = escHtml(item.id || item.duplicateOf || '');
370
- const duplicate = item.duplicate ? ' <span style="color:var(--orange)">already exists</span>' : '';
370
+ const verb = item.type === 'dispatch' ? 'Dispatched' : type;
371
+ const duplicate = item.duplicate ? '<div style="font-size:10px;color:var(--orange);margin-top:2px">Already existed from a previous request; no duplicate work item was created.</div>' : '';
371
372
  const warning = item.warning ? '<div style="font-size:10px;color:var(--muted);margin-top:2px">' + escHtml(item.warning) + '</div>' : '';
372
- return '<div class="modal-qa-action-feedback" style="' + baseStyle + 'color:' + (item.duplicate ? 'var(--orange)' : 'var(--green)') + '">&#10003; ' + type + ': <strong>' + label + '</strong>' + duplicate + warning + '</div>';
373
+ return '<div class="modal-qa-action-feedback" style="' + baseStyle + 'color:var(--green)">&#10003; ' + verb + ': <strong>' + label + '</strong>' + duplicate + warning + '</div>';
373
374
  }).join('');
374
375
  }
375
376
 
@@ -125,7 +125,7 @@ function openAddPrModal() {
125
125
  '<div style="display:flex;flex-direction:column;gap:10px">' +
126
126
  '<label style="color:var(--text);font-size:var(--text-md)">PR URL <input id="pr-link-url" style="' + inputStyle + '" placeholder="https://github.com/org/repo/pull/123"></label>' +
127
127
  '<label style="color:var(--text);font-size:var(--text-md)">Title <input id="pr-link-title" style="' + inputStyle + '" placeholder="Short description (optional — auto-detected from URL)"></label>' +
128
- '<label style="color:var(--text);font-size:var(--text-md)">Project <select id="pr-link-project" style="' + inputStyle + '"><option value="">Auto / Central</option>' + projOpts + '</select></label>' +
128
+ '<label style="color:var(--text);font-size:var(--text-md)">Project <select id="pr-link-project" style="' + inputStyle + '"><option value="">Auto-detect from URL (central if no unique match)</option>' + projOpts + '</select></label>' +
129
129
  '<label style="color:var(--text);font-size:var(--text-md)">Context <textarea id="pr-link-context" rows="3" style="' + inputStyle + ';resize:vertical" placeholder="Why are you linking this? What should agents know about it?"></textarea></label>' +
130
130
  '<label style="display:flex;align-items:center;gap:8px;color:var(--text);font-size:var(--text-md);margin-top:4px;cursor:pointer">' +
131
131
  '<input type="checkbox" id="pr-link-observe" style="width:16px;height:16px;accent-color:var(--blue)">' +
@@ -151,14 +151,16 @@ async function _submitLinkPr(e) {
151
151
  const autoObserve = document.getElementById('pr-link-observe')?.checked || false;
152
152
 
153
153
  try { closeModal(); } catch { /* expected */ }
154
- showToast('cmd-toast', 'PR linked' + (autoObserve ? ' (auto-observe on)' : ''), true);
155
154
  try {
156
155
  const res = await fetch('/api/pull-requests/link', {
157
156
  method: 'POST', headers: { 'Content-Type': 'application/json' },
158
157
  body: JSON.stringify({ url, title, project, context, autoObserve })
159
158
  });
160
159
  const data = await res.json();
161
- if (res.ok) { refresh(); } else { alert('Failed: ' + (data.error || 'unknown')); openAddPrModal(); }
160
+ if (res.ok) {
161
+ showToast('cmd-toast', (data.message || 'PR linked') + (autoObserve ? ' (auto-observe on)' : ''), true, 6000);
162
+ refresh();
163
+ } else { alert('Failed: ' + (data.error || 'unknown')); openAddPrModal(); }
162
164
  } catch (e) { alert('Error: ' + e.message); openAddPrModal(); }
163
165
  }
164
166
 
package/dashboard.js CHANGED
@@ -97,6 +97,13 @@ function reloadConfig() {
97
97
  }
98
98
  ensureConfiguredProjectStateFiles();
99
99
 
100
+ function resolveScheduleProjectValue(project, projects = PROJECTS) {
101
+ if (project === undefined) return { project: undefined };
102
+ const target = shared.resolveConfiguredProject(project, projects);
103
+ if (target.error) return { project: null, error: target.error };
104
+ return { project: target.project?.name || null };
105
+ }
106
+
100
107
  function mutateDashboardConfig(mutator) {
101
108
  return mutateJsonFileLocked(CONFIG_PATH, (config) => {
102
109
  if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
@@ -375,6 +382,83 @@ function findProjectByName(projects, projectName) {
375
382
  return shared.findProjectByName(projects, projectName);
376
383
  }
377
384
 
385
+ function resolveManualPrLinkProject(url, projectName, projects = PROJECTS) {
386
+ const explicitProjectName = String(projectName || '').trim();
387
+ if (explicitProjectName) {
388
+ const explicitProject = shared.resolveProjectSource(explicitProjectName, projects, { allowCentral: false, minionsDir: MINIONS_DIR });
389
+ if (explicitProject.error) {
390
+ const err = new Error(explicitProject.error);
391
+ err.statusCode = 400;
392
+ throw err;
393
+ }
394
+ const targetProject = explicitProject.project;
395
+ return {
396
+ project: targetProject,
397
+ resolution: {
398
+ reason: 'explicit',
399
+ project: targetProject.name || explicitProjectName,
400
+ storage: 'project',
401
+ message: `Linked to selected project "${targetProject.name || explicitProjectName}".`,
402
+ },
403
+ };
404
+ }
405
+
406
+ const parsedPr = shared.parsePrUrl(url);
407
+ const prScope = parsedPr?.scope || '';
408
+ if (!prScope) {
409
+ return {
410
+ project: null,
411
+ resolution: {
412
+ reason: 'unrecognized-url',
413
+ project: 'central',
414
+ storage: 'central',
415
+ message: 'Could not infer a project from this PR URL; linked in central PR tracking.',
416
+ },
417
+ };
418
+ }
419
+
420
+ const matches = projects.filter(p => shared.getProjectPrScope(p) === prScope);
421
+ if (matches.length === 1) {
422
+ const targetProject = matches[0];
423
+ return {
424
+ project: targetProject,
425
+ resolution: {
426
+ reason: 'inferred',
427
+ scope: prScope,
428
+ project: targetProject.name || '',
429
+ storage: 'project',
430
+ message: `Inferred project "${targetProject.name}" from PR scope ${prScope}.`,
431
+ },
432
+ };
433
+ }
434
+
435
+ if (matches.length > 1) {
436
+ const names = matches.map(p => p.name).filter(Boolean);
437
+ return {
438
+ project: null,
439
+ resolution: {
440
+ reason: 'ambiguous',
441
+ scope: prScope,
442
+ project: 'central',
443
+ storage: 'central',
444
+ matches: names,
445
+ message: `PR scope ${prScope} matches multiple configured projects (${names.join(', ')}); linked in central PR tracking. Select a project to attach it.`,
446
+ },
447
+ };
448
+ }
449
+
450
+ return {
451
+ project: null,
452
+ resolution: {
453
+ reason: 'unmatched',
454
+ scope: prScope,
455
+ project: 'central',
456
+ storage: 'central',
457
+ message: `No configured project matches PR scope ${prScope}; linked in central PR tracking.`,
458
+ },
459
+ };
460
+ }
461
+
378
462
  function resolveWorkItemsCreateTarget(projectName, projects = PROJECTS) {
379
463
  const target = shared.resolveProjectSource(projectName, projects, { defaultWhenSingle: true, minionsDir: MINIONS_DIR });
380
464
  if (target.error) return { error: target.error };
@@ -409,6 +493,73 @@ function findWorkItemsTargetById(id, source, projects = PROJECTS) {
409
493
  return { found: false };
410
494
  }
411
495
 
496
+ function buildPlanWorkItem(body, projects = PROJECTS, options = {}) {
497
+ if (!body?.title || !String(body.title).trim()) return { error: 'title is required' };
498
+ const target = resolveWorkItemsCreateTarget(body.project, projects);
499
+ if (target.error) return { error: target.error };
500
+ let now = options.now ? new Date(options.now) : new Date();
501
+ if (!Number.isFinite(now.getTime())) now = new Date();
502
+ const item = {
503
+ id: options.id || ('W-' + shared.uid()),
504
+ title: body.title,
505
+ type: 'plan',
506
+ priority: body.priority || 'high',
507
+ description: body.description || '',
508
+ status: WI_STATUS.PENDING,
509
+ created: now.toISOString(),
510
+ createdBy: 'dashboard',
511
+ branchStrategy: body.branch_strategy || body.branchStrategy || 'parallel',
512
+ };
513
+ if (target.project) item.project = target.project.name;
514
+ if (body.agent) item.agent = body.agent;
515
+ return { item, id: item.id };
516
+ }
517
+
518
+ function buildManualPrdItemPlan(body, projects = PROJECTS, options = {}) {
519
+ if (!body?.name || !String(body.name).trim()) return { error: 'name is required' };
520
+ const target = resolveWorkItemsCreateTarget(body.project, projects);
521
+ if (target.error) return { error: target.error };
522
+
523
+ const overrideProject = body.item_project ?? body.itemProject;
524
+ const itemProjectTarget = overrideProject !== undefined
525
+ ? shared.resolveConfiguredProject(overrideProject, projects)
526
+ : null;
527
+ if (itemProjectTarget?.error) return { error: itemProjectTarget.error };
528
+
529
+ let now = options.now ? new Date(options.now) : new Date();
530
+ if (!Number.isFinite(now.getTime())) now = new Date();
531
+ const nowTime = now.getTime();
532
+ const projectName = target.project?.name || (projects.length > 0 ? projects[0].name : 'Unknown');
533
+ const itemProjectName = itemProjectTarget?.project?.name || target.project?.name || (projects.length === 1 ? projects[0].name : '');
534
+ const item = {
535
+ id: body.id || ('M' + String(nowTime).slice(-4)),
536
+ name: String(body.name).trim(),
537
+ description: body.description || '',
538
+ priority: body.priority || 'medium',
539
+ estimated_complexity: body.estimated_complexity || 'medium',
540
+ status: 'missing',
541
+ depends_on: [],
542
+ acceptance_criteria: [],
543
+ };
544
+ if (itemProjectName) item.project = itemProjectName;
545
+
546
+ return {
547
+ plan: {
548
+ version: 'manual-' + now.toISOString().slice(0, 10),
549
+ project: projectName,
550
+ generated_by: 'dashboard',
551
+ generated_at: now.toISOString().slice(0, 10),
552
+ plan_summary: item.name,
553
+ status: 'approved',
554
+ requires_approval: false,
555
+ branch_strategy: 'parallel',
556
+ missing_features: [item],
557
+ open_questions: [],
558
+ },
559
+ id: item.id,
560
+ };
561
+ }
562
+
412
563
  function validatePipelineProjects(pipeline, projects = PROJECTS) {
413
564
  const refs = [];
414
565
  const collect = (value) => {
@@ -418,6 +569,7 @@ function validatePipelineProjects(pipeline, projects = PROJECTS) {
418
569
  if (value.project !== undefined) collect(value.project);
419
570
  else if (value._project !== undefined) collect(value._project);
420
571
  else if (value.name !== undefined) collect(value.name);
572
+ else if (value.localPath !== undefined) collect(value.localPath);
421
573
  return;
422
574
  }
423
575
  refs.push(String(value));
@@ -480,21 +632,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
480
632
  throw err;
481
633
  }
482
634
  const projects = shared.getProjects(config);
483
- const explicitProjectName = String(projectName || '').trim();
484
- const explicitProject = explicitProjectName
485
- ? shared.resolveProjectSource(explicitProjectName, projects, { allowCentral: false, minionsDir: MINIONS_DIR })
486
- : null;
487
- let targetProject = explicitProject?.project || null;
488
- if (explicitProject?.error) {
489
- const err = new Error(explicitProject.error);
490
- err.statusCode = 400;
491
- throw err;
492
- }
493
- if (!explicitProjectName) {
494
- const prScope = shared.parsePrUrl(url)?.scope || '';
495
- const matches = prScope ? projects.filter(p => shared.getProjectPrScope(p) === prScope) : [];
496
- if (matches.length === 1) targetProject = matches[0];
497
- }
635
+ const { project: targetProject, resolution: projectResolution } = resolveManualPrLinkProject(url, projectName, projects);
498
636
  const prPath = targetProject ? shared.projectPrPath(targetProject) : shared.centralPullRequestsPath(MINIONS_DIR);
499
637
 
500
638
  const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
@@ -519,11 +657,12 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
519
657
  _contextOnly: !autoObserve,
520
658
  _autoObserve: !!autoObserve,
521
659
  _context: contextText,
660
+ _projectResolution: projectResolution,
522
661
  }, {
523
662
  project: targetProject,
524
663
  itemId: linkedWorkItemId,
525
664
  });
526
- return { ...result, prPath, targetProject, prNum };
665
+ return { ...result, prPath, targetProject, projectResolution, prNum };
527
666
  }
528
667
 
529
668
  function _normalizeSkillDirForCompare(dir) {
@@ -2686,6 +2825,8 @@ function normalizePipelineForCompare(pipeline) {
2686
2825
  stages: Array.isArray(pipeline.stages) ? pipeline.stages : [],
2687
2826
  trigger: pipeline.trigger && typeof pipeline.trigger === 'object' ? pipeline.trigger : {},
2688
2827
  enabled: pipeline.enabled !== false,
2828
+ project: pipeline.project !== undefined ? pipeline.project : null,
2829
+ projects: Array.isArray(pipeline.projects) ? pipeline.projects : [],
2689
2830
  stopWhen: pipeline.stopWhen || null,
2690
2831
  monitoredResources: Array.isArray(pipeline.monitoredResources) ? pipeline.monitoredResources : [],
2691
2832
  };
@@ -2699,6 +2840,8 @@ function buildPipelineFromAction(action) {
2699
2840
  trigger: action.trigger && typeof action.trigger === 'object' ? action.trigger : {},
2700
2841
  enabled: action.enabled !== false,
2701
2842
  };
2843
+ if (action.project !== undefined) pipeline.project = action.project;
2844
+ if (Array.isArray(action.projects)) pipeline.projects = action.projects;
2702
2845
  if (action.stopWhen) pipeline.stopWhen = action.stopWhen;
2703
2846
  if (Array.isArray(action.monitoredResources) && action.monitoredResources.length > 0) {
2704
2847
  pipeline.monitoredResources = action.monitoredResources;
@@ -2713,6 +2856,10 @@ function pipelineDefinitionsEqual(a, b) {
2713
2856
  function createPipelineFromAction(action) {
2714
2857
  const { savePipeline, getPipeline } = require('./engine/pipeline');
2715
2858
  const pipeline = buildPipelineFromAction(action);
2859
+ const projectError = validatePipelineProjects(pipeline);
2860
+ if (projectError) {
2861
+ return { type: 'create-pipeline', id: pipeline.id, error: projectError };
2862
+ }
2716
2863
  const existing = getPipeline(pipeline.id);
2717
2864
  if (existing) {
2718
2865
  if (pipelineDefinitionsEqual(existing, pipeline)) {
@@ -3078,7 +3225,9 @@ async function _ccExecuteLocalApiAction(action) {
3078
3225
 
3079
3226
  async function executeCCActions(actions, { source = 'command-center', inferredProject = null } = {}) {
3080
3227
  const results = [];
3081
- for (const rawAction of actions) {
3228
+ const dispatchIdsCreatedInThisCall = new Map();
3229
+ for (let actionIndex = 0; actionIndex < actions.length; actionIndex++) {
3230
+ const rawAction = actions[actionIndex];
3082
3231
  const action = normalizeCCAction(rawAction);
3083
3232
  if (action?._intentFallbackError) {
3084
3233
  results.push({
@@ -3178,9 +3327,19 @@ async function executeCCActions(actions, { source = 'command-center', inferredPr
3178
3327
  const createResult = createWorkItemWithDedup(wiPath, item);
3179
3328
  if (!createResult.created) {
3180
3329
  const duplicateId = createResult.duplicateOf || createResult.item?.id;
3330
+ if (duplicateId && dispatchIdsCreatedInThisCall.has(duplicateId)) {
3331
+ results.push({
3332
+ type: action.type,
3333
+ id: duplicateId,
3334
+ ok: true,
3335
+ reusedFromAction: dispatchIdsCreatedInThisCall.get(duplicateId),
3336
+ });
3337
+ break;
3338
+ }
3181
3339
  results.push({ type: action.type, id: duplicateId, ok: true, duplicate: true, duplicateOf: duplicateId });
3182
3340
  break;
3183
3341
  }
3342
+ dispatchIdsCreatedInThisCall.set(id, actionIndex);
3184
3343
  results.push({ type: action.type, id, ok: true });
3185
3344
 
3186
3345
  // Pre-flight routing check: warn the user if no agent is currently available so the new
@@ -3404,6 +3563,7 @@ function _buildDocChatActionFeedback(actions, actionResults) {
3404
3563
  feedback.push({ type, error: String(result.error) });
3405
3564
  continue;
3406
3565
  }
3566
+ if (result.reusedFromAction !== undefined) continue;
3407
3567
  const id = result.id || result.duplicateOf;
3408
3568
  if (!result.ok || !id) continue;
3409
3569
  const item = { type, id: String(id), ok: true };
@@ -5035,21 +5195,12 @@ const server = http.createServer(async (req, res) => {
5035
5195
  try {
5036
5196
  const body = await readBody(req);
5037
5197
  if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
5038
- const target = resolveWorkItemsCreateTarget(body.project);
5039
- if (target.error) return jsonReply(res, 400, { error: target.error });
5198
+ const planWorkItem = buildPlanWorkItem(body);
5199
+ if (planWorkItem.error) return jsonReply(res, 400, { error: planWorkItem.error });
5040
5200
  // Write as a work item with type 'plan' — user must explicitly execute plan-to-prd after reviewing
5041
5201
  const wiPath = path.join(MINIONS_DIR, 'work-items.json');
5042
- const id = 'W-' + shared.uid();
5043
- const item = {
5044
- id, title: body.title, type: 'plan',
5045
- priority: body.priority || 'high', description: body.description || '',
5046
- status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
5047
- branchStrategy: body.branch_strategy || body.branchStrategy || 'parallel',
5048
- };
5049
- if (target.project) item.project = target.project.name;
5050
- if (body.agent) item.agent = body.agent;
5051
- mutateWorkItems(wiPath, items => { items.push(item); });
5052
- return jsonReply(res, 200, { ok: true, id, agent: body.agent || '' });
5202
+ mutateWorkItems(wiPath, items => { items.push(planWorkItem.item); });
5203
+ return jsonReply(res, 200, { ok: true, id: planWorkItem.id, agent: body.agent || '' });
5053
5204
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
5054
5205
  }
5055
5206
 
@@ -5057,31 +5208,14 @@ const server = http.createServer(async (req, res) => {
5057
5208
  try {
5058
5209
  const body = await readBody(req);
5059
5210
  if (!body.name || !body.name.trim()) return jsonReply(res, 400, { error: 'name is required' });
5060
- const target = resolveWorkItemsCreateTarget(body.project);
5061
- if (target.error) return jsonReply(res, 400, { error: target.error });
5211
+ const manualPrd = buildManualPrdItemPlan(body);
5212
+ if (manualPrd.error) return jsonReply(res, 400, { error: manualPrd.error });
5062
5213
 
5063
5214
  if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
5064
5215
 
5065
- const id = body.id || ('M' + String(Date.now()).slice(-4));
5066
5216
  const planFile = 'manual-' + shared.uid() + '.json';
5067
- const plan = {
5068
- version: 'manual-' + new Date().toISOString().slice(0, 10),
5069
- project: target.project?.name || 'Unknown',
5070
- generated_by: 'dashboard',
5071
- generated_at: new Date().toISOString().slice(0, 10),
5072
- plan_summary: body.name,
5073
- status: 'approved',
5074
- requires_approval: false,
5075
- branch_strategy: 'parallel',
5076
- missing_features: [{
5077
- id, name: body.name, description: body.description || '',
5078
- priority: body.priority || 'medium', estimated_complexity: body.estimated_complexity || 'medium',
5079
- status: 'missing', depends_on: [], acceptance_criteria: [],
5080
- }],
5081
- open_questions: [],
5082
- };
5083
- safeWrite(path.join(PRD_DIR, planFile), plan);
5084
- return jsonReply(res, 200, { ok: true, id, file: planFile });
5217
+ safeWrite(path.join(PRD_DIR, planFile), manualPrd.plan);
5218
+ return jsonReply(res, 200, { ok: true, id: manualPrd.id, file: planFile });
5085
5219
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
5086
5220
  }
5087
5221
 
@@ -7414,9 +7548,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7414
7548
  const body = await readBody(req);
7415
7549
  let { id, cron, title, type, project, agent, description, priority, enabled } = body;
7416
7550
  if (!cron || !title) return jsonReply(res, 400, { error: 'cron and title are required' });
7417
- const projectTarget = shared.resolveConfiguredProject(project, PROJECTS);
7551
+ reloadConfig();
7552
+ const projectTarget = resolveScheduleProjectValue(project, PROJECTS);
7418
7553
  if (projectTarget.error) return jsonReply(res, 400, { error: projectTarget.error });
7419
- project = projectTarget.project?.name || null;
7554
+ project = projectTarget.project || null;
7420
7555
 
7421
7556
  // Auto-generate ID from title if not provided
7422
7557
  if (!id) {
@@ -7451,10 +7586,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7451
7586
  const body = await readBody(req);
7452
7587
  let { id, cron, title, type, project, agent, description, priority, enabled } = body;
7453
7588
  if (!id) return jsonReply(res, 400, { error: 'id required' });
7589
+ reloadConfig();
7454
7590
  if (project !== undefined) {
7455
- const projectTarget = shared.resolveConfiguredProject(project, PROJECTS);
7591
+ const projectTarget = resolveScheduleProjectValue(project, PROJECTS);
7456
7592
  if (projectTarget.error) return jsonReply(res, 400, { error: projectTarget.error });
7457
- project = projectTarget.project?.name || null;
7593
+ project = projectTarget.project || null;
7458
7594
  }
7459
7595
 
7460
7596
  let missingSchedules = false;
@@ -7516,16 +7652,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7516
7652
  reloadConfig();
7517
7653
  const sched = (CONFIG.schedules || []).find(s => s.id === id);
7518
7654
  if (!sched) return jsonReply(res, 404, { error: 'Schedule not found' });
7519
- if (sched.project) {
7520
- const projectTarget = shared.resolveConfiguredProject(sched.project, PROJECTS);
7521
- if (projectTarget.error) return jsonReply(res, 400, { error: projectTarget.error });
7522
- sched.project = projectTarget.project.name;
7523
- }
7655
+ const projectTarget = resolveScheduleProjectValue(sched.project, PROJECTS);
7656
+ if (projectTarget.error) return jsonReply(res, 400, { error: projectTarget.error });
7657
+ const schedForRun = { ...sched, project: projectTarget.project || null };
7524
7658
 
7525
7659
  const schedulerMod = require('./engine/scheduler');
7526
7660
  let item;
7527
7661
  try {
7528
- item = schedulerMod.createScheduledWorkItem(sched);
7662
+ item = schedulerMod.createScheduledWorkItem(schedForRun);
7529
7663
  } catch (e) {
7530
7664
  return jsonReply(res, 400, { error: e.message });
7531
7665
  }
@@ -7536,7 +7670,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7536
7670
  meeting = createMeeting({
7537
7671
  title: item.title,
7538
7672
  agenda: item.description,
7539
- participants: Array.isArray(sched.participants) ? sched.participants : [],
7673
+ participants: Array.isArray(schedForRun.participants) ? schedForRun.participants : [],
7540
7674
  });
7541
7675
  } else {
7542
7676
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
@@ -7555,22 +7689,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7555
7689
  return jsonReply(res, 409, {
7556
7690
  error: 'Schedule already has an active work item',
7557
7691
  id: duplicate.id,
7558
- scheduleId: sched.id,
7692
+ scheduleId: schedForRun.id,
7559
7693
  });
7560
7694
  }
7561
7695
  }
7562
7696
 
7563
- const runEntry = schedulerMod.recordScheduleRun(sched.id, item.id);
7697
+ const runEntry = schedulerMod.recordScheduleRun(schedForRun.id, item.id);
7564
7698
  try {
7565
7699
  shared.mutateControl(control => ({ ...control, _wakeupAt: Date.now() }));
7566
7700
  } catch (e) {
7567
- shared.log('warn', `Schedule run-now wakeup failed for ${sched.id}: ${e.message}`);
7701
+ shared.log('warn', `Schedule run-now wakeup failed for ${schedForRun.id}: ${e.message}`);
7568
7702
  }
7569
7703
  invalidateStatusCache();
7570
7704
  return jsonReply(res, 200, {
7571
7705
  ok: true,
7572
7706
  id: item.id,
7573
- scheduleId: sched.id,
7707
+ scheduleId: schedForRun.id,
7574
7708
  lastRun: runEntry?.lastRun || null,
7575
7709
  ...(meeting ? { meetingId: meeting.id } : {}),
7576
7710
  });
@@ -8356,7 +8490,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8356
8490
  { method: 'GET', path: /^\/api\/plans\/([^?]+)$/, template: '/api/plans/:file', desc: 'Read a full plan (JSON from prd/ or markdown from plans/)', handler: handlePlansRead },
8357
8491
 
8358
8492
  // PRD items
8359
- { method: 'POST', path: '/api/prd-items', desc: 'Create a PRD item as a plan file in prd/ (auto-approved)', params: 'name, description?, priority?, estimated_complexity?, project?, id?', handler: handlePrdItemsCreate },
8493
+ { method: 'POST', path: '/api/prd-items', desc: 'Create a PRD item as a plan file in prd/ (auto-approved)', params: 'name, description?, priority?, estimated_complexity?, project?, item_project?, id?', handler: handlePrdItemsCreate },
8360
8494
  { method: 'POST', path: '/api/prd-items/update', desc: 'Edit a PRD item in its source plan JSON', params: 'source, itemId, name?, description?, priority?, estimated_complexity?, status?', handler: handlePrdItemsUpdate },
8361
8495
  { method: 'POST', path: '/api/prd-items/remove', desc: 'Remove a PRD item from plan + cancel materialized work item', params: 'source, itemId', handler: handlePrdItemsRemove },
8362
8496
  // /api/prd/regenerate removed — use /api/plans/approve which does diff-aware update
@@ -8390,9 +8524,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8390
8524
  } catch (e) {
8391
8525
  return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
8392
8526
  }
8393
- const { id: prId, prPath, prNum, created, linked } = linkResult;
8527
+ const { id: prId, prPath, prNum, created, linked, targetProject, projectResolution } = linkResult;
8394
8528
  invalidateStatusCache();
8395
- jsonReply(res, 200, { ok: true, id: prId, created, linked });
8529
+ jsonReply(res, 200, {
8530
+ ok: true,
8531
+ id: prId,
8532
+ created,
8533
+ linked,
8534
+ project: targetProject?.name || 'central',
8535
+ projectResolution,
8536
+ message: projectResolution?.message || 'PR linked.',
8537
+ });
8396
8538
 
8397
8539
  // Async-enrich: fetch title, description, branch, author from GitHub/ADO API
8398
8540
  (async () => {
@@ -9012,6 +9154,9 @@ module.exports = {
9012
9154
  _findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
9013
9155
  _createWorkItemWithDedup: createWorkItemWithDedup,
9014
9156
  _resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
9157
+ _buildPlanWorkItem: buildPlanWorkItem,
9158
+ _buildManualPrdItemPlan: buildManualPrdItemPlan,
9159
+ _resolveScheduleProjectValue: resolveScheduleProjectValue,
9015
9160
  _collectArchivedWorkItems: collectArchivedWorkItems,
9016
9161
  _createPipelineFromAction: createPipelineFromAction,
9017
9162
  _setCcLocalApiInvokerForTest,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-09T08:23:02.798Z"
4
+ "cachedAt": "2026-05-09T09:32:10.535Z"
5
5
  }
package/engine/issues.js CHANGED
@@ -9,6 +9,7 @@ const shared = require('./shared');
9
9
 
10
10
  const DEFAULT_REPO = 'yemi33/minions';
11
11
  const DEFAULT_LABELS = ['bug'];
12
+ const WRITABLE_REPO_PERMISSIONS = new Set(['WRITE', 'MAINTAIN', 'ADMIN']);
12
13
 
13
14
  class GitHubIssueError extends Error {
14
15
  constructor(message, statusCode = 500) {
@@ -67,16 +68,105 @@ function extractIssueUrl(output) {
67
68
  return match ? match[0] : null;
68
69
  }
69
70
 
70
- function runGh(execFileSync, args, timeout) {
71
- return execFileSync('gh', args, {
71
+ function runGh(execFileSync, args, timeout, { env } = {}) {
72
+ const opts = {
72
73
  encoding: 'utf8',
73
74
  timeout,
74
75
  windowsHide: true,
75
- });
76
+ };
77
+ if (env) opts.env = { ...process.env, ...env };
78
+ return execFileSync('gh', args, opts);
79
+ }
80
+
81
+ function isWritableRepoPermission(permission) {
82
+ return WRITABLE_REPO_PERMISSIONS.has(String(permission || '').trim().toUpperCase());
83
+ }
84
+
85
+ function repoViewerPermission({ repo, execFileSync, ghEnv }) {
86
+ const output = runGh(execFileSync, ['repo', 'view', repo, '--json', 'viewerPermission'], 10000, { env: ghEnv });
87
+ const parsed = JSON.parse(output || '{}');
88
+ return String(parsed.viewerPermission || '').trim().toUpperCase();
89
+ }
90
+
91
+ function parseGhAuthUsers(output) {
92
+ const users = [];
93
+ const seen = new Set();
94
+ for (const line of String(output || '').split(/\r?\n/)) {
95
+ const match = line.match(/\baccount\s+([^\s()]+)/i) || line.match(/\bas\s+([^\s()]+)/i);
96
+ if (!match) continue;
97
+ const user = match[1].trim();
98
+ const key = user.toLowerCase();
99
+ if (!user || seen.has(key)) continue;
100
+ seen.add(key);
101
+ users.push(user);
102
+ }
103
+ return users;
104
+ }
105
+
106
+ function listAuthenticatedGhUsers({ execFileSync }) {
107
+ const output = runGh(execFileSync, ['auth', 'status', '--hostname', 'github.com'], 10000);
108
+ return parseGhAuthUsers(output);
109
+ }
110
+
111
+ function ghTokenForUser({ user, execFileSync }) {
112
+ return String(runGh(execFileSync, ['auth', 'token', '--hostname', 'github.com', '--user', user], 10000) || '').trim();
113
+ }
114
+
115
+ function resolveWritableGitHubEnv({ repo, execFileSync }) {
116
+ let activePermission = '';
117
+ let activeError = null;
118
+ try {
119
+ activePermission = repoViewerPermission({ repo, execFileSync });
120
+ if (isWritableRepoPermission(activePermission)) return undefined;
121
+ } catch (e) {
122
+ activeError = e;
123
+ }
124
+
125
+ let users = [];
126
+ let accountListError = null;
127
+ try {
128
+ users = listAuthenticatedGhUsers({ execFileSync });
129
+ } catch (e) {
130
+ if (activeError && isAuthError(activeError) && isAuthError(e)) {
131
+ throw new GitHubIssueError('GitHub auth required. Run: gh auth login', 401);
132
+ }
133
+ accountListError = e;
134
+ }
135
+
136
+ const candidateErrors = [];
137
+ for (const user of users) {
138
+ try {
139
+ const token = ghTokenForUser({ user, execFileSync });
140
+ if (!token) continue;
141
+ const ghEnv = { GH_TOKEN: token };
142
+ const permission = repoViewerPermission({ repo, execFileSync, ghEnv });
143
+ if (isWritableRepoPermission(permission)) return ghEnv;
144
+ } catch (e) {
145
+ candidateErrors.push(`${user}: ${conciseGhMessage(e)}`);
146
+ }
147
+ }
148
+
149
+ const activeDetail = activePermission
150
+ ? ` Active account permission is ${activePermission}.`
151
+ : activeError
152
+ ? ` Active account permission could not be checked: ${conciseGhMessage(activeError)}.`
153
+ : '';
154
+ const authStatusDetail = accountListError
155
+ ? ` Authenticated accounts could not be listed: ${conciseGhMessage(accountListError)}.`
156
+ : '';
157
+ const candidateDetail = candidateErrors.length
158
+ ? ` Checked ${candidateErrors.length} authenticated account(s) without finding write access.`
159
+ : '';
160
+ throw new GitHubIssueError(
161
+ `No authenticated GitHub account with write access to ${repo}.${activeDetail} ` +
162
+ `${authStatusDetail}${candidateDetail} ` +
163
+ `Run: gh auth login --hostname github.com with a profile that can write to ${repo}, or set GH_TOKEN for that account.`,
164
+ 403
165
+ );
76
166
  }
77
167
 
78
- function listRepoLabels({ repo, execFileSync }) {
79
- const output = runGh(execFileSync, ['label', 'list', '--repo', repo, '--json', 'name', '--limit', '1000'], 15000);
168
+ function listRepoLabels({ repo, execFileSync, ghEnv }) {
169
+ const output = runGh(execFileSync, ['label', 'list', '--repo', repo, '--json', 'name', '--limit', '1000'], 15000, { env: ghEnv });
80
170
  const parsed = JSON.parse(output || '[]');
81
171
  if (!Array.isArray(parsed)) {
82
172
  throw new GitHubIssueError('GitHub label list returned an unexpected response shape');
@@ -89,14 +179,14 @@ function listRepoLabels({ repo, execFileSync }) {
89
179
  return labelsByLower;
90
180
  }
91
181
 
92
- function resolveLabels({ labels, repo, execFileSync }) {
182
+ function resolveLabels({ labels, repo, execFileSync, ghEnv }) {
93
183
  const requested = normalizeLabels(labels);
94
184
  if (requested.length === 0) {
95
185
  return { requested, labelsToApply: [], labelsSkipped: [], validationUnavailable: false };
96
186
  }
97
187
 
98
188
  try {
99
- const available = listRepoLabels({ repo, execFileSync });
189
+ const available = listRepoLabels({ repo, execFileSync, ghEnv });
100
190
  const labelsToApply = [];
101
191
  const labelsSkipped = [];
102
192
  for (const label of requested) {
@@ -175,10 +265,10 @@ function _redactIssueContent(value, { repo, projects } = {}) {
175
265
  });
176
266
  }
177
267
 
178
- function createIssueWithLabels({ title, bodyFile, repo, labels, execFileSync }) {
268
+ function createIssueWithLabels({ title, bodyFile, repo, labels, execFileSync, ghEnv }) {
179
269
  const args = ['issue', 'create', '--repo', repo, '--title', title, '--body-file', bodyFile];
180
270
  if (labels.length > 0) args.push('--label', labels.join(','));
181
- const output = runGh(execFileSync, args, 30000);
271
+ const output = runGh(execFileSync, args, 30000, { env: ghEnv });
182
272
  const url = extractIssueUrl(output);
183
273
  if (!url) {
184
274
  throw new GitHubIssueError(`Issue may not have been created: ${conciseGhMessage(output)}`);
@@ -202,6 +292,7 @@ function createGitHubIssue({
202
292
  } catch (e) {
203
293
  throw new GitHubIssueError('gh CLI not found. Install from https://cli.github.com/');
204
294
  }
295
+ const ghEnv = resolveWritableGitHubEnv({ repo, execFileSync });
205
296
 
206
297
  const redactionProjects = projects || shared.getProjects();
207
298
  const safeTitle = _redactIssueContent(title, { repo, projects: redactionProjects });
@@ -214,13 +305,14 @@ function createGitHubIssue({
214
305
 
215
306
  let resolved;
216
307
  try {
217
- resolved = resolveLabels({ labels, repo, execFileSync });
308
+ resolved = resolveLabels({ labels, repo, execFileSync, ghEnv });
218
309
  const created = createIssueWithLabels({
219
310
  title: safeTitle,
220
311
  bodyFile,
221
312
  repo,
222
313
  labels: resolved.labelsToApply,
223
314
  execFileSync,
315
+ ghEnv,
224
316
  });
225
317
  const filedWithoutLabels = resolved.requested.length > 0 && resolved.labelsToApply.length === 0;
226
318
  return {
@@ -237,7 +329,7 @@ function createGitHubIssue({
237
329
  if (isAuthError(e)) throw new GitHubIssueError('GitHub auth required. Run: gh auth login', 401);
238
330
  if (resolved && resolved.labelsToApply.length > 0 && isLabelUnavailableError(e)) {
239
331
  try {
240
- const created = createIssueWithLabels({ title: safeTitle, bodyFile, repo, labels: [], execFileSync });
332
+ const created = createIssueWithLabels({ title: safeTitle, bodyFile, repo, labels: [], execFileSync, ghEnv });
241
333
  const skipped = normalizeLabels([...resolved.labelsSkipped, ...resolved.labelsToApply], []);
242
334
  return {
243
335
  ok: true,
@@ -205,19 +205,26 @@ function _pipelineProjectValues(...values) {
205
205
  for (const value of values) {
206
206
  if (value === undefined) continue;
207
207
  if (Array.isArray(value)) out.push(...value);
208
+ else if (value && typeof value === 'object' && value.project !== undefined) out.push(value.project);
209
+ else if (value && typeof value === 'object' && value._project !== undefined) out.push(value._project);
208
210
  else out.push(value);
209
211
  }
210
212
  return out;
211
213
  }
212
214
 
213
- function _resolvePipelineProjects(values, config) {
215
+ function _pipelineProjectValuePresent(value) {
216
+ if (value === undefined) return false;
217
+ if (value === null) return true;
218
+ if (typeof value === 'string') return value.trim() !== '';
219
+ return true;
220
+ }
221
+
222
+ function _resolvePipelineProjectValues(rawValues, config) {
214
223
  const projects = shared.getProjects(config);
215
- const rawValues = _pipelineProjectValues(...values);
216
- if (rawValues.length === 0) return { projects: [null] };
217
224
  const resolved = [];
218
225
  const seen = new Set();
219
226
  for (const raw of rawValues) {
220
- if (raw === undefined) continue;
227
+ if (!_pipelineProjectValuePresent(raw)) continue;
221
228
  if (raw === null || String(raw).trim().toLowerCase() === 'central') {
222
229
  if (!seen.has('central')) { seen.add('central'); resolved.push(null); }
223
230
  continue;
@@ -230,6 +237,16 @@ function _resolvePipelineProjects(values, config) {
230
237
  return { projects: resolved.length ? resolved : [null] };
231
238
  }
232
239
 
240
+ function _resolvePipelineProjects(scopes, config) {
241
+ for (const scope of (Array.isArray(scopes) ? scopes : [scopes])) {
242
+ const values = _pipelineProjectValues(...(Array.isArray(scope) ? scope : [scope]))
243
+ .filter(_pipelineProjectValuePresent);
244
+ if (values.length === 0) continue;
245
+ return _resolvePipelineProjectValues(values, config);
246
+ }
247
+ return { projects: [null] };
248
+ }
249
+
233
250
  function _pipelineProjectSlug(project) {
234
251
  return project ? shared.safeSlugComponent(project.name || 'project', 32).toLowerCase() : 'central';
235
252
  }
@@ -339,12 +356,9 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
339
356
  for (let i = 0; i < count; i++) {
340
357
  const item = items[i % items.length];
341
358
  const projectResolution = _resolvePipelineProjects([
342
- item.projects,
343
- item.project,
344
- stage.projects,
345
- stage.project,
346
- pipeline.projects,
347
- pipeline.project,
359
+ [item.projects, item.project],
360
+ [stage.projects, stage.project],
361
+ [pipeline.projects, pipeline.project],
348
362
  ], config);
349
363
  if (projectResolution.error) return { status: PIPELINE_STATUS.FAILED, error: projectResolution.error };
350
364
  for (const project of projectResolution.projects) {
@@ -376,7 +390,11 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
376
390
  touchedProjects.push(project?.name || null);
377
391
  }
378
392
  }
379
- return { status: PIPELINE_STATUS.RUNNING, artifacts: { workItems: [...new Set(createdIds)], projects: [...new Set(touchedProjects)] } };
393
+ const projects = [...new Set(touchedProjects)];
394
+ return {
395
+ status: PIPELINE_STATUS.RUNNING,
396
+ artifacts: { workItems: [...new Set(createdIds)], projects },
397
+ };
380
398
  }
381
399
 
382
400
  function executeTaskStageLegacy(stage, stageState, run, config) {
@@ -488,7 +506,10 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
488
506
  if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
489
507
 
490
508
  const slug = slugify(stage.title || 'pipeline-plan');
491
- const projectResolution = _resolvePipelineProjects([stage.projects, stage.project, pipeline.projects, pipeline.project], config);
509
+ const projectResolution = _resolvePipelineProjects([
510
+ [stage.projects, stage.project],
511
+ [pipeline.projects, pipeline.project],
512
+ ], config);
492
513
  if (projectResolution.error) return { status: PIPELINE_STATUS.FAILED, error: projectResolution.error };
493
514
  const targetProjects = projectResolution.projects;
494
515
  const wiIdForProject = (project) => `PL-${run.runId.slice(4, 12)}-${stage.id}-prd${targetProjects.length > 1 || project ? '-' + _pipelineProjectSlug(project) : ''}`;
@@ -1093,6 +1114,7 @@ module.exports = {
1093
1114
  getPipelineRuns, getActiveRun, startRun, updateRunStage, completeRun,
1094
1115
  discoverPipelineWork,
1095
1116
  evaluateCondition, // exported for testing
1096
- executeTaskStage, isStageComplete, resolveTemplate, // exported for testing
1117
+ executeTaskStage, executePlanStage, isStageComplete, resolveTemplate, // exported for testing
1118
+ _resolvePipelineProjects, // exported for testing
1097
1119
  _findMeetingsInRun, _findExistingPlanForMeeting, _findExistingPrdForPlan, // exported for testing
1098
1120
  };
package/engine/shared.js CHANGED
@@ -2963,7 +2963,7 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
2963
2963
  target[key] = normalizedEntry[key];
2964
2964
  }
2965
2965
  }
2966
- for (const key of ['_manual', '_autoObserve', '_context']) {
2966
+ for (const key of ['_manual', '_autoObserve', '_context', '_projectResolution']) {
2967
2967
  if (normalizedEntry[key] != null) target[key] = normalizedEntry[key];
2968
2968
  }
2969
2969
  if (normalizedEntry._contextOnly != null) {
package/engine.js CHANGED
@@ -592,7 +592,7 @@ async function spawnAgent(dispatchItem, config) {
592
592
  // Resolve prompt — prefers sidecar file when dispatchItem._promptFile is set
593
593
  // (large prompts are written to engine/contexts/<id>.md to keep dispatch.json
594
594
  // small — see shared.sidecarDispatchPrompt / #1167).
595
- const taskPrompt = shared.resolveDispatchPrompt(dispatchItem);
595
+ let taskPrompt = shared.resolveDispatchPrompt(dispatchItem);
596
596
  const claudeConfig = config.claude || {};
597
597
  const engineConfig = config.engine || {};
598
598
  const startedAt = ts();
@@ -601,15 +601,16 @@ async function spawnAgent(dispatchItem, config) {
601
601
 
602
602
  // Resolve project context for this dispatch
603
603
  // meta.project has {name, localPath} — enrich with full config (mainBranch, repoHost, etc.)
604
- const metaProject = meta?.project || {};
604
+ const metaProject = meta?.project;
605
605
  const projects = getProjects(config);
606
- const fullProject = shared.findProjectByNameOrPath(projects, metaProject.name || metaProject.localPath);
607
- if ((metaProject.name || metaProject.localPath) && !fullProject) {
608
- const err = new Error(shared.formatUnknownProjectError(metaProject.name || metaProject.localPath, projects));
606
+ const projectResolution = shared.resolveConfiguredProject(metaProject, projects, { defaultWhenSingle: true });
607
+ if (projectResolution.error) {
608
+ const err = new Error(projectResolution.error);
609
609
  updateAgentStatus(id, AGENT_STATUS.FAILED, err.message);
610
610
  throw err;
611
611
  }
612
- const project = fullProject ? { ...fullProject, ...metaProject } : (projects.length === 1 && !(metaProject.name || metaProject.localPath) ? projects[0] : {});
612
+ const metaProjectFields = metaProject && typeof metaProject === 'object' ? metaProject : {};
613
+ const project = projectResolution.project ? { ...projectResolution.project, ...metaProjectFields } : {};
613
614
  const rootDir = project.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
614
615
 
615
616
  // Determine working directory
@@ -621,8 +622,8 @@ async function spawnAgent(dispatchItem, config) {
621
622
  const _gitOpts = { stdio: 'pipe', timeout: 30000, windowsHide: true, env: shared.gitEnv() };
622
623
  const _worktreeGitOpts = { ..._gitOpts, timeout: worktreeCreateTimeout };
623
624
 
624
- // Build prompt before worktree setup prompt doesn't depend on worktree path
625
- // and this avoids blocking 200ms of file reads behind 20-60s of git operations
625
+ // Build the initial prompt before worktree setup, then refresh shared-branch
626
+ // work-item prompts after setup because reused worktrees can live at arbitrary paths.
626
627
  const systemPrompt = buildSystemPrompt(agentId, config, project);
627
628
  const agentContext = buildAgentContext(agentId, config, project);
628
629
  const pendingSteering = steering.buildPendingSteeringPrompt(agentId);
@@ -642,15 +643,18 @@ async function spawnAgent(dispatchItem, config) {
642
643
  'This report is the primary completion signal; fenced completion blocks are only a fallback.',
643
644
  '',
644
645
  ].join('\n') : '';
645
- const taskPromptWithSteering = pendingSteering.prompt
646
- ? `${pendingSteering.prompt}\n\n---\n\n${taskPrompt}`
647
- : taskPrompt;
648
- const taskPromptWithReport = completionReportInstruction
649
- ? `${taskPromptWithSteering}\n\n---\n\n${completionReportInstruction}`
650
- : taskPromptWithSteering;
651
- const fullTaskPrompt = agentContext
652
- ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPromptWithReport}`
653
- : taskPromptWithReport;
646
+ const buildFullTaskPrompt = (promptBody) => {
647
+ const taskPromptWithSteering = pendingSteering.prompt
648
+ ? `${pendingSteering.prompt}\n\n---\n\n${promptBody}`
649
+ : promptBody;
650
+ const taskPromptWithReport = completionReportInstruction
651
+ ? `${taskPromptWithSteering}\n\n---\n\n${completionReportInstruction}`
652
+ : taskPromptWithSteering;
653
+ return agentContext
654
+ ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPromptWithReport}`
655
+ : taskPromptWithReport;
656
+ };
657
+ let fullTaskPrompt = buildFullTaskPrompt(taskPrompt);
654
658
  const tmpDir = path.join(ENGINE_DIR, 'tmp');
655
659
  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
656
660
  const safeId = id.replace(/[:\\/*?"<>|]/g, '-');
@@ -1064,6 +1068,25 @@ async function spawnAgent(dispatchItem, config) {
1064
1068
 
1065
1069
  updateAgentStatus(id, AGENT_STATUS.READY, 'Worktree ready, preparing to spawn process');
1066
1070
 
1071
+ if (worktreePath && meta?.source === 'work-item' && meta?.item?.branchStrategy === 'shared-branch') {
1072
+ const refreshed = renderProjectWorkItemPromptForAgent(
1073
+ meta.item,
1074
+ routing.normalizeWorkType(type, WORK_TYPE.IMPLEMENT),
1075
+ agentId,
1076
+ config,
1077
+ project,
1078
+ rootDir,
1079
+ branchName,
1080
+ { worktreePath }
1081
+ );
1082
+ if (refreshed.prompt) {
1083
+ taskPrompt = refreshed.prompt;
1084
+ fullTaskPrompt = buildFullTaskPrompt(taskPrompt);
1085
+ safeWrite(promptPath, fullTaskPrompt);
1086
+ log('info', `Refreshed shared-branch prompt for ${id} with worktree ${worktreePath}`);
1087
+ }
1088
+ }
1089
+
1067
1090
  // Inject dirty file list when worktree has uncommitted changes (e.g., max_turns retry)
1068
1091
  // This signals to the respawned agent that prior work exists in the worktree (#960)
1069
1092
  if (worktreePath && fs.existsSync(worktreePath)) {
@@ -1714,7 +1737,7 @@ async function spawnAgent(dispatchItem, config) {
1714
1737
  }
1715
1738
  }, 5000);
1716
1739
 
1717
- // Move pending -> active under a lock to avoid cross-process lost updates (engine/dashboard)
1740
+ // Move pending -> active under lock to avoid lost updates.
1718
1741
  mutateDispatch((dispatch) => {
1719
1742
  const idx = dispatch.pending.findIndex(d => d.id === id);
1720
1743
  if (idx < 0) return dispatch;
@@ -1736,9 +1759,7 @@ async function spawnAgent(dispatchItem, config) {
1736
1759
  // reads dispatchItem.started_at for runtimeMs. (W-moux9nwn0008f923)
1737
1760
  dispatchItem.started_at = startedAt;
1738
1761
 
1739
- // Atomically stamp dispatched_to/dispatched_at on the originating work item (#402)
1740
- // The discover phase sets these via safeWrite which can race with concurrent writes;
1741
- // this locked write ensures the fields are persisted reliably.
1762
+ // Atomically stamp dispatched_to/dispatched_at on the originating work item (#402).
1742
1763
  if (meta?.item?.id) {
1743
1764
  try {
1744
1765
  let wiPath = null;
@@ -3072,7 +3093,8 @@ async function discoverFromPrs(config, project) {
3072
3093
  /**
3073
3094
  * Scan work-items.json for manually queued tasks
3074
3095
  */
3075
- function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, project, root, branchName) {
3096
+ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, project, root, branchName, options = {}) {
3097
+ const worktreePath = options.worktreePath || path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`);
3076
3098
  const vars = {
3077
3099
  ...buildBaseVars(agentId, config, project),
3078
3100
  item_id: item.id,
@@ -3089,7 +3111,7 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
3089
3111
  scope_section: `## Scope: Project — ${project?.name || 'default'}\n\nThis task is scoped to a single project.`,
3090
3112
  branch_name: branchName,
3091
3113
  project_path: root,
3092
- worktree_path: path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`),
3114
+ worktree_path: worktreePath,
3093
3115
  commit_message: item.commitMessage || `feat: ${item.title || item.id}`,
3094
3116
  notes_content: '',
3095
3117
  pr_id: item.pr_id || item._pr || item.targetPr || item.sourcePr || item.pr || '',
@@ -3740,12 +3762,9 @@ function discoverCentralWorkItems(config) {
3740
3762
 
3741
3763
  const workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
3742
3764
  const isFanOut = item.scope === 'fan-out';
3743
- const explicitItemProject = typeof item.project === 'string' ? item.project : (item.project?.name || item.project?.localPath);
3744
- const explicitProjectTarget = explicitItemProject
3745
- ? shared.resolveProjectSource(explicitItemProject, dispatchProjects, { allowCentral: false })
3746
- : null;
3747
- if (explicitProjectTarget?.error) {
3748
- const error = explicitProjectTarget.error;
3765
+ const itemProjectResolution = shared.resolveConfiguredProject(item.project, projects);
3766
+ if (itemProjectResolution.error) {
3767
+ const error = itemProjectResolution.error;
3749
3768
  mutations.set(item.id, { status: WI_STATUS.FAILED, failReason: error, failedAt: ts() });
3750
3769
  log('warn', `central work item ${item.id}: ${error}`);
3751
3770
  continue;
@@ -3849,13 +3868,11 @@ function discoverCentralWorkItems(config) {
3849
3868
  planReadError = e;
3850
3869
  }
3851
3870
  }
3852
- const requestedProjectName = declaredPlanProject || explicitItemProject;
3853
- const requestedTarget = requestedProjectName
3854
- ? shared.resolveProjectSource(requestedProjectName, dispatchProjects, { allowCentral: false })
3855
- : null;
3856
- const requestedProject = requestedTarget?.project || null;
3857
- if (requestedTarget?.error) {
3858
- const error = requestedTarget.error;
3871
+ const requestedProjectResolution = declaredPlanProject
3872
+ ? shared.resolveConfiguredProject(declaredPlanProject, projects)
3873
+ : itemProjectResolution;
3874
+ if (requestedProjectResolution.error) {
3875
+ const error = requestedProjectResolution.error;
3859
3876
  mutations.set(item.id, {
3860
3877
  status: WI_STATUS.FAILED,
3861
3878
  failReason: error,
@@ -3865,7 +3882,7 @@ function discoverCentralWorkItems(config) {
3865
3882
  log('warn', `central work item ${item.id}: ${error}`);
3866
3883
  continue;
3867
3884
  }
3868
- const targetProject = requestedProject || (dispatchProjects.length === 1 ? dispatchProjects[0] : null);
3885
+ const targetProject = requestedProjectResolution.project || (projects.length === 1 ? projects[0] : null);
3869
3886
  if (declaredPlanProject) {
3870
3887
  const projectMutation = { project: targetProject.name, _declaredPlanProject: declaredPlanProject };
3871
3888
  mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, projectMutation));
@@ -4916,6 +4933,7 @@ module.exports = {
4916
4933
 
4917
4934
  // Playbooks
4918
4935
  renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS, buildWorkItemDispatchVars,
4936
+ renderProjectWorkItemPromptForAgent, // exported for testing
4919
4937
 
4920
4938
  // Timeout / Steering / Idle (re-exported from engine/timeout.js)
4921
4939
  checkTimeouts, checkSteering, checkIdleThreshold,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1811",
3
+ "version": "0.1.1813",
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"
@@ -18,6 +18,18 @@ Before answering, classify the human's chat message:
18
18
 
19
19
  Do not emit `===ACTIONS===` or fenced `action` JSON for normal small document questions, summaries, rewrites, extraction, or localized edits. If a small task becomes medium/large after inspection, stop and dispatch a work item rather than pushing through in doc-chat.
20
20
 
21
+ ## Explicit Dispatch/Delegation Hard Stop
22
+
23
+ Explicit dispatch/delegation intent hard stop: when the human's chat message asks to `dispatch`, `delegate`, `assign`, `have Minions...`, `open work items for...`, `queue fixes for...`, or otherwise asks another agent/minion/work item to do the work, you MUST emit `===ACTIONS===` with dispatch JSON when the required fields are available, and you MUST NOT use `Write`, `Edit`, or `Bash` against any file. If a required dispatch field is genuinely unknown, ask for that missing field in plain text; do not edit files or run Bash while waiting for clarification.
24
+
25
+ This rule takes precedence over all document-editing paths below. Do not "helpfully" start implementing, fixing, or testing inline after acknowledging a dispatch/delegation request. The document may mention source files, contain findings, or list exact edits to make; those references are inputs for the dispatched work item's description, not permission to edit those files in doc-chat.
26
+
27
+ Negative example A: if a findings or audit document is open and the human says "dispatch fixes for every issue", the correct response is one or more dispatch actions. Do not use `Edit`, `Write`, or `Bash` on source files referenced by the document, such as `engine.js`, `engine/pipeline.js`, or `test/unit.test.js`, even if the findings make the fix look obvious.
28
+
29
+ Negative example B: if the human says "go fix these items", "implement this list", "have minions tackle these", or similar delegation phrasing, the correct response is dispatch action JSON. Do not edit the referenced implementation files directly from doc-chat.
30
+
31
+ ANY `Edit` or `Write` call targeting a path other than the document-context `filePath` is forbidden, regardless of how compelling, urgent, or specific the human request sounds. If the requested work spans any file other than the current document filePath, dispatch it instead of editing it.
32
+
21
33
  ## Minions Orchestration Requests
22
34
 
23
35
  For explicit dispatch/delegation requests or medium/larger work without a direct-handling override, emit the same Command Center work-item action shape:
@@ -48,6 +60,6 @@ For wholesale rewrites, format conversions, or changes touching most of the file
48
60
 
49
61
  ### Rules for both paths
50
62
 
51
- - Never edit any file other than the one named in the document context.
63
+ - Never edit any file other than the one named in the document context. `Edit`/`Write` against any other path is forbidden; if the work needs other files, dispatch it.
52
64
  - If the user is asking a question rather than requesting an edit, do not edit. Answer in plain text.
53
65
  - If a JSON file's edit would invalidate it, prefer the whole-file rewrite path so the server can validate the result before persisting.