@yemi33/minions 0.1.1775 → 0.1.1777

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,16 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1775 (2026-05-07)
3
+ ## 0.1.1777 (2026-05-07)
4
+
5
+ ### Fixes
6
+ - tighten worktreeRoot validation everywhere worktrees are created
7
+
8
+ ## 0.1.1776 (2026-05-07)
9
+
10
+ ### Fixes
11
+ - refuse worktree paths nested in any project root
12
+
13
+ ## 0.1.1774 (2026-05-07)
4
14
 
5
15
  ### Features
6
- - Improve dashboard API route metadata
7
16
  - suppress stale doc-chat model errors
8
17
 
9
18
  ### Fixes
10
19
  - yemi33/minions#2168
11
- - honor central plan work item project
12
- - restore canonical-home shared helpers
13
- - yemi33/minions#2170
14
20
 
15
21
  ## 0.1.1772 (2026-05-07)
16
22
 
package/engine/cleanup.js CHANGED
@@ -366,6 +366,31 @@ async function runCleanup(config, verbose = false) {
366
366
  }
367
367
  }
368
368
 
369
+ // 2b. Detect git worktrees registered inside any linked project's working tree.
370
+ // Nested worktrees cause glob/grep tools running with cwd=projectRoot to match
371
+ // BOTH copies of every file; a single Edit/MultiEdit then writes the same
372
+ // change to both locations, producing "mirror dirty file" leaks (W-cc-doc-chat-continuity).
373
+ // We only WARN here — removing someone else's worktree without consent could
374
+ // destroy in-flight work. The operator runs `git worktree remove <path>`.
375
+ cleaned.nestedWorktrees = 0;
376
+ for (const project of projects) {
377
+ const root = project.localPath ? path.resolve(project.localPath) : null;
378
+ if (!root || !fs.existsSync(root)) continue;
379
+ let raw;
380
+ try {
381
+ raw = String(shared.execSilent('git worktree list --porcelain', { cwd: root, timeout: 10000, windowsHide: true }) || '');
382
+ } catch (e) { log('warn', `nested-worktree scan for ${project.name || root}: ${e.message}`); continue; }
383
+ for (const line of raw.split('\n')) {
384
+ if (!line.startsWith('worktree ')) continue;
385
+ const wt = line.slice('worktree '.length).trim();
386
+ if (!wt) continue;
387
+ if (path.resolve(wt) === root) continue; // main worktree — expected
388
+ if (!shared.isPathInsideOrEqual(wt, root)) continue;
389
+ cleaned.nestedWorktrees++;
390
+ log('warn', `Nested worktree in project "${project.name || root}": "${wt}" is inside "${root}". This causes glob tools to match both copies and produces mirror writes. Run: git worktree remove "${wt}"`);
391
+ }
392
+ }
393
+
369
394
  // 3. Clean git worktrees for merged/abandoned PRs
370
395
  const _attemptedWorktreePaths = new Set(); // dedup across projects sharing a worktreeRoot
371
396
  for (const project of projects) {
@@ -664,8 +689,8 @@ async function runCleanup(config, verbose = false) {
664
689
  }
665
690
  } catch (e) { log('warn', 'prune orphaned dispatches: ' + e.message); }
666
691
 
667
- if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches > 0) {
668
- log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches`);
692
+ if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches + (cleaned.nestedWorktrees || 0) > 0) {
693
+ log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches, ${cleaned.nestedWorktrees || 0} nested worktrees flagged`);
669
694
  }
670
695
 
671
696
  // 8. Clean swept KB files older than 7 days
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-07T21:35:37.002Z"
4
+ "cachedAt": "2026-05-07T23:17:20.330Z"
5
5
  }
