@bridge_gpt/mcp-server 0.2.2 → 0.2.4

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 (113) hide show
  1. package/README.md +97 -15
  2. package/build/agent-config-credential-migration.js +272 -0
  3. package/build/agents.generated.js +1 -1
  4. package/build/chain-orchestrator.js +16 -1
  5. package/build/commands.generated.js +9 -7
  6. package/build/conductor/bridge-api-client.js +625 -0
  7. package/build/conductor/claude-hook.js +251 -0
  8. package/build/conductor/cli.js +1048 -0
  9. package/build/conductor/data-normalization.js +114 -0
  10. package/build/conductor/doctor.js +164 -0
  11. package/build/conductor/done-gate.js +325 -0
  12. package/build/conductor/epic-reconcile.js +139 -0
  13. package/build/conductor/epic-runtime.js +611 -0
  14. package/build/conductor/epic-state.js +125 -0
  15. package/build/conductor/errors.js +85 -0
  16. package/build/conductor/git-ci-types.js +129 -0
  17. package/build/conductor/git-hooks.js +218 -0
  18. package/build/conductor/git-inspection.js +185 -0
  19. package/build/conductor/git-producer.js +137 -0
  20. package/build/conductor/merge-ledger.js +198 -0
  21. package/build/conductor/paths.js +224 -0
  22. package/build/conductor/plan.js +77 -0
  23. package/build/conductor/pr-ci-producer.js +427 -0
  24. package/build/conductor/pr-discovery.js +135 -0
  25. package/build/conductor/producer-ledger.js +125 -0
  26. package/build/conductor/redaction.js +112 -0
  27. package/build/conductor/store.js +1156 -0
  28. package/build/conductor/supervisor-config.js +150 -0
  29. package/build/conductor/supervisor-escalation.js +244 -0
  30. package/build/conductor/supervisor-judgment-python.js +141 -0
  31. package/build/conductor/supervisor-judgment.js +215 -0
  32. package/build/conductor/supervisor-ledger.js +119 -0
  33. package/build/conductor/supervisor-merge.js +127 -0
  34. package/build/conductor/supervisor-message-relay.js +61 -0
  35. package/build/conductor/supervisor-notification.js +39 -0
  36. package/build/conductor/supervisor-runtime.js +351 -0
  37. package/build/conductor/supervisor-state.js +572 -0
  38. package/build/conductor/supervisor-types.js +16 -0
  39. package/build/conductor/taxonomy.js +58 -0
  40. package/build/conductor/tools.js +367 -0
  41. package/build/conductor/types.js +9 -0
  42. package/build/conductor-bin.js +21 -0
  43. package/build/conductor-claude-hook-bin.js +21 -0
  44. package/build/credential-store.js +175 -4
  45. package/build/credentials-cli.js +223 -0
  46. package/build/decision-page-schema.js +60 -0
  47. package/build/decision-page-template.js +262 -10
  48. package/build/doctor.js +5 -1
  49. package/build/index.js +554 -66
  50. package/build/pipeline-orchestrator.js +5 -1
  51. package/build/pipeline-utils.js +45 -5
  52. package/build/pipelines.generated.js +37 -9
  53. package/build/readme.generated.js +1 -1
  54. package/build/review-tickets.js +596 -0
  55. package/build/scheduled-prompt.js +16 -10
  56. package/build/start-tickets-conductor.js +496 -0
  57. package/build/start-tickets-prereqs.js +32 -23
  58. package/build/start-tickets-repo.js +49 -0
  59. package/build/start-tickets.js +682 -81
  60. package/build/version.generated.js +1 -1
  61. package/design-assets/favicon/android-chrome-192x192.png +0 -0
  62. package/design-assets/favicon/android-chrome-512x512.png +0 -0
  63. package/design-assets/favicon/apple-touch-icon.png +0 -0
  64. package/design-assets/favicon/favicon-16x16.png +0 -0
  65. package/design-assets/favicon/favicon-32x32.png +0 -0
  66. package/design-assets/favicon/favicon.ico +0 -0
  67. package/design-assets/favicon/site.webmanifest +1 -0
  68. package/design-assets/just-logo-rough-draft.png +0 -0
  69. package/package.json +17 -5
  70. package/pipelines/idea-to-ticket.json +5 -0
  71. package/pipelines/plan-epic.json +16 -1
  72. package/pipelines/review-ticket.json +2 -1
  73. package/public/css/main.min.css +2 -0
  74. package/public/css/main.min.css.map +1 -0
  75. package/public/fonts/OFL.txt +93 -0
  76. package/public/fonts/SourceSansPro-Black.ttf +0 -0
  77. package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
  78. package/public/fonts/SourceSansPro-Bold.ttf +0 -0
  79. package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
  80. package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
  81. package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
  82. package/public/fonts/SourceSansPro-Italic.ttf +0 -0
  83. package/public/fonts/SourceSansPro-Light.ttf +0 -0
  84. package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
  85. package/public/fonts/SourceSansPro-Regular.ttf +0 -0
  86. package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
  87. package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
  88. package/public/img/bridge-logo-160x51.webp +0 -0
  89. package/public/img/bridge-logo-300x92.webp +0 -0
  90. package/public/img/favicon/android-chrome-192x192.png +0 -0
  91. package/public/img/favicon/android-chrome-512x512.png +0 -0
  92. package/public/img/favicon/apple-touch-icon.png +0 -0
  93. package/public/img/favicon/favicon-16x16.png +0 -0
  94. package/public/img/favicon/favicon-32x32.png +0 -0
  95. package/public/img/favicon/favicon.ico +0 -0
  96. package/public/img/favicon/site.webmanifest +1 -0
  97. package/public/img/installation/bitbucket/app-password-1.png +0 -0
  98. package/public/img/installation/bitbucket/app-password-2.png +0 -0
  99. package/public/img/installation/bitbucket/create-token-1.png +0 -0
  100. package/public/img/installation/bitbucket/create-token-2.png +0 -0
  101. package/public/img/installation/bitbucket/webhook-1.png +0 -0
  102. package/public/img/installation/github/github-review-webhook.png +0 -0
  103. package/public/img/installation/jira/credentials/api-key.png +0 -0
  104. package/public/img/installation/jira/webhook/create-rule.png +0 -0
  105. package/public/img/installation/jira/webhook/project-settings.png +0 -0
  106. package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
  107. package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
  108. package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
  109. package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
  110. package/public/img/installation/pinecone/pinecone-index.png +0 -0
  111. package/public/js/main.min.js +2 -0
  112. package/public/js/main.min.js.map +1 -0
  113. package/smoke-test/SMOKE-TEST.md +17 -9
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Deterministic supervisor state reduction + housekeeping (BAPI-396, conductor
3
+ * C4).
4
+ *
5
+ * This module converts raw conductor ledger events into per-worker watchdog
6
+ * state and run-level projection state. It is PURE and deterministic: it has no
7
+ * I/O, no timers, and no LLM calls. Every time-dependent function takes an
8
+ * explicit `now` (epoch ms) so tests are wall-clock independent.
9
+ *
10
+ * Truth precedence (enforced here): raw events are the source of truth. A
11
+ * `supervisor.assessment` event is audit-only and NEVER overrides a worker's
12
+ * raw-derived state. Terminal states (`complete` / `failed`) are sticky and are
13
+ * only replaced by a stronger explicit raw terminal event.
14
+ */
15
+ /** Marker stored on the projection summary so hydration can validate it. */
16
+ export const SUPERVISOR_SUMMARY_KIND = "supervisor_projection_summary";
17
+ const TERMINAL_STATES = new Set(["complete", "failed"]);
18
+ /** Is this worker in a sticky terminal state? */
19
+ function isTerminalState(state) {
20
+ return TERMINAL_STATES.has(state);
21
+ }
22
+ /** Safe ISO -> epoch ms (returns null on unparseable input). */
23
+ function isoToMs(value) {
24
+ if (typeof value !== "string" || value.length === 0)
25
+ return null;
26
+ const ms = Date.parse(value);
27
+ return Number.isFinite(ms) ? ms : null;
28
+ }
29
+ /** Epoch ms -> ISO string. */
30
+ function msToIso(now) {
31
+ return new Date(now).toISOString();
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Construction & hydration
35
+ // ---------------------------------------------------------------------------
36
+ /**
37
+ * Initialize an empty {@link SupervisorRunState} scoped to exactly one run. The
38
+ * global deadline is `now + config.global_timeout_ms`.
39
+ */
40
+ export function createEmptySupervisorRunState(runId, config, now) {
41
+ const startedIso = msToIso(now);
42
+ return {
43
+ run_id: runId,
44
+ status: "unknown",
45
+ last_seq: 0,
46
+ last_event_time: null,
47
+ workers: {},
48
+ gates: {},
49
+ latest_assessment: null,
50
+ escalations: [],
51
+ llm_budget: {
52
+ enabled: config.llm_enabled,
53
+ max_calls: config.llm_max_calls,
54
+ used_calls: 0,
55
+ },
56
+ started_at: startedIso,
57
+ updated_at: startedIso,
58
+ global_deadline_at: msToIso(now + config.global_timeout_ms),
59
+ roster_discovered: false,
60
+ };
61
+ }
62
+ /** Is a parsed summary object a plausible supervisor summary? */
63
+ function isValidSupervisorSummary(value) {
64
+ return (value !== null &&
65
+ typeof value === "object" &&
66
+ !Array.isArray(value) &&
67
+ value.kind === SUPERVISOR_SUMMARY_KIND);
68
+ }
69
+ /**
70
+ * Hydrate run state from a supervisor snapshot's projection summary. ALWAYS
71
+ * enforces the requested `runId` — a summary persisted under a different run is
72
+ * never allowed to take ownership. Falls back to {@link
73
+ * createEmptySupervisorRunState} when the snapshot is missing or malformed.
74
+ */
75
+ export function hydrateSupervisorRunStateFromSnapshot(snapshot, runId, config, now) {
76
+ const empty = createEmptySupervisorRunState(runId, config, now);
77
+ const summary = snapshot?.projection?.summary;
78
+ if (!isValidSupervisorSummary(summary)) {
79
+ return empty;
80
+ }
81
+ // Merge persisted fields on top of the empty scaffold, then HARD-OVERRIDE the
82
+ // run id so a cross-run summary can never be hydrated under this run.
83
+ const hydrated = {
84
+ ...empty,
85
+ status: typeof summary.status === "string" ? summary.status : empty.status,
86
+ last_seq: typeof summary.last_seq === "number" && summary.last_seq >= 0 ? summary.last_seq : empty.last_seq,
87
+ last_event_time: typeof summary.last_event_time === "string" ? summary.last_event_time : null,
88
+ workers: isPlainRecord(summary.workers) ? summary.workers : {},
89
+ gates: isPlainRecord(summary.gates) ? summary.gates : {},
90
+ latest_assessment: summary.latest_assessment && typeof summary.latest_assessment === "object"
91
+ ? summary.latest_assessment
92
+ : null,
93
+ escalations: Array.isArray(summary.escalations)
94
+ ? summary.escalations
95
+ : [],
96
+ llm_budget: summary.llm_budget && typeof summary.llm_budget === "object"
97
+ ? {
98
+ enabled: config.llm_enabled,
99
+ max_calls: config.llm_max_calls,
100
+ used_calls: typeof summary.llm_budget.used_calls === "number"
101
+ ? summary.llm_budget.used_calls
102
+ : 0,
103
+ }
104
+ : empty.llm_budget,
105
+ started_at: typeof summary.started_at === "string" ? summary.started_at : empty.started_at,
106
+ global_deadline_at: typeof summary.global_deadline_at === "string" ? summary.global_deadline_at : empty.global_deadline_at,
107
+ roster_discovered: summary.roster_discovered === true,
108
+ run_id: runId,
109
+ };
110
+ return hydrated;
111
+ }
112
+ function isPlainRecord(value) {
113
+ return value !== null && typeof value === "object" && !Array.isArray(value);
114
+ }
115
+ /**
116
+ * Return the existing worker state for `workerId`, creating it if absent. A
117
+ * roster-discovered worker starts `not_started`; a worker first seen via an
118
+ * arbitrary event starts `unknown`. Existing workers are returned as-is —
119
+ * terminal states are preserved (never downgraded by mere rediscovery).
120
+ */
121
+ export function ensureWorkerState(state, workerId, options = {}) {
122
+ const existing = state.workers[workerId];
123
+ if (existing) {
124
+ if (options.ticketKey && !existing.ticket_key) {
125
+ existing.ticket_key = options.ticketKey;
126
+ }
127
+ return existing;
128
+ }
129
+ const created = {
130
+ worker_id: workerId,
131
+ ticket_key: options.ticketKey ?? null,
132
+ state: options.fromRoster ? "not_started" : "unknown",
133
+ liveness: "unknown",
134
+ first_seen_seq: options.seq ?? null,
135
+ last_event_seq: options.seq ?? null,
136
+ last_event_time: null,
137
+ last_progress_time: null,
138
+ last_heartbeat_time: null,
139
+ blocked_reason: null,
140
+ terminal_reason: null,
141
+ observed_event_types: [],
142
+ };
143
+ state.workers[workerId] = created;
144
+ return created;
145
+ }
146
+ /** Record that a worker observed `eventType` (deduped, bounded list). */
147
+ function noteObservedType(worker, eventType) {
148
+ if (!worker.observed_event_types.includes(eventType)) {
149
+ worker.observed_event_types.push(eventType);
150
+ }
151
+ }
152
+ /** Extract a normalized `data.details` object from an event, if present. */
153
+ function eventDetails(event) {
154
+ const details = event.data?.details;
155
+ return isPlainRecord(details) ? details : {};
156
+ }
157
+ /**
158
+ * Extract a worker roster from a `run.started` event. Supports rosters at
159
+ * `data.details.workers`, `data.workers`, or `data.raw.workers`. Each entry is
160
+ * read for `worker_id` and `ticket_key` only (compact, secret-free).
161
+ */
162
+ function extractRoster(event) {
163
+ const candidates = [
164
+ eventDetails(event).workers,
165
+ event.data?.workers,
166
+ isPlainRecord(event.data?.raw) ? event.data.raw.workers : undefined,
167
+ ];
168
+ for (const candidate of candidates) {
169
+ if (Array.isArray(candidate)) {
170
+ const roster = [];
171
+ for (const entry of candidate) {
172
+ if (!isPlainRecord(entry))
173
+ continue;
174
+ const workerId = entry.worker_id;
175
+ if (typeof workerId !== "string" || workerId.length === 0)
176
+ continue;
177
+ const ticketKey = entry.ticket_key;
178
+ roster.push({
179
+ worker_id: workerId,
180
+ ticket_key: typeof ticketKey === "string" ? ticketKey : null,
181
+ });
182
+ }
183
+ if (roster.length > 0)
184
+ return roster;
185
+ }
186
+ }
187
+ return [];
188
+ }
189
+ /** Lowercased `data.status` from an event, or "". */
190
+ function eventStatus(event) {
191
+ const status = event.data?.status;
192
+ return typeof status === "string" ? status.trim().toLowerCase() : "";
193
+ }
194
+ /** Lowercased `data.reason` (or details.reason) from an event, or "". */
195
+ function eventReason(event) {
196
+ const reason = event.data?.reason ?? eventDetails(event).reason;
197
+ return typeof reason === "string" ? reason.trim().toLowerCase() : "";
198
+ }
199
+ const PROGRESS_EVENT_TYPES = new Set([
200
+ "tool.intent",
201
+ "worktree.changed",
202
+ "git.commit_created",
203
+ ]);
204
+ const BLOCKED_STATUS_TOKENS = new Set(["blocked", "waiting_for_input", "needs_input"]);
205
+ /** Statuses on `run.stopped` that mean the worker failed (vs. completed). */
206
+ const FAILED_STATUS_TOKENS = new Set(["failed", "error", "errored", "aborted", "cancelled", "canceled"]);
207
+ /**
208
+ * Apply ONE raw conductor event to the supervisor state, mutating and returning
209
+ * it. Events whose `run_id` does not match are ignored WITHOUT advancing
210
+ * `last_seq`. Matching events advance `last_seq` (monotonic) and update
211
+ * `last_event_time`. Raw events are authoritative; `supervisor.assessment` is
212
+ * treated as audit input only.
213
+ */
214
+ export function applyConductorEventToSupervisorState(state, event, now) {
215
+ // Run scoping: ignore foreign-run events entirely.
216
+ if (event.run_id !== state.run_id) {
217
+ return state;
218
+ }
219
+ const eventType = event.type;
220
+ const eventTimeMs = isoToMs(event.time) ?? now;
221
+ const eventTimeIso = event.time ?? msToIso(now);
222
+ // Advance the cursor and run-level last event time.
223
+ if (typeof event.seq === "number" && event.seq > state.last_seq) {
224
+ state.last_seq = event.seq;
225
+ }
226
+ state.last_event_time = eventTimeIso;
227
+ if (state.status === "unknown")
228
+ state.status = "active";
229
+ // run.started: discover the roster. Workers are created `not_started`.
230
+ if (eventType === "run.started") {
231
+ const roster = extractRoster(event);
232
+ if (roster.length > 0)
233
+ state.roster_discovered = true;
234
+ for (const member of roster) {
235
+ const worker = ensureWorkerState(state, member.worker_id, {
236
+ fromRoster: true,
237
+ ticketKey: member.ticket_key,
238
+ seq: event.seq,
239
+ });
240
+ noteObservedType(worker, eventType);
241
+ worker.last_event_seq = event.seq ?? worker.last_event_seq;
242
+ worker.last_event_time = eventTimeIso;
243
+ }
244
+ state.updated_at = msToIso(now);
245
+ return state;
246
+ }
247
+ // supervisor.assessment and message.sent are SUPERVISOR-side audit events and
248
+ // must NOT advance the target worker's liveness/stall anchor. message.sent is
249
+ // emitted scoped to the *target* worker (so the relay can attribute it), so
250
+ // without this guard the very act of sending an escalation to a stalled worker
251
+ // would stamp that worker with a fresh last_event_time — making it look `alive`
252
+ // on the next housekeeping pass and resetting its stall anchor, defeating the
253
+ // watchdog. (message.delivered / message.acked ARE the worker's own activity and
254
+ // are handled as liveness signals in the worker switch below — BAPI-397.)
255
+ if (eventType === "supervisor.assessment" || eventType === "message.sent") {
256
+ state.updated_at = msToIso(now);
257
+ return state;
258
+ }
259
+ // Events with a worker_id mutate that worker; otherwise run-level only.
260
+ const workerId = event.worker_id;
261
+ if (typeof workerId !== "string" || workerId.length === 0) {
262
+ applyRunLevelEvent(state, event, eventType);
263
+ state.updated_at = msToIso(now);
264
+ return state;
265
+ }
266
+ const worker = ensureWorkerState(state, workerId, { seq: event.seq });
267
+ noteObservedType(worker, eventType);
268
+ worker.last_event_seq = event.seq ?? worker.last_event_seq;
269
+ worker.last_event_time = eventTimeIso;
270
+ switch (eventType) {
271
+ case "run.heartbeat": {
272
+ worker.last_heartbeat_time = eventTimeIso;
273
+ if (!isTerminalState(worker.state) && worker.state !== "blocked") {
274
+ // A heartbeat alone is not "progress" but it does promote a not_started
275
+ // or unknown worker to active.
276
+ if (worker.state === "not_started" || worker.state === "unknown") {
277
+ worker.state = "active";
278
+ }
279
+ }
280
+ break;
281
+ }
282
+ case "agent.notification": {
283
+ const status = eventStatus(event);
284
+ const reason = eventReason(event);
285
+ if (BLOCKED_STATUS_TOKENS.has(status) || BLOCKED_STATUS_TOKENS.has(reason)) {
286
+ if (!isTerminalState(worker.state)) {
287
+ worker.state = "blocked";
288
+ worker.blocked_reason = status || reason || "blocked";
289
+ }
290
+ }
291
+ else if (!isTerminalState(worker.state) && worker.state === "not_started") {
292
+ // An ambiguous notification still indicates the worker is alive.
293
+ worker.state = "active";
294
+ }
295
+ break;
296
+ }
297
+ case "tool.intent":
298
+ case "worktree.changed":
299
+ case "git.commit_created": {
300
+ if (PROGRESS_EVENT_TYPES.has(eventType)) {
301
+ worker.last_progress_time = eventTimeIso;
302
+ if (!isTerminalState(worker.state)) {
303
+ // Deterministic progress clears a blocked/stalled marker and keeps the
304
+ // worker active.
305
+ if (worker.state === "not_started" || worker.state === "unknown" || worker.state === "stalled" || worker.state === "blocked") {
306
+ worker.state = "active";
307
+ }
308
+ worker.blocked_reason = null;
309
+ }
310
+ }
311
+ break;
312
+ }
313
+ case "gate.met": {
314
+ if (!isTerminalState(worker.state)) {
315
+ worker.state = "candidate_done";
316
+ }
317
+ break;
318
+ }
319
+ case "ci.passed": {
320
+ if (!isTerminalState(worker.state)) {
321
+ worker.state = "verifying";
322
+ }
323
+ worker.last_progress_time = eventTimeIso;
324
+ break;
325
+ }
326
+ case "ci.failed": {
327
+ // Only an explicit, semantically-terminal worker-scoped CI failure marks a
328
+ // worker failed; otherwise keep it nonterminal (CI may retry).
329
+ const reason = eventReason(event);
330
+ const status = eventStatus(event);
331
+ if (status === "terminal" || reason === "terminal" || reason === "give_up") {
332
+ worker.state = "failed";
333
+ worker.terminal_reason = reason || "ci_failed";
334
+ }
335
+ else if (!isTerminalState(worker.state)) {
336
+ worker.last_progress_time = eventTimeIso;
337
+ }
338
+ break;
339
+ }
340
+ case "run.stopped": {
341
+ const status = eventStatus(event);
342
+ const reason = eventReason(event);
343
+ if (FAILED_STATUS_TOKENS.has(status) || FAILED_STATUS_TOKENS.has(reason)) {
344
+ worker.state = "failed";
345
+ worker.terminal_reason = reason || status || "failed";
346
+ }
347
+ else {
348
+ worker.state = "complete";
349
+ worker.terminal_reason = reason || status || "complete";
350
+ }
351
+ break;
352
+ }
353
+ case "message.delivered":
354
+ case "message.acked": {
355
+ // BAPI-397: a worker that polled + acked a supervisor relay message is
356
+ // demonstrably alive. Treat it as a LIVENESS signal (promote a
357
+ // not_started/unknown worker to active, like a heartbeat) but NOT as
358
+ // deterministic implementation progress: message relay types are
359
+ // deliberately absent from PROGRESS_EVENT_TYPES, so last_progress_time is
360
+ // never advanced here. Terminal states stay sticky (the guard below) and a
361
+ // blocked/stalled worker is not silently revived by an ack.
362
+ if (!isTerminalState(worker.state) &&
363
+ (worker.state === "not_started" || worker.state === "unknown")) {
364
+ worker.state = "active";
365
+ }
366
+ break;
367
+ }
368
+ case "merge.succeeded": {
369
+ // BAPI-398: a successful autonomous merge is the worker's payoff — mark it
370
+ // complete with an auto-merge terminal reason.
371
+ worker.state = "complete";
372
+ worker.terminal_reason = worker.terminal_reason || "merge_succeeded";
373
+ break;
374
+ }
375
+ case "merge.failed": {
376
+ // BAPI-398: merge.failed is RETRYABLE (head-SHA drift / CI-not-green /
377
+ // provider conflict can self-resolve on a new head SHA + action key). Record
378
+ // it as observed (noteObservedType above) but never mark the worker terminal.
379
+ break;
380
+ }
381
+ case "merge.dry_run": {
382
+ // BAPI-398: dry-run is audit-only (repo flag off / fail-closed). Keep the
383
+ // worker nonterminal.
384
+ break;
385
+ }
386
+ case "merge.pending_approval": {
387
+ // BAPI-413: pending_approval is nonterminal — the worker must stay active
388
+ // until the human redeems the token and the backend returns merge.succeeded.
389
+ break;
390
+ }
391
+ default:
392
+ break;
393
+ }
394
+ state.updated_at = msToIso(now);
395
+ return state;
396
+ }
397
+ /** Apply a run-level (no worker_id) event to gate/verification metadata. */
398
+ function applyRunLevelEvent(state, event, eventType) {
399
+ switch (eventType) {
400
+ case "gate.met":
401
+ state.gates.gate_met = true;
402
+ break;
403
+ case "ci.passed":
404
+ state.gates.ci = "passed";
405
+ break;
406
+ case "ci.failed":
407
+ state.gates.ci = "failed";
408
+ break;
409
+ case "git.pr_opened":
410
+ state.gates.pr_opened = true;
411
+ break;
412
+ case "merge.succeeded":
413
+ case "merge.failed":
414
+ case "merge.dry_run":
415
+ case "merge.pending_approval":
416
+ // BAPI-398/413: run-level merge events (no worker_id) are recorded compactly
417
+ // under gate metadata WITHOUT creating a worker. Worker-scoped merge events
418
+ // are handled in the main reducer; merges are normally worker-scoped.
419
+ state.gates.merge = eventType.slice("merge.".length);
420
+ break;
421
+ default:
422
+ break;
423
+ }
424
+ }
425
+ // ---------------------------------------------------------------------------
426
+ // Liveness & housekeeping (Step 6)
427
+ // ---------------------------------------------------------------------------
428
+ /**
429
+ * Classify a worker's LIVENESS from timestamps only — independent of its
430
+ * progress (watchdog) state. Terminal workers return a stable `alive` liveness
431
+ * (they are done, not dead). A worker with no observed timestamps is `unknown`.
432
+ */
433
+ export function classifyWorkerLiveness(worker, config, now) {
434
+ if (isTerminalState(worker.state))
435
+ return "alive";
436
+ const lastSignalMs = mostRecentSignalMs(worker);
437
+ if (lastSignalMs === null)
438
+ return "unknown";
439
+ const elapsed = now - lastSignalMs;
440
+ if (elapsed >= config.liveness.dead_after_ms)
441
+ return "dead";
442
+ if (elapsed >= config.liveness.stalled_after_ms)
443
+ return "stalled";
444
+ if (elapsed >= config.liveness.quiet_after_ms)
445
+ return "quiet";
446
+ return "alive";
447
+ }
448
+ /** Most recent of heartbeat / event / progress timestamps (ms), or null. */
449
+ function mostRecentSignalMs(worker) {
450
+ const candidates = [
451
+ isoToMs(worker.last_heartbeat_time),
452
+ isoToMs(worker.last_event_time),
453
+ isoToMs(worker.last_progress_time),
454
+ ].filter((v) => v !== null);
455
+ if (candidates.length === 0)
456
+ return null;
457
+ return Math.max(...candidates);
458
+ }
459
+ /** Reference time for a worker's CURRENT-state stall budget. */
460
+ function stateAnchorMs(worker) {
461
+ // For long active/verifying work, prefer recent progress so a long but
462
+ // PROGRESSING worker is never falsely stalled. Fall back to last event time,
463
+ // then heartbeat.
464
+ if (worker.state === "active" || worker.state === "verifying") {
465
+ return mostRecentSignalMs(worker);
466
+ }
467
+ // not_started/unknown/candidate_done/blocked/stalled: anchor on last event.
468
+ return (isoToMs(worker.last_event_time) ??
469
+ isoToMs(worker.last_heartbeat_time) ??
470
+ isoToMs(worker.last_progress_time));
471
+ }
472
+ /**
473
+ * Recompute liveness for every worker and apply STATE-SPECIFIC stall
474
+ * thresholds. A worker is moved to `stalled` only when its CURRENT state's
475
+ * threshold has elapsed — there is no flat timeout. Terminal workers are never
476
+ * touched. Active/verifying workers with recent progress are protected from
477
+ * false-positive stalls because their anchor is the most recent signal.
478
+ */
479
+ export function applySupervisorHousekeeping(state, config, now) {
480
+ for (const worker of Object.values(state.workers)) {
481
+ worker.liveness = classifyWorkerLiveness(worker, config, now);
482
+ if (isTerminalState(worker.state) || worker.state === "stalled") {
483
+ continue;
484
+ }
485
+ const threshold = config.stall_thresholds_ms[worker.state];
486
+ const anchor = stateAnchorMs(worker);
487
+ if (anchor === null)
488
+ continue;
489
+ if (now - anchor >= threshold) {
490
+ worker.state = "stalled";
491
+ }
492
+ }
493
+ state.updated_at = msToIso(now);
494
+ return state;
495
+ }
496
+ /**
497
+ * A run is terminal ONLY when a worker roster has been discovered AND every
498
+ * known worker is `complete` or `failed`. With no roster discovered, the run is
499
+ * not terminal (the global timeout is the separate backstop).
500
+ */
501
+ export function isSupervisorRunTerminal(state) {
502
+ const workers = Object.values(state.workers);
503
+ if (workers.length === 0)
504
+ return false;
505
+ if (!state.roster_discovered)
506
+ return false;
507
+ return workers.every((w) => isTerminalState(w.state));
508
+ }
509
+ /** Has the configured global deadline passed at `now`? Independent of stalls. */
510
+ export function hasSupervisorGlobalTimeoutElapsed(state, now) {
511
+ const deadlineMs = isoToMs(state.global_deadline_at);
512
+ if (deadlineMs === null)
513
+ return false;
514
+ return now >= deadlineMs;
515
+ }
516
+ // ---------------------------------------------------------------------------
517
+ // Projection serialization (Step 7)
518
+ // ---------------------------------------------------------------------------
519
+ /** Compact, secret-free per-worker summary stored in `active_workers`. */
520
+ function compactWorker(worker) {
521
+ return {
522
+ worker_id: worker.worker_id,
523
+ ticket_key: worker.ticket_key,
524
+ state: worker.state,
525
+ liveness: worker.liveness,
526
+ last_event_seq: worker.last_event_seq,
527
+ last_event_time: worker.last_event_time,
528
+ last_progress_time: worker.last_progress_time,
529
+ last_heartbeat_time: worker.last_heartbeat_time,
530
+ blocked_reason: worker.blocked_reason,
531
+ terminal_reason: worker.terminal_reason,
532
+ };
533
+ }
534
+ /**
535
+ * Convert run state into a {@link SupervisorProjectionInput}. `active_workers`
536
+ * holds compact worker summaries (never raw event data); `gates` holds gate
537
+ * metadata; `assessment` holds the latest assessment or null; and `summary`
538
+ * holds the FULL resumable supervisor state (worker watchdog states, liveness,
539
+ * last seq, escalation history, LLM budget usage, run start/deadline metadata).
540
+ *
541
+ * The serialized summary is secret-free by construction: it is built only from
542
+ * the compact state fields above — never from raw payloads, secrets, or LLM
543
+ * prompts.
544
+ */
545
+ export function toSupervisorProjectionInput(state) {
546
+ const summary = {
547
+ kind: SUPERVISOR_SUMMARY_KIND,
548
+ run_id: state.run_id,
549
+ status: state.status,
550
+ last_seq: state.last_seq,
551
+ last_event_time: state.last_event_time,
552
+ workers: state.workers,
553
+ gates: state.gates,
554
+ latest_assessment: state.latest_assessment,
555
+ escalations: state.escalations,
556
+ llm_budget: state.llm_budget,
557
+ started_at: state.started_at,
558
+ updated_at: state.updated_at,
559
+ global_deadline_at: state.global_deadline_at,
560
+ roster_discovered: state.roster_discovered,
561
+ };
562
+ return {
563
+ run_id: state.run_id,
564
+ status: state.status,
565
+ last_seq: state.last_seq,
566
+ last_event_time: state.last_event_time,
567
+ active_workers: Object.values(state.workers).map(compactWorker),
568
+ gates: state.gates,
569
+ assessment: state.latest_assessment,
570
+ summary,
571
+ };
572
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared DTO / state-vocabulary contracts for the conductor supervisor runtime
3
+ * (BAPI-396, conductor C4).
4
+ *
5
+ * These are pure TypeScript type declarations with NO runtime behavior so every
6
+ * supervisor module (config, reducer, escalation, judgment, runtime) can depend
7
+ * on them freely without import cycles or side effects.
8
+ *
9
+ * Vocabulary split — the supervisor tracks two ORTHOGONAL axes per worker:
10
+ * - {@link WatchdogState}: the worker's progress through the run lifecycle
11
+ * (derived from raw ledger events; raw events are the source of truth).
12
+ * - {@link WorkerLiveness}: how recently the worker produced ANY signal
13
+ * (derived purely from timestamps). Liveness is NOT progress: a worker can
14
+ * be `active` (progress) yet `quiet` (liveness) during a long build.
15
+ */
16
+ export {};
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Conductor semantic event taxonomy.
3
+ *
4
+ * This module is the SINGLE SOURCE OF TRUTH for the set of accepted conductor
5
+ * event types. The SQLite `events` table CHECK constraint, the Zod tool input
6
+ * enums, and the CLI parser all derive their accepted vocabulary from
7
+ * {@link SEMANTIC_EVENT_TYPES} here. It is deliberately framework-agnostic and
8
+ * has no I/O or store dependencies so it can be imported from the store, the
9
+ * tools, the CLI, and unit tests alike.
10
+ */
11
+ import { ConductorValidationError } from "./errors.js";
12
+ /**
13
+ * The exact, ordered set of accepted semantic event types. The order is part of
14
+ * the contract (a unit test pins it) so downstream artifacts — the schema CHECK
15
+ * constraint and the Zod enum — render deterministically.
16
+ */
17
+ export const SEMANTIC_EVENT_TYPES = [
18
+ "run.started",
19
+ "run.heartbeat",
20
+ "run.stopped",
21
+ "agent.notification",
22
+ "tool.intent",
23
+ "worktree.changed",
24
+ "git.commit_created",
25
+ "git.pr_opened",
26
+ "ci.passed",
27
+ "ci.failed",
28
+ "gate.met",
29
+ "supervisor.assessment",
30
+ "message.sent",
31
+ "message.delivered",
32
+ "message.acked",
33
+ "merge.dry_run",
34
+ "merge.attempted",
35
+ "merge.succeeded",
36
+ "merge.failed",
37
+ "merge.pending_approval",
38
+ ];
39
+ /**
40
+ * Type guard: returns `true` only when `value` is one of the exact taxonomy
41
+ * strings. Rejects non-strings, casing variants, and surrounding whitespace.
42
+ */
43
+ export function isSemanticEventType(value) {
44
+ return (typeof value === "string" &&
45
+ SEMANTIC_EVENT_TYPES.includes(value));
46
+ }
47
+ /**
48
+ * Narrow `value` to a {@link SemanticEventType}, throwing a
49
+ * {@link ConductorValidationError} for any non-taxonomy value. The error names
50
+ * the invalid event-type problem but never echoes unrelated payload data.
51
+ */
52
+ export function assertSemanticEventType(value) {
53
+ if (isSemanticEventType(value)) {
54
+ return value;
55
+ }
56
+ const rendered = typeof value === "string" ? value : typeof value;
57
+ throw new ConductorValidationError(`Unknown conductor event type "${rendered}". Allowed types: ${SEMANTIC_EVENT_TYPES.join(", ")}.`);
58
+ }