@yemi33/minions 0.1.2199 → 0.1.2201

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/dashboard.js CHANGED
@@ -1579,10 +1579,11 @@ function _archivePrdPostProcess({ planFile, archivePath, planPath, plansDir, _mu
1579
1579
  try {
1580
1580
  const mdPath = path.join(plansDir, plan.source_plan);
1581
1581
  if (fs.existsSync(mdPath)) {
1582
- const planArchive = path.join(plansDir, 'archive');
1583
- if (!fs.existsSync(planArchive)) fs.mkdirSync(planArchive, { recursive: true });
1584
- fs.renameSync(mdPath, path.join(planArchive, plan.source_plan));
1585
- archivedSource = plan.source_plan;
1582
+ // DATA-LOSS GUARD: moveFileNoClobber dedupes + retries so archiving a
1583
+ // source plan can't overwrite a previously-archived plan of the same
1584
+ // basename (and survives transient Windows file locks).
1585
+ const mdDest = shared.moveFileNoClobber(mdPath, path.join(plansDir, 'archive'), plan.source_plan);
1586
+ archivedSource = path.basename(mdDest);
1586
1587
  }
1587
1588
  } catch (e) {
1588
1589
  const warning = `Archive could not move source plan ${plan.source_plan}: ${e.message}`;
@@ -7033,7 +7034,10 @@ const server = http.createServer(async (req, res) => {
7033
7034
  const file = body.file || 'notes.md';
7034
7035
  // Only allow saving notes.md (prevent arbitrary file writes)
7035
7036
  if (file !== 'notes.md') return jsonReply(res, 400, { error: 'only notes.md can be edited' });
7036
- safeWrite(path.join(MINIONS_DIR, file), body.content);
7037
+ // DATA-LOSS GUARD: write under the same lock the engine consolidation
7038
+ // path holds (withFileLock(notes.md.lock)) so a concurrent consolidation
7039
+ // flush in the engine process can't lose-update this save (and vice-versa).
7040
+ shared.mutateTextFileLocked(path.join(MINIONS_DIR, file), () => body.content);
7037
7041
  return jsonReply(res, 200, { ok: true });
7038
7042
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
7039
7043
  }
@@ -7774,9 +7778,12 @@ const server = http.createServer(async (req, res) => {
7774
7778
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
7775
7779
 
7776
7780
  const archiveDir = isPrd ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
7777
- if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
7778
- const archivePath = path.join(archiveDir, body.file);
7779
- fs.renameSync(planPath, archivePath);
7781
+ // DATA-LOSS GUARD: moveFileNoClobber dedupes the destination so a same-
7782
+ // basename collision (e.g. re-archiving a project's canonical
7783
+ // <project>-<date> name, or the W-mq8qdai6 live↔archive collision class)
7784
+ // can't silently destroy a previously-archived plan/PRD + its completed
7785
+ // work-item history. Bumps to -2/-3 instead of overwriting.
7786
+ const archivePath = shared.moveFileNoClobber(planPath, archiveDir, body.file);
7780
7787
 
7781
7788
  let archivedSource = null;
7782
7789
  let plan = {};
@@ -7839,9 +7846,11 @@ const server = http.createServer(async (req, res) => {
7839
7846
  console.warn(warning);
7840
7847
  continue;
7841
7848
  }
7842
- const prdArchivePath = path.join(prdArchiveDir, prdFile);
7849
+ let prdArchivePath;
7843
7850
  try {
7844
- fs.renameSync(prdLivePath, prdArchivePath);
7851
+ // DATA-LOSS GUARD: dedupe + retry so the cascade can't clobber a
7852
+ // previously-archived PRD of the same basename.
7853
+ prdArchivePath = shared.moveFileNoClobber(prdLivePath, prdArchiveDir, prdFile);
7845
7854
  } catch (e) {
7846
7855
  const warning = `Archive could not move PRD ${prdFile}: ${e.message}`;
7847
7856
  archiveWarnings.push(warning);
@@ -7906,18 +7915,21 @@ const server = http.createServer(async (req, res) => {
7906
7915
  const targetDir = isJson ? PRD_DIR : PLANS_DIR;
7907
7916
  const archivePath = path.join(targetDir, 'archive', body.file);
7908
7917
  if (!fs.existsSync(archivePath)) return jsonReply(res, 404, { error: 'File not found in archive' });
7909
- fs.renameSync(archivePath, path.join(targetDir, body.file));
7918
+ // DATA-LOSS GUARD: restoring from archive must not clobber a LIVE plan/PRD
7919
+ // of the same basename (renameSync overwrites). moveFileNoClobber bumps
7920
+ // the restored name instead so the existing live file survives.
7921
+ const liveDest = shared.moveFileNoClobber(archivePath, targetDir, body.file);
7910
7922
 
7911
7923
  // Also unarchive linked source plan
7912
7924
  let unarchivedSource = null;
7913
7925
  if (isJson) {
7914
7926
  try {
7915
- const prd = safeJson(path.join(targetDir, body.file));
7927
+ const prd = safeJson(liveDest);
7916
7928
  if (prd?.source_plan) {
7917
7929
  const mdArchivePath = path.join(PLANS_DIR, 'archive', prd.source_plan);
7918
7930
  if (fs.existsSync(mdArchivePath)) {
7919
- fs.renameSync(mdArchivePath, path.join(PLANS_DIR, prd.source_plan));
7920
- unarchivedSource = prd.source_plan;
7931
+ const liveMdDest = shared.moveFileNoClobber(mdArchivePath, PLANS_DIR, prd.source_plan);
7932
+ unarchivedSource = path.basename(liveMdDest);
7921
7933
  }
7922
7934
  }
7923
7935
  } catch { /* optional */ }
@@ -7925,7 +7937,13 @@ const server = http.createServer(async (req, res) => {
7925
7937
 
7926
7938
  invalidateStatusCache();
7927
7939
  invalidatePlansCache();
7928
- return jsonReply(res, 200, { ok: true, unarchivedSource });
7940
+ // `restoredAs` reflects the actual restored basename: moveFileNoClobber
7941
+ // bumps to <name>-2 when a live file of the same name already exists, so
7942
+ // the client isn't misled into expecting body.file when it differs.
7943
+ const restoredAs = path.basename(liveDest);
7944
+ const payload = { ok: true, unarchivedSource };
7945
+ if (restoredAs !== body.file) payload.restoredAs = restoredAs;
7946
+ return jsonReply(res, 200, payload);
7929
7947
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
7930
7948
  }
7931
7949
 
@@ -8384,19 +8402,25 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8384
8402
 
8385
8403
  // Append to notes.md as a new team note
8386
8404
  const notesPath = path.join(MINIONS_DIR, 'notes.md');
8387
- let notes = safeRead(notesPath) || '# Minions Notes\n\n## Active Notes\n';
8388
8405
  const today = new Date().toISOString().slice(0, 10);
8389
8406
  const entry = `\n### ${today}: ${title}\n**By:** Persisted from inbox (${name})\n**What:** ${content.slice(0, 500)}\n\n---\n`;
8390
-
8391
- const marker = '## Active Notes';
8392
- const idx = notes.indexOf(marker);
8393
- if (idx !== -1) {
8394
- const insertAt = idx + marker.length;
8395
- notes = notes.slice(0, insertAt) + '\n' + entry + notes.slice(insertAt);
8396
- } else {
8397
- notes += '\n' + entry;
8398
- }
8399
- safeWrite(notesPath, notes);
8407
+ // DATA-LOSS GUARD: read-modify-write notes.md under the same lock the
8408
+ // engine consolidation path holds. The previous unlocked read→splice→write
8409
+ // could lose-update against a concurrent consolidation flush (the engine
8410
+ // and dashboard are separate processes), silently dropping one note.
8411
+ const NOTES_DEFAULT = '# Minions Notes\n\n## Active Notes\n';
8412
+ shared.mutateTextFileLocked(notesPath, (prev) => {
8413
+ let notes = prev || NOTES_DEFAULT;
8414
+ const marker = '## Active Notes';
8415
+ const idx = notes.indexOf(marker);
8416
+ if (idx !== -1) {
8417
+ const insertAt = idx + marker.length;
8418
+ notes = notes.slice(0, insertAt) + '\n' + entry + notes.slice(insertAt);
8419
+ } else {
8420
+ notes += '\n' + entry;
8421
+ }
8422
+ return notes;
8423
+ }, { defaultValue: NOTES_DEFAULT });
8400
8424
 
8401
8425
  // W-mq1j85cj00055a8f — rewrite WI references that pointed at the
8402
8426
  // now-archived inbox note to the persisted destination (notes.md).
package/engine/cli.js CHANGED
@@ -1211,6 +1211,8 @@ const commands = {
1211
1211
 
1212
1212
  process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
1213
1213
  process.on('SIGINT', () => gracefulShutdown('SIGINT'));
1214
+ // WAL checkpoint-on-exit is registered by engine/db getDb() itself (covers
1215
+ // the engine daemon, dashboard, and CLI uniformly) — no hook needed here.
1214
1216
 
1215
1217
  // Crash handlers — log the error and exit cleanly so the process is detectable as crashed
1216
1218
  process.on('unhandledRejection', (reason) => {
@@ -20,6 +20,20 @@ const fs = require('fs');
20
20
  let _db = null;
21
21
  let _dbPath = null;
22
22
  let _dbInitError = null;
23
+ let _exitCheckpointRegistered = false;
24
+
25
+ // DATA-LOSS GUARD: register a one-time process-exit hook that checkpoints +
26
+ // closes the DB. Registered by getDb() on first successful open so EVERY DB
27
+ // opener (engine daemon, dashboard, CLI) gets WAL durability on exit — not just
28
+ // the engine CLI path. Idempotent: closeDb()'s `if (_db)` guard makes a later
29
+ // graceful-shutdown close a no-op. Synchronous-only work (node:sqlite is sync),
30
+ // which is all `process.on('exit')` permits.
31
+ function _registerExitCheckpoint() {
32
+ if (_exitCheckpointRegistered) return;
33
+ _exitCheckpointRegistered = true;
34
+ try { process.on('exit', () => { try { closeDb(); } catch { /* best-effort */ } }); }
35
+ catch { /* env without process events */ }
36
+ }
23
37
 
24
38
  function _resolveDbPath() {
25
39
  // Lazy-require shared/queries so this module can be safely required
@@ -129,6 +143,7 @@ function getDb() {
129
143
  if (lastErr) throw lastErr;
130
144
  const { runMigrations } = require('./migrate');
131
145
  runMigrations(_db);
146
+ _registerExitCheckpoint();
132
147
  return _db;
133
148
  } catch (e) {
134
149
  const nodeMajor = parseInt(String(process.versions.node).split('.')[0], 10);
@@ -142,7 +157,16 @@ function getDb() {
142
157
 
143
158
  // Force-close — used by tests + graceful shutdown. Safe to call when not open.
144
159
  function closeDb() {
145
- if (_db) { try { _db.close(); } catch { /* already closed */ } _db = null; }
160
+ if (_db) {
161
+ // DATA-LOSS GUARD: under journal_mode=WAL + synchronous=NORMAL, committed
162
+ // transactions can live only in the -wal file until a checkpoint folds them
163
+ // into the main DB. Checkpoint on graceful close so a host crash / power loss
164
+ // after shutdown can't drop the most recent commits (and so the WAL doesn't
165
+ // grow unbounded). Best-effort — a failed checkpoint must not block close.
166
+ try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* best-effort */ }
167
+ try { _db.close(); } catch { /* already closed */ }
168
+ _db = null;
169
+ }
146
170
  _dbPath = null;
147
171
  _dbInitError = null;
148
172
  }
@@ -101,10 +101,8 @@ function _readDispatchJsonFallback() {
101
101
  // Surface to logs the same way the legacy queries.getDispatch path
102
102
  // did via shared.readJsonNoRestore. (Phase 1 contract test
103
103
  // "getDispatch warns on corrupt dispatch.json".)
104
- try {
105
- // eslint-disable-next-line no-console
106
- console.warn(`[dispatch-store] corrupt JSON in ${dispatchPath}: ${e.message}`);
107
- } catch { /* console may be wrapped in tests */ }
104
+ // eslint-disable-next-line no-console
105
+ console.warn(`[dispatch-store] corrupt JSON in ${dispatchPath}: ${e.message}`);
108
106
  return _emptySectioned();
109
107
  }
110
108
  }
@@ -432,8 +432,11 @@ function archivePlan(planFile, plan, projects, config) {
432
432
  if (!fs.existsSync(prdArchiveDir)) fs.mkdirSync(prdArchiveDir, { recursive: true });
433
433
  try {
434
434
  if (fs.existsSync(planPath)) {
435
- fs.renameSync(planPath, path.join(prdArchiveDir, planFile));
436
- log('info', `Archived completed PRD: prd/archive/${planFile}`);
435
+ // DATA-LOSS GUARD: moveFileNoClobber dedupes + retries so a same-basename
436
+ // collision can't silently overwrite a previously-archived PRD (+ its
437
+ // completed work-item history). renameSync clobbers on Windows.
438
+ const prdDest = shared.moveFileNoClobber(planPath, prdArchiveDir, planFile);
439
+ log('info', `Archived completed PRD: prd/archive/${path.basename(prdDest)}`);
437
440
  }
438
441
  // Remove .backup sidecar — if left behind, safeJson() would restore the pre-completion
439
442
  // snapshot (status: approved, no _completionNotified) on engine restart, re-triggering
@@ -457,8 +460,8 @@ function archivePlan(planFile, plan, projects, config) {
457
460
  const sourcePlanName = plan.source_plan || planFile.replace(/\.json$/, '.md');
458
461
  if (sourcePlanName && fs.existsSync(path.join(PLANS_DIR, sourcePlanName))) {
459
462
  try {
460
- fs.renameSync(path.join(PLANS_DIR, sourcePlanName), path.join(planArchiveDir, sourcePlanName));
461
- log('info', `Archived source plan: plans/archive/${sourcePlanName}`);
463
+ const mdDest = shared.moveFileNoClobber(path.join(PLANS_DIR, sourcePlanName), planArchiveDir, sourcePlanName);
464
+ log('info', `Archived source plan: plans/archive/${path.basename(mdDest)}`);
462
465
  } catch (err) { log('warn', `Failed to archive plan ${sourcePlanName}: ${err.message}`); }
463
466
  } else {
464
467
  // Fallback: match by content
@@ -467,8 +470,8 @@ function archivePlan(planFile, plan, projects, config) {
467
470
  const mdContent = shared.safeRead(path.join(PLANS_DIR, md)) || '';
468
471
  if (mdContent.includes(projectName) || mdContent.includes(plan.plan_summary?.slice(0, 40) || '___nomatch___')) {
469
472
  try {
470
- fs.renameSync(path.join(PLANS_DIR, md), path.join(planArchiveDir, md));
471
- log('info', `Archived source plan: plans/archive/${md}`);
473
+ const mdDest = shared.moveFileNoClobber(path.join(PLANS_DIR, md), planArchiveDir, md);
474
+ log('info', `Archived source plan: plans/archive/${path.basename(mdDest)}`);
472
475
  } catch (err) { log('warn', `Failed to archive plan ${md}: ${err.message}`); }
473
476
  break;
474
477
  }
@@ -298,6 +298,13 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
298
298
  'existing_prd_json', // only set when re-running plan-to-prd over an existing PRD
299
299
  'branch_strategy_hint', // only set for shared-branch plans
300
300
  'review_note', // only set on fix/review tasks tied to a comment
301
+ // W-mqh08n0u002ha5c4 — shared-branch carve-out flag. Set to '1' by
302
+ // renderProjectWorkItemPromptForAgent only when the dispatched item is on a
303
+ // shared-branch plan; '' otherwise. Drives the {{#shared_branch}} /
304
+ // {{^shared_branch}} blocks in docs.md / test.md that suppress premature PR
305
+ // creation. Optional because every other playbook ignores it and the
306
+ // default state is the empty string (renders the non-shared branch).
307
+ 'shared_branch',
301
308
  // P-4d6e2af3 — comma-separated list of target projects for cross-repo
302
309
  // plans. Only set in engine.js's WORK_TYPE.PLAN branch when the WI carries
303
310
  // item._targetProjects (≥2 entries from dashboard.js#buildPlanWorkItem).
@@ -261,8 +261,10 @@ function removeProject(target, options = {}) {
261
261
  const filenameMatch = f.toLowerCase().includes(lowerName);
262
262
  const content = filenameMatch ? '' : fs.readFileSync(fp, 'utf8');
263
263
  if (!filenameMatch && !projectLineRe.test(content)) continue;
264
- fs.renameSync(fp, path.join(plansArchive, f));
265
- summary.archivedPlans.push('plans/' + f);
264
+ // DATA-LOSS GUARD: dedupe + retry so re-removing a project (same plan
265
+ // basename) can't overwrite a previously-archived plan.
266
+ const planDest = shared.moveFileNoClobber(fp, plansArchive, f);
267
+ summary.archivedPlans.push('plans/' + path.basename(planDest));
266
268
  } catch { /* skip unreadable plan */ }
267
269
  }
268
270
  }
package/engine/shared.js CHANGED
@@ -1872,6 +1872,30 @@ function uniquePath(filePath) {
1872
1872
  return `${base}-${Date.now()}${ext}`;
1873
1873
  }
1874
1874
 
1875
+ /**
1876
+ * Move a file into `destDir` WITHOUT ever overwriting an existing file there —
1877
+ * the canonical archive/restore data-loss guard. `fs.renameSync` clobbers its
1878
+ * destination (silently, on Windows especially), so archiving a plan/PRD whose
1879
+ * basename already exists in the archive dir, or restoring over a live file of
1880
+ * the same name, would destroy the existing file (+ its history). This dedupes
1881
+ * the destination basename via `uniquePath` (-2/-3/...) and wraps the rename in
1882
+ * `_retryFsOp` so a transient Windows EPERM/EBUSY (AV / indexer lock) doesn't
1883
+ * fail the move. Creates `destDir` if missing. Returns the final destination
1884
+ * path actually written (caller derives the basename for logs/records).
1885
+ *
1886
+ * @param {string} srcPath - file to move (caller ensures it exists)
1887
+ * @param {string} destDir - directory to move it into
1888
+ * @param {string} [destName=path.basename(srcPath)] - basename to use at dest
1889
+ * @returns {string} absolute destination path the file now lives at
1890
+ */
1891
+ function moveFileNoClobber(srcPath, destDir, destName) {
1892
+ const name = destName || path.basename(srcPath);
1893
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
1894
+ const dest = uniquePath(path.join(destDir, name));
1895
+ _retryFsOp(() => fs.renameSync(srcPath, dest), `move ${name} -> archive`);
1896
+ return dest;
1897
+ }
1898
+
1875
1899
  function truncateTextBytes(text, maxBytes, suffix = '') {
1876
1900
  const value = text == null ? '' : String(text);
1877
1901
  if (!maxBytes || maxBytes <= 0) return '';
@@ -2530,7 +2554,6 @@ const ENGINE_DEFAULTS = {
2530
2554
  assertCleanStatusTimeoutMs: 10000,
2531
2555
  workItemCreateDedupWindowMs: 15 * 60 * 1000, // 15min — collapse duplicate CC/API create races
2532
2556
  idleAlertMinutes: 15,
2533
- fanOutTimeout: null, // falls back to agentTimeout
2534
2557
  restartGracePeriod: 1200000, // 20min
2535
2558
  shutdownTimeout: 300000, // 5min — max wait for active agents during graceful shutdown
2536
2559
  allowTempAgents: false, // opt-in: spawn ephemeral agents when all permanent agents are busy
@@ -8214,6 +8237,7 @@ module.exports = {
8214
8237
  mutatePullRequests,
8215
8238
  uid,
8216
8239
  uniquePath,
8240
+ moveFileNoClobber,
8217
8241
  isPlainObject,
8218
8242
  truncateTextBytes,
8219
8243
  tailTextBytes,
@@ -117,13 +117,12 @@ function _resyncScopeIfJsonDiverged(db, scope) {
117
117
  const lastHash = _lastMirrorHashByScope.get(scope);
118
118
  if (currentHash == null) return;
119
119
  if (lastHash != null && currentHash === lastHash) return;
120
- if (lastHash == null) {
121
- const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
122
- if (sqlHas) {
123
- _lastMirrorHashByScope.set(scope, currentHash);
124
- return;
125
- }
126
- }
120
+ // First touch in this process (lastHash == null): if SQL already holds rows
121
+ // for the scope, SQL is the established source of truth (post-Phase-9.4)
122
+ // adopt the current JSON hash and DO NOT hydrate. Hydrating here would let a
123
+ // crash-stale / .backup-restored / git-checked-out older JSON mirror DELETE +
124
+ // overwrite newer SQL. Only an empty-in-SQL scope (genuine first-install
125
+ // migration) falls through to hydrate-from-JSON, which is purely additive.
127
126
  if (lastHash == null) {
128
127
  const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
129
128
  if (sqlHas) {
package/engine.js CHANGED
@@ -6026,6 +6026,8 @@ async function discoverFromPrs(config, project) {
6026
6026
  const autoFixPaused = config.engine?.autoFixPaused === true;
6027
6027
  const autoFixReviewFeedback = !autoFixPaused && (config.engine?.autoFixReviewFeedback ?? ENGINE_DEFAULTS.autoFixReviewFeedback);
6028
6028
  const autoFixHumanComments = !autoFixPaused && (config.engine?.autoFixHumanComments ?? ENGINE_DEFAULTS.autoFixHumanComments);
6029
+ const autoFixBuilds = !autoFixPaused && (config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds);
6030
+ const autoFixConflicts = !autoFixPaused && (config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts);
6029
6031
 
6030
6032
  // Collect active PR dispatches to prevent simultaneous review+fix on same PR
6031
6033
  const dispatch = getDispatch();
@@ -6407,7 +6409,6 @@ async function discoverFromPrs(config, project) {
6407
6409
  const gracePeriodMs = config.engine?.buildFixGracePeriod ?? ENGINE_DEFAULTS.buildFixGracePeriod;
6408
6410
  if (Date.now() - new Date(pr._buildFixPushedAt).getTime() < gracePeriodMs) continue;
6409
6411
  }
6410
- const autoFixBuilds = !autoFixPaused && (config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds);
6411
6412
  if (pollEnabled && autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing'
6412
6413
  && !fixDispatched
6413
6414
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.BUILD_FAILURE)) {
@@ -6568,7 +6569,6 @@ async function discoverFromPrs(config, project) {
6568
6569
  }
6569
6570
 
6570
6571
  // PRs with merge conflicts — dispatch fix to resolve (gated by provider polling + autoFixConflicts)
6571
- const autoFixConflicts = !autoFixPaused && (config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts);
6572
6572
  if (pollEnabled && autoFixConflicts && pr.status === PR_STATUS.ACTIVE && pr._mergeConflict && !fixDispatched
6573
6573
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.MERGE_CONFLICT)) {
6574
6574
  // W-mpritzcr0004afc5 (#2955): "don't fan out a parallel conflict-fix
@@ -6773,6 +6773,17 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
6773
6773
  project_path: root,
6774
6774
  worktree_path: worktreePath,
6775
6775
  commit_message: item.commitMessage || `feat: ${item.title || item.id}`,
6776
+ // W-mqh08n0u002ha5c4 — shared-branch carve-out flag. On a shared-branch
6777
+ // plan (branch_strategy: shared-branch) NO PR is opened until all plan
6778
+ // items complete — the engine/verify flow opens the single aggregate PR.
6779
+ // implement items already route to implement-shared.md (which omits PR
6780
+ // creation); this flag lets non-implement playbooks (docs.md, test.md)
6781
+ // suppress their own premature PR-creation step the same way via
6782
+ // {{#shared_branch}} / {{^shared_branch}} conditional blocks. Truthy only
6783
+ // when the item is genuinely on a shared branch (mirrors selectPlaybook's
6784
+ // implement→implement-shared gate at engine/playbook.js). Default '' for
6785
+ // parallel/PR-targeted dispatches keeps existing per-type PR behavior.
6786
+ shared_branch: (item.branchStrategy === 'shared-branch' && item.featureBranch) ? '1' : '',
6776
6787
  notes_content: '',
6777
6788
  pr_id: item.pr_id || item._pr || item.targetPr || item.sourcePr || item.pr || '',
6778
6789
  pr_number: item.prNumber || item.pr_number || '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2199",
3
+ "version": "0.1.2201",
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"
package/playbooks/docs.md CHANGED
@@ -90,6 +90,20 @@ the engine handles cleanup automatically.
90
90
  Commit only the doc files (and any helper assets they reference). Do not bundle
91
91
  unrelated code changes into a docs PR.
92
92
 
93
+ {{#shared_branch}}
94
+ ```bash
95
+ git add <doc files>
96
+ git commit -m "{{commit_message}}"
97
+ git push origin {{branch_name}}
98
+ ```
99
+
100
+ This is part of a **shared-branch plan** — other plan items share `{{branch_name}}`.
101
+ Push your commit to the shared branch and STOP. Do **NOT** create a PR — the engine
102
+ opens one aggregate PR automatically when all plan items complete. Opening a PR now
103
+ would be premature (other items are still in flight) and the early branch would collect
104
+ merge conflicts from concurrent sibling pushes.
105
+ {{/shared_branch}}
106
+ {{^shared_branch}}
93
107
  ```bash
94
108
  git add <doc files>
95
109
  git commit -m "{{commit_message}}"
@@ -99,6 +113,7 @@ git push -u origin {{branch_name}}
99
113
  Create the PR for docs tasks — docs go through the same review flow as code.
100
114
  Use the appropriate repo-host tooling for PR creation. For Azure DevOps, prefer the
101
115
  `az` CLI first and use the ADO MCP only as a fallback.
116
+ {{/shared_branch}}
102
117
 
103
118
  ## Rules
104
119
 
@@ -109,9 +124,17 @@ Use the appropriate repo-host tooling for PR creation. For Azure DevOps, prefer
109
124
 
110
125
  ## When to Stop
111
126
 
127
+ {{#shared_branch}}
128
+ Your task is complete once the doc accurately reflects current code, any doc-validation
129
+ tests pass, and your commit is pushed to the shared branch `{{branch_name}}`. Do NOT
130
+ create a PR — the engine creates one when all plan items are done. Do not continue
131
+ editing adjacent docs that weren't part of the task.
132
+ {{/shared_branch}}
133
+ {{^shared_branch}}
112
134
  Your task is complete once the doc accurately reflects current code, the PR is created
113
135
  with the changed doc files, and any doc-validation tests pass. Do not continue editing
114
136
  adjacent docs that weren't part of the task.
137
+ {{/shared_branch}}
115
138
 
116
139
  ## Team Decisions
117
140
  {{notes_content}}
@@ -232,6 +232,12 @@ gh pr review <number> --comment --body-file <verdict.md> --repo OWNER/REPO # f
232
232
 
233
233
  The marker is an HTML comment on its own line — GitHub's renderer strips it before display, so reviewers see nothing. The body round-trips verbatim through the API so the engine can match it.
234
234
 
235
+ **Brand link (MANDATORY).** Whenever the visible body of a PR comment or review names "Minions" — e.g. "Fixed by Minions", "Reviewed by Minions", "Minions rebased this branch" — render that word as a markdown hyperlink to the Minions site:
236
+ ```
237
+ [Minions](https://icy-water-0224cc51e.2.azurestaticapps.net/minions)
238
+ ```
239
+ So write `Fixed by [Minions](https://icy-water-0224cc51e.2.azurestaticapps.net/minions)`, not a bare `Fixed by Minions`. This applies to GitHub and ADO comments alike. Do **not** link occurrences inside the HTML marker line, code blocks, or file paths — only the human-readable prose mention. Link the first occurrence per comment; subsequent repeats in the same comment may stay plain.
240
+
235
241
  ## GitHub Tooling and Auth
236
242
 
237
243
  For GitHub repo operations, use GitHub MCP tools or the `gh` CLI. Prefer commands such as `gh pr create`, `gh pr view`, `gh pr comment`, `gh pr review --comment`, `gh issue view`, and `gh run view`. **For comment / review posts, always go through `minions pr comment` or include the marker template above.**
package/playbooks/test.md CHANGED
@@ -31,7 +31,12 @@ Builds, tests, dependency installs, and server startups can be silent for severa
31
31
 
32
32
  Work from the current project checkout prepared by the engine. Follow the repo's own instructions (`CLAUDE.md`, README, package files, Makefiles, project scripts) and run the smallest sensible set of commands that proves the requested behavior.
33
33
 
34
+ {{#shared_branch}}
35
+ This task is part of a **shared-branch plan** — other plan items share `{{branch_name}}`. If the task asks you to add or modify files, commit those focused changes and push them to the shared branch (`git push origin {{branch_name}}`), then STOP. Do **NOT** create a PR — the engine opens one aggregate PR automatically when all plan items complete. Creating a PR now would be premature and the early branch would collect merge conflicts from concurrent sibling pushes. For pure build/run/verify tasks, do not push.
36
+ {{/shared_branch}}
37
+ {{^shared_branch}}
34
38
  If the task asks you to add or modify files, commit those focused changes, push the branch, and create a PR using the same conventions as implement tasks. For pure build/run/verify tasks, do not push or create a PR.
39
+ {{/shared_branch}}
35
40
 
36
41
  If a build or test fails, report the error clearly instead of fixing it unless the task explicitly asks for a fix.
37
42