@yemi33/minions 0.1.2291 → 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/docs/README.md +1 -0
- package/docs/command-center.md +1 -1
- package/docs/plan-lifecycle.md +4 -2
- package/docs/runtime-adapters.md +1 -0
- package/engine/cli.js +13 -0
- package/engine/features.js +15 -0
- package/engine/lifecycle.js +3 -1
- package/engine/pipeline.js +1 -1
- package/engine/prd-store.js +113 -7
- package/engine/queries.js +62 -1
- package/engine/shared.js +12 -8
- package/engine/work-items-store.js +21 -0
- package/engine.js +43 -146
- package/package.json +1 -1
package/docs/README.md
CHANGED
|
@@ -58,6 +58,7 @@ Architecture, design proposals, and lifecycle references for people working on t
|
|
|
58
58
|
|
|
59
59
|
Operational runbooks for engine operators and fleet maintainers.
|
|
60
60
|
|
|
61
|
+
- [notes.md](notes.md) — Consolidated team knowledge: patterns, conventions, bugs, and build findings accumulated from agent inbox notes. Read by the engine and injected into agent prompts as shared context.
|
|
61
62
|
- [auto-discovery.md](auto-discovery.md) — Auto-discovery and execution pipeline: the per-tick orchestration loop and the four work-discovery sources.
|
|
62
63
|
- [diagnostics-memory.md](diagnostics-memory.md) — Operator runbook for the in-process memory + perf observability surface: `/api/diagnostics/memory[/history]`, `/api/diagnostics/heap-snapshot` guard-token capture, `MEMORY_BASELINE` log emissions, `--cpu-prof`/`--heap-prof` capture, and the `test/perf/soak.test.js` heap-growth regression gate.
|
|
63
64
|
- [engine-restart.md](engine-restart.md) — How agents survive an engine restart: state persistence, the 20-minute startup grace period, and orphan reattachment via PID files and `live-output.log`.
|
package/docs/command-center.md
CHANGED
|
@@ -63,7 +63,7 @@ Any violation rejects the **whole turn** with a typed error envelope (`code: 'in
|
|
|
63
63
|
| Runtime | `imageInput` | Behavior |
|
|
64
64
|
|---------|--------------|----------|
|
|
65
65
|
| claude | `true` | Images delivered as an Anthropic Messages `content[]` envelope of base64 blocks over stream-json (`claude.buildPrompt` + `--input-format stream-json` in `claude.buildArgs`). |
|
|
66
|
-
| copilot | `
|
|
66
|
+
| copilot | `true` | Images materialized to tmp files and passed as `--attachment <path>` (W-mqv7324u0021db5d). **Exception:** when the opt-in Copilot worker pool is active (`engine.ccUseWorkerPool: true`), images are silently dropped — the pool's ACP `stream()` does not forward images; a note in `_invokeCcStream` marks this intentional. |
|
|
67
67
|
| codex | `false` | Degraded — same typed `model-unavailable` envelope. |
|
|
68
68
|
|
|
69
69
|
`_resolveImageOpts` (in `engine/llm.js`) is pure and unit-tested: it forwards the `images` list only when `runtime.capabilities.imageInput` is truthy, otherwise returns the typed error so CC/doc-chat surface the envelope instead of a silently-text-only reply. `_spawnProcess` re-applies the same gate (`if (!caps.imageInput) adapterOpts.images = undefined`) as defense in depth. Image **filenames** are run through the untrusted-input fence (`buildSource('cc-image-filename', …)`) before they reach prompt text, since they are user-supplied.
|
package/docs/plan-lifecycle.md
CHANGED
|
@@ -118,7 +118,7 @@ The verify agent does not call `archivePlan()`. After the testing guide and any
|
|
|
118
118
|
|
|
119
119
|
Manual archive behavior:
|
|
120
120
|
|
|
121
|
-
1. For PRD JSON files, the dashboard
|
|
121
|
+
1. For PRD JSON files, the dashboard archives them **in-place** in `prd/` — the file is not moved. The JSON is stamped with `archived: true`, `status: "archived"`, and `archivedAt` (Phase 10 step 4.2b). The `.backup` sidecar is left intact since the file keeps its canonical path.
|
|
122
122
|
2. If the PRD has `source_plan`, the matching `plans/<source>.md` is moved to `plans/archive/<source>.md`.
|
|
123
123
|
3. Pending or queued work items linked to the archived PRD are cancelled with `_cancelledBy: "plan-archived"`; completed work items remain as history.
|
|
124
124
|
4. Plan worktrees are cleaned up by the archive handler/lifecycle cleanup path.
|
|
@@ -148,7 +148,9 @@ After PRs are created, humans can leave comments containing `@minions` to trigge
|
|
|
148
148
|
|------|---------|
|
|
149
149
|
| `plans/*.md` | Source plans awaiting review or linked to active PRDs |
|
|
150
150
|
| `prd/*.json` | Active PRDs with materializable items |
|
|
151
|
-
| `plans/archive
|
|
151
|
+
| `plans/archive/` | Manually archived plan Markdown files |
|
|
152
|
+
| `prd/*.json` (with `archived: true`) | PRDs archived in-place (Phase 10 step 4.2b — not moved; flag + `archivedAt` stamped on the file) |
|
|
153
|
+
| `prd/archive/` | Legacy: PRDs archived before Phase 10 (still scanned for filename-collision detection) |
|
|
152
154
|
| `playbooks/verify.md` | Verification task playbook |
|
|
153
155
|
| `playbooks/implement.md` | Implementation playbook |
|
|
154
156
|
| `playbooks/plan-to-prd.md` | Plan → PRD conversion playbook |
|
package/docs/runtime-adapters.md
CHANGED
|
@@ -81,6 +81,7 @@ real behavioral split appears between adapters.
|
|
|
81
81
|
| `resumePromptCarryover` | ✗ | ✓ | ✓ | CC resume turns prepend recent visible Q&A in stdin when the runtime session store is opaque to Minions. |
|
|
82
82
|
| `resumeBookkeepingTurn` | ✓ | — | — | Claude CLI injects a synthetic "Continue from where you left off." meta turn on `--resume`; CC prompts must tell the model not to treat it as user intent. |
|
|
83
83
|
| `streamConsumer` | ✓ | ✓ | ✓ | Adapter implements `createStreamConsumer(ctx)` — required by `engine/llm.js` accumulator. |
|
|
84
|
+
| `imageInput` | ✓ | ✓ | ✗ | Runtime accepts `images: [{mimeType, dataBase64}]` and delivers them to the CLI. Claude: Anthropic Messages base64 content-block envelope via `--input-format stream-json`. Copilot: base64 payloads materialized to tmp files and passed as `--attachment <path>` (W-mqv7324u0021db5d). When false, `_resolveImageOpts` returns a typed `model-unavailable` error. |
|
|
84
85
|
|
|
85
86
|
When a behavior is genuinely uniform across all current adapters, it still gets
|
|
86
87
|
a flag if a future runtime might disagree — flags are cheap.
|
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
|
package/engine/features.js
CHANGED
|
@@ -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']);
|
package/engine/lifecycle.js
CHANGED
|
@@ -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.
|
package/engine/pipeline.js
CHANGED
|
@@ -57,7 +57,7 @@ function getPipeline(id) {
|
|
|
57
57
|
|
|
58
58
|
function savePipeline(pipeline) {
|
|
59
59
|
if (!fs.existsSync(PIPELINES_DIR)) fs.mkdirSync(PIPELINES_DIR, { recursive: true });
|
|
60
|
-
|
|
60
|
+
mutateJsonFileLocked(path.join(PIPELINES_DIR, pipeline.id + '.json'), () => pipeline, { defaultValue: {} });
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function deletePipeline(id) {
|
package/engine/prd-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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) —
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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.
|
|
2937
|
-
//
|
|
2938
|
-
//
|
|
2939
|
-
//
|
|
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
|
-
|
|
2972
|
+
shared._retryFsOp(
|
|
2973
|
+
() => fs.rmSync(worktreePath, { recursive: true, force: true }),
|
|
2974
|
+
`husk cleanup before -b retry ${branchName}`
|
|
2975
|
+
);
|
|
2956
2976
|
}
|
|
2957
|
-
} catch {
|
|
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
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
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.
|
|
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"
|