@yemi33/minions 0.1.1712 → 0.1.1713

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.1713 (2026-05-04)
4
+
5
+ ### Features
6
+ - fix ado project metadata discovery (#2048)
7
+
3
8
  ## 0.1.1712 (2026-05-04)
4
9
 
5
10
  ### Fixes
package/dashboard.js CHANGED
@@ -29,6 +29,7 @@ const routing = require('./engine/routing');
29
29
  const playbook = require('./engine/playbook');
30
30
  const dispatchMod = require('./engine/dispatch');
31
31
  const steering = require('./engine/steering');
32
+ const projectDiscovery = require('./engine/project-discovery');
32
33
  const os = require('os');
33
34
 
34
35
  const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
@@ -4854,17 +4855,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4854
4855
  return jsonReply(res, 200, { confirmToken: token, ttlMs: PROJECT_CONFIRM_TOKEN_TTL_MS });
4855
4856
  }
4856
4857
 
4857
- function _execGitInRepo(repoPath, args, timeoutMs) {
4858
- const { execFileSync } = require('child_process');
4859
- return execFileSync('git', args, {
4860
- cwd: repoPath,
4861
- encoding: 'utf8',
4862
- timeout: timeoutMs || 5000,
4863
- stdio: ['ignore', 'pipe', 'pipe'],
4864
- windowsHide: true,
4865
- }).trim();
4866
- }
4867
-
4868
4858
  async function handleProjectsAdd(req, res) {
4869
4859
  try {
4870
4860
  const body = await readBody(req);
@@ -4897,43 +4887,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4897
4887
  return jsonReply(res, 400, { error: 'Project already linked at ' + target });
4898
4888
  }
4899
4889
 
4900
- // Auto-discover from git repo
4901
- const detected = { name: path.basename(target), _found: [] };
4902
- try {
4903
- let head = '';
4904
- try { head = _execGitInRepo(target, ['symbolic-ref', 'refs/remotes/origin/HEAD'], 5000); }
4905
- catch { head = _execGitInRepo(target, ['symbolic-ref', 'HEAD'], 5000); }
4906
- if (!head) throw new Error('empty git ref');
4907
- detected.mainBranch = head.replace('refs/remotes/origin/', '').replace('refs/heads/', '');
4908
- } catch { detected.mainBranch = 'main'; }
4909
- try {
4910
- const remoteUrl = _execGitInRepo(target, ['remote', 'get-url', 'origin'], 5000);
4911
- if (remoteUrl.includes('github.com')) {
4912
- detected.repoHost = 'github';
4913
- const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
4914
- if (m) { detected.org = m[1]; detected.repoName = m[2]; }
4915
- } else if (remoteUrl.includes('visualstudio.com') || remoteUrl.includes('dev.azure.com')) {
4916
- detected.repoHost = 'ado';
4917
- const m = remoteUrl.match(/https:\/\/([^.]+)\.visualstudio\.com[^/]*\/([^/]+)\/_git\/([^/\s]+)/) ||
4918
- remoteUrl.match(/https:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/\s]+)/);
4919
- if (m) { detected.org = m[1]; detected.project = m[2]; detected.repoName = m[3]; }
4920
- }
4921
- } catch (e) { console.error('git remote detection:', e.message); }
4922
- try {
4923
- const pkgPath = path.join(target, 'package.json');
4924
- if (fs.existsSync(pkgPath)) {
4925
- const pkg = safeJson(pkgPath);
4926
- if (pkg.name) detected.name = pkg.name.replace(/^@[^/]+\//, '');
4927
- }
4928
- } catch { /* optional */ }
4929
- let description = '';
4930
- try {
4931
- const claudeMd = path.join(target, 'CLAUDE.md');
4932
- if (fs.existsSync(claudeMd)) {
4933
- const lines = (safeRead(claudeMd) || '').split('\n').filter(l => l.trim() && !l.startsWith('#'));
4934
- if (lines[0] && lines[0].length < 200) description = lines[0].trim();
4935
- }
4936
- } catch { /* optional */ }
4890
+ // Auto-discover from git repo. Shared with minions.js so CLI and dashboard
4891
+ // handle ADO URL variants and repository GUID enrichment consistently.
4892
+ const detected = projectDiscovery.discoverProjectMetadata(target);
4893
+ if (!detected.name) detected.name = path.basename(target);
4894
+ const description = detected.description || '';
4937
4895
 
4938
4896
  const rawName = body.name || detected.name;
4939
4897
 
@@ -4949,17 +4907,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4949
4907
  return jsonReply(res, e.statusCode || 400, { error: e.message });
4950
4908
  }
