@yemi33/minions 0.1.1810 → 0.1.1812

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/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:52:32.908Z"
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
 
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,
@@ -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');
@@ -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,10 @@ 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
+ const artifacts = { workItems: [...new Set(createdIds)] };
395
+ if (projects.some(Boolean)) artifacts.projects = projects;
396
+ return { status: PIPELINE_STATUS.RUNNING, artifacts };
380
397
  }
381
398
 
382
399
  function executeTaskStageLegacy(stage, stageState, run, config) {
@@ -488,7 +505,10 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
488
505
  if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
489
506
 
490
507
  const slug = slugify(stage.title || 'pipeline-plan');
491
- const projectResolution = _resolvePipelineProjects([stage.projects, stage.project, pipeline.projects, pipeline.project], config);
508
+ const projectResolution = _resolvePipelineProjects([
509
+ [stage.projects, stage.project],
510
+ [pipeline.projects, pipeline.project],
511
+ ], config);
492
512
  if (projectResolution.error) return { status: PIPELINE_STATUS.FAILED, error: projectResolution.error };
493
513
  const targetProjects = projectResolution.projects;
494
514
  const wiIdForProject = (project) => `PL-${run.runId.slice(4, 12)}-${stage.id}-prd${targetProjects.length > 1 || project ? '-' + _pipelineProjectSlug(project) : ''}`;
@@ -1093,6 +1113,7 @@ module.exports = {
1093
1113
  getPipelineRuns, getActiveRun, startRun, updateRunStage, completeRun,
1094
1114
  discoverPipelineWork,
1095
1115
  evaluateCondition, // exported for testing
1096
- executeTaskStage, isStageComplete, resolveTemplate, // exported for testing
1116
+ executeTaskStage, executePlanStage, isStageComplete, resolveTemplate, // exported for testing
1117
+ _resolvePipelineProjects, // exported for testing
1097
1118
  _findMeetingsInRun, _findExistingPlanForMeeting, _findExistingPrdForPlan, // exported for testing
1098
1119
  };
@@ -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,