@yemi33/minions 0.1.2448 → 0.1.2449

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 (315) hide show
  1. package/bin/cli-api-client.js +1 -1
  2. package/bin/install-internal-minions.js +1382 -44
  3. package/bin/install-layout.js +150 -0
  4. package/bin/minions.js +460 -167
  5. package/dashboard/docs/typography.md +65 -12
  6. package/dashboard/js/command-center.js +58 -4
  7. package/dashboard/js/detail-panel.js +36 -0
  8. package/dashboard/js/memory-panel.js +59 -12
  9. package/dashboard/js/qa.js +179 -20
  10. package/dashboard/js/refresh.js +148 -12
  11. package/dashboard/js/render-dispatch.js +3 -4
  12. package/dashboard/js/render-inbox.js +2 -2
  13. package/dashboard/js/render-other.js +3 -3
  14. package/dashboard/js/render-pipelines.js +14 -0
  15. package/dashboard/js/render-plans.js +57 -9
  16. package/dashboard/js/render-prd.js +132 -23
  17. package/dashboard/js/render-prs.js +195 -166
  18. package/dashboard/js/render-schedules.js +63 -3
  19. package/dashboard/js/render-utils.js +3 -3
  20. package/dashboard/js/render-watches.js +19 -3
  21. package/dashboard/js/render-work-items.js +238 -30
  22. package/dashboard/js/settings.js +205 -54
  23. package/dashboard/js/utils.js +51 -1
  24. package/dashboard/pages/home.html +1 -1
  25. package/dashboard/pages/qa.html +1 -16
  26. package/dashboard/pages/work.html +40 -0
  27. package/dashboard/shared/cc-limits.js +79 -0
  28. package/dashboard/shared/pr-filters.js +21 -38
  29. package/dashboard/shared/project-git-summary.js +1 -1
  30. package/dashboard/shared/record-filters.js +169 -0
  31. package/dashboard/shared/watches-source.js +1 -1
  32. package/dashboard/shared/welcome-popup.js +1 -1
  33. package/dashboard/shared/wi-filters.js +302 -0
  34. package/dashboard/slim/body.html +1 -0
  35. package/dashboard/slim/js/command-send.js +26 -0
  36. package/dashboard/slim/js/modals-tiles.js +380 -39
  37. package/dashboard/slim/js/status.js +13 -21
  38. package/dashboard/slim/layout.html +1 -0
  39. package/dashboard/slim/panel-bootstrap.js +6 -2
  40. package/dashboard/slim/styles.css +38 -0
  41. package/dashboard/styles.css +159 -55
  42. package/dashboard-build.js +13 -2
  43. package/dashboard.js +956 -423
  44. package/docs/README.md +11 -6
  45. package/docs/api-errors.md +2 -2
  46. package/docs/architecture-review-2026-07-09.md +1 -1
  47. package/docs/architecture.excalidraw +2 -2
  48. package/docs/auto-discovery.md +18 -9
  49. package/docs/branch-derivation.md +4 -4
  50. package/docs/capture-demos.js +39 -2
  51. package/docs/ci-runner-canary.md +123 -0
  52. package/docs/claude-md-propagation.md +2 -2
  53. package/docs/cloud-agent-dispatch.md +204 -0
  54. package/docs/command-center.md +7 -7
  55. package/docs/completion-reports.md +43 -20
  56. package/docs/constants.md +10 -3
  57. package/docs/constellation-bridge.md +134 -6
  58. package/docs/constellation-style-telemetry.md +4 -4
  59. package/docs/contracts/capability-protocol.v1.json +165 -0
  60. package/docs/cooldown-merge-semantics.md +12 -12
  61. package/docs/copilot-cli-schema.md +7 -7
  62. package/docs/cross-repo-plans.md +10 -10
  63. package/docs/dead-code-audit-retractions.md +5 -5
  64. package/docs/default-branch-ci.md +173 -0
  65. package/docs/deprecated.json +31 -31
  66. package/docs/design-inbox-entries-schema.md +3 -3
  67. package/docs/design-language.md +1051 -0
  68. package/docs/design-state-storage.md +11 -11
  69. package/docs/diagnostics-crash-reports.md +9 -9
  70. package/docs/diagnostics-memory.md +5 -5
  71. package/docs/documentation-audit-2026-07-09.md +7 -7
  72. package/docs/engine-restart.md +90 -5
  73. package/docs/harness-mode.md +1 -1
  74. package/docs/internal-install.md +338 -39
  75. package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
  76. package/docs/kb-pr3223-cascade-archiving.md +1 -1
  77. package/docs/kb-pr696-merge-conflict-docs.md +6 -6
  78. package/docs/kb-sweep.md +35 -35
  79. package/docs/keep-processes.md +1 -1
  80. package/docs/live-checkout-mode.md +30 -30
  81. package/docs/managed-spawn.md +18 -14
  82. package/docs/named-agents.md +7 -7
  83. package/docs/plan-lifecycle.md +69 -2
  84. package/docs/pr-author-identity.md +114 -0
  85. package/docs/pr-auto-fix-dispatch.md +19 -4
  86. package/docs/pr-comment-followup.md +6 -6
  87. package/docs/pr-review-fix-loop.md +59 -10
  88. package/docs/process-termination.md +40 -0
  89. package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
  90. package/docs/qa-runbook-lifecycle.md +367 -17
  91. package/docs/qa-runbooks.md +3 -3
  92. package/docs/rfc-completion-json.md +18 -18
  93. package/docs/runtime-adapters.md +26 -21
  94. package/docs/security.md +6 -6
  95. package/docs/self-improvement.md +4 -4
  96. package/docs/shared-lifecycle-module-map.md +473 -472
  97. package/docs/skills.md +52 -3
  98. package/docs/slim-ux/concepts.md +121 -116
  99. package/docs/specs/agent-configurability.md +18 -18
  100. package/docs/specs/agent-rename.md +18 -18
  101. package/docs/team-memory.md +38 -21
  102. package/docs/timeouts-and-liveness.md +118 -10
  103. package/docs/tutorials/01-install-and-connect.md +1 -1
  104. package/docs/watches.md +40 -39
  105. package/docs/workspace-manifests.md +4 -4
  106. package/docs/worktree-lifecycle.md +293 -14
  107. package/engine/README.md +46 -0
  108. package/engine/{ado-comment.js → ado/comment.js} +8 -8
  109. package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
  110. package/engine/{ado.js → ado/index.js} +417 -63
  111. package/engine/{ado-status.js → ado/status.js} +6 -8
  112. package/engine/{ado-token.js → ado/token.js} +1 -1
  113. package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
  114. package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
  115. package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
  116. package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
  117. package/engine/{harness-context.js → agents/harness-context.js} +5 -5
  118. package/engine/{harness.js → agents/harness.js} +3 -3
  119. package/engine/{llm.js → agents/llm.js} +18 -14
  120. package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
  121. package/engine/{playbook.js → agents/playbook.js} +155 -22
  122. package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
  123. package/engine/{preflight.js → agents/preflight.js} +29 -10
  124. package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
  125. package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
  126. package/engine/{steering.js → agents/steering.js} +5 -5
  127. package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
  128. package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
  129. package/engine/{api-validation.js → api/api-validation.js} +1 -1
  130. package/engine/api/bridge.js +787 -0
  131. package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
  132. package/engine/api/companion.js +560 -0
  133. package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
  134. package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
  135. package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
  136. package/engine/api-contracts/agent-content.js +4 -4
  137. package/engine/api-contracts/capability-manifest.js +236 -0
  138. package/engine/api-contracts/capability-protocol.js +333 -0
  139. package/engine/api-contracts/cc-ops.js +1 -1
  140. package/engine/api-contracts/config-runtime.js +5 -0
  141. package/engine/api-contracts/core.js +28 -1
  142. package/engine/api-contracts/index.js +100 -0
  143. package/engine/api-contracts/orchestration.js +18 -5
  144. package/engine/api-contracts/pull-requests.js +37 -6
  145. package/engine/api-contracts/qa-process.js +29 -6
  146. package/engine/api-contracts/work-plan-prd.js +21 -1
  147. package/engine/cloud/contract.js +212 -0
  148. package/engine/cloud/index.js +159 -0
  149. package/engine/{execution-model.js → core/execution-model.js} +1 -1
  150. package/engine/{features.js → core/features.js} +4 -4
  151. package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
  152. package/engine/{queries.js → core/queries.js} +201 -36
  153. package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
  154. package/engine/{shared.js → core/shared.js} +1637 -175
  155. package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
  156. package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
  157. package/engine/db/index.js +11 -2
  158. package/engine/db/migrations/002-dispatches.js +3 -3
  159. package/engine/db/migrations/003-work-items.js +1 -1
  160. package/engine/db/migrations/004-pull-requests.js +1 -1
  161. package/engine/db/migrations/006-metrics.js +1 -1
  162. package/engine/db/migrations/007-watches.js +2 -2
  163. package/engine/db/migrations/008-small-state.js +1 -1
  164. package/engine/db/migrations/009-qa.js +1 -1
  165. package/engine/db/migrations/010-pr-links.js +1 -1
  166. package/engine/db/migrations/011-remaining-state.js +1 -1
  167. package/engine/db/migrations/012-steering-deliveries.js +2 -2
  168. package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
  169. package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
  170. package/engine/db/migrations/015-plans-prds.js +0 -0
  171. package/engine/db/migrations/018-sql-only-cutover.js +2 -2
  172. package/engine/db/migrations/021-archived-work-items.js +1 -1
  173. package/engine/db/migrations/022-global-cc-session.js +1 -1
  174. package/engine/db/migrations/023-engine-state.js +1 -1
  175. package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
  176. package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
  177. package/engine/db/migrations/029-repair-reused-versions.js +20 -0
  178. package/engine/db/migrations/031-pr-author-identity.js +137 -0
  179. package/engine/{consolidation.js → memory/consolidation.js} +6 -6
  180. package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
  181. package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
  182. package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
  183. package/engine/{memory-store.js → memory/memory-store.js} +3 -3
  184. package/engine/{promotion.js → memory/promotion.js} +3 -3
  185. package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
  186. package/engine/{review-learning.js → memory/review-learning.js} +10 -5
  187. package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
  188. package/engine/{logs-store.js → observability/logs-store.js} +5 -5
  189. package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
  190. package/engine/{check-status.js → operations/check-status.js} +3 -3
  191. package/engine/{cli.js → operations/cli.js} +271 -113
  192. package/engine/{distribution.js → operations/distribution.js} +5 -6
  193. package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
  194. package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
  195. package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
  196. package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
  197. package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
  198. package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
  199. package/engine/{meeting.js → orchestration/meeting.js} +6 -16
  200. package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
  201. package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
  202. package/engine/{routing.js → orchestration/routing.js} +3 -3
  203. package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
  204. package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
  205. package/engine/{timeout.js → orchestration/timeout.js} +158 -109
  206. package/engine/{db-events.js → persistence/db-events.js} +2 -2
  207. package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
  208. package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
  209. package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
  210. package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
  211. package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
  212. package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
  213. package/engine/persistence/state-operations.js +350 -0
  214. package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
  215. package/engine/{issues.js → planning/issues.js} +2 -2
  216. package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
  217. package/engine/planning/prd-result-sidecar.js +190 -0
  218. package/engine/{prd-store.js → planning/prd-store.js} +17 -17
  219. package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
  220. package/engine/{projects.js → planning/projects.js} +10 -10
  221. package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
  222. package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
  223. package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
  224. package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
  225. package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
  226. package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
  227. package/engine/{process-utils.js → processes/process-utils.js} +599 -55
  228. package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
  229. package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
  230. package/engine/{comment-format.js → providers/comment-format.js} +5 -5
  231. package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
  232. package/engine/{gh-token.js → providers/gh-token.js} +4 -4
  233. package/engine/{github.js → providers/github.js} +131 -54
  234. package/engine/{pr-action.js → providers/pr-action.js} +13 -12
  235. package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
  236. package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
  237. package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
  238. package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
  239. package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
  240. package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
  241. package/engine/{pr-track.js → providers/pr-track.js} +11 -13
  242. package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
  243. package/engine/qa/auto-prd-qa.js +313 -0
  244. package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
  245. package/engine/qa/prd-session.js +240 -0
  246. package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
  247. package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
  248. package/engine/{qa-runs.js → qa/runs.js} +286 -15
  249. package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
  250. package/engine/qa/visual-journey.js +654 -0
  251. package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
  252. package/engine/qa-runners/maestro.js +3 -3
  253. package/engine/qa-runners/playwright.js +2 -2
  254. package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
  255. package/engine/recovery/stop-stack.js +607 -0
  256. package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
  257. package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
  258. package/engine/runtimes/claude.js +14 -12
  259. package/engine/runtimes/codex.js +8 -6
  260. package/engine/runtimes/copilot.js +17 -16
  261. package/engine/{watch-actions.js → watches/actions.js} +13 -13
  262. package/engine/{watches.js → watches/index.js} +43 -32
  263. package/engine/{watches-store.js → watches/store.js} +4 -4
  264. package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
  265. package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
  266. package/engine/worktrees/inventory.js +671 -0
  267. package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
  268. package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
  269. package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
  270. package/engine/worktrees/quarantine-refs.js +173 -0
  271. package/engine.js +1137 -208
  272. package/minions.js +147 -77
  273. package/package.json +10 -6
  274. package/playbooks/_pr-description-audit.md +110 -78
  275. package/playbooks/build-fix-complex.md +2 -0
  276. package/playbooks/fix.md +16 -12
  277. package/playbooks/implement-shared.md +2 -0
  278. package/playbooks/implement.md +19 -20
  279. package/playbooks/plan-to-prd.md +18 -3
  280. package/playbooks/qa-session-draft.md +136 -1
  281. package/playbooks/qa-session-execute.md +80 -2
  282. package/playbooks/qa-session-setup.md +17 -1
  283. package/playbooks/qa-validate.md +1 -1
  284. package/playbooks/setup.md +2 -0
  285. package/playbooks/shared-rules.md +25 -32
  286. package/playbooks/templates/followup-dispatch.md +4 -3
  287. package/playbooks/verify.md +1 -1
  288. package/prompts/cc-system.md +19 -27
  289. package/watch-plugins/README.md +92 -0
  290. package/watch-plugins/ado-author-prs.js +336 -0
  291. package/watch-plugins/gh-author-prs.js +375 -0
  292. package/watch-plugins/http.js +474 -0
  293. package/watch-plugins/teams-channel.js +869 -0
  294. package/docs/dev-composite-workflow.md +0 -101
  295. package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
  296. package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
  297. package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
  298. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  299. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  300. package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
  301. package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
  302. package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
  303. package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
  304. package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
  305. package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
  306. package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
  307. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  308. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  309. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  310. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  311. package/docs/visual-evidence-ci.md +0 -103
  312. package/engine/bridge.js +0 -379
  313. package/engine/quarantine-refs.js +0 -103
  314. package/engine/state-operations.js +0 -178
  315. /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
