@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 +47 -21
- package/docs/README.md +1 -0
- package/docs/completion-reports.md +5 -6
- package/engine/queries.js +5 -4
- package/engine/shared.js +100 -0
- package/engine.js +111 -17
- package/package.json +1 -1
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
|
|
7375
|
-
//
|
|
7376
|
-
//
|
|
7377
|
-
//
|
|
7378
|
-
//
|
|
7379
|
-
//
|
|
7380
|
-
//
|
|
7381
|
-
//
|
|
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
|
-
|
|
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
|
|
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`,
|
|
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` —
|
|
300
|
-
2.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
3540
|
+
activeProcesses.delete(id);
|
|
3541
3541
|
}
|
|
3542
3542
|
if (registeredInActivityMap) {
|
|
3543
|
-
|
|
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
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
//
|
|
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 (
|
|
5317
|
-
//
|
|
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.
|
|
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"
|