@yemi33/minions 0.1.1780 → 0.1.1782
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 +10 -0
- package/engine/cleanup.js +24 -14
- package/engine/copilot-models.json +1 -1
- package/engine/meeting.js +2 -8
- package/engine/preflight.js +3 -4
- package/engine/shared.js +62 -14
- package/engine.js +4 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1782 (2026-05-07)
|
|
4
|
+
|
|
5
|
+
### Other
|
|
6
|
+
- refactor(worktree): consolidate porcelain parsers behind shared helper
|
|
7
|
+
|
|
8
|
+
## 0.1.1781 (2026-05-07)
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
- plug Property-1 holes + consolidate isPathInside helper
|
|
12
|
+
|
|
3
13
|
## 0.1.1780 (2026-05-07)
|
|
4
14
|
|
|
5
15
|
### Features
|
package/engine/cleanup.js
CHANGED
|
@@ -68,11 +68,13 @@ function isProtectedLocalBranch(branch, project = {}) {
|
|
|
68
68
|
|
|
69
69
|
function localBranchWorktreeInUse(root, branch) {
|
|
70
70
|
try {
|
|
71
|
-
const
|
|
71
|
+
const raw = String(shared.execSilent('git worktree list --porcelain', {
|
|
72
72
|
cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
|
|
73
73
|
}) || '');
|
|
74
|
-
return
|
|
74
|
+
return shared.parseWorktreePorcelain(raw).some(w => w.branch === branch);
|
|
75
75
|
} catch {
|
|
76
|
+
// Defensive: assume "in use" so the caller doesn't proceed to delete a
|
|
77
|
+
// branch when worktree state can't be enumerated.
|
|
76
78
|
return true;
|
|
77
79
|
}
|
|
78
80
|
}
|
|
@@ -369,25 +371,33 @@ async function runCleanup(config, verbose = false) {
|
|
|
369
371
|
// 2b. Detect git worktrees registered inside any linked project's working tree.
|
|
370
372
|
// Nested worktrees cause glob/grep tools running with cwd=projectRoot to match
|
|
371
373
|
// BOTH copies of every file; a single Edit/MultiEdit then writes the same
|
|
372
|
-
// change to both locations, producing
|
|
374
|
+
// change to both locations, producing mirror-write leaks.
|
|
373
375
|
// We only WARN here — removing someone else's worktree without consent could
|
|
374
376
|
// destroy in-flight work. The operator runs `git worktree remove <path>`.
|
|
375
377
|
cleaned.nestedWorktrees = 0;
|
|
378
|
+
const _scannedRoots = new Set(); // dedup projects sharing localPath
|
|
376
379
|
for (const project of projects) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
+
if (!project.localPath) continue;
|
|
381
|
+
const root = path.resolve(project.localPath);
|
|
382
|
+
if (_scannedRoots.has(root)) continue;
|
|
383
|
+
_scannedRoots.add(root);
|
|
384
|
+
if (!fs.existsSync(root)) {
|
|
385
|
+
// Configured project whose checkout has been moved or deleted — surface
|
|
386
|
+
// it so the operator knows their config is out of sync. A missing root
|
|
387
|
+
// means we cannot scan it, leaving nested worktrees there undetectable.
|
|
388
|
+
log('warn', `Project "${project.name || root}" has localPath "${root}" which does not exist on disk — skipping worktree scan`);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
let trees;
|
|
380
392
|
try {
|
|
381
|
-
raw = String(shared.execSilent('git worktree list --porcelain', { cwd: root, timeout: 10000, windowsHide: true }) || '');
|
|
393
|
+
const raw = String(shared.execSilent('git worktree list --porcelain', { cwd: root, timeout: 10000, windowsHide: true }) || '');
|
|
394
|
+
trees = shared.parseWorktreePorcelain(raw);
|
|
382
395
|
} catch (e) { log('warn', `nested-worktree scan for ${project.name || root}: ${e.message}`); continue; }
|
|
383
|
-
for (const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (!wt) continue;
|
|
387
|
-
if (path.resolve(wt) === root) continue; // main worktree — expected
|
|
388
|
-
if (!shared.isPathInsideOrEqual(wt, root)) continue;
|
|
396
|
+
for (const wt of trees) {
|
|
397
|
+
// Strict — main worktree (equal path) is expected, descendants are the leak.
|
|
398
|
+
if (!shared.isPathInside(wt.path, root)) continue;
|
|
389
399
|
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}"`);
|
|
400
|
+
log('warn', `Nested worktree in project "${project.name || root}": "${wt.path}" is inside "${root}". This causes glob tools to match both copies and produces mirror writes. Run: git worktree remove "${wt.path}"`);
|
|
391
401
|
}
|
|
392
402
|
}
|
|
393
403
|
|
package/engine/meeting.js
CHANGED
|
@@ -155,17 +155,12 @@ function getStructuredNoteArtifacts(structuredCompletion) {
|
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
function isPathInside(parent, child) {
|
|
159
|
-
const rel = path.relative(parent, child);
|
|
160
|
-
return Boolean(rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
158
|
function resolveMeetingNoteArtifactPath(artifactPath) {
|
|
164
159
|
const raw = String(artifactPath || '').trim();
|
|
165
160
|
if (!raw || raw.includes('\0')) return null;
|
|
166
161
|
const resolved = path.resolve(path.isAbsolute(raw) ? raw : path.join(shared.MINIONS_DIR, raw));
|
|
167
162
|
const root = path.resolve(MEETING_NOTE_ARTIFACT_ROOT);
|
|
168
|
-
if (!isPathInside(
|
|
163
|
+
if (!shared.isPathInside(resolved, root)) return null;
|
|
169
164
|
if (path.extname(resolved).toLowerCase() !== '.md') return null;
|
|
170
165
|
return resolved;
|
|
171
166
|
}
|
|
@@ -179,7 +174,7 @@ function readMeetingNoteArtifact(artifactPath) {
|
|
|
179
174
|
try {
|
|
180
175
|
const realRoot = fs.realpathSync(MEETING_NOTE_ARTIFACT_ROOT);
|
|
181
176
|
const realPath = fs.realpathSync(resolved);
|
|
182
|
-
if (!isPathInside(
|
|
177
|
+
if (!shared.isPathInside(realPath, realRoot)) {
|
|
183
178
|
log('warn', `Ignoring meeting note artifact outside notes/inbox: ${artifactPath}`);
|
|
184
179
|
return '';
|
|
185
180
|
}
|
|
@@ -914,7 +909,6 @@ module.exports = {
|
|
|
914
909
|
// exported for testing — engine code MUST go through
|
|
915
910
|
// getMeetings/discoverMeetingWork/collectMeetingFindings/checkMeetingTimeouts,
|
|
916
911
|
// never these helpers directly.
|
|
917
|
-
isPathInside,
|
|
918
912
|
resolveMeetingNoteArtifactPath,
|
|
919
913
|
cleanMeetingSummaryText,
|
|
920
914
|
splitMeetingSummaryFragments,
|
package/engine/preflight.js
CHANGED
|
@@ -275,10 +275,9 @@ function runPreflight(opts = {}) {
|
|
|
275
275
|
// 5. worktreeRoot config check — for every linked project, verify that the
|
|
276
276
|
// configured engine.worktreeRoot resolves OUTSIDE the project's
|
|
277
277
|
// localPath. A nested worktreeRoot causes glob/grep to match both
|
|
278
|
-
// copies of every file, producing silent mirror writes
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// is the second line of defense.
|
|
278
|
+
// copies of every file, producing silent mirror writes. Hard-fail at
|
|
279
|
+
// preflight so the operator sees it before any agent dispatch — the
|
|
280
|
+
// runtime guard in spawnAgent is the second line of defense.
|
|
282
281
|
try {
|
|
283
282
|
const path = require('path');
|
|
284
283
|
const projects = shared.getProjects(opts.config) || [];
|
package/engine/shared.js
CHANGED
|
@@ -2143,30 +2143,76 @@ function buildWorktreeDirName({
|
|
|
2143
2143
|
}
|
|
2144
2144
|
|
|
2145
2145
|
/**
|
|
2146
|
-
* True when `childPath` is
|
|
2147
|
-
*
|
|
2148
|
-
*
|
|
2149
|
-
*
|
|
2150
|
-
* escapes via `..`.
|
|
2151
|
-
*
|
|
2152
|
-
* Why this helper exists: a git worktree placed inside the parent repo's
|
|
2153
|
-
* working tree causes glob/grep tools running with `cwd = projectRoot` to
|
|
2154
|
-
* match BOTH copies of every file. A single Edit/MultiEdit then writes the
|
|
2155
|
-
* same change to both locations, producing the "mirror dirty file" pattern.
|
|
2156
|
-
* Worktrees must always be siblings/cousins of the project root, never
|
|
2157
|
-
* descendants.
|
|
2146
|
+
* True when `childPath` is strictly nested within `parentPath` (descendant,
|
|
2147
|
+
* NOT the same path). Cross-platform via `path.relative`; resilient to mixed
|
|
2148
|
+
* separators. Returns false for equal paths, different roots/drives, or
|
|
2149
|
+
* `childPath` escapes via `..`.
|
|
2158
2150
|
*/
|
|
2159
|
-
function
|
|
2151
|
+
function isPathInside(childPath, parentPath) {
|
|
2160
2152
|
if (!childPath || !parentPath) return false;
|
|
2161
2153
|
const childAbs = path.resolve(String(childPath));
|
|
2162
2154
|
const parentAbs = path.resolve(String(parentPath));
|
|
2163
2155
|
const rel = path.relative(parentAbs, childAbs);
|
|
2164
|
-
if (rel === '') return
|
|
2156
|
+
if (rel === '') return false;
|
|
2165
2157
|
if (rel.startsWith('..')) return false;
|
|
2166
2158
|
if (path.isAbsolute(rel)) return false;
|
|
2167
2159
|
return true;
|
|
2168
2160
|
}
|
|
2169
2161
|
|
|
2162
|
+
/**
|
|
2163
|
+
* Same as `isPathInside` but ALSO returns true when paths are equal.
|
|
2164
|
+
*
|
|
2165
|
+
* Why this helper exists: a git worktree placed at — or inside — the parent
|
|
2166
|
+
* repo's working tree causes glob/grep tools running with `cwd = projectRoot`
|
|
2167
|
+
* to match BOTH copies of every file. A single Edit/MultiEdit then writes
|
|
2168
|
+
* the same change to both locations, producing the "mirror dirty file"
|
|
2169
|
+
* pattern. Worktrees must always be siblings/cousins of the project root,
|
|
2170
|
+
* never the root itself nor a descendant.
|
|
2171
|
+
*/
|
|
2172
|
+
function isPathInsideOrEqual(childPath, parentPath) {
|
|
2173
|
+
if (!childPath || !parentPath) return false;
|
|
2174
|
+
const childAbs = path.resolve(String(childPath));
|
|
2175
|
+
const parentAbs = path.resolve(String(parentPath));
|
|
2176
|
+
if (childAbs === parentAbs) return true;
|
|
2177
|
+
return isPathInside(childAbs, parentAbs);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/**
|
|
2181
|
+
* Parse `git worktree list --porcelain` output. Pure — callers run the
|
|
2182
|
+
* subprocess (sync `execSilent` or async `execAsync`) and feed stdout in.
|
|
2183
|
+
*
|
|
2184
|
+
* Returns `[{path, head, branch, bare, detached, locked, prunable}]`.
|
|
2185
|
+
* `branch` strips the `refs/heads/` prefix; flag fields default to false.
|
|
2186
|
+
*
|
|
2187
|
+
* Porcelain format: each worktree is a block of `key value\n` lines
|
|
2188
|
+
* terminated by a blank line. First line of every block is `worktree <path>`
|
|
2189
|
+
* (always present). Optional follow-ups: `HEAD <sha>`, `branch refs/heads/<name>`,
|
|
2190
|
+
* and the flag lines `bare`, `detached`, `locked [<reason>]`, `prunable [<reason>]`.
|
|
2191
|
+
*/
|
|
2192
|
+
function parseWorktreePorcelain(raw) {
|
|
2193
|
+
const trees = [];
|
|
2194
|
+
if (!raw) return trees;
|
|
2195
|
+
let current = null;
|
|
2196
|
+
const flush = () => { if (current) { trees.push(current); current = null; } };
|
|
2197
|
+
for (const line of String(raw).split(/\r?\n/)) {
|
|
2198
|
+
if (line === '') { flush(); continue; }
|
|
2199
|
+
if (line.startsWith('worktree ')) {
|
|
2200
|
+
flush();
|
|
2201
|
+
current = { path: line.slice('worktree '.length).trim(), head: '', branch: '', bare: false, detached: false, locked: false, prunable: false };
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
if (!current) continue;
|
|
2205
|
+
if (line.startsWith('HEAD ')) current.head = line.slice('HEAD '.length).trim();
|
|
2206
|
+
else if (line.startsWith('branch ')) current.branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
|
|
2207
|
+
else if (line === 'bare') current.bare = true;
|
|
2208
|
+
else if (line === 'detached') current.detached = true;
|
|
2209
|
+
else if (line === 'locked' || line.startsWith('locked ')) current.locked = true;
|
|
2210
|
+
else if (line === 'prunable' || line.startsWith('prunable ')) current.prunable = true;
|
|
2211
|
+
}
|
|
2212
|
+
flush();
|
|
2213
|
+
return trees;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2170
2216
|
/**
|
|
2171
2217
|
* Throws when `worktreePath` would land inside (or equal) `projectRoot`.
|
|
2172
2218
|
* Called by the engine spawn path before `git worktree add`, and by the
|
|
@@ -3257,7 +3303,9 @@ module.exports = {
|
|
|
3257
3303
|
sanitizePath,
|
|
3258
3304
|
sanitizeBranch,
|
|
3259
3305
|
buildWorktreeDirName, // exported for testing
|
|
3306
|
+
isPathInside,
|
|
3260
3307
|
isPathInsideOrEqual,
|
|
3308
|
+
parseWorktreePorcelain,
|
|
3261
3309
|
assertWorktreeOutsideProject,
|
|
3262
3310
|
isLiveCommandCenterPath,
|
|
3263
3311
|
describeCcProtectedPaths,
|
package/engine.js
CHANGED
|
@@ -474,20 +474,8 @@ async function syncReusedWorktree(rootDir, worktreePath, branchName, gitOpts = {
|
|
|
474
474
|
async function findExistingWorktree(repoDir, branchName) {
|
|
475
475
|
try {
|
|
476
476
|
const out = await execAsync(`git worktree list --porcelain`, { cwd: repoDir, timeout: 10000 });
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
for (let i = 0; i < lines.length; i++) {
|
|
480
|
-
if (lines[i].trim() === branchRef) {
|
|
481
|
-
// Walk back to find the worktree path
|
|
482
|
-
for (let j = i - 1; j >= 0; j--) {
|
|
483
|
-
if (lines[j].startsWith('worktree ')) {
|
|
484
|
-
const wtPath = lines[j].slice('worktree '.length).trim();
|
|
485
|
-
if (fs.existsSync(wtPath)) return wtPath;
|
|
486
|
-
break;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
}
|
|
477
|
+
const found = shared.parseWorktreePorcelain(out).find(w => w.branch === branchName);
|
|
478
|
+
if (found && fs.existsSync(found.path)) return found.path;
|
|
491
479
|
} catch (e) { log('warn', 'git: ' + e.message); }
|
|
492
480
|
return null;
|
|
493
481
|
}
|
|
@@ -666,6 +654,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
666
654
|
if (eShared.message?.includes('already used by worktree') || eShared.message?.includes('already checked out')) {
|
|
667
655
|
const existingWtPath = await findExistingWorktree(rootDir, branchName);
|
|
668
656
|
if (existingWtPath && fs.existsSync(existingWtPath)) {
|
|
657
|
+
shared.assertWorktreeOutsideProject(existingWtPath, rootDir);
|
|
669
658
|
log('info', `Shared branch ${branchName} already checked out at ${existingWtPath} — reusing`);
|
|
670
659
|
worktreePath = existingWtPath;
|
|
671
660
|
} else { throw eShared; }
|
|
@@ -723,6 +712,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
723
712
|
log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
|
|
724
713
|
throw e2;
|
|
725
714
|
}
|
|
715
|
+
shared.assertWorktreeOutsideProject(existingWtPath, rootDir);
|
|
726
716
|
log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
|
|
727
717
|
worktreePath = existingWtPath;
|
|
728
718
|
} else if (existingWtPath && !fs.existsSync(existingWtPath)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1782",
|
|
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"
|