4951
4909
 
4952
- const prUrlBase = detected.repoHost === 'github'
4953
- ? (detected.org && detected.repoName ? `https://github.com/${detected.org}/${detected.repoName}/pull/` : '')
4954
- : (detected.org && detected.project && detected.repoName
4955
- ? `https://${detected.org}.visualstudio.com/DefaultCollection/${detected.project}/_git/${detected.repoName}/pullrequest/` : '');
4956
-
4957
4910
  const project = {
4958
4911
  name, description, localPath: target.replace(/\\/g, '/'),
4959
- repoHost: detected.repoHost || 'ado', repositoryId: '',
4912
+ repoHost: detected.repoHost || 'ado', repositoryId: detected.repositoryId || '',
4960
4913
  adoOrg: detected.org || '', adoProject: detected.project || '',
4961
4914
  repoName: detected.repoName || name, mainBranch: detected.mainBranch || 'main',
4962
- prUrlBase,
4915
+ prUrlBase: projectDiscovery.buildPrUrlBase({
4916
+ repoHost: detected.repoHost,
4917
+ org: detected.org,
4918
+ project: detected.project,
4919
+ repoName: detected.repoName,
4920
+ prUrlBase: detected.prUrlBase,
4921
+ }),
4963
4922
  workSources: { pullRequests: { enabled: true, cooldownMinutes: 30 }, workItems: { enabled: true, cooldownMinutes: 0 } }
4964
4923
  };
4965
4924
 
@@ -5028,20 +4987,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5028
4987
  // Enrich each repo with metadata
5029
4988
  const existingPaths = new Set(PROJECTS.map(p => path.resolve(p.localPath)));
5030
4989
  const results = repos.map(repoPath => {
5031
- const result = { path: repoPath.replace(/\\/g, '/'), name: path.basename(repoPath), host: 'git', linked: existingPaths.has(path.resolve(repoPath)) };
5032
- try {
5033
- const remoteUrl = _execGitInRepo(repoPath, ['remote', 'get-url', 'origin'], 3000);
5034
- const gh = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
5035
- const ado = remoteUrl.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/\s]+)/) || remoteUrl.match(/([^.]+)\.visualstudio\.com.*?\/([^/]+)\/_git\/([^/\s]+)/);
5036
- if (gh) { result.host = 'GitHub'; result.org = gh[1]; result.name = gh[2]; }
5037
- else if (ado) { result.host = 'ADO'; result.org = ado[1]; result.name = ado[3] || ado[2]; }
5038
- } catch { /* no remote */ }
5039
- try {
5040
- const pkg = JSON.parse(fs.readFileSync(path.join(repoPath, 'package.json'), 'utf8'));
5041
- if (pkg.name) result.name = pkg.name.replace(/@[^/]+\//, '');
5042
- if (pkg.description) result.description = pkg.description.slice(0, 100);
5043
- } catch { /* no package.json */ }
5044
- return result;
4990
+ const detected = projectDiscovery.discoverProjectMetadata(repoPath, { adoLookupTimeoutMs: 5000 });
4991
+ return projectDiscovery.buildScanResult(repoPath, detected, existingPaths.has(path.resolve(repoPath)));
5045
4992
  });
5046
4993
 
5047
4994
  return jsonReply(res, 200, { repos: results });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-04T17:48:19.949Z"
4
+ "cachedAt": "2026-05-04T18:44:26.581Z"
5
5
  }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Shared project metadata discovery for CLI and dashboard project linking.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { execFileSync: defaultExecFileSync } = require('child_process');
