@yemi33/minions 0.1.1713 → 0.1.1715

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.1715 (2026-05-04)
4
+
5
+ ### Features
6
+ - auto-repair ADO project metadata (#2052)
7
+
3
8
  ## 0.1.1713 (2026-05-04)
4
9
 
5
10
  ### Features
package/README.md CHANGED
@@ -541,7 +541,7 @@ Engine behavior is controlled via `config.json`. Key settings:
541
541
  | `inboxConsolidateThreshold` | 5 | Inbox files needed before consolidation |
542
542
  | `worktreeCreateTimeout` | 300000 (5min) | Timeout for each `git worktree add` attempt |
543
543
  | `worktreeCreateRetries` | 1 | Retry count for transient `git worktree add` failures (0-3) |
544
- | `worktreeRoot` | `../worktrees` | Where git worktrees are created |
544
+ | `worktreeRoot` | `../worktrees` | Where git worktrees are created; may be absolute, and Windows users should prefer a short root such as `C:\wt` |
545
545
  | `idleAlertMinutes` | 15 | Alert after no dispatch for this many minutes |
546
546
  | `restartGracePeriod` | 1200000 (20min) | Grace period for agent re-attachment after engine restart |
547
547
  | `shutdownTimeout` | 300000 (5min) | Max wait for active agents during graceful shutdown (SIGTERM/SIGINT) |
@@ -75,7 +75,7 @@ async function openSettings() {
75
75
  settingsField('Heartbeat Timeout', 'set-heartbeatTimeout', e.heartbeatTimeout || 300000, 'ms', 'No output = dead after this') +
76
76
  settingsField('Worktree Create Timeout', 'set-worktreeCreateTimeout', e.worktreeCreateTimeout || 300000, 'ms', 'Timeout for git worktree add (increase for large repos/Windows)') +
77
77
  settingsField('Worktree Create Retries', 'set-worktreeCreateRetries', e.worktreeCreateRetries || 1, '', 'Retry count for transient worktree add failures (0-3)') +
78
- settingsField('Worktree Root', 'set-worktreeRoot', e.worktreeRoot || '../worktrees', '', 'Relative path for git worktrees') +
78
+ settingsField('Worktree Root', 'set-worktreeRoot', e.worktreeRoot || '../worktrees', '', 'Relative or absolute path for git worktrees; on Windows prefer a short path like C:\\wt') +
79
79
  settingsField('Idle Alert', 'set-idleAlertMinutes', e.idleAlertMinutes || 15, 'min', 'Alert after agent idle this long') +
80
80
  settingsField('Shutdown Timeout', 'set-shutdownTimeout', e.shutdownTimeout || 300000, 'ms', 'Max wait for agents during graceful shutdown') +
81
81
  settingsField('Restart Grace Period', 'set-restartGracePeriod', e.restartGracePeriod || 1200000, 'ms', 'Grace period before orphan detection on restart') +
package/dashboard.js CHANGED
@@ -135,6 +135,12 @@ function normalizeWorkItemDedupText(value) {
135
135
  .trim();
136
136
  }
137
137
 
138
+ function normalizeWorkItemDedupTitle(value) {
139
+ return normalizeWorkItemDedupText(value)
140
+ .replace(/\s+/g, ' ')
141
+ .toLowerCase();
142
+ }
143
+
138
144
  function resolveWorkItemDedupProject(item, wiPath = '') {
139
145
  const projectName = normalizeWorkItemDedupText(item?.project || item?._project || item?._source);
140
146
  if (projectName) {
@@ -181,15 +187,30 @@ function normalizeWorkItemDedupPrIdentity(item, project = null) {
181
187
  function workItemCreateFingerprint(item, options = {}) {
182
188
  const project = resolveWorkItemDedupProject(item, options.wiPath);
183
189
  return {
184
- title: normalizeWorkItemDedupText(item?.title),
190
+ title: normalizeWorkItemDedupTitle(item?.title),
185
191
  type: routing.normalizeWorkType(item?.type || item?.workType, WORK_TYPE.IMPLEMENT),
186
- priority: normalizeWorkItemDedupText(item?.priority || 'medium').toLowerCase(),
187
- description: normalizeWorkItemDedupText(item?.description),
192
+ source: normalizeWorkItemDedupText(project?.name || item?.project || item?._project || item?._source).toLowerCase(),
188
193
  scope: normalizeWorkItemDedupText(item?.scope).toLowerCase(),
189
194
  prIdentity: normalizeWorkItemDedupPrIdentity(item, project),
190
195
  };
191
196
  }
192
197
 
198
+ function isCompatibleWorkItemCreateScope(existingFingerprint, candidateFingerprint) {
199
+ const existingFanOut = existingFingerprint.scope === 'fan-out';
200
+ const candidateFanOut = candidateFingerprint.scope === 'fan-out';
201
+ if (existingFanOut || candidateFanOut) {
202
+ return existingFingerprint.scope === candidateFingerprint.scope;
203
+ }
204
+ return true;
205
+ }
206
+
207
+ function isCompatibleWorkItemCreatePrIdentity(existingFingerprint, candidateFingerprint) {
208
+ if (existingFingerprint.prIdentity || candidateFingerprint.prIdentity) {
209
+ return existingFingerprint.prIdentity === candidateFingerprint.prIdentity;
210
+ }
211
+ return true;
212
+ }
213
+
193
214
  function isActiveWorkItemCreateStatus(status) {
194
215
  return status === WI_STATUS.PENDING || status === WI_STATUS.DISPATCHED || status === WI_STATUS.QUEUED;
195
216
  }
@@ -211,10 +232,9 @@ function findDuplicateWorkItemCreate(items, candidate, options = {}) {
211
232
  const existingFingerprint = workItemCreateFingerprint(item, options);
212
233
  return existingFingerprint.title === candidateFingerprint.title &&
213
234
  existingFingerprint.type === candidateFingerprint.type &&
214
- existingFingerprint.priority === candidateFingerprint.priority &&
215
- existingFingerprint.description === candidateFingerprint.description &&
216
- existingFingerprint.scope === candidateFingerprint.scope &&
217
- existingFingerprint.prIdentity === candidateFingerprint.prIdentity;
235
+ existingFingerprint.source === candidateFingerprint.source &&
236
+ isCompatibleWorkItemCreateScope(existingFingerprint, candidateFingerprint) &&
237
+ isCompatibleWorkItemCreatePrIdentity(existingFingerprint, candidateFingerprint);
218
238
  }) || null;
219
239
  }
220
240
 
package/engine/ado.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  const path = require('path');
7
+ const childProcess = require('child_process');
7
8
  const shared = require('./shared');
8
9
  const { exec, execAsync, getAdoOrgBase, log, ts, dateStamp, PR_STATUS, createThrottleTracker } = shared;
9
10
  const { getPrs } = require('./queries');
@@ -74,6 +75,207 @@ function sameAdoProject(a, b) {
74
75
  && String(a.repoHost || 'ado').toLowerCase() === String(b.repoHost || 'ado').toLowerCase();
75
76
  }
76
77
 
78
+ function decodeUrlSegment(segment) {
79
+ try { return decodeURIComponent(segment); } catch { return segment; }
80
+ }
81
+
82
+ function stripGitSuffix(value) {
83
+ return String(value || '').trim().replace(/\.git$/i, '');
84
+ }
85
+
86
+ function encodeAdoPathSegment(value) {
87
+ return encodeURIComponent(String(value || '').trim());
88
+ }
89
+
90
+ function buildAdoPrUrlBase(meta) {
91
+ const repo = meta.repoName || meta.repositoryId || '';
92
+ if (!meta.adoOrg || !meta.adoProject || !repo) return '';
93
+ if (meta.host === 'visualstudio') {
94
+ const collection = meta.defaultCollection ? 'DefaultCollection/' : '';
95
+ return `https://${meta.adoOrg}.visualstudio.com/${collection}${encodeAdoPathSegment(meta.adoProject)}/_git/${encodeAdoPathSegment(repo)}/pullrequest/`;
96
+ }
97
+ return `https://dev.azure.com/${encodeAdoPathSegment(meta.adoOrg)}/${encodeAdoPathSegment(meta.adoProject)}/_git/${encodeAdoPathSegment(repo)}/pullrequest/`;
98
+ }
99
+
100
+ function adoRepoCandidateFromParts(parts, source) {
101
+ const repo = stripGitSuffix(decodeUrlSegment(parts.repo || ''));
102
+ const repositoryId = isAdoGuid(repo) ? repo : '';
103
+ const repoName = repositoryId ? '' : repo;
104
+ const candidate = {
105
+ adoOrg: decodeUrlSegment(parts.org || '').trim(),
106
+ adoProject: decodeUrlSegment(parts.project || '').trim(),
107
+ repoName,
108
+ repositoryId,
109
+ host: parts.host || 'dev.azure',
110
+ defaultCollection: parts.defaultCollection === true,
111
+ source,
112
+ };
113
+ candidate.prUrlBase = buildAdoPrUrlBase(candidate);
114
+ return candidate;
115
+ }
116
+
117
+ function parseAdoRepoMetadata(value, source) {
118
+ const raw = String(value || '').trim();
119
+ if (!raw) return null;
120
+
121
+ const devAzure = raw.match(/https?:\/\/(?:[^/@]+@)?dev\.azure\.com\/([^/?#]+)\/([^/?#]+)\/_git\/([^/?#\s]+)/i);
122
+ if (devAzure) {
123
+ return adoRepoCandidateFromParts({
124
+ host: 'dev.azure',
125
+ org: devAzure[1],
126
+ project: devAzure[2],
127
+ repo: devAzure[3],
128
+ }, source);
129
+ }
130
+
131
+ const visualStudio = raw.match(/https?:\/\/(?:[^/@]+@)?([^/.@]+)\.visualstudio\.com\/(?:(DefaultCollection)\/)?([^/?#]+)\/_git\/([^/?#\s]+)/i);
132
+ if (visualStudio) {
133
+ return adoRepoCandidateFromParts({
134
+ host: 'visualstudio',
135
+ defaultCollection: !!visualStudio[2],
136
+ org: visualStudio[1],
137
+ project: visualStudio[3],
138
+ repo: visualStudio[4],
139
+ }, source);
140
+ }
141
+
142
+ const ssh = raw.match(/^(?:ssh:\/\/)?git@ssh\.dev\.azure\.com(?::|\/)v3\/([^/]+)\/([^/]+)\/([^/\s]+)$/i);
143
+ if (ssh) {
144
+ return adoRepoCandidateFromParts({
145
+ host: 'dev.azure',
146
+ org: ssh[1],
147
+ project: ssh[2],
148
+ repo: ssh[3],
149
+ }, source);
150
+ }
151
+
152
+ return null;
153
+ }
154
+
155
+ function parseCanonicalAdoPrId(value, source) {
156
+ const match = String(value || '').trim().match(/^ado:([^/#]+)\/([^/#]+)\/([^/#]+)#\d+$/i);
157
+ if (!match) return null;
158
+ return adoRepoCandidateFromParts({
159
+ host: 'dev.azure',
160
+ org: match[1],
161
+ project: match[2],
162
+ repo: match[3],
163
+ }, source);
164
+ }
165
+
166
+ function normalizeAdoOrgForCompare(value) {
167
+ return String(value || '').trim().toLowerCase().replace(/\.visualstudio\.com$/i, '');
168
+ }
169
+
170
+ function normalizeAdoFieldForCompare(value) {
171
+ return String(value || '').trim().toLowerCase();
172
+ }
173
+
174
+ function isAdoRepairCandidateCompatible(project, candidate) {
175
+ if (!project || !candidate) return false;
176
+ if (project.adoOrg && candidate.adoOrg
177
+ && normalizeAdoOrgForCompare(project.adoOrg) !== normalizeAdoOrgForCompare(candidate.adoOrg)) return false;
178
+ if (project.adoProject && candidate.adoProject
179
+ && normalizeAdoFieldForCompare(project.adoProject) !== normalizeAdoFieldForCompare(candidate.adoProject)) return false;
180
+ if (project.repoName && candidate.repoName
181
+ && normalizeAdoFieldForCompare(project.repoName) !== normalizeAdoFieldForCompare(candidate.repoName)) return false;
182
+ if (project.repositoryId && candidate.repositoryId
183
+ && normalizeAdoFieldForCompare(project.repositoryId) !== normalizeAdoFieldForCompare(candidate.repositoryId)) return false;
184
+ return true;
185
+ }
186
+
187
+ function getMissingAdoProjectConfigFields(project) {
188
+ return ['adoOrg', 'adoProject', 'repoName', 'prUrlBase', 'repositoryId']
189
+ .filter(field => !String(project?.[field] || '').trim());
190
+ }
191
+
192
+ function getOriginRemoteUrl(project) {
193
+ const localPath = String(project?.localPath || '').trim();
194
+ if (!localPath) return '';
195
+ try {
196
+ const result = childProcess.spawnSync('git', ['-C', localPath, 'config', '--get', 'remote.origin.url'], {
197
+ encoding: 'utf8',
198
+ windowsHide: true,
199
+ timeout: 3000,
200
+ });
201
+ if (result.status !== 0) return '';
202
+ return String(result.stdout || '').trim().split(/\r?\n/)[0] || '';
203
+ } catch {
204
+ return '';
205
+ }
206
+ }
207
+
208
+ function collectAdoRepairCandidates(project, prs = []) {
209
+ const candidates = [];
210
+ const add = (candidate) => {
211
+ if (candidate && isAdoRepairCandidateCompatible(project, candidate)) candidates.push(candidate);
212
+ };
213
+
214
+ if (project?.prUrlBase) add(parseAdoRepoMetadata(project.prUrlBase, 'configured prUrlBase'));
215
+ add(parseAdoRepoMetadata(getOriginRemoteUrl(project), 'origin remote'));
216
+
217
+ for (const pr of Array.isArray(prs) ? prs : []) {
218
+ add(parseAdoRepoMetadata(pr?.url, 'tracked PR URL'));
219
+ add(parseCanonicalAdoPrId(pr?.id, 'tracked canonical PR ID'));
220
+ }
221
+
222
+ return candidates;
223
+ }
224
+
225
+ function persistAdoProjectRepair(project, repairs, source) {
226
+ Object.assign(project, repairs);
227
+
228
+ try {
229
+ let persisted = false;
230
+ mutateJsonFileLocked(adoConfigPath(), (config) => {
231
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return config;
232
+ if (!Array.isArray(config.projects)) return config;
233
+ const target = config.projects.find(p => sameAdoProject(p, project));
234
+ if (!target) return config;
235
+ for (const [field, value] of Object.entries(repairs)) {
236
+ if (!String(target[field] || '').trim()) target[field] = value;
237
+ }
238
+ persisted = true;
239
+ return config;
240
+ }, { defaultValue: { projects: [] }, skipWriteIfUnchanged: true });
241
+ const repairedFields = Object.keys(repairs).join(', ');
242
+ if (persisted) {
243
+ log('info', `Auto-repaired ADO project config for ${getAdoProjectLabel(project)} from ${source}: ${repairedFields}`);
244
+ } else {
245
+ log('warn', `Auto-repaired ADO project config for ${getAdoProjectLabel(project)} from ${source} in memory but could not find the project in config.json to persist it`);
246
+ }
247
+ } catch (e) {
248
+ log('warn', `Auto-repaired ADO project config for ${getAdoProjectLabel(project)} but failed to persist it: ${e.message}`);
249
+ }
250
+ }
251
+
252
+ function repairAdoProjectConfig(project, purpose, prs = null) {
253
+ if (!project || isGitHubProject(project)) return false;
254
+ const missingBefore = getMissingAdoProjectConfigFields(project);
255
+ if (missingBefore.length === 0) return false;
256
+
257
+ const trackedPrs = prs || shared.safeJson(shared.projectPrPath(project)) || [];
258
+ for (const candidate of collectAdoRepairCandidates(project, trackedPrs)) {
259
+ const repairs = {};
260
+ if (!project.adoOrg && candidate.adoOrg) repairs.adoOrg = candidate.adoOrg;
261
+ if (!project.adoProject && candidate.adoProject) repairs.adoProject = candidate.adoProject;
262
+ if (!project.repoName && candidate.repoName) repairs.repoName = candidate.repoName;
263
+ if (!project.repositoryId && candidate.repositoryId) repairs.repositoryId = candidate.repositoryId;
264
+ if (!project.prUrlBase && candidate.prUrlBase) repairs.prUrlBase = candidate.prUrlBase;
265
+ if (Object.keys(repairs).length > 0) {
266
+ persistAdoProjectRepair(project, repairs, candidate.source);
267
+ return true;
268
+ }
269
+ }
270
+
271
+ const missingAfter = getMissingAdoProjectConfigFields(project)
272
+ .filter(field => field !== 'repositoryId' || !project.repoName);
273
+ if (missingAfter.some(field => field === 'adoOrg' || field === 'adoProject' || field === 'repoName')) {
274
+ log('warn', `${purpose} cannot auto-repair ADO project config for ${getAdoProjectLabel(project)}: missing ${missingAfter.map(f => `project.${f}`).join(', ')} and no trusted origin remote, tracked PR URL, or canonical ADO PR ID supplied enough metadata`);
275
+ }
276
+ return false;
277
+ }
278
+
77
279
  function persistAdoRepositoryGuid(project, guid, repoName) {
78
280
  if (!isAdoGuid(guid)) return;
79
281
  const previous = String(project?.repositoryId || '').trim();
@@ -387,6 +589,7 @@ async function forEachActivePr(config, token, callback) {
387
589
 
388
590
  for (const project of projects) {
389
591
  if (isGitHubProject(project)) continue;
592
+ repairAdoProjectConfig(project, 'ADO PR polling');
390
593
  if (!project.adoOrg || !project.adoProject) continue;
391
594
 
392
595
  const prs = getPrs(project);
@@ -870,6 +1073,7 @@ async function reconcilePrs(config) {
870
1073
 
871
1074
  for (const project of projects) {
872
1075
  if (isGitHubProject(project)) continue;
1076
+ repairAdoProjectConfig(project, 'ADO PR reconciliation');
873
1077
  if (!project.adoOrg || !project.adoProject) continue;
874
1078
  const adoRepositoryId = getAdoRepositoryId(project);
875
1079
  if (!adoRepositoryId) {
@@ -1020,6 +1224,8 @@ async function reconcilePrs(config) {
1020
1224
  */
1021
1225
  async function checkLiveReviewStatus(pr, project) {
1022
1226
  try {
1227
+ repairAdoProjectConfig(project, 'ADO live review check', pr ? [pr] : null);
1228
+ if (!project.adoOrg || !project.adoProject) return null;
1023
1229
  const token = await getAdoToken();
1024
1230
  if (!token) return null;
1025
1231
  const orgBase = shared.getAdoOrgBase(project);
@@ -1066,6 +1272,8 @@ async function checkLiveReviewStatus(pr, project) {
1066
1272
  */
1067
1273
  async function checkLiveBuildAndConflict(pr, project) {
1068
1274
  try {
1275
+ repairAdoProjectConfig(project, 'ADO live build/conflict check', pr ? [pr] : null);
1276
+ if (!project.adoOrg || !project.adoProject) return null;
1069
1277
  const token = await getAdoToken();
1070
1278
  if (!token) return null;
1071
1279
  const orgBase = shared.getAdoOrgBase(project);
@@ -1163,6 +1371,9 @@ async function fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo) {
1163
1371
  * mergeConflict, url, project } or null on auth failure.
1164
1372
  */
1165
1373
  async function fetchSinglePrBuildStatus(project, prNumber) {
1374
+ repairAdoProjectConfig(project, 'ADO single PR status fetch');
1375
+ if (!project.adoOrg || !project.adoProject) return null;
1376
+
1166
1377
  const token = await getAdoToken();
1167
1378
  if (!token) return null;
1168
1379
 
@@ -1261,6 +1472,7 @@ const getAdoThrottleState = () => _adoThrottle.getState();
1261
1472
  * @returns {{ prNumber: number, url: string }|null}
1262
1473
  */
1263
1474
  async function findOpenPrOnBranch(project, branch) {
1475
+ repairAdoProjectConfig(project, 'ADO branch PR lookup');
1264
1476
  if (!project.adoOrg || !project.adoProject || !branch) return null;
1265
1477
  const adoRepositoryId = getAdoRepositoryId(project);
1266
1478
  if (!adoRepositoryId) {
package/engine/cleanup.js CHANGED
@@ -39,6 +39,23 @@ function worktreeDirMatchesBranch(dirLower, branch) {
39
39
  return dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug);
40
40
  }
41
41
 
42
+ function worktreeBranchMatches(actualBranch, branch) {
43
+ if (!actualBranch || !branch) return false;
44
+ return sanitizeBranch(actualBranch).toLowerCase() === sanitizeBranch(branch).toLowerCase();
45
+ }
46
+
47
+ function worktreeMatchesBranch(dirLower, branch, actualBranch = '') {
48
+ return worktreeBranchMatches(actualBranch, branch) || worktreeDirMatchesBranch(dirLower, branch);
49
+ }
50
+
51
+ function getWorktreeBranch(wtPath) {
52
+ try {
53
+ return exec(`git -C "${wtPath}" branch --show-current`, { encoding: 'utf8', stdio: 'pipe', timeout: 5000, windowsHide: true }).trim();
54
+ } catch {
55
+ return '';
56
+ }
57
+ }
58
+
42
59
  let _orphanPidProcessNamesCache = null;
43
60
  function _orphanPidProcessNames() {
44
61
  if (_orphanPidProcessNamesCache) return _orphanPidProcessNamesCache;
@@ -235,12 +252,13 @@ function runCleanup(config, verbose = false) {
235
252
 
236
253
  let shouldClean = false;
237
254
  let isProtected = false;
255
+ const actualBranch = getWorktreeBranch(wtPath);
238
256
 
239
257
  // Check if this worktree's branch is merged/abandoned
240
- // Use sanitized exact match on the branch portion of the dir name (format: {slug}-{branch}-{suffix})
258
+ // Prefer actual git branch metadata; compact Windows dirs intentionally omit branch names.
241
259
  const dirLower = dir.toLowerCase();
242
260
  for (const branch of mergedBranches) {
243
- if (worktreeDirMatchesBranch(dirLower, branch)) {
261
+ if (worktreeMatchesBranch(dirLower, branch, actualBranch)) {
244
262
  shouldClean = true;
245
263
  break;
246
264
  }
@@ -249,8 +267,7 @@ function runCleanup(config, verbose = false) {
249
267
  // Check if referenced by active/pending dispatch (use sanitized branch comparison)
250
268
  const isReferenced = [...dispatch.pending, ...(dispatch.active || [])].some(d => {
251
269
  if (!d.meta?.branch) return false;
252
- const dispBranch = sanitizeBranch(d.meta.branch).toLowerCase();
253
- return dirLower.includes(dispBranch);
270
+ return worktreeMatchesBranch(dirLower, d.meta.branch, actualBranch);
254
271
  });
255
272
  if (isReferenced) isProtected = true;
256
273
 
@@ -275,8 +292,7 @@ function runCleanup(config, verbose = false) {
275
292
  for (const pf of fs.readdirSync(checkDir).filter(f => f.endsWith('.json'))) {
276
293
  const plan = safeJson(path.join(checkDir, pf));
277
294
  if (plan?.branch_strategy === 'shared-branch' && plan?.feature_branch && plan?.status !== 'completed') {
278
- const planBranch = sanitizeBranch(plan.feature_branch).toLowerCase();
279
- if (dirLower.includes(planBranch)) {
295
+ if (worktreeMatchesBranch(dirLower, plan.feature_branch, actualBranch)) {
280
296
  isProtected = true;
281
297
  if (shouldClean) {
282
298
  shouldClean = false;
@@ -291,7 +307,7 @@ function runCleanup(config, verbose = false) {
291
307
  } catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
292
308
  }
293
309
 
294
- wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected });
310
+ wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected, actualBranch });
295
311
  }
296
312
 
297
313
  // Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
@@ -323,7 +339,7 @@ function runCleanup(config, verbose = false) {
323
339
  const entryDirLower = entry.dir.toLowerCase();
324
340
  let stillMerged = false;
325
341
  for (const branch of freshMergedBranches) {
326
- if (worktreeDirMatchesBranch(entryDirLower, branch)) {
342
+ if (worktreeMatchesBranch(entryDirLower, branch, entry.actualBranch)) {
327
343
  stillMerged = true;
328
344
  break;
329
345
  }
@@ -331,7 +347,7 @@ function runCleanup(config, verbose = false) {
331
347
  // If originally marked due to merged branch but PR was reopened, skip deletion
332
348
  if (!stillMerged) {
333
349
  // Check if it was marked for age/cap cleanup (not branch-based) — those are still valid
334
- const wasMarkedByBranch = [...mergedBranches].some(branch => worktreeDirMatchesBranch(entryDirLower, branch));
350
+ const wasMarkedByBranch = [...mergedBranches].some(branch => worktreeMatchesBranch(entryDirLower, branch, entry.actualBranch));
335
351
  if (wasMarkedByBranch) {
336
352
  if (verbose) console.log(` Skipping worktree ${entry.dir}: PR was reopened since initial check`);
337
353
  log('info', `Worktree deletion skipped — PR reopened: ${entry.dir}`);
@@ -832,4 +848,6 @@ module.exports = {
832
848
  runCleanup,
833
849
  scrubStaleMetrics,
834
850
  worktreeDirMatchesBranch, // exported for testing
851
+ worktreeMatchesBranch, // exported for testing
852
+ getWorktreeBranch, // exported for lifecycle cleanup
835
853
  };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-04T18:44:26.581Z"
4
+ "cachedAt": "2026-05-04T18:47:12.870Z"
5
5
  }
@@ -14,7 +14,7 @@ const { trackEngineUsage } = require('./llm');
14
14
  const { resolveRuntime } = require('./runtimes');
15
15
  const queries = require('./queries');
16
16
  const { isBranchActive } = require('./cooldown');
17
- const { worktreeDirMatchesBranch } = require('./cleanup');
17
+ const { worktreeMatchesBranch, getWorktreeBranch } = require('./cleanup');
18
18
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
19
19
  MINIONS_DIR, ENGINE_DIR, PLANS_DIR, PRD_DIR, INBOX_DIR, AGENTS_DIR } = queries;
20
20
 
@@ -446,10 +446,12 @@ function cleanupPlanWorktrees(planFile, plan, projects, config) {
446
446
  if (!fs.existsSync(wtRoot)) continue;
447
447
  const dirs = fs.readdirSync(wtRoot);
448
448
  for (const dir of dirs) {
449
+ const wtPath = path.join(wtRoot, dir);
449
450
  const dirLower = dir.toLowerCase();
450
- const matches = [...branchSlugs].some(slug => dirLower.includes(slug));
451
+ const actualBranch = getWorktreeBranch(wtPath);
452
+ const actualBranchSlug = actualBranch ? shared.sanitizeBranch(actualBranch).toLowerCase() : '';
453
+ const matches = [...branchSlugs].some(slug => dirLower.includes(slug) || actualBranchSlug === slug);
451
454
  if (matches) {
452
- const wtPath = path.join(wtRoot, dir);
453
455
  if (shared.removeWorktree(wtPath, root, wtRoot)) cleanedWt++;
454
456
  }
455
457
  }
@@ -1587,13 +1589,13 @@ async function handlePostMerge(pr, project, config, newStatus) {
1587
1589
  if (pr.branch && project) {
1588
1590
  const root = path.resolve(project.localPath);
1589
1591
  const wtRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
1590
- // Find worktrees matching this branch dir format is {slug}-{branch}-{suffix}
1592
+ // Find worktrees matching this branch; compact Windows dirs require branch metadata.
1591
1593
  try {
1592
1594
  const dirs = require('fs').readdirSync(wtRoot);
1593
1595
  for (const dir of dirs) {
1596
+ const wtPath = path.join(wtRoot, dir);
1594
1597
  const dirLower = dir.toLowerCase();
1595
- if (worktreeDirMatchesBranch(dirLower, pr.branch) || dir === pr.branch || dir === `bt-${prNum}`) {
1596
- const wtPath = path.join(wtRoot, dir);
1598
+ if (worktreeMatchesBranch(dirLower, pr.branch, getWorktreeBranch(wtPath)) || dir === pr.branch || dir === `bt-${prNum}`) {
1597
1599
  try {
1598
1600
  if (!require('fs').statSync(wtPath).isDirectory()) continue;
1599
1601
  execSilent(`git worktree remove "${wtPath}" --force`, { cwd: root, stdio: 'pipe', timeout: 15000 });
@@ -2724,7 +2726,9 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2724
2726
  // Find the worktree directory for this dispatch's branch
2725
2727
  const branchSlug = shared.sanitizeBranch ? shared.sanitizeBranch(meta.branch) : meta.branch.replace(/[^a-zA-Z0-9._\-\/]/g, '-');
2726
2728
  const dirs = fs.readdirSync(worktreeRoot).filter(d => {
2727
- return worktreeDirMatchesBranch(d.toLowerCase(), meta.branch) && fs.statSync(path.join(worktreeRoot, d)).isDirectory();
2729
+ const wtPath = path.join(worktreeRoot, d);
2730
+ return fs.statSync(wtPath).isDirectory()
2731
+ && worktreeMatchesBranch(d.toLowerCase(), meta.branch, getWorktreeBranch(wtPath));
2728
2732
  });
2729
2733
  // Only remove if no other active dispatch uses this branch
2730
2734
  const dispatch = getDispatch();
package/engine/shared.js CHANGED
@@ -1702,6 +1702,28 @@ function sanitizeBranch(name) {
1702
1702
  return String(name).replace(/[^a-zA-Z0-9._\-\/]/g, '-').slice(0, 200);
1703
1703
  }
1704
1704
 
1705
+ function _worktreeNameSuffix(dispatchId, projectName, branchName) {
1706
+ const id = String(dispatchId || '').split('-').filter(Boolean).pop();
1707
+ if (id) return safeSlugComponent(id, 32);
1708
+ const hash = crypto.createHash('sha1')
1709
+ .update(`${projectName || 'default'}\n${branchName || 'worktree'}`)
1710
+ .digest('hex')
1711
+ .slice(0, 12);
1712
+ return hash;
1713
+ }
1714
+
1715
+ function buildWorktreeDirName({
1716
+ dispatchId = '',
1717
+ projectName = 'default',
1718
+ branchName = 'worktree',
1719
+ platform = process.platform,
1720
+ } = {}) {
1721
+ const suffix = _worktreeNameSuffix(dispatchId, projectName, branchName);
1722
+ if (platform === 'win32') return `W-${suffix}`;
1723
+ const projectSlug = String(projectName || 'default').replace(/[^a-zA-Z0-9_-]/g, '-');
1724
+ return `${projectSlug}-${sanitizeBranch(branchName || 'worktree')}-${suffix}`;
1725
+ }
1726
+
1705
1727
  // ── HTTP Origin Allowlist & Security Headers ─────────────────────────────────
1706
1728
  // Pure helpers used by dashboard.js to gate mutating requests against an
1707
1729
  // explicit allowlist of local origins and to attach uniform security response
@@ -2661,6 +2683,7 @@ module.exports = {
2661
2683
  getAdoOrgBase,
2662
2684
  sanitizePath,
2663
2685
  sanitizeBranch,
2686
+ buildWorktreeDirName, // exported for testing
2664
2687
  isLiveCommandCenterPath,
2665
2688
  describeCcProtectedPaths,
2666
2689
  renderCcSystemPrompt,
package/engine.js CHANGED
@@ -513,9 +513,11 @@ async function spawnAgent(dispatchItem, config) {
513
513
 
514
514
  if (branchName) {
515
515
  updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
516
- const wtSuffix = id ? id.split('-').pop() : shared.uid();
517
- const projectSlug = (project.name || 'default').replace(/[^a-zA-Z0-9_-]/g, '-');
518
- const wtDirName = `${projectSlug}-${branchName}-${wtSuffix}`;
516
+ const wtDirName = shared.buildWorktreeDirName({
517
+ dispatchId: id,
518
+ projectName: project.name || 'default',
519
+ branchName,
520
+ });
519
521
  worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', wtDirName);
520
522
 
521
523
  // If branch is already checked out in an existing worktree, reuse it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1713",
3
+ "version": "0.1.1715",
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"