@@ -0,0 +1,787 @@
1
+ /**
2
+ * engine/api/bridge.js — Constellation-bridge config + marker accessors.
3
+ *
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().
8
+ *
9
+ * Strict semantics: only the literal boolean `true` enables the bridge.
10
+ * Mirror this check on the Constellation reader to avoid truthy coercion
11
+ * silently flipping bridge state on a typo'd `"enabled": "false"`.
12
+ */
13
+
14
+ const path = require('path');
15
+ const crypto = require('crypto');
16
+ const shared = require('../core/shared');
17
+
18
+ const {
19
+ MINIONS_DIR,
20
+ CONSTELLATION_BRIDGE_MARKER_PATH,
21
+ CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
22
+ safeJson,
23
+ mutateJsonFileLocked,
24
+ } = shared;
25
+
26
+ const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
27
+ const BRIDGE_SNAPSHOT_PROTOCOL = 'minions.constellation.snapshot';
28
+ const BRIDGE_SNAPSHOT_PROTOCOL_VERSION = 1;
29
+
30
+ const SNAPSHOT_CAPS = Object.freeze({
31
+ id: 128,
32
+ status: 128,
33
+ agent: 128,
34
+ title: 512,
35
+ project: 128,
36
+ url: 2048,
37
+ timestamp: 64,
38
+ shortLabel: 128,
39
+ branch: 255,
40
+ name: 128,
41
+ epoch: 64,
42
+ });
43
+ const INACTIVE_AGENT_STATUSES = new Set(['idle', 'available', 'sleeping']);
44
+
45
+ function _string(value, max, fallback = null) {
46
+ if (typeof value !== 'string') return fallback;
47
+ const trimmed = value.trim();
48
+ if (!trimmed) return fallback;
49
+ return trimmed.slice(0, max);
50
+ }
51
+
52
+ /** Non-negative integer gauge (counts, byte sizes, PIDs) or undefined. */
53
+ function _count(value) {
54
+ // Strictly a real number: Number(null) is 0 and Number('') is 0, so coercing
55
+ // here would turn a reserved managed spec's `pid: null` into a live-looking 0.
56
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) return undefined;
57
+ return value;
58
+ }
59
+
60
+ /**
61
+ * Non-negative fractional gauge (milliseconds, seconds) or undefined. Rounded
62
+ * to 3 decimals so float noise cannot make an otherwise-identical sample look
63
+ * like a content change to the entity versioner.
64
+ */
65
+ function _gauge(value) {
66
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
67
+ return Math.round(value * 1000) / 1000;
68
+ }
69
+
70
+ function _isoTime(value) {
71
+ if (value === null || value === undefined || value === '') return undefined;
72
+ const ms = typeof value === 'number' ? value : Date.parse(String(value));
73
+ if (!Number.isFinite(ms)) return undefined;
74
+ return new Date(ms).toISOString().slice(0, SNAPSHOT_CAPS.timestamp);
75
+ }
76
+
77
+ function _defined(obj) {
78
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
79
+ }
80
+
81
+ function _projectList(items, projector) {
82
+ if (!Array.isArray(items)) return [];
83
+ const projected = [];
84
+ for (const item of items) {
85
+ if (!item || typeof item !== 'object') continue;
86
+ const value = projector(item);
87
+ if (value) projected.push(value);
88
+ }
89
+ return projected;
90
+ }
91
+
92
+ function _projectWorkItem(item) {
93
+ const id = _string(item.id, SNAPSHOT_CAPS.id);
94
+ if (!id) return null;
95
+ return _defined({
96
+ id,
97
+ title: _string(item.title, SNAPSHOT_CAPS.title, id),
98
+ type: _string(item.type, SNAPSHOT_CAPS.shortLabel) || undefined,
99
+ priority: _string(item.priority, SNAPSHOT_CAPS.shortLabel) || undefined,
100
+ status: _string(item.status, SNAPSHOT_CAPS.status, 'unknown'),
101
+ project: _string(item.project, SNAPSHOT_CAPS.project) || undefined,
102
+ agent: _string(item.agent, SNAPSHOT_CAPS.agent) || undefined,
103
+ createdBy: _string(item.createdBy, SNAPSHOT_CAPS.shortLabel) || undefined,
104
+ created: _isoTime(item.created),
105
+ dispatched_to: _string(item.dispatched_to, SNAPSHOT_CAPS.agent) || undefined,
106
+ dispatched_at: _isoTime(item.dispatched_at),
107
+ completedAt: _isoTime(item.completedAt || item.completed_at),
108
+ });
109
+ }
110
+
111
+ function _projectAuthor(author) {
112
+ const a = shared.normalizePrAuthorIdentity(author);
113
+ if (!a) return undefined;
114
+ const projected = _defined({
115
+ provider: _string(a.provider, SNAPSHOT_CAPS.shortLabel) || undefined,
116
+ id: _string(a.id, SNAPSHOT_CAPS.shortLabel) || undefined,
117
+ descriptor: _string(a.descriptor, SNAPSHOT_CAPS.id) || undefined,
118
+ login: _string(a.login, SNAPSHOT_CAPS.name) || undefined,
119
+ displayName: _string(a.displayName, SNAPSHOT_CAPS.name) || undefined,
120
+ url: _string(a.url, SNAPSHOT_CAPS.url) || undefined,
121
+ });
122
+ return Object.keys(projected).length ? projected : undefined;
123
+ }
124
+
125
+ function _projectPullRequest(pr) {
126
+ const id = _string(pr.id, SNAPSHOT_CAPS.id);
127
+ const prNumber = Number(pr.prNumber);
128
+ const url = _string(pr.url, SNAPSHOT_CAPS.url);
129
+ if (!id || !url || !Number.isInteger(prNumber) || prNumber < 0) return null;
130
+ return _defined({
131
+ id,
132
+ prNumber,
133
+ title: _string(pr.title, SNAPSHOT_CAPS.title, id),
134
+ agent: _string(pr.agent, SNAPSHOT_CAPS.agent) || undefined,
135
+ author: _projectAuthor(pr.author),
136
+ branch: _string(pr.branch, SNAPSHOT_CAPS.branch) || undefined,
137
+ reviewStatus: _string(pr.reviewStatus, SNAPSHOT_CAPS.status, 'unknown'),
138
+ status: _string(pr.status, SNAPSHOT_CAPS.status, 'unknown'),
139
+ url,
140
+ created: _isoTime(pr.created),
141
+ lastReviewedAt: _isoTime(pr.lastReviewedAt),
142
+ lastPushedAt: _isoTime(pr.lastPushedAt),
143
+ lastDispatchedAt: _isoTime(pr.lastDispatchedAt),
144
+ mergeStatus: _string(pr.mergeStatus || pr.mergeStateStatus, SNAPSHOT_CAPS.shortLabel) || undefined,
145
+ mergeable: typeof pr.mergeable === 'boolean' ? pr.mergeable : undefined,
146
+ isDraft: typeof pr.isDraft === 'boolean' ? pr.isDraft : undefined,
147
+ behindBy: Number.isInteger(pr.behindBy) && pr.behindBy >= 0 ? pr.behindBy : undefined,
148
+ });
149
+ }
150
+
151
+ function _projectPlan(plan) {
152
+ const id = _string(plan.id || plan.file, SNAPSHOT_CAPS.id);
153
+ if (!id) return null;
154
+ return _defined({
155
+ id,
156
+ title: _string(plan.title || plan.summary || plan.plan_summary, SNAPSHOT_CAPS.title, id),
157
+ status: _string(plan.status, SNAPSHOT_CAPS.status, plan.archived ? 'archived' : 'unknown'),
158
+ agent: _string(plan.agent || plan.generatedBy || plan.generated_by, SNAPSHOT_CAPS.agent) || undefined,
159
+ project: _string(plan.project, SNAPSHOT_CAPS.project) || undefined,
160
+ createdAt: _isoTime(plan.createdAt || plan.generatedAt || plan.generated_at),
161
+ updatedAt: _isoTime(plan.updatedAt),
162
+ });
163
+ }
164
+
165
+ function _projectSchedule(schedule) {
166
+ const id = _string(schedule.id, SNAPSHOT_CAPS.id);
167
+ if (!id) return null;
168
+ return _defined({
169
+ id,
170
+ title: _string(schedule.title, SNAPSHOT_CAPS.title, id),
171
+ status: _string(schedule.status, SNAPSHOT_CAPS.status,
172
+ schedule.enabled === false ? 'disabled' : 'enabled'),
173
+ type: _string(schedule.type, SNAPSHOT_CAPS.shortLabel, 'schedule'),
174
+ project: _string(schedule.project, SNAPSHOT_CAPS.project) || undefined,
175
+ agent: _string(schedule.agent, SNAPSHOT_CAPS.agent) || undefined,
176
+ nextRun: _isoTime(schedule.nextRun),
177
+ lastRun: _isoTime(schedule.lastRun || schedule._lastRun || schedule._lastCompletedAt),
178
+ });
179
+ }
180
+
181
+ function _projectDispatch(entry, status) {
182
+ const id = _string(entry.id, SNAPSHOT_CAPS.id);
183
+ if (!id) return null;
184
+ const meta = entry.meta && typeof entry.meta === 'object' ? entry.meta : {};
185
+ const metaItem = meta.item && typeof meta.item === 'object' ? meta.item : {};
186
+ const metaProject = meta.project && typeof meta.project === 'object' ? meta.project : {};
187
+ return _defined({
188
+ id,
189
+ agent: _string(entry.agent, SNAPSHOT_CAPS.agent, 'unassigned'),
190
+ status: _string(entry.status, SNAPSHOT_CAPS.status, status),
191
+ type: _string(entry.type, SNAPSHOT_CAPS.shortLabel) || undefined,
192
+ project: _string(entry.project || metaProject.name, SNAPSHOT_CAPS.project) || undefined,
193
+ workItemId: _string(entry.workItemId || metaItem.id, SNAPSHOT_CAPS.id) || undefined,
194
+ prId: _string(entry.prId || entry.pr_id, SNAPSHOT_CAPS.id) || undefined,
195
+ startedAt: _isoTime(entry.startedAt || entry.started_at),
196
+ completedAt: _isoTime(entry.completedAt || entry.completed_at),
197
+ outcome: _string(entry.outcome || entry.result, SNAPSHOT_CAPS.shortLabel) || undefined,
198
+ });
199
+ }
200
+
201
+ function _projectDispatchQueue(dispatch) {
202
+ if (!dispatch || typeof dispatch !== 'object') return [];
203
+ const projected = [];
204
+ for (const [bucket, status] of [
205
+ ['pending', 'queued'],
206
+ ['active', 'active'],
207
+ ['review', 'review'],
208
+ ['completed', 'completed'],
209
+ ]) {
210
+ const entries = bucket === 'completed' && Array.isArray(dispatch[bucket])
211
+ ? dispatch[bucket].slice(-20)
212
+ : dispatch[bucket];
213
+ projected.push(..._projectList(entries, entry => _projectDispatch(entry, status)));
214
+ }
215
+ return projected;
216
+ }
217
+
218
+ function _projectAgent(agent) {
219
+ const id = _string(agent.id || agent.agent, SNAPSHOT_CAPS.agent);
220
+ if (!id) return null;
221
+ return _defined({
222
+ agent: id,
223
+ status: _string(agent.status, SNAPSHOT_CAPS.status, 'unknown'),
224
+ project: _string(agent.project, SNAPSHOT_CAPS.project) || undefined,
225
+ currentTaskId: _string(agent.currentTaskId || agent.currentDispatchId, SNAPSHOT_CAPS.id) || undefined,
226
+ currentTaskTitle: _string(agent.currentTaskTitle || agent.currentTask, SNAPSHOT_CAPS.title) || undefined,
227
+ updatedAt: _isoTime(agent.updatedAt || agent.lastActionAt),
228
+ });
229
+ }
230
+
231
+ /* ── Extended entity families (Mission Control) ────────────────────────────
232
+ *
233
+ * qa_run / pipeline / managed_process / engine_health widen the projection to
234
+ * the rest of Mission Control's surface. They follow the same rule as every
235
+ * projector above — each field is copied EXPLICITLY, never spread — and their
236
+ * sources are the highest-risk records in the engine: a managed-process record
237
+ * carries `cmd`, `args`, `cwd`, `log_path`, `healthcheck` and free-form
238
+ * `attrs` (credential-shaped env keys included); a QA run carries a free-text
239
+ * `summary` and artifact filesystem paths; a pipeline definition carries a
240
+ * 20 KB free-text `description`, per-stage task prompts and monitored-resource
241
+ * URLs. None of that may cross the bridge, so none of it is copied.
242
+ *
243
+ * OPT-IN, NOT DEFAULT-ON. The Constellation consumer validates `entities` with
244
+ * a `.strict()` Zod object (packages/shared/src/orchestrator-messages.ts), and
245
+ * its poller fails CLOSED on a schema error without falling back to the legacy
246
+ * per-endpoint projection. Emitting an unrequested new kind would therefore
247
+ * take Mission Control dark for every consumer that has not yet widened its
248
+ * schema. Callers ask for kinds explicitly; a caller that asks for nothing gets
249
+ * a byte-identical v1 snapshot. That keeps the additive-field compatibility
250
+ * rule in docs/constellation-bridge.md true for entity KINDS as well as fields,
251
+ * so BRIDGE_SNAPSHOT_PROTOCOL_VERSION still does not move.
252
+ *
253
+ * Every extended kind uses `id` as its identity field. That is the default
254
+ * branch of the consumer's getOrchestratorEntityId(), so adopting these kinds
255
+ * needs no per-kind identity mapping on the Constellation side.
256
+ */
257
+
258
+ const EXTENDED_SNAPSHOT_KINDS = Object.freeze([
259
+ 'qa_run',
260
+ 'pipeline',
261
+ 'managed_process',
262
+ 'engine_health',
263
+ ]);
264
+
265
+ // Mirrors packages/shared/src/orchestrator-messages.ts:
266
+ // ORCHESTRATOR_SNAPSHOT_MAX_ENTITIES_PER_KIND = 10_000
267
+ // device_summary: z.array(...).max(1)
268
+ // Enforced here so an oversized list is truncated at the producer instead of
269
+ // making the consumer reject the entire snapshot.
270
+ const SNAPSHOT_MAX_ENTITIES_PER_KIND = 10000;
271
+ const SNAPSHOT_SINGLETON_MAX_ENTITIES = 1;
272
+ const SNAPSHOT_SINGLETON_KINDS = new Set(['device_summary', 'engine_health']);
273
+
274
+ /**
275
+ * The exact field list each extended projector may emit, in emission order.
276
+ * This is the machine-readable form of the privacy boundary: a contract test
277
+ * compares it against the live projection in both directions, so widening a
278
+ * projection is always a visible diff in this constant.
279
+ */
280
+ const SNAPSHOT_PROJECTED_FIELDS = Object.freeze({
281
+ qa_run: Object.freeze([
282
+ 'id', 'runbookId', 'targetName', 'status', 'project', 'workItemId',
283
+ 'artifactCount', 'createdAt', 'startedAt', 'completedAt',
284
+ ]),
285
+ pipeline: Object.freeze([
286
+ 'id', 'title', 'status', 'stageCount', 'runCount',
287
+ 'lastRunId', 'lastRunStatus', 'lastRunStartedAt', 'lastRunCompletedAt',
288
+ ]),
289
+ managed_process: Object.freeze([
290
+ 'id', 'status', 'alive', 'healthy', 'pid', 'project', 'agent',
291
+ 'workItemId', 'startedAt', 'lastHealthyAt', 'expiresAt',
292
+ ]),
293
+ engine_health: Object.freeze([
294
+ 'id', 'state', 'pid', 'startedAt', 'lastTickAt', 'heartbeatAt',
295
+ 'rss', 'heapUsed', 'heapTotal', 'heapSizeLimit', 'external',
296
+ 'eventLoopLagP50', 'eventLoopLagP99', 'eventLoopLagMax',
297
+ 'gcCount', 'gcPausesTotalMs', 'lastGcPauseMs', 'lastGcKind',
298
+ 'uptimeSeconds', 'sampledAt',
299
+ ]),
300
+ });
301
+
302
+ /**
303
+ * Allowlist a caller's requested extended kinds. Unknown names, legacy kind
304
+ * names, and prototype-shaped strings are dropped rather than echoed, and the
305
+ * result is ordered by the registry so a given request always produces the
306
+ * same ETag variant and the same `entities` key order.
307
+ */
308
+ function resolveSnapshotKinds(requested) {
309
+ const raw = typeof requested === 'string'
310
+ ? requested.split(',')
311
+ : (Array.isArray(requested) ? requested : []);
312
+ const asked = new Set();
313
+ for (const entry of raw) {
314
+ const name = _string(entry, SNAPSHOT_CAPS.shortLabel);
315
+ if (name) asked.add(name);
316
+ }
317
+ return EXTENDED_SNAPSHOT_KINDS.filter(kind => asked.has(kind));
318
+ }
319
+
320
+ function _projectQaRun(run) {
321
+ const id = _string(run.id, SNAPSHOT_CAPS.id);
322
+ if (!id) return null;
323
+ return _defined({
324
+ id,
325
+ runbookId: _string(run.runbookId, SNAPSHOT_CAPS.id) || undefined,
326
+ targetName: _string(run.targetName, SNAPSHOT_CAPS.name) || undefined,
327
+ status: _string(run.status, SNAPSHOT_CAPS.status, 'unknown'),
328
+ project: _string(run.project, SNAPSHOT_CAPS.project) || undefined,
329
+ workItemId: _string(run.workItemId, SNAPSHOT_CAPS.id) || undefined,
330
+ // Count only. Artifact records carry filesystem paths and agent-authored
331
+ // labels; the consumer fetches artifacts from Minions directly when a
332
+ // human opens the run.
333
+ artifactCount: _count(Array.isArray(run.artifacts) ? run.artifacts.length : 0),
334
+ createdAt: _isoTime(run.createdAt),
335
+ startedAt: _isoTime(run.startedAt),
336
+ completedAt: _isoTime(run.completedAt),
337
+ });
338
+ }
339
+
340
+ function _projectPipeline(pipeline) {
341
+ const id = _string(pipeline.id, SNAPSHOT_CAPS.id);
342
+ if (!id) return null;
343
+ const stages = Array.isArray(pipeline.stages) ? pipeline.stages : [];
344
+ const runs = Array.isArray(pipeline.runs) ? pipeline.runs : [];
345
+ // The API merges the last-5 runs oldest-first, so the newest is the tail.
346
+ const last = runs.length ? runs[runs.length - 1] : null;
347
+ const lastStatus = last ? _string(last.status, SNAPSHOT_CAPS.status) : null;
348
+ return _defined({
349
+ id,
350
+ // `title` is operator-authored bounded text, exactly like the title already
351
+ // projected for work_item / pr / plan / schedule. It is the one free-text
352
+ // class docs/constellation-bridge.md admits; the 20 KB `description`,
353
+ // per-stage task prompts and monitoredResources URLs are not.
354
+ title: _string(pipeline.title, SNAPSHOT_CAPS.title, id),
355
+ // A pipeline definition has no status of its own — its latest run's status
356
+ // is what an operator means by "how is this pipeline doing". An explicitly
357
+ // disabled pipeline outranks that: startRun refuses it, so a stale
358
+ // `completed` would misrepresent it as merely idle between runs.
359
+ status: pipeline.enabled === false ? 'disabled' : (lastStatus || 'idle'),
360
+ stageCount: _count(stages.length),
361
+ runCount: _count(runs.length),
362
+ lastRunId: last ? (_string(last.runId, SNAPSHOT_CAPS.id) || undefined) : undefined,
363
+ lastRunStatus: lastStatus || undefined,
364
+ lastRunStartedAt: last ? _isoTime(last.startedAt) : undefined,
365
+ lastRunCompletedAt: last ? _isoTime(last.completedAt) : undefined,
366
+ });
367
+ }
368
+
369
+ function _projectManagedProcess(spec) {
370
+ // The spec's name IS its identity; `id` (rather than `name`) keeps the
371
+ // consumer's default entity-id resolver working with no per-kind mapping.
372
+ const id = _string(spec.name, SNAPSHOT_CAPS.name);
373
+ if (!id) return null;
374
+ const alive = spec.alive === true;
375
+ const healthy = spec.healthy === true;
376
+ return _defined({
377
+ id,
378
+ status: !alive ? 'stopped' : (healthy ? 'healthy' : 'unhealthy'),
379
+ alive,
380
+ healthy,
381
+ pid: _count(spec.pid),
382
+ project: _string(spec.owner_project, SNAPSHOT_CAPS.project) || undefined,
383
+ agent: _string(spec.owner_agent, SNAPSHOT_CAPS.agent) || undefined,
384
+ workItemId: _string(spec.owner_wi, SNAPSHOT_CAPS.id) || undefined,
385
+ startedAt: _isoTime(spec.started_at),
386
+ lastHealthyAt: _isoTime(spec.last_health_at),
387
+ expiresAt: _isoTime(spec.ttl_expires_at),
388
+ });
389
+ }
390
+
391
+ /**
392
+ * Singleton health summary for the engine process: liveness plus the memory,
393
+ * event-loop and GC gauges sampled by the memoryBaseline tick phase. All
394
+ * numeric — the only string fields are the engine state and the GC kind, both
395
+ * closed vocabularies capped through _string().
396
+ */
397
+ function _projectEngineHealth(engine, memory) {
398
+ const sample = memory && typeof memory === 'object' ? memory : {};
399
+ return _defined({
400
+ id: 'engine',
401
+ state: _string(engine.state, SNAPSHOT_CAPS.status, 'unknown'),
402
+ pid: Number.isInteger(engine.pid) && engine.pid > 0 ? engine.pid : null,
403
+ startedAt: _isoTime(engine.started_at || engine.startedAt),
404
+ // The engine has no persisted tick counter; lastTickAt is the observable
405
+ // tick signal, and it deliberately participates in the content hash so a
406
+ // consumer can see the engine ticking via changeVersion.
407
+ lastTickAt: _isoTime(engine.lastTickAt),
408
+ heartbeatAt: _isoTime(engine.heartbeat),
409
+ rss: _count(sample.rss),
410
+ heapUsed: _count(sample.heapUsed),
411
+ heapTotal: _count(sample.heapTotal),
412
+ heapSizeLimit: _count(sample.heapSizeLimit),
413
+ external: _count(sample.external),
414
+ eventLoopLagP50: _gauge(sample.eventLoopLagP50),
415
+ eventLoopLagP99: _gauge(sample.eventLoopLagP99),
416
+ eventLoopLagMax: _gauge(sample.eventLoopLagMax),
417
+ gcCount: _count(sample.gcCount),
418
+ gcPausesTotalMs: _gauge(sample.gcPausesTotalMs),
419
+ lastGcPauseMs: _gauge(sample.lastGcPauseMs),
420
+ lastGcKind: _string(sample.lastGcKind, SNAPSHOT_CAPS.shortLabel) || undefined,
421
+ uptimeSeconds: _gauge(sample.uptime),
422
+ sampledAt: _isoTime(sample.capturedAt),
423
+ });
424
+ }
425
+
426
+ /** Route one extended kind to its projector. */
427
+ function _projectExtendedKind(kind, state, engine) {
428
+ switch (kind) {
429
+ case 'qa_run': return _projectList(state.qaRuns, _projectQaRun);
430
+ case 'pipeline': return _projectList(state.pipelines, _projectPipeline);
431
+ case 'managed_process': return _projectList(state.managedProcesses, _projectManagedProcess);
432
+ case 'engine_health': return [_projectEngineHealth(engine, state.memory)];
433
+ default: return [];
434
+ }
435
+ }
436
+
437
+ /* ── Snapshot ordering tokens (additive; NOT a protocolVersion bump) ───────
438
+ *
439
+ * `snapshotVersion` is a counter that is monotonically non-decreasing for the
440
+ * lifetime of one Minions process. It advances only when the *projected*
441
+ * content of at least one entity was added, removed, or modified, so a
442
+ * consumer can order two polls with an integer compare instead of diffing
443
+ * whole snapshots. Each entity additionally carries the `changeVersion` at
444
+ * which its own content hash last changed, so a consumer can skip re-applying
445
+ * entities it already mirrors.
446
+ *
447
+ * `epoch` identifies the process that owns the counter. The counter is
448
+ * in-memory, so it resets with the process; a consumer that sees a new `epoch`
449
+ * must discard its cached versions and take the full snapshot rather than
450
+ * mis-comparing numbers across the reset.
451
+ *
452
+ * All three fields are additive and optional, which per the compatibility rule
453
+ * in docs/constellation-bridge.md ("protocolVersion changes only for a
454
+ * breaking schema change. Additive optional fields do not require a version
455
+ * bump") means BRIDGE_SNAPSHOT_PROTOCOL_VERSION deliberately does NOT move.
456
+ *
457
+ * WHY THIS IS NOT BUILT ON THE `events` TABLE — which already backs this
458
+ * endpoint's HTTP ETag and keeps doing so, unchanged:
459
+ * 1. Its rows are payload-less for exactly the entities projected here. The
460
+ * emitStateEvent calls in the work-item store, the pull-request store and
461
+ * the dispatch queue pass a topic string only — no entity id, no
462
+ * before/after payload — so a per-entity delta cannot be reconstructed
463
+ * from them at all.
464
+ * 2. Emission is best-effort by hard contract: engine/persistence/db-events.js
465
+ * swallows every SQLite failure (logging once per process) so that a state
466
+ * writer can never throw from a cache-invalidation side effect. The id
467
+ * sequence can therefore gap silently, and a consumer would be unable to
468
+ * tell "nothing changed" apart from "the emit failed".
469
+ * Hashing the projected content is immune to both: it is exactly as correct as
470
+ * the projection itself, and it needs no database access.
471
+ */
472
+
473
+ const SNAPSHOT_EPOCH = [
474
+ process.pid.toString(36),
475
+ Date.now().toString(36),
476
+ crypto.randomBytes(4).toString('hex'),
477
+ ].join('-').slice(0, SNAPSHOT_CAPS.epoch);
478
+
479
+ /*
480
+ * Identity + content rules per entity kind. `volatile` fields are stamped from
481
+ * the snapshot's own generation time rather than describing the entity, so they
482
+ * are excluded from the content hash — otherwise every poll would look like a
483
+ * change and the counter would carry no signal at all.
484
+ */
485
+ const ENTITY_VERSION_RULES = Object.freeze({
486
+ work_item: { identity: 'id', volatile: [] },
487
+ pr: { identity: 'id', volatile: [] },
488
+ plan: { identity: 'id', volatile: [] },
489
+ schedule: { identity: 'id', volatile: [] },
490
+ dispatch: { identity: 'id', volatile: [] },
491
+ agent_status: { identity: 'agent', volatile: [] },
492
+ device_summary: { identity: 'name', volatile: ['lastSeen', 'snapshotGeneratedAt'] },
493
+ qa_run: { identity: 'id', volatile: [] },
494
+ pipeline: { identity: 'id', volatile: [] },
495
+ managed_process: { identity: 'id', volatile: [] },
496
+ engine_health: { identity: 'id', volatile: [] },
497
+ });
498
+ // Fail-safe for a future entity kind whose rule was not registered above: `id`
499
+ // with no volatile fields is correct for every kind projected today, so a miss
500
+ // degrades to a slightly noisier version rather than crashing the snapshot.
501
+ const DEFAULT_ENTITY_VERSION_RULE = Object.freeze({ identity: 'id', volatile: [] });
502
+
503
+ let _snapshotVersion = 0;
504
+ let _entityVersionLedger = new Map();
505
+
506
+ function _canonical(value) {
507
+ if (Array.isArray(value)) return value.map(_canonical);
508
+ if (value && typeof value === 'object') {
509
+ const sorted = {};
510
+ for (const key of Object.keys(value).sort()) sorted[key] = _canonical(value[key]);
511
+ return sorted;
512
+ }
513
+ return value;
514
+ }
515
+
516
+ /** Stable hash of a projected entity's content, independent of key order. */
517
+ function _contentHash(entity, volatileFields) {
518
+ const subject = { ...entity };
519
+ for (const field of volatileFields) delete subject[field];
520
+ return crypto.createHash('sha256').update(JSON.stringify(_canonical(subject))).digest('hex');
521
+ }
522
+
523
+ /**
524
+ * Stamp every projected entity with the version at which its content last
525
+ * changed, and advance the process-wide snapshot version when the ledger of
526
+ * (identity → content hash) pairs differs from the previous poll. Returns a
527
+ * new entities map; the inputs are not mutated. The ledger is rebuilt from the
528
+ * current snapshot on every call, so it stays bounded by the live entity count.
529
+ *
530
+ * A kind that is ABSENT from this snapshot is UNKNOWN, not empty: its ledger
531
+ * entries are carried forward untouched. Extended kinds are opt-in, so the same
532
+ * process serves both narrow polls (`bridge health`, an unwidened consumer) and
533
+ * wide ones. Rebuilding the ledger from only the present kinds would make every
534
+ * extended entity look newly-changed on the next wide poll and would advance
535
+ * snapshotVersion on every alternation — a phantom-change generator driven by
536
+ * who asked last, not by what changed.
537
+ */
538
+ function _applyEntityVersions(entities) {
539
+ const candidate = _snapshotVersion + 1;
540
+ const ledger = new Map();
541
+ const versioned = {};
542
+ const presentKinds = new Set(Object.keys(entities));
543
+ let changed = false;
544
+
545
+ // Carry forward the kinds this snapshot did not report on.
546
+ for (const [key, entry] of _entityVersionLedger) {
547
+ if (!presentKinds.has(key.slice(0, key.indexOf('\u0000')))) ledger.set(key, entry);
548
+ }
549
+
550
+ for (const [kind, list] of Object.entries(entities)) {
551
+ const rule = ENTITY_VERSION_RULES[kind] || DEFAULT_ENTITY_VERSION_RULE;
552
+ versioned[kind] = _capEntities(kind, list).map((entity, index) => {
553
+ const identity = _string(entity[rule.identity], SNAPSHOT_CAPS.id) || `#${index}`;
554
+ const key = `${kind}\u0000${identity}`;
555
+ const hash = _contentHash(entity, rule.volatile);
556
+ const previous = _entityVersionLedger.get(key);
557
+ const unchanged = previous !== undefined && previous.hash === hash;
558
+ if (!unchanged) changed = true;
559
+ const changeVersion = unchanged ? previous.changeVersion : candidate;
560
+ ledger.set(key, { hash, changeVersion });
561
+ return { ...entity, changeVersion };
562
+ });
563
+ }
564
+
565
+ // Additions and modifications already flipped the flag above; a shrunk ledger
566
+ // is the remaining way the map can differ. Dropping an entity IS a change —
567
+ // this snapshot is a complete replacement, so a consumer has to unmirror it.
568
+ // Carried-forward entries are on both sides of this comparison, so an absent
569
+ // kind cannot masquerade as a removal.
570
+ if (!changed && ledger.size !== _entityVersionLedger.size) changed = true;
571
+
572
+ if (changed) _snapshotVersion = candidate;
573
+ _entityVersionLedger = ledger;
574
+ return versioned;
575
+ }
576
+
577
+ /**
578
+ * Truncate a kind's list to the ceiling the Constellation consumer enforces.
579
+ * Over-cap lists are rejected wholesale on the consumer side, so silently
580
+ * dropping the tail here degrades to "fewer entities" instead of "no snapshot".
581
+ * Callers supply the list already in their preferred order.
582
+ */
583
+ function _capEntities(kind, list) {
584
+ const cap = SNAPSHOT_SINGLETON_KINDS.has(kind)
585
+ ? SNAPSHOT_SINGLETON_MAX_ENTITIES
586
+ : SNAPSHOT_MAX_ENTITIES_PER_KIND;
587
+ return list.length > cap ? list.slice(0, cap) : list;
588
+ }
589
+
590
+ /**
591
+ * Build the complete metadata-only state projection consumed by Constellation.
592
+ * Every field is copied explicitly so free-text task descriptions, prompts,
593
+ * completion reports, diffs, comments, paths, logs, and credentials cannot
594
+ * cross the bridge accidentally.
595
+ *
596
+ * `options.extendedKinds` opts into the Mission Control entity families
597
+ * (EXTENDED_SNAPSHOT_KINDS). Omitting it yields a byte-identical v1 snapshot.
598
+ */
599
+ function buildBridgeSnapshot(state = {}, options = {}) {
600
+ const extendedKinds = resolveSnapshotKinds(options && options.extendedKinds);
601
+ const generatedAt = _isoTime(state.generatedAt) || new Date().toISOString();
602
+ const engine = state.control && typeof state.control === 'object' ? state.control : {};
603
+ const workItems = _projectList(state.workItems, _projectWorkItem);
604
+ const pullRequests = _projectList(state.pullRequests, _projectPullRequest);
605
+ const plans = _projectList(state.plans, _projectPlan);
606
+ const schedules = _projectList(state.schedules, _projectSchedule);
607
+ const agents = _projectList(state.agents, _projectAgent);
608
+ const dispatches = _projectDispatchQueue(state.dispatch);
609
+ const activeAgents = agents.filter(agent => !INACTIVE_AGENT_STATUSES.has(agent.status.toLowerCase())).length;
610
+ const minionsVersion = _string(state.minionsVersion, SNAPSHOT_CAPS.shortLabel);
611
+ const engineState = _string(engine.state, SNAPSHOT_CAPS.status, 'unknown');
612
+ const enginePid = Number.isInteger(engine.pid) && engine.pid > 0 ? engine.pid : null;
613
+ const projects = Array.isArray(state.projects) ? state.projects : [];
614
+
615
+ const entities = _applyEntityVersions({
616
+ work_item: workItems,
617
+ pr: pullRequests,
618
+ plan: plans,
619
+ schedule: schedules,
620
+ dispatch: dispatches,
621
+ agent_status: agents,
622
+ device_summary: [{
623
+ name: 'Minions',
624
+ status: engineState,
625
+ lastSeen: generatedAt,
626
+ agentCount: agents.length,
627
+ activeAgentCount: activeAgents,
628
+ workItemCount: workItems.length,
629
+ pullRequestCount: pullRequests.length,
630
+ planCount: plans.length,
631
+ scheduleCount: schedules.length,
632
+ dispatchCount: dispatches.length,
633
+ projectCount: projects.length,
634
+ protocolVersion: BRIDGE_SNAPSHOT_PROTOCOL_VERSION,
635
+ minionsVersion,
636
+ snapshotGeneratedAt: generatedAt,
637
+ enginePid,
638
+ }],
639
+ ...Object.fromEntries(extendedKinds.map(kind => [kind, _projectExtendedKind(kind, state, engine)])),
640
+ });
641
+
642
+ return {
643
+ protocol: BRIDGE_SNAPSHOT_PROTOCOL,
644
+ protocolVersion: BRIDGE_SNAPSHOT_PROTOCOL_VERSION,
645
+ complete: true,
646
+ generatedAt,
647
+ minionsVersion,
648
+ snapshotVersion: _snapshotVersion,
649
+ epoch: SNAPSHOT_EPOCH,
650
+ engine: {
651
+ state: engineState,
652
+ pid: enginePid,
653
+ },
654
+ entities,
655
+ };
656
+ }
657
+
658
+ function projectSnapshotHealthForBridge(snapshot) {
659
+ if (!snapshot || typeof snapshot !== 'object') return null;
660
+ if (snapshot.protocol !== BRIDGE_SNAPSHOT_PROTOCOL) return null;
661
+ if (snapshot.protocolVersion !== BRIDGE_SNAPSHOT_PROTOCOL_VERSION) return null;
662
+ if (snapshot.complete !== true || !snapshot.entities || typeof snapshot.entities !== 'object') return null;
663
+ const entities = snapshot.entities;
664
+ const summary = Array.isArray(entities.device_summary) ? entities.device_summary[0] || {} : {};
665
+ const count = key => (Array.isArray(entities[key]) ? entities[key].length : 0);
666
+ return {
667
+ protocolVersion: snapshot.protocolVersion,
668
+ minionsVersion: snapshot.minionsVersion ?? null,
669
+ engineState: snapshot.engine?.state ?? null,
670
+ enginePid: snapshot.engine?.pid ?? null,
671
+ agentCount: count('agent_status'),
672
+ activeAgentCount: summary.activeAgentCount ?? null,
673
+ workItemCount: count('work_item'),
674
+ pullRequestCount: count('pr'),
675
+ planCount: count('plan'),
676
+ scheduleCount: count('schedule'),
677
+ dispatchCount: count('dispatch'),
678
+ projectCount: summary.projectCount ?? null,
679
+ };
680
+ }
681
+
682
+ /**
683
+ * Strict check: `true` ⇔ bridge enabled. Any other shape (missing field,
684
+ * non-object, string "true", etc.) returns false.
685
+ */
686
+ function isBridgeEnabled(config) {
687
+ return config?.engine?.constellationBridge?.enabled === true;
688
+ }
689
+
690
+ /**
691
+ * Read the cross-repo marker written by the Constellation agent. Returns
692
+ * `null` when the file is missing, unreadable, or schema-mismatched.
693
+ *
694
+ * Marker shape (see ENGINE_DEFAULTS.constellationBridge docstring):
695
+ * { schemaVersion: 1, lastSeenAt: ISO8601,
696
+ * agentVersion?: string, source?: 'constellation-agent' }
697
+ */
698
+ function readBridgeMarker(markerPath = CONSTELLATION_BRIDGE_MARKER_PATH) {
699
+ const raw = safeJson(markerPath);
700
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
701
+ if (raw.schemaVersion !== CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION) return null;
702
+ if (typeof raw.lastSeenAt !== 'string') return null;
703
+ return {
704
+ schemaVersion: raw.schemaVersion,
705
+ lastSeenAt: raw.lastSeenAt,
706
+ agentVersion: typeof raw.agentVersion === 'string' ? raw.agentVersion : null,
707
+ source: typeof raw.source === 'string' ? raw.source : null,
708
+ };
709
+ }
710
+
711
+ /**
712
+ * Flip `config.engine.constellationBridge.enabled` atomically via
713
+ * mutateJsonFileLocked. Returns `{ previous: bool, current: bool }`.
714
+ * `configPath` override exists for unit tests.
715
+ */
716
+ function setBridgeEnabled(enabled, configPath = CONFIG_PATH) {
717
+ const next = enabled === true;
718
+ let previous = false;
719
+ mutateJsonFileLocked(configPath, (cfg) => {
720
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return cfg;
721
+ cfg.engine = cfg.engine || {};
722
+ cfg.engine.constellationBridge = cfg.engine.constellationBridge || {};
723
+ previous = cfg.engine.constellationBridge.enabled === true;
724
+ cfg.engine.constellationBridge.enabled = next;
725
+ return cfg;
726
+ });
727
+ return { previous, current: next };
728
+ }
729
+
730
+ /**
731
+ * Human-readable relative age string (e.g. "12s ago", "3m ago", "2h ago").
732
+ * Caps at "1d+ ago" — anything older than the bridge polling cadence is
733
+ * already actionable as "stale".
734
+ */
735
+ function formatRelativeAge(isoTimestamp, nowMs = Date.now()) {
736
+ const t = Date.parse(isoTimestamp);
737
+ if (!Number.isFinite(t)) return '(unknown)';
738
+ const deltaSec = Math.max(0, Math.round((nowMs - t) / 1000));
739
+ if (deltaSec < 60) return `${deltaSec}s ago`;
740
+ if (deltaSec < 3600) return `${Math.round(deltaSec / 60)}m ago`;
741
+ if (deltaSec < 86400) return `${Math.round(deltaSec / 3600)}h ago`;
742
+ return '1d+ ago';
743
+ }
744
+
745
+ /**
746
+ * Compose the small stable projection the Constellation bridge consumes
747
+ * from `/api/status`. Kept narrow on purpose: full /api/status is large,
748
+ * unstable, and may surface unrelated local state. New fields go behind
749
+ * a deliberate schema version bump.
750
+ */
751
+ function projectStatusForBridge(statusJson) {
752
+ if (!statusJson || typeof statusJson !== 'object') return null;
753
+ const dispatch = statusJson.dispatch || {};
754
+ const queueCount = (arr) => (Array.isArray(arr) ? arr.length : 0);
755
+ return {
756
+ engineState: statusJson.control?.state ?? null,
757
+ enginePid: statusJson.control?.pid ?? null,
758
+ minionsVersion: statusJson.version ?? null,
759
+ agentCount: Array.isArray(statusJson.agents) ? statusJson.agents.length : null,
760
+ activeAgentCount: Array.isArray(statusJson.agents)
761
+ ? statusJson.agents.filter(a => a && a.status && a.status !== 'idle').length
762
+ : null,
763
+ dispatchPending: queueCount(dispatch.pending),
764
+ dispatchActive: queueCount(dispatch.active),
765
+ dispatchCompleted: queueCount(dispatch.completed),
766
+ projectCount: Array.isArray(statusJson.projects) ? statusJson.projects.length : null,
767
+ };
768
+ }
769
+
770
+ module.exports = {
771
+ isBridgeEnabled,
772
+ readBridgeMarker,
773
+ setBridgeEnabled,
774
+ formatRelativeAge,
775
+ buildBridgeSnapshot,
776
+ projectSnapshotHealthForBridge,
777
+ projectStatusForBridge,
778
+ resolveSnapshotKinds,
779
+ BRIDGE_SNAPSHOT_PROTOCOL,
780
+ BRIDGE_SNAPSHOT_PROTOCOL_VERSION,
781
+ EXTENDED_SNAPSHOT_KINDS,
782
+ SNAPSHOT_PROJECTED_FIELDS,
783
+ SNAPSHOT_MAX_ENTITIES_PER_KIND,
784
+ SNAPSHOT_SINGLETON_MAX_ENTITIES,
785
+ CONSTELLATION_BRIDGE_MARKER_PATH,
786
+ CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
787
+ };