@yemi33/minions 0.1.1807 → 0.1.1808

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.1808 (2026-05-08)
4
+
5
+ ### Fixes
6
+ - Plan-to-PRD conversion can override explicit plan project with contextual project (#2235)
7
+
3
8
  ## 0.1.1807 (2026-05-08)
4
9
 
5
10
  ### Other
package/dashboard.js CHANGED
@@ -5506,12 +5506,14 @@ const server = http.createServer(async (req, res) => {
5506
5506
  shared.sanitizePath(body.file, PLANS_DIR);
5507
5507
  const planPath = path.join(MINIONS_DIR, 'plans', body.file);
5508
5508
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
5509
+ const planContent = fs.readFileSync(planPath, 'utf8');
5510
+ const declaredProject = shared.extractPlanDeclaredProject(planContent);
5509
5511
 
5510
5512
  const queued = shared.queuePlanToPrd({
5511
5513
  planFile: body.file,
5512
5514
  title: 'Convert plan to PRD: ' + body.file.replace('.md', ''),
5513
5515
  description: 'Plan file: plans/' + body.file,
5514
- project: body.project || '', createdBy: 'dashboard:execute',
5516
+ project: declaredProject || body.project || '', createdBy: 'dashboard:execute',
5515
5517
  });
5516
5518
  if (!queued) return jsonReply(res, 200, { ok: true, alreadyQueued: true });
5517
5519
  invalidateStatusCache();
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-08T19:31:14.792Z"
4
+ "cachedAt": "2026-05-08T20:57:25.233Z"
5
5
  }
@@ -332,14 +332,29 @@ function validatePlaybookVars(playbookName, vars) {
332
332
  * @param {string} playbookType — playbook type name (e.g. 'implement', 'review')
333
333
  * @returns {string} absolute path to the playbook file
334
334
  */
335
+ function isSafeProjectPlaybookName(projectName) {
336
+ const name = String(projectName || '').trim();
337
+ return !!name &&
338
+ !name.includes('\0') &&
339
+ !name.includes('..') &&
340
+ !/[\\/]/.test(name) &&
341
+ !path.isAbsolute(name) &&
342
+ !/^[a-zA-Z]:/.test(name);
343
+ }
344
+
335
345
  function resolvePlaybookPath(projectName, playbookType) {
336
346
  if (projectName) {
337
- const localPath = path.join(MINIONS_DIR, 'projects', projectName, 'playbooks', `${playbookType}.md`);
338
- try {
339
- fs.accessSync(localPath, fs.constants.R_OK);
340
- log('info', `Using project-local playbook: projects/${projectName}/playbooks/${playbookType}.md`);
341
- return localPath;
342
- } catch { /* no local override — fall through to global */ }
347
+ const projectDirName = String(projectName).trim();
348
+ if (isSafeProjectPlaybookName(projectDirName)) {
349
+ const localPath = path.join(MINIONS_DIR, 'projects', projectDirName, 'playbooks', `${playbookType}.md`);
350
+ try {
351
+ fs.accessSync(localPath, fs.constants.R_OK);
352
+ log('info', `Using project-local playbook: projects/${projectDirName}/playbooks/${playbookType}.md`);
353
+ return localPath;
354
+ } catch { /* no local override — fall through to global */ }
355
+ } else {
356
+ log('warn', `Skipping project-local playbook lookup for unsafe project name: ${projectDirName}`);
357
+ }
343
358
  }
344
359
  return path.join(PLAYBOOKS_DIR, `${playbookType}.md`);
345
360
  }
package/engine/shared.js CHANGED
@@ -1579,6 +1579,24 @@ function queuePlanToPrd({ planFile, prdFile, title, description, project, create
1579
1579
  }, { defaultValue: [] });
1580
1580
  return queued;
1581
1581
  }
1582
+
1583
+ function extractPlanDeclaredProject(planContent) {
1584
+ if (typeof planContent !== 'string' || !planContent.trim()) return '';
1585
+ const lines = planContent.split(/\r?\n/).slice(0, 80);
1586
+ for (const rawLine of lines) {
1587
+ const line = rawLine
1588
+ .replace(/^\s*[-*]\s+/, '')
1589
+ .replace(/\*\*/g, '')
1590
+ .trim();
1591
+ const match = line.match(/^Project\s*:\s*(.+)$/i);
1592
+ if (!match) continue;
1593
+ let value = match[1].trim();
1594
+ value = value.replace(/\s+#.*$/, '').trim();
1595
+ value = value.replace(/^["'`]+|["'`]+$/g, '').trim();
1596
+ return value;
1597
+ }
1598
+ return '';
1599
+ }
1582
1600
  const DISPATCH_RESULT = { SUCCESS: 'success', ERROR: 'error', TIMEOUT: 'timeout' };
1583
1601
  const PIPELINE_STATUS = {
1584
1602
  PENDING: 'pending', RUNNING: 'running', COMPLETED: 'completed',
@@ -3257,7 +3275,7 @@ module.exports = {
3257
3275
  runtimeConfigWarnings,
3258
3276
  projectWorkSourceWarnings,
3259
3277
  backfillProjectWorkSourceDefaults,
3260
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd,
3278
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
3261
3279
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS,
3262
3280
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
3263
3281
  FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
@@ -3302,6 +3320,7 @@ module.exports = {
3302
3320
  getAdoOrgBase,
3303
3321
  sanitizePath,
3304
3322
  sanitizeBranch,
3323
+ safeSlugComponent,
3305
3324
  buildWorktreeDirName, // exported for testing
3306
3325
  isPathInside,
3307
3326
  isPathInsideOrEqual,
package/engine.js CHANGED
@@ -1978,6 +1978,20 @@ function buildWiDescription(item, planFile) {
1978
1978
  return `${item.description || ''}\n\n**Plan:** ${planFile}\n**Plan Item:** ${item.id}\n**Complexity:** ${complexity}${criteria ? '\n\n**Acceptance Criteria:**\n' + criteria : ''}`;
1979
1979
  }
1980
1980
 
1981
+ function safePrdProjectSlug(projectName) {
1982
+ const slug = shared.safeSlugComponent(projectName || 'project', 80)
1983
+ .toLowerCase()
1984
+ .replace(/[^a-z0-9_-]+/g, '-')
1985
+ .replace(/^-+|-+$/g, '');
1986
+ return slug || 'project';
1987
+ }
1988
+
1989
+ function safePrdFilenameForProject(projectName, suffix) {
1990
+ const fileName = `${safePrdProjectSlug(projectName)}-${suffix}.json`;
1991
+ shared.sanitizePath(fileName, PRD_DIR);
1992
+ return fileName;
1993
+ }
1994
+
1981
1995
  function materializePlansAsWorkItems(config) {
1982
1996
  if (!fs.existsSync(PRD_DIR)) { try { fs.mkdirSync(PRD_DIR, { recursive: true }); } catch (e) { log('warn', 'create PRD directory: ' + e.message); } }
1983
1997
  const writePrdLocked = (fileName, data) => {
@@ -1990,6 +2004,100 @@ function materializePlansAsWorkItems(config) {
1990
2004
  return mutator(current) || current;
1991
2005
  }, { defaultValue: fallback || {}, ...options });
1992
2006
  };
2007
+ const declaredProjectPrdFilename = (fileName, projectName) => {
2008
+ const match = String(fileName || '').match(/-(\d{4}-\d{2}-\d{2}(?:-\d+)?)\.json$/);
2009
+ if (!match) return null;
2010
+ return safePrdFilenameForProject(projectName, match[1]);
2011
+ };
2012
+ const migratePrdFilenameReferences = (oldFileName, newFileName) => {
2013
+ if (!oldFileName || !newFileName || oldFileName === newFileName) return 0;
2014
+ let migrated = 0;
2015
+ const wiPaths = new Set([path.join(MINIONS_DIR, 'work-items.json')]);
2016
+ for (const project of getProjects(config)) wiPaths.add(projectWorkItemsPath(project));
2017
+ for (const wiPath of wiPaths) {
2018
+ if (!fs.existsSync(wiPath)) continue;
2019
+ mutateWorkItems(wiPath, (items) => {
2020
+ for (const wi of items) {
2021
+ if (!wi || typeof wi !== 'object') continue;
2022
+ if (wi.sourcePlan === oldFileName) {
2023
+ wi.sourcePlan = newFileName;
2024
+ migrated++;
2025
+ }
2026
+ if (wi._artifacts?.sourcePlan === oldFileName) {
2027
+ wi._artifacts.sourcePlan = newFileName;
2028
+ migrated++;
2029
+ }
2030
+ }
2031
+ return items;
2032
+ });
2033
+ }
2034
+ if (fs.existsSync(DISPATCH_PATH)) {
2035
+ mutateDispatch((dispatch) => {
2036
+ for (const queue of ['pending', 'active', 'completed']) {
2037
+ for (const entry of dispatch[queue] || []) {
2038
+ const metaItem = entry?.meta?.item;
2039
+ if (!metaItem || typeof metaItem !== 'object') continue;
2040
+ if (metaItem.sourcePlan === oldFileName) {
2041
+ metaItem.sourcePlan = newFileName;
2042
+ migrated++;
2043
+ }
2044
+ if (metaItem._prdFilename === oldFileName) {
2045
+ metaItem._prdFilename = newFileName;
2046
+ migrated++;
2047
+ }
2048
+ }
2049
+ }
2050
+ return dispatch;
2051
+ });
2052
+ }
2053
+ return migrated;
2054
+ };
2055
+ const enforceDeclaredPlanProject = (fileName, currentPlan) => {
2056
+ if (!currentPlan?.source_plan) return { fileName, plan: currentPlan };
2057
+ let declaredProject = '';
2058
+ try {
2059
+ declaredProject = shared.extractPlanDeclaredProject(safeRead(path.join(PLANS_DIR, currentPlan.source_plan)) || '');
2060
+ } catch (e) {
2061
+ log('warn', `Plan project enforcement: could not read source plan ${currentPlan.source_plan}: ${e.message}`);
2062
+ }
2063
+ if (!declaredProject) return { fileName, plan: currentPlan };
2064
+
2065
+ let changed = false;
2066
+ const normalizedPlan = mutatePrdLocked(fileName, currentPlan, (planData) => {
2067
+ if (planData.project !== declaredProject) {
2068
+ planData.project = declaredProject;
2069
+ changed = true;
2070
+ }
2071
+ if (Array.isArray(planData.missing_features)) {
2072
+ for (const feature of planData.missing_features) {
2073
+ if (feature && feature.project !== declaredProject) {
2074
+ feature.project = declaredProject;
2075
+ changed = true;
2076
+ }
2077
+ }
2078
+ }
2079
+ return planData;
2080
+ }, { skipWriteIfUnchanged: true });
2081
+
2082
+ let nextFileName = fileName;
2083
+ const desiredFileName = declaredProjectPrdFilename(fileName, declaredProject);
2084
+ if (desiredFileName && desiredFileName.toLowerCase() !== String(fileName).toLowerCase()) {
2085
+ const fromPath = path.join(PRD_DIR, fileName);
2086
+ const desiredPath = shared.sanitizePath(desiredFileName, PRD_DIR);
2087
+ const toPath = shared.uniquePath(desiredPath);
2088
+ try {
2089
+ fs.renameSync(fromPath, toPath);
2090
+ nextFileName = path.basename(toPath);
2091
+ const migrated = migratePrdFilenameReferences(fileName, nextFileName);
2092
+ if (migrated > 0) log('info', `Plan project enforcement: migrated ${migrated} PRD reference(s) from ${fileName} to ${nextFileName}`);
2093
+ changed = true;
2094
+ } catch (e) {
2095
+ log('warn', `Plan project enforcement: could not rename ${fileName} to ${path.basename(toPath)}: ${e.message}`);
2096
+ }
2097
+ }
2098
+ if (changed) log('info', `Plan project enforcement: preserved declared project "${declaredProject}" for ${nextFileName}`);
2099
+ return { fileName: nextFileName, plan: normalizedPlan };
2100
+ };
1993
2101
 
1994
2102
  // Enforce: PRDs must be .json — auto-rename .md files that contain valid PRD JSON
1995
2103
  // Check both prd/ and plans/ (agents may still write JSON to plans/)
@@ -2036,12 +2144,13 @@ function materializePlansAsWorkItems(config) {
2036
2144
  // Regex for detecting sequential PRD item IDs (P-001, P-002) — hoisted outside loop
2037
2145
  const SEQUENTIAL_ID_RE = /^P-?\d+$/;
2038
2146
 
2039
- for (const file of planFiles) {
2147
+ for (let file of planFiles) {
2040
2148
  // safeJsonNoRestore — if a PRD was archived between readdir and this
2041
2149
  // read, do not auto-resurrect it from a stale .backup sidecar
2042
2150
  // (W-mouptdh1000h9f39).
2043
2151
  let plan = safeJsonNoRestore(path.join(PRD_DIR, file));
2044
2152
  if (!plan?.missing_features) continue;
2153
+ ({ fileName: file, plan } = enforceDeclaredPlanProject(file, plan));
2045
2154
 
2046
2155
  // ID collision prevention: remap sequential IDs (P-001, P-002) to globally unique P-<uid> IDs.
2047
2156
  // Agents are instructed to use P-<uuid> format but sometimes generate sequential IDs,
@@ -3576,7 +3685,7 @@ function discoverCentralWorkItems(config) {
3576
3685
  const items = safeJson(centralPath) || [];
3577
3686
  const projects = getProjects(config);
3578
3687
  const dispatchProjects = getCentralDispatchProjects(projects);
3579
- const projectsByName = new Map(dispatchProjects.map(p => [p.name, p]));
3688
+ const projectsByName = new Map(dispatchProjects.map(p => [String(p.name || '').toLowerCase(), p]));
3580
3689
  const newWork = [];
3581
3690
  // Collect mutations to apply atomically inside lock callback (avoids TOCTOU)
3582
3691
  const mutations = new Map(); // item.id → { field: value, ... }
@@ -3706,9 +3815,32 @@ function discoverCentralWorkItems(config) {
3706
3815
 
3707
3816
  const agentName = config.agents[agentId]?.name || agentId;
3708
3817
  const agentRole = config.agents[agentId]?.role || 'Agent';
3818
+ let planFileContent = null;
3819
+ let planReadError = null;
3820
+ let declaredPlanProject = '';
3821
+ if (workType === WORK_TYPE.PLAN_TO_PRD && item.planFile) {
3822
+ const planPath = path.join(PLANS_DIR, item.planFile);
3823
+ try {
3824
+ planFileContent = fs.readFileSync(planPath, 'utf8');
3825
+ declaredPlanProject = shared.extractPlanDeclaredProject(planFileContent);
3826
+ } catch (e) {
3827
+ planReadError = e;
3828
+ }
3829
+ }
3709
3830
  const firstProject = dispatchProjects[0];
3710
- const requestedProjectName = typeof item.project === 'string' ? item.project : item.project?.name;
3711
- const targetProject = (requestedProjectName && projectsByName.get(requestedProjectName)) || firstProject;
3831
+ const requestedProjectName = declaredPlanProject || (typeof item.project === 'string' ? item.project : item.project?.name);
3832
+ const requestedProject = requestedProjectName ? projectsByName.get(String(requestedProjectName).toLowerCase()) : null;
3833
+ const targetProject = requestedProject || (declaredPlanProject
3834
+ ? { name: declaredPlanProject, localPath: '', repoName: declaredPlanProject, mainBranch: firstProject?.mainBranch || 'main' }
3835
+ : firstProject);
3836
+ if (declaredPlanProject) {
3837
+ const projectMutation = { project: targetProject.name, _declaredPlanProject: declaredPlanProject };
3838
+ if (!requestedProject) projectMutation._declaredPlanProjectMissing = true;
3839
+ mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, projectMutation));
3840
+ if (!requestedProject) {
3841
+ log('warn', `plan-to-prd: plan ${item.planFile} declares project "${declaredPlanProject}" but no configured project matches; preserving the declared project name with no project_path`);
3842
+ }
3843
+ }
3712
3844
 
3713
3845
  // Branch mutex: skip if target branch is locked by an active dispatch
3714
3846
  const centralBranch = item.branch || item.featureBranch || `work/${item.id}`;
@@ -3776,16 +3908,16 @@ function discoverCentralWorkItems(config) {
3776
3908
  if (workType === WORK_TYPE.PLAN_TO_PRD && item.planFile) {
3777
3909
  if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
3778
3910
  if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
3779
- const planPath = path.join(PLANS_DIR, item.planFile);
3780
- try {
3781
- vars.plan_content = fs.readFileSync(planPath, 'utf8');
3782
- } catch (e) {
3783
- log('warn', `plan-to-prd: could not read plan file ${item.planFile} for ${item.id}: ${e.message}`);
3911
+ if (planFileContent !== null) {
3912
+ vars.plan_content = planFileContent;
3913
+ } else {
3914
+ if (planReadError) log('warn', `plan-to-prd: could not read plan file ${item.planFile} for ${item.id}: ${planReadError.message}`);
3784
3915
  vars.plan_content = item.description || '';
3785
3916
  }
3786
3917
  vars.plan_summary = (item.title || item.planFile).substring(0, 80);
3787
3918
  vars.plan_file = item.planFile || '';
3788
3919
  vars.project_name_lower = (targetProject?.name || 'project').toLowerCase();
3920
+ vars.project_filename_slug = safePrdProjectSlug(targetProject?.name || 'project');
3789
3921
  // Default empty string so the {{existing_prd_json}} token always resolves —
3790
3922
  // playbook treats empty as "no existing PRD, fresh run". Without this default
3791
3923
  // the renderPlaybook pass logs an "unresolved template variables" warning
@@ -3805,8 +3937,9 @@ function discoverCentralWorkItems(config) {
3805
3937
  }
3806
3938
  if (!prdFilename) {
3807
3939
  // Generate unique PRD filename — check prd/ and prd/archive/ for collisions
3808
- const prdBase = vars.project_name_lower + '-' + dateStamp();
3940
+ const prdBase = vars.project_filename_slug + '-' + dateStamp();
3809
3941
  prdFilename = prdBase + '.json';
3942
+ shared.sanitizePath(prdFilename, PRD_DIR);
3810
3943
  const prdExisting = new Set([
3811
3944
  ...prdFiles,
3812
3945
  ...safeReadDir(path.join(PRD_DIR, 'archive')).filter(f => f.endsWith('.json')),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1807",
3
+ "version": "0.1.1808",
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"
@@ -14,6 +14,7 @@ A user has provided a plan. Analyze it against the codebase and produce a struct
14
14
  ## Instructions
15
15
 
16
16
  1. **Read the plan carefully** — understand the goals, scope, and requirements
17
+ - If the plan declares `Project: <name>` (including `**Project:** <name>`), the engine has resolved `{{project_name}}` from that declaration. Preserve `{{project_name}}` for the top-level `project`, default item `project`, filename, and implementation framing; do not let contextual mentions of another product or repository override it.
17
18
  2. **Check for an existing PRD** — if the engine provides `existing_prd_json` below, a PRD already exists for this plan. See "Reusing an Existing PRD" section for how to preserve item IDs and done statuses. If no existing PRD is provided, this is a fresh run — all items start as `"missing"`.
18
19
  3. **Explore the codebase** at `{{project_path}}` — understand the existing structure to write accurate descriptions and acceptance criteria. Do NOT use observations about existing PRs or partial work to set item statuses — status is determined only by existing PRD items (step 2), not codebase state
19
20
  4. **Break the plan into discrete, implementable items** — each should be a single PR's worth of work, with enough detail for another agent to implement it directly
@@ -47,7 +48,7 @@ This file is NOT checked into the repo. The engine reads it on every tick and di
47
48
  "id": "P-<uuid>",
48
49
  "name": "Short feature name",
49
50
  "description": "What needs to be built and why",
50
- "project": "ProjectName",
51
+ "project": "{{project_name}}",
51
52
  "status": "missing",
52
53
  "estimated_complexity": "small|medium|large",
53
54
  "priority": "high|medium|low",
@@ -86,7 +87,7 @@ Rules for items:
86
87
  - IDs must be `P-<uuid>` format (e.g. `P-a3f9b2c1`) — globally unique, never sequential
87
88
  - **`status` is `"missing"` for new items** — do not set `done`, `complete`, `implemented`, or any other value based on codebase observations. The only exception is when reusing an existing PRD (see below) — items already `"done"` in the existing PRD carry forward as `"done"`. Pre-setting any other status on new items causes them to be silently skipped by the engine.
88
89
  - **Do NOT include a "verify" or "test" or "integration test" item** — the engine automatically creates a verify task when all PRD items are done. Adding one manually creates a duplicate that blocks plan completion.
89
- - **`project` field is REQUIRED** — set it to the project name where the code changes go (e.g., `"OfficeAgent"`, `"office-bohemia"`). Cross-repo plans must route each item to the correct project. The engine materializes items into that project's work queue.
90
+ - **`project` field is REQUIRED** — set it to the project name where the code changes go (e.g., `"OfficeAgent"`, `"office-bohemia"`). If the plan declares a single project, every item must use `{{project_name}}`; contextual mentions of another repo/product must not override it. Cross-repo plans must route each item to the correct project. The engine materializes items into that project's work queue.
90
91
  - `depends_on` lists IDs of items that must be done first
91
92
  - Keep descriptions actionable — name the files, functions, patterns, or integration points the implementing agent should touch whenever the plan makes them clear
92
93
  - Include `acceptance_criteria` so reviewers know when it's done