@yemi33/minions 0.1.2285 → 0.1.2287
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/render-inbox.js +32 -3
- package/dashboard/js/render-other.js +1 -1
- package/dashboard/js/render-plans.js +3 -3
- package/dashboard/js/render-prs.js +45 -4
- package/dashboard/js/render-schedules.js +2 -2
- package/dashboard/js/render-skills.js +3 -3
- package/dashboard/js/render-watches.js +2 -2
- package/dashboard/js/render-work-items.js +3 -1
- package/dashboard/js/settings.js +45 -0
- package/dashboard/pages/engine.html +3 -3
- package/dashboard/pages/meetings.html +2 -2
- package/dashboard/pages/pipelines.html +1 -1
- package/dashboard/pages/tools.html +3 -3
- package/dashboard/pages/watches.html +1 -1
- package/dashboard/slim/js/status.js +29 -15
- package/dashboard/styles.css +22 -4
- package/dashboard.js +146 -72
- package/docs/deprecated.json +35 -161
- package/docs/live-checkout-mode.md +54 -0
- package/docs/project-skills.md +0 -1
- package/engine/cleanup.js +51 -2
- package/engine/db/migrations/015-plans-prds.js +0 -0
- package/engine/features.js +14 -0
- package/engine/llm.js +1 -0
- package/engine/playbook.js +4 -4
- package/engine/prd-store.js +264 -0
- package/engine/queries.js +97 -59
- package/engine/runtimes/copilot.js +32 -5
- package/engine/shared.js +24 -8
- package/engine/watch-actions.js +4 -1
- package/engine.js +42 -7
- package/package.json +1 -1
|
@@ -152,6 +152,60 @@ git for-each-ref --format '%(refname:short) %(upstream:track)' refs/heads \
|
|
|
152
152
|
|
|
153
153
|
The engine has no opinion about local branches; this hygiene is the operator's responsibility in live mode.
|
|
154
154
|
|
|
155
|
+
## Hybrid mode / deferred validation
|
|
156
|
+
|
|
157
|
+
> **Requires `checkoutMode: 'live'`** — `liveValidation` is ignored when `checkoutMode` is `'worktree'` (the default).
|
|
158
|
+
|
|
159
|
+
Hybrid mode lets you run *coding* work items (implement, fix, docs, …) in isolated worktrees while keeping *validation* work items (build-and-test, test, verify, …) serialized in live checkout — the setup where build caches, native toolchains, and test infrastructure only exist in one canonical checkout.
|
|
160
|
+
|
|
161
|
+
### Full flow
|
|
162
|
+
|
|
163
|
+
1. **Coding WI dispatched** — because `liveValidation.type` is set and this WI's type is *not* the validation type, `resolveCheckoutMode(project, workItem.type)` returns `'worktree'`. The engine creates an isolated worktree as normal, runs the agent, and pushes a PR branch.
|
|
164
|
+
2. **Coding WI completes** — if `liveValidation.autoDispatch: true`, the lifecycle hook (`engine/lifecycle.js`) auto-creates a validation WI of type `liveValidation.type` targeting the same PR branch.
|
|
165
|
+
3. **Validation WI dispatched** — `resolveCheckoutMode(project, 'build-and-test')` returns `'live'` (matches `liveValidation.type`). The engine runs `prepareLiveCheckout` in the operator's canonical checkout, which checks out the coding WI's branch in-place and runs the validation agent.
|
|
166
|
+
4. **Validation WI completes** — `restoreLiveCheckoutAtDispatchEnd` checks the operator's tree back to the original ref.
|
|
167
|
+
|
|
168
|
+
### Config snippet
|
|
169
|
+
|
|
170
|
+
```jsonc
|
|
171
|
+
{
|
|
172
|
+
"projects": [{
|
|
173
|
+
"name": "android-aosp",
|
|
174
|
+
"localPath": "/home/yemi/aosp",
|
|
175
|
+
"checkoutMode": "live",
|
|
176
|
+
"liveValidation": {
|
|
177
|
+
"type": "build-and-test",
|
|
178
|
+
"autoDispatch": true
|
|
179
|
+
}
|
|
180
|
+
}]
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Configure via Dashboard → Settings → Projects → **Live validation (deferred build/test)** section. The section is greyed-out when `checkoutMode` is not `'live'`.
|
|
185
|
+
|
|
186
|
+
### Agent behavior contract
|
|
187
|
+
|
|
188
|
+
When a coding agent is dispatched on a project with `liveValidation.autoDispatch: true`, the agent's playbook includes a note that inline build/test runs are not required:
|
|
189
|
+
|
|
190
|
+
> Skip inline build and test verification steps — a separate validation dispatch will run these in the live checkout after this coding WI completes. Push the branch and report success; the validation WI handles build/test confirmation.
|
|
191
|
+
|
|
192
|
+
This prevents coding agents (running in isolated worktrees) from attempting builds that may fail due to missing native toolchains, caches, or environment variables only present in the canonical checkout.
|
|
193
|
+
|
|
194
|
+
### Migration guidance
|
|
195
|
+
|
|
196
|
+
If your project is on `checkoutMode: live` and you want parallel coding WIs, add:
|
|
197
|
+
|
|
198
|
+
```jsonc
|
|
199
|
+
"liveValidation": { "type": "build-and-test", "autoDispatch": true }
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
No other config changes needed. The engine automatically:
|
|
203
|
+
- Dispatches coding WIs into isolated worktrees (escaping the live-checkout cap of 1)
|
|
204
|
+
- Dispatches validation WIs serially in live checkout (preserving your build environment)
|
|
205
|
+
- Auto-creates validation WIs after each coding WI's PR is pushed
|
|
206
|
+
|
|
207
|
+
To opt back out, clear `liveValidation` from the project config (or set to `null` via Dashboard → Settings).
|
|
208
|
+
|
|
155
209
|
## Non-goals
|
|
156
210
|
|
|
157
211
|
Live-checkout mode is deliberately small. These are NOT supported and will not be added:
|
package/docs/project-skills.md
CHANGED
|
@@ -13,7 +13,6 @@ This document is the contract every playbook author and project teammate needs t
|
|
|
13
13
|
| `engine/discover-project-skills.js` | Bounded filesystem walk + classification heuristic. The crash-safe primitive. |
|
|
14
14
|
| `engine/playbook-intents.js` | Single source of truth for the playbook → intent set mapping. |
|
|
15
15
|
| `engine/playbook.js` | Calls discovery + filter at render time, injects `{{project_skills_block}}` into the rendered prompt. |
|
|
16
|
-
| `engine/discover-review-skills.js` | Thin re-export shim. Backward-compat only — new code uses `discover-project-skills.js`. |
|
|
17
16
|
|
|
18
17
|
## What gets discovered
|
|
19
18
|
|
package/engine/cleanup.js
CHANGED
|
@@ -458,6 +458,40 @@ function reapAgentScratch(worktreeRoot) {
|
|
|
458
458
|
return reaped;
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
+
// Disposable agent-deliverable scratch that piles up in knowledge/: per-batch
|
|
462
|
+
// cleanup implementation/analysis notes and the regenerable consolidated digests.
|
|
463
|
+
// These are gitignored working notes nothing reads back — emitted by the
|
|
464
|
+
// simplify/KB-cleanup sweeps at daily cadence, they accumulate forever otherwise.
|
|
465
|
+
// The pattern is intentionally NARROW: curated KB entries and the per-agent
|
|
466
|
+
// memory files (knowledge/agents/<id>.md) never match it and are never touched.
|
|
467
|
+
const KNOWLEDGE_SCRATCH_RE = /(?:kb-cleanup-batch|consolidated-digest|consolidated-ci-health)/i;
|
|
468
|
+
const KNOWLEDGE_SCRATCH_TTL_MS = 48 * 60 * 60 * 1000; // 48h — keep a couple days for reference, reap older
|
|
469
|
+
const KNOWLEDGE_SCRATCH_MAX_SCAN = 5000;
|
|
470
|
+
|
|
471
|
+
function reapKnowledgeScratch(minionsDir = MINIONS_DIR) {
|
|
472
|
+
const dir = path.join(minionsDir, 'knowledge');
|
|
473
|
+
let names;
|
|
474
|
+
try { names = fs.readdirSync(dir); } catch { return 0; } // no knowledge dir → nothing to do
|
|
475
|
+
const cutoff = Date.now() - KNOWLEDGE_SCRATCH_TTL_MS;
|
|
476
|
+
let reaped = 0;
|
|
477
|
+
const limit = Math.min(names.length, KNOWLEDGE_SCRATCH_MAX_SCAN);
|
|
478
|
+
for (let i = 0; i < limit; i++) {
|
|
479
|
+
const name = names[i];
|
|
480
|
+
// Only top-level .md files whose name matches the disposable-scratch pattern.
|
|
481
|
+
if (!name.endsWith('.md') || !KNOWLEDGE_SCRATCH_RE.test(name)) continue;
|
|
482
|
+
const full = path.join(dir, name);
|
|
483
|
+
let stat;
|
|
484
|
+
try { stat = fs.statSync(full); } catch { continue; }
|
|
485
|
+
if (!stat.isFile() || stat.mtimeMs >= cutoff) continue; // recent — keep for reference
|
|
486
|
+
try { fs.unlinkSync(full); reaped++; }
|
|
487
|
+
catch { /* locked / in use — leave it for a later pass */ }
|
|
488
|
+
}
|
|
489
|
+
if (reaped > 0) {
|
|
490
|
+
log('info', `Reaped ${reaped} stale knowledge-scratch notes (>${Math.round(KNOWLEDGE_SCRATCH_TTL_MS / 3600000)}h) from ${dir}`);
|
|
491
|
+
}
|
|
492
|
+
return reaped;
|
|
493
|
+
}
|
|
494
|
+
|
|
461
495
|
// ─── Cleanup Orchestrator ────────────────────────────────────────────────────
|
|
462
496
|
|
|
463
497
|
async function runCleanup(config, verbose = false) {
|
|
@@ -742,7 +776,11 @@ async function runCleanup(config, verbose = false) {
|
|
|
742
776
|
}
|
|
743
777
|
|
|
744
778
|
// Get PRs for this project
|
|
745
|
-
|
|
779
|
+
// Array.isArray guard: safeJson(p) || [] only substitutes [] for falsy values;
|
|
780
|
+
// a truthy non-array (e.g. {}, 42, true) bypasses the fallback and causes a
|
|
781
|
+
// TypeError in the for-of loop below. Array.isArray covers that case.
|
|
782
|
+
const _prsRaw = safeJson(projectPrPath(project));
|
|
783
|
+
const prs = Array.isArray(_prsRaw) ? _prsRaw : [];
|
|
746
784
|
const mergedBranches = new Set();
|
|
747
785
|
for (const pr of prs) {
|
|
748
786
|
if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED) {
|
|
@@ -960,7 +998,10 @@ async function runCleanup(config, verbose = false) {
|
|
|
960
998
|
// Remove all marked worktrees
|
|
961
999
|
// Re-read PR status immediately before deletion — a PR can be reopened between
|
|
962
1000
|
// the initial status check and the actual deletion (Bug #15: TOCTOU race)
|
|
963
|
-
|
|
1001
|
+
// Array.isArray guard: same as the prs read above — non-array truthy JSON
|
|
1002
|
+
// bypasses || [] and would throw inside the try-catch, silently skipping deletions.
|
|
1003
|
+
const _freshPrsRaw = safeJson(projectPrPath(project));
|
|
1004
|
+
const freshPrs = Array.isArray(_freshPrsRaw) ? _freshPrsRaw : [];
|
|
964
1005
|
const freshMergedBranches = new Set();
|
|
965
1006
|
const freshMergedPrByBranch = new Map();
|
|
966
1007
|
for (const pr of freshPrs) {
|
|
@@ -1585,6 +1626,13 @@ async function runCleanup(config, verbose = false) {
|
|
|
1585
1626
|
}
|
|
1586
1627
|
} catch (e) { log('warn', `pruneStaleBackupSidecars: ${e.message}`); }
|
|
1587
1628
|
|
|
1629
|
+
// 17. Reap stale gitignored knowledge-scratch notes (kb-cleanup-batch / digests)
|
|
1630
|
+
// that the simplify/KB-cleanup sweeps emit into knowledge/ and never read back.
|
|
1631
|
+
cleaned.knowledgeScratch = 0;
|
|
1632
|
+
try {
|
|
1633
|
+
cleaned.knowledgeScratch = reapKnowledgeScratch();
|
|
1634
|
+
} catch (e) { log('warn', `reapKnowledgeScratch: ${e.message}`); }
|
|
1635
|
+
|
|
1588
1636
|
return cleaned;
|
|
1589
1637
|
}
|
|
1590
1638
|
|
|
@@ -1738,4 +1786,5 @@ module.exports = {
|
|
|
1738
1786
|
cleanupMergedPrLocalBranch, // exported for lifecycle cleanup and testing
|
|
1739
1787
|
collectPhantomBranchesForProject, // P-e0b4f7a5 — exported for testing
|
|
1740
1788
|
reapAgentScratch, // exported for testing
|
|
1789
|
+
reapKnowledgeScratch, // exported for testing
|
|
1741
1790
|
};
|
|
Binary file
|
package/engine/features.js
CHANGED
|
@@ -78,6 +78,20 @@ const FEATURES = {
|
|
|
78
78
|
addedIn: '0.1.1916',
|
|
79
79
|
requiredCcRuntime: 'copilot',
|
|
80
80
|
},
|
|
81
|
+
// prdReadsFromSql — Phase 10 step 3 read-flip. When ON, getPrdInfo sources its
|
|
82
|
+
// PRD list (existingPrds / verifyPrsByPlan / allPrdItems) from the SQL mirror
|
|
83
|
+
// (prds/prd_items/prd_verify_prs) after reconciling it from disk, instead of
|
|
84
|
+
// scanning prd/*.json directly. JSON stays canonical and the dual-write keeps
|
|
85
|
+
// SQL in sync; reconciliation catches PRDs written outside the chokepoint. The
|
|
86
|
+
// two code paths are proven output-equivalent by db-phase10-read-flip.test.js.
|
|
87
|
+
// Reversible: set `features.prdReadsFromSql: false` to fall back to the file
|
|
88
|
+
// scan instantly. Temporary migration gate — remove once the read-flip soaks.
|
|
89
|
+
'prdReadsFromSql': {
|
|
90
|
+
description: 'Source the dashboard PRD/plan read (getPrdInfo) from the SQL mirror instead of scanning prd/*.json. Reversible; the two paths are output-equivalent (Phase 10 step 3).',
|
|
91
|
+
default: true,
|
|
92
|
+
addedIn: '0.1.2090',
|
|
93
|
+
expires: '2026-12-01',
|
|
94
|
+
},
|
|
81
95
|
};
|
|
82
96
|
|
|
83
97
|
const ENV_TRUTHY = new Set(['1', 'true', 'on', 'yes']);
|
package/engine/llm.js
CHANGED
|
@@ -405,6 +405,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
|
|
|
405
405
|
maxBudget, bare, fallbackModel,
|
|
406
406
|
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
407
407
|
images,
|
|
408
|
+
tmpDir: llmTmpDir,
|
|
408
409
|
};
|
|
409
410
|
// Capability-gate per-flag opts before prompt construction so adapters can
|
|
410
411
|
// make resume-aware prompt decisions from the same opts used for argv.
|
package/engine/playbook.js
CHANGED
|
@@ -1007,11 +1007,11 @@ function renderPlaybook(type, vars) {
|
|
|
1007
1007
|
// ─── Playbook Section Validator ──────────────────────────────────────────────
|
|
1008
1008
|
|
|
1009
1009
|
// Required structural section patterns — warn (do not throw) when absent.
|
|
1010
|
-
//
|
|
1010
|
+
// NOTE: '## Tools' / '## Constraints' are NOT included because none of the
|
|
1011
|
+
// production playbooks use those headers. Only '## Your Task' is universally
|
|
1012
|
+
// present — it is injected by renderPlaybook via the playbook template.
|
|
1011
1013
|
const _REQUIRED_PROMPT_SECTIONS = [
|
|
1012
|
-
{ pattern: /^## Your Task\b/m,
|
|
1013
|
-
{ pattern: /^## Tools?\b/m, label: '## Tools' },
|
|
1014
|
-
{ pattern: /^## Constraints\b/m, label: '## Constraints' },
|
|
1014
|
+
{ pattern: /^## Your Task\b/m, label: '## Your Task' },
|
|
1015
1015
|
];
|
|
1016
1016
|
|
|
1017
1017
|
/**
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// engine/prd-store.js
|
|
2
|
+
//
|
|
3
|
+
// Phase 10 step 2: SQL mirror for PRD state. PRD content writes go to the
|
|
4
|
+
// canonical prd/*.json file AND are mirrored into the plans/prds/prd_items/
|
|
5
|
+
// prd_verify_prs tables created by migration 015. JSON stays canonical and is
|
|
6
|
+
// still the only thing read in this step — the SQL side is a passive mirror so
|
|
7
|
+
// the read-flip (step 3) has a trustworthy source.
|
|
8
|
+
//
|
|
9
|
+
// All entry points are BEST-EFFORT: a SQLite failure (Node < 22.5, locked DB,
|
|
10
|
+
// schema not yet migrated) must never break a PRD JSON write. Callers wrap via
|
|
11
|
+
// the thin `mirror*`/`remove*` helpers which swallow and return {ok:false}.
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
|
|
16
|
+
function _toMs(v) {
|
|
17
|
+
if (v == null) return null;
|
|
18
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
19
|
+
const parsed = Date.parse(v);
|
|
20
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
21
|
+
}
|
|
22
|
+
function _bool01(v) { return v ? 1 : 0; }
|
|
23
|
+
|
|
24
|
+
// Resolve <MINIONS_DIR>/prd/<f>.json → {filename, archived:0}; the
|
|
25
|
+
// prd/archive/<f>.json variant → {filename, archived:1}; anything else → null.
|
|
26
|
+
// Path comparison is normalized + case-insensitive on the directory prefix so
|
|
27
|
+
// Windows drive-letter / separator differences don't cause a miss.
|
|
28
|
+
function parsePrdPath(filePath) {
|
|
29
|
+
if (!filePath || typeof filePath !== 'string') return null;
|
|
30
|
+
let minionsDir;
|
|
31
|
+
try { minionsDir = require('./shared').MINIONS_DIR; } catch { return null; }
|
|
32
|
+
if (!minionsDir) return null;
|
|
33
|
+
const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
34
|
+
const full = norm(filePath);
|
|
35
|
+
const prdRoot = norm(path.join(minionsDir, 'prd'));
|
|
36
|
+
const archiveRoot = norm(path.join(minionsDir, 'prd', 'archive'));
|
|
37
|
+
if (!full.endsWith('.json')) return null;
|
|
38
|
+
const base = path.basename(filePath);
|
|
39
|
+
if (full === norm(path.join(archiveRoot, base)) && full.startsWith(archiveRoot + '/')) {
|
|
40
|
+
return { filename: base, archived: 1 };
|
|
41
|
+
}
|
|
42
|
+
if (full === norm(path.join(prdRoot, base)) && full.startsWith(prdRoot + '/')) {
|
|
43
|
+
return { filename: base, archived: 0 };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// (filename, archived) → plans.id, preferring the same archived bucket.
|
|
49
|
+
function _resolvePlanId(db, sourcePlan, archived) {
|
|
50
|
+
if (!sourcePlan) return null;
|
|
51
|
+
const same = db.prepare('SELECT id FROM plans WHERE filename=? AND archived=? ORDER BY id LIMIT 1').get(sourcePlan, archived);
|
|
52
|
+
if (same) return same.id;
|
|
53
|
+
const other = db.prepare('SELECT id FROM plans WHERE filename=? ORDER BY id LIMIT 1').get(sourcePlan);
|
|
54
|
+
return other ? other.id : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Core upsert: write/replace the prds row + its prd_items + prd_verify_prs for
|
|
58
|
+
// (filename, archived). Returns the prds.id. Runs inside a transaction.
|
|
59
|
+
function _upsertPrd(db, filename, archived, prd, now) {
|
|
60
|
+
const sourcePlan = prd.source_plan || prd.sourcePlan || null;
|
|
61
|
+
const planId = _resolvePlanId(db, sourcePlan, archived);
|
|
62
|
+
const existing = db.prepare('SELECT id, created_at FROM prds WHERE filename=? AND archived=?').get(filename, archived);
|
|
63
|
+
|
|
64
|
+
let prdId;
|
|
65
|
+
if (existing) {
|
|
66
|
+
prdId = existing.id;
|
|
67
|
+
db.prepare(`
|
|
68
|
+
UPDATE prds SET plan_id=?, project=?, version=?, status=?,
|
|
69
|
+
completed_at=?, completion_notified=?, plan_stale=?, source_plan=?,
|
|
70
|
+
source_plan_modified_at=?, data=?, updated_at=? WHERE id=?
|
|
71
|
+
`).run(
|
|
72
|
+
planId, prd.project || null, prd.version != null ? String(prd.version) : null, prd.status || null,
|
|
73
|
+
_toMs(prd.completedAt || prd.completed_at), _bool01(prd._completionNotified), _bool01(prd.planStale),
|
|
74
|
+
sourcePlan, prd.sourcePlanModifiedAt || prd.source_plan_modified_at || null,
|
|
75
|
+
JSON.stringify(prd), now, prdId,
|
|
76
|
+
);
|
|
77
|
+
db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(prdId);
|
|
78
|
+
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(prdId);
|
|
79
|
+
} else {
|
|
80
|
+
const info = db.prepare(`
|
|
81
|
+
INSERT INTO prds (plan_id, filename, project, version, status, archived,
|
|
82
|
+
completed_at, completion_notified, plan_stale, source_plan,
|
|
83
|
+
source_plan_modified_at, data, created_at, updated_at)
|
|
84
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
85
|
+
`).run(
|
|
86
|
+
planId, filename, prd.project || null, prd.version != null ? String(prd.version) : null, prd.status || null, archived,
|
|
87
|
+
_toMs(prd.completedAt || prd.completed_at), _bool01(prd._completionNotified), _bool01(prd.planStale),
|
|
88
|
+
sourcePlan, prd.sourcePlanModifiedAt || prd.source_plan_modified_at || null,
|
|
89
|
+
JSON.stringify(prd), _toMs(prd.generated_at || prd.createdAt) || now, now,
|
|
90
|
+
);
|
|
91
|
+
prdId = Number(info.lastInsertRowid);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const insItem = db.prepare(`
|
|
95
|
+
INSERT INTO prd_items (prd_id, feature_id, name, project, status, type, work_item_id, data, updated_at)
|
|
96
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
97
|
+
`);
|
|
98
|
+
const features = Array.isArray(prd.missing_features) ? prd.missing_features : [];
|
|
99
|
+
for (const ft of features) {
|
|
100
|
+
if (!ft || typeof ft !== 'object') continue;
|
|
101
|
+
try {
|
|
102
|
+
insItem.run(prdId, ft.id || null, ft.name || null, ft.project || prd.project || null,
|
|
103
|
+
ft.status || null, ft.type || null, ft.workItemId || ft.work_item_id || null,
|
|
104
|
+
JSON.stringify(ft), now);
|
|
105
|
+
} catch { /* dup (prd_id, feature_id) → skip */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const insVp = db.prepare(`
|
|
109
|
+
INSERT INTO prd_verify_prs (prd_id, pr_id, url, title, status, project, merged_at, data, updated_at)
|
|
110
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
111
|
+
`);
|
|
112
|
+
const verifyPrs = Array.isArray(prd.verifyPrs) ? prd.verifyPrs : [];
|
|
113
|
+
for (const vp of verifyPrs) {
|
|
114
|
+
if (!vp || typeof vp !== 'object') continue;
|
|
115
|
+
try {
|
|
116
|
+
insVp.run(prdId, vp.id != null ? String(vp.id) : null, vp.url || null, vp.title || null,
|
|
117
|
+
vp.status || null, vp.project || prd.project || null, _toMs(vp.mergedAt || vp.merged_at),
|
|
118
|
+
JSON.stringify(vp), now);
|
|
119
|
+
} catch { /* best-effort */ }
|
|
120
|
+
}
|
|
121
|
+
return prdId;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Mirror a PRD object to SQL for the given prd/*.json path. Best-effort.
|
|
125
|
+
function mirrorPrdToSql(filePath, prd) {
|
|
126
|
+
const parsed = parsePrdPath(filePath);
|
|
127
|
+
if (!parsed || !prd || typeof prd !== 'object' || Array.isArray(prd)) return { ok: false, reason: 'not-a-prd' };
|
|
128
|
+
try {
|
|
129
|
+
const { getDb, withTransaction } = require('./db');
|
|
130
|
+
const db = getDb();
|
|
131
|
+
withTransaction(db, () => _upsertPrd(db, parsed.filename, parsed.archived, prd, Date.now()));
|
|
132
|
+
try { require('./db-events').emitStateEvent('prds', { filename: parsed.filename, archived: parsed.archived }); } catch { /* event optional */ }
|
|
133
|
+
return { ok: true };
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return { ok: false, reason: e && e.message };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Remove a PRD (and its items/verify rows) from SQL for the given path. Best-effort.
|
|
140
|
+
function removePrdFromSql(filePath) {
|
|
141
|
+
const parsed = parsePrdPath(filePath);
|
|
142
|
+
if (!parsed) return { ok: false, reason: 'not-a-prd' };
|
|
143
|
+
try {
|
|
144
|
+
const { getDb, withTransaction } = require('./db');
|
|
145
|
+
const db = getDb();
|
|
146
|
+
withTransaction(db, () => {
|
|
147
|
+
const row = db.prepare('SELECT id FROM prds WHERE filename=? AND archived=?').get(parsed.filename, parsed.archived);
|
|
148
|
+
if (!row) return;
|
|
149
|
+
db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(row.id);
|
|
150
|
+
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(row.id);
|
|
151
|
+
db.prepare('DELETE FROM prds WHERE id=?').run(row.id);
|
|
152
|
+
});
|
|
153
|
+
try { require('./db-events').emitStateEvent('prds', { filename: parsed.filename, archived: parsed.archived, removed: true }); } catch { /* optional */ }
|
|
154
|
+
return { ok: true };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return { ok: false, reason: e && e.message };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Read one PRD back from SQL as the original object (the `data` blob). Used by
|
|
161
|
+
// the divergence test now and the read-flip in step 3.
|
|
162
|
+
function readPrdFromSql(filePath) {
|
|
163
|
+
const parsed = parsePrdPath(filePath);
|
|
164
|
+
if (!parsed) return null;
|
|
165
|
+
try {
|
|
166
|
+
const { getDb } = require('./db');
|
|
167
|
+
const row = getDb().prepare('SELECT data FROM prds WHERE filename=? AND archived=?').get(parsed.filename, parsed.archived);
|
|
168
|
+
return row ? JSON.parse(row.data) : null;
|
|
169
|
+
} catch { return null; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Per-process mtime cache so reconciliation re-mirrors only files that actually
|
|
173
|
+
// changed on disk since this process last saw them (empty after restart → the
|
|
174
|
+
// first read re-mirrors everything once). Keyed `${archived} ${filename}`.
|
|
175
|
+
const _reconcileMtimes = new Map();
|
|
176
|
+
|
|
177
|
+
function _prdDirsFor(minionsDir) {
|
|
178
|
+
return [
|
|
179
|
+
{ dir: path.join(minionsDir, 'prd'), archived: 0 },
|
|
180
|
+
{ dir: path.join(minionsDir, 'prd', 'archive'), archived: 1 },
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Reconcile the SQL mirror from the prd/ + prd/archive/ directories: re-mirror
|
|
185
|
+
// files that are new or changed on disk (catches PRDs written OUTSIDE the
|
|
186
|
+
// mutateJsonFileLocked chokepoint — e.g. the plan-to-prd agent writing the JSON
|
|
187
|
+
// directly, or an archive file-move), and drop SQL rows whose backing file is
|
|
188
|
+
// gone. Best-effort; safe to call on every read. Returns a small stats object.
|
|
189
|
+
function reconcilePrdsFromDisk() {
|
|
190
|
+
let minionsDir;
|
|
191
|
+
try { minionsDir = require('./shared').MINIONS_DIR; } catch { return { ok: false }; }
|
|
192
|
+
if (!minionsDir) return { ok: false };
|
|
193
|
+
let db;
|
|
194
|
+
try { db = require('./db').getDb(); } catch { return { ok: false }; }
|
|
195
|
+
|
|
196
|
+
let mirrored = 0, removed = 0;
|
|
197
|
+
const seen = new Set(); // `${archived} ${filename}` present on disk
|
|
198
|
+
for (const { dir, archived } of _prdDirsFor(minionsDir)) {
|
|
199
|
+
let files;
|
|
200
|
+
try { files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); } catch { continue; }
|
|
201
|
+
for (const f of files) {
|
|
202
|
+
const key = `${archived} ${f}`;
|
|
203
|
+
seen.add(key);
|
|
204
|
+
const full = path.join(dir, f);
|
|
205
|
+
let mtime;
|
|
206
|
+
try { mtime = Math.round(fs.statSync(full).mtimeMs); } catch { continue; }
|
|
207
|
+
if (_reconcileMtimes.get(key) === mtime) continue; // unchanged since last seen
|
|
208
|
+
const prd = _readJsonObj(full);
|
|
209
|
+
if (!prd) continue;
|
|
210
|
+
try {
|
|
211
|
+
const { withTransaction } = require('./db');
|
|
212
|
+
withTransaction(db, () => _upsertPrd(db, f, archived, prd, Date.now()));
|
|
213
|
+
_reconcileMtimes.set(key, mtime);
|
|
214
|
+
mirrored += 1;
|
|
215
|
+
} catch { /* best-effort */ }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Drop rows whose backing file disappeared (delete/move not caught by a hook).
|
|
219
|
+
try {
|
|
220
|
+
const rows = db.prepare('SELECT id, filename, archived FROM prds').all();
|
|
221
|
+
for (const r of rows) {
|
|
222
|
+
const key = `${r.archived} ${r.filename}`;
|
|
223
|
+
if (seen.has(key)) continue;
|
|
224
|
+
try {
|
|
225
|
+
require('./db').withTransaction(db, () => {
|
|
226
|
+
db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(r.id);
|
|
227
|
+
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(r.id);
|
|
228
|
+
db.prepare('DELETE FROM prds WHERE id=?').run(r.id);
|
|
229
|
+
});
|
|
230
|
+
_reconcileMtimes.delete(key);
|
|
231
|
+
removed += 1;
|
|
232
|
+
} catch { /* best-effort */ }
|
|
233
|
+
}
|
|
234
|
+
} catch { /* best-effort */ }
|
|
235
|
+
return { ok: true, mirrored, removed };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// List every PRD row as { filename, archived, plan } where plan is the parsed
|
|
239
|
+
// data blob. Sorted by (archived, filename) for deterministic consumer order
|
|
240
|
+
// (matches the disk-scan order once the caller also sorts). Read-flip source.
|
|
241
|
+
function listPrdRows() {
|
|
242
|
+
try {
|
|
243
|
+
const db = require('./db').getDb();
|
|
244
|
+
const rows = db.prepare('SELECT filename, archived, data FROM prds ORDER BY archived, filename').all();
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const r of rows) {
|
|
247
|
+
let plan;
|
|
248
|
+
try { plan = JSON.parse(r.data); } catch { continue; }
|
|
249
|
+
out.push({ filename: r.filename, archived: !!r.archived, plan });
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
} catch { return []; }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
parsePrdPath,
|
|
257
|
+
mirrorPrdToSql,
|
|
258
|
+
removePrdFromSql,
|
|
259
|
+
readPrdFromSql,
|
|
260
|
+
reconcilePrdsFromDisk,
|
|
261
|
+
listPrdRows,
|
|
262
|
+
_reconcileMtimes, // exported for tests (reset between cases)
|
|
263
|
+
_upsertPrd, // exported for tests
|
|
264
|
+
};
|
package/engine/queries.js
CHANGED
|
@@ -2150,70 +2150,108 @@ function getPrdInfo(config) {
|
|
|
2150
2150
|
{ dir: PRD_DIR, archived: false },
|
|
2151
2151
|
{ dir: path.join(PRD_DIR, 'archive'), archived: true },
|
|
2152
2152
|
];
|
|
2153
|
-
|
|
2153
|
+
|
|
2154
|
+
// Phase 10 step 3 (read-flip) — both the disk scan and the SQL-mirror source
|
|
2155
|
+
// funnel each PRD through this one processor, so the consumed shape
|
|
2156
|
+
// (existingPrds / verifyPrsByPlan / allPrdItems) is identical by construction.
|
|
2157
|
+
// The only difference is where `plan` + `mtimeMs` come from. Equivalence of the
|
|
2158
|
+
// two paths is proven by test/unit/db-phase10-read-flip.test.js.
|
|
2159
|
+
const processPrd = (pf, archived, plan, mtimeMs) => {
|
|
2160
|
+
if (!plan || !plan.missing_features) return;
|
|
2161
|
+
// Staleness: gate on actual source-plan content change rather than mtime
|
|
2162
|
+
// alone (W-mqfevwr60018bd09). Pure path / pure mtime drifts return
|
|
2163
|
+
// stale=false so a repointed source_plan doesn't masquerade as a revision.
|
|
2164
|
+
let planStale = false;
|
|
2165
|
+
if (!archived && plan.source_plan) {
|
|
2166
|
+
try { planStale = shared.isSourcePlanContentStale(PLANS_DIR, plan).stale; } catch { /* optional */ }
|
|
2167
|
+
}
|
|
2168
|
+
existingPrds.push({
|
|
2169
|
+
file: pf,
|
|
2170
|
+
status: plan.status || 'active',
|
|
2171
|
+
planStale: planStale || plan.planStale || false,
|
|
2172
|
+
completedAt: plan.completedAt || '',
|
|
2173
|
+
_archived: archived,
|
|
2174
|
+
});
|
|
2175
|
+
if (Array.isArray(plan.verifyPrs) && plan.verifyPrs.length > 0) {
|
|
2176
|
+
verifyPrsByPlan[pf] = plan.verifyPrs.map(r => ({
|
|
2177
|
+
id: r.id, url: r.url || '', title: r.title || '',
|
|
2178
|
+
status: r.status || 'active', project: r.project || '',
|
|
2179
|
+
...(r.mergedAt ? { mergedAt: r.mergedAt } : {}),
|
|
2180
|
+
}));
|
|
2181
|
+
}
|
|
2182
|
+
for (const f of plan.missing_features) {
|
|
2183
|
+
allPrdItems.push({
|
|
2184
|
+
...f, _source: pf, _planStatus: plan.status || 'active',
|
|
2185
|
+
_planSummary: plan.plan_summary || pf, _planProject: plan.project || '',
|
|
2186
|
+
_archived: archived, _sourcePlan: plan.source_plan || '',
|
|
2187
|
+
_branchStrategy: plan.branch_strategy || 'parallel',
|
|
2188
|
+
_planStale: planStale || plan.planStale || false, _lastSyncedFromPlan: plan.lastSyncedFromPlan || null,
|
|
2189
|
+
_prdUpdatedAt: new Date(mtimeMs).toISOString(),
|
|
2190
|
+
_prdCompletedAt: plan.completedAt || '',
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
|
|
2195
|
+
let useSqlReads = false;
|
|
2196
|
+
try { useSqlReads = require('./features').isFeatureOn('prdReadsFromSql', config); } catch { /* fall back to disk */ }
|
|
2197
|
+
|
|
2198
|
+
if (useSqlReads) {
|
|
2199
|
+
// SQL source: reconcile the mirror from disk (catches PRDs written outside
|
|
2200
|
+
// the dual-write chokepoint — plan-to-prd agent direct writes, archive
|
|
2201
|
+
// moves, deletes), then read each PRD's content from SQL. Files still live on
|
|
2202
|
+
// disk in this phase, so stat them for the mtime fields + `age`.
|
|
2154
2203
|
try {
|
|
2155
|
-
const
|
|
2156
|
-
|
|
2204
|
+
const prdStore = require('./prd-store');
|
|
2205
|
+
prdStore.reconcilePrdsFromDisk();
|
|
2206
|
+
for (const { filename, archived, plan } of prdStore.listPrdRows()) {
|
|
2157
2207
|
try {
|
|
2158
|
-
const
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
plan = cached.plan;
|
|
2167
|
-
} else {
|
|
2168
|
-
plan = readJsonNoRestore(filePath);
|
|
2169
|
-
_prdFileCache.set(filePath, { mtimeMs: stat.mtimeMs, plan });
|
|
2170
|
-
}
|
|
2171
|
-
if (!plan || !plan.missing_features) continue;
|
|
2172
|
-
|
|
2173
|
-
// Staleness: gate on actual source-plan content change rather
|
|
2174
|
-
// than mtime alone (W-mqfevwr60018bd09). Pure path / pure mtime
|
|
2175
|
-
// drifts return stale=false here so a repointed source_plan
|
|
2176
|
-
// doesn't masquerade as a real revision in the cached PRD info.
|
|
2177
|
-
let planStale = false;
|
|
2178
|
-
if (!archived && plan.source_plan) {
|
|
2179
|
-
try {
|
|
2180
|
-
planStale = shared.isSourcePlanContentStale(PLANS_DIR, plan).stale;
|
|
2181
|
-
} catch { /* optional */ }
|
|
2182
|
-
}
|
|
2183
|
-
existingPrds.push({
|
|
2184
|
-
file: pf,
|
|
2185
|
-
status: plan.status || 'active',
|
|
2186
|
-
planStale: planStale || plan.planStale || false,
|
|
2187
|
-
completedAt: plan.completedAt || '',
|
|
2188
|
-
_archived: archived,
|
|
2189
|
-
});
|
|
2190
|
-
if (Array.isArray(plan.verifyPrs) && plan.verifyPrs.length > 0) {
|
|
2191
|
-
verifyPrsByPlan[pf] = plan.verifyPrs.map(r => ({
|
|
2192
|
-
id: r.id, url: r.url || '', title: r.title || '',
|
|
2193
|
-
status: r.status || 'active', project: r.project || '',
|
|
2194
|
-
...(r.mergedAt ? { mergedAt: r.mergedAt } : {}),
|
|
2195
|
-
}));
|
|
2196
|
-
}
|
|
2197
|
-
for (const f of plan.missing_features) {
|
|
2198
|
-
allPrdItems.push({
|
|
2199
|
-
...f, _source: pf, _planStatus: plan.status || 'active',
|
|
2200
|
-
_planSummary: plan.plan_summary || pf, _planProject: plan.project || '',
|
|
2201
|
-
_archived: archived, _sourcePlan: plan.source_plan || '',
|
|
2202
|
-
_branchStrategy: plan.branch_strategy || 'parallel',
|
|
2203
|
-
_planStale: planStale || plan.planStale || false, _lastSyncedFromPlan: plan.lastSyncedFromPlan || null,
|
|
2204
|
-
_prdUpdatedAt: new Date(stat.mtimeMs).toISOString(),
|
|
2205
|
-
_prdCompletedAt: plan.completedAt || '',
|
|
2206
|
-
});
|
|
2207
|
-
}
|
|
2208
|
+
const dir = archived ? path.join(PRD_DIR, 'archive') : PRD_DIR;
|
|
2209
|
+
let mtimeMs = 0;
|
|
2210
|
+
try {
|
|
2211
|
+
const stat = fs.statSync(path.join(dir, filename));
|
|
2212
|
+
mtimeMs = stat.mtimeMs;
|
|
2213
|
+
if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
|
|
2214
|
+
} catch { /* file may have just moved/deleted between reconcile and read */ }
|
|
2215
|
+
processPrd(filename, archived, plan, mtimeMs);
|
|
2208
2216
|
} catch { /* optional */ }
|
|
2209
2217
|
}
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2218
|
+
} catch {
|
|
2219
|
+
// Hard SQL failure → fall back to the disk scan so the dashboard never
|
|
2220
|
+
// goes blank on a transient DB problem.
|
|
2221
|
+
useSqlReads = false;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
if (!useSqlReads) {
|
|
2226
|
+
for (const { dir, archived } of planDirs) {
|
|
2227
|
+
try {
|
|
2228
|
+
const planFiles = fs.readdirSync(dir).filter(f => f.endsWith('.json')).sort();
|
|
2229
|
+
for (const pf of planFiles) {
|
|
2230
|
+
try {
|
|
2231
|
+
const filePath = path.join(dir, pf);
|
|
2232
|
+
const stat = fs.statSync(filePath);
|
|
2233
|
+
if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
|
|
2234
|
+
|
|
2235
|
+
// Per-file mtime cache: only re-read files that changed
|
|
2236
|
+
const cached = _prdFileCache.get(filePath);
|
|
2237
|
+
let plan;
|
|
2238
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
2239
|
+
plan = cached.plan;
|
|
2240
|
+
} else {
|
|
2241
|
+
plan = readJsonNoRestore(filePath);
|
|
2242
|
+
_prdFileCache.set(filePath, { mtimeMs: stat.mtimeMs, plan });
|
|
2243
|
+
}
|
|
2244
|
+
processPrd(pf, archived, plan, stat.mtimeMs);
|
|
2245
|
+
} catch { /* optional */ }
|
|
2214
2246
|
}
|
|
2215
|
-
|
|
2216
|
-
|
|
2247
|
+
// Clean stale entries from file cache when dirs changed
|
|
2248
|
+
if (dirsChanged) {
|
|
2249
|
+
for (const cachedPath of _prdFileCache.keys()) {
|
|
2250
|
+
if (cachedPath.startsWith(dir) && !fs.existsSync(cachedPath)) _prdFileCache.delete(cachedPath);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
} catch { /* optional */ }
|
|
2254
|
+
}
|
|
2217
2255
|
}
|
|
2218
2256
|
|
|
2219
2257
|
if (allPrdItems.length === 0) return { progress: null, status: null };
|