@yemi33/minions 0.1.2193 → 0.1.2194

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
@@ -7363,19 +7363,20 @@ const server = http.createServer(async (req, res) => {
7363
7363
  return data;
7364
7364
  }, { defaultValue: {} });
7365
7365
 
7366
- // W-mqacrzis0003df4a — Fresh source-plan staleness check. Approve is the
7367
- // last gate before materialization, and the diff-aware regen block below
7368
- // gates on `wasStale`. The persisted `data.planStale` flag lags by an
7369
- // engine tick (~10s); without this fresh stat a fast user can Approve
7370
- // within the tick window, `wasStale` stays false, the diff-aware regen
7371
- // is silently skipped, and items materialize from the OLD PRD. Mirrors
7372
- // the staleness logic in engine/queries.js#getPrdInfo + the /api/plans
7373
- // handler above so all three readers agree.
7366
+ // W-mqacrzis0003df4a + W-mqfevwr60018bd09 — Fresh source-plan
7367
+ // staleness check (content-hash gated). Approve is the last gate
7368
+ // before materialization, and the diff-aware regen block below gates
7369
+ // on `wasStale`. The persisted `data.planStale` flag lags by an
7370
+ // engine tick (~10s); without this fresh stat a fast user can
7371
+ // Approve within the tick window, `wasStale` stays false, the
7372
+ // diff-aware regen is silently skipped, and items materialize from
7373
+ // the OLD PRD. The content-hash gate (W-mqfevwr60018bd09) also
7374
+ // ensures path-only repoints don't fire the destructive diff-aware
7375
+ // regen — Mirrors the staleness logic in engine/queries.js#getPrdInfo
7376
+ // and the /api/plans handler above so all three readers agree.
7374
7377
  if (!wasStale && plan && plan.source_plan && plan.sourcePlanModifiedAt) {
7375
7378
  try {
7376
- const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
7377
- const recorded = new Date(plan.sourcePlanModifiedAt).getTime();
7378
- if (recorded && sourceMtime > recorded) wasStale = true;
7379
+ if (shared.isSourcePlanContentStale(PLANS_DIR, plan).stale) wasStale = true;
7379
7380
  } catch { /* source plan may have been deleted/renamed — fall through with wasStale=false */ }
7380
7381
  }
7381
7382
 
package/engine/queries.js CHANGED
@@ -1892,13 +1892,14 @@ function getPrdInfo(config) {
1892
1892
  }
1893
1893
  if (!plan || !plan.missing_features) continue;
1894
1894
 
1895
- // Staleness: compare source plan mtime to recorded sourcePlanModifiedAt
1895
+ // Staleness: gate on actual source-plan content change rather
1896
+ // than mtime alone (W-mqfevwr60018bd09). Pure path / pure mtime
1897
+ // drifts return stale=false here so a repointed source_plan
1898
+ // doesn't masquerade as a real revision in the cached PRD info.
1896
1899
  let planStale = false;
1897
1900
  if (!archived && plan.source_plan) {
1898
1901
  try {
1899
- const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
1900
- const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
1901
- if (recorded && sourceMtime > recorded) planStale = true;
1902
+ planStale = shared.isSourcePlanContentStale(PLANS_DIR, plan).stale;
1902
1903
  } catch { /* optional */ }
1903
1904
  }
