@yemi33/minions 0.1.2378 → 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 (100) 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/README.md +1 -0
  7. package/docs/architecture-review-2026-07-09.md +2 -4
  8. package/docs/auto-discovery.md +36 -49
  9. package/docs/blog-first-successful-dispatch.md +1 -1
  10. package/docs/branch-derivation.md +2 -2
  11. package/docs/command-center.md +1 -1
  12. package/docs/completion-reports.md +14 -4
  13. package/docs/constellation-bridge.md +59 -10
  14. package/docs/constellation-style-telemetry.md +6 -6
  15. package/docs/cooldown-merge-semantics.md +4 -0
  16. package/docs/copilot-cli-schema.md +3 -3
  17. package/docs/cross-repo-plans.md +17 -17
  18. package/docs/deprecated.json +2 -2
  19. package/docs/design-inbox-entries-schema.md +31 -39
  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/managed-spawn.md +4 -4
  25. package/docs/onboarding.md +1 -2
  26. package/docs/pr-comment-followup.md +3 -3
  27. package/docs/pr-review-fix-loop.md +1 -1
  28. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  29. package/docs/qa-runbook-lifecycle.md +4 -4
  30. package/docs/qa-runbooks.md +2 -2
  31. package/docs/rfc-completion-json.md +4 -1
  32. package/docs/runtime-adapters.md +1 -1
  33. package/docs/self-improvement.md +4 -5
  34. package/docs/shared-lifecycle-module-map.md +3 -1
  35. package/docs/slim-ux/architecture-suggestions.md +5 -6
  36. package/docs/slim-ux/concepts.md +23 -25
  37. package/docs/watches.md +7 -7
  38. package/docs/workspace-manifests.md +1 -1
  39. package/docs/worktree-lifecycle.md +1 -1
  40. package/engine/abandoned-pr-reconciliation.js +4 -5
  41. package/engine/acp-transport.js +49 -8
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +124 -15
  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 +34 -41
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +221 -283
  61. package/engine/llm.js +1 -1
  62. package/engine/logs-store.js +2 -2
  63. package/engine/managed-spawn.js +2 -23
  64. package/engine/meeting.js +6 -6
  65. package/engine/metrics-store.js +2 -2
  66. package/engine/note-link-backfill.js +6 -11
  67. package/engine/pipeline.js +18 -36
  68. package/engine/playbook.js +1 -1
  69. package/engine/pooled-agent-process.js +46 -26
  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 +3 -5
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +132 -637
  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 +167 -349
  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 +2 -2
@@ -59,7 +59,7 @@
59
59
  */
60
60
 
61
61
  const transport = require('./acp-transport');
62
- const { Worker, hashMcpServers } = transport;
62
+ const { Worker, hashMcpServers, hashProcessContext } = transport;
63
63
 
64
64
  // Test seam — mirrors engine/cc-worker-pool.js's `_internals` pattern, but is
65
65
  // its OWN object (constructor-injected into Worker per acquisition) so this
@@ -106,11 +106,13 @@ let _nextWorkerId = 1;
106
106
  const _free = []; // Worker[] — idle, unleased
107
107
  const _busy = new Map(); // dispatchId → Worker — currently leased
108
108
  const _queue = []; // pending acquire requests, FIFO
109
+ const _leaseContexts = new Map(); // dispatchId → mutable session-only context
109
110
  // Slots reserved for a worker that's mid-reuse/respawn/fresh-spawn — pulled
110
111
  // out of `_free` (or not yet spawned) but not yet placed in `_busy`. Counted
111
112
  // toward `_poolSize` so a burst of concurrent acquireWorker calls can't
112
113
  // overshoot the configured pool size while spawns are still in flight.
113
114
  let _reserved = 0;
115
+ let _draining = false;
114
116
 