8
+
9
+ function decodeUrlSegment(segment) {
10
+ try { return decodeURIComponent(String(segment || '')); } catch { return String(segment || ''); }
11
+ }
12
+
13
+ function stripGitSuffix(value) {
14
+ return String(value || '').replace(/\.git$/i, '');
15
+ }
16
+
17
+ function encodePathSegment(segment) {
18
+ return encodeURIComponent(String(segment || '')).replace(/%2F/gi, '/');
19
+ }
20
+
21
+ function normalizeRemoteForUrl(remoteUrl) {
22
+ const raw = String(remoteUrl || '').trim();
23
+ if (/^git@ssh\.dev\.azure\.com:/i.test(raw)) {
24
+ return raw.replace(/^git@ssh\.dev\.azure\.com:/i, 'ssh://git@ssh.dev.azure.com/');
25
+ }
26
+ return raw;
27
+ }
28
+
29
+ function urlWithoutCredentials(url) {
30
+ url.username = '';
31
+ url.password = '';
32
+ return url;
33
+ }
34
+
35
+ function sanitizeUrlString(value) {
36
+ try {
37
+ const url = urlWithoutCredentials(new URL(normalizeRemoteForUrl(value)));
38
+ return url.toString().replace(/\/$/, '');
39
+ } catch {
40
+ return String(value || '').trim();
41
+ }
42
+ }
43
+
44
+ function isAdoRemoteUrl(remoteUrl) {
45
+ return /(dev\.azure\.com|visualstudio\.com|ssh\.dev\.azure\.com)/i.test(String(remoteUrl || ''));
46
+ }
47
+
48
+ function adoRemoteFromParts({ url, org, project, repoName, orgUrl, repoPathParts, collection = '' }) {
49
+ const safeRepo = stripGitSuffix(repoName);
50
+ const remoteUrl = sanitizeUrlString(`${url.origin}/${repoPathParts.join('/')}`).replace(/\.git$/i, '');
51
+ return {
52
+ repoHost: 'ado',
53
+ org: decodeUrlSegment(org),
54
+ project: decodeUrlSegment(project),
55
+ repoName: stripGitSuffix(decodeUrlSegment(safeRepo)),
56
+ orgUrl,
57
+ collection,
58
+ remoteUrl,
59
+ prUrlBase: deriveAdoPrUrlBase({ repoUrl: remoteUrl, orgUrl, project, repoName: safeRepo }),
60
+ };
61
+ }
62
+
63
+ function parseAdoRemoteUrl(remoteUrl) {
64
+ const raw = String(remoteUrl || '').trim();
65
+ if (!raw || !isAdoRemoteUrl(raw)) return null;
66
+
67
+ let url;
68
+ try {
69
+ url = urlWithoutCredentials(new URL(normalizeRemoteForUrl(raw)));
70
+ } catch {
71
+ return null;
72
+ }
73
+
74
+ const host = url.hostname.toLowerCase();
75
+ const encodedParts = url.pathname.split('/').filter(Boolean);
76
+ const decodedParts = encodedParts.map(decodeUrlSegment);
77
+
78
+ if (host === 'dev.azure.com') {
79
+ const gitIndex = decodedParts.findIndex(p => p.toLowerCase() === '_git');
80
+ if (gitIndex < 2 || !decodedParts[gitIndex + 1]) return null;
81
+ const org = decodedParts[0];
82
+ const project = decodedParts[1];
83
+ const repoName = decodedParts[gitIndex + 1];
84
+ return adoRemoteFromParts({
85
+ url,
86
+ org,
87
+ project,
88
+ repoName,
89
+ orgUrl: `https://dev.azure.com/${encodePathSegment(org)}`,
90
+ repoPathParts: encodedParts.slice(0, gitIndex + 2),
91
+ });
92
+ }
93
+
94
+ if (host.endsWith('.visualstudio.com')) {
95
+ const org = host.slice(0, -'.visualstudio.com'.length);
96
+ let offset = 0;
97
+ let collection = '';
98
+ if ((decodedParts[0] || '').toLowerCase() === 'defaultcollection') {
99
+ offset = 1;
100
+ collection = 'DefaultCollection';
101
+ }
102
+ const gitIndex = decodedParts.findIndex((p, i) => i >= offset && p.toLowerCase() === '_git');
103
+ if (gitIndex < offset + 1 || !decodedParts[gitIndex + 1]) return null;
104
+ const project = decodedParts[offset];
105
+ const repoName = decodedParts[gitIndex + 1];
106
+ const orgUrl = collection
107
+ ? `https://${org}.visualstudio.com/${collection}`
108
+ : `https://${org}.visualstudio.com`;
109
+ return adoRemoteFromParts({
110
+ url,
111
+ org,
112
+ project,
113
+ repoName,
114
+ orgUrl,
115
+ repoPathParts: encodedParts.slice(0, gitIndex + 2),
116
+ collection,
117
+ });
118
+ }
119
+
120
+ if (host === 'ssh.dev.azure.com') {
121
+ if ((decodedParts[0] || '').toLowerCase() !== 'v3' || decodedParts.length < 4) return null;
122
+ const [, org, project, repoName] = decodedParts;
123
+ const orgUrl = `https://dev.azure.com/${encodePathSegment(org)}`;
124
+ return {
125
+ repoHost: 'ado',
126
+ org,
127
+ project,
128
+ repoName: stripGitSuffix(repoName),
129
+ orgUrl,
130
+ collection: '',
131
+ remoteUrl: `https://dev.azure.com/${encodePathSegment(org)}/${encodePathSegment(project)}/_git/${encodePathSegment(stripGitSuffix(repoName))}`,
132
+ prUrlBase: deriveAdoPrUrlBase({ orgUrl, project, repoName }),
133
+ };
134
+ }
135
+
136
+ return null;
137
+ }
138
+
139
+ function parseGitHubRemoteUrl(remoteUrl) {
140
+ const raw = String(remoteUrl || '').trim();
141
+ const match = raw.match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:[#?].*)?$/i);
142
+ if (!match) return null;
143
+ return {
144
+ repoHost: 'github',
145
+ org: decodeUrlSegment(match[1]),
146
+ repoName: stripGitSuffix(decodeUrlSegment(match[2])),
147
+ };
148
+ }
149
+
150
+ function deriveAdoPrUrlBase({ repoUrl, orgUrl, project, repoName }) {
151
+ const candidate = sanitizeUrlString(repoUrl || '');
152
+ if (candidate && /\/_git\//i.test(candidate)) {
153
+ return `${candidate.replace(/\.git$/i, '').replace(/\/$/, '')}/pullrequest/`;
154
+ }
155
+ if (orgUrl && project && repoName) {
156
+ return `${String(orgUrl).replace(/\/$/, '')}/${encodePathSegment(project)}/_git/${encodePathSegment(stripGitSuffix(repoName))}/pullrequest/`;
157
+ }
158
+ return '';
159
+ }
160
+
161
+ function parseJsonOutput(output) {
162
+ const text = String(output || '').trim();
163
+ if (!text) return null;
164
+ return JSON.parse(text);
165
+ }
166
+
167
+ function normalizeAzRepoResult(repo, fallback) {
168
+ if (!repo || typeof repo !== 'object') return null;
169
+ const repoUrl = repo.webUrl || repo.remoteUrl || fallback.remoteUrl || '';
170
+ const parsedUrl = parseAdoRemoteUrl(repoUrl);
171
+ const project = repo.project?.name || parsedUrl?.project || fallback.project || '';
172
+ const repoName = repo.name || parsedUrl?.repoName || fallback.repoName || '';
173
+ const org = parsedUrl?.org || fallback.org || '';
174
+ const orgUrl = parsedUrl?.orgUrl || fallback.orgUrl || '';
175
+ return {
176
+ ...fallback,
177
+ ...(parsedUrl || {}),
178
+ org,
179
+ orgUrl,
180
+ project,
181
+ repoName,
182
+ repositoryId: String(repo.id || fallback.repositoryId || '').trim(),
183
+ remoteUrl: repoUrl || parsedUrl?.remoteUrl || fallback.remoteUrl || '',
184
+ prUrlBase: deriveAdoPrUrlBase({ repoUrl, orgUrl, project, repoName }) || parsedUrl?.prUrlBase || fallback.prUrlBase || '',
185
+ };
186
+ }
187
+
188
+ function runAzJson(execFileSync, args, timeoutMs) {
189
+ return parseJsonOutput(execFileSync('az', args, {
190
+ encoding: 'utf8',
191
+ timeout: timeoutMs,
192
+ stdio: ['ignore', 'pipe', 'ignore'],
193
+ windowsHide: true,
194
+ }));
195
+ }
196
+
197
+ function resolveAdoRemoteMetadata(remote, options = {}) {
198
+ if (!remote) return null;
199
+ const execFileSync = options.execFileSync || defaultExecFileSync;
200
+ const timeoutMs = options.adoLookupTimeoutMs || 10000;
201
+ if (options.resolveAdo !== false && remote.orgUrl && remote.project && remote.repoName) {
202
+ const baseArgs = [
203
+ 'repos', 'show',
204
+ '--repository', remote.repoName,
205
+ '--organization', remote.orgUrl,
206
+ '--project', remote.project,
207
+ '--output', 'json',
208
+ ];
209
+ try {
210
+ const repo = runAzJson(execFileSync, baseArgs, timeoutMs);
211
+ const normalized = normalizeAzRepoResult(repo, remote);
212
+ if (normalized) return normalized;
213
+ } catch { /* fall back to parsed remote metadata */ }
214
+
215
+ try {
216
+ const repos = runAzJson(execFileSync, [
217
+ 'repos', 'list',
218
+ '--organization', remote.orgUrl,
219
+ '--project', remote.project,
220
+ '--output', 'json',
221
+ ], timeoutMs);
222
+ const match = Array.isArray(repos)
223
+ ? repos.find(repo => {
224
+ const name = String(repo?.name || '').toLowerCase();
225
+ const parsed = parseAdoRemoteUrl(repo?.remoteUrl || repo?.webUrl || '');
226
+ return name === String(remote.repoName || '').toLowerCase()
227
+ || parsed?.remoteUrl === remote.remoteUrl
228
+ || parsed?.repoName?.toLowerCase() === String(remote.repoName || '').toLowerCase();
229
+ })
230
+ : null;
231
+ const normalized = normalizeAzRepoResult(match, remote);
232
+ if (normalized) return normalized;
233
+ } catch { /* fall back to parsed remote metadata */ }
234
+ }
235
+ return { ...remote, repositoryId: remote.repositoryId || '', prUrlBase: remote.prUrlBase || deriveAdoPrUrlBase(remote) };
236
+ }
237
+
238
+ function execGit(execFileSync, targetDir, args, timeout = 5000) {
239
+ return String(execFileSync('git', args, {
240
+ cwd: targetDir,
241
+ encoding: 'utf8',
242
+ timeout,
243
+ stdio: ['ignore', 'pipe', 'pipe'],
244
+ windowsHide: true,
245
+ })).trim();
246
+ }
247
+
248
+ function discoverProjectMetadata(targetDir, options = {}) {
249
+ const execFileSync = options.execFileSync || defaultExecFileSync;
250
+ const result = { _found: [] };
251
+
252
+ try {
253
+ let head = '';
254
+ try {
255
+ head = execGit(execFileSync, targetDir, ['symbolic-ref', 'refs/remotes/origin/HEAD']);
256
+ } catch {
257
+ head = execGit(execFileSync, targetDir, ['symbolic-ref', 'HEAD']);
258
+ }
259
+ const branch = head.replace('refs/remotes/origin/', '').replace('refs/heads/', '');
260
+ if (branch) {
261
+ result.mainBranch = branch;
262
+ result._found.push('main branch');
263
+ }
264
+ } catch {}
265
+
266
+ try {
267
+ const remoteUrl = execGit(execFileSync, targetDir, ['remote', 'get-url', 'origin']);
268
+ const github = parseGitHubRemoteUrl(remoteUrl);
269
+ if (github) {
270
+ Object.assign(result, github);
271
+ result._found.push('GitHub remote');
272
+ } else {
273
+ const adoRemote = parseAdoRemoteUrl(remoteUrl);
274
+ if (adoRemote) {
275
+ const ado = resolveAdoRemoteMetadata(adoRemote, options);
276
+ Object.assign(result, ado);
277
+ result._found.push(ado.repositoryId ? 'Azure DevOps remote + repository metadata' : 'Azure DevOps remote');
278
+ }
279
+ }
280
+ } catch {}
281
+
282
+ try {
283
+ const claudeMdPath = path.join(targetDir, 'CLAUDE.md');
284
+ if (fs.existsSync(claudeMdPath)) {
285
+ const content = fs.readFileSync(claudeMdPath, 'utf8');
286
+ const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
287
+ if (lines[0] && lines[0].length < 200) {
288
+ result.description = lines[0].trim();
289
+ result._found.push('description from CLAUDE.md');
290
+ }
291
+ }
292
+ } catch {}
293
+ if (!result.description) {
294
+ try {
295
+ const readmePath = path.join(targetDir, 'README.md');
296
+ if (fs.existsSync(readmePath)) {
297
+ const content = fs.readFileSync(readmePath, 'utf8').slice(0, 2000);
298
+ const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('!'));
299
+ if (lines[0] && lines[0].length < 200) {
300
+ result.description = lines[0].trim();
301
+ result._found.push('description from README.md');
302
+ }
303
+ }
304
+ } catch {}
305
+ }
306
+
307
+ try {
308
+ const pkgPath = path.join(targetDir, 'package.json');
309
+ if (fs.existsSync(pkgPath)) {
310
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
311
+ if (pkg.name) {
312
+ result.name = pkg.name.replace(/^@[^/]+\//, '');
313
+ result._found.push('name from package.json');
314
+ }
315
+ if (!result.description && pkg.description) result.description = String(pkg.description).slice(0, 200);
316
+ }
317
+ } catch {}
318
+
319
+ return result;
320
+ }
321
+
322
+ function buildPrUrlBase({ repoHost, org, project, repoName, prUrlBase }) {
323
+ if (prUrlBase) return prUrlBase;
324
+ if (repoHost === 'github') {
325
+ return org && repoName ? `https://github.com/${org}/${repoName}/pull/` : '';
326
+ }
327
+ if (repoHost === 'ado' && org && project && repoName) {
328
+ return `https://dev.azure.com/${org}/${encodePathSegment(project)}/_git/${encodePathSegment(repoName)}/pullrequest/`;
329
+ }
330
+ return '';
331
+ }
332
+
333
+ function buildProjectEntry({ name, description, localPath, repoHost, repositoryId, org, project, repoName, mainBranch, prUrlBase }) {
334
+ const safeName = (name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'project';
335
+ return {
336
+ name: safeName,
337
+ description: description || '',
338
+ localPath: (localPath || '').replace(/\\/g, '/'),
339
+ repoHost: repoHost || 'github',
340
+ repositoryId: repositoryId || '',
341
+ adoOrg: org || '',
342
+ adoProject: project || '',
343
+ repoName: repoName || name,
344
+ mainBranch: mainBranch || 'main',
345
+ prUrlBase: buildPrUrlBase({ repoHost, org, project, repoName, prUrlBase }),
346
+ workSources: {
347
+ pullRequests: { enabled: true, cooldownMinutes: 30 },
348
+ workItems: { enabled: true, cooldownMinutes: 0 },
349
+ },
350
+ };
351
+ }
352
+
353
+ function buildScanResult(repoPath, detected = {}, linked = false) {
354
+ return {
355
+ path: repoPath.replace(/\\/g, '/'),
356
+ name: detected.name || detected.repoName || path.basename(repoPath),
357
+ host: detected.repoHost || 'git',
358
+ org: detected.org || '',
359
+ project: detected.project || '',
360
+ repoName: detected.repoName || path.basename(repoPath),
361
+ repositoryId: detected.repositoryId || '',
362
+ mainBranch: detected.mainBranch || 'main',
363
+ description: detected.description || '',
364
+ prUrlBase: detected.prUrlBase || '',
365
+ linked,
366
+ };
367
+ }
368
+
369
+ module.exports = {
370
+ parseAdoRemoteUrl,
371
+ parseGitHubRemoteUrl,
372
+ resolveAdoRemoteMetadata,
373
+ discoverProjectMetadata,
374
+ buildPrUrlBase,
375
+ buildProjectEntry,
376
+ buildScanResult,
377
+ };
package/engine/shared.js CHANGED
@@ -1479,6 +1479,8 @@ function nextWorkItemId(items, prefix) {
1479
1479
 
1480
1480
  function getAdoOrgBase(project) {
1481
1481
  if (project.prUrlBase) {
1482
+ const devAzure = project.prUrlBase.match(/^(https?:\/\/dev\.azure\.com\/[^/]+)/i);
1483
+ if (devAzure) return devAzure[1];
1482
1484
  const m = project.prUrlBase.match(/^(https?:\/\/[^/]+(?:\/DefaultCollection)?)/);
1483
1485
  if (m) return m[1];
1484
1486
  }
package/minions.js CHANGED
@@ -17,6 +17,7 @@ const path = require('path');
17
17
  const readline = require('readline');
18
18
  const { execSync } = require('child_process');
19
19
  const { ENGINE_DEFAULTS, DEFAULT_AGENTS, DEFAULT_CLAUDE } = require('./engine/shared');
20
+ const projectDiscovery = require('./engine/project-discovery');
20
21
 
21
22
  const MINIONS_HOME = __dirname;
22
23
  const CONFIG_PATH = path.join(MINIONS_HOME, 'config.json');
@@ -50,77 +51,7 @@ function ask(q, def) {
50
51
  }
51
52
 
52
53
  function autoDiscover(targetDir) {
53
- const result = { _found: [] };
54
-
55
- // 1. Detect main branch from git
56
- try {
57
- let head = '';
58
- try {
59
- head = execSync('git symbolic-ref refs/remotes/origin/HEAD', { cwd: targetDir, encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
60
- } catch {
61
- head = execSync('git symbolic-ref HEAD', { cwd: targetDir, encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
62
- }
63
- const branch = head.replace('refs/remotes/origin/', '').replace('refs/heads/', '');
64
- if (branch) { result.mainBranch = branch; result._found.push('main branch'); }
65
- } catch {}
66
-
67
- // 2. Detect repo host, org, project, repo name from git remote URL
68
- try {
69
- const remoteUrl = execSync('git remote get-url origin', { cwd: targetDir, encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
70
- if (remoteUrl.includes('github.com')) {
71
- result.repoHost = 'github';
72
- // https://github.com/org/repo.git or git@github.com:org/repo.git
73
- const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
74
- if (m) { result.org = m[1]; result.repoName = m[2]; }
75
- result._found.push('GitHub remote');
76
- } else if (remoteUrl.includes('visualstudio.com') || remoteUrl.includes('dev.azure.com')) {
77
- result.repoHost = 'ado';
78
- // https://org.visualstudio.com/project/_git/repo or https://dev.azure.com/org/project/_git/repo
79
- const m1 = remoteUrl.match(/https:\/\/([^.]+)\.visualstudio\.com[^/]*\/([^/]+)\/_git\/([^/\s]+)/);
80
- const m2 = remoteUrl.match(/https:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/\s]+)/);
81
- const m = m1 || m2;
82
- if (m) { result.org = m[1]; result.project = m[2]; result.repoName = m[3]; }
83
- result._found.push('Azure DevOps remote');
84
- }
85
- } catch {}
86
-
87
- // 3. Read description from CLAUDE.md first line or README.md first paragraph
88
- try {
89
- const claudeMdPath = path.join(targetDir, 'CLAUDE.md');
90
- if (fs.existsSync(claudeMdPath)) {
91
- const content = fs.readFileSync(claudeMdPath, 'utf8');
92
- // Look for a description-like first line or paragraph (skip headings)
93
- const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
94
- if (lines[0] && lines[0].length < 200) {
95
- result.description = lines[0].trim();
96
- result._found.push('description from CLAUDE.md');
97
- }
98
- }
99
- } catch {}
100
- if (!result.description) {
101
- try {
102
- const readmePath = path.join(targetDir, 'README.md');
103
- if (fs.existsSync(readmePath)) {
104
- const content = fs.readFileSync(readmePath, 'utf8').slice(0, 2000);
105
- const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('!'));
106
- if (lines[0] && lines[0].length < 200) {
107
- result.description = lines[0].trim();
108
- result._found.push('description from README.md');
109
- }
110
- }
111
- } catch {}
112
- }
113
-
114
- // 4. Detect project name
115
- try {
116
- const pkgPath = path.join(targetDir, 'package.json');
117
- if (fs.existsSync(pkgPath)) {
118
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
119
- if (pkg.name) { result.name = pkg.name.replace(/^@[^/]+\//, ''); result._found.push('name from package.json'); }
120
- }
121
- } catch {}
122
-
123
- return result;
54
+ return projectDiscovery.discoverProjectMetadata(targetDir);
124
55
  }
125
56
 
126
57
  // ─── Shared Helpers (used by both addProject and scanAndAdd) ─────────────────
@@ -149,38 +80,11 @@ function _detectAvailableRuntimes() {
149
80
  return found;
150
81
  }
151
82
 
152
- function buildPrUrlBase({ repoHost, org, project, repoName }) {
153
- if (repoHost === 'github') {
154
- return org && repoName ? `https://github.com/${org}/${repoName}/pull/` : '';
155
- }
156
- if (repoHost === 'ado' && org && project && repoName) {
157
- return `https://dev.azure.com/${org}/${project}/_git/${repoName}/pullrequest/`;
158
- }
159
- return '';
160
- }
161
-
162
- function buildProjectEntry({ name, description, localPath, repoHost, repositoryId, org, project, repoName, mainBranch }) {
163
- // Sanitize name for use as directory name in projects/<name>/
164
- const safeName = (name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'project';
165
- return {
166
- name: safeName,
167
- description: description || '',
168
- localPath: (localPath || '').replace(/\\/g, '/'),
169
- repoHost: repoHost || 'github',
170
- repositoryId: repositoryId || '',
171
- adoOrg: org || '',
172
- adoProject: project || '',
173
- repoName: repoName || name,
174
- mainBranch: mainBranch || 'main',
175
- prUrlBase: buildPrUrlBase({ repoHost, org, project, repoName }),
176
- // Discovery defaults must mirror dashboard.js POST /api/projects — without
177
- // these, discoverFromWorkItems / discoverFromPrs silently no-op (the engine
178
- // looks healthy but never dispatches anything).
179
- workSources: {
180
- pullRequests: { enabled: true, cooldownMinutes: 30 },
181
- workItems: { enabled: true, cooldownMinutes: 0 },
182
- },
183
- };
83
+ function buildProjectEntry(opts) {
84
+ // Discovery defaults must mirror dashboard.js POST /api/projects — without
85
+ // these, discoverFromWorkItems / discoverFromPrs silently no-op (the engine
86
+ // looks healthy but never dispatches anything).
87
+ return projectDiscovery.buildProjectEntry(opts);
184
88
  }
185
89
 
186
90
 
@@ -221,12 +125,15 @@ async function addProject(targetDir) {
221
125
  const org = await ask('Organization', detected.org || '');
222
126
  const project = await ask('Project', detected.project || '');
223
127
  const repoName = await ask('Repo name', detected.repoName || name);
224
- const repositoryId = await ask('Repository ID (GUID, optional)', '');
128
+ const repositoryId = await ask('Repository ID (GUID, optional)', detected.repositoryId || '');
225
129
  const mainBranch = await ask('Main branch', detected.mainBranch || 'main');
226
130
 
227
131
  rl.close();
228
132
 
229
- config.projects.push(buildProjectEntry({ name, description, localPath: target, repoHost, repositoryId, org, project, repoName, mainBranch }));
133
+ config.projects.push(buildProjectEntry({
134
+ name, description, localPath: target, repoHost, repositoryId, org, project, repoName, mainBranch,
135
+ prUrlBase: detected.prUrlBase,
136
+ }));
230
137
  saveConfig(config);
231
138
 
232
139
  console.log(`\n Linked "${name}" (${target})`);
@@ -373,17 +280,7 @@ async function scanAndAdd({ root, depth } = {}) {
373
280
  const enriched = repos.map(repoPath => {
374
281
  const detected = autoDiscover(repoPath);
375
282
  const alreadyLinked = linkedPaths.has(path.resolve(repoPath));
376
- return {
377
- path: repoPath,
378
- name: detected.name || detected.repoName || path.basename(repoPath),
379
- host: detected.repoHost || '?',
380
- org: detected.org || '',
381
- project: detected.project || '',
382
- repoName: detected.repoName || path.basename(repoPath),
383
- mainBranch: detected.mainBranch || 'main',
384
- description: detected.description || '',
385
- linked: alreadyLinked,
386
- };
283
+ return projectDiscovery.buildScanResult(repoPath, detected, alreadyLinked);
387
284
  });
388
285
 
389
286
  console.log(` Found ${enriched.length} git repo(s):\n`);
@@ -449,8 +346,8 @@ async function scanAndAdd({ root, depth } = {}) {
449
346
  existingNames.add(name);
450
347
  config.projects.push(buildProjectEntry({
451
348
  name, description: repo.description, localPath: repo.path,
452
- repoHost: repo.host, org: repo.org, project: repo.project,
453
- repoName: repo.repoName, mainBranch: repo.mainBranch,
349
+ repoHost: repo.host, repositoryId: repo.repositoryId, org: repo.org, project: repo.project,
350
+ repoName: repo.repoName, mainBranch: repo.mainBranch, prUrlBase: repo.prUrlBase,
454
351
  }));
455
352
  console.log(` + ${name} (${repo.path})`);
456
353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1712",
3
+ "version": "0.1.1713",
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"