@yemi33/minions 0.1.2192 → 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
@@ -484,7 +484,7 @@ function normalizePrTitleMatchText(value) {
484
484
 
485
485
  function findPrRecordReferencedByText(prs, text, project = null) {
486
486
  if (!Array.isArray(prs) || !String(text || '').trim()) return null;
487
- const explicitRef = extractPrRefFromText(text);
487
+ const explicitRef = shared.extractPrRefFromText(text);
488
488
  if (explicitRef) return shared.findPrRecord(prs, explicitRef, project);
489
489
  const normalizedText = normalizePrTitleMatchText(text);
490
490
  if (!normalizedText) return null;
@@ -3957,14 +3957,6 @@ function trimTrailingPrRefPunctuation(value) {
3957
3957
  return String(value || '').replace(/[),.;:]+$/g, '');
3958
3958
  }
3959
3959
 
3960
- // Thin wrapper over shared.extractPrRefFromText so callers in dashboard.js
3961
- // keep their existing import shape while the canonical regex + extraction
3962
- // logic lives in engine/shared.js (issue #2999 / W-mpx6i5kh000ac040).
3963
- function extractPrRefFromText(value) {
3964
- return shared.extractPrRefFromText(value);
3965
- }
3966
-
3967
-
3968
3960
  // ── Shared LLM call core — used by CC panel and doc modals ──────────────────
3969
3961
 
3970
3962
  // Session store for doc modals — keyed by filePath or title, persisted to disk.
@@ -7371,19 +7363,20 @@ const server = http.createServer(async (req, res) => {
7371
7363
  return data;
7372
7364
  }, { defaultValue: {} });
7373
7365
 
7374
- // W-mqacrzis0003df4a — Fresh source-plan staleness check. Approve is the
7375
- // last gate before materialization, and the diff-aware regen block below
7376
- // gates on `wasStale`. The persisted `data.planStale` flag lags by an
7377
- // engine tick (~10s); without this fresh stat a fast user can Approve
7378
- // within the tick window, `wasStale` stays false, the diff-aware regen
7379
- // is silently skipped, and items materialize from the OLD PRD. Mirrors
7380
- // the staleness logic in engine/queries.js#getPrdInfo + the /api/plans
7381
- // 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.
7382
7377
  if (!wasStale && plan && plan.source_plan && plan.sourcePlanModifiedAt) {
7383
7378
  try {
7384
- const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
7385
- const recorded = new Date(plan.sourcePlanModifiedAt).getTime();
7386
- if (recorded && sourceMtime > recorded) wasStale = true;
7379
+ if (shared.isSourcePlanContentStale(PLANS_DIR, plan).stale) wasStale = true;
7387
7380
  } catch { /* source plan may have been deleted/renamed — fall through with wasStale=false */ }
7388
7381
  }
7389
7382
 
@@ -7645,6 +7638,39 @@ const server = http.createServer(async (req, res) => {
7645
7638
  if (!materializedPlanItemIds.has(pi.id)) newCount++;
7646
7639
  }
7647
7640
 
7641
+ // W-mqexsm7y000qccd2 — flip orphan-pending PRD items to 'missing' so
7642
+ // the materializer's PRD_MATERIALIZABLE filter picks them up on the
7643
+ // next tick. 'pending' isn't a valid PRD-item status (the enum is
7644
+ // {missing, updated, done}) but leaks into PRDs via upstream tools;
7645
+ // without this flip the operator sees `new: N` but the WIs never
7646
+ // appear because the materializer drops them. Defensive scope: only
7647
+ // flip items with no live WI (orphans) — pending PRD items that
7648
+ // already have a kept/dispatched WI are left alone so the existing
7649
+ // re-open path stays authoritative.
7650
+ let orphanPendingFlipped = 0;
7651
+ const orphanPendingIds = new Set();
7652
+ for (const pi of planItems) {
7653
+ if (pi.status === 'pending' && !materializedPlanItemIds.has(pi.id) && pi.id) {
7654
+ orphanPendingIds.add(pi.id);
7655
+ }
7656
+ }
7657
+ if (orphanPendingIds.size > 0) {
7658
+ try {
7659
+ mutateJsonFileLocked(planPath, (current) => {
7660
+ if (!current || !Array.isArray(current.missing_features)) return current;
7661
+ const stamp = new Date().toISOString();
7662
+ for (const f of current.missing_features) {
7663
+ if (orphanPendingIds.has(f.id) && f.status === 'pending') {
7664
+ f.status = 'missing';
7665
+ f._orphanPendingFlippedAt = stamp;
7666
+ orphanPendingFlipped++;
7667
+ }
7668
+ }
7669
+ return current;
7670
+ });
7671
+ } catch (e) { console.error('orphan-pending PRD flip:', e.message); }
7672
+ }
7673
+
7648
7674
  // Clean dispatch entries for deleted items
7649
7675
  for (const itemId of deletedItemIds) {
7650
7676
  cleanDispatchEntries(d =>
@@ -7652,7 +7678,7 @@ const server = http.createServer(async (req, res) => {
7652
7678
  );
7653
7679
  }
7654
7680
 
7655
- return jsonReply(res, 200, { ok: true, reset, kept, new: newCount });
7681
+ return jsonReply(res, 200, { ok: true, reset, kept, new: newCount, orphanPendingFlipped });
7656
7682
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
7657
7683
  }
7658
7684
 
package/docs/README.md CHANGED
@@ -52,6 +52,7 @@ Architecture, design proposals, and lifecycle references for people working on t
52
52
  Operational runbooks for engine operators and fleet maintainers.
53
53
 
54
54
  - [auto-discovery.md](auto-discovery.md) — Auto-discovery and execution pipeline: the per-tick orchestration loop and the four work-discovery sources.
55
+ - [diagnostics-memory.md](diagnostics-memory.md) — Operator runbook for the in-process memory + perf observability surface: `/api/diagnostics/memory[/history]`, `/api/diagnostics/heap-snapshot` guard-token capture, `MEMORY_BASELINE` log emissions, `--cpu-prof`/`--heap-prof` capture, and the `test/perf/soak.test.js` heap-growth regression gate.
55
56
  - [engine-restart.md](engine-restart.md) — How agents survive an engine restart: state persistence, the 20-minute startup grace period, and orphan reattachment via PID files and `live-output.log`.
56
57
  - [human-vs-automated.md](human-vs-automated.md) — Quick reference table of which features humans start, run, decide, and recover, and the two human approval gates.
57
58
  - [kb-sweep.md](kb-sweep.md) — Knowledge-base sweep runbook: how `engine/kb-sweep.js` consolidates `notes/inbox/` into `knowledge/` and survives `minions restart`.
@@ -1,6 +1,6 @@
1
1
  # Completion Reports
2
2
 
3
- Every Minions agent ends its dispatch by writing a JSON completion report. The report is the engine's primary, machine-readable signal that the work item is finished and is the source of truth for status, retry decisions, dashboard surfaces, and downstream automation. Fenced ` ```completion ` blocks in stdout are still parsed as a compatibility fallback, but the JSON file always wins when both exist.
3
+ Every Minions agent ends its dispatch by writing a JSON completion report. The report is the engine's sole structured-completion source — it is machine-readable, signed by a per-spawn nonce, and the source of truth for status, retry decisions, dashboard surfaces, and downstream automation. (The legacy fenced ` ```completion ` block and `task_complete` prose-summary fallbacks were removed in PR #126 / commit `ac76148f`; without a well-formed report on disk the engine has no structured signals to read.)
4
4
 
5
5
  This document is the canonical schema. Playbooks should cross-link here instead of restating the field list.
6
6
 
@@ -24,7 +24,7 @@ The agent must write the JSON to that exact path before exiting. Any character o
24
24
  Each spawn also receives a per-dispatch cryptographic value via the `MINIONS_COMPLETION_NONCE` environment variable. The engine generates this with `crypto.randomBytes(16).toString('hex')` in `engine.js:spawnAgent()` and stores it on the in-memory active-process record. The agent is required to copy the value verbatim into the report's `nonce` field. On parse, `engine/lifecycle.js:runPostCompletionHooks()` compares `report.nonce` against the in-memory value:
25
25
 
26
26
  - **Match** — the report is trusted and processed normally.
27
- - **Mismatch** — the report is treated as forged (a prompt-injected agent or a stale process writing into a sibling dispatch's completion path). Every signal it carries — `status`, `pr`, `noop`, `failure_class`, `retryable`, `needs_rerun`, fenced/summary fallbacks — is discarded. The dispatch is failed with `failure_class: 'completion-nonce-mismatch'` and the work item is marked failed (no auto-retry honors the agent's `retryable` claim).
27
+ - **Mismatch** — the report is treated as forged (a prompt-injected agent or a stale process writing into a sibling dispatch's completion path). Every signal it carries — `status`, `pr`, `noop`, `failure_class`, `retryable`, `needs_rerun`, artifacts, follow-ups — is discarded. The dispatch is failed with `failure_class: 'completion-nonce-mismatch'` and the work item is marked failed (no auto-retry honors the agent's `retryable` claim).
28
28
  - **Missing** — by default, the engine logs `[security] completion-nonce-missing dispatch=… required=false (degraded — report honored)` and still honors the report. Flip `ENGINE_DEFAULTS.completionNonceRequired` (or `engine.completionNonceRequired` in `config.json`) to `true` to hard-fail missing nonces too. Default is `false` for one release so older runtime caches and agents that haven't picked up the prompt change degrade with a warning instead of breaking.
29
29
 
30
30
  Security event log lines are emitted on the `error` channel and are designed to be greppable:
@@ -296,11 +296,10 @@ The dashboard caps the rendered list at 20 artifacts per report (`engine/queries
296
296
 
297
297
  The engine reads completion signals in this order (`engine/lifecycle.js`):
298
298
 
299
- 1. The JSON report at `MINIONS_COMPLETION_REPORT` — primary.
300
- 2. A fenced ` ```completion ` block in the agent's stdout fallback only.
301
- 3. Process exit code and stdout heuristics — last-resort recovery.
299
+ 1. The JSON report at `MINIONS_COMPLETION_REPORT` — sole structured-completion source. `parseCompletionReportFile()` requires a well-formed plain-object JSON with a `status` field; anything else is dropped with a warn log.
300
+ 2. Runtime result events + the `[process-exit]` sentinel in `live-output.log` — used to confirm the dispatch actually exited and to capture the runtime's own result prose into `resultSummary`. These cannot supply structured fields (`pr`, `failure_class`, `retryable`, `noop`, `verdict`, `artifacts`, …); without a report on disk those signals are simply absent.
302
301
 
303
- If the JSON report exists and is well-formed, the engine ignores the fenced block. If the JSON is missing or malformed (logged as a warning), the engine falls back to the fenced block, then to stdout parsing.
302
+ The legacy fenced ` ```completion ` block parser and the `task_complete` prose-summary fallback were removed in PR #126 (`ac76148f`, 2026-06-11) after a 14-day sweep window showed zero hits. If the report file is missing, malformed, or fails the nonce check, the engine logs at warn and the dispatch proceeds with no structured signals — the PR-attachment contract and the phantom-completion guard in `detectNonTerminalResultSummary` are the only remaining safety nets.
304
303
 
305
304
  ## Examples
306
305
 
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
@@ -3537,10 +3537,10 @@ async function spawnAgent(dispatchItem, config) {
3537
3537
  try { shared.killImmediate(proc); } catch { /* already exited */ }
3538
3538
  }
3539
3539
  if (registeredInActiveProcesses) {
3540
- try { activeProcesses.delete(id); } catch { /* map.delete never throws but be defensive */ }
3540
+ activeProcesses.delete(id);
3541
3541
  }
3542
3542
  if (registeredInActivityMap) {
3543
- try { realActivityMap.delete(id); } catch { /* defensive */ }
3543
+ realActivityMap.delete(id);
3544
3544
  }
3545
3545
  if (logFd !== undefined) {
3546
3546
  try { fs.closeSync(logFd); } catch { /* fd may already be closed */ }
@@ -5196,12 +5196,59 @@ function materializePlansAsWorkItems(config) {
5196
5196
  // the bumped name itself is owned by an archived PRD — same risk.
5197
5197
  log('warn', `Plan project enforcement: skipping rename of ${fileName} to ${toBasename} — would collide with archived PRD`);
5198
5198
  } else {
5199
+ // W-mqfevwrm — Serialize the rename behind file locks on BOTH the
5200
+ // source and destination paths so a concurrent `mutateJsonFileLocked`
5201
+ // on either name cannot interleave (creating a divergent ghost PRD
5202
+ // or losing data). Move the `.backup` sidecar along with the `.json`
5203
+ // so the canonical name retains its restore-from-backup safety net
5204
+ // — without this, deleting the renamed `.json` (concurrent sweep,
5205
+ // plan-completion purge, etc.) leaves no `.backup` under the new
5206
+ // name and the PRD is permanently lost (live incident 2026-06-15:
5207
+ // `minions-opg-2026-06-10.json` gone, only the OLD-name `.backup`
5208
+ // survived, all 9 WIs orphaned). The OLD-name `.backup` is removed
5209
+ // so it cannot resurrect a ghost PRD on a future read of the OLD
5210
+ // path (W-mouptdh1000h9f39-style landmine).
5199
5211
  try {
5200
- fs.renameSync(fromPath, toPath);
5201
- nextFileName = toBasename;
5202
- const migrated = migratePrdFilenameReferences(fileName, nextFileName);
5203
- if (migrated > 0) log('info', `Plan project enforcement: migrated ${migrated} PRD reference(s) from ${fileName} to ${nextFileName}`);
5204
- changed = true;
5212
+ withFileLock(`${fromPath}.lock`, () => {
5213
+ withFileLock(`${toPath}.lock`, () => {
5214
+ // Re-verify under both locks — another tick may have raced
5215
+ // ahead and either deleted the source or claimed the dest.
5216
+ if (!fs.existsSync(fromPath)) {
5217
+ log('warn', `Plan project enforcement: source ${fileName} disappeared before rename (raced)`);
5218
+ return;
5219
+ }
5220
+ if (fs.existsSync(toPath)) {
5221
+ log('warn', `Plan project enforcement: destination ${toBasename} appeared before rename (raced) — skipping`);
5222
+ return;
5223
+ }
5224
+ fs.renameSync(fromPath, toPath);
5225
+ // Move the `.backup` sidecar along with the primary `.json`
5226
+ // so safeJson's restore-from-backup path stays viable under
5227
+ // the new canonical name. Both ops are best-effort: missing
5228
+ // `.backup` is normal (nothing to move) and an unlink failure
5229
+ // on the OLD-name `.backup` is logged but non-fatal.
5230
+ const fromBackup = `${fromPath}.backup`;
5231
+ const toBackup = `${toPath}.backup`;
5232
+ if (fs.existsSync(fromBackup)) {
5233
+ try {
5234
+ fs.renameSync(fromBackup, toBackup);
5235
+ } catch (be) {
5236
+ // Fall back to copy + unlink if rename across the same dir
5237
+ // surfaces a Windows EPERM (AV/Search Indexer hold).
5238
+ try {
5239
+ fs.copyFileSync(fromBackup, toBackup);
5240
+ try { fs.unlinkSync(fromBackup); } catch { /* best-effort */ }
5241
+ } catch (ce) {
5242
+ log('warn', `Plan project enforcement: could not move .backup sidecar ${fileName}.backup → ${toBasename}.backup: ${be.message} / ${ce.message}`);
5243
+ }
5244
+ }
5245
+ }
5246
+ nextFileName = toBasename;
5247
+ const migrated = migratePrdFilenameReferences(fileName, nextFileName);
5248
+ if (migrated > 0) log('info', `Plan project enforcement: migrated ${migrated} PRD reference(s) from ${fileName} to ${nextFileName}`);
5249
+ changed = true;
5250
+ });
5251
+ });
5205
5252
  } catch (e) {
5206
5253
  log('warn', `Plan project enforcement: could not rename ${fileName} to ${toBasename}: ${e.message}`);
5207
5254
  }
@@ -5301,34 +5348,72 @@ function materializePlansAsWorkItems(config) {
5301
5348
  }
5302
5349
  } catch (e) { log('warn', `Sequential ID remapping failed for ${file}: ${e.message}`); }
5303
5350
 
5304
- // 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.
5305
5357
  if (plan.source_plan) {
5306
- 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;
5307
5362
  try {
5308
- const sourceMtime = Math.floor(fs.statSync(sourcePlanPath).mtimeMs); // floor to strip sub-ms Windows precision
5309
- const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
5310
- if (!recorded) {
5311
- // 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);
5312
5373
  plan = mutatePrdLocked(file, plan, (current) => {
5313
5374
  if (!current.sourcePlanModifiedAt) current.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
5375
+ if (!current._sourcePlanContentHash && baselineHash) current._sourcePlanContentHash = baselineHash;
5314
5376
  return current;
5315
5377
  }, { skipWriteIfUnchanged: true });
5316
- } else if (sourceMtime > recorded) {
5317
- // 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.
5318
5382
  log('info', `Source plan ${plan.source_plan} updated — re-syncing PRD ${file}`);
5319
5383
  autoCleanPrdWorkItems(file, config);
5320
5384
 
5321
- // Handle PRD based on current status
5322
5385
  const prdStatus = plan.status || (plan.requires_approval ? 'awaiting-approval' : null);
5386
+ const refreshedHash = staleness.currentHash || shared.computeSourcePlanContentHash(resolved);
5323
5387
 
5324
5388
  plan = mutatePrdLocked(file, plan, (current) => {
5325
5389
  current.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
5390
+ if (refreshedHash) current._sourcePlanContentHash = refreshedHash;
5326
5391
  current.lastSyncedFromPlan = ts();
5327
5392
  const currentPrdStatus = current.status || (current.requires_approval ? 'awaiting-approval' : null);
5328
5393
  if (currentPrdStatus) current.planStale = true;
5329
5394
  return current;
5330
5395
  });
5331
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
+ }
5332
5417
  }
5333
5418
  } catch (e) { log('warn', 'plan staleness check: ' + e.message); }
5334
5419
  }
@@ -5369,9 +5454,18 @@ function materializePlansAsWorkItems(config) {
5369
5454
  for (const w of queries.getWorkItems()) {
5370
5455
  if (w.id) allExistingWiIds.add(w.id);
5371
5456
  }
5457
+ // W-mqexsm7y000qccd2 — orphan-pending PRD items (status === 'pending'
5458
+ // with no live WI) used to be silently ignored: 'pending' isn't in
5459
+ // PRD_MATERIALIZABLE ({missing, updated}) and isn't a DONE_STATUSES
5460
+ // member, so the materializer's filter dropped them and dependents
5461
+ // stayed dependency_unmet forever. handlePlansRegenerate flips these
5462
+ // to 'missing' on operator action; the materializer extension below
5463
+ // self-heals on every tick so the materializer is robust to PRD-item
5464
+ // status drift regardless of how 'pending' leaked in.
5372
5465
  const items = plan.missing_features.filter(f =>
5373
5466
  statusFilter.has(f.status) ||
5374
- (DONE_STATUSES.has(f.status) && f.id && !allExistingWiIds.has(f.id))
5467
+ (DONE_STATUSES.has(f.status) && f.id && !allExistingWiIds.has(f.id)) ||
5468
+ (f.status === 'pending' && f.id && !allExistingWiIds.has(f.id))
5375
5469
  );
5376
5470
 
5377
5471
  // Group items by target project (per-item project field overrides plan-level project)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2192",
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"