1904
1905
  existingPrds.push({
package/engine/shared.js CHANGED
@@ -657,6 +657,103 @@ function safeReadDir(dir) {
657
657
  try { return fs.readdirSync(dir); } catch { return []; }
658
658
  }
659
659
 
660
+ // ── PRD source-plan resolution & content hashing (W-mqfevwr60018bd09) ────────
661
+ //
662
+ // Repointing a PRD's `source_plan` field (e.g. plans/foo.md →
663
+ // plans/archive/foo.md, or any operator edit that touches mtime without
664
+ // changing the markdown body) used to trigger the diff-aware PRD resync as
665
+ // if the plan content had changed — destroying / re-materializing work
666
+ // items even though only the POINTER moved. These helpers gate the
667
+ // destructive resync on actual *content* change via a sha256 hash, and
668
+ // also fall back to plans/archive/<basename> so a silently-relocated
669
+ // source plan no longer spams ENOENT warnings on every tick.
670
+
671
+ /**
672
+ * Resolve a PRD's `source_plan` field to an absolute file path under
673
+ * `plansDir`. Tries the direct join first; falls back to
674
+ * plansDir/archive/<basename(source_plan)> when the direct path is
675
+ * missing. Returns null when neither location resolves to a regular file.
676
+ */
677
+ function resolveSourcePlanPath(plansDir, sourcePlan) {
678
+ if (!plansDir || !sourcePlan || typeof sourcePlan !== 'string') return null;
679
+ const direct = path.join(plansDir, sourcePlan);
680
+ try { if (fs.statSync(direct).isFile()) return direct; } catch { /* miss */ }
681
+ const archived = path.join(plansDir, 'archive', path.basename(sourcePlan));
682
+ try { if (fs.statSync(archived).isFile()) return archived; } catch { /* miss */ }
683
+ return null;
684
+ }
685
+
686
+ /**
687
+ * Compute a deterministic sha256 hex digest of a source plan markdown
688
+ * file. Returns null when the file cannot be read. Used to gate the
689
+ * destructive PRD resync on actual content change rather than mtime
690
+ * or path drift.
691
+ */
692
+ function computeSourcePlanContentHash(absPath) {
693
+ if (!absPath || typeof absPath !== 'string') return null;
694
+ try {
695
+ const buf = fs.readFileSync(absPath);
696
+ return crypto.createHash('sha256').update(buf).digest('hex');
697
+ } catch { return null; }
698
+ }
699
+
700
+ /**
701
+ * Decide whether a PRD's source plan has *actually* changed since the
702
+ * last sync, gating the destructive diff-aware resync on content change
703
+ * rather than mtime or path drift.
704
+ *
705
+ * Returns:
706
+ * {
707
+ * stale: boolean, // true → callers should treat the PRD as stale
708
+ * contentChanged: boolean, // true iff we have BOTH hashes AND they differ
709
+ * currentHash: string|null, // computed only when mtime advanced
710
+ * resolvedPath: string|null,// absolute path of the resolved markdown
711
+ * sourceMtime: number|null, // floor(ms) mtime of the resolved file
712
+ * }
713
+ *
714
+ * `stale` is true only when:
715
+ * - the resolved file exists, AND
716
+ * - mtime advanced past plan.sourcePlanModifiedAt, AND
717
+ * - either no _sourcePlanContentHash was recorded on the PRD (legacy
718
+ * fallback to mtime — preserves pre-fix behavior on first encounter),
719
+ * OR the recorded hash differs from the current file's hash.
720
+ *
721
+ * `contentChanged` is true only when both the recorded hash and the
722
+ * current hash exist AND differ. Pure-pointer / pure-mtime drifts leave
723
+ * `contentChanged` false so callers can silently re-baseline
724
+ * `sourcePlanModifiedAt` (and `_sourcePlanContentHash` for legacy PRDs)
725
+ * without triggering the destructive resync pipeline.
726
+ */
727
+ function isSourcePlanContentStale(plansDir, plan) {
728
+ const out = {
729
+ stale: false, contentChanged: false,
730
+ currentHash: null, resolvedPath: null, sourceMtime: null,
731
+ };
732
+ if (!plan || typeof plan !== 'object' || !plan.source_plan) return out;
733
+ const resolved = resolveSourcePlanPath(plansDir, plan.source_plan);
734
+ out.resolvedPath = resolved;
735
+ if (!resolved) return out;
736
+ let mtime;
737
+ try { mtime = Math.floor(fs.statSync(resolved).mtimeMs); } catch { return out; }
738
+ out.sourceMtime = mtime;
739
+ const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
740
+ if (!recorded || mtime <= recorded) return out;
741
+ // mtime advanced — verify the content actually differs before flagging.
742
+ out.currentHash = computeSourcePlanContentHash(resolved);
743
+ const recordedHash = plan._sourcePlanContentHash || null;
744
+ if (recordedHash && out.currentHash) {
745
+ out.contentChanged = recordedHash !== out.currentHash;
746
+ out.stale = out.contentChanged;
747
+ } else {
748
+ // Legacy PRD with no recorded hash — fall back to mtime semantics so
749
+ // we don't silently ignore real revisions made before the hash field
750
+ // existed. The engine sweep records the fresh hash on this pass so
751
+ // subsequent mtime-only drifts no longer trigger resync.
752
+ out.stale = true;
753
+ }
754
+ return out;
755
+ }
756
+
660
757
  // ── SQL-routing shim for migrated state files ──────────────────────────────
661
758
  //
662
759
  // Phase 9 (post-Phase 8 cleanup): every state file that has a SQL backing
@@ -8211,6 +8308,9 @@ module.exports = {
8211
8308
  getProjectOrg,
8212
8309
  getAdoOrgBase,
8213
8310
  sanitizePath,
8311
+ resolveSourcePlanPath, // W-mqfevwr60018bd09 — PRD staleness: resolve source_plan to absolute path (archive-aware)
8312
+ computeSourcePlanContentHash, // W-mqfevwr60018bd09 — sha256 of source plan markdown body
8313
+ isSourcePlanContentStale, // W-mqfevwr60018bd09 — gate destructive PRD resync on actual content change
8214
8314
  sanitizeBranch,
8215
8315
  getOperatorLogin,
8216
8316
  deriveWorkItemBranchName,
package/engine.js CHANGED
@@ -5348,34 +5348,72 @@ function materializePlansAsWorkItems(config) {
5348
5348
  }
5349
5349
  } catch (e) { log('warn', `Sequential ID remapping failed for ${file}: ${e.message}`); }
5350
5350
 
5351
- // Plan staleness: if source_plan .md was modified since last sync, auto-clean and re-sync
5351
+ // Plan staleness: if source_plan markdown content was modified since
5352
+ // last sync, auto-clean and re-sync. W-mqfevwr60018bd09 — gate the
5353
+ // destructive resync on a sha256 content hash rather than mtime alone
5354
+ // so a path-only repoint (e.g. operator changes `source_plan` from
5355
+ // `plans/foo.md` to `plans/archive/foo.md` to silence ENOENT warnings)
5356
+ // no longer destroys work items when the markdown body is identical.
5352
5357
  if (plan.source_plan) {
5353
- const sourcePlanPath = path.join(PLANS_DIR, plan.source_plan);
5358
+ const staleness = shared.isSourcePlanContentStale(PLANS_DIR, plan);
5359
+ const resolved = staleness.resolvedPath;
5360
+ const sourceMtime = staleness.sourceMtime;
5361
+ const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
5354
5362
  try {
5355
- const sourceMtime = Math.floor(fs.statSync(sourcePlanPath).mtimeMs); // floor to strip sub-ms Windows precision
5356
- const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
5357
- if (!recorded) {
5358
- // First time seeing this plan record baseline mtime (no clean needed)
5363
+ if (!resolved) {
5364
+ // Neither plans/<source_plan> nor plans/archive/<basename> exists.
5365
+ // Stay quiet — emitting an ENOENT warn every tick was the original
5366
+ // noise that prompted operators to repoint source_plan and trigger
5367
+ // the destructive resync (see W-mqfevwr60018bd09 repro).
5368
+ } else if (!recorded) {
5369
+ // First time seeing this plan — baseline both the mtime and the
5370
+ // content hash so future ticks can distinguish path-only drift
5371
+ // from a real revision.
5372
+ const baselineHash = staleness.currentHash || shared.computeSourcePlanContentHash(resolved);
5359
5373
  plan = mutatePrdLocked(file, plan, (current) => {
5360
5374
  if (!current.sourcePlanModifiedAt) current.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
5375
+ if (!current._sourcePlanContentHash && baselineHash) current._sourcePlanContentHash = baselineHash;
5361
5376
  return current;
5362
5377
  }, { skipWriteIfUnchanged: true });
5363
- } else if (sourceMtime > recorded) {
5364
- // Source plan changed auto-clean pending/failed items so they re-materialize with updated data
5378
+ } else if (staleness.stale) {
5379
+ // mtime advanced AND content actually changed (or this is a
5380
+ // legacy PRD with no recorded hash — see isSourcePlanContentStale).
5381
+ // Run the destructive resync as before.
5365
5382
  log('info', `Source plan ${plan.source_plan} updated — re-syncing PRD ${file}`);
5366
5383
  autoCleanPrdWorkItems(file, config);
5367
5384
 
5368
- // Handle PRD based on current status
5369
5385
  const prdStatus = plan.status || (plan.requires_approval ? 'awaiting-approval' : null);
5386
+ const refreshedHash = staleness.currentHash || shared.computeSourcePlanContentHash(resolved);
5370
5387
 
5371
5388
  plan = mutatePrdLocked(file, plan, (current) => {
5372
5389
  current.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
5390
+ if (refreshedHash) current._sourcePlanContentHash = refreshedHash;
5373
5391
  current.lastSyncedFromPlan = ts();
5374
5392
  const currentPrdStatus = current.status || (current.requires_approval ? 'awaiting-approval' : null);
5375
5393
  if (currentPrdStatus) current.planStale = true;
5376
5394
  return current;
5377
5395
  });
5378
5396
  if (prdStatus) log('info', `PRD ${file} flagged as stale (plan revised while ${prdStatus}) — user can regenerate from dashboard`);
5397
+ } else if (sourceMtime > recorded) {
5398
+ // mtime drifted forward but content hash matches — silently
5399
+ // re-baseline the tracking fields so we don't keep recomputing
5400
+ // the hash on every tick. No resync, no planStale flip.
5401
+ const refreshedHash = staleness.currentHash || shared.computeSourcePlanContentHash(resolved);
5402
+ plan = mutatePrdLocked(file, plan, (current) => {
5403
+ current.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
5404
+ if (refreshedHash && !current._sourcePlanContentHash) current._sourcePlanContentHash = refreshedHash;
5405
+ return current;
5406
+ }, { skipWriteIfUnchanged: true });
5407
+ } else if (!plan._sourcePlanContentHash) {
5408
+ // Steady state but no hash recorded yet (PRD pre-dates this fix).
5409
+ // Backfill silently so the very next path-only repoint is a no-op.
5410
+ const baselineHash = shared.computeSourcePlanContentHash(resolved);
5411
+ if (baselineHash) {
5412
+ plan = mutatePrdLocked(file, plan, (current) => {
5413
+ if (!current._sourcePlanContentHash) current._sourcePlanContentHash = baselineHash;
5414
+ return current;
5415
+ }, { skipWriteIfUnchanged: true });
5416
+ }
5379
5417
  }
5380
5418
  } catch (e) { log('warn', 'plan staleness check: ' + e.message); }
5381
5419
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2193",
3
+ "version": "0.1.2194",
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"