@@ -1745,6 +1745,17 @@ async function rebaseBranchOntoMain(pr, project, config) {
1745
1745
  const tmpWt = path.join(wtRoot, `rebase-${shared.sanitizeBranch(branch)}-${Date.now()}`).replace(/\\/g, '/');
1746
1746
  const _gitOpts = { cwd: root, timeout: 30000, windowsHide: true };
1747
1747
 
1748
+ // Refuse to create a worktree nested in the project root — would cause
1749
+ // glob/grep tools running with cwd=root to match both copies of every file
1750
+ // and produce mirror writes. Misconfigured engine.worktreeRoot is the only
1751
+ // way this lands inside root; the assert throws so the caller can recover.
1752
+ try {
1753
+ shared.assertWorktreeOutsideProject(tmpWt, root);
1754
+ } catch (err) {
1755
+ log('warn', `Post-merge rebase: refusing nested worktree path — ${err.message}`);
1756
+ return { success: false, error: err.message };
1757
+ }
1758
+
1748
1759
  try {
1749
1760
  await execAsync(`git fetch origin "${mainBranch}" "${branch}"`, _gitOpts);
1750
1761
  try {
@@ -238,8 +238,13 @@ function runPreflight(opts = {}) {
238
238
  // us the config. checkOrExit() / cli start() / doctor() pass it; legacy
239
239
  // callers don't, in which case we skip silently.
240
240
  if (opts && opts.config && typeof opts.config === 'object') {
241
+ // Hoisted: `shared` is referenced by every check block below, including
242
+ // workSources warnings and the worktreeRoot check. Previously declared
243
+ // inside the first inner try, which left later blocks reading an
244
+ // undefined identifier (ReferenceError silently caught by the wrapping
245
+ // try/catch).
246
+ const shared = require('./shared');
241
247
  try {
242
- const shared = require('./shared');
243
248
  let runtimeNames = [];
244
249
  try { runtimeNames = require('./runtimes').listRuntimes(); }
245
250
  catch { /* registry may be missing during partial installs */ }
@@ -266,6 +271,32 @@ function runPreflight(opts = {}) {
266
271
  results.push({ name: `Project config (${w.id})`, ok: 'warn', message: w.message });
267
272
  }
268
273
  } catch { /* defensive */ }
274
+
275
+ // 5. worktreeRoot config check — for every linked project, verify that the
276
+ // configured engine.worktreeRoot resolves OUTSIDE the project's
277
+ // localPath. A nested worktreeRoot causes glob/grep to match both
278
+ // copies of every file, producing silent mirror writes (the W-cc-doc-
279
+ // chat-continuity leak class). Hard-fail at preflight so the operator
280
+ // sees it before any agent dispatch — the runtime guard in spawnAgent
281
+ // is the second line of defense.
282
+ try {
283
+ const path = require('path');
284
+ const projects = shared.getProjects(opts.config) || [];
285
+ const wtRoot = opts.config?.engine?.worktreeRoot || shared.ENGINE_DEFAULTS.worktreeRoot;
286
+ for (const project of projects) {
287
+ if (!project || !project.localPath) continue;
288
+ const root = path.resolve(project.localPath);
289
+ const resolved = path.resolve(root, wtRoot);
290
+ if (shared.isPathInsideOrEqual(resolved, root)) {
291
+ results.push({
292
+ name: `worktreeRoot (${project.name || root})`,
293
+ ok: false,
294
+ message: `engine.worktreeRoot "${wtRoot}" resolves to "${resolved}" — INSIDE project root "${root}". This causes glob/grep to match duplicate files and produces mirror writes. Set engine.worktreeRoot to a sibling path (default: "../worktrees").`,
295
+ });
296
+ allOk = false;
297
+ }
298
+ }
299
+ } catch { /* defensive — preflight must never throw */ }
269
300
  }
270
301
 
271
302
  return { passed: allOk, results };
package/engine/shared.js CHANGED
@@ -2121,6 +2121,49 @@ function buildWorktreeDirName({
2121
2121
  return `${projectSlug}-${sanitizeBranch(branchName || 'worktree')}-${suffix}`;
2122
2122
  }
2123
2123
 
2124
+ /**
2125
+ * True when `childPath` is the same as or nested within `parentPath`. Uses
2126
+ * `path.relative` so it's cross-platform and resilient to mixed separators
2127
+ * (Windows worktree paths often arrive with forward slashes from git output).
2128
+ * Returns false when paths refer to different roots/drives or `childPath`
2129
+ * escapes via `..`.
2130
+ *
2131
+ * Why this helper exists: a git worktree placed inside the parent repo's
2132
+ * working tree causes glob/grep tools running with `cwd = projectRoot` to
2133
+ * match BOTH copies of every file. A single Edit/MultiEdit then writes the
2134
+ * same change to both locations, producing the "mirror dirty file" pattern.
2135
+ * Worktrees must always be siblings/cousins of the project root, never
2136
+ * descendants.
2137
+ */
2138
+ function isPathInsideOrEqual(childPath, parentPath) {
2139
+ if (!childPath || !parentPath) return false;
2140
+ const childAbs = path.resolve(String(childPath));
2141
+ const parentAbs = path.resolve(String(parentPath));
2142
+ const rel = path.relative(parentAbs, childAbs);
2143
+ if (rel === '') return true;
2144
+ if (rel.startsWith('..')) return false;
2145
+ if (path.isAbsolute(rel)) return false;
2146
+ return true;
2147
+ }
2148
+
2149
+ /**
2150
+ * Throws when `worktreePath` would land inside (or equal) `projectRoot`.
2151
+ * Called by the engine spawn path before `git worktree add`, and by the
2152
+ * cleanup sweep that audits already-registered worktrees per linked project.
2153
+ * The thrown Error has `code: 'WORKTREE_NESTED_IN_PROJECT'` so callers can
2154
+ * branch on it without parsing the message.
2155
+ */
2156
+ function assertWorktreeOutsideProject(worktreePath, projectRoot) {
2157
+ if (!isPathInsideOrEqual(worktreePath, projectRoot)) return;
2158
+ const err = new Error(
2159
+ `Refusing to use worktree path "${worktreePath}" — it is inside project root "${projectRoot}". ` +
2160
+ `A worktree nested in its parent project causes glob/grep tools to match both copies and ` +
2161
+ `produces "mirror" dirty files. Place worktrees outside the project (engine.worktreeRoot default: "../worktrees").`
2162
+ );
2163
+ err.code = 'WORKTREE_NESTED_IN_PROJECT';
2164
+ throw err;
2165
+ }
2166
+
2124
2167
  // ── HTTP Origin Allowlist & Security Headers ─────────────────────────────────
2125
2168
  // Pure helpers used by dashboard.js to gate mutating requests against an
2126
2169
  // explicit allowlist of local origins and to attach uniform security response
@@ -3192,6 +3235,8 @@ module.exports = {
3192
3235
  sanitizePath,
3193
3236
  sanitizeBranch,
3194
3237
  buildWorktreeDirName, // exported for testing
3238
+ isPathInsideOrEqual,
3239
+ assertWorktreeOutsideProject,
3195
3240
  isLiveCommandCenterPath,
3196
3241
  describeCcProtectedPaths,
3197
3242
  renderCcSystemPrompt,
package/engine.js CHANGED
@@ -625,10 +625,18 @@ async function spawnAgent(dispatchItem, config) {
625
625
  branchName,
626
626
  });
627
627
  worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', wtDirName);
628
+ // Refuse to spawn into a worktree path that's inside the project root —
629
+ // nested worktrees cause glob/grep to match both copies (mirror writes).
630
+ // Throws on violation; caught by the outer try/catch which fails dispatch.
631
+ shared.assertWorktreeOutsideProject(worktreePath, rootDir);
628
632
 
629
633
  // If branch is already checked out in an existing worktree, reuse it
630
634
  const existingWt = await findExistingWorktree(rootDir, branchName);
631
635
  if (existingWt) {
636
+ // Same guard for reuse — a previously-created bad worktree must not
637
+ // be silently reused either; the cleanup sweep flags these so the
638
+ // operator can remove them.
639
+ shared.assertWorktreeOutsideProject(existingWt, rootDir);
632
640
  worktreePath = existingWt;
633
641
  log('info', `Reusing existing worktree for ${branchName}: ${existingWt}`);
634
642
  // Probe origin first — locally-created branches that were never pushed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1775",
3
+ "version": "0.1.1777",
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"