@yemi33/minions 0.1.2379 → 0.1.2380

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 (96) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +2 -3
  3. package/dashboard/js/render-prd.js +1 -1
  4. package/dashboard/js/render-work-items.js +13 -6
  5. package/dashboard.js +393 -721
  6. package/docs/architecture-review-2026-07-09.md +2 -4
  7. package/docs/auto-discovery.md +36 -49
  8. package/docs/blog-first-successful-dispatch.md +1 -1
  9. package/docs/branch-derivation.md +2 -2
  10. package/docs/command-center.md +1 -1
  11. package/docs/completion-reports.md +14 -4
  12. package/docs/constellation-bridge.md +59 -10
  13. package/docs/constellation-style-telemetry.md +6 -6
  14. package/docs/cooldown-merge-semantics.md +4 -0
  15. package/docs/copilot-cli-schema.md +3 -3
  16. package/docs/cross-repo-plans.md +17 -17
  17. package/docs/deprecated.json +2 -2
  18. package/docs/design-state-storage.md +1 -1
  19. package/docs/documentation-audit-2026-07-09.md +2 -2
  20. package/docs/engine-restart.md +20 -8
  21. package/docs/harness-mode.md +1 -1
  22. package/docs/managed-spawn.md +4 -4
  23. package/docs/onboarding.md +1 -2
  24. package/docs/pr-comment-followup.md +3 -3
  25. package/docs/pr-review-fix-loop.md +1 -1
  26. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  27. package/docs/qa-runbook-lifecycle.md +4 -4
  28. package/docs/qa-runbooks.md +2 -2
  29. package/docs/rfc-completion-json.md +4 -1
  30. package/docs/runtime-adapters.md +1 -1
  31. package/docs/self-improvement.md +4 -5
  32. package/docs/shared-lifecycle-module-map.md +3 -1
  33. package/docs/slim-ux/architecture-suggestions.md +5 -6
  34. package/docs/slim-ux/concepts.md +23 -25
  35. package/docs/watches.md +7 -7
  36. package/docs/workspace-manifests.md +1 -1
  37. package/docs/worktree-lifecycle.md +1 -1
  38. package/engine/abandoned-pr-reconciliation.js +4 -5
  39. package/engine/ado-status.js +5 -5
  40. package/engine/ado.js +20 -25
  41. package/engine/agent-worker-pool.js +58 -1
  42. package/engine/bridge.js +260 -5
  43. package/engine/cleanup.js +48 -131
  44. package/engine/cli.js +125 -83
  45. package/engine/cooldown.js +9 -16
  46. package/engine/db/index.js +22 -9
  47. package/engine/db/migrations/009-qa.js +1 -1
  48. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  49. package/engine/db/migrations/021-archived-work-items.js +72 -0
  50. package/engine/db/migrations/022-global-cc-session.js +31 -0
  51. package/engine/db/migrations/023-engine-state.js +30 -0
  52. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  53. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  54. package/engine/dispatch-store.js +2 -7
  55. package/engine/dispatch.js +34 -41
  56. package/engine/github.js +20 -27
  57. package/engine/lifecycle.js +221 -283
  58. package/engine/llm.js +1 -1
  59. package/engine/logs-store.js +2 -2
  60. package/engine/managed-spawn.js +2 -23
  61. package/engine/meeting.js +6 -6
  62. package/engine/metrics-store.js +2 -2
  63. package/engine/note-link-backfill.js +6 -11
  64. package/engine/pipeline.js +18 -36
  65. package/engine/playbook.js +1 -1
  66. package/engine/prd-store.js +73 -54
  67. package/engine/preflight.js +2 -5
  68. package/engine/projects.js +15 -62
  69. package/engine/pull-requests-store.js +0 -17
  70. package/engine/qa-runbooks.js +2 -2
  71. package/engine/qa-runs.js +1 -8
  72. package/engine/qa-sessions.js +41 -64
  73. package/engine/queries.js +120 -219
  74. package/engine/routing.js +3 -5
  75. package/engine/scheduler.js +0 -4
  76. package/engine/shared-branch-pr-reconcile.js +2 -3
  77. package/engine/shared.js +132 -637
  78. package/engine/small-state-store.js +89 -10
  79. package/engine/state-operations.js +16 -4
  80. package/engine/stdio-timestamps.js +1 -1
  81. package/engine/timeout.js +5 -12
  82. package/engine/watch-actions.js +20 -22
  83. package/engine/watches-store.js +1 -1
  84. package/engine/watches.js +6 -10
  85. package/engine/work-item-validation.js +52 -0
  86. package/engine/work-items-store.js +127 -29
  87. package/engine/worktree-gc.js +2 -2
  88. package/engine/worktree-pool.js +8 -18
  89. package/engine.js +147 -343
  90. package/minions.js +2 -2
  91. package/package.json +1 -1
  92. package/playbooks/plan-to-prd.md +2 -2
  93. package/playbooks/shared-rules.md +3 -3
  94. package/playbooks/templates/followup-dispatch.md +1 -1
  95. package/playbooks/verify.md +1 -1
  96. package/prompts/cc-system.md +2 -2
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Output: JSON to stdout. Exit 0 on success, 1 on error/not-found.
16
16
  *
