@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.
@@ -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:
@@ -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
- const prs = safeJson(projectPrPath(project)) || [];
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
- const freshPrs = safeJson(projectPrPath(project)) || [];
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
  };
@@ -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.
@@ -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
- // Pluralisation: /^## Tools?\b/m matches both '## Tool' and '## Tools'.
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, label: '## Your Task' },
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
- for (const { dir, archived } of planDirs) {
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 planFiles = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
2156
- for (const pf of planFiles) {
2204
+ const prdStore = require('./prd-store');
2205
+ prdStore.reconcilePrdsFromDisk();
2206
+ for (const { filename, archived, plan } of prdStore.listPrdRows()) {
2157
2207
  try {
2158
- const filePath = path.join(dir, pf);
2159
- const stat = fs.statSync(filePath);
2160
- if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
2161
-
2162
- // Per-file mtime cache: only re-read files that changed
2163
- const cached = _prdFileCache.get(filePath);
2164
- let plan;
2165
- if (cached && cached.mtimeMs === stat.mtimeMs) {
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
- // Clean stale entries from file cache when dirs changed
2211
- if (dirsChanged) {
2212
- for (const cachedPath of _prdFileCache.keys()) {
2213
- if (cachedPath.startsWith(dir) && !fs.existsSync(cachedPath)) _prdFileCache.delete(cachedPath);
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
- } catch { /* optional */ }
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 };