@yemi33/minions 0.1.2379 → 0.1.2381

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.
Files changed (100) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +3 -4
  3. package/dashboard/js/render-other.js +43 -23
  4. package/dashboard/js/render-prd.js +1 -1
  5. package/dashboard/js/render-work-items.js +13 -6
  6. package/dashboard/js/settings.js +40 -17
  7. package/dashboard.js +435 -768
  8. package/docs/architecture-review-2026-07-09.md +2 -4
  9. package/docs/auto-discovery.md +36 -49
  10. package/docs/blog-first-successful-dispatch.md +1 -1
  11. package/docs/branch-derivation.md +2 -2
  12. package/docs/command-center.md +1 -1
  13. package/docs/completion-reports.md +14 -4
  14. package/docs/constellation-bridge.md +59 -10
  15. package/docs/constellation-style-telemetry.md +6 -6
  16. package/docs/cooldown-merge-semantics.md +4 -0
  17. package/docs/copilot-cli-schema.md +3 -3
  18. package/docs/cross-repo-plans.md +17 -17
  19. package/docs/deprecated.json +2 -2
  20. package/docs/design-state-storage.md +1 -1
  21. package/docs/documentation-audit-2026-07-09.md +2 -2
  22. package/docs/engine-restart.md +20 -8
  23. package/docs/harness-mode.md +1 -1
  24. package/docs/live-checkout-mode.md +45 -26
  25. package/docs/managed-spawn.md +4 -4
  26. package/docs/onboarding.md +1 -2
  27. package/docs/pr-comment-followup.md +3 -3
  28. package/docs/pr-review-fix-loop.md +1 -1
  29. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  30. package/docs/qa-runbook-lifecycle.md +4 -4
  31. package/docs/qa-runbooks.md +2 -2
  32. package/docs/rfc-completion-json.md +4 -1
  33. package/docs/runtime-adapters.md +1 -1
  34. package/docs/self-improvement.md +4 -5
  35. package/docs/shared-lifecycle-module-map.md +3 -1
  36. package/docs/slim-ux/architecture-suggestions.md +5 -6
  37. package/docs/slim-ux/concepts.md +23 -25
  38. package/docs/watches.md +7 -7
  39. package/docs/workspace-manifests.md +1 -1
  40. package/docs/worktree-lifecycle.md +1 -1
  41. package/engine/abandoned-pr-reconciliation.js +4 -5
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +58 -1
  45. package/engine/bridge.js +260 -5
  46. package/engine/cleanup.js +48 -131
  47. package/engine/cli.js +125 -83
  48. package/engine/cooldown.js +9 -16
  49. package/engine/db/index.js +22 -9
  50. package/engine/db/migrations/009-qa.js +1 -1
  51. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  52. package/engine/db/migrations/021-archived-work-items.js +72 -0
  53. package/engine/db/migrations/022-global-cc-session.js +31 -0
  54. package/engine/db/migrations/023-engine-state.js +30 -0
  55. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  56. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  57. package/engine/dispatch-store.js +2 -7
  58. package/engine/dispatch.js +39 -44
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +279 -354
  61. package/engine/live-checkout.js +193 -149
  62. package/engine/llm.js +1 -1
  63. package/engine/logs-store.js +2 -2
  64. package/engine/managed-spawn.js +2 -23
  65. package/engine/meeting.js +6 -6
  66. package/engine/metrics-store.js +2 -2
  67. package/engine/note-link-backfill.js +6 -11
  68. package/engine/pipeline.js +18 -36
  69. package/engine/playbook.js +13 -16
  70. package/engine/prd-store.js +73 -54
  71. package/engine/preflight.js +2 -5
  72. package/engine/projects.js +15 -62
  73. package/engine/pull-requests-store.js +0 -17
  74. package/engine/qa-runbooks.js +2 -2
  75. package/engine/qa-runs.js +1 -8
  76. package/engine/qa-sessions.js +41 -64
  77. package/engine/queries.js +120 -219
  78. package/engine/routing.js +4 -6
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +268 -699
  82. package/engine/small-state-store.js +89 -10
  83. package/engine/state-operations.js +16 -4
  84. package/engine/stdio-timestamps.js +1 -1
  85. package/engine/timeout.js +5 -12
  86. package/engine/watch-actions.js +20 -22
  87. package/engine/watches-store.js +1 -1
  88. package/engine/watches.js +6 -10
  89. package/engine/work-item-validation.js +52 -0
  90. package/engine/work-items-store.js +127 -29
  91. package/engine/worktree-gc.js +2 -2
  92. package/engine/worktree-pool.js +8 -18
  93. package/engine.js +197 -358
  94. package/minions.js +2 -2
  95. package/package.json +1 -1
  96. package/playbooks/plan-to-prd.md +2 -2
  97. package/playbooks/shared-rules.md +3 -3
  98. package/playbooks/templates/followup-dispatch.md +1 -1
  99. package/playbooks/verify.md +1 -1
  100. package/prompts/cc-system.md +9 -9
