@yemi33/minions 0.1.2292 → 0.1.2293

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/engine/cli.js CHANGED
@@ -624,6 +624,19 @@ const commands = {
624
624
  }
625
625
  } catch (err) { e.log('warn', `note-link backfill failed: ${err.message}`); }
626
626
 
627
+ // Backfill work_items.prd_item_id (the WI↔PRD-item FK) for rows that predate
628
+ // the dual-write stamp (#546) or that a JSON re-hydrate transiently zeroed.
629
+ // Set-based, only touches NULL FKs, no-op once everything is linked — safe on
630
+ // every boot. Lets the Phase-10 render join move off the feature-id string.
631
+ try {
632
+ const fk = require('./prd-store').backfillWorkItemPrdItemIds();
633
+ const filled = (fk.byWorkItemId || 0) + (fk.bySourcePlan || 0);
634
+ if (fk.ok && filled > 0) {
635
+ e.log('info', `Backfilled prd_item_id on ${filled} work item(s) (${fk.byWorkItemId} by workItemId, ${fk.bySourcePlan} by sourcePlan)`);
636
+ console.log(` Linked ${filled} work item(s) to their PRD item.`);
637
+ }
638
+ } catch (err) { e.log('warn', `prd_item_id backfill failed: ${err.message}`); }
639
+
627
640
  // Auto-heal projects missing workSources (cloned-repo / hand-rolled-config
628
641
  // footgun): without this block, discoverFromWorkItems / discoverFromPrs
629
642
  // bail silently and the engine looks healthy but never dispatches. The
@@ -92,6 +92,21 @@ const FEATURES = {
92
92
  addedIn: '0.1.2090',
93
93
  expires: '2026-12-01',
94
94
  },
95
+ // prdJoinFromFk — Phase 10 step 4.3 render-join flip. When ON, getPrdInfo
96
+ // resolves each PRD item to its work item via the stable SQL FK
97
+ // (work_items.prd_item_id → prd_items.id) instead of the fragile feature-id
98
+ // string match (work_item.id === feature.id), which bleeds when a live and an
99
+ // archived PRD share a feature id (footgun #7). PURELY ADDITIVE: the FK lookup
100
+ // is tried first and falls back to the exact legacy id match for any row not
101
+ // yet stamped, when the flag is OFF, or when SQL is unavailable — so flipping
102
+ // it off (or a NULL FK) reproduces current behavior byte-for-byte. Requires
103
+ // prdReadsFromSql (the FK only exists in SQL). Temporary migration gate.
104
+ 'prdJoinFromFk': {
105
+ description: 'Resolve the dashboard WI↔PRD-item join via the SQL FK (work_items.prd_item_id) instead of the feature-id string match. Additive with a string-match fallback; reversible (Phase 10 step 4.3).',
106
+ default: true,
107
+ addedIn: '0.1.2232',
108
+ expires: '2026-12-01',
109
+ },
95
110
  };
96
111
 
97
112
  const ENV_TRUTHY = new Set(['1', 'true', 'on', 'yes']);
@@ -2496,7 +2496,7 @@ function fixCompletionChangedBranch(structuredCompletion) {
2496
2496
  }
2497
2497
 