115
117
  function _occupied() {
116
118
  return _free.length + _busy.size + _reserved;
@@ -130,7 +132,10 @@ function _pump() {
130
132
  while (_queue.length > 0) {
131
133
  const req = _queue[0];
132
134
 
133
- const matchIdx = _free.findIndex((w) => w.mcpServersHash === req.mcpServersHash);
135
+ const matchIdx = _free.findIndex((w) =>
136
+ w.mcpServersHash === req.mcpServersHash
137
+ && w.processContextHash === req.processContextHash
138
+ );
134
139
  if (matchIdx >= 0) {
135
140
  _queue.shift();
136
141
  const worker = _free.splice(matchIdx, 1)[0];
@@ -165,6 +170,11 @@ async function _reuseWorker(worker, req) {
165
170
  await worker.newSession({ mcpServers: req.mcpServers, model: req.model, effort: req.effort, cwd: req.cwd });
166
171
  _trace(`dispatch=${req.dispatchId} reuse: worker=${worker.id} sessionId=${worker.sessionId}`);
167
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
+ }
168
178
  req.resolve(worker);
169
179
  } catch (err) {
170
180
  _reserved--;
@@ -181,6 +191,11 @@ async function _respawnWorker(stale, req) {
181
191
  try {
182
192
  const worker = await _bootWorker(req);
183
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
+ }
184
199
  req.resolve(worker);
185
200
  } catch (err) {
186
201
  _reserved--;
@@ -193,6 +208,11 @@ async function _spawnFreshWorker(req) {
193
208
  try {
194
209
  const worker = await _bootWorker(req);
195
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
+ }
196
216
  req.resolve(worker);
197
217
  } catch (err) {
198
218
  _reserved--;
@@ -215,6 +235,8 @@ async function _bootWorker(req) {
215
235
  effort: req.effort,
216
236
  mcpServers: req.mcpServers,
217
237
  mcpServersHash: req.mcpServersHash,
238
+ processEnv: req.processEnv,
239
+ processContextHash: req.processContextHash,
218
240
  cwd: req.cwd,
219
241
  internals: _internals,
220
242
  trace: _trace,
@@ -246,6 +268,7 @@ function _onWorkerExit(worker) {
246
268
  for (const [dispatchId, w] of _busy) {
247
269
  if (w === worker) {
248
270
  _busy.delete(dispatchId);
271
+ _clearLeaseContext(dispatchId);
249
272
  removed = true;
250
273
  break;
251
274
  }
@@ -272,6 +295,7 @@ async function _applyHermeticDirs(worker, hermeticDirs) {
272
295
  worker.stream(`/add-dir ${dir}`, {
273
296
  onDone: () => { _trace(`worker=${worker.id} add-dir ok: ${dir}`); resolve(); },
274
297
  onError: (err) => { _trace(`worker=${worker.id} add-dir failed: ${dir} (${err && err.message})`); resolve(); },
298
+ deferSystemPrompt: true,
275
299
  }).catch(() => resolve());
276
300
  });
277
301
  }
@@ -279,9 +303,20 @@ async function _applyHermeticDirs(worker, hermeticDirs) {
279
303
 
280
304
  // ── Public API ────────────────────────────────────────────────────────────
281
305
 
282
- async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, hermeticDirs } = {}) {
306
+ function _clearLeaseContext(dispatchId) {
307
+ const context = _leaseContexts.get(dispatchId);
308
+ if (!context) return false;
309
+ for (const key of Object.keys(context)) delete context[key];
310
+ _leaseContexts.delete(dispatchId);
311
+ return true;
312
+ }
313
+
314
+ async function acquireWorker({
315
+ dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
316
+ } = {}) {
283
317
  if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
284
- if (_busy.has(dispatchId)) {
318
+ if (_draining) throw new Error('agent-worker-pool: draining');
319
+ if (_busy.has(dispatchId) || _leaseContexts.has(dispatchId)) {
285
320
  throw new Error(
286
321
  `agent-worker-pool.acquireWorker: dispatchId ${dispatchId} already has an active worker ` +
287
322
  '(1 worker : 1 active dispatch invariant — call release() before acquiring again)'
@@ -289,17 +324,42 @@ async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, herme
289
324
  }
290
325
 
291
326
  const mcpServersHash = hashMcpServers(mcpServers);
292
- const req = { dispatchId, cwd, model, effort, mcpServers, mcpServersHash };
327
+ const workerProcessEnv = processEnv && typeof processEnv === 'object'
328
+ ? Object.freeze({ ...processEnv })
329
+ : undefined;
330
+ const processContextHash = hashProcessContext(workerProcessEnv);
331
+ const leaseContext = sessionContext && typeof sessionContext === 'object'
332
+ ? { ...sessionContext }
333
+ : {};
334
+ const req = {
335
+ dispatchId, cwd, model, effort, mcpServers, mcpServersHash,
336
+ processEnv: workerProcessEnv, processContextHash,
337
+ };
338
+ _leaseContexts.set(dispatchId, leaseContext);
293
339
 
294
- const worker = await new Promise((resolve, reject) => {
295
- req.resolve = resolve;
296
- req.reject = reject;
297
- _queue.push(req);
298
- _pump();
299
- });
340
+ let worker;
341
+ try {
342
+ worker = await new Promise((resolve, reject) => {
343
+ req.resolve = resolve;
344
+ req.reject = reject;
345
+ _queue.push(req);
346
+ _pump();
347
+ });
300
348
 
301
- await _applyHermeticDirs(worker, hermeticDirs);
302
- _busy.set(dispatchId, worker);
349
+ if (_draining) throw new Error('agent-worker-pool: draining');
350
+ _busy.set(dispatchId, worker);
351
+ await _applyHermeticDirs(worker, hermeticDirs);
352
+ if (worker.killed) throw new Error('agent-worker-pool: worker died during acquisition');
353
+ if (_draining) throw new Error('agent-worker-pool: draining');
354
+ } catch (err) {
355
+ if (_busy.get(dispatchId) === worker) _busy.delete(dispatchId);
356
+ _clearLeaseContext(dispatchId);
357
+ if (worker) {
358
+ try { worker.close(); } catch { /* already torn down */ }
359
+ _pump();
360
+ }
361
+ throw err;
362
+ }
303
363
 
304
364
  return {
305
365
  dispatchId,
@@ -311,8 +371,20 @@ async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, herme
311
371
  // captured copy), since sessionId can rotate under a reused worker.
312
372
  get pid() { return (worker.proc && worker.proc.pid) || null; },
313
373
  get sessionId() { return worker.sessionId || null; },
314
- stream: (promptText, opts) => worker.stream(promptText, opts),
315
- cancel: () => worker.cancel(),
374
+ stream: async (promptText, opts = {}) => {
375
+ try {
376
+ return await worker.stream(promptText, {
377
+ ...opts,
378
+ trustedSessionContext: leaseContext,
379
+ });
380
+ } finally {
381
+ _clearLeaseContext(dispatchId);
382
+ }
383
+ },
384
+ cancel: () => {
385
+ _clearLeaseContext(dispatchId);
386
+ return worker.cancel();
387
+ },
316
388
  release: () => release(dispatchId),
317
389
  };
318
390
  }
@@ -325,6 +397,7 @@ async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, herme
325
397
  // never acquired) — safe to call defensively.
326
398
  function release(dispatchId) {
327
399
  const worker = _busy.get(dispatchId);
400
+ _clearLeaseContext(dispatchId);
328
401
  if (!worker) return false;
329
402
  _busy.delete(dispatchId);
330
403
  if (worker.killed) {
@@ -332,14 +405,44 @@ function release(dispatchId) {
332
405
  _pump();
333
406
  return true;
334
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
+ }
335
413
  _free.push(worker);
336
414
  _trace(`dispatch=${dispatchId} release: worker=${worker.id} returned to free set`);
337
415
  _pump();
338
416
  return true;
339
417
  }
340
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
+
341
442
  function shutdown() {
443
+ _draining = true;
342
444
  for (const req of _queue.splice(0)) {
445
+ _clearLeaseContext(req.dispatchId);
343
446
  try { req.reject(new Error('agent-worker-pool: shutdown')); } catch { /* swallow */ }
344
447
  }
345
448
  for (const worker of _free.splice(0)) {
@@ -349,6 +452,7 @@ function shutdown() {
349
452
  try { worker.close(); } catch { /* swallow */ }
350
453
  }
351
454
  _busy.clear();
455
+ for (const dispatchId of [..._leaseContexts.keys()]) _clearLeaseContext(dispatchId);
352
456
  _reserved = 0;
353
457
  }
354
458
 
@@ -357,6 +461,10 @@ module.exports = {
357
461
  release,
358
462
  setPoolSize,
359
463
  getPoolSize,
464
+ beginDrain,
465
+ isDraining,
466
+ isDrained,
467
+ getActiveLeaseIds,
360
468
  shutdown,
361
469
  DEFAULT_POOL_SIZE,
362
470
  // Exposed for unit tests; engine code MUST go through the public API.
@@ -364,4 +472,5 @@ module.exports = {
364
472
  _free,
365
473
  _busy,
366
474
  _queue,
475
+ _leaseContexts,
367
476
  };
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
  };