17
- * Cached (default): reads pull-requests.json maintained by the engine (~3 min stale).
17
+ * Cached (default): reads engine-maintained SQL state (~3 min stale).
18
18
  * Live (--live): makes fresh ADO API calls — use when engine isn't running or you
19
19
  * just pushed and need up-to-the-moment build status.
20
20
  */
@@ -23,7 +23,7 @@
23
23
 
24
24
  const path = require('path');
25
25
  const shared = require('./shared');
26
- const { safeJson, safeJsonArr, projectPrPath, getProjects, MINIONS_DIR } = shared;
26
+ const { safeJson, readPullRequests, getProjects, MINIONS_DIR } = shared;
27
27
 
28
28
  // ── Arg parsing ──────────────────────────────────────────────────────────────
29
29
 
@@ -56,7 +56,7 @@ const prNumber = parseInt(prNumberArg, 10);
56
56
 
57
57
  function findInCache(projects) {
58
58
  for (const project of projects) {
59
- const prs = safeJsonArr(projectPrPath(project));
59
+ const prs = readPullRequests(project);
60
60
  shared.normalizePrRecords(prs, project);
61
61
  const pr = prs.find(p => p.prNumber === prNumber || p.id === shared.getCanonicalPrId(project, prNumber) || shared.getPrDisplayId(p) === `PR-${prNumber}`);
62
62
  if (!pr) continue;
@@ -104,7 +104,7 @@ async function main() {
104
104
  if (!live) {
105
105
  const result = findInCache(projects);
106
106
  if (result) { console.log(JSON.stringify(result, null, 2)); return; }
107
- console.error(`PR #${prNumber} not found in cached pull-requests.json`);
107
+ console.error(`PR #${prNumber} not found in the cached PR store`);
108
108
  console.error('Tip: try --live for a fresh ADO API call, or check the PR number is correct.');
109
109
  process.exit(1);
110
110
  }
@@ -133,7 +133,7 @@ async function main() {
133
133
  return;
134
134
  }
135
135
 
136
- console.error(`PR #${prNumber} not found (tried live ADO API and cached pull-requests.json).`);
136
+ console.error(`PR #${prNumber} not found (tried live ADO API and the cached PR store).`);
137
137
  process.exit(1);
138
138
  }
139
139
 
package/engine/ado.js CHANGED
@@ -279,7 +279,7 @@ function repairAdoProjectConfig(project, purpose, prs = null) {
279
279
  const missingBefore = getMissingAdoProjectConfigFields(project);
280
280
  if (missingBefore.length === 0) return false;
281
281
 
282
- const trackedPrs = prs || shared.safeJsonArr(shared.projectPrPath(project));
282
+ const trackedPrs = prs || shared.readPullRequests(project);
283
283
  for (const candidate of collectAdoRepairCandidates(project, trackedPrs)) {
284
284
  const repairs = {};
285
285
  if (!project.adoOrg && candidate.adoOrg) repairs.adoOrg = candidate.adoOrg;
@@ -1199,7 +1199,7 @@ async function forEachActivePr(config, token, callback) {
1199
1199
  }
1200
1200
 
1201
1201
  if (projectUpdated > 0) {
1202
- mutateJsonFileLocked(shared.projectPrPath(project), (currentPrs) => {
1202
+ shared.mutatePullRequests(project, (currentPrs) => {
1203
1203
  // Merge back only fields changed by callbacks; preserve concurrent disk updates.
1204
1204
  for (const { before, after } of updatedRecords) {
1205
1205
  const updatedPrNumber = shared.getPrNumber(after);
@@ -1244,9 +1244,9 @@ async function forEachActivePr(config, token, callback) {
1244
1244
  }
1245
1245
  }
1246
1246
 
1247
- // BUG-C2 — Also poll manually-linked ADO PRs from central pull-requests.json.
1247
+ // BUG-C2 — Also poll manually linked ADO PRs from the central PR store.
1248
1248
  // Mirror of engine/github.js:540-633. Without this block, any ADO PR enrolled
1249
- // into central pull-requests.json via the dashboard attach UI or
1249
+ // into the central PR store via the dashboard attach UI or
1250
1250
  // lifecycle.enrollPrFromCanonicalId is never polled (status, build, review,
1251
1251
  // comments, abandoned-reconciliation all freeze permanently).
1252
1252
  //
@@ -1272,8 +1272,7 @@ async function forEachActivePr(config, token, callback) {
1272
1272
  // in that project's per-project file, so the project loop will really see
1273
1273
  // them. Blindly skipping all project-compatible IDs caused a permanent
1274
1274
  // orphan when the record lived in the central file (#328).
1275
- const centralPath = path.join(shared.MINIONS_DIR, 'pull-requests.json');
1276
- const centralPrs = shared.safeJsonArr(centralPath);
1275
+ const centralPrs = shared.readPullRequests('central');
1277
1276
  const configuredAdoProjects = projects.filter(p => !isGitHubProject(p) && p.adoOrg && p.adoProject);
1278
1277
 
1279
1278
  const activeCentral = centralPrs.filter(pr => {
@@ -1287,7 +1286,7 @@ async function forEachActivePr(config, token, callback) {
1287
1286
  // polled (fix for #328).
1288
1287
  const owningProject = configuredAdoProjects.find(p => shared.isPrCompatibleWithProject(p, { id: pr.id }, ''));
1289
1288
  if (owningProject) {
1290
- const projectPrs = shared.safeJsonArr(shared.projectPrPath(owningProject));
1289
+ const projectPrs = shared.readPullRequests(owningProject);
1291
1290
  if (projectPrs.some(p => p.id === pr.id)) return false; // present in per-project file → project loop handles it
1292
1291
  }
1293
1292
  return true;
@@ -1353,7 +1352,7 @@ async function forEachActivePr(config, token, callback) {
1353
1352
  }
1354
1353
 
1355
1354
  if (centralUpdated > 0) {
1356
- mutateJsonFileLocked(centralPath, (currentPrs) => {
1355
+ shared.mutatePullRequests('central', (currentPrs) => {
1357
1356
  for (const { before, after } of updatedCentralRecords) {
1358
1357
  const idx = currentPrs.findIndex(p => p.id === after.id);
1359
1358
  if (idx >= 0) {
@@ -1457,7 +1456,7 @@ async function pollPrStatus(config) {
1457
1456
  }
1458
1457
  // F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
1459
1458
  // — no further re-dispatch path can fire from this PR, so keeping it
1460
- // around would only bloat pull-requests.json.
1459
+ // around would only bloat the PR store.
1461
1460
  if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
1462
1461
  delete pr.humanFeedback.editsSeen;
1463
1462
  }
@@ -1911,7 +1910,7 @@ async function pollPrStatus(config) {
1911
1910
  // review status flipped, etc.) is dropped via clearPrNoOpFixAttempt. The
1912
1911
  // dispatch gate already ignores these stale records (isPrNoOpFixCausePaused
1913
1912
  // re-validates the fingerprint), but without this sweep they linger in
1914
- // pull-requests.json forever — bloating the file and confusing the
1913
+ // PR store forever — bloating the registry and confusing the
1915
1914
  // dashboard `pr-paused-chip`. Runs AFTER all live-field updates so the
1916
1915
  // fingerprint comparison uses post-poll PR state.
1917
1916
  try {
@@ -2160,7 +2159,7 @@ async function pollPrHumanComments(config) {
2160
2159
 
2161
2160
  /**
2162
2161
  * Reconcile PRs: find active ADO PRs created by the minions that aren't tracked
2163
- * in pull-requests.json, and add them. Matches PRs to work items by branch name.
2162
+ * in the PR store, and add them. Matches PRs to work items by branch name.
2164
2163
  */
2165
2164
  async function reconcilePrs(config) {
2166
2165
  // #3758 — inert when no project actually uses ADO.
@@ -2207,18 +2206,15 @@ async function reconcilePrs(config) {
2207
2206
 
2208
2207
  if (adoPrs.length === 0) continue;
2209
2208
 
2210
- const prPath = shared.projectPrPath(project);
2211
- const existingPrs = shared.safeJsonArr(prPath);
2209
+ const existingPrs = shared.readPullRequests(project);
2212
2210
  shared.normalizePrRecords(existingPrs, project);
2213
2211
  const existingIds = new Set(existingPrs.map(p => p.id));
2214
2212
  let projectAdded = 0;
2215
2213
  let metadataUpdated = 0;
2216
2214
 
2217
2215
  // Load work items to match branches to work item IDs
2218
- const wiPath = shared.projectWorkItemsPath(project);
2219
- const workItems = shared.safeJsonArr(wiPath);
2220
- const centralWiPath = path.join(shared.MINIONS_DIR, 'work-items.json');
2221
- const centralItems = shared.safeJsonArr(centralWiPath);
2216
+ const workItems = shared.readWorkItems(project);
2217
+ const centralItems = shared.readWorkItems('central');
2222
2218
  const allItems = [...workItems, ...centralItems];
2223
2219
 
2224
2220
  let projectUpdated = 0;
@@ -2251,9 +2247,9 @@ async function reconcilePrs(config) {
2251
2247
  log('warn', `ADO: invalid sourceRefName "${branch.slice(0, 64)}" for PR ${prId}: ${refErr.message}`);
2252
2248
  }
2253
2249
  }
2254
- // PR already tracked — write link to pr-links.json if we can extract an ID
2250
+ // PR already tracked — write a PR link if we can extract an ID
2255
2251
  if (confirmedItemId) {
2256
- shared.upsertPullRequestRecord(prPath, existing || {
2252
+ shared.upsertPullRequestRecord(project, existing || {
2257
2253
  id: prId,
2258
2254
  prNumber: adoPr.pullRequestId,
2259
2255
  title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
@@ -2312,7 +2308,7 @@ async function reconcilePrs(config) {
2312
2308
  sourcePlan: linkedItem?.sourcePlan || '',
2313
2309
  itemType: linkedItem?.itemType || '',
2314
2310
  };
2315
- const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
2311
+ const upserted = shared.upsertPullRequestRecord(project, entry, { project, itemId: confirmedItemId });
2316
2312
  existingPrs.push(upserted.record || entry);
2317
2313
  existingIds.add(prId);
2318
2314
  projectAdded++;
@@ -2331,7 +2327,7 @@ async function reconcilePrs(config) {
2331
2327
  const backfilled = shared.backfillPrPrdItems(existingPrs, shared.getPrLinks());
2332
2328
 
2333
2329
  if (projectAdded > 0 || projectUpdated > 0 || backfilled > 0 || metadataUpdated > 0) {
2334
- mutateJsonFileLocked(prPath, (currentPrs) => {
2330
+ shared.mutatePullRequests(project, (currentPrs) => {
2335
2331
  // Merge reconciled PRs into the locked copy by ID
2336
2332
  for (const pr of existingPrs) {
2337
2333
  const idx = currentPrs.findIndex(p => p.id === pr.id);
@@ -2905,7 +2901,7 @@ async function reconcileAbandonedPrs(config) {
2905
2901
  // log line is honest about what we left behind.
2906
2902
  let pending = 0;
2907
2903
  for (const project of projects) {
2908
- const prs = shared.safeJsonArr(shared.projectPrPath(project));
2904
+ const prs = shared.readPullRequests(project);
2909
2905
  pending += prs.filter(pr =>
2910
2906
  pr.status === shared.PR_STATUS.ABANDONED && !pr._reconciliation404Confirmed
2911
2907
  ).length;
@@ -2926,8 +2922,7 @@ async function reconcileAbandonedPrs(config) {
2926
2922
  continue;
2927
2923
  }
2928
2924
 
2929
- const prPath = shared.projectPrPath(project);
2930
- const prs = shared.safeJsonArr(prPath);
2925
+ const prs = shared.readPullRequests(project);
2931
2926
  const abandonedPrs = prs.filter(pr =>
2932
2927
  pr.status === shared.PR_STATUS.ABANDONED
2933
2928
  && !pr._reconciliation404Confirmed
@@ -3010,7 +3005,7 @@ async function reconcileAbandonedPrs(config) {
3010
3005
 
3011
3006
  if (updates.length > 0) {
3012
3007
  const reconciledAt = ts();
3013
- shared.mutatePullRequests(prPath, (currentPrs) => {
3008
+ shared.mutatePullRequests(project, (currentPrs) => {
3014
3009
  for (const upd of updates) {
3015
3010
  const pr = currentPrs.find(p => shared.getPrNumber(p) === upd.prNumber);
3016
3011
  if (!pr) continue;
@@ -112,6 +112,7 @@ const _leaseContexts = new Map(); // dispatchId → mutable session-only context
112
112
  // toward `_poolSize` so a burst of concurrent acquireWorker calls can't
113
113
  // overshoot the configured pool size while spawns are still in flight.
114
114
  let _reserved = 0;
115
+ let _draining = false;
115
116
 
116
117
  function _occupied() {
117
118
  return _free.length + _busy.size + _reserved;
@@ -169,6 +170,11 @@ async function _reuseWorker(worker, req) {
169
170
  await worker.newSession({ mcpServers: req.mcpServers, model: req.model, effort: req.effort, cwd: req.cwd });
170
171
  _trace(`dispatch=${req.dispatchId} reuse: worker=${worker.id} sessionId=${worker.sessionId}`);
171
172
  _reserved--;
173
+ if (_draining) {
174
+ try { worker.close(); } catch { /* already torn down */ }
175
+ req.reject(new Error('agent-worker-pool: draining'));
176
+ return;
177
+ }
172
178
  req.resolve(worker);
173
179
  } catch (err) {
174
180
  _reserved--;
@@ -185,6 +191,11 @@ async function _respawnWorker(stale, req) {
185
191
  try {
186
192
  const worker = await _bootWorker(req);
187
193
  _reserved--;
194
+ if (_draining) {
195
+ try { worker.close(); } catch { /* already torn down */ }
196
+ req.reject(new Error('agent-worker-pool: draining'));
197
+ return;
198
+ }
188
199
  req.resolve(worker);
189
200
  } catch (err) {
190
201
  _reserved--;
@@ -197,6 +208,11 @@ async function _spawnFreshWorker(req) {
197
208
  try {
198
209
  const worker = await _bootWorker(req);
199
210
  _reserved--;
211
+ if (_draining) {
212
+ try { worker.close(); } catch { /* already torn down */ }
213
+ req.reject(new Error('agent-worker-pool: draining'));
214
+ return;
215
+ }
200
216
  req.resolve(worker);
201
217
  } catch (err) {
202
218
  _reserved--;
@@ -299,6 +315,7 @@ async function acquireWorker({
299
315
  dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
300
316
  } = {}) {
301
317
  if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
318
+ if (_draining) throw new Error('agent-worker-pool: draining');
302
319
  if (_busy.has(dispatchId) || _leaseContexts.has(dispatchId)) {
303
320
  throw new Error(
304
321
  `agent-worker-pool.acquireWorker: dispatchId ${dispatchId} already has an active worker ` +
@@ -329,13 +346,20 @@ async function acquireWorker({
329
346
  _pump();
330
347
  });
331
348
 
349
+ if (_draining) throw new Error('agent-worker-pool: draining');
350
+ _busy.set(dispatchId, worker);
332
351
  await _applyHermeticDirs(worker, hermeticDirs);
333
352
  if (worker.killed) throw new Error('agent-worker-pool: worker died during acquisition');
353
+ if (_draining) throw new Error('agent-worker-pool: draining');
334
354
  } catch (err) {
355
+ if (_busy.get(dispatchId) === worker) _busy.delete(dispatchId);
335
356
  _clearLeaseContext(dispatchId);
357
+ if (worker) {
358
+ try { worker.close(); } catch { /* already torn down */ }
359
+ _pump();
360
+ }
336
361
  throw err;
337
362
  }
338
- _busy.set(dispatchId, worker);
339
363
 
340
364
  return {
341
365
  dispatchId,
@@ -381,13 +405,42 @@ function release(dispatchId) {
381
405
  _pump();
382
406
  return true;
383
407
  }
408
+ if (_draining) {
409
+ try { worker.close(); } catch { /* already torn down */ }
410
+ _trace(`dispatch=${dispatchId} release: draining, worker=${worker.id} closed`);
411
+ return true;
412
+ }
384
413
  _free.push(worker);
385
414
  _trace(`dispatch=${dispatchId} release: worker=${worker.id} returned to free set`);
386
415
  _pump();
387
416
  return true;
388
417
  }
389
418
 
419
+ function beginDrain() {
420
+ if (_draining) return;
421
+ _draining = true;
422
+ for (const req of _queue.splice(0)) {
423
+ try { req.reject(new Error('agent-worker-pool: draining')); } catch { /* already settled */ }
424
+ }
425
+ for (const worker of _free.splice(0)) {
426
+ try { worker.close(); } catch { /* already torn down */ }
427
+ }
428
+ }
429
+
430
+ function isDraining() {
431
+ return _draining;
432
+ }
433
+
434
+ function isDrained() {
435
+ return _busy.size === 0 && _reserved === 0;
436
+ }
437
+
438
+ function getActiveLeaseIds() {
439
+ return [..._busy.keys()];
440
+ }
441
+
390
442
  function shutdown() {
443
+ _draining = true;
391
444
  for (const req of _queue.splice(0)) {
392
445
  _clearLeaseContext(req.dispatchId);
393
446
  try { req.reject(new Error('agent-worker-pool: shutdown')); } catch { /* swallow */ }
@@ -408,6 +461,10 @@ module.exports = {
408
461
  release,
409
462
  setPoolSize,
410
463
  getPoolSize,
464
+ beginDrain,
465
+ isDraining,
466
+ isDrained,
467
+ getActiveLeaseIds,
411
468
  shutdown,
412
469
  DEFAULT_POOL_SIZE,
413
470
  // Exposed for unit tests; engine code MUST go through the public API.
package/engine/bridge.js CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * engine/bridge.js — Constellation-bridge config + marker accessors.
3
3
  *
4
- * Pure helpers for the `minions bridge ...` subcommand and any future
5
- * code that wants to inspect or mutate the read-only Constellation bridge
6
- * surface. The bridge polling logic itself lives in the Constellation
7
- * repo (P-wi1-bridge-readonly) this file owns ONLY the on/off flag, the
8
- * marker-file contract, and atomic config writes for the toggle.
4
+ * Pure helpers for the `minions bridge ...` subcommand and the versioned
5
+ * metadata snapshot consumed by Constellation. Minions remains authoritative:
6
+ * the Constellation agent polls the local dashboard and mirrors only the
7
+ * allowlisted projection returned by buildBridgeSnapshot().
9
8
  *
10
9
  * Strict semantics: only the literal boolean `true` enables the bridge.
11
10
  * Mirror this check on the Constellation reader to avoid truthy coercion
@@ -24,6 +23,258 @@ const {
24
23
  } = shared;
25
24
 
26
25
  const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
26
+ const BRIDGE_SNAPSHOT_PROTOCOL = 'minions.constellation.snapshot';
27
+ const BRIDGE_SNAPSHOT_PROTOCOL_VERSION = 1;
28
+
29
+ const SNAPSHOT_CAPS = Object.freeze({
30
+ id: 128,
31
+ status: 128,
32
+ agent: 128,
33
+ title: 512,
34
+ project: 128,
35
+ url: 2048,
36
+ timestamp: 64,
37
+ shortLabel: 128,
38
+ branch: 255,
39
+ name: 128,
40
+ });
41
+ const INACTIVE_AGENT_STATUSES = new Set(['idle', 'available', 'sleeping']);
42
+
43
+ function _string(value, max, fallback = null) {
44
+ if (typeof value !== 'string') return fallback;
45
+ const trimmed = value.trim();
46
+ if (!trimmed) return fallback;
47
+ return trimmed.slice(0, max);
48
+ }
49
+
50
+ function _isoTime(value) {
51
+ if (value === null || value === undefined || value === '') return undefined;
52
+ const ms = typeof value === 'number' ? value : Date.parse(String(value));
53
+ if (!Number.isFinite(ms)) return undefined;
54
+ return new Date(ms).toISOString().slice(0, SNAPSHOT_CAPS.timestamp);
55
+ }
56
+
57
+ function _defined(obj) {
58
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
59
+ }
60
+
61
+ function _projectList(items, projector) {
62
+ if (!Array.isArray(items)) return [];
63
+ const projected = [];
64
+ for (const item of items) {
65
+ if (!item || typeof item !== 'object') continue;
66
+ const value = projector(item);
67
+ if (value) projected.push(value);
68
+ }
69
+ return projected;
70
+ }
71
+
72
+ function _projectWorkItem(item) {
73
+ const id = _string(item.id, SNAPSHOT_CAPS.id);
74
+ if (!id) return null;
75
+ return _defined({
76
+ id,
77
+ title: _string(item.title, SNAPSHOT_CAPS.title, id),
78
+ type: _string(item.type, SNAPSHOT_CAPS.shortLabel) || undefined,
79
+ priority: _string(item.priority, SNAPSHOT_CAPS.shortLabel) || undefined,
80
+ status: _string(item.status, SNAPSHOT_CAPS.status, 'unknown'),
81
+ project: _string(item.project, SNAPSHOT_CAPS.project) || undefined,
82
+ agent: _string(item.agent, SNAPSHOT_CAPS.agent) || undefined,
83
+ createdBy: _string(item.createdBy, SNAPSHOT_CAPS.shortLabel) || undefined,
84
+ created: _isoTime(item.created),
85
+ dispatched_to: _string(item.dispatched_to, SNAPSHOT_CAPS.agent) || undefined,
86
+ dispatched_at: _isoTime(item.dispatched_at),
87
+ completedAt: _isoTime(item.completedAt || item.completed_at),
88
+ });
89
+ }
90
+
91
+ function _projectPullRequest(pr) {
92
+ const id = _string(pr.id, SNAPSHOT_CAPS.id);
93
+ const prNumber = Number(pr.prNumber);
94
+ const url = _string(pr.url, SNAPSHOT_CAPS.url);
95
+ if (!id || !url || !Number.isInteger(prNumber) || prNumber < 0) return null;
96
+ return _defined({
97
+ id,
98
+ prNumber,
99
+ title: _string(pr.title, SNAPSHOT_CAPS.title, id),
100
+ agent: _string(pr.agent, SNAPSHOT_CAPS.agent) || undefined,
101
+ branch: _string(pr.branch, SNAPSHOT_CAPS.branch) || undefined,
102
+ reviewStatus: _string(pr.reviewStatus, SNAPSHOT_CAPS.status, 'unknown'),
103
+ status: _string(pr.status, SNAPSHOT_CAPS.status, 'unknown'),
104
+ url,
105
+ created: _isoTime(pr.created),
106
+ lastReviewedAt: _isoTime(pr.lastReviewedAt),
107
+ lastPushedAt: _isoTime(pr.lastPushedAt),
108
+ lastDispatchedAt: _isoTime(pr.lastDispatchedAt),
109
+ mergeStatus: _string(pr.mergeStatus || pr.mergeStateStatus, SNAPSHOT_CAPS.shortLabel) || undefined,
110
+ mergeable: typeof pr.mergeable === 'boolean' ? pr.mergeable : undefined,
111
+ isDraft: typeof pr.isDraft === 'boolean' ? pr.isDraft : undefined,
112
+ behindBy: Number.isInteger(pr.behindBy) && pr.behindBy >= 0 ? pr.behindBy : undefined,
113
+ });
114
+ }
115
+
116
+ function _projectPlan(plan) {
117
+ const id = _string(plan.id || plan.file, SNAPSHOT_CAPS.id);
118
+ if (!id) return null;
119
+ return _defined({
120
+ id,
121
+ title: _string(plan.title || plan.summary || plan.plan_summary, SNAPSHOT_CAPS.title, id),
122
+ status: _string(plan.status, SNAPSHOT_CAPS.status, plan.archived ? 'archived' : 'unknown'),
123
+ agent: _string(plan.agent || plan.generatedBy || plan.generated_by, SNAPSHOT_CAPS.agent) || undefined,
124
+ project: _string(plan.project, SNAPSHOT_CAPS.project) || undefined,
125
+ createdAt: _isoTime(plan.createdAt || plan.generatedAt || plan.generated_at),
126
+ updatedAt: _isoTime(plan.updatedAt),
127
+ });
128
+ }
129
+
130
+ function _projectSchedule(schedule) {
131
+ const id = _string(schedule.id, SNAPSHOT_CAPS.id);
132
+ if (!id) return null;
133
+ return _defined({
134
+ id,
135
+ title: _string(schedule.title, SNAPSHOT_CAPS.title, id),
136
+ status: _string(schedule.status, SNAPSHOT_CAPS.status,
137
+ schedule.enabled === false ? 'disabled' : 'enabled'),
138
+ type: _string(schedule.type, SNAPSHOT_CAPS.shortLabel, 'schedule'),
139
+ project: _string(schedule.project, SNAPSHOT_CAPS.project) || undefined,
140
+ agent: _string(schedule.agent, SNAPSHOT_CAPS.agent) || undefined,
141
+ nextRun: _isoTime(schedule.nextRun),
142
+ lastRun: _isoTime(schedule.lastRun || schedule._lastRun || schedule._lastCompletedAt),
143
+ });
144
+ }
145
+
146
+ function _projectDispatch(entry, status) {
147
+ const id = _string(entry.id, SNAPSHOT_CAPS.id);
148
+ if (!id) return null;
149
+ const meta = entry.meta && typeof entry.meta === 'object' ? entry.meta : {};
150
+ const metaItem = meta.item && typeof meta.item === 'object' ? meta.item : {};
151
+ const metaProject = meta.project && typeof meta.project === 'object' ? meta.project : {};
152
+ return _defined({
153
+ id,
154
+ agent: _string(entry.agent, SNAPSHOT_CAPS.agent, 'unassigned'),
155
+ status: _string(entry.status, SNAPSHOT_CAPS.status, status),
156
+ type: _string(entry.type, SNAPSHOT_CAPS.shortLabel) || undefined,
157
+ project: _string(entry.project || metaProject.name, SNAPSHOT_CAPS.project) || undefined,
158
+ workItemId: _string(entry.workItemId || metaItem.id, SNAPSHOT_CAPS.id) || undefined,
159
+ prId: _string(entry.prId || entry.pr_id, SNAPSHOT_CAPS.id) || undefined,
160
+ startedAt: _isoTime(entry.startedAt || entry.started_at),
161
+ completedAt: _isoTime(entry.completedAt || entry.completed_at),
162
+ outcome: _string(entry.outcome || entry.result, SNAPSHOT_CAPS.shortLabel) || undefined,
163
+ });
164
+ }
165
+
166
+ function _projectDispatchQueue(dispatch) {
167
+ if (!dispatch || typeof dispatch !== 'object') return [];
168
+ const projected = [];
169
+ for (const [bucket, status] of [
170
+ ['pending', 'queued'],
171
+ ['active', 'active'],
172
+ ['review', 'review'],
173
+ ['completed', 'completed'],
174
+ ]) {
175
+ const entries = bucket === 'completed' && Array.isArray(dispatch[bucket])
176
+ ? dispatch[bucket].slice(-20)
177
+ : dispatch[bucket];
178
+ projected.push(..._projectList(entries, entry => _projectDispatch(entry, status)));
179
+ }
180
+ return projected;
181
+ }
182
+
183
+ function _projectAgent(agent) {
184
+ const id = _string(agent.id || agent.agent, SNAPSHOT_CAPS.agent);
185
+ if (!id) return null;
186
+ return _defined({
187
+ agent: id,
188
+ status: _string(agent.status, SNAPSHOT_CAPS.status, 'unknown'),
189
+ project: _string(agent.project, SNAPSHOT_CAPS.project) || undefined,
190
+ currentTaskId: _string(agent.currentTaskId || agent.currentDispatchId, SNAPSHOT_CAPS.id) || undefined,
191
+ currentTaskTitle: _string(agent.currentTaskTitle || agent.currentTask, SNAPSHOT_CAPS.title) || undefined,
192
+ updatedAt: _isoTime(agent.updatedAt || agent.lastActionAt),
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Build the complete metadata-only state projection consumed by Constellation.
198
+ * Every field is copied explicitly so free-text task descriptions, prompts,
199
+ * completion reports, diffs, comments, paths, logs, and credentials cannot
200
+ * cross the bridge accidentally.
201
+ */
202
+ function buildBridgeSnapshot(state = {}) {
203
+ const generatedAt = _isoTime(state.generatedAt) || new Date().toISOString();
204
+ const engine = state.control && typeof state.control === 'object' ? state.control : {};
205
+ const workItems = _projectList(state.workItems, _projectWorkItem);
206
+ const pullRequests = _projectList(state.pullRequests, _projectPullRequest);
207
+ const plans = _projectList(state.plans, _projectPlan);
208
+ const schedules = _projectList(state.schedules, _projectSchedule);
209
+ const agents = _projectList(state.agents, _projectAgent);
210
+ const dispatches = _projectDispatchQueue(state.dispatch);
211
+ const activeAgents = agents.filter(agent => !INACTIVE_AGENT_STATUSES.has(agent.status.toLowerCase())).length;
212
+ const minionsVersion = _string(state.minionsVersion, SNAPSHOT_CAPS.shortLabel);
213
+ const engineState = _string(engine.state, SNAPSHOT_CAPS.status, 'unknown');
214
+ const enginePid = Number.isInteger(engine.pid) && engine.pid > 0 ? engine.pid : null;
215
+ const projects = Array.isArray(state.projects) ? state.projects : [];
216
+
217
+ return {
218
+ protocol: BRIDGE_SNAPSHOT_PROTOCOL,
219
+ protocolVersion: BRIDGE_SNAPSHOT_PROTOCOL_VERSION,
220
+ complete: true,
221
+ generatedAt,
222
+ minionsVersion,
223
+ engine: {
224
+ state: engineState,
225
+ pid: enginePid,
226
+ },
227
+ entities: {
228
+ work_item: workItems,
229
+ pr: pullRequests,
230
+ plan: plans,
231
+ schedule: schedules,
232
+ dispatch: dispatches,
233
+ agent_status: agents,
234
+ device_summary: [{
235
+ name: 'Minions',
236
+ status: engineState,
237
+ lastSeen: generatedAt,
238
+ agentCount: agents.length,
239
+ activeAgentCount: activeAgents,
240
+ workItemCount: workItems.length,
241
+ pullRequestCount: pullRequests.length,
242
+ planCount: plans.length,
243
+ scheduleCount: schedules.length,
244
+ dispatchCount: dispatches.length,
245
+ projectCount: projects.length,
246
+ protocolVersion: BRIDGE_SNAPSHOT_PROTOCOL_VERSION,
247
+ minionsVersion,
248
+ snapshotGeneratedAt: generatedAt,
249
+ enginePid,
250
+ }],
251
+ },
252
+ };
253
+ }
254
+
255
+ function projectSnapshotHealthForBridge(snapshot) {
256
+ if (!snapshot || typeof snapshot !== 'object') return null;
257
+ if (snapshot.protocol !== BRIDGE_SNAPSHOT_PROTOCOL) return null;
258
+ if (snapshot.protocolVersion !== BRIDGE_SNAPSHOT_PROTOCOL_VERSION) return null;
259
+ if (snapshot.complete !== true || !snapshot.entities || typeof snapshot.entities !== 'object') return null;
260
+ const entities = snapshot.entities;
261
+ const summary = Array.isArray(entities.device_summary) ? entities.device_summary[0] || {} : {};
262
+ const count = key => (Array.isArray(entities[key]) ? entities[key].length : 0);
263
+ return {
264
+ protocolVersion: snapshot.protocolVersion,
265
+ minionsVersion: snapshot.minionsVersion ?? null,
266
+ engineState: snapshot.engine?.state ?? null,
267
+ enginePid: snapshot.engine?.pid ?? null,
268
+ agentCount: count('agent_status'),
269
+ activeAgentCount: summary.activeAgentCount ?? null,
270
+ workItemCount: count('work_item'),
271
+ pullRequestCount: count('pr'),
272
+ planCount: count('plan'),
273
+ scheduleCount: count('schedule'),
274
+ dispatchCount: count('dispatch'),
275
+ projectCount: summary.projectCount ?? null,
276
+ };
277
+ }
27
278
 
28
279
  /**
29
280
  * Strict check: `true` ⇔ bridge enabled. Any other shape (missing field,
@@ -118,7 +369,11 @@ module.exports = {
118
369
  readBridgeMarker,
119
370
  setBridgeEnabled,
120
371
  formatRelativeAge,
372
+ buildBridgeSnapshot,
373
+ projectSnapshotHealthForBridge,
121
374
  projectStatusForBridge,
375
+ BRIDGE_SNAPSHOT_PROTOCOL,
376
+ BRIDGE_SNAPSHOT_PROTOCOL_VERSION,
122
377
  CONSTELLATION_BRIDGE_MARKER_PATH,
123
378
  CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
124
379
  };