@yemi33/minions 0.1.2297 → 0.1.2299

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
@@ -11444,25 +11444,19 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11444
11444
 
11445
11445
  // W-mq03l6zh0006f0a1-d — read-only diagnostics surface for the per-org ADO
11446
11446
  // throttle tracker. Returns { orgs: { [orgBase]: { throttled, retryAfter,
11447
- // consecutiveHits } } }. Prefers the per-org getter ado.getAdoThrottleStateAll
11448
- // when present (introduced by W-mq03l6zh0006f0a1-b). Falls back to the
11449
- // process-global ado.getAdoThrottleState() under the synthetic key `global`
11450
- // when the per-org getter is not present, so the endpoint stays live across
11451
- // the staged rollout of the per-org isolation work.
11447
+ // consecutiveHits } } }. Uses getAdoThrottleState().perOrg, the per-org map
11448
+ // exposed by the aggregate getter (W-mq03l6zh0006f0a1-b).
11452
11449
  async function handleDiagnosticsAdoThrottle(req, res) {
11453
11450
  try {
11454
11451
  let orgs = {};
11455
- if (typeof ado.getAdoThrottleStateAll === 'function') {
11456
- const all = ado.getAdoThrottleStateAll() || {};
11452
+ if (typeof ado.getAdoThrottleState === 'function') {
11453
+ const all = ado.getAdoThrottleState().perOrg || {};
11457
11454
  // Defensive copy — handler must never expose internal mutable state.
11458
11455
  for (const [k, v] of Object.entries(all)) {
11459
11456
  if (v && typeof v === 'object') {
11460
11457
  orgs[k] = { throttled: !!v.throttled, retryAfter: Number(v.retryAfter) || 0, consecutiveHits: Number(v.consecutiveHits) || 0 };
11461
11458
  }
11462
11459
  }
11463
- } else if (typeof ado.getAdoThrottleState === 'function') {
11464
- const v = ado.getAdoThrottleState() || {};
11465
- orgs.global = { throttled: !!v.throttled, retryAfter: Number(v.retryAfter) || 0, consecutiveHits: Number(v.consecutiveHits) || 0 };
11466
11460
  }
11467
11461
  return jsonReply(res, 200, { orgs });
11468
11462
  } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
@@ -81,6 +81,7 @@ Do **not** invent, regenerate, or share the nonce across dispatches — each spa
81
81
  | `noop` | boolean | Canonical no-op signal. See [No-op semantics](#no-op-semantics). |
82
82
  | `noopReason` | string | Human-readable rationale shown when `noop: true`. Falls back to `summary` if absent. |
83
83
  | `files_changed` | string \| array | Comma-separated list (or array) of key files changed. |
84
+ | `affected_files` | string[] | Optional array of relative file paths this dispatch touched or plans to touch. Used by the dispatcher to emit a conflict warning (`WI <new-id> may conflict with in-progress <existing-id> on files: [list]`) when a new WI's `affected_files` overlaps with an in-progress WI's `affected_files`. Logging-only — dispatch is never blocked. Stored back onto the work item after completion so re-dispatches can also participate in overlap detection. |
84
85
  | `tests` | string | `pass`, `fail`, `skipped`, `N/A`, or a free-form note like `skipped — relying on PR pipeline`. |
85
86
  | `pending` | string | Any remaining work, or `none`. |
86
87
  | `followups` | array | Optional. PR-comment follow-up work items the agent dispatched via `POST /api/work-items` with `meta.pr_followup` set. Each entry: `{wi_id, title, reason, parent_comment_id}`. See [PR-comment follow-ups](#pr-comment-follow-ups). |
package/engine/ado.js CHANGED
@@ -2683,14 +2683,6 @@ const getAdoThrottleState = (orgBase) => {
2683
2683
  return { throttled, retryAfter, consecutiveHits, perOrg };
2684
2684
  };
2685
2685
 
2686
- /** Returns the per-org tracker state map keyed by canonical orgBase. */
2687
- const getAdoThrottleStateAll = () => {
2688
- const out = {};
2689
- for (const [key, tracker] of _adoThrottlesByOrg) {
2690
- out[key] = tracker.getState();
2691
- }
2692
- return out;
2693
- };
2694
2686
 
2695
2687
  /**
2696
2688
  * Query ADO for an open PR on a specific branch.
@@ -2955,7 +2947,6 @@ module.exports = {
2955
2947
  _setAdoTokenAuthNoteWriterForTest, // exported for testing
2956
2948
  isAdoThrottled,
2957
2949
  getAdoThrottleState,
2958
- getAdoThrottleStateAll,
2959
2950
  fetchAdoPrMetadata,
2960
2951
  prExists, // issue #246 — confirm a loose ref points at a PR (not a work item) before stamping
2961
2952
  fetchSinglePrBuildStatus,
@@ -166,6 +166,36 @@ function getBranchDispatchLockKey(entry) {
166
166
  return `${projectKey}:${normalizedBranch}`;
167
167
  }
168
168
 
169
+ // ─── File-Overlap Conflict Detection (P-mqyp000ab028c9d0) ───────────────────
170
+ //
171
+ // Logging-only warning surfaced when a new WI declares affected_files that
172
+ // overlap with files declared by a currently in-progress (active) WI. No
173
+ // blocking — dispatch always proceeds. Goal: surface silent parallel edit
174
+ // races before they become merge conflicts.
175
+
176
+ function _getItemAffectedFiles(dispatchItem) {
177
+ const files = dispatchItem?.meta?.item?.affected_files;
178
+ if (!Array.isArray(files) || files.length === 0) return null;
179
+ const set = new Set(files.filter(f => typeof f === 'string' && f.length > 0));
180
+ return set.size > 0 ? set : null;
181
+ }
182
+
183
+ // Called inside addToDispatch (before push) to emit one log line per active
184
+ // dispatch that shares at least one file with the incoming item.
185
+ function _warnFileOverlap(newItem, activeDispatches) {
186
+ const newFiles = _getItemAffectedFiles(newItem);
187
+ if (!newFiles) return;
188
+ const newWiId = newItem.meta?.item?.id || newItem.id;
189
+ for (const existing of activeDispatches) {
190
+ const existingFiles = _getItemAffectedFiles(existing);
191
+ if (!existingFiles) continue;
192
+ const overlap = [...newFiles].filter(f => existingFiles.has(f));
193
+ if (overlap.length === 0) continue;
194
+ const existingWiId = existing.meta?.item?.id || existing.id;
195
+ log('warn', `WI ${newWiId} may conflict with in-progress ${existingWiId} on files: ${overlap.join(', ')}`);
196
+ }
197
+ }
198
+
169
199
  function findActivePrOrBranchLock(dispatch, item) {
170
200
  const active = dispatch.active || [];
171
201
 
@@ -238,6 +268,7 @@ function addToDispatch(item) {
238
268
  log('info', `Dedup: skipping ${item.id} — ${activeLock.reason} already in ${activeLock.existing.id}`);
239
269
  return dispatch;
240
270
  }
271
+ _warnFileOverlap(item, dispatch.active || []);
241
272
  dispatch.pending.push(item);
242
273
  added = true;
243
274
  return dispatch;
@@ -495,25 +495,25 @@ function checkPlanCompletion(meta, config) {
495
495
  function archivePlan(planFile, plan, projects, config) {
496
496
  const planPath = path.join(PRD_DIR, planFile);
497
497
 
498
- // Archive PRD .json to prd/archive/
499
- const prdArchiveDir = path.join(PRD_DIR, 'archive');
500
- if (!fs.existsSync(prdArchiveDir)) fs.mkdirSync(prdArchiveDir, { recursive: true });
498
+ // Phase 10 — archive the PRD IN PLACE: flag it (archived/status/archivedAt)
499
+ // where it sits, rather than MOVING it to prd/archive/. This matches the
500
+ // canonical dashboard (_archivePrdPostProcess, #533) + watch-action archive
501
+ // paths. The legacy move was the LAST path still physically relocating a PRD,
502
+ // and it reintroduced the live↔archive basename-collision class (footgun #7)
503
+ // that the in-place flag model retired — plus it needed a .backup neutralize
504
+ // that is moot now that prd/*.json never gets a .backup sidecar at all (#570).
505
+ // mutateJsonFileLocked dual-writes the flag into SQL via the chokepoint; the
506
+ // archived-PRD scanner guards (#530) already treat status:'archived' correctly.
501
507
  try {
502
508
  if (fs.existsSync(planPath)) {
503
- // DATA-LOSS GUARD: moveFileNoClobber dedupes + retries so a same-basename
504
- // collision can't silently overwrite a previously-archived PRD (+ its
505
- // completed work-item history). renameSync clobbers on Windows.
506
- const prdDest = shared.moveFileNoClobber(planPath, prdArchiveDir, planFile);
507
- log('info', `Archived completed PRD: prd/archive/${path.basename(prdDest)}`);
508
- }
509
- // Remove .backup sidecar — if left behind, safeJson() would restore the pre-completion
510
- // snapshot (status: approved, no _completionNotified) on engine restart, re-triggering
511
- // plan completion and spawning duplicate verify tasks for already-archived plans.
512
- // On Windows, the unlink can fail due to file locking; overwrite with archived status
513
- // as a fallback so a restored backup is inert even if deletion fails.
514
- const backupCleanup = shared.neutralizeJsonBackupSidecar(planPath);
515
- if (!backupCleanup.ok) {
516
- log('warn', `Archive backup cleanup failed for ${planFile}: unlink failed (${backupCleanup.unlinkError}); fallback neutralize failed (${backupCleanup.writeError})`);
509
+ mutateJsonFileLocked(planPath, (data) => {
510
+ if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
511
+ data.status = 'archived';
512
+ data.archived = true;
513
+ data.archivedAt = new Date().toISOString();
514
+ return data;
515
+ }, { defaultValue: {} });
516
+ log('info', `Archived PRD in place: ${planFile} (archived flag set)`);
517
517
  }
518
518
  } catch (err) {
519
519
  log('warn', `Failed to archive PRD ${planFile}: ${err.message}`);
@@ -777,6 +777,27 @@ function updateWorkItemStatus(meta, status, reason) {
777
777
  syncPrdItemStatus(itemId, status, meta.item?.sourcePlan);
778
778
  }
779
779
 
780
+ // P-mqyp000ab028c9d0 — persist affected_files from a completion report onto the
781
+ // work item for future file-overlap checks. Runs only when the completion report
782
+ // declares a non-empty string[] for `affected_files`; no-ops otherwise.
783
+ function storeAffectedFilesFromCompletion(meta, structuredCompletion) {
784
+ const raw = structuredCompletion?.affected_files;
785
+ if (!Array.isArray(raw) || raw.length === 0) return;
786
+ const files = raw.filter(f => typeof f === 'string' && f.length > 0);
787
+ if (files.length === 0) return;
788
+ const itemId = meta?.item?.id;
789
+ if (!itemId) return;
790
+ const wiPath = resolveWorkItemPath(meta);
791
+ if (!wiPath) return;
792
+ mutateJsonFileLocked(wiPath, (items) => {
793
+ if (!Array.isArray(items)) return items;
794
+ const target = items.find(i => i.id === itemId);
795
+ if (!target) return items;
796
+ target.affected_files = files;
797
+ return items;
798
+ }, { skipWriteIfUnchanged: true });
799
+ }
800
+
780
801
  const _VALID_PRD_STATUSES = new Set([...Object.values(WI_STATUS), 'missing']);
781
802
  // (#984) PRD statuses that are stale when the work item is actually done
782
803
  const _STALE_PRD_STATUSES = new Set([WI_STATUS.DISPATCHED, WI_STATUS.FAILED, WI_STATUS.PENDING]);
@@ -3586,24 +3607,25 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config, runtimeN
3586
3607
  function updateAgentHistory(agentId, dispatchItem, result) {
3587
3608
 
3588
3609
  const historyPath = path.join(AGENTS_DIR, agentId, 'history.md');
3589
- let history = safeRead(historyPath) || '# Agent History\n\n';
3590
3610
  const entry = `### ${ts()} — ${result}\n` +
3591
3611
  `- **Task:** ${dispatchItem.task}\n` +
3592
3612
  `- **Type:** ${dispatchItem.type}\n` +
3593
3613
  `- **Project:** ${dispatchItem.meta?.project?.name || 'central'}\n` +
3594
3614
  `- **Branch:** ${dispatchItem.meta?.branch || 'none'}\n` +
3595
3615
  `- **Dispatch ID:** ${dispatchItem.id}\n\n`;
3596
- const headerEnd = history.indexOf('\n\n');
3597
- if (headerEnd >= 0) {
3598
- history = history.slice(0, headerEnd + 2) + entry + history.slice(headerEnd + 2);
3599
- } else {
3600
- history += entry;
3601
- }
3602
- const entries = history.split('### ').filter(Boolean);
3603
- const header = entries[0].startsWith('#') ? entries.shift() : '# Agent History\n\n';
3604
- const trimmed = entries.slice(0, 20);
3605
- history = header + trimmed.map(e => '### ' + e).join('');
3606
- shared.safeWrite(historyPath, history);
3616
+ shared.mutateTextFileLocked(historyPath, (history) => {
3617
+ history = history || '# Agent History\n\n';
3618
+ const headerEnd = history.indexOf('\n\n');
3619
+ if (headerEnd >= 0) {
3620
+ history = history.slice(0, headerEnd + 2) + entry + history.slice(headerEnd + 2);
3621
+ } else {
3622
+ history += entry;
3623
+ }
3624
+ const entries = history.split('### ').filter(Boolean);
3625
+ const header = entries[0].startsWith('#') ? entries.shift() : '# Agent History\n\n';
3626
+ const trimmed = entries.slice(0, 20);
3627
+ return header + trimmed.map(e => '### ' + e).join('');
3628
+ }, { defaultValue: '# Agent History\n\n' });
3607
3629
  log('info', `Updated history for ${agentId}`);
3608
3630
  }
3609
3631
 
@@ -5426,6 +5448,10 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5426
5448
  meta._noopReason = noopRationale.slice(0, 500);
5427
5449
  }
5428
5450
  updateWorkItemStatus(meta, WI_STATUS.DONE, '');
5451
+ // P-mqyp000ab028c9d0 — persist affected_files from the completion report
5452
+ // onto the work item so future dispatches of the same WI can participate in
5453
+ // the file-overlap conflict detection in dispatch.js#_warnFileOverlap.
5454
+ try { storeAffectedFilesFromCompletion(meta, structuredCompletion); } catch (err) { log('warn', `storeAffectedFilesFromCompletion: ${err.message}`); }
5429
5455
  // W-mqtplpk6001oe6d5 — back-stamp workItemId onto the PRD item now that WI is done.
5430
5456
  if (meta.item.sourcePlan) {
5431
5457
  try { stampPrdItemWorkItemId(meta.item.id, meta.item.sourcePlan); } catch (err) { log('warn', `stampPrdItemWorkItemId: ${err.message}`); }
package/engine.js CHANGED
@@ -5661,7 +5661,7 @@ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
5661
5661
  // ─── Inbox Consolidation (extracted to engine/consolidation.js) ──────────────
5662
5662
 
5663
5663
  const { consolidateInbox } = require('./engine/consolidation');
5664
- const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, checkLiveBuildAndConflict: adoCheckLiveBuildAndConflict, needsAdoPollRetry, getAdoToken, isAdoThrottled, getAdoThrottleStateAll } = require('./engine/ado');
5664
+ const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, checkLiveBuildAndConflict: adoCheckLiveBuildAndConflict, needsAdoPollRetry, getAdoToken, isAdoThrottled, getAdoThrottleState } = require('./engine/ado');
5665
5665
  const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview, checkLiveBuildAndConflict: ghCheckLiveBuildAndConflict, isGhThrottled } = require('./engine/github');
5666
5666
  const { reconcileSharedBranchPrs } = require('./engine/shared-branch-pr-reconcile');
5667
5667
 
@@ -5762,15 +5762,6 @@ function safePrdProjectSlug(projectName) {
5762
5762
  return slug || 'project';
5763
5763
  }
5764
5764
 
5765
- function safePrdFilenameForProject(projectName, suffix) {
5766
- const fileName = `${safePrdProjectSlug(projectName)}-${suffix}.json`;
5767
- const resolved = shared.sanitizePath(fileName, PRD_DIR);
5768
- if (path.dirname(resolved) !== path.resolve(PRD_DIR)) {
5769
- throw new Error('invalid PRD filename: nested paths are not allowed');
5770
- }
5771
- return path.basename(resolved);
5772
- }
5773
-
5774
5765
  /**
5775
5766
  * Atomically reserve a unique PRD filename in `prdDir` (P-9b7e5d3c).
5776
5767
  *
@@ -9779,7 +9770,7 @@ async function tickInner() {
9779
9770
  // Per-org throttle skip happens inside forEachActivePr (one log line per skipped project).
9780
9771
  // Top-level short-circuit: when every known ADO org is throttled, skip the whole phase
9781
9772
  // with one log line to avoid the per-project iteration cost.
9782
- const adoThrottleStates = getAdoThrottleStateAll() || {};
9773
+ const adoThrottleStates = getAdoThrottleState().perOrg || {};
9783
9774
  const adoOrgCount = Object.keys(adoThrottleStates).length;
9784
9775
  const allAdoThrottled = adoOrgCount > 0 && Object.values(adoThrottleStates).every(s => s && s.throttled);
9785
9776
  if (allAdoThrottled) {
@@ -9859,7 +9850,7 @@ async function tickInner() {
9859
9850
  // Per-org throttle skip happens inside forEachActivePr (one log line per skipped project).
9860
9851
  // Top-level short-circuit: when every known ADO org is throttled, skip the whole phase
9861
9852
  // with one log line to avoid the per-project iteration cost.
9862
- const adoThrottleStates = getAdoThrottleStateAll() || {};
9853
+ const adoThrottleStates = getAdoThrottleState().perOrg || {};
9863
9854
  const adoOrgCount = Object.keys(adoThrottleStates).length;
9864
9855
  const allAdoThrottled = adoOrgCount > 0 && Object.values(adoThrottleStates).every(s => s && s.throttled);
9865
9856
  if (allAdoThrottled) {
@@ -10570,7 +10561,7 @@ module.exports = {
10570
10561
 
10571
10562
  // Shared helpers (used by lifecycle.js and tests)
10572
10563
  reconcileItemsWithPrs, detectDependencyCycles,
10573
- safePrdProjectSlug, safePrdFilenameForProject, // exported for testing (W-mqyn7joo0004079f)
10564
+ safePrdProjectSlug, // exported for testing (W-mqyn7joo0004079f)
10574
10565
  isSoftFixDispatch, // exported for testing (W-mqyn7joo0004079f)
10575
10566
  areDependenciesMet, // exported for testing (P-bf04-decompose-zero-children)
10576
10567
  parseConflictFiles, pruneAncestorDeps, preflightMergeSimulation, // exported for testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2297",
3
+ "version": "0.1.2299",
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"