@yemi33/minions 0.1.2283 → 0.1.2285

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.
@@ -767,10 +767,17 @@ function syncPrdItemStatus(itemId, status, sourcePlan) {
767
767
  // safeJsonNoRestore so an archived PRD's .backup sidecar can't resurrect
768
768
  // the active PRD just because a done WI still references it.
769
769
  const plan = safeJsonNoRestore(fpath);
770
+ // Frozen PRDs (paused/rejected/awaiting-approval/completed) must not have
771
+ // their item statuses mutated by a late-arriving WI status update — that
772
+ // would silently flip a paused/rejected PRD's items and feed the
773
+ // materializer. Mirrors the guard reconcilePrdStatuses already has.
774
+ if (_PRD_FROZEN_STATUSES.has(plan?.status)) continue;
770
775
  const feature = plan?.missing_features?.find(f => f.id === itemId);
771
776
  if (!feature || feature.status === status) continue;
772
777
  let updated = false;
773
778
  mutateJsonFileLocked(fpath, (fresh) => {
779
+ // Re-check under the lock — status may have frozen between peek and lock.
780
+ if (_PRD_FROZEN_STATUSES.has(fresh?.status)) return fresh;
774
781
  const f = fresh?.missing_features?.find(x => x.id === itemId);
775
782
  if (f && f.status !== status) {
776
783
  f.status = status;
@@ -522,15 +522,28 @@ function _findExistingPlanForMeeting(meetingIds, plansDir) {
522
522
  return slugMatch;
523
523
  }
524
524
 
525
- // Check if a PRD already exists for a given plan file (plan already converted)
525
+ // Canonicalize a plan filename by stripping a trailing collision suffix
526
+ // (`-2`, `-3`, …) that `uniquePath` appends AFTER the `-YYYY-MM-DD` date when a
527
+ // same-name plan already exists. A pipeline run whose plan got collision-bumped
528
+ // to `<name>-<date>-2.md` must still link to the PRD written for the canonical
529
+ // `<name>-<date>.md` (a duplicate plan otherwise strands the PRD permanently
530
+ // `awaiting-approval`, defeating the stage's autoApprove). Only strips the
531
+ // suffix when the remainder ends in a full date, so real names are untouched.
532
+ function _canonicalPlanName(planFile) {
533
+ return String(planFile || '').replace(/^(.*-\d{4}-\d{2}-\d{2})-\d+(\.md)$/i, '$1$2');
534
+ }
535
+
536
+ // Check if a PRD already exists for a given plan file (plan already converted).
537
+ // Matches by canonical name so a collision-bumped plan still finds its PRD.
526
538
  function _findExistingPrdForPlan(planFile, prdDir) {
527
539
  if (!fs.existsSync(prdDir)) return null;
528
540
  const prdFiles = safeReadDir(prdDir).filter(f => f.endsWith('.json'));
541
+ const planKey = _canonicalPlanName(planFile);
529
542
  for (const pf of prdFiles) {
530
543
  // safeJsonNoRestore: PRDs are terminal artifacts — never restore archived
531
544
  // PRDs from a stale .backup sidecar (W-mouptdh1000h9f39).
532
545
  const prd = safeJsonNoRestore(path.join(prdDir, pf));
533
- if (prd?.source_plan && path.basename(String(prd.source_plan)) === planFile) return pf;
546
+ if (prd?.source_plan && _canonicalPlanName(path.basename(String(prd.source_plan))) === planKey) return pf;
534
547
  }
535
548
  return null;
536
549
  }
@@ -925,10 +938,14 @@ function isStageComplete(stage, stageState, run, config) {
925
938
  const discoveredWiIds = [];
926
939
  const prdFiles = fs.existsSync(prdDir) ? safeReadDir(prdDir).filter(f => f.endsWith('.json')) : [];
927
940
  for (const planFile of plans) {
941
+ const planKey = _canonicalPlanName(planFile);
928
942
  for (const pf of prdFiles) {
929
943
  // safeJsonNoRestore — see _findExistingPrdForPlan above (W-mouptdh1000h9f39).
930
944
  const prd = safeJsonNoRestore(path.join(prdDir, pf));
931
- if (prd?.source_plan && path.basename(String(prd.source_plan)) === planFile && !(artifacts.prds || []).includes(pf) && !discoveredPrds.includes(pf)) {
945
+ // Canonical match: a collision-bumped run plan (<name>-<date>-2.md)
946
+ // still links to the PRD written for <name>-<date>.md, so the stage's
947
+ // autoApprove reaches the PRD instead of stranding it awaiting-approval.
948
+ if (prd?.source_plan && _canonicalPlanName(path.basename(String(prd.source_plan))) === planKey && !(artifacts.prds || []).includes(pf) && !discoveredPrds.includes(pf)) {
932
949
  discoveredPrds.push(pf);
933
950
  }
934
951
  }
@@ -1244,5 +1261,5 @@ module.exports = {
1244
1261
  evaluateCondition, // exported for testing
1245
1262
  truncatePipelineContext, executeTaskStage, executePlanStage, executeScheduleStage, executeApiStage, executeMeetingStage, executeMergePrsStage, isStageComplete, resolveTemplate, // exported for testing
1246
1263
  _resolvePipelineProjects, // exported for testing
1247
- _findMeetingsInRun, _findExistingPlanForMeeting, _findExistingPrdForPlan, // exported for testing
1264
+ _findMeetingsInRun, _findExistingPlanForMeeting, _findExistingPrdForPlan, _canonicalPlanName, // exported for testing
1248
1265
  };
package/engine/shared.js CHANGED
@@ -1328,7 +1328,12 @@ function forEachPidFile(callback) {
1328
1328
  function neutralizeJsonBackupSidecar(filePath, inertData = { status: 'archived' }) {
1329
1329
  const backupPath = filePath + '.backup';
1330
1330
  try {
1331
- fs.unlinkSync(backupPath);
1331
+ // Retry the unlink — on Windows a transient EPERM/EBUSY (AV scanner, lagging
1332
+ // OS handle, indexer) otherwise leaves the .backup sidecar in place, and the
1333
+ // next by-name safeJson() resurrects the just-deleted PRD from it. _retryFsOp
1334
+ // rethrows non-retryable codes (e.g. ENOENT) immediately, so the absent path
1335
+ // below still fires for an already-missing sidecar.
1336
+ _retryFsOp(() => fs.unlinkSync(backupPath), `neutralize backup ${path.basename(backupPath)}`);
1332
1337
  return { ok: true, action: 'removed', backupPath };
1333
1338
  } catch (unlinkErr) {
1334
1339
  if (unlinkErr.code === 'ENOENT') return { ok: true, action: 'absent', backupPath };
package/engine.js CHANGED
@@ -9304,6 +9304,26 @@ let tickCount = 0;
9304
9304
  // In-memory cache of plan filenames confirmed completed — avoids redundant
9305
9305
  // checkPlanCompletion calls. Cleared automatically on engine restart.
9306
9306
  const completedPlanCache = new Set();
9307
+ // Filenames where a live PRD shares a basename with an archived PRD but diverges
9308
+ // (different source_plan / introduces new work). We refuse to auto-purge those
9309
+ // and warn once instead of every tick. (RC4 — footgun #7 collision protection.)
9310
+ const _ghostPurgeCollisionWarned = new Set();
9311
+
9312
+ // True when a live PRD that shares a basename with an archived one is merely a
9313
+ // stale echo of the archive (a .backup ghost-restore), safe to purge. False when
9314
+ // it's a genuinely distinct re-opened PRD that must NOT be silently deleted.
9315
+ // Conservative: if either file is unreadable we fall back to the legacy
9316
+ // "purge the ghost" behavior so we don't regress resurrection cleanup.
9317
+ function _isGhostPrdRestore(live, archived) {
9318
+ if (!live || !archived) return true;
9319
+ const ls = live.source_plan || live.sourcePlan;
9320
+ const as = archived.source_plan || archived.sourcePlan;
9321
+ if (ls && as && ls !== as) return false; // different plan identity → real PRD
9322
+ const archivedIds = new Set((archived.missing_features || []).map(f => f && f.id).filter(Boolean));
9323
+ const hasNewWork = (live.missing_features || []).some(f => f && f.id && !archivedIds.has(f.id));
9324
+ if (hasNewWork) return false; // introduces work the archive never had → real PRD
9325
+ return true; // same identity, no new work → stale echo → ghost
9326
+ }
9307
9327
  let lastWatchCheckAt = 0;
9308
9328
  let lastPrStatusPollAt = 0;
9309
9329
  let lastPrCommentsPollAt = 0;
@@ -9722,11 +9742,26 @@ async function tickInner() {
9722
9742
  for (const file of prdFiles) {
9723
9743
  if (completedPlanCache.has(file)) continue;
9724
9744
  if (fs.existsSync(path.join(PRD_DIR, 'archive', file))) {
9725
- // Orphaned backup restore plan is already archived. Purge the ghost copy.
9726
- try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch { }
9727
- try { fs.unlinkSync(path.join(PRD_DIR, file + '.backup')); } catch { }
9728
- completedPlanCache.add(file);
9729
- continue;
9745
+ // A live PRD basename also exists in the archive. Historically this was
9746
+ // unconditionally treated as an orphaned .backup ghost-restore and purged
9747
+ // but a re-opened plan can legitimately create a NEW live PRD sharing
9748
+ // an archived basename (footgun #7). Identity-check before deleting so we
9749
+ // never silently destroy a real divergent PRD.
9750
+ const liveP = safeJsonNoRestore(path.join(PRD_DIR, file));
9751
+ const archP = safeJsonNoRestore(path.join(PRD_DIR, 'archive', file));
9752
+ if (_isGhostPrdRestore(liveP, archP)) {
9753
+ // Orphaned backup restore — plan is already archived. Purge the ghost copy.
9754
+ try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch { }
9755
+ shared.neutralizeJsonBackupSidecar(path.join(PRD_DIR, file));
9756
+ completedPlanCache.add(file);
9757
+ continue;
9758
+ }
9759
+ // Divergent live PRD — do NOT delete. Warn once and fall through to the
9760
+ // normal completion handling below so it's treated as a real PRD.
9761
+ if (!_ghostPurgeCollisionWarned.has(file)) {
9762
+ log('warn', `PRD ${file} shares a basename with an archived PRD but diverges (different source_plan or new work) — NOT purging; resolve the collision manually`);
9763
+ _ghostPurgeCollisionWarned.add(file);
9764
+ }
9730
9765
  }
9731
9766
  const plan = safeJson(path.join(PRD_DIR, file));
9732
9767
  if (plan && plan.missing_features) {
@@ -10446,6 +10481,7 @@ module.exports = {
10446
10481
  materializeSpecsAsWorkItems, // exported for testing (P-f7-git-log)
10447
10482
  reservePrdFilename, // exported for testing (P-9b7e5d3c)
10448
10483
  sweepStaleArchivedPrdBackups, // exported for testing
10484
+ _isGhostPrdRestore, // exported for testing (RC4 — ghost-purge identity check)
10449
10485
 
10450
10486
  // Shared helpers (used by lifecycle.js and tests)
10451
10487
  reconcileItemsWithPrs, detectDependencyCycles,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2283",
3
+ "version": "0.1.2285",
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"