2498
2498
  function normalizePrFixBranchName(branch) {
2499
- return String(branch || '').trim().replace(/^refs\/heads\//, '');
2499
+ return String(branch || '').trim().replace(/^refs\/heads\//i, '');
2500
2500
  }
2501
2501
 
2502
2502
  function getPrFixBaselineHead(pr) {
@@ -6300,6 +6300,8 @@ module.exports = {
6300
6300
  enrollPrFromCanonicalId,
6301
6301
  _setEnrollmentGhRunnerForTest,
6302
6302
  // W-mqtplpk6001oe6d5 — stamp PR ref + workItemId onto WI and PRD item at completion time.
6303
+ // W-mqvwzgi500259361 — exported for unit testing case-insensitive refs/heads/ stripping.
6304
+ normalizePrFixBranchName,
6303
6305
  stampWiPrRef,
6304
6306
  stampPrdItemWorkItemId,
6305
6307
  // M003 — auto-dispatch live-validation WI after coding WI completion.
@@ -74,7 +74,10 @@ function _upsertPrd(db, filename, archived, prd, now) {
74
74
  sourcePlan, prd.sourcePlanModifiedAt || prd.source_plan_modified_at || null,
75
75
  JSON.stringify(prd), now, prdId,
76
76
  );
77
- db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(prdId);
77
+ // NOTE: prd_items are NOT bulk-deleted here — they are upserted by their
78
+ // stable (prd_id, feature_id) key below so prd_items.id survives a re-mirror
79
+ // (work_items.prd_item_id is an FK to it). prd_verify_prs carries no FK, so
80
+ // a cheap delete+reinsert is fine.
78
81
  db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(prdId);
79
82
  } else {
80
83
  const info = db.prepare(`
@@ -91,18 +94,73 @@ function _upsertPrd(db, filename, archived, prd, now) {
91
94
  prdId = Number(info.lastInsertRowid);
92
95
  }
93
96
 
94
- const insItem = db.prepare(`
97
+ // Upsert prd_items by their STABLE natural key (prd_id, feature_id) instead of
98
+ // delete-all-then-reinsert, so the surrogate prd_items.id is durable across
99
+ // re-mirrors. work_items.prd_item_id is an FK to this id; churning it (via
100
+ // AUTOINCREMENT on every mirror) dangled every stamped row — the Phase-10
101
+ // step-4 join-flip blocker. feature_id-less legacy items can't be keyed (NULL
102
+ // != NULL under UNIQUE), so they are swept + reinserted each time; they are
103
+ // never FK targets, so their churn is harmless.
104
+ const features = Array.isArray(prd.missing_features) ? prd.missing_features : [];
105
+ const keepIds = [];
106
+ for (const ft of features) { if (ft && typeof ft === 'object' && ft.id) keepIds.push(String(ft.id)); }
107
+ // Drop id-bearing items no longer on the PRD, plus all NULL-feature_id rows.
108
+ if (keepIds.length) {
109
+ db.prepare(
110
+ `DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NOT NULL AND feature_id NOT IN (${keepIds.map(() => '?').join(',')})`
111
+ ).run(prdId, ...keepIds);
112
+ } else {
113
+ db.prepare('DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NOT NULL').run(prdId);
114
+ }
115
+ db.prepare('DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NULL').run(prdId);
116
+
117
+ const upsertItem = db.prepare(`
95
118
  INSERT INTO prd_items (prd_id, feature_id, name, project, status, type, work_item_id, data, updated_at)
96
119
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
120
+ ON CONFLICT(prd_id, feature_id) DO UPDATE SET
121
+ name=excluded.name, project=excluded.project, status=excluded.status,
122
+ type=excluded.type, work_item_id=excluded.work_item_id,
123
+ data=excluded.data, updated_at=excluded.updated_at
97
124
  `);
98
- const features = Array.isArray(prd.missing_features) ? prd.missing_features : [];
125
+ const insItemNoId = db.prepare(`
126
+ INSERT INTO prd_items (prd_id, feature_id, name, project, status, type, work_item_id, data, updated_at)
127
+ VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)
128
+ `);
129
+ const selItemId = db.prepare('SELECT id FROM prd_items WHERE prd_id=? AND feature_id=?');
130
+ // Stamp the materialized work item(s) → prd_items.id. The two backref paths
131
+ // mirror the migration-015 backfill: an explicit feature.workItemId (set by
132
+ // lifecycle.stampPrdItemWorkItemId), and the sourcePlan(=PRD filename) +
133
+ // feature-id equality the legacy JSON model joined on. Self-heals on every
134
+ // mirror, so a WI materialized after this PRD was first seen still gets its FK.
135
+ const stampWiById = db.prepare(
136
+ 'UPDATE work_items SET prd_item_id=? WHERE id=? AND (prd_item_id IS NULL OR prd_item_id<>?)'
137
+ );
138
+ const stampWiBySourcePlan = db.prepare(
139
+ `UPDATE work_items SET prd_item_id=? WHERE json_extract(data,'$.sourcePlan')=? AND json_extract(data,'$.id')=? AND (prd_item_id IS NULL OR prd_item_id<>?)`
140
+ );
99
141
  for (const ft of features) {
100
142
  if (!ft || typeof ft !== 'object') continue;
143
+ const proj = ft.project || prd.project || null;
144
+ const explicitWi = ft.workItemId || ft.work_item_id || null;
145
+ if (!ft.id) {
146
+ try {
147
+ insItemNoId.run(prdId, ft.name || null, proj, ft.status || null, ft.type || null,
148
+ explicitWi, JSON.stringify(ft), now);
149
+ } catch { /* best-effort */ }
150
+ continue;
151
+ }
152
+ const featureId = String(ft.id);
101
153
  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 */ }
154
+ upsertItem.run(prdId, featureId, ft.name || null, proj, ft.status || null, ft.type || null,
155
+ explicitWi, JSON.stringify(ft), now);
156
+ } catch { continue; }
157
+ let prdItemId;
158
+ try { prdItemId = selItemId.get(prdId, featureId)?.id; } catch { /* ignore */ }
159
+ if (!prdItemId) continue;
160
+ if (explicitWi) {
161
+ try { stampWiById.run(prdItemId, String(explicitWi), prdItemId); } catch { /* best-effort */ }
162
+ }
163
+ try { stampWiBySourcePlan.run(prdItemId, filename, featureId, prdItemId); } catch { /* best-effort */ }
106
164
  }
107
165
 
108
166
  const insVp = db.prepare(`
@@ -235,6 +293,53 @@ function reconcilePrdsFromDisk() {
235
293
  return { ok: true, mirrored, removed };
236
294
  }
237
295
 
296
+ // Idempotent boot sweep: populate work_items.prd_item_id for rows that predate
297
+ // the dual-write stamp (everything materialized before #546), or that a JSON
298
+ // re-hydrate transiently zeroed. The per-mirror stamp in _upsertPrd only fires
299
+ // for PRDs that are re-mirrored, and reconcile is mtime-gated — so a PRD that
300
+ // never changes again never re-stamps its historical WIs. This catch-up is
301
+ // set-based (two SQL UPDATEs, no per-row loop), only touches NULL FKs, and
302
+ // prefers the live (archived=0) prd_item when a basename exists in both buckets.
303
+ // Safe to run on every boot — a no-op once every matchable WI is linked.
304
+ // Returns { ok, byWorkItemId, bySourcePlan } counts.
305
+ function backfillWorkItemPrdItemIds() {
306
+ let db;
307
+ try { db = require('./db').getDb(); } catch { return { ok: false }; }
308
+ try {
309
+ return require('./db').withTransaction(db, () => {
310
+ // Path 1 — explicit prd_items.work_item_id backref (most precise).
311
+ const r1 = db.prepare(`
312
+ UPDATE work_items SET prd_item_id = (
313
+ SELECT pi.id FROM prd_items pi JOIN prds p ON p.id = pi.prd_id
314
+ WHERE pi.work_item_id = work_items.id
315
+ ORDER BY p.archived ASC, pi.id ASC LIMIT 1
316
+ )
317
+ WHERE prd_item_id IS NULL
318
+ AND EXISTS (SELECT 1 FROM prd_items pi WHERE pi.work_item_id = work_items.id)
319
+ `).run();
320
+ // Path 2 — sourcePlan(=PRD filename) + feature-id equality on the WI blob,
321
+ // for WIs the PRD side never stamped with an explicit workItemId.
322
+ const r2 = db.prepare(`
323
+ UPDATE work_items SET prd_item_id = (
324
+ SELECT pi.id FROM prd_items pi JOIN prds p ON p.id = pi.prd_id
325
+ WHERE p.filename = json_extract(work_items.data, '$.sourcePlan')
326
+ AND pi.feature_id = json_extract(work_items.data, '$.id')
327
+ ORDER BY p.archived ASC, pi.id ASC LIMIT 1
328
+ )
329
+ WHERE prd_item_id IS NULL
330
+ AND EXISTS (
331
+ SELECT 1 FROM prd_items pi JOIN prds p ON p.id = pi.prd_id
332
+ WHERE p.filename = json_extract(work_items.data, '$.sourcePlan')
333
+ AND pi.feature_id = json_extract(work_items.data, '$.id')
334
+ )
335
+ `).run();
336
+ return { ok: true, byWorkItemId: Number(r1.changes) || 0, bySourcePlan: Number(r2.changes) || 0 };
337
+ });
338
+ } catch (e) {
339
+ return { ok: false, reason: e && e.message };
340
+ }
341
+ }
342
+
238
343
  // List every PRD row as { filename, archived, plan } where plan is the parsed
239
344
  // data blob. Sorted by (archived, filename) for deterministic consumer order
240
345
  // (matches the disk-scan order once the caller also sorts). Read-flip source.
@@ -258,6 +363,7 @@ module.exports = {
258
363
  removePrdFromSql,
259
364
  readPrdFromSql,
260
365
  reconcilePrdsFromDisk,
366
+ backfillWorkItemPrdItemIds,
261
367
  listPrdRows,
262
368
  _reconcileMtimes, // exported for tests (reset between cases)
263
369
  _upsertPrd, // exported for tests
package/engine/queries.js CHANGED
@@ -2151,6 +2151,16 @@ function getPrdInfo(config) {
2151
2151
  { dir: path.join(PRD_DIR, 'archive'), archived: true },
2152
2152
  ];
2153
2153
 
2154
+ // Phase 10 step 4.3 — FK-based WI↔PRD-item join scaffolding. `prdItemIdByKey`
2155
+ // maps `${physicalArchived} ${filename} ${feature_id}` → prd_items.id so each
2156
+ // PRD item carries its stable surrogate id; built below once the SQL-read
2157
+ // decision is known. Declared here so processPrd's closure can stamp
2158
+ // `_prdItemId` at push time. Null when the FK join is off / SQL unavailable →
2159
+ // the per-item join falls back to the legacy feature-id string match.
2160
+ let useFkJoin = false;
2161
+ try { useFkJoin = require('./features').isFeatureOn('prdJoinFromFk', config); } catch { /* fall back to string join */ }
2162
+ let prdItemIdByKey = null;
2163
+
2154
2164
  // Phase 10 step 3 (read-flip) — both the disk scan and the SQL-mirror source
2155
2165
  // funnel each PRD through this one processor, so the consumed shape
2156
2166
  // (existingPrds / verifyPrsByPlan / allPrdItems) is identical by construction.
@@ -2187,6 +2197,11 @@ function getPrdInfo(config) {
2187
2197
  }));
2188
2198
  }
2189
2199
  for (const f of plan.missing_features) {
2200
+ // Stamp the stable surrogate prd_items.id, keyed on the PHYSICAL archived
2201
+ // bit (the `archived` param == SQL location), not the logical `isArch`.
2202
+ const prdItemId = (prdItemIdByKey && f && f.id != null)
2203
+ ? (prdItemIdByKey.get(`${archived ? 1 : 0} ${pf} ${f.id}`) ?? null)
2204
+ : null;
2190
2205
  allPrdItems.push({
2191
2206
  ...f, _source: pf, _planStatus: plan.status || 'active',
2192
2207
  _planSummary: plan.plan_summary || pf, _planProject: plan.project || '',
@@ -2195,6 +2210,7 @@ function getPrdInfo(config) {
2195
2210
  _planStale: planStale || plan.planStale || false, _lastSyncedFromPlan: plan.lastSyncedFromPlan || null,
2196
2211
  _prdUpdatedAt: new Date(mtimeMs).toISOString(),
2197
2212
  _prdCompletedAt: plan.completedAt || '',
2213
+ _prdItemId: prdItemId,
2198
2214
  });
2199
2215
  }
2200
2216
  };
@@ -2202,6 +2218,21 @@ function getPrdInfo(config) {
2202
2218
  let useSqlReads = false;
2203
2219
  try { useSqlReads = require('./features').isFeatureOn('prdReadsFromSql', config); } catch { /* fall back to disk */ }
2204
2220
 
2221
+ // Build the feature → prd_items.id map before the read loop so processPrd can
2222
+ // stamp _prdItemId. Only when the FK join is on AND we're SQL-sourced (the FK
2223
+ // lives only in SQL). Best-effort: any failure leaves prdItemIdByKey null and
2224
+ // the join silently falls back to the legacy feature-id string match.
2225
+ if (useFkJoin && useSqlReads) {
2226
+ try {
2227
+ const db = require('./db').getDb();
2228
+ const map = new Map();
2229
+ for (const r of db.prepare('SELECT p.filename AS filename, p.archived AS archived, pi.feature_id AS feature_id, pi.id AS id FROM prd_items pi JOIN prds p ON p.id = pi.prd_id WHERE pi.feature_id IS NOT NULL').all()) {
2230
+ map.set(`${r.archived ? 1 : 0} ${r.filename} ${r.feature_id}`, r.id);
2231
+ }
2232
+ prdItemIdByKey = map;
2233
+ } catch { prdItemIdByKey = null; }
2234
+ }
2235
+
2205
2236
  if (useSqlReads) {
2206
2237
  // SQL source: reconcile the mirror from disk (catches PRDs written outside
2207
2238
  // the dual-write chokepoint — plan-to-prd agent direct writes, archive
@@ -2293,6 +2324,29 @@ function getPrdInfo(config) {
2293
2324
  }
2294
2325
  } catch { /* optional */ }
2295
2326
 
2327
+ // Phase 10 step 4.3 — index the (JSON-sourced) work items by their stable SQL
2328
+ // FK so the per-item join can resolve PRD item → WI by prd_items.id rather
2329
+ // than the feature-id string. The FK itself lives only in SQL (it's not in the
2330
+ // JSON mirror), so read work_items.prd_item_id once and join it to the WI
2331
+ // objects we already loaded, keyed by wi.id (first-wins, matching allWiById).
2332
+ // Best-effort: null map → the join falls back to the legacy string match.
2333
+ let wiByPrdItemId = null;
2334
+ if (useFkJoin && useSqlReads && prdItemIdByKey) {
2335
+ try {
2336
+ const db = require('./db').getDb();
2337
+ const fkByWiId = new Map();
2338
+ for (const r of db.prepare('SELECT id, prd_item_id FROM work_items WHERE prd_item_id IS NOT NULL').all()) {
2339
+ if (!fkByWiId.has(r.id)) fkByWiId.set(r.id, r.prd_item_id);
2340
+ }
2341
+ const map = {};
2342
+ for (const wi of Object.values(allWiById)) {
2343
+ const fk = fkByWiId.get(wi.id);
2344
+ if (fk != null && map[fk] === undefined) map[fk] = wi;
2345
+ }
2346
+ wiByPrdItemId = map;
2347
+ } catch { wiByPrdItemId = null; }
2348
+ }
2349
+
2296
2350
  // PR-to-PRD linking — derived from PR.prdItems (single source of truth).
2297
2351
  // getPullRequests includes records from unconfigured project subdirs so PRD
2298
2352
  // links can resolve to last-known status even after a project is removed.
@@ -2388,7 +2442,14 @@ function getPrdInfo(config) {
2388
2442
  // Augment each item with execution metadata from the work item.
2389
2443
  const statusDisplay = { pending: 'missing', dispatched: 'in-progress' };
2390
2444
  for (const item of items) {
2391
- const wi = wiById[item.id];
2445
+ // Phase 10 step 4.3 — resolve the work item via the stable SQL FK
2446
+ // (item._prdItemId → prd_items.id → work_items.prd_item_id) first; fall back
2447
+ // to the legacy feature-id string match (wiById[item.id]) for unstamped rows,
2448
+ // a NULL FK, the flag OFF, or SQL unavailable. The fallback reproduces the
2449
+ // pre-flip behavior exactly, so a live+archived feature-id collision (footgun
2450
+ // #7) is corrected when the FK is present and degrades to legacy otherwise.
2451
+ const wi = (wiByPrdItemId && item._prdItemId != null && wiByPrdItemId[item._prdItemId])
2452
+ || wiById[item.id];
2392
2453
  // PRD 'updated'/'missing' = intentional rework signal — takes priority over a done work item (#930).
2393
2454
  // Otherwise work item status is source of truth when available (PRD JSON may lag behind).
2394
2455
  // If PRD says dispatched/failed but no work item exists, treat as pending (orphaned — #779)
package/engine/shared.js CHANGED
@@ -1005,14 +1005,18 @@ function _routeJsonReadToSql(p) {
1005
1005
  * stale `.backup` is actively harmful. See its JSDoc for selection guidance.
1006
1006
  */
1007
1007
 
1008
- // PL-prd-no-backup (W-mqub65ez0004b3bd) — archived PRDs under prd/archive/ are
1009
- // permanently removed terminal artifacts. They must NOT auto-restore from a
1010
- // stale `.backup` sidecar (W-mouptdh1000h9f39: archived PRD came back and
1011
- // re-dispatched work). Intentionally NARROW to prd/archive/ only:
1012
- // - prd/archive/*.json no backup write + no safeJson restore
1013
- // - prd/*.json (root-level canonical) → normal backup/restore lifecycle
1014
- // preserved so concurrent-sweep loss is recoverable (prd-rename-race.test.js).
1015
- const _NO_BACKUP_JSON_RE = /(?:^|[\\/])prd[\\/]archive[\\/][^\\/]+\.json$/i;
1008
+ // PL-prd-no-backup (W-mqub65ez0004b3bd) — PRD `.json` files must NOT auto-restore
1009
+ // from a stale `.backup` sidecar: a removed/archived PRD coming back and
1010
+ // re-dispatching work is the original resurrection footgun (W-mouptdh1000h9f39).
1011
+ // Phase 10 step 4.3 BROADENED from prd/archive/ to ALL prd/*.json (root +
1012
+ // archive). The root-level backup lifecycle previously survived ONLY to recover
1013
+ // the enforceDeclaredPlanProject rename-race (a concurrent sweep deleting a
1014
+ // just-renamed PRD); that rename is now retired and SQL is the canonical store,
1015
+ // so the sidecar is pure resurrection fuel with no remaining benefit:
1016
+ // - prd/*.json AND prd/archive/*.json → no backup write + no safeJson restore.
1017
+ // Matches a single filename segment under prd/ (optionally one archive/ level),
1018
+ // so nested non-PRD paths and plans/ (which are .md) are unaffected.
1019
+ const _NO_BACKUP_JSON_RE = /(?:^|[\\/])prd[\\/](?:archive[\\/])?[^\\/]+\.json$/i;
1016
1020
  function _isNoBackupJsonPath(p) {
1017
1021
  return typeof p === 'string' && _NO_BACKUP_JSON_RE.test(p);
1018
1022
  }
@@ -273,6 +273,18 @@ function _fileContentHash(filePath) {
273
273
 
274
274
  function _hydrateScopeFromJson(db, scope) {
275
275
  const jsonItems = _readJsonArrayFallback(scope);
276
+ // Preserve the SQL-only prd_item_id FK across the delete+reinsert. It is owned
277
+ // by the PRD dual-write (prd-store.js#_upsertPrd), NOT represented in the JSON
278
+ // mirror, so a naive rebuild-from-JSON silently zeroes it for the whole scope —
279
+ // regressing the WI↔PRD link every time the JSON diverges (the count would
280
+ // oscillate as the PRD mirror re-stamps and the next hydrate wipes it again).
281
+ // Snapshot before the DELETE, restore onto the rows that survive the rebuild.
282
+ const fkById = new Map();
283
+ try {
284
+ for (const r of db.prepare('SELECT id, prd_item_id FROM work_items WHERE scope = ? AND prd_item_id IS NOT NULL').all(scope)) {
285
+ fkById.set(r.id, r.prd_item_id);
286
+ }
287
+ } catch { /* column absent on a pre-v15 schema — nothing to preserve */ }
276
288
  // DELETE before re-insert: callers that wrote a smaller JSON file
277
289
  // (test cleanup() removing items) must end up with a smaller SQL state.
278
290
  db.prepare('DELETE FROM work_items WHERE scope = ?').run(scope);
@@ -298,6 +310,14 @@ function _hydrateScopeFromJson(db, scope) {
298
310
  now,
299
311
  );
300
312
  }
313
+ // Restore the FK for rows that still exist after the rebuild (a WI dropped
314
+ // from the JSON is gone, and its link goes with it — correct).
315
+ if (fkById.size) {
316
+ const restore = db.prepare('UPDATE work_items SET prd_item_id = ? WHERE scope = ? AND id = ?');
317
+ for (const [id, fk] of fkById) {
318
+ try { restore.run(fk, scope, id); } catch { /* best effort */ }
319
+ }
320
+ }
301
321
  }
302
322
 
303
323
  function applyWorkItemsMutation(scope, mutator) {
@@ -375,4 +395,5 @@ module.exports = {
375
395
  dropScope,
376
396
  _filePathForScope,
377
397
  _mirrorJsonFromSql,
398
+ _hydrateScopeFromJson, // exported for testing (prd_item_id-preservation)
378
399
  };
package/engine.js CHANGED
@@ -196,9 +196,15 @@ function cleanupTempAgent(agentId) {
196
196
  try {
197
197
  const agentDir = path.join(AGENTS_DIR, agentId);
198
198
  // Keep output archive but remove temp agent directory (live-output.log etc.)
199
- fs.rmSync(agentDir, { recursive: true, force: true });
199
+ // Use _retryFsOp so Windows EBUSY/EPERM (AV / file indexer lock) is retried.
200
+ shared._retryFsOp(
201
+ () => fs.rmSync(agentDir, { recursive: true, force: true }),
202
+ `cleanupTempAgent(${agentId})`
203
+ );
200
204
  log('info', `Temp agent ${agentId} cleaned up`);
201
- } catch { /* cleanup */ }
205
+ } catch (err) {
206
+ log('warn', `cleanupTempAgent: fs.rmSync failed for ${agentId}: ${err && err.message}`);
207
+ }
202
208
  }
203
209
 
204
210
  // Per-tick cache of refs that failed to fetch — avoids repeating 30s ETIMEDOUT for same missing ref
@@ -974,7 +980,17 @@ async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeC
974
980
  await shared.shellSafeGit(['worktree', 'remove', '--force', worktreePath], { ...gitOpts, cwd: rootDir, timeout: 30000 });
975
981
  } catch (rmErr) {
976
982
  log('warn', `runWorktreeAdd: worktree remove after partial checkout failed (${rmErr.message}) — falling back to fs-level cleanup`);
977
- try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch {}
983
+ try {
984
+ // W-mqw3g5be000s934f: use _retryFsOp so transient Windows EBUSY/EPERM from
985
+ // antivirus or GVFS virtual-filesystem handles do not silently abandon cleanup.
986
+ shared._retryFsOp(
987
+ () => fs.rmSync(worktreePath, { recursive: true, force: true }),
988
+ `runWorktreeAdd: fs-level cleanup ${worktreePath}`,
989
+ { attempts: 3, baseMs: 100 },
990
+ );
991
+ } catch (fsErr) {
992
+ log('warn', `runWorktreeAdd: fs-level cleanup failed after retries (${fsErr.message}) — worktree directory may be leaked: ${worktreePath}`);
993
+ }
978
994
  try { await shared.shellSafeGit(['worktree', 'prune'], { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch {}
979
995
  }
980
996
  const incompleteErr = new Error(`GVFS incomplete checkout at ${worktreePath}: worktree add exited cleanly but no source files were checked out (only .git was written). Retriable.`);
@@ -2933,10 +2949,10 @@ async function spawnAgent(dispatchItem, config) {
2933
2949
  try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
2934
2950
  removeStaleIndexLock(rootDir);
2935
2951
  // Clean up partial worktree directory from the failed -b
2936
- // attempt. Keep this a RAW fs.rmSync: this husk pre-dates
2937
- // `git worktree add`, so git does not yet own it and
2938
- // shared.removeWorktree's `git worktree remove --force` would
2939
- // no-op on it (P-b3d9a162). Two guards before the delete:
2952
+ // attempt. This husk pre-dates `git worktree add`, so git
2953
+ // does not yet own it and shared.removeWorktree's
2954
+ // `git worktree remove --force` would no-op on it
2955
+ // (P-b3d9a162). Two guards before the delete:
2940
2956
  // 1. isWorktreePathLive (fail-open): skip if another
2941
2957
  // dispatch raced onto this path — it returns true on a
2942
2958
  // SQL outage, so we leak the husk rather than nuke a
@@ -2945,6 +2961,7 @@ async function spawnAgent(dispatchItem, config) {
2945
2961
  // carries its own `.git` DIRECTORY (a mis-pointed
2946
2962
  // worktreePath onto a real repo) — a linked worktree's
2947
2963
  // `.git` is a FILE, so this never blocks a real husk.
2964
+ // W-mqw3i9vp000tcfaf: use shared._retryFsOp so Windows EBUSY/EPERM doesn't brick the branch.
2948
2965
  try {
2949
2966
  const _huskGit = path.join(worktreePath, '.git');
2950
2967
  let _huskIsRealRepo = false;
@@ -2952,9 +2969,14 @@ async function spawnAgent(dispatchItem, config) {
2952
2969
  if (fs.existsSync(worktreePath)
2953
2970
  && !_huskIsRealRepo
2954
2971
  && !shared.isWorktreePathLive(worktreePath, { excludeDispatchId: id })) {
2955
- fs.rmSync(worktreePath, { recursive: true, force: true });
2972
+ shared._retryFsOp(
2973
+ () => fs.rmSync(worktreePath, { recursive: true, force: true }),
2974
+ `husk cleanup before -b retry ${branchName}`
2975
+ );
2956
2976
  }
2957
- } catch { /* optional */ }
2977
+ } catch (rmErr) {
2978
+ log('warn', `spawnAgent: husk cleanup failed before -b retry for ${branchName}: ${rmErr.message?.split('\n')[0]}`);
2979
+ }
2958
2980
  try {
2959
2981
  await runWorktreeAdd(rootDir, worktreePath, ['-b', branchName, _freshCreateBase], _worktreeGitOpts, 0);
2960
2982
  } catch (e1b) {
@@ -5863,54 +5885,6 @@ function materializePlansAsWorkItems(config) {
5863
5885
  return mutator(current) || current;
5864
5886
  }, { defaultValue: fallback || {}, ...options });
5865
5887
  };
5866
- const declaredProjectPrdFilename = (fileName, projectName) => {
5867
- const match = String(fileName || '').match(/-(\d{4}-\d{2}-\d{2}(?:-\d+)?)\.json$/);
5868
- if (!match) return null;
5869
- return safePrdFilenameForProject(projectName, match[1]);
5870
- };
5871
- const migratePrdFilenameReferences = (oldFileName, newFileName) => {
5872
- if (!oldFileName || !newFileName || oldFileName === newFileName) return 0;
5873
- let migrated = 0;
5874
- const wiPaths = new Set([path.join(MINIONS_DIR, 'work-items.json')]);
5875
- for (const project of getProjects(config)) wiPaths.add(projectWorkItemsPath(project));
5876
- for (const wiPath of wiPaths) {
5877
- if (!fs.existsSync(wiPath)) continue;
5878
- mutateWorkItems(wiPath, (items) => {
5879
- for (const wi of items) {
5880
- if (!wi || typeof wi !== 'object') continue;
5881
- if (wi.sourcePlan === oldFileName) {
5882
- wi.sourcePlan = newFileName;
5883
- migrated++;
5884
- }
5885
- if (wi._artifacts?.sourcePlan === oldFileName) {
5886
- wi._artifacts.sourcePlan = newFileName;
5887
- migrated++;
5888
- }
5889
- }
5890
- return items;
5891
- });
5892
- }
5893
- if (fs.existsSync(DISPATCH_PATH)) {
5894
- mutateDispatch((dispatch) => {
5895
- for (const queue of ['pending', 'active', 'completed']) {
5896
- for (const entry of dispatch[queue] || []) {
5897
- const metaItem = entry?.meta?.item;
5898
- if (!metaItem || typeof metaItem !== 'object') continue;
5899
- if (metaItem.sourcePlan === oldFileName) {
5900
- metaItem.sourcePlan = newFileName;
5901
- migrated++;
5902
- }
5903
- if (metaItem._prdFilename === oldFileName) {
5904
- metaItem._prdFilename = newFileName;
5905
- migrated++;
5906
- }
5907
- }
5908
- }
5909
- return dispatch;
5910
- });
5911
- }
5912
- return migrated;
5913
- };
5914
5888
  const enforceDeclaredPlanProject = (fileName, currentPlan) => {
5915
5889
  if (!currentPlan?.source_plan) return { fileName, plan: currentPlan };
5916
5890
  let declaredProject = '';
@@ -5938,95 +5912,18 @@ function materializePlansAsWorkItems(config) {
5938
5912
  return planData;
5939
5913
  }, { skipWriteIfUnchanged: true });
5940
5914
 
5941
- let nextFileName = fileName;
5942
- const desiredFileName = declaredProjectPrdFilename(fileName, declaredProject);
5943
- if (desiredFileName && desiredFileName.toLowerCase() !== String(fileName).toLowerCase()) {
5944
- const fromPath = path.join(PRD_DIR, fileName);
5945
- const desiredPath = shared.sanitizePath(desiredFileName, PRD_DIR);
5946
- // W-mq8qdai6 refuse to rename into a name owned by an archived PRD.
5947
- // `shared.uniquePath` only scans the live `prd/` directory; without this
5948
- // guard, an archived PRD with the canonical "<project>-<date>.json" name
5949
- // would silently collide with the rename target. The dashboard joins
5950
- // work items to PRDs by basename (sourcePlan === <prd filename>), so a
5951
- // live↔archive name collision bleeds the archived PRD's done items,
5952
- // verify task, and PRs into the live PRD's view (the bug-fix-plan vs
5953
- // minions-opg-2026-06-10 incident).
5954
- const archiveBasenames = new Set(
5955
- safeReadDir(path.join(PRD_DIR, 'archive'))
5956
- .filter(f => f.endsWith('.json'))
5957
- .map(f => f.toLowerCase())
5958
- );
5959
- if (archiveBasenames.has(desiredFileName.toLowerCase())) {
5960
- log('warn', `Plan project enforcement: skipping rename of ${fileName} to ${desiredFileName} — would collide with archived PRD`);
5961
- } else {
5962
- const toPath = shared.uniquePath(desiredPath);
5963
- const toBasename = path.basename(toPath);
5964
- if (archiveBasenames.has(toBasename.toLowerCase())) {
5965
- // uniquePath bumped past a live conflict (e.g. <name>-2.json) but
5966
- // the bumped name itself is owned by an archived PRD — same risk.
5967
- log('warn', `Plan project enforcement: skipping rename of ${fileName} to ${toBasename} — would collide with archived PRD`);
5968
- } else {
5969
- // W-mqfevwrm — Serialize the rename behind file locks on BOTH the
5970
- // source and destination paths so a concurrent `mutateJsonFileLocked`
5971
- // on either name cannot interleave (creating a divergent ghost PRD
5972
- // or losing data). Move the `.backup` sidecar along with the `.json`
5973
- // so the canonical name retains its restore-from-backup safety net
5974
- // — without this, deleting the renamed `.json` (concurrent sweep,
5975
- // plan-completion purge, etc.) leaves no `.backup` under the new
5976
- // name and the PRD is permanently lost (live incident 2026-06-15:
5977
- // `minions-opg-2026-06-10.json` gone, only the OLD-name `.backup`
5978
- // survived, all 9 WIs orphaned). The OLD-name `.backup` is removed
5979
- // so it cannot resurrect a ghost PRD on a future read of the OLD
5980
- // path (W-mouptdh1000h9f39-style landmine).
5981
- try {
5982
- withFileLock(`${fromPath}.lock`, () => {
5983
- withFileLock(`${toPath}.lock`, () => {
5984
- // Re-verify under both locks — another tick may have raced
5985
- // ahead and either deleted the source or claimed the dest.
5986
- if (!fs.existsSync(fromPath)) {
5987
- log('warn', `Plan project enforcement: source ${fileName} disappeared before rename (raced)`);
5988
- return;
5989
- }
5990
- if (fs.existsSync(toPath)) {
5991
- log('warn', `Plan project enforcement: destination ${toBasename} appeared before rename (raced) — skipping`);
5992
- return;
5993
- }
5994
- fs.renameSync(fromPath, toPath);
5995
- // Move the `.backup` sidecar along with the primary `.json`
5996
- // so safeJson's restore-from-backup path stays viable under
5997
- // the new canonical name. Both ops are best-effort: missing
5998
- // `.backup` is normal (nothing to move) and an unlink failure
5999
- // on the OLD-name `.backup` is logged but non-fatal.
6000
- const fromBackup = `${fromPath}.backup`;
6001
- const toBackup = `${toPath}.backup`;
6002
- if (fs.existsSync(fromBackup)) {
6003
- try {
6004
- fs.renameSync(fromBackup, toBackup);
6005
- } catch (be) {
6006
- // Fall back to copy + unlink if rename across the same dir
6007
- // surfaces a Windows EPERM (AV/Search Indexer hold).
6008
- try {
6009
- fs.copyFileSync(fromBackup, toBackup);
6010
- try { fs.unlinkSync(fromBackup); } catch { /* best-effort */ }
6011
- } catch (ce) {
6012
- log('warn', `Plan project enforcement: could not move .backup sidecar ${fileName}.backup → ${toBasename}.backup: ${be.message} / ${ce.message}`);
6013
- }
6014
- }
6015
- }
6016
- nextFileName = toBasename;
6017
- const migrated = migratePrdFilenameReferences(fileName, nextFileName);
6018
- if (migrated > 0) log('info', `Plan project enforcement: migrated ${migrated} PRD reference(s) from ${fileName} to ${nextFileName}`);
6019
- changed = true;
6020
- });
6021
- });
6022
- } catch (e) {
6023
- log('warn', `Plan project enforcement: could not rename ${fileName} to ${toBasename}: ${e.message}`);
6024
- }
6025
- }
6026
- }
6027
- }
6028
- if (changed) log('info', `Plan project enforcement: preserved declared project "${declaredProject}" for ${nextFileName}`);
6029
- return { fileName: nextFileName, plan: normalizedPlan };
5915
+ // Phase 10 step 4.3 — the PRD filename is NO LONGER renamed to the canonical
5916
+ // "<project>-<date>.json". That rename existed only to keep the basename
5917
+ // identity (sourcePlan === <prd filename>) and the live↔archive collision
5918
+ // guard (footgun #7) working. With the surrogate-key model — archive is an
5919
+ // in-place flag (no move) and the WI↔PRD-item join is the stable SQL FK
5920
+ // (work_items.prd_item_id), not the basename the rename is dead weight and
5921
+ // its own machinery (the `.backup` sidecar move, migratePrdFilenameReferences,
5922
+ // the rename-race window that prd-rename-race.test.js guarded) is retired.
5923
+ // The SEMANTIC enforcement above (declared project on the PRD + its features)
5924
+ // still runs; only the file-move is gone.
5925
+ if (changed) log('info', `Plan project enforcement: preserved declared project "${declaredProject}" for ${fileName}`);
5926
+ return { fileName, plan: normalizedPlan };
6030
5927
  };
6031
5928
 
6032
5929
  // Enforce: PRDs must be .json — auto-rename .md files that contain valid PRD JSON
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2292",
3
+ "version": "0.1.2293",
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"