@yemi33/minions 0.1.1812 → 0.1.1814

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1814 (2026-05-09)
4
+
5
+ ### Features
6
+ - harden PRD and playbook paths (#2247)
7
+
8
+ ## 0.1.1813 (2026-05-09)
9
+
10
+ ### Features
11
+ - improve manual PR project inference (#2242)
12
+
3
13
  ## 0.1.1812 (2026-05-09)
4
14
 
5
15
  ### Features
@@ -1340,8 +1340,10 @@ async function ccExecuteAction(action, targetTabId) {
1340
1340
  break;
1341
1341
  }
1342
1342
  case 'link-pr': {
1343
- await _ccFetch('/api/pull-requests/link', { url: action.url, title: action.title || '', project: action.project || '', autoObserve: action.autoObserve !== false });
1344
- status.innerHTML = '&#10003; PR linked: <strong>' + escHtml(action.url) + '</strong>';
1343
+ var prLinkRes = await _ccFetch('/api/pull-requests/link', { url: action.url, title: action.title || '', project: action.project || '', autoObserve: action.autoObserve !== false });
1344
+ var prLinkData = await prLinkRes.json().catch(function() { return {}; });
1345
+ status.innerHTML = '&#10003; PR linked: <strong>' + escHtml(action.url) + '</strong>' +
1346
+ (prLinkData.message ? '<div style="font-size:11px;color:var(--muted);margin-top:4px">' + escHtml(prLinkData.message) + '</div>' : '');
1345
1347
  status.style.color = 'var(--green)';
1346
1348
  break;
1347
1349
  }
@@ -125,7 +125,7 @@ function openAddPrModal() {
125
125
  '<div style="display:flex;flex-direction:column;gap:10px">' +
126
126
  '<label style="color:var(--text);font-size:var(--text-md)">PR URL <input id="pr-link-url" style="' + inputStyle + '" placeholder="https://github.com/org/repo/pull/123"></label>' +
127
127
  '<label style="color:var(--text);font-size:var(--text-md)">Title <input id="pr-link-title" style="' + inputStyle + '" placeholder="Short description (optional — auto-detected from URL)"></label>' +
128
- '<label style="color:var(--text);font-size:var(--text-md)">Project <select id="pr-link-project" style="' + inputStyle + '"><option value="">Auto / Central</option>' + projOpts + '</select></label>' +
128
+ '<label style="color:var(--text);font-size:var(--text-md)">Project <select id="pr-link-project" style="' + inputStyle + '"><option value="">Auto-detect from URL (central if no unique match)</option>' + projOpts + '</select></label>' +
129
129
  '<label style="color:var(--text);font-size:var(--text-md)">Context <textarea id="pr-link-context" rows="3" style="' + inputStyle + ';resize:vertical" placeholder="Why are you linking this? What should agents know about it?"></textarea></label>' +
130
130
  '<label style="display:flex;align-items:center;gap:8px;color:var(--text);font-size:var(--text-md);margin-top:4px;cursor:pointer">' +
131
131
  '<input type="checkbox" id="pr-link-observe" style="width:16px;height:16px;accent-color:var(--blue)">' +
@@ -151,14 +151,16 @@ async function _submitLinkPr(e) {
151
151
  const autoObserve = document.getElementById('pr-link-observe')?.checked || false;
152
152
 
153
153
  try { closeModal(); } catch { /* expected */ }
154
- showToast('cmd-toast', 'PR linked' + (autoObserve ? ' (auto-observe on)' : ''), true);
155
154
  try {
156
155
  const res = await fetch('/api/pull-requests/link', {
157
156
  method: 'POST', headers: { 'Content-Type': 'application/json' },
158
157
  body: JSON.stringify({ url, title, project, context, autoObserve })
159
158
  });
160
159
  const data = await res.json();
161
- if (res.ok) { refresh(); } else { alert('Failed: ' + (data.error || 'unknown')); openAddPrModal(); }
160
+ if (res.ok) {
161
+ showToast('cmd-toast', (data.message || 'PR linked') + (autoObserve ? ' (auto-observe on)' : ''), true, 6000);
162
+ refresh();
163
+ } else { alert('Failed: ' + (data.error || 'unknown')); openAddPrModal(); }
162
164
  } catch (e) { alert('Error: ' + e.message); openAddPrModal(); }
163
165
  }
164
166
 
package/dashboard.js CHANGED
@@ -382,6 +382,83 @@ function findProjectByName(projects, projectName) {
382
382
  return shared.findProjectByName(projects, projectName);
383
383
  }
384
384
 
385
+ function resolveManualPrLinkProject(url, projectName, projects = PROJECTS) {
386
+ const explicitProjectName = String(projectName || '').trim();
387
+ if (explicitProjectName) {
388
+ const explicitProject = shared.resolveProjectSource(explicitProjectName, projects, { allowCentral: false, minionsDir: MINIONS_DIR });
389
+ if (explicitProject.error) {
390
+ const err = new Error(explicitProject.error);
391
+ err.statusCode = 400;
392
+ throw err;
393
+ }
394
+ const targetProject = explicitProject.project;
395
+ return {
396
+ project: targetProject,
397
+ resolution: {
398
+ reason: 'explicit',
399
+ project: targetProject.name || explicitProjectName,
400
+ storage: 'project',
401
+ message: `Linked to selected project "${targetProject.name || explicitProjectName}".`,
402
+ },
403
+ };
404
+ }
405
+
406
+ const parsedPr = shared.parsePrUrl(url);
407
+ const prScope = parsedPr?.scope || '';
408
+ if (!prScope) {
409
+ return {
410
+ project: null,
411
+ resolution: {
412
+ reason: 'unrecognized-url',
413
+ project: 'central',
414
+ storage: 'central',
415
+ message: 'Could not infer a project from this PR URL; linked in central PR tracking.',
416
+ },
417
+ };
418
+ }
419
+
420
+ const matches = projects.filter(p => shared.getProjectPrScope(p) === prScope);
421
+ if (matches.length === 1) {
422
+ const targetProject = matches[0];
423
+ return {
424
+ project: targetProject,
425
+ resolution: {
426
+ reason: 'inferred',
427
+ scope: prScope,
428
+ project: targetProject.name || '',
429
+ storage: 'project',
430
+ message: `Inferred project "${targetProject.name}" from PR scope ${prScope}.`,
431
+ },
432
+ };
433
+ }
434
+
435
+ if (matches.length > 1) {
436
+ const names = matches.map(p => p.name).filter(Boolean);
437
+ return {
438
+ project: null,
439
+ resolution: {
440
+ reason: 'ambiguous',
441
+ scope: prScope,
442
+ project: 'central',
443
+ storage: 'central',
444
+ matches: names,
445
+ message: `PR scope ${prScope} matches multiple configured projects (${names.join(', ')}); linked in central PR tracking. Select a project to attach it.`,
446
+ },
447
+ };
448
+ }
449
+
450
+ return {
451
+ project: null,
452
+ resolution: {
453
+ reason: 'unmatched',
454
+ scope: prScope,
455
+ project: 'central',
456
+ storage: 'central',
457
+ message: `No configured project matches PR scope ${prScope}; linked in central PR tracking.`,
458
+ },
459
+ };
460
+ }
461
+
385
462
  function resolveWorkItemsCreateTarget(projectName, projects = PROJECTS) {
386
463
  const target = shared.resolveProjectSource(projectName, projects, { defaultWhenSingle: true, minionsDir: MINIONS_DIR });
387
464
  if (target.error) return { error: target.error };
@@ -416,6 +493,73 @@ function findWorkItemsTargetById(id, source, projects = PROJECTS) {
416
493
  return { found: false };
417
494
  }
418
495
 
496
+ function buildPlanWorkItem(body, projects = PROJECTS, options = {}) {
497
+ if (!body?.title || !String(body.title).trim()) return { error: 'title is required' };
498
+ const target = resolveWorkItemsCreateTarget(body.project, projects);
499
+ if (target.error) return { error: target.error };
500
+ let now = options.now ? new Date(options.now) : new Date();
501
+ if (!Number.isFinite(now.getTime())) now = new Date();
502
+ const item = {
503
+ id: options.id || ('W-' + shared.uid()),
504
+ title: body.title,
505
+ type: 'plan',
506
+ priority: body.priority || 'high',
507
+ description: body.description || '',
508
+ status: WI_STATUS.PENDING,
509
+ created: now.toISOString(),
510
+ createdBy: 'dashboard',
511
+ branchStrategy: body.branch_strategy || body.branchStrategy || 'parallel',
512
+ };
513
+ if (target.project) item.project = target.project.name;
514
+ if (body.agent) item.agent = body.agent;
515
+ return { item, id: item.id };
516
+ }
517
+
518
+ function buildManualPrdItemPlan(body, projects = PROJECTS, options = {}) {
519
+ if (!body?.name || !String(body.name).trim()) return { error: 'name is required' };
520
+ const target = resolveWorkItemsCreateTarget(body.project, projects);
521
+ if (target.error) return { error: target.error };
522
+
523
+ const overrideProject = body.item_project ?? body.itemProject;
524
+ const itemProjectTarget = overrideProject !== undefined
525
+ ? shared.resolveConfiguredProject(overrideProject, projects)
526
+ : null;
527
+ if (itemProjectTarget?.error) return { error: itemProjectTarget.error };
528
+
529
+ let now = options.now ? new Date(options.now) : new Date();
530
+ if (!Number.isFinite(now.getTime())) now = new Date();
531
+ const nowTime = now.getTime();
532
+ const projectName = target.project?.name || (projects.length > 0 ? projects[0].name : 'Unknown');
533
+ const itemProjectName = itemProjectTarget?.project?.name || target.project?.name || (projects.length === 1 ? projects[0].name : '');
534
+ const item = {
535
+ id: body.id || ('M' + String(nowTime).slice(-4)),
536
+ name: String(body.name).trim(),
537
+ description: body.description || '',
538
+ priority: body.priority || 'medium',
539
+ estimated_complexity: body.estimated_complexity || 'medium',
540
+ status: 'missing',
541
+ depends_on: [],
542
+ acceptance_criteria: [],
543
+ };
544
+ if (itemProjectName) item.project = itemProjectName;
545
+
546
+ return {
547
+ plan: {
548
+ version: 'manual-' + now.toISOString().slice(0, 10),
549
+ project: projectName,
550
+ generated_by: 'dashboard',
551
+ generated_at: now.toISOString().slice(0, 10),
552
+ plan_summary: item.name,
553
+ status: 'approved',
554
+ requires_approval: false,
555
+ branch_strategy: 'parallel',
556
+ missing_features: [item],
557
+ open_questions: [],
558
+ },
559
+ id: item.id,
560
+ };
561
+ }
562
+
419
563
  function validatePipelineProjects(pipeline, projects = PROJECTS) {
420
564
  const refs = [];
421
565
  const collect = (value) => {
@@ -488,21 +632,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
488
632
  throw err;
489
633
  }
490
634
  const projects = shared.getProjects(config);
491
- const explicitProjectName = String(projectName || '').trim();
492
- const explicitProject = explicitProjectName
493
- ? shared.resolveProjectSource(explicitProjectName, projects, { allowCentral: false, minionsDir: MINIONS_DIR })
494
- : null;
495
- let targetProject = explicitProject?.project || null;
496
- if (explicitProject?.error) {
497
- const err = new Error(explicitProject.error);
498
- err.statusCode = 400;
499
- throw err;
500
- }
501
- if (!explicitProjectName) {
502
- const prScope = shared.parsePrUrl(url)?.scope || '';
503
- const matches = prScope ? projects.filter(p => shared.getProjectPrScope(p) === prScope) : [];
504
- if (matches.length === 1) targetProject = matches[0];
505
- }
635
+ const { project: targetProject, resolution: projectResolution } = resolveManualPrLinkProject(url, projectName, projects);
506
636
  const prPath = targetProject ? shared.projectPrPath(targetProject) : shared.centralPullRequestsPath(MINIONS_DIR);
507
637
 
508
638
  const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
@@ -527,11 +657,12 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
527
657
  _contextOnly: !autoObserve,
528
658
  _autoObserve: !!autoObserve,
529
659
  _context: contextText,
660
+ _projectResolution: projectResolution,
530
661
  }, {
531
662
  project: targetProject,
532
663
  itemId: linkedWorkItemId,
533
664
  });
534
- return { ...result, prPath, targetProject, prNum };
665
+ return { ...result, prPath, targetProject, projectResolution, prNum };
535
666
  }
536
667
 
537
668
  function _normalizeSkillDirForCompare(dir) {
@@ -5064,21 +5195,12 @@ const server = http.createServer(async (req, res) => {
5064
5195
  try {
5065
5196
  const body = await readBody(req);
5066
5197
  if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
5067
- const target = resolveWorkItemsCreateTarget(body.project);
5068
- if (target.error) return jsonReply(res, 400, { error: target.error });
5198
+ const planWorkItem = buildPlanWorkItem(body);
5199
+ if (planWorkItem.error) return jsonReply(res, 400, { error: planWorkItem.error });
5069
5200
  // Write as a work item with type 'plan' — user must explicitly execute plan-to-prd after reviewing
5070
5201
  const wiPath = path.join(MINIONS_DIR, 'work-items.json');
5071
- const id = 'W-' + shared.uid();
5072
- const item = {
5073
- id, title: body.title, type: 'plan',
5074
- priority: body.priority || 'high', description: body.description || '',
5075
- status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
5076
- branchStrategy: body.branch_strategy || body.branchStrategy || 'parallel',
5077
- };
5078
- if (target.project) item.project = target.project.name;
5079
- if (body.agent) item.agent = body.agent;
5080
- mutateWorkItems(wiPath, items => { items.push(item); });
5081
- return jsonReply(res, 200, { ok: true, id, agent: body.agent || '' });
5202
+ mutateWorkItems(wiPath, items => { items.push(planWorkItem.item); });
5203
+ return jsonReply(res, 200, { ok: true, id: planWorkItem.id, agent: body.agent || '' });
5082
5204
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
5083
5205
  }
5084
5206
 
@@ -5086,31 +5208,14 @@ const server = http.createServer(async (req, res) => {
5086
5208
  try {
5087
5209
  const body = await readBody(req);
5088
5210
  if (!body.name || !body.name.trim()) return jsonReply(res, 400, { error: 'name is required' });
5089
- const target = resolveWorkItemsCreateTarget(body.project);
5090
- if (target.error) return jsonReply(res, 400, { error: target.error });
5211
+ const manualPrd = buildManualPrdItemPlan(body);
5212
+ if (manualPrd.error) return jsonReply(res, 400, { error: manualPrd.error });
5091
5213
 
5092
5214
  if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
5093
5215
 
5094
- const id = body.id || ('M' + String(Date.now()).slice(-4));
5095
5216
  const planFile = 'manual-' + shared.uid() + '.json';
5096
- const plan = {
5097
- version: 'manual-' + new Date().toISOString().slice(0, 10),
5098
- project: target.project?.name || 'Unknown',
5099
- generated_by: 'dashboard',
5100
- generated_at: new Date().toISOString().slice(0, 10),
5101
- plan_summary: body.name,
5102
- status: 'approved',
5103
- requires_approval: false,
5104
- branch_strategy: 'parallel',
5105
- missing_features: [{
5106
- id, name: body.name, description: body.description || '',
5107
- priority: body.priority || 'medium', estimated_complexity: body.estimated_complexity || 'medium',
5108
- status: 'missing', depends_on: [], acceptance_criteria: [],
5109
- }],
5110
- open_questions: [],
5111
- };
5112
- safeWrite(path.join(PRD_DIR, planFile), plan);
5113
- return jsonReply(res, 200, { ok: true, id, file: planFile });
5217
+ safeWrite(path.join(PRD_DIR, planFile), manualPrd.plan);
5218
+ return jsonReply(res, 200, { ok: true, id: manualPrd.id, file: planFile });
5114
5219
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
5115
5220
  }
5116
5221
 
@@ -8385,7 +8490,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8385
8490
  { method: 'GET', path: /^\/api\/plans\/([^?]+)$/, template: '/api/plans/:file', desc: 'Read a full plan (JSON from prd/ or markdown from plans/)', handler: handlePlansRead },
8386
8491
 
8387
8492
  // PRD items
8388
- { method: 'POST', path: '/api/prd-items', desc: 'Create a PRD item as a plan file in prd/ (auto-approved)', params: 'name, description?, priority?, estimated_complexity?, project?, id?', handler: handlePrdItemsCreate },
8493
+ { method: 'POST', path: '/api/prd-items', desc: 'Create a PRD item as a plan file in prd/ (auto-approved)', params: 'name, description?, priority?, estimated_complexity?, project?, item_project?, id?', handler: handlePrdItemsCreate },
8389
8494
  { method: 'POST', path: '/api/prd-items/update', desc: 'Edit a PRD item in its source plan JSON', params: 'source, itemId, name?, description?, priority?, estimated_complexity?, status?', handler: handlePrdItemsUpdate },
8390
8495
  { method: 'POST', path: '/api/prd-items/remove', desc: 'Remove a PRD item from plan + cancel materialized work item', params: 'source, itemId', handler: handlePrdItemsRemove },
8391
8496
  // /api/prd/regenerate removed — use /api/plans/approve which does diff-aware update
@@ -8419,9 +8524,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8419
8524
  } catch (e) {
8420
8525
  return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
8421
8526
  }
8422
- const { id: prId, prPath, prNum, created, linked } = linkResult;
8527
+ const { id: prId, prPath, prNum, created, linked, targetProject, projectResolution } = linkResult;
8423
8528
  invalidateStatusCache();
8424
- jsonReply(res, 200, { ok: true, id: prId, created, linked });
8529
+ jsonReply(res, 200, {
8530
+ ok: true,
8531
+ id: prId,
8532
+ created,
8533
+ linked,
8534
+ project: targetProject?.name || 'central',
8535
+ projectResolution,
8536
+ message: projectResolution?.message || 'PR linked.',
8537
+ });
8425
8538
 
8426
8539
  // Async-enrich: fetch title, description, branch, author from GitHub/ADO API
8427
8540
  (async () => {
@@ -9041,6 +9154,8 @@ module.exports = {
9041
9154
  _findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
9042
9155
  _createWorkItemWithDedup: createWorkItemWithDedup,
9043
9156
  _resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
9157
+ _buildPlanWorkItem: buildPlanWorkItem,
9158
+ _buildManualPrdItemPlan: buildManualPrdItemPlan,
9044
9159
  _resolveScheduleProjectValue: resolveScheduleProjectValue,
9045
9160
  _collectArchivedWorkItems: collectArchivedWorkItems,
9046
9161
  _createPipelineFromAction: createPipelineFromAction,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-09T08:52:32.908Z"
4
+ "cachedAt": "2026-05-09T15:01:20.730Z"
5
5
  }
@@ -390,9 +390,9 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
390
390
  touchedProjects.push(project?.name || null);
391
391
  }
392
392
  }
393
- const projects = [...new Set(touchedProjects)];
394
393
  const artifacts = { workItems: [...new Set(createdIds)] };
395
- if (projects.some(Boolean)) artifacts.projects = projects;
394
+ const projectArtifacts = [...new Set(touchedProjects.filter(Boolean))];
395
+ if (projectArtifacts.length > 0) artifacts.projects = projectArtifacts;
396
396
  return { status: PIPELINE_STATUS.RUNNING, artifacts };
397
397
  }
398
398
 
@@ -342,21 +342,41 @@ function isSafeProjectPlaybookName(projectName) {
342
342
  !/^[a-zA-Z]:/.test(name);
343
343
  }
344
344
 
345
+ function isSafePlaybookType(playbookType) {
346
+ const name = String(playbookType || '').trim();
347
+ return !!name &&
348
+ !name.includes('\0') &&
349
+ !name.includes('..') &&
350
+ !/[\\/]/.test(name) &&
351
+ !path.isAbsolute(name) &&
352
+ !/^[a-zA-Z]:/.test(name);
353
+ }
354
+
345
355
  function resolvePlaybookPath(projectName, playbookType) {
356
+ const playbookTypeName = String(playbookType || '').trim();
357
+ if (!isSafePlaybookType(playbookTypeName)) {
358
+ log('warn', `Skipping unsafe playbook type lookup: ${playbookTypeName.replace(/\0/g, '')}`);
359
+ return path.join(PLAYBOOKS_DIR, '__invalid__.md');
360
+ }
346
361
  if (projectName) {
347
362
  const projectDirName = String(projectName).trim();
348
363
  if (isSafeProjectPlaybookName(projectDirName)) {
349
- const localPath = path.join(MINIONS_DIR, 'projects', projectDirName, 'playbooks', `${playbookType}.md`);
364
+ const projectsDir = path.join(MINIONS_DIR, 'projects');
365
+ const localPath = path.resolve(projectsDir, projectDirName, 'playbooks', `${playbookTypeName}.md`);
366
+ if (!shared.isPathInside(localPath, projectsDir)) {
367
+ log('warn', `Skipping project-local playbook outside projects/: ${projectDirName}`);
368
+ return path.join(PLAYBOOKS_DIR, `${playbookTypeName}.md`);
369
+ }
350
370
  try {
351
371
  fs.accessSync(localPath, fs.constants.R_OK);
352
- log('info', `Using project-local playbook: projects/${projectDirName}/playbooks/${playbookType}.md`);
372
+ log('info', `Using project-local playbook: projects/${projectDirName}/playbooks/${playbookTypeName}.md`);
353
373
  return localPath;
354
374
  } catch { /* no local override — fall through to global */ }
355
375
  } else {
356
376
  log('warn', `Skipping project-local playbook lookup for unsafe project name: ${projectDirName}`);
357
377
  }
358
378
  }
359
- return path.join(PLAYBOOKS_DIR, `${playbookType}.md`);
379
+ return path.join(PLAYBOOKS_DIR, `${playbookTypeName}.md`);
360
380
  }
361
381
 
362
382
 
@@ -11,13 +11,84 @@ const shared = require('./shared');
11
11
  const dispatch = require('./dispatch');
12
12
  const { MINIONS_DIR } = shared;
13
13
 
14
- /**
15
- * @param {object} d - dispatch entry
16
- * @returns {string} project name from any of the three meta shapes the engine
17
- * uses (item.project, project.name, project string)
18
- */
19
- function _dispatchProjectName(d) {
20
- return d?.meta?.item?.project || d?.meta?.project?.name || d?.meta?.project || '';
14
+ function _sameProjectName(a, b) {
15
+ return String(a || '').toLowerCase() === String(b || '').toLowerCase();
16
+ }
17
+
18
+ function _projectRefMatches(projectRef, removedProject, projects) {
19
+ if (!projectRef) return false;
20
+ const refs = (projectRef && typeof projectRef === 'object')
21
+ ? [projectRef.name, projectRef.localPath]
22
+ : [projectRef];
23
+ return refs.some(ref => {
24
+ if (!ref) return false;
25
+ const result = shared.resolveConfiguredProject(ref, projects);
26
+ return !!(result.project && _sameProjectName(result.project.name, removedProject.name));
27
+ });
28
+ }
29
+
30
+ function _workItemMatchesProject(item, removedProject, projects) {
31
+ const refs = [
32
+ item?.project,
33
+ item?._project,
34
+ item?.planProject,
35
+ item?._planProject,
36
+ item?._declaredPlanProject,
37
+ ];
38
+ return refs.some(ref => _projectRefMatches(ref, removedProject, projects));
39
+ }
40
+
41
+ function _isCentralDispatchSource(source) {
42
+ return source === 'central-work-item' || source === 'central-work-item-fanout';
43
+ }
44
+
45
+ function _dispatchMatchesProject(d, removedProject, projects) {
46
+ const meta = d?.meta || {};
47
+ if (_isCentralDispatchSource(meta.source)) {
48
+ return _workItemMatchesProject(meta.item, removedProject, projects) ||
49
+ _projectRefMatches(meta.project, removedProject, projects);
50
+ }
51
+ return _workItemMatchesProject(meta.item, removedProject, projects) ||
52
+ _projectRefMatches(meta.project, removedProject, projects);
53
+ }
54
+
55
+ function _centralDispatchDefaultedToProject(d, removedProject, projects) {
56
+ const meta = d?.meta || {};
57
+ return _isCentralDispatchSource(meta.source) &&
58
+ meta.item?.id &&
59
+ !_workItemMatchesProject(meta.item, removedProject, projects) &&
60
+ _projectRefMatches(meta.project, removedProject, projects);
61
+ }
62
+
63
+ function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
64
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
65
+ const ids = new Set();
66
+ const state = shared.safeJson(dispatchPath) || {};
67
+ for (const queue of ['pending', 'active']) {
68
+ for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
69
+ if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
70
+ }
71
+ }
72
+ return ids;
73
+ }
74
+
75
+ function _requeueProjectlessCentralWorkItems(itemIds) {
76
+ if (!itemIds || itemIds.size === 0) return 0;
77
+ const wiPath = path.join(MINIONS_DIR, 'work-items.json');
78
+ if (!fs.existsSync(wiPath)) return 0;
79
+ let requeued = 0;
80
+ shared.mutateWorkItems(wiPath, items => {
81
+ if (!Array.isArray(items)) return items;
82
+ for (const w of items) {
83
+ if (!itemIds.has(w?.id) || w.status !== shared.WI_STATUS.DISPATCHED) continue;
84
+ w.status = shared.WI_STATUS.PENDING;
85
+ delete w.dispatched_at;
86
+ delete w.dispatched_to;
87
+ requeued++;
88
+ }
89
+ return items;
90
+ });
91
+ return requeued;
21
92
  }
22
93
 
23
94
  /**
@@ -56,9 +127,10 @@ function removeProject(target, options = {}) {
56
127
  try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
57
128
  catch (e) { return { ...summary, error: 'Failed to read config: ' + e.message }; }
58
129
 
59
- const project = shared.findProjectByNameOrPath(config.projects || [], target);
130
+ const projects = shared.getProjects(config);
131
+ const project = shared.resolveConfiguredProject(target, projects).project;
60
132
  if (!project) {
61
- const available = (config.projects || []).map(p => p.name).join(', ') || '(none)';
133
+ const available = projects.map(p => p.name).join(', ') || '(none)';
62
134
  return { ...summary, error: `No project linked matching: ${target}. Available: ${available}` };
63
135
  }
64
136
  summary.project = { name: project.name, localPath: project.localPath };
@@ -72,15 +144,17 @@ function removeProject(target, options = {}) {
72
144
  );
73
145
  summary.cancelledItems += dispatch.cancelPendingWorkItems(
74
146
  path.join(MINIONS_DIR, 'work-items.json'),
75
- w => String(w.project || '').toLowerCase() === String(project.name || '').toLowerCase(),
147
+ w => _workItemMatchesProject(w, project, projects),
76
148
  'project-removed',
77
149
  );
78
150
 
79
151
  // 2. Drain dispatch — also kills active agent processes and unlinks pid +
80
152
  // prompt sidecars in engine/tmp/, matching what plan delete does.
153
+ const projectlessCentralItemIds = _collectProjectlessCentralDispatchItemIds(project, projects);
81
154
  summary.drainedDispatches = dispatch.cleanDispatchEntries(
82
- d => String(_dispatchProjectName(d) || '').toLowerCase() === String(project.name || '').toLowerCase(),
155
+ d => _dispatchMatchesProject(d, project, projects),
83
156
  );
157
+ _requeueProjectlessCentralWorkItems(projectlessCentralItemIds);
84
158
 
85
159
  // 3. Clean up worktrees under this project's worktree root, honoring
86
160
  // config.engine.worktreeRoot (mirrors lifecycle.js cleanupPlanWorktrees).
@@ -103,7 +177,7 @@ function removeProject(target, options = {}) {
103
177
  // specifically. Don't touch schedules with project='any' or unset.
104
178
  if (Array.isArray(config.schedules)) {
105
179
  for (const s of config.schedules) {
106
- if (shared.resolveProjectSource(s.project, [project], { allowCentral: false }).project && s.enabled !== false) {
180
+ if (_projectRefMatches(s.project, project, projects) && s.enabled !== false) {
107
181
  s.enabled = false;
108
182
  summary.disabledSchedules++;
109
183
  }
@@ -129,7 +203,7 @@ function removeProject(target, options = {}) {
129
203
  for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
130
204
  try {
131
205
  const prd = JSON.parse(fs.readFileSync(path.join(prdDir, f), 'utf8'));
132
- if (prd?.project !== project.name) continue;
206
+ if (!_projectRefMatches(prd?.project, project, projects)) continue;
133
207
  archivePlan(f, prd, [project], config);
134
208
  summary.archivedPlans.push('prd/' + f);
135
209
  if (prd.source_plan) archivedSourcePlans.add(prd.source_plan);
@@ -164,14 +238,17 @@ function removeProject(target, options = {}) {
164
238
  ...(p.monitoredResources || []),
165
239
  ...((p.stages || []).flatMap(s => s.monitoredResources || [])),
166
240
  ];
167
- if (refs.some(r => r && shared.resolveProjectSource(r.project || r._project, [project], { allowCentral: false }).project)) {
241
+ if (refs.some(r => r && (
242
+ _projectRefMatches(r.project, project, projects) ||
243
+ _projectRefMatches(r._project, project, projects)
244
+ ))) {
168
245
  summary.pipelineRefs.push(p.id);
169
246
  }
170
247
  }
171
248
  } catch { /* pipelines optional */ }
172
249
 
173
250
  // 7. Remove from config.json (and persist any schedule disables)
174
- config.projects = (config.projects || []).filter(p => !shared.resolveProjectSource(p, [project], { allowCentral: false }).project);
251
+ config.projects = (config.projects || []).filter(p => !_sameProjectName(p?.name, project.name));
175
252
  try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); }
176
253
  catch (e) { return { ...summary, error: 'Failed to write config: ' + e.message }; }
177
254
 
package/engine/shared.js CHANGED
@@ -2209,7 +2209,8 @@ function sanitizePath(file, baseDir) {
2209
2209
  if (path.isAbsolute(file) || /^[a-zA-Z]:/.test(file)) throw new Error('invalid file path: absolute path not allowed');
2210
2210
  const resolved = path.resolve(baseDir, file);
2211
2211
  const normalizedBase = path.resolve(baseDir);
2212
- if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
2212
+ const rel = path.relative(normalizedBase, resolved);
2213
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
2213
2214
  throw new Error('invalid file path: outside allowed directory');
2214
2215
  }
2215
2216
  return resolved;
@@ -2963,7 +2964,7 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
2963
2964
  target[key] = normalizedEntry[key];
2964
2965
  }
2965
2966
  }
2966
- for (const key of ['_manual', '_autoObserve', '_context']) {
2967
+ for (const key of ['_manual', '_autoObserve', '_context', '_projectResolution']) {
2967
2968
  if (normalizedEntry[key] != null) target[key] = normalizedEntry[key];
2968
2969
  }
2969
2970
  if (normalizedEntry._contextOnly != null) {
package/engine.js CHANGED
@@ -2015,8 +2015,11 @@ function safePrdProjectSlug(projectName) {
2015
2015
 
2016
2016
  function safePrdFilenameForProject(projectName, suffix) {
2017
2017
  const fileName = `${safePrdProjectSlug(projectName)}-${suffix}.json`;
2018
- shared.sanitizePath(fileName, PRD_DIR);
2019
- return fileName;
2018
+ const resolved = shared.sanitizePath(fileName, PRD_DIR);
2019
+ if (path.dirname(resolved) !== path.resolve(PRD_DIR)) {
2020
+ throw new Error('invalid PRD filename: nested paths are not allowed');
2021
+ }
2022
+ return path.basename(resolved);
2020
2023
  }
2021
2024
 
2022
2025
  function materializePlansAsWorkItems(config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1812",
3
+ "version": "0.1.1814",
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"