@yemi33/minions 0.1.1810 → 0.1.1811

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1811 (2026-05-09)
4
+
5
+ ### Features
6
+ - centralize project source resolution (#2251)
7
+
3
8
  ## 0.1.1810 (2026-05-09)
4
9
 
5
10
  ### Features
package/dashboard.js CHANGED
@@ -130,8 +130,8 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
130
130
  if (body.projects && Array.isArray(body.projects)) {
131
131
  if (!Array.isArray(current.projects)) current.projects = [];
132
132
  for (const update of body.projects) {
133
- const candidateProject = (candidate.projects || []).find(p => p.name === update.name);
134
- const currentProject = current.projects.find(p => p.name === update.name);
133
+ const candidateProject = shared.findProjectByName(candidate.projects || [], update.name);
134
+ const currentProject = shared.findProjectByName(current.projects, update.name);
135
135
  if (!candidateProject || !currentProject) continue;
136
136
  currentProject.workSources = candidateProject.workSources;
137
137
  }
@@ -259,12 +259,11 @@ function normalizeWorkItemDedupTitle(value) {
259
259
  function resolveWorkItemDedupProject(item, wiPath = '') {
260
260
  const projectName = normalizeWorkItemDedupText(item?.project || item?._project || item?._source);
261
261
  if (projectName) {
262
- const namedProject = PROJECTS.find(p => p?.name === projectName);
263
- if (namedProject) return namedProject;
262
+ const namedProject = shared.resolveProjectSource(projectName, PROJECTS, { allowCentral: false });
263
+ if (namedProject.project) return namedProject.project;
264
264
  }
265
265
  if (!wiPath) return null;
266
- const resolvedWiPath = path.resolve(wiPath);
267
- return PROJECTS.find(p => path.resolve(shared.projectWorkItemsPath(p)) === resolvedWiPath) || null;
266
+ return shared.resolveProjectSource(wiPath, PROJECTS, { allowCentral: false }).project || null;
268
267
  }
269
268
 
270
269
  function getWorkItemPrRefCandidates(item) {
@@ -377,18 +376,37 @@ function findProjectByName(projects, projectName) {
377
376
  }
378
377
 
379
378
  function resolveWorkItemsCreateTarget(projectName, projects = PROJECTS) {
380
- const project = String(projectName || '').trim();
381
- let targetProject = null;
382
- if (project) {
383
- targetProject = findProjectByName(projects, project);
384
- if (!targetProject) return { error: formatUnknownProjectError(project, projects) };
385
- } else if (projects.length === 1) {
386
- targetProject = projects[0];
379
+ const target = shared.resolveProjectSource(projectName, projects, { defaultWhenSingle: true, minionsDir: MINIONS_DIR });
380
+ if (target.error) return { error: target.error };
381
+ return target;
382
+ }
383
+
384
+ function resolveProjectSourceTarget(source, projects = PROJECTS, options = {}) {
385
+ return shared.resolveProjectSource(source, projects, { minionsDir: MINIONS_DIR, ...options });
386
+ }
387
+
388
+ function dispatchPrefixForResolvedSource(target) {
389
+ return target?.project ? `work-${target.project.name}-` : 'central-work-';
390
+ }
391
+
392
+ function findWorkItemsTargetById(id, source, projects = PROJECTS) {
393
+ const explicitSource = source !== undefined && source !== null && String(source).trim() !== '';
394
+ if (explicitSource) {
395
+ const target = resolveProjectSourceTarget(source, projects);
396
+ if (target.error) return { error: target.error };
397
+ const items = shared.safeJson(target.wiPath) || [];
398
+ return { ...target, found: items.some(i => i.id === id) };
387
399
  }
388
- return {
389
- project: targetProject,
390
- wiPath: targetProject ? shared.projectWorkItemsPath(targetProject) : path.join(MINIONS_DIR, 'work-items.json'),
391
- };
400
+
401
+ const central = resolveProjectSourceTarget('central', projects);
402
+ const centralItems = shared.safeJson(central.wiPath) || [];
403
+ if (centralItems.some(i => i.id === id)) return { ...central, found: true };
404
+ for (const project of projects) {
405
+ const target = resolveProjectSourceTarget(project.name, projects);
406
+ const items = shared.safeJson(target.wiPath) || [];
407
+ if (items.some(i => i.id === id)) return { ...target, found: true };
408
+ }
409
+ return { found: false };
392
410
  }
393
411
 
394
412
  function validatePipelineProjects(pipeline, projects = PROJECTS) {
@@ -463,9 +481,12 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
463
481
  }
464
482
  const projects = shared.getProjects(config);
465
483
  const explicitProjectName = String(projectName || '').trim();
466
- let targetProject = explicitProjectName ? findProjectByName(projects, explicitProjectName) : null;
467
- if (explicitProjectName && !targetProject) {
468
- const err = new Error(formatUnknownProjectError(explicitProjectName, projects));
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);
469
490
  err.statusCode = 400;
470
491
  throw err;
471
492
  }
@@ -474,7 +495,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
474
495
  const matches = prScope ? projects.filter(p => shared.getProjectPrScope(p) === prScope) : [];
475
496
  if (matches.length === 1) targetProject = matches[0];
476
497
  }
477
- const prPath = targetProject ? shared.projectPrPath(targetProject) : path.join(MINIONS_DIR, 'pull-requests.json');
498
+ const prPath = targetProject ? shared.projectPrPath(targetProject) : shared.centralPullRequestsPath(MINIONS_DIR);
478
499
 
479
500
  const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
480
501
  const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
@@ -3088,22 +3109,22 @@ async function executeCCActions(actions, { source = 'command-center', inferredPr
3088
3109
  // projects → root-level work-items.json (orchestration system standalone use).
3089
3110
  let targetProject = null;
3090
3111
  if (project) {
3091
- targetProject = PROJECTS.find(p => p.name?.toLowerCase() === project.toLowerCase());
3092
- if (!targetProject) {
3093
- const known = PROJECTS.map(p => p.name).join(', ') || '(none configured)';
3094
- results.push({ type: action.type, error: `Project "${project}" not found. Known projects: ${known}` });
3112
+ const target = resolveProjectSourceTarget(project, PROJECTS, { allowCentral: false });
3113
+ targetProject = target.project;
3114
+ if (target.error) {
3115
+ results.push({ type: action.type, error: target.error });
3095
3116
  break;
3096
3117
  }
3097
3118
  } else if (prRef) {
3098
3119
  const allPrs = getPullRequests().filter(p => !p._ghost);
3099
3120
  linkedPr = shared.findPrRecord(allPrs, prRef) || null;
3100
3121
  if (linkedPr?._project && linkedPr._project !== 'central') {
3101
- targetProject = PROJECTS.find(p => p.name?.toLowerCase() === String(linkedPr._project).toLowerCase()) || null;
3122
+ targetProject = resolveProjectSourceTarget(linkedPr._project, PROJECTS, { allowCentral: false }).project || null;
3102
3123
  }
3103
3124
  } else if (inferredProject) {
3104
3125
  // Doc-chat fallback: filePath-derived project when the LLM omits the field. Validated against
3105
3126
  // PROJECTS upstream by _inferDocChatProject — a stale lookup would just yield null here.
3106
- targetProject = PROJECTS.find(p => p.name?.toLowerCase() === inferredProject.toLowerCase()) || null;
3127
+ targetProject = resolveProjectSourceTarget(inferredProject, PROJECTS, { allowCentral: false }).project || null;
3107
3128
  }
3108
3129
  if (!targetProject) {
3109
3130
  if (PROJECTS.length > 1) {
@@ -3124,7 +3145,7 @@ async function executeCCActions(actions, { source = 'command-center', inferredPr
3124
3145
  break;
3125
3146
  }
3126
3147
 
3127
- const wiPath = targetProject ? shared.projectWorkItemsPath(targetProject) : path.join(MINIONS_DIR, 'work-items.json');
3148
+ const wiPath = targetProject ? shared.projectWorkItemsPath(targetProject) : shared.centralWorkItemsPath(MINIONS_DIR);
3128
3149
 
3129
3150
  // Promote `agent` (singular) → `agents` (array). Models emit either shape and the prior code
3130
3151
  // only read `action.agents`, silently dropping `agent: "lambert"` style hints.
@@ -3187,7 +3208,7 @@ async function executeCCActions(actions, { source = 'command-center', inferredPr
3187
3208
  // unresolved → error so build-and-test can't accidentally run against the wrong repo.
3188
3209
  const projectName = action.project || pr._project || null;
3189
3210
  const project = projectName
3190
- ? PROJECTS.find(p => p.name?.toLowerCase() === String(projectName).toLowerCase())
3211
+ ? resolveProjectSourceTarget(projectName, PROJECTS, { allowCentral: false }).project
3191
3212
  : null;
3192
3213
  if (!project) {
3193
3214
  results.push({ type: 'build-and-test', error: `Project not found for PR ${pr.id}: ${projectName || '(none)'}` });
@@ -3248,14 +3269,14 @@ async function executeCCActions(actions, { source = 'command-center', inferredPr
3248
3269
  const project = action.project || '';
3249
3270
  let targetProject = null;
3250
3271
  if (project) {
3251
- targetProject = PROJECTS.find(p => p.name?.toLowerCase() === project.toLowerCase());
3252
- if (!targetProject) {
3253
- const known = PROJECTS.map(p => p.name).join(', ') || '(none configured)';
3254
- results.push({ type: 'reopen-work-item', id: action.id, error: `Project "${project}" not found. Known projects: ${known}` });
3272
+ const target = resolveProjectSourceTarget(project, PROJECTS, { allowCentral: false });
3273
+ targetProject = target.project;
3274
+ if (target.error) {
3275
+ results.push({ type: 'reopen-work-item', id: action.id, error: target.error });
3255
3276
  break;
3256
3277
  }
3257
3278
  } else if (inferredProject) {
3258
- targetProject = PROJECTS.find(p => p.name?.toLowerCase() === inferredProject.toLowerCase()) || null;
3279
+ targetProject = resolveProjectSourceTarget(inferredProject, PROJECTS, { allowCentral: false }).project || null;
3259
3280
  }
3260
3281
  if (!targetProject) {
3261
3282
  if (PROJECTS.length > 1) {
@@ -3264,7 +3285,7 @@ async function executeCCActions(actions, { source = 'command-center', inferredPr
3264
3285
  }
3265
3286
  if (PROJECTS.length === 1) targetProject = PROJECTS[0];
3266
3287
  }
3267
- const wiPath = targetProject ? shared.projectWorkItemsPath(targetProject) : path.join(MINIONS_DIR, 'work-items.json');
3288
+ const wiPath = targetProject ? shared.projectWorkItemsPath(targetProject) : shared.centralWorkItemsPath(MINIONS_DIR);
3268
3289
  let reopenResult = null;
3269
3290
  mutateJsonFileLocked(wiPath, items => {
3270
3291
  if (!Array.isArray(items)) items = [];
@@ -3844,7 +3865,7 @@ function _inferDocChatProject(filePath) {
3844
3865
  if (!filePath) return null;
3845
3866
  const m = String(filePath).replace(/\\/g, '/').match(/^projects\/([^/]+)\//);
3846
3867
  if (!m) return null;
3847
- const inferred = PROJECTS.find(p => p.name?.toLowerCase() === m[1].toLowerCase());
3868
+ const inferred = resolveProjectSourceTarget(m[1], PROJECTS, { allowCentral: false }).project;
3848
3869
  return inferred?.name || null;
3849
3870
  }
3850
3871
 
@@ -4531,14 +4552,14 @@ const server = http.createServer(async (req, res) => {
4531
4552
  }
4532
4553
 
4533
4554
  const config = queries.getConfig();
4534
- const project = PROJECTS.find(p => {
4535
- // safeJsonNoRestore never resurrect an archived PRD's .backup
4536
- // sidecar during project resolution (W-mouptdh1000h9f39). The
4537
- // active-from-archive write above (mutateJsonFileLocked) already
4538
- // created activePath when needed.
4539
- const plan = safeJsonNoRestore(activePath) || safeJsonNoRestore(prdPath);
4540
- return plan && p.name?.toLowerCase() === (plan.project || '').toLowerCase();
4541
- }) || PROJECTS[0] || null;
4555
+ // safeJsonNoRestore never resurrect an archived PRD's .backup sidecar
4556
+ // during project resolution (W-mouptdh1000h9f39). The active-from-archive
4557
+ // write above (mutateJsonFileLocked) already created activePath when needed.
4558
+ const projectPlan = safeJsonNoRestore(activePath) || safeJsonNoRestore(prdPath);
4559
+ const projectTarget = projectPlan?.project
4560
+ ? resolveProjectSourceTarget(projectPlan.project, PROJECTS, { allowCentral: false })
4561
+ : null;
4562
+ const project = projectTarget?.project || (PROJECTS.length === 1 ? PROJECTS[0] : null);
4542
4563
 
4543
4564
  // Check for existing verify WI — reset to pending if already done (re-verify)
4544
4565
  if (project) {
@@ -4592,25 +4613,9 @@ const server = http.createServer(async (req, res) => {
4592
4613
  if (!id) return jsonReply(res, 400, { error: 'id required' });
4593
4614
 
4594
4615
  // Find the right file — check source first, then search all project files
4595
- let wiPath;
4596
- if (source && source !== 'central') {
4597
- const proj = PROJECTS.find(p => p.name === source);
4598
- if (proj) wiPath = shared.projectWorkItemsPath(proj);
4599
- }
4600
- if (!wiPath) {
4601
- // Search central first, then all projects
4602
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
4603
- const centralItems = shared.safeJson(centralPath) || [];
4604
- if (centralItems.some(i => i.id === id)) {
4605
- wiPath = centralPath;
4606
- } else {
4607
- for (const proj of PROJECTS) {
4608
- const projPath = shared.projectWorkItemsPath(proj);
4609
- const projItems = shared.safeJson(projPath) || [];
4610
- if (projItems.some(i => i.id === id)) { wiPath = projPath; break; }
4611
- }
4612
- }
4613
- }
4616
+ let resolvedTarget = findWorkItemsTargetById(id, source, PROJECTS);
4617
+ if (resolvedTarget.error) return jsonReply(res, 404, { error: resolvedTarget.error });
4618
+ let wiPath = resolvedTarget.found ? resolvedTarget.wiPath : null;
4614
4619
  // If no work item found, attempt to re-materialize from PRD item definition
4615
4620
  if (!wiPath) {
4616
4621
  const prdFile = body.prdFile;
@@ -4629,8 +4634,9 @@ const server = http.createServer(async (req, res) => {
4629
4634
 
4630
4635
  // Determine target work-items file (project from PRD item or plan, fallback to central)
4631
4636
  const projName = prdItem.project || plan.project || prdFile.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
4632
- const proj = PROJECTS.find(p => p.name?.toLowerCase() === projName.toLowerCase());
4633
- const targetWiPath = proj ? shared.projectWorkItemsPath(proj) : path.join(MINIONS_DIR, 'work-items.json');
4637
+ const prdTarget = resolveProjectSourceTarget(projName, PROJECTS, { allowCentral: false });
4638
+ const proj = prdTarget.project;
4639
+ const targetWiPath = proj ? shared.projectWorkItemsPath(proj) : shared.centralWorkItemsPath(MINIONS_DIR);
4634
4640
 
4635
4641
  // Create new work item from PRD item definition (same logic as materializePlansAsWorkItems)
4636
4642
  const complexity = prdItem.estimated_complexity || 'medium';
@@ -4662,8 +4668,7 @@ const server = http.createServer(async (req, res) => {
4662
4668
 
4663
4669
  // Clear dispatch history and cooldowns for this item
4664
4670
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
4665
- const sourcePrefix = proj ? `work-${proj.name}-` : 'central-work-';
4666
- const dispatchKey = sourcePrefix + id;
4671
+ const dispatchKey = (proj ? `work-${proj.name}-` : 'central-work-') + id;
4667
4672
  try {
4668
4673
  mutateJsonFileLocked(dispatchPath, (dispatch) => {
4669
4674
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
@@ -4708,8 +4713,7 @@ const server = http.createServer(async (req, res) => {
4708
4713
 
4709
4714
  // Clear completed dispatch entries so the engine doesn't dedup this item
4710
4715
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
4711
- const sourcePrefix = (!source || source === 'central') ? 'central-work-' : `work-${source}-`;
4712
- const dispatchKey = sourcePrefix + id;
4716
+ const dispatchKey = dispatchPrefixForResolvedSource(resolvedTarget) + id;
4713
4717
  try {
4714
4718
  mutateJsonFileLocked(dispatchPath, (dispatch) => {
4715
4719
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
@@ -4737,17 +4741,9 @@ const server = http.createServer(async (req, res) => {
4737
4741
  const { id, source } = body;
4738
4742
  if (!id) return jsonReply(res, 400, { error: 'id required' });
4739
4743
 
4740
- // Find the right work-items file
4741
- let wiPath;
4742
- if (!source || source === 'central') {
4743
- wiPath = path.join(MINIONS_DIR, 'work-items.json');
4744
- } else {
4745
- const proj = PROJECTS.find(p => p.name === source);
4746
- if (proj) {
4747
- wiPath = shared.projectWorkItemsPath(proj);
4748
- }
4749
- }
4750
- if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
4744
+ const target = resolveProjectSourceTarget(source, PROJECTS);
4745
+ if (target.error) return jsonReply(res, 404, { error: target.error });
4746
+ const wiPath = target.wiPath;
4751
4747
 
4752
4748
  let item = null;
4753
4749
  let found = false;
@@ -4797,15 +4793,9 @@ const server = http.createServer(async (req, res) => {
4797
4793
  const { id, source, reason } = body;
4798
4794
  if (!id) return jsonReply(res, 400, { error: 'id required' });
4799
4795
 
4800
- // Find the right work-items file
4801
- let wiPath;
4802
- if (!source || source === 'central') {
4803
- wiPath = path.join(MINIONS_DIR, 'work-items.json');
4804
- } else {
4805
- const proj = PROJECTS.find(p => p.name === source);
4806
- if (proj) wiPath = shared.projectWorkItemsPath(proj);
4807
- }
4808
- if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
4796
+ const target = resolveProjectSourceTarget(source, PROJECTS);
4797
+ if (target.error) return jsonReply(res, 404, { error: target.error });
4798
+ const wiPath = target.wiPath;
4809
4799
 
4810
4800
  let result = null;
4811
4801
  mutateJsonFileLocked(wiPath, (items) => {
@@ -4853,16 +4843,9 @@ const server = http.createServer(async (req, res) => {
4853
4843
  const { id, source } = body;
4854
4844
  if (!id) return jsonReply(res, 400, { error: 'id required' });
4855
4845
 
4856
- let wiPath;
4857
- if (!source || source === 'central') {
4858
- wiPath = path.join(MINIONS_DIR, 'work-items.json');
4859
- } else {
4860
- const proj = PROJECTS.find(p => p.name === source);
4861
- if (proj) {
4862
- wiPath = shared.projectWorkItemsPath(proj);
4863
- }
4864
- }
4865
- if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
4846
+ const target = resolveProjectSourceTarget(source, PROJECTS);
4847
+ if (target.error) return jsonReply(res, 404, { error: target.error });
4848
+ const wiPath = target.wiPath;
4866
4849
 
4867
4850
  let archivedItem = null;
4868
4851
  mutateJsonFileLocked(wiPath, (items) => {
@@ -4884,7 +4867,7 @@ const server = http.createServer(async (req, res) => {
4884
4867
  }, { defaultValue: [] });
4885
4868
 
4886
4869
  // Clean dispatch entries for archived item
4887
- const sourcePrefix = (!source || source === 'central') ? 'central-work-' : `work-${source}-`;
4870
+ const sourcePrefix = dispatchPrefixForResolvedSource(target);
4888
4871
  cleanDispatchEntries(d =>
4889
4872
  d.meta?.dispatchKey === sourcePrefix + id ||
4890
4873
  d.meta?.item?.id === id
@@ -4910,15 +4893,9 @@ const server = http.createServer(async (req, res) => {
4910
4893
  const project = body.project || body.source;
4911
4894
  if (!id) return jsonReply(res, 400, { error: 'id required' });
4912
4895
 
4913
- // Find the right work-items file
4914
- let wiPath;
4915
- if (!project || project === 'central') {
4916
- wiPath = path.join(MINIONS_DIR, 'work-items.json');
4917
- } else {
4918
- const proj = PROJECTS.find(p => p.name === project);
4919
- if (proj) wiPath = shared.projectWorkItemsPath(proj);
4920
- }
4921
- if (!wiPath) return jsonReply(res, 404, { error: 'project not found' });
4896
+ const target = resolveProjectSourceTarget(project, PROJECTS);
4897
+ if (target.error) return jsonReply(res, 404, { error: target.error });
4898
+ const wiPath = target.wiPath;
4922
4899
 
4923
4900
  let result = null;
4924
4901
  mutateJsonFileLocked(wiPath, (items) => {
@@ -4938,8 +4915,7 @@ const server = http.createServer(async (req, res) => {
4938
4915
  if (result.code !== 200) return jsonReply(res, result.code, result.body);
4939
4916
 
4940
4917
  // Clear dispatch history and cooldowns outside lock
4941
- const sourcePrefix = (!project || project === 'central') ? 'central-work-' : `work-${project}-`;
4942
- const dispatchKey = sourcePrefix + id;
4918
+ const dispatchKey = dispatchPrefixForResolvedSource(target) + id;
4943
4919
  try {
4944
4920
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
4945
4921
  mutateJsonFileLocked(dispatchPath, (dispatch) => {
@@ -5005,16 +4981,9 @@ const server = http.createServer(async (req, res) => {
5005
4981
  const { id, source, title, description, type, priority, agent } = body;
5006
4982
  if (!id) return jsonReply(res, 400, { error: 'id required' });
5007
4983
 
5008
- let wiPath;
5009
- if (!source || source === 'central') {
5010
- wiPath = path.join(MINIONS_DIR, 'work-items.json');
5011
- } else {
5012
- const proj = PROJECTS.find(p => p.name === source);
5013
- if (proj) {
5014
- wiPath = shared.projectWorkItemsPath(proj);
5015
- }
5016
- }
5017
- if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
4984
+ const target = resolveProjectSourceTarget(source, PROJECTS);
4985
+ if (target.error) return jsonReply(res, 404, { error: target.error });
4986
+ const wiPath = target.wiPath;
5018
4987
 
5019
4988
  let result = null;
5020
4989
  let agentChanged = false;
@@ -5097,7 +5066,7 @@ const server = http.createServer(async (req, res) => {
5097
5066
  const planFile = 'manual-' + shared.uid() + '.json';
5098
5067
  const plan = {
5099
5068
  version: 'manual-' + new Date().toISOString().slice(0, 10),
5100
- project: target.project?.name || (PROJECTS.length > 0 ? PROJECTS[0].name : 'Unknown'),
5069
+ project: target.project?.name || 'Unknown',
5101
5070
  generated_by: 'dashboard',
5102
5071
  generated_at: new Date().toISOString().slice(0, 10),
5103
5072
  plan_summary: body.name,
@@ -5844,7 +5813,8 @@ const server = http.createServer(async (req, res) => {
5844
5813
  }).join('\n');
5845
5814
 
5846
5815
  const projectName = plan.project || body.file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
5847
- const targetProject = PROJECTS.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) || PROJECTS[0];
5816
+ const projectTarget = resolveProjectSourceTarget(projectName, PROJECTS, { allowCentral: false });
5817
+ const targetProject = projectTarget.project || (PROJECTS.length === 1 ? PROJECTS[0] : null);
5848
5818
  if (targetProject) {
5849
5819
  diffAwareQueued = shared.queuePlanToPrd({
5850
5820
  planFile: plan.source_plan, prdFile: body.file,
@@ -6808,7 +6778,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6808
6778
  let alreadyLinked = false;
6809
6779
  mutateDashboardConfig(config => {
6810
6780
  if (!Array.isArray(config.projects)) config.projects = [];
6811
- alreadyLinked = config.projects.some(p => path.resolve(p.localPath) === target);
6781
+ alreadyLinked = config.projects.some(p => shared.sameResolvedPath(p.localPath, target));
6812
6782
  return config;
6813
6783
  });
6814
6784
  if (alreadyLinked) {
@@ -6853,7 +6823,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6853
6823
  let duplicate = false;
6854
6824
  mutateDashboardConfig(config => {
6855
6825
  if (!Array.isArray(config.projects)) config.projects = [];
6856
- if (config.projects.some(p => path.resolve(p.localPath) === target)) {
6826
+ if (config.projects.some(p => shared.sameResolvedPath(p.localPath, target))) {
6857
6827
  duplicate = true;
6858
6828
  return config;
6859
6829
  }
@@ -8008,7 +7978,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8008
7978
  if (body.projects && Array.isArray(body.projects)) {
8009
7979
  if (!config.projects) config.projects = [];
8010
7980
  for (const update of body.projects) {
8011
- const proj = config.projects.find(p => p.name === update.name);
7981
+ const proj = shared.findProjectByName(config.projects, update.name);
8012
7982
  if (!proj) continue;
8013
7983
  if (!proj.workSources) proj.workSources = {};
8014
7984
  if (update.workSources?.pullRequests !== undefined) {
package/engine/cli.js CHANGED
@@ -1157,11 +1157,14 @@ const commands = {
1157
1157
  }
1158
1158
 
1159
1159
  const config = getConfig();
1160
- const { getProjects, projectWorkItemsPath } = require('./shared');
1160
+ const { getProjects, projectWorkItemsPath, resolveProjectSource } = require('./shared');
1161
1161
  const projects = getProjects(config);
1162
- const targetProject = opts.project
1163
- ? projects.find(p => p.name?.toLowerCase() === opts.project?.toLowerCase()) || projects[0]
1164
- : projects[0];
1162
+ const target = opts.project ? resolveProjectSource(opts.project, projects, { allowCentral: false }) : null;
1163
+ if (target?.error) {
1164
+ console.log(target.error);
1165
+ return;
1166
+ }
1167
+ const targetProject = target?.project || projects[0];
1165
1168
  const wiPath = projectWorkItemsPath(targetProject);
1166
1169
  let item;
1167
1170
  mutateWorkItems(wiPath, items => {
@@ -1202,11 +1205,14 @@ const commands = {
1202
1205
  }
1203
1206
 
1204
1207
  const config = getConfig();
1205
- const { getProjects } = require('./shared');
1208
+ const { getProjects, resolveProjectSource } = require('./shared');
1206
1209
  const projects = getProjects(config);
1207
- const targetProject = projectName
1208
- ? projects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) || projects[0]
1209
- : projects[0];
1210
+ const target = projectName ? resolveProjectSource(projectName, projects, { allowCentral: false }) : null;
1211
+ if (target?.error) {
1212
+ console.log(target.error);
1213
+ return;
1214
+ }
1215
+ const targetProject = target?.project || projects[0];
1210
1216
 
1211
1217
  if (!targetProject) {
1212
1218
  console.log('No projects configured. Run: minions add <dir>');
@@ -1395,7 +1401,7 @@ const commands = {
1395
1401
  const wiPath = (item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout')
1396
1402
  ? path.join(MINIONS_DIR, 'work-items.json')
1397
1403
  : item.meta.project?.localPath
1398
- ? shared.projectWorkItemsPath({ localPath: item.meta.project.localPath, name: item.meta.project.name, workSources: config.projects?.find(p => p.name === item.meta.project.name)?.workSources })
1404
+ ? shared.projectWorkItemsPath(shared.resolveProjectSource(item.meta.project, config.projects || [], { allowCentral: false }).project || item.meta.project)
1399
1405
  : null;
1400
1406
  if (wiPath) {
1401
1407
  mutateWorkItems(wiPath, items => {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-09T01:00:53.065Z"
4
+ "cachedAt": "2026-05-09T08:23:02.798Z"
5
5
  }
@@ -79,7 +79,8 @@ function mutateDispatch(mutator) {
79
79
 
80
80
  function getDispatchProjectKey(project) {
81
81
  if (!project) return '';
82
- return project.name || (project.localPath ? path.resolve(project.localPath).toLowerCase() : '');
82
+ if (project.name) return String(project.name).toLowerCase();
83
+ return project.localPath ? path.resolve(project.localPath).toLowerCase() : '';
83
84
  }
84
85
 
85
86
  function getPrDispatchTargetKey(entry) {
@@ -176,15 +177,8 @@ function addToDispatch(item) {
176
177
  function _resolveDispatchProject(projectRef, config) {
177
178
  if (!projectRef) return null;
178
179
  const projects = getProjects(config);
179
- if (projectRef.name) {
180
- const byName = projects.find(p => p.name === projectRef.name);
181
- if (byName) return byName;
182
- }
183
- if (projectRef.localPath) {
184
- const refPath = path.resolve(projectRef.localPath);
185
- const byPath = projects.find(p => p.localPath && path.resolve(p.localPath) === refPath);
186
- if (byPath) return byPath;
187
- }
180
+ const resolved = shared.resolveProjectSource(projectRef, projects, { allowCentral: false });
181
+ if (resolved.project) return resolved.project;
188
182
  return projectRef;
189
183
  }
190
184
 
@@ -160,7 +160,8 @@ function checkPlanCompletion(meta, config) {
160
160
  // Resolve the primary project for writing new work items (PR, verify)
161
161
  const projectName = plan.project;
162
162
  const primaryProject = projectName
163
- ? projects.find(p => p.name?.toLowerCase() === projectName?.toLowerCase()) : projects[0];
163
+ ? shared.resolveProjectSource(projectName, projects, { allowCentral: false }).project
164
+ : (projects.length === 1 ? projects[0] : null);
164
165
  if (!primaryProject) {
165
166
  log('warn', `Plan ${planFile}: no primary project found — skipping PR/verify creation`);
166
167
  return;
@@ -196,9 +197,8 @@ function checkPlanCompletion(meta, config) {
196
197
  log('info', `Plan ${planFile}: verify WI ${existingVerify.id} already ${existingVerify.status} — skipping`);
197
198
  } else if (isReopenableVerify(existingVerify) && doneItems.length > 0) {
198
199
  const verifyProject = existingVerify.project || projectName;
199
- const vWiPath = shared.projectWorkItemsPath(
200
- projects.find(p => p.name?.toLowerCase() === verifyProject?.toLowerCase()) || primaryProject
201
- );
200
+ const vProject = shared.resolveProjectSource(verifyProject, projects, { allowCentral: false }).project || primaryProject;
201
+ const vWiPath = shared.projectWorkItemsPath(vProject);
202
202
  let reopenedVerify = false;
203
203
  mutateWorkItems(vWiPath, items => {
204
204
  const v = items.find(w => w.id === existingVerify.id);
@@ -777,7 +777,8 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
777
777
 
778
778
  const projects = shared.getProjects(config);
779
779
  if (projects.length === 0 && !meta?.project?.name) return 0;
780
- const defaultProject = (meta?.project?.name && projects.find(p => p.name === meta.project.name)) || projects[0];
780
+ const defaultProject = (meta?.project?.name && shared.resolveProjectSource(meta.project.name, projects, { allowCentral: false }).project) ||
781
+ (projects.length === 1 ? projects[0] : null);
781
782
  const useCentral = !defaultProject;
782
783
 
783
784
  // Match each PR to its correct project by finding which repo URL appears near the PR number in output
@@ -950,16 +951,15 @@ function hasCanonicalPrAttachment(itemId, config) {
950
951
  function resolvePrFallbackProject(meta, config) {
951
952
  const projects = shared.getProjects(config);
952
953
  if (meta?.project?.name) {
953
- const match = projects.find(p => p.name === meta.project.name);
954
+ const match = shared.resolveProjectSource(meta.project.name, projects, { allowCentral: false }).project;
954
955
  if (match) return match;
955
956
  }
956
957
  if (meta?.project?.localPath) {
957
- const metaPath = path.resolve(meta.project.localPath);
958
- const match = projects.find(p => p.localPath && path.resolve(p.localPath) === metaPath);
958
+ const match = shared.resolveProjectSource(meta.project.localPath, projects, { allowCentral: false }).project;
959
959
  if (match) return match;
960
960
  }
961
961
  if (meta?.item?.project) {
962
- const match = projects.find(p => p.name === meta.item.project);
962
+ const match = shared.resolveProjectSource(meta.item.project, projects, { allowCentral: false }).project;
963
963
  if (match) return match;
964
964
  }
965
965
  return projects.length === 1 ? projects[0] : null;
@@ -1813,7 +1813,7 @@ async function processPendingRebases(config) {
1813
1813
  for (const entry of snapshot) {
1814
1814
  if (isBranchActive(entry.branch)) { remaining.push(entry); continue; }
1815
1815
 
1816
- const project = shared.getProjects(config).find(p => p.name === entry.projectName);
1816
+ const project = shared.resolveProjectSource(entry.projectName, shared.getProjects(config), { allowCentral: false }).project;
1817
1817
  if (!project) continue;
1818
1818
 
1819
1819
  const prs = getPrs(project);
@@ -2037,7 +2037,7 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config, runtimeN
2037
2037
  if (!m('created')) enrichedBlock = enrichedBlock.replace('---\n', `---\ncreated: ${dateStamp()}\n`);
2038
2038
  const skillDirName = name.replace(/[^a-z0-9-]/g, '-');
2039
2039
  if (scope === 'project' && project) {
2040
- const proj = shared.getProjects(config).find(p => p.name === project);
2040
+ const proj = shared.resolveProjectSource(project, shared.getProjects(config), { allowCentral: false }).project;
2041
2041
  if (proj) {
2042
2042
  const projectSkillRoot = skillWriteTargets(effectiveRuntime, proj).project
2043
2043
  || path.resolve(proj.localPath, '.claude', 'skills');
@@ -66,7 +66,7 @@ function removeProject(target, options = {}) {
66
66
  // 1. Cancel pending/queued work items linked to this project (project-local
67
67
  // file + central). Done items are preserved as history.
68
68
  summary.cancelledItems += dispatch.cancelPendingWorkItems(
69
- path.join(MINIONS_DIR, 'projects', project.name, 'work-items.json'),
69
+ shared.projectWorkItemsPath(project),
70
70
  () => true,
71
71
  'project-removed',
72
72
  );
@@ -103,7 +103,7 @@ function removeProject(target, options = {}) {
103
103
  // specifically. Don't touch schedules with project='any' or unset.
104
104
  if (Array.isArray(config.schedules)) {
105
105
  for (const s of config.schedules) {
106
- if (s.project === project.name && s.enabled !== false) {
106
+ if (shared.resolveProjectSource(s.project, [project], { allowCentral: false }).project && s.enabled !== false) {
107
107
  s.enabled = false;
108
108
  summary.disabledSchedules++;
109
109
  }
@@ -164,20 +164,20 @@ function removeProject(target, options = {}) {
164
164
  ...(p.monitoredResources || []),
165
165
  ...((p.stages || []).flatMap(s => s.monitoredResources || [])),
166
166
  ];
167
- if (refs.some(r => r && (r.project === project.name || r._project === project.name))) {
167
+ if (refs.some(r => r && shared.resolveProjectSource(r.project || r._project, [project], { allowCentral: false }).project)) {
168
168
  summary.pipelineRefs.push(p.id);
169
169
  }
170
170
  }
171
171
  } catch { /* pipelines optional */ }
172
172
 
173
173
  // 7. Remove from config.json (and persist any schedule disables)
174
- config.projects = (config.projects || []).filter(p => p.name !== project.name);
174
+ config.projects = (config.projects || []).filter(p => !shared.resolveProjectSource(p, [project], { allowCentral: false }).project);
175
175
  try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); }
176
176
  catch (e) { return { ...summary, error: 'Failed to write config: ' + e.message }; }
177
177
 
178
178
  // 8. Move (or purge) projects/<name>/ — preserves PR/work-item history by
179
179
  // default so a re-add can pick up where it left off.
180
- const dataDir = path.join(MINIONS_DIR, 'projects', project.name);
180
+ const dataDir = shared.projectStateDir(project);
181
181
  if (fs.existsSync(dataDir)) {
182
182
  if (dataMode === 'purge') {
183
183
  try { fs.rmSync(dataDir, { recursive: true, force: true }); summary.purgedDataDir = true; }
package/engine/queries.js CHANGED
@@ -703,7 +703,7 @@ function buildPrUrlFromId(prId, pr, projects) {
703
703
  }
704
704
  }
705
705
  }
706
- const project = pr?._project ? projects.find(p => p.name === pr._project) : null;
706
+ const project = pr?._project ? shared.resolveProjectSource(pr._project, projects, { allowCentral: false }).project : null;
707
707
  const prNumber = shared.getPrNumber(pr || prId);
708
708
  if (project?.prUrlBase && prNumber != null) return project.prUrlBase + prNumber;
709
709
  return '';
@@ -1179,7 +1179,7 @@ function getWorkItems(config) {
1179
1179
  const allPrs = getPullRequests(config);
1180
1180
  for (const item of allItems) {
1181
1181
  if (item._pr && !item._prUrl) {
1182
- const project = projects.find(p => p.name === item.project || p.name === item._source) || null;
1182
+ const project = shared.resolveProjectSource(item.project || item._source, projects, { allowCentral: false }).project || null;
1183
1183
  const canonicalPrId = shared.getCanonicalPrId(project, item._pr);
1184
1184
  const displayPrId = shared.getPrDisplayId(item._pr);
1185
1185
  const exactPr = allPrs.find(p => p.id === canonicalPrId);
@@ -1418,7 +1418,7 @@ function getPrdInfo(config) {
1418
1418
  // Fallback: work item _pr field for anything still missing
1419
1419
  for (const wi of Object.values(wiById)) {
1420
1420
  if (!wi._pr || prdToPr[wi.id]?.length) continue;
1421
- const project = projects.find(p => p.name === wi.project || p.name === wi._source) || null;
1421
+ const project = shared.resolveProjectSource(wi.project || wi._source, projects, { allowCentral: false }).project || null;
1422
1422
  const canonicalPrId = shared.getCanonicalPrId(project, wi._pr);
1423
1423
  const exactPr = prById[canonicalPrId] || null;
1424
1424
  const displayMatches = exactPr ? [] : Object.values(prById).filter(candidate => shared.getPrDisplayId(candidate) === shared.getPrDisplayId(wi._pr));
package/engine/shared.js CHANGED
@@ -1725,7 +1725,7 @@ function findProjectByName(projects, projectName) {
1725
1725
  function findProjectByNameOrPath(projects, target) {
1726
1726
  const value = String(target || '').trim();
1727
1727
  if (!value) return null;
1728
- return findProjectByName(projects, value) || (projects || []).find(p => p?.localPath && sameResolvedPath(p.localPath, value)) || null;
1728
+ return resolveProjectSource(value, projects, { allowCentral: false }).project || null;
1729
1729
  }
1730
1730
 
1731
1731
  function resolveConfiguredProject(projectName, projectsOrConfig, options = {}) {
@@ -1746,6 +1746,117 @@ function resolveConfiguredProject(projectName, projectsOrConfig, options = {}) {
1746
1746
  return { project: null, explicit: false, value: '' };
1747
1747
  }
1748
1748
 
1749
+ function centralWorkItemsPath(minionsDir = MINIONS_DIR) {
1750
+ return path.join(minionsDir, 'work-items.json');
1751
+ }
1752
+
1753
+ function centralPullRequestsPath(minionsDir = MINIONS_DIR) {
1754
+ return path.join(minionsDir, 'pull-requests.json');
1755
+ }
1756
+
1757
+ function _projectSourceRawValue(source) {
1758
+ if (source && typeof source === 'object') {
1759
+ return source.name ?? source.project ?? source._project ?? source.source ?? source._source ??
1760
+ source.wiPath ?? source.workItemsPath ?? source.prPath ?? source.pullRequestsPath ??
1761
+ source.stateDir ?? source.path ?? source.localPath ?? '';
1762
+ }
1763
+ return source;
1764
+ }
1765
+
1766
+ function _sameSourcePath(value, targetPath, minionsDir = MINIONS_DIR) {
1767
+ if (!value || !targetPath) return false;
1768
+ if (sameResolvedPath(value, targetPath)) return true;
1769
+ if (!path.isAbsolute(value) && sameResolvedPath(path.resolve(minionsDir, value), targetPath)) return true;
1770
+ return false;
1771
+ }
1772
+
1773
+ function _projectSourceDescriptor(project, value, explicit, minionsDir = MINIONS_DIR) {
1774
+ const wiPath = project ? projectWorkItemsPath(project) : centralWorkItemsPath(minionsDir);
1775
+ const prPath = project ? projectPrPath(project) : centralPullRequestsPath(minionsDir);
1776
+ const stateDir = project ? projectStateDir(project) : minionsDir;
1777
+ return {
1778
+ project: project || null,
1779
+ explicit: !!explicit,
1780
+ value: value || '',
1781
+ sourceName: project?.name || 'central',
1782
+ isCentral: !project,
1783
+ stateDir,
1784
+ wiPath,
1785
+ prPath,
1786
+ };
1787
+ }
1788
+
1789
+ function resolveProjectSource(source, projectsOrConfig, options = {}) {
1790
+ const projects = Array.isArray(projectsOrConfig) ? projectsOrConfig : getProjects(projectsOrConfig);
1791
+ const minionsDir = options.minionsDir ? path.resolve(options.minionsDir) : MINIONS_DIR;
1792
+ const allowCentral = options.allowCentral !== false;
1793
+ const raw = _projectSourceRawValue(source);
1794
+ const value = String(raw || '').trim();
1795
+ const explicit = !!value;
1796
+
1797
+ if (!value) {
1798
+ if (options.defaultWhenSingle && projects.length === 1) {
1799
+ return _projectSourceDescriptor(projects[0], '', false, minionsDir);
1800
+ }
1801
+ if (allowCentral) return _projectSourceDescriptor(null, '', false, minionsDir);
1802
+ return { project: null, explicit: false, value: '', sourceName: '', isCentral: false, wiPath: null, prPath: null, stateDir: null };
1803
+ }
1804
+
1805
+ const centralWorkPath = centralWorkItemsPath(minionsDir);
1806
+ const centralPrPath = centralPullRequestsPath(minionsDir);
1807
+ const centralNames = new Set(['central', 'root']);
1808
+ const lowerValue = value.toLowerCase();
1809
+ const isCentral = centralNames.has(lowerValue) ||
1810
+ _sameSourcePath(value, minionsDir, minionsDir) ||
1811
+ _sameSourcePath(value, centralWorkPath, minionsDir) ||
1812
+ _sameSourcePath(value, centralPrPath, minionsDir);
1813
+ if (isCentral) {
1814
+ if (allowCentral) return _projectSourceDescriptor(null, value, true, minionsDir);
1815
+ return {
1816
+ project: null,
1817
+ explicit: true,
1818
+ value,
1819
+ sourceName: '',
1820
+ isCentral: false,
1821
+ wiPath: null,
1822
+ prPath: null,
1823
+ stateDir: null,
1824
+ error: 'central source is not allowed here',
1825
+ };
1826
+ }
1827
+
1828
+ for (const project of projects || []) {
1829
+ if (!project) continue;
1830
+ if (String(project.name || '').toLowerCase() === lowerValue) {
1831
+ return _projectSourceDescriptor(project, value, explicit, minionsDir);
1832
+ }
1833
+ const candidates = [
1834
+ project.localPath,
1835
+ projectStateDir(project),
1836
+ projectWorkItemsPath(project),
1837
+ projectPrPath(project),
1838
+ legacyProjectStateDir(project),
1839
+ legacyProjectStatePath(project, 'work-items.json'),
1840
+ legacyProjectStatePath(project, 'pull-requests.json'),
1841
+ ].filter(Boolean);
1842
+ if (candidates.some(candidate => _sameSourcePath(value, candidate, minionsDir))) {
1843
+ return _projectSourceDescriptor(project, value, explicit, minionsDir);
1844
+ }
1845
+ }
1846
+
1847
+ return {
1848
+ project: null,
1849
+ explicit: true,
1850
+ value,
1851
+ sourceName: '',
1852
+ isCentral: false,
1853
+ wiPath: null,
1854
+ prPath: null,
1855
+ stateDir: null,
1856
+ error: formatUnknownProjectError(value, projects),
1857
+ };
1858
+ }
1859
+
1749
1860
  function projectRoot(project) {
1750
1861
  return path.resolve(project.localPath);
1751
1862
  }
@@ -1812,9 +1923,13 @@ function mergeProjectStateArrays(current, legacy) {
1812
1923
 
1813
1924
  function sameResolvedPath(a, b) {
1814
1925
  if (!a || !b) return false;
1815
- const left = path.resolve(a);
1816
- const right = path.resolve(b);
1817
- return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
1926
+ try {
1927
+ const left = path.resolve(a);
1928
+ const right = path.resolve(b);
1929
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
1930
+ } catch {
1931
+ return false;
1932
+ }
1818
1933
  }
1819
1934
 
1820
1935
  function removeLegacyProjectStateDir(project) {
@@ -3323,7 +3438,10 @@ module.exports = {
3323
3438
  findProjectByName,
3324
3439
  findProjectByNameOrPath,
3325
3440
  resolveConfiguredProject,
3441
+ resolveProjectSource,
3326
3442
  projectRoot,
3443
+ centralWorkItemsPath,
3444
+ centralPullRequestsPath,
3327
3445
  projectStateDir,
3328
3446
  projectStateDirEnsure,
3329
3447
  projectWorkItemsPath,
@@ -3331,6 +3449,7 @@ module.exports = {
3331
3449
  legacyProjectStateDir,
3332
3450
  legacyProjectStatePath,
3333
3451
  ensureProjectStateFiles,
3452
+ sameResolvedPath,
3334
3453
  resolveProjectForPrPath, // exported for testing
3335
3454
  getPrLinks,
3336
3455
  addPrLink,
package/engine.js CHANGED
@@ -2248,7 +2248,7 @@ function materializePlansAsWorkItems(config) {
2248
2248
 
2249
2249
  const defaultProjectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
2250
2250
  const allProjects = getProjects(config);
2251
- const defaultProject = allProjects.find(p => p.name?.toLowerCase() === defaultProjectName.toLowerCase());
2251
+ const defaultProject = shared.resolveProjectSource(defaultProjectName, allProjects, { allowCentral: false }).project;
2252
2252
  // No project found — use central work-items.json (engine works without projects)
2253
2253
  const useCentral = !defaultProject;
2254
2254
 
@@ -2268,7 +2268,7 @@ function materializePlansAsWorkItems(config) {
2268
2268
  const itemsByProject = new Map(); // projectName -> { project, items: [] }
2269
2269
  for (const item of items) {
2270
2270
  if (item.project) {
2271
- const itemProject = allProjects.find(p => p.name?.toLowerCase() === String(item.project).toLowerCase());
2271
+ const itemProject = shared.resolveProjectSource(item.project, allProjects, { allowCentral: false }).project;
2272
2272
  if (!itemProject) {
2273
2273
  const error = shared.formatUnknownProjectError(item.project, allProjects);
2274
2274
  log('warn', `PRD ${file} item ${item.id || item.name}: ${error}`);
@@ -2288,7 +2288,7 @@ function materializePlansAsWorkItems(config) {
2288
2288
  itemsByProject.get('_central').items.push(item);
2289
2289
  } else {
2290
2290
  const itemProjectName = defaultProjectName;
2291
- const itemProject = allProjects.find(p => p.name?.toLowerCase() === itemProjectName.toLowerCase()) || defaultProject;
2291
+ const itemProject = shared.resolveProjectSource(itemProjectName, allProjects, { allowCentral: false }).project || defaultProject;
2292
2292
  if (!itemProject) continue;
2293
2293
  if (!itemsByProject.has(itemProject.name)) {
2294
2294
  itemsByProject.set(itemProject.name, { project: itemProject, items: [] });
@@ -2333,7 +2333,7 @@ function materializePlansAsWorkItems(config) {
2333
2333
  let alreadyExists = !!existingWi;
2334
2334
  if (!alreadyExists) {
2335
2335
  for (const p of allProjects) {
2336
- if (p.name === projName) continue;
2336
+ if (String(p.name || '').toLowerCase() === String(projName || '').toLowerCase()) continue;
2337
2337
  const otherItems = safeJson(projectWorkItemsPath(p)) || [];
2338
2338
  const otherWi = otherItems.find(w => w.id === item.id);
2339
2339
  if (otherWi) {
@@ -2399,7 +2399,7 @@ function materializePlansAsWorkItems(config) {
2399
2399
 
2400
2400
  // Process cross-project re-opens outside the lock (no nested locks)
2401
2401
  for (const { itemId, projectName: rProjName, item: rItem } of deferredReopens) {
2402
- const rProject = allProjects.find(p => p.name === rProjName);
2402
+ const rProject = shared.resolveProjectSource(rProjName, allProjects, { allowCentral: false }).project;
2403
2403
  if (!rProject) continue;
2404
2404
  const rPath = projectWorkItemsPath(rProject);
2405
2405
  mutateWorkItems(rPath, items => {
@@ -2631,7 +2631,7 @@ function isPrAutomationCausePending(project, pr, causeKey) {
2631
2631
  if (d.meta?.automationCauseKey !== causeKey) return false;
2632
2632
  if (!prCanonicalId) return true;
2633
2633
  const dispatchProject = d.meta?.project?.name
2634
- ? (getProjects(getConfig()).find(p => p.name === d.meta.project.name) || d.meta.project)
2634
+ ? (shared.resolveProjectSource(d.meta.project.name, getProjects(getConfig()), { allowCentral: false }).project || d.meta.project)
2635
2635
  : (d.meta?.project || null);
2636
2636
  const dispatchPrId = shared.getCanonicalPrId(dispatchProject, d.meta?.pr, d.meta?.pr?.url || '');
2637
2637
  return !dispatchPrId || dispatchPrId === prCanonicalId;
@@ -2678,8 +2678,6 @@ async function discoverFromPrs(config, project) {
2678
2678
  const newWork = [];
2679
2679
 
2680
2680
  const projMeta = { name: project?.name, localPath: project?.localPath };
2681
- const projectsByName = new Map(shared.getProjects(config).map(p => [p.name, p]));
2682
-
2683
2681
  // Resolve poll-enabled per project — stale reviewStatus is untrustworthy without poller
2684
2682
  const isAdoProject = project?.repoHost !== 'github';
2685
2683
  const pollEnabled = isAdoProject
@@ -2699,7 +2697,7 @@ async function discoverFromPrs(config, project) {
2699
2697
  .filter(d => d.meta?.pr?.id)
2700
2698
  .map(d => {
2701
2699
  const dispatchProject = d.meta?.project?.name
2702
- ? (projectsByName.get(d.meta.project.name) || d.meta.project)
2700
+ ? (shared.resolveProjectSource(d.meta.project.name, shared.getProjects(config), { allowCentral: false }).project || d.meta.project)
2703
2701
  : (d.meta?.project || null);
2704
2702
  return shared.getCanonicalPrId(dispatchProject, d.meta.pr, d.meta.pr?.url || '');
2705
2703
  })
@@ -3157,15 +3155,8 @@ function withWorkItemPrContext(item, pr) {
3157
3155
  function projectFromDispatchMeta(metaProject, config) {
3158
3156
  if (!metaProject) return null;
3159
3157
  const projects = getProjects(config);
3160
- if (metaProject.name) {
3161
- const byName = projects.find(p => p.name === metaProject.name);
3162
- if (byName) return byName;
3163
- }
3164
- if (metaProject.localPath) {
3165
- const refPath = path.resolve(metaProject.localPath);
3166
- const byPath = projects.find(p => p.localPath && path.resolve(p.localPath) === refPath);
3167
- if (byPath) return byPath;
3168
- }
3158
+ const resolved = shared.resolveProjectSource(metaProject, projects, { allowCentral: false });
3159
+ if (resolved.project) return resolved.project;
3169
3160
  return metaProject;
3170
3161
  }
3171
3162
 
@@ -3707,7 +3698,6 @@ function discoverCentralWorkItems(config) {
3707
3698
  const items = safeJson(centralPath) || [];
3708
3699
  const projects = getProjects(config);
3709
3700
  const dispatchProjects = getCentralDispatchProjects(projects);
3710
- const projectsByName = new Map(dispatchProjects.map(p => [String(p.name || '').toLowerCase(), p]));
3711
3701
  const newWork = [];
3712
3702
  // Collect mutations to apply atomically inside lock callback (avoids TOCTOU)
3713
3703
  const mutations = new Map(); // item.id → { field: value, ... }
@@ -3750,9 +3740,12 @@ function discoverCentralWorkItems(config) {
3750
3740
 
3751
3741
  const workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
3752
3742
  const isFanOut = item.scope === 'fan-out';
3753
- const explicitItemProject = typeof item.project === 'string' ? item.project : item.project?.name;
3754
- if (explicitItemProject && !projectsByName.get(String(explicitItemProject).toLowerCase())) {
3755
- const error = shared.formatUnknownProjectError(explicitItemProject, dispatchProjects);
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;
3756
3749
  mutations.set(item.id, { status: WI_STATUS.FAILED, failReason: error, failedAt: ts() });
3757
3750
  log('warn', `central work item ${item.id}: ${error}`);
3758
3751
  continue;
@@ -3856,10 +3849,13 @@ function discoverCentralWorkItems(config) {
3856
3849
  planReadError = e;
3857
3850
  }
3858
3851
  }
3859
- const requestedProjectName = declaredPlanProject || (typeof item.project === 'string' ? item.project : item.project?.name);
3860
- const requestedProject = requestedProjectName ? projectsByName.get(String(requestedProjectName).toLowerCase()) : null;
3861
- if (requestedProjectName && !requestedProject) {
3862
- const error = shared.formatUnknownProjectError(requestedProjectName, dispatchProjects);
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;
3863
3859
  mutations.set(item.id, {
3864
3860
  status: WI_STATUS.FAILED,
3865
3861
  failReason: error,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1810",
3
+ "version": "0.1.1811",
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"