@@ -1,6 +1,6 @@
1
- // engine/metrics-store.js — SQL-backed implementation of engine/metrics.json.
1
+ // engine/metrics-store.js — SQL-backed engine metrics.
2
2
  //
3
- // metrics.json is a single flat object whose top-level keys mix
3
+ // Metrics expose a flat object whose top-level keys mix
4
4
  // per-agent counters with reserved keys (`_engine`, `_daily`,
5
5
  // `_contextPressure`). The store reconstructs that legacy shape for
6
6
  // reads, and reverses it for writes — each (kind, key) row holds one
@@ -148,12 +148,7 @@ function repointItemNoteLinks(item, resolve) {
148
148
  return fixed;
149
149
  }
150
150
 
151
- // Repair broken note links across every work-items file (each project's plus
152
- // the central one), routed through shared.mutateWorkItems so the canonical SQL
153
- // store AND its JSON mirror stay consistent — the same write path the live
154
- // rewriter uses. Idempotent: only touches links that are provably broken AND
155
- // resolvable, so once healed a re-run writes nothing (skipWriteIfUnchanged).
156
- // Safe to call on every engine boot. Per-file try/catch isolates a bad scope.
151
+ // Repair broken note links across every project scope plus central.
157
152
  function runNoteLinkBackfill(opts = {}) {
158
153
  const shared = require('./shared');
159
154
  const minionsDir = opts.minionsDir || shared.MINIONS_DIR;
@@ -172,14 +167,14 @@ function runNoteLinkBackfill(opts = {}) {
172
167
  const projects = Array.isArray(config.projects) ? config.projects : [];
173
168
  const candidates = [];
174
169
  for (const p of projects) {
175
- if (p && p.name) candidates.push(path.join(minionsDir, 'projects', p.name, 'work-items.json'));
170
+ if (p && p.name) candidates.push(p);
176
171
  }
177
- candidates.push(path.join(minionsDir, 'work-items.json'));
172
+ candidates.push('central');
178
173
 
179
174
  let scanned = 0, fixedItems = 0, fixedLinks = 0;
180
- for (const wiPath of candidates) {
175
+ for (const scope of candidates) {
181
176
  try {
182
- shared.mutateWorkItems(wiPath, (items) => {
177
+ shared.mutateWorkItems(scope, (items) => {
183
178
  if (!Array.isArray(items)) return items;
184
179
  for (const item of items) {
185
180
  if (!item) continue;
@@ -191,7 +186,7 @@ function runNoteLinkBackfill(opts = {}) {
191
186
  }
192
187
  return items;
193
188
  });
194
- } catch { /* per-file isolation — one bad scope can't block the others */ }
189
+ } catch { /* one bad scope cannot block the others */ }
195
190
  }
196
191
 
197
192
  return { scanned, fixedItems, fixedLinks };
@@ -8,20 +8,18 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
- const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
11
+ const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
12
12
  const routing = require('./routing');
13
13
  const http = require('http');
14
14
  const { shouldRunNow } = require('./scheduler');
15
+ const smallStateStore = require('./small-state-store');
15
16
 
16
17
  // All module-relative paths flow through MINIONS_DIR so MINIONS_TEST_DIR
17
18
  // (set by test/unit.test.js createTestMinionsDir) consistently redirects
18
19
  // pipeline writes into the temp root instead of the live runtime root.
19
20
  const PIPELINES_DIR = path.join(MINIONS_DIR, 'pipelines');
20
- const PIPELINE_RUNS_PATH = path.join(MINIONS_DIR, 'engine', 'pipeline-runs.json');
21
- const CENTRAL_WI_PATH = path.join(MINIONS_DIR, 'work-items.json');
22
21
  const MEETINGS_DIR = path.join(MINIONS_DIR, 'meetings');
23
22
  const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
24
- const PRD_DIR = path.join(MINIONS_DIR, 'prd');
25
23
  const NOTES_INBOX_DIR = path.join(MINIONS_DIR, 'notes', 'inbox');
26
24
  const NOTES_ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
27
25
  const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
@@ -74,22 +72,11 @@ function deletePipeline(id) {
74
72
  // ── Run State ────────────────────────────────────────────────────────────────
75
73
 
76
74
  function getPipelineRuns() {
77
- return safeJsonObj(PIPELINE_RUNS_PATH);
75
+ return smallStateStore.readPipelineRuns();
78
76
  }
79
77
 
80
- // W-mrcrh5hj0017d22f: SQL is the canonical (and only) work-items store post
81
- // Phase 9.4 the JSON files (central work-items.json + each
82
- // projects/<name>/work-items.json) are passive, best-effort mirrors that can
83
- // legitimately lag behind a just-completed write (mirror failures are
84
- // swallowed by design so they never block the SQL write; see
85
- // work-items-store.js's `_mirrorJsonFromSql` comments). Reading those JSON
86
- // files directly (via safeJson/safeJsonArr) can therefore observe a work
87
- // item whose `status` mirrored to DONE but whose `resultSummary` write
88
- // hasn't landed in the mirror yet — which silently degraded stage-output
89
- // template substitution ({{stages.<id>.output}}) to the stage's own work
90
- // item id instead of its real completion text. Route every work-item read
91
- // in this module through the SQL-backed store so pipeline state always sees
92
- // the authoritative record.
78
+ // Always read work items from the authoritative SQL store so stage-output
79
+ // template substitution sees the latest completion metadata.
93
80
  function _allWorkItems() {
94
81
  return require('./work-items-store').readAllWorkItems();
95
82
  }
@@ -319,8 +306,8 @@ function _pipelineProjectSlug(project) {
319
306
  return project ? shared.safeSlugComponent(project.name || 'project', 32).toLowerCase() : 'central';
320
307
  }
321
308
 
322
- function _pipelineWorkItemsPath(project) {
323
- return project ? shared.projectWorkItemsPath(project) : CENTRAL_WI_PATH;
309
+ function _pipelineWorkItemScope(project) {
310
+ return project || 'central';
324
311
  }
325
312
 
326
313
  // ── Condition Evaluation ─────────────────────────────────────────────────────
@@ -435,7 +422,7 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
435
422
  for (const project of projectResolution.projects) {
436
423
  const projectSlug = _pipelineProjectSlug(project);
437
424
  const id = `PL-${run.runId.slice(4, 12)}-${stage.id}-${i}${projectResolution.projects.length > 1 || project ? '-' + projectSlug : ''}`;
438
- const wiPath = _pipelineWorkItemsPath(project);
425
+ const wiScope = _pipelineWorkItemScope(project);
439
426
  const wiType = routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE);
440
427
  // W-mp8ho6w500034a58: read-only stages don't commit, so a pipeline
441
428
  // branch label is meaningless to them — omit it entirely so the
@@ -443,7 +430,7 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
443
430
  const wiBranch = READ_ONLY_ROOT_TASK_TYPES.has(wiType)
444
431
  ? null
445
432
  : `pipeline/${run.pipelineId}/${stage.id}`;
446
- mutateWorkItems(wiPath, workItems => {
433
+ mutateWorkItems(wiScope, workItems => {
447
434
  if (workItems.some(w => w.id === id)) { createdIds.push(id); return workItems; }
448
435
  workItems.push({
449
436
  id,
@@ -574,12 +561,10 @@ const _VALID_EXISTING_PRD_STATUSES = new Set(Object.values(PLAN_STATUS));
574
561
  // permanently skip re-running plan-to-prd for a plan that never actually got
575
562
  // a usable PRD. Require both a recognized top-level status AND an array
576
563
  // `missing_features` field before counting the PRD as an existing conversion.
577
- function _findExistingPrdForPlan(planFile, prdDir) {
564
+ function _findExistingPrdForPlan(planFile) {
578
565
  const prdFiles = require('./prd-store').listPrdRows().filter(row => !row.archived);
579
566
  const planKey = _canonicalPlanName(planFile);
580
567
  for (const { filename: pf, plan: prd } of prdFiles) {
581
- // safeJsonNoRestore: PRDs are terminal artifacts — never restore archived
582
- // PRDs from a stale .backup sidecar (W-mouptdh1000h9f39).
583
568
  if (!prd?.source_plan || _canonicalPlanName(path.basename(String(prd.source_plan))) !== planKey) continue;
584
569
  if (!(_VALID_EXISTING_PRD_STATUSES.has(prd.status) || isPrdArchived(prd)) || !Array.isArray(prd.missing_features)) {
585
570
  log('warn', `Pipeline plan-to-prd: found PRD "${pf}" for plan "${planFile}" but it lacks a valid status/missing_features (garbage/incomplete conversion) — treating plan-to-prd as not yet done`);
@@ -623,8 +608,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
623
608
  log('info', `Pipeline ${run.pipelineId}: reconciling plan stage — adopting existing plan "${existingPlanFile}"`);
624
609
 
625
610
  // Check if a PRD already exists for this plan (skip plan-to-prd entirely)
626
- const prdDir = PRD_DIR;
627
- const existingPrdFile = _findExistingPrdForPlan(existingPlanFile, prdDir);
611
+ const existingPrdFile = _findExistingPrdForPlan(existingPlanFile);
628
612
  if (existingPrdFile) {
629
613
  log('info', `Pipeline ${run.pipelineId}: PRD "${existingPrdFile}" already exists for plan "${existingPlanFile}" — skipping plan-to-prd`);
630
614
  return {
@@ -636,10 +620,10 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
636
620
  // Adopt or create plan-to-prd WIs atomically under lock for each target project.
637
621
  const adoptedWiIds = [];
638
622
  for (const project of targetProjects) {
639
- const wiPath = _pipelineWorkItemsPath(project);
623
+ const wiScope = _pipelineWorkItemScope(project);
640
624
  const wiId = wiIdForProject(project);
641
625
  let adoptedWiId = wiId;
642
- mutateWorkItems(wiPath, workItems => {
626
+ mutateWorkItems(wiScope, workItems => {
643
627
  const existing = workItems.find(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === existingPlanFile);
644
628
  if (existing) {
645
629
  existing._pipelineRun = run.runId;
@@ -744,9 +728,9 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
744
728
 
745
729
  const createdWiIds = [];
746
730
  for (const project of targetProjects) {
747
- const wiPath = _pipelineWorkItemsPath(project);
731
+ const wiScope = _pipelineWorkItemScope(project);
748
732
  const wiId = wiIdForProject(project);
749
- mutateWorkItems(wiPath, workItems => {
733
+ mutateWorkItems(wiScope, workItems => {
750
734
  if (!workItems.some(w => w.id === wiId)) {
751
735
  workItems.push({
752
736
  id: wiId,
@@ -986,7 +970,6 @@ function isStageComplete(stage, stageState, run, config) {
986
970
  if (!prdDone) return false;
987
971
 
988
972
  // Discover PRDs and their work items — collect into local arrays, then merge into artifacts
989
- const prdDir = PRD_DIR;
990
973
  const plans = artifacts.plans || [];
991
974
  const discoveredPrds = [];
992
975
  const discoveredWiIds = [];
@@ -1019,9 +1002,8 @@ function isStageComplete(stage, stageState, run, config) {
1019
1002
  // Auto-approve if configured
1020
1003
  if (stage.autoApprove && artifacts.prds?.length > 0) {
1021
1004
  for (const prdFile of artifacts.prds) {
1022
- const prdPath = path.join(prdDir, prdFile);
1023
1005
  let approved = false;
1024
- mutateJsonFileLocked(prdPath, (prd) => {
1006
+ require('./prd-store').mutatePrd(prdFile, (prd) => {
1025
1007
  if (prd && prd.status === PLAN_STATUS.AWAITING_APPROVAL) {
1026
1008
  prd.status = PLAN_STATUS.APPROVED;
1027
1009
  prd.approvedAt = ts();
@@ -1029,7 +1011,7 @@ function isStageComplete(stage, stageState, run, config) {
1029
1011
  approved = true;
1030
1012
  }
1031
1013
  return prd;
1032
- }, { skipWriteIfUnchanged: true });
1014
+ });
1033
1015
  if (approved) log('info', `Pipeline ${run.pipelineId}: auto-approved PRD ${prdFile}`);
1034
1016
  }
1035
1017
  }
@@ -1047,7 +1029,7 @@ function isStageComplete(stage, stageState, run, config) {
1047
1029
  if (prIds.length === 0) return true; // nothing to merge
1048
1030
  const projects = shared.getProjects(config);
1049
1031
  for (const project of projects) {
1050
- const prs = safeJsonArr(shared.projectPrPath(project));
1032
+ const prs = shared.readPullRequests(project);
1051
1033
  for (const prId of prIds) {
1052
1034
  const pr = shared.findPrRecord(prs, prId, project);
1053
1035
  if (pr && pr.status !== PR_STATUS.MERGED && pr.status !== PR_STATUS.ABANDONED) return false;
@@ -402,6 +402,7 @@ const PLAYBOOK_REQUIRED_VARS = {
402
402
  'implement': ['item_id', 'item_name', 'branch_name', 'project_path'],
403
403
  'implement-shared': ['item_id', 'item_name', 'branch_name', 'worktree_path'],
404
404
  'fix': ['pr_id', 'pr_branch'],
405
+ // M005 — SHERLOC two-phase build-fix. Same PR context as fix.
405
406
  'build-fix-complex': ['pr_id', 'pr_branch'],
406
407
  'review': ['pr_id', 'pr_branch'],
407
408
  'build-and-test': ['pr_id', 'pr_branch', 'project_path'],
@@ -434,11 +435,14 @@ const PLAYBOOK_REQUIRED_VARS = {
434
435
  'meeting-investigate': ['meeting_title', 'agenda'],
435
436
  'meeting-debate': ['meeting_title', 'agenda'],
436
437
  'meeting-conclude': ['meeting_title', 'agenda'],
437
- // M005 — SHERLOC two-phase build-fix. Same required vars as fix (PR-context
438
- // dispatch), but routed to build-fix-complex.md for multi-file failures.
439
- 'build-fix-complex': ['pr_id', 'pr_branch'],
440
438
  };
441
439
 
440
+ const LIVE_VALIDATION_DEFERRED_PLAYBOOKS = new Set([
441
+ 'implement',
442
+ 'fix',
443
+ 'build-fix-complex',
444
+ ]);
445
+
442
446
  /**
443
447
  * Validate that all required template variables for a playbook type are present
444
448
  * and non-empty in the provided vars object.
@@ -848,18 +852,11 @@ function renderPlaybook(type, vars) {
848
852
  } catch (e) { log('warn', `qa-validate context render failed: ${e.message}`); }
849
853
  }
850
854
 
851
- // M005 live-validation deferred-build notice.
852
- // Inject only for coding playbooks (implement, fix, docs, decompose) when the
853
- // matched project has liveValidation.autoDispatch === true. Agents on these
854
- // projects must push code without running builds or tests; a separate dispatch
855
- // (of liveValidation.type) runs the full build on the live environment after
856
- // the PR lands. When autoDispatch is false the agent is responsible for its own
857
- // inline validation (may fail in isolated worktrees — documented limitation).
858
- const LIVE_VALIDATION_PLAYBOOKS = new Set(['implement', 'fix', 'build-fix-complex', 'docs', 'decompose']);
859
- if (LIVE_VALIDATION_PLAYBOOKS.has(type)) {
860
- const lv = matchedProject && matchedProject.liveValidation;
861
- if (lv && lv.autoDispatch === true) {
862
- const lvType = lv.type || 'live-validation';
855
+ // Only playbooks whose completion creates the lifecycle follow-up may defer
856
+ // builds. The shared resolver also requires effective live `test` routing.
857
+ if (LIVE_VALIDATION_DEFERRED_PLAYBOOKS.has(type)) {
858
+ const lvType = shared.resolveLiveValidationAutoDispatchType(matchedProject);
859
+ if (lvType) {
863
860
  inertAppendices.push(
864
861
  `\n\n---\n\n## Live Validation — Validation Deferred\n\n` +
865
862
  `**Live validation is deferred for this project.** ` +
@@ -1077,7 +1074,7 @@ function buildSystemPrompt(agentId, config, project) {
1077
1074
  prompt += `2. ${getRepoHostToolRule(project)}\n`;
1078
1075
  prompt += `3. Follow the project conventions in CLAUDE.md if present\n`;
1079
1076
  prompt += `4. Write learnings to the path specified in the task prompt (format: \`notes/inbox/{agent}-{work-item-id}-{date}-{time}.md\`)\n`;
1080
- prompt += `5. Agent status is managed by the engine via dispatch.json — agents do not need to track their own status\n\n`;
1077
+ prompt += `5. Agent status is managed by the engine — agents do not need to track their own status\n\n`;
1081
1078
 
1082
1079
  // The lean system prompt (identity + rules) does not contain task-dispatch
1083
1080
  // sections (## Your Task, ## Tools, ## Constraints) by design — those live
@@ -1,9 +1,6 @@
1
1
  // engine/prd-store.js
2
2
  //
3
- // SQL-authoritative PRD state. Paths are retained as stable identifiers for
4
- // existing callers, but no runtime PRD JSON is read or written here.
5
-
6
- const path = require('path');
3
+ // SQL-authoritative PRD state.
7
4
 
8
5
  function _toMs(v) {
9
6
  if (v == null) return null;
@@ -13,28 +10,12 @@ function _toMs(v) {
13
10
  }
14
11
  function _bool01(v) { return v ? 1 : 0; }
15
12
 
16
- // Resolve <MINIONS_DIR>/prd/<f>.json → {filename, archived:0}; the
17
- // prd/archive/<f>.json variant → {filename, archived:1}; anything else → null.
18
- // Path comparison is normalized + case-insensitive on the directory prefix so
19
- // Windows drive-letter / separator differences don't cause a miss.
20
- function parsePrdPath(filePath) {
21
- if (!filePath || typeof filePath !== 'string') return null;
22
- let minionsDir;
23
- try { minionsDir = require('./shared').MINIONS_DIR; } catch { return null; }
24
- if (!minionsDir) return null;
25
- const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
26
- const full = norm(filePath);
27
- const prdRoot = norm(path.join(minionsDir, 'prd'));
28
- const archiveRoot = norm(path.join(minionsDir, 'prd', 'archive'));
29
- if (!full.endsWith('.json')) return null;
30
- const base = path.basename(filePath);
31
- if (full === norm(path.join(archiveRoot, base)) && full.startsWith(archiveRoot + '/')) {
32
- return { filename: base, archived: 1 };
33
- }
34
- if (full === norm(path.join(prdRoot, base)) && full.startsWith(prdRoot + '/')) {
35
- return { filename: base, archived: 0 };
13
+ function _validatePrdFilename(filename) {
14
+ if (typeof filename !== 'string' || !filename.endsWith('.json')
15
+ || filename.includes('/') || filename.includes('\\') || filename === '.json') {
16
+ throw new TypeError('PRD filename must be a basename ending in .json');
36
17
  }
37
- return null;
18
+ return filename;
38
19
  }
39
20
 
40
21
  // (filename, archived) → plans.id, preferring the same archived bucket.
@@ -48,6 +29,22 @@ function _resolvePlanId(db, sourcePlan, archived) {
48
29
 
49
30
  // Core upsert: write/replace the prds row + its prd_items + prd_verify_prs for
50
31
  // (filename, archived). Returns the prds.id. Runs inside a transaction.
32
+ function _clearWorkItemLinks(db, prdId, predicate = '', params = []) {
33
+ db.prepare(`
34
+ UPDATE work_items SET prd_item_id=NULL
35
+ WHERE prd_item_id IN (
36
+ SELECT id FROM prd_items WHERE prd_id=? ${predicate}
37
+ )
38
+ `).run(prdId, ...params);
39
+ }
40
+
41
+ function _deletePrdRow(db, prdId) {
42
+ _clearWorkItemLinks(db, prdId);
43
+ db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(prdId);
44
+ db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(prdId);
45
+ db.prepare('DELETE FROM prds WHERE id=?').run(prdId);
46
+ }
47
+
51
48
  function _upsertPrd(db, filename, archived, prd, now) {
52
49
  const sourcePlan = prd.source_plan || prd.sourcePlan || null;
53
50
  const planId = _resolvePlanId(db, sourcePlan, archived);
@@ -98,10 +95,13 @@ function _upsertPrd(db, filename, archived, prd, now) {
98
95
  for (const ft of features) { if (ft && typeof ft === 'object' && ft.id) keepIds.push(String(ft.id)); }
99
96
  // Drop id-bearing items no longer on the PRD, plus all NULL-feature_id rows.
100
97
  if (keepIds.length) {
98
+ const predicate = `AND feature_id IS NOT NULL AND feature_id NOT IN (${keepIds.map(() => '?').join(',')})`;
99
+ _clearWorkItemLinks(db, prdId, predicate, keepIds);
101
100
  db.prepare(
102
101
  `DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NOT NULL AND feature_id NOT IN (${keepIds.map(() => '?').join(',')})`
103
102
  ).run(prdId, ...keepIds);
104
103
  } else {
104
+ _clearWorkItemLinks(db, prdId, 'AND feature_id IS NOT NULL');
105
105
  db.prepare('DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NOT NULL').run(prdId);
106
106
  }
107
107
  db.prepare('DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NULL').run(prdId);
@@ -181,27 +181,51 @@ function _upsertPrd(db, filename, archived, prd, now) {
181
181
  return prdId;
182
182
  }
183
183
 
184
- function writePrd(filePath, prd) {
185
- const parsed = parsePrdPath(filePath);
186
- if (!parsed || !prd || typeof prd !== 'object' || Array.isArray(prd)) {
187
- throw new TypeError('prd-store.writePrd requires a PRD path and object');
184
+ function writePrd(filename, prd, { archived = false } = {}) {
185
+ _validatePrdFilename(filename);
186
+ if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
187
+ throw new TypeError('prd-store.writePrd requires a filename and object');
188
188
  }
189
189
  const { getDb, withTransaction } = require('./db');
190
190
  const db = getDb();
191
- const prdId = withTransaction(db, () => _upsertPrd(db, parsed.filename, parsed.archived, prd, Date.now()));
192
- require('./db-events').emitStateEvent('prds', { filename: parsed.filename, archived: parsed.archived });
191
+ const archivedValue = archived ? 1 : 0;
192
+ const prdId = withTransaction(db, () => _upsertPrd(db, filename, archivedValue, prd, Date.now()));
193
+ require('./db-events').emitStateEvent('prds', { filename, archived: archivedValue });
193
194
  return { ok: true, id: prdId, plan: prd };
194
195
  }
195
196
 
196
- function mutatePrd(filePath, mutator, { defaultValue = {} } = {}) {
197
- const parsed = parsePrdPath(filePath);
198
- if (!parsed || typeof mutator !== 'function') {
199
- throw new TypeError('prd-store.mutatePrd requires a PRD path and mutator');
197
+ function createPrdUnique(baseFilename, prd) {
198
+ _validatePrdFilename(baseFilename);
199
+ if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
200
+ throw new TypeError('prd-store.createPrdUnique requires a PRD object');
201
+ }
202
+ const stem = baseFilename.slice(0, -'.json'.length);
203
+ const { getDb, withTransaction } = require('./db');
204
+ const db = getDb();
205
+ const filename = withTransaction(db, () => {
206
+ const exists = db.prepare('SELECT 1 FROM prds WHERE filename=? LIMIT 1');
207
+ for (let attempt = 0; attempt < 100; attempt++) {
208
+ const candidate = attempt === 0 ? baseFilename : `${stem}-${attempt}.json`;
209
+ if (exists.get(candidate)) continue;
210
+ _upsertPrd(db, candidate, 0, prd, Date.now());
211
+ return candidate;
212
+ }
213
+ throw new Error('Could not reserve unique PRD filename after 100 attempts');
214
+ });
215
+ require('./db-events').emitStateEvent('prds', { filename, archived: 0 });
216
+ return filename;
217
+ }
218
+
219
+ function mutatePrd(filename, mutator, { archived = false, defaultValue = {} } = {}) {
220
+ _validatePrdFilename(filename);
221
+ if (typeof mutator !== 'function') {
222
+ throw new TypeError('prd-store.mutatePrd requires a filename and mutator');
200
223
  }
201
224
  const { getDb, withTransaction } = require('./db');
202
225
  const db = getDb();
203
226
  const result = withTransaction(db, () => {
204
- const row = db.prepare('SELECT data FROM prds WHERE filename=? AND archived=?').get(parsed.filename, parsed.archived);
227
+ const archivedValue = archived ? 1 : 0;
228
+ const row = db.prepare('SELECT data FROM prds WHERE filename=? AND archived=?').get(filename, archivedValue);
205
229
  let current = row ? JSON.parse(row.data) : structuredClone(defaultValue);
206
230
  if (!current || typeof current !== 'object' || Array.isArray(current)) current = structuredClone(defaultValue);
207
231
  const next = mutator(current);
@@ -209,36 +233,33 @@ function mutatePrd(filePath, mutator, { defaultValue = {} } = {}) {
209
233
  if (!finalData || typeof finalData !== 'object' || Array.isArray(finalData)) {
210
234
  throw new TypeError('PRD mutator must return an object or mutate in place');
211
235
  }
212
- _upsertPrd(db, parsed.filename, parsed.archived, finalData, Date.now());
236
+ _upsertPrd(db, filename, archivedValue, finalData, Date.now());
213
237
  return finalData;
214
238
  });
215
- require('./db-events').emitStateEvent('prds', { filename: parsed.filename, archived: parsed.archived });
239
+ require('./db-events').emitStateEvent('prds', { filename, archived: archived ? 1 : 0 });
216
240
  return result;
217
241
  }
218
242
 
219
- function deletePrd(filePath) {
220
- const parsed = parsePrdPath(filePath);
221
- if (!parsed) throw new TypeError('prd-store.deletePrd requires a PRD path');
243
+ function deletePrd(filename, { archived = false } = {}) {
244
+ _validatePrdFilename(filename);
222
245
  const { getDb, withTransaction } = require('./db');
223
246
  const db = getDb();
224
247
  const removed = withTransaction(db, () => {
225
- const row = db.prepare('SELECT id FROM prds WHERE filename=? AND archived=?').get(parsed.filename, parsed.archived);
248
+ const archivedValue = archived ? 1 : 0;
249
+ const row = db.prepare('SELECT id FROM prds WHERE filename=? AND archived=?').get(filename, archivedValue);
226
250
  if (!row) return false;
227
- db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(row.id);
228
- db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(row.id);
229
- db.prepare('DELETE FROM prds WHERE id=?').run(row.id);
251
+ _deletePrdRow(db, row.id);
230
252
  return true;
231
253
  });
232
- if (removed) require('./db-events').emitStateEvent('prds', { filename: parsed.filename, archived: parsed.archived, removed: true });
254
+ if (removed) require('./db-events').emitStateEvent('prds', { filename, archived: archived ? 1 : 0, removed: true });
233
255
  return removed;
234
256
  }
235
257
 
236
- function readPrd(filePath) {
237
- const parsed = parsePrdPath(filePath);
238
- if (!parsed) return null;
258
+ function readPrd(filename, { archived = false } = {}) {
259
+ _validatePrdFilename(filename);
239
260
  const row = require('./db').getDb()
240
261
  .prepare('SELECT data FROM prds WHERE filename=? AND archived=?')
241
- .get(parsed.filename, parsed.archived);
262
+ .get(filename, archived ? 1 : 0);
242
263
  return row ? JSON.parse(row.data) : null;
243
264
  }
244
265
 
@@ -262,9 +283,7 @@ function restorePrdFromArchive(filename) {
262
283
  if (!row) return null;
263
284
  const prd = JSON.parse(row.data);
264
285
  _upsertPrd(db, filename, 0, prd, Date.now());
265
- db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(row.id);
266
- db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(row.id);
267
- db.prepare('DELETE FROM prds WHERE id=?').run(row.id);
286
+ _deletePrdRow(db, row.id);
268
287
  return prd;
269
288
  });
270
289
  if (restored) {
@@ -332,8 +351,8 @@ function listPrdRows() {
332
351
  }
333
352
 
334
353
  module.exports = {
335
- parsePrdPath,
336
354
  writePrd,
355
+ createPrdUnique,
337
356
  mutatePrd,
338
357
  deletePrd,
339
358
  readPrd,
@@ -266,11 +266,8 @@ function runPreflight(opts = {}) {
266
266
  // discoverFromWorkItems / discoverFromPrs return [] without logging.
267
267
  try {
268
268
  const projectWarns = shared.projectWorkSourceWarnings(opts.config, (project) => {
269
- const wiPath = shared.projectWorkItemsPath(project);
270
- const prPath = shared.projectPrPath(project);
271
- const safeJson = shared.safeJson;
272
- const wi = (() => { try { const a = safeJson(wiPath); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
273
- const pr = (() => { try { const a = safeJson(prPath); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
269
+ const wi = (() => { try { return shared.readWorkItems(project).length; } catch { return 0; } })();
270
+ const pr = (() => { try { return shared.readPullRequests(project).length; } catch { return 0; } })();
274
271
  return { workItems: wi, pullRequests: pr };
275
272
  });
276
273
  for (const w of projectWarns) {