@bridge_gpt/mcp-server 0.2.34 → 0.2.37

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 (109) hide show
  1. package/README.md +490 -366
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/base-url.js +79 -0
  6. package/build/bridge-api-urls.js +9 -0
  7. package/build/chain-orchestrator.js +93 -15
  8. package/build/claude-review-workflow.js +264 -0
  9. package/build/claude-user-config-doctor.js +317 -0
  10. package/build/cli-release.js +53 -0
  11. package/build/commands.generated.js +6 -5
  12. package/build/conductor/bridge-api-client.js +393 -4
  13. package/build/conductor/deny-enforcement-preflight.js +1 -0
  14. package/build/conductor/done-gate.js +44 -5
  15. package/build/conductor/epic-reconcile.js +6 -0
  16. package/build/conductor/install-doctor.js +462 -0
  17. package/build/conductor-bin.js +3 -3
  18. package/build/conductor-bundle-artifacts.js +37 -15
  19. package/build/credential-store.js +205 -4
  20. package/build/direct-ticket-tools.js +70 -0
  21. package/build/doctor.js +473 -81
  22. package/build/executor/cli.js +83 -6
  23. package/build/executor/credentials.js +39 -11
  24. package/build/executor/deps.js +32 -1
  25. package/build/executor/env.js +71 -28
  26. package/build/executor/heartbeat.js +138 -17
  27. package/build/executor/http-client.js +49 -8
  28. package/build/executor/index.js +4 -0
  29. package/build/executor/job-errors.js +4 -0
  30. package/build/executor/job-runner.js +530 -20
  31. package/build/executor/observation.js +130 -0
  32. package/build/executor/permissions.js +114 -8
  33. package/build/executor/preflight.js +127 -8
  34. package/build/executor/prompt-spec.js +51 -0
  35. package/build/executor/runner.js +23 -2
  36. package/build/executor/service-unit.js +876 -0
  37. package/build/executor/test-clock.js +75 -3
  38. package/build/executor/types.js +4 -18
  39. package/build/executor/worker-command.js +73 -12
  40. package/build/executor/worker-config-isolation.js +287 -0
  41. package/build/executor/worker-finalization.js +68 -14
  42. package/build/executor/worktree.js +46 -4
  43. package/build/index.js +1039 -237
  44. package/build/init.js +503 -111
  45. package/build/install-bridge-conductor.js +491 -0
  46. package/build/install-bridge.js +1188 -247
  47. package/build/install-reexec.js +233 -0
  48. package/build/launcher-config-inspection.js +351 -0
  49. package/build/mcp-host-config.js +11 -1
  50. package/build/mcp-install-state.js +32 -0
  51. package/build/mcp-invoke.js +49 -6
  52. package/build/mcp-provisioning.js +52 -13
  53. package/build/mcp-registration-doctor.js +14 -5
  54. package/build/notifications.js +553 -0
  55. package/build/pipeline-orchestrator.js +146 -4
  56. package/build/pipeline-utils.js +3 -0
  57. package/build/pipelines.generated.js +32 -13
  58. package/build/plan-execution-ledger.js +550 -0
  59. package/build/plan-phase-routing.js +272 -0
  60. package/build/plane/alembic-head.js +110 -0
  61. package/build/plane/build-freshness.js +167 -0
  62. package/build/plane/cli.js +480 -0
  63. package/build/plane/defaults.js +266 -0
  64. package/build/plane/manifest.js +377 -0
  65. package/build/plane/member-logs.js +147 -0
  66. package/build/plane/member-roster.js +147 -0
  67. package/build/plane/preflight.js +289 -0
  68. package/build/plane/shutdown.js +195 -0
  69. package/build/plane/status.js +125 -0
  70. package/build/plane/supervisor.js +569 -0
  71. package/build/plane/test-fakes.js +156 -0
  72. package/build/plane/types.js +75 -0
  73. package/build/readme.generated.js +1 -1
  74. package/build/run-unit-tests-launcher.js +259 -0
  75. package/build/setup-epic.js +772 -28
  76. package/build/sfcc/log-gate.js +38 -11
  77. package/build/sfcc/log-query.js +55 -15
  78. package/build/sfcc/ocapi-shape.js +51 -14
  79. package/build/sfcc/output.js +41 -11
  80. package/build/sfcc/permissions.js +24 -2
  81. package/build/sfcc/read-projection.js +181 -0
  82. package/build/sfcc/read-result.js +158 -0
  83. package/build/sfcc/reads-custom-object-def.js +29 -18
  84. package/build/sfcc/reads-site-preference.js +75 -29
  85. package/build/sfcc/reads-system-object.js +40 -34
  86. package/build/sfcc/sfcc-result.js +106 -0
  87. package/build/sfcc/tool-wrapper.js +56 -13
  88. package/build/sfcc/write-grants.js +45 -22
  89. package/build/sfcc/write-guard.js +21 -13
  90. package/build/sfcc/write-result.js +61 -14
  91. package/build/sfcc/write-tool-common.js +126 -32
  92. package/build/sfcc/writes-system-object.js +11 -50
  93. package/build/start-tickets-prereqs.js +129 -0
  94. package/build/start-tickets.js +17 -13
  95. package/build/ticket-backend-metadata.js +59 -0
  96. package/build/ticket-key-utils.js +92 -0
  97. package/build/tool-error-envelope.js +71 -0
  98. package/build/tool-surface-gating.js +72 -0
  99. package/build/update-status.js +102 -0
  100. package/build/upgrade-advice.js +47 -0
  101. package/build/upgrade-cli.js +412 -110
  102. package/build/version.generated.js +1 -1
  103. package/build/worktree-core.js +73 -0
  104. package/docs/CONDUCTOR.md +132 -6
  105. package/docs/install/mcp-tool-integrations.md +29 -21
  106. package/package.json +9 -6
  107. package/pipelines/implement-ticket.json +19 -4
  108. package/build/conductor/supervisor-judgment-python.js +0 -141
  109. package/build/conductor/supervisor-judgment.js +0 -215
@@ -0,0 +1,553 @@
1
+ /**
2
+ * `notifications` — read-only CLI for the Epic Conductor durable notification
3
+ * sink (BAPI-755).
4
+ *
5
+ * npx -y @bridge_gpt/mcp-server notifications --repo <repo> --run <epic-key|run-id> [--follow]
6
+ *
7
+ * Resolves an epic key or a concrete run id to its canonical epic run via the
8
+ * EXISTING run-read endpoint (`fetchEpicRunState`, shared with `setup-epic`),
9
+ * then fetches `GET /jira/epic-runs/runs/{epic_run_id}/notifications` — a new
10
+ * BAPI-755 endpoint built in parallel on the Python side. This module treats
11
+ * that endpoint's response shape as a fixed, given contract and validates every
12
+ * field defensively rather than casting it.
13
+ *
14
+ * Strictly read-only and side-effect-free: no mutation, no MCP server startup.
15
+ * Without `--follow` it prints the latest page once and exits. With `--follow`
16
+ * it polls forever (until interrupted) at the same ~60s cadence as the
17
+ * server's own `epic_conductor_notification_sweep` job
18
+ * (`api/library/scheduled_jobs.py`), draining any full page immediately so a
19
+ * backlog is caught up fast before returning to normal cadence.
20
+ *
21
+ * Credentials are resolved ONLY through the shared conductor access seam
22
+ * ({@link resolveConductorBridgeApiAccess}, which itself resolves ONLY through
23
+ * `resolveBapiCredentials`) — never read directly from the environment here,
24
+ * and never obtained by spawning a subprocess. The API key travels only in the
25
+ * `X-API-Key` request header, matching the existing conductor Bridge API
26
+ * client convention; it never appears in a URL, in stdout, in stderr, or in any
27
+ * thrown/error text.
28
+ *
29
+ * Every notification data line goes to stdout (so a script can safely parse
30
+ * it); every other line — progress, "no data yet", warnings, errors — goes to
31
+ * stderr. This module deliberately renders in monochrome: no shared ANSI/color
32
+ * helper exists elsewhere in this codebase for CLI output (the one bespoke use,
33
+ * `review-tickets.ts`'s fail-loud alert box, is a one-off inline escape
34
+ * sequence, not a reusable convention), so per this ticket's guidance this CLI
35
+ * does not invent a new styling system.
36
+ */
37
+ import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
38
+ import os from "node:os";
39
+ import { ConductorBridgeApiError, fetchConductorJsonWithTimeout, fetchEpicRunState, resolveConductorBridgeApiAccess, safeDiagnosticMessage, buildConductorJiraUrl, CONDUCTOR_FETCH_TIMEOUT_MS, } from "./conductor/bridge-api-client.js";
40
+ // ---------------------------------------------------------------------------
41
+ // Tunables (injectable — never a bare hardcoded setTimeout/limit downstream)
42
+ // ---------------------------------------------------------------------------
43
+ /**
44
+ * Follow-mode poll cadence, matching the server's own notification sweep
45
+ * (`epic_conductor_notification_sweep`, a 60s `IntervalTrigger` in
46
+ * `api/library/scheduled_jobs.py`). Overridable via
47
+ * {@link NotificationsCliDeps.pollIntervalMs} for tests.
48
+ */
49
+ export const NOTIFICATIONS_FOLLOW_POLL_INTERVAL_MS = 60_000;
50
+ /**
51
+ * Page size requested on every fetch (both the one-shot non-follow fetch and
52
+ * every follow-mode poll). A page that comes back at exactly this size is
53
+ * treated as "more may be waiting" and drained immediately, without a poll
54
+ * delay; a page smaller than this is "caught up". Overridable via
55
+ * {@link NotificationsCliDeps.pageLimit} for tests.
56
+ */
57
+ export const NOTIFICATIONS_DEFAULT_PAGE_LIMIT = 200;
58
+ function isSafeInteger(value) {
59
+ return typeof value === "number" && Number.isSafeInteger(value);
60
+ }
61
+ function isValidTimestamp(value) {
62
+ return typeof value === "string" && value.trim() !== "" && !Number.isNaN(Date.parse(value));
63
+ }
64
+ /** A nullable/absent field must be `null`/absent, or a non-empty string. */
65
+ function readNullableNonEmptyString(record, key) {
66
+ const raw = record[key];
67
+ if (raw === undefined || raw === null)
68
+ return { ok: true, value: null };
69
+ return typeof raw === "string" && raw.trim() !== "" ? { ok: true, value: raw } : { ok: false };
70
+ }
71
+ /** A nullable/absent field must be `null`/absent, or a positive safe integer. */
72
+ function readNullablePositiveInt(record, key) {
73
+ const raw = record[key];
74
+ if (raw === undefined || raw === null)
75
+ return { ok: true, value: null };
76
+ return isSafeInteger(raw) && raw > 0 ? { ok: true, value: raw } : { ok: false };
77
+ }
78
+ /** A nullable/absent field must be `null`/absent, or a valid timestamp string. */
79
+ function readNullableTimestamp(record, key) {
80
+ const raw = record[key];
81
+ if (raw === undefined || raw === null)
82
+ return { ok: true, value: null };
83
+ return isValidTimestamp(raw) ? { ok: true, value: raw } : { ok: false };
84
+ }
85
+ /** Validate one raw notification item. Returns `null` for any malformed shape. */
86
+ function validateNotification(raw) {
87
+ if (!raw || typeof raw !== "object")
88
+ return null;
89
+ const r = raw;
90
+ if (!isSafeInteger(r.id) || r.id <= 0)
91
+ return null;
92
+ if (typeof r.kind !== "string" || r.kind.trim() === "")
93
+ return null;
94
+ if (!isValidTimestamp(r.created_at))
95
+ return null;
96
+ if (!isSafeInteger(r.attempt_count) || r.attempt_count < 0)
97
+ return null;
98
+ const ticketKey = readNullableNonEmptyString(r, "ticket_key");
99
+ if (!ticketKey.ok)
100
+ return null;
101
+ const gate = readNullableNonEmptyString(r, "gate");
102
+ if (!gate.ok)
103
+ return null;
104
+ const reason = readNullableNonEmptyString(r, "reason");
105
+ if (!reason.ok)
106
+ return null;
107
+ const headSha = readNullableNonEmptyString(r, "head_sha");
108
+ if (!headSha.ok)
109
+ return null;
110
+ const prNumber = readNullablePositiveInt(r, "pr_number");
111
+ if (!prNumber.ok)
112
+ return null;
113
+ const sentAt = readNullableTimestamp(r, "sent_at");
114
+ if (!sentAt.ok)
115
+ return null;
116
+ return {
117
+ id: r.id,
118
+ kind: r.kind,
119
+ ticket_key: ticketKey.value,
120
+ gate: gate.value,
121
+ reason: reason.value,
122
+ pr_number: prNumber.value,
123
+ head_sha: headSha.value,
124
+ attempt_count: r.attempt_count,
125
+ sent_at: sentAt.value,
126
+ created_at: r.created_at,
127
+ };
128
+ }
129
+ /** Validate the whole `{ notifications: [...] }` envelope. Never casts blindly. */
130
+ function validateNotificationsResponse(parsed) {
131
+ if (!parsed || typeof parsed !== "object") {
132
+ return { ok: false, error: "malformed notifications response: expected a JSON object" };
133
+ }
134
+ const list = parsed.notifications;
135
+ if (!Array.isArray(list)) {
136
+ return { ok: false, error: "malformed notifications response: 'notifications' is not an array" };
137
+ }
138
+ const notifications = [];
139
+ for (let i = 0; i < list.length; i++) {
140
+ const n = validateNotification(list[i]);
141
+ if (!n) {
142
+ return { ok: false, error: `malformed notifications response: invalid entry at index ${i}` };
143
+ }
144
+ notifications.push(n);
145
+ }
146
+ return { ok: true, notifications };
147
+ }
148
+ // ---------------------------------------------------------------------------
149
+ // Sanitized error classification (never a raw body/URL/API key)
150
+ // ---------------------------------------------------------------------------
151
+ /**
152
+ * Classify a thrown fetch error as recoverable (network hiccup, timeout, 5xx,
153
+ * or 429 — worth a follow-mode retry) or terminal (auth failure, 404, or any
154
+ * other non-2xx — never retried). Always returns a bounded, secret-free
155
+ * message via the shared {@link safeDiagnosticMessage} formatter.
156
+ */
157
+ function describeConductorFetchError(err) {
158
+ if (err instanceof ConductorBridgeApiError) {
159
+ const recoverable = err.kind === "network" ||
160
+ err.kind === "timeout" ||
161
+ err.kind === "server" ||
162
+ (err.kind === "http" && err.status === 429);
163
+ return { recoverable, message: safeDiagnosticMessage(err, "Bridge API request failed") };
164
+ }
165
+ return { recoverable: false, message: "unexpected error contacting the Bridge API" };
166
+ }
167
+ /**
168
+ * Resolve `epicKeyOrRunId` to a canonical {@link ResolvedNotificationRun} via
169
+ * the SAME run-read endpoint `setup-epic` already uses ({@link fetchEpicRunState},
170
+ * `GET /jira/epic-runs/runs/{identifier}/state?repo_name=...` — the server
171
+ * accepts either the epic key or the concrete run UUID as `{identifier}`).
172
+ *
173
+ * `fetchEpicRunState` itself casts the parsed JSON to `EpicRunState` without
174
+ * validation; this function does NOT trust that cast — it re-validates the
175
+ * three fields it actually needs (`epic_run_id`, `epic_key`, `repo_name`) as
176
+ * non-empty strings, and rejects a response whose `repo_name` disagrees with
177
+ * the repo the caller authenticated against, before returning anything.
178
+ */
179
+ export async function resolveNotificationRun(access, epicKeyOrRunId, fetchImpl = globalThis.fetch) {
180
+ let state;
181
+ try {
182
+ state = await fetchEpicRunState(access, epicKeyOrRunId, fetchImpl);
183
+ }
184
+ catch (err) {
185
+ const described = describeConductorFetchError(err);
186
+ return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': ${described.message}` };
187
+ }
188
+ if (!state || typeof state !== "object") {
189
+ return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': malformed response` };
190
+ }
191
+ const run = state.epic_run;
192
+ if (!run || typeof run !== "object") {
193
+ return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': response is missing epic_run` };
194
+ }
195
+ const r = run;
196
+ const epicRunId = r.epic_run_id;
197
+ const epicKey = r.epic_key;
198
+ const repoName = r.repo_name;
199
+ if (typeof epicRunId !== "string" || epicRunId.trim() === "") {
200
+ return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': missing/invalid epic_run_id` };
201
+ }
202
+ if (typeof epicKey !== "string" || epicKey.trim() === "") {
203
+ return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': missing/invalid epic_key` };
204
+ }
205
+ if (typeof repoName !== "string" || repoName.trim() === "") {
206
+ return { ok: false, error: `could not resolve run '${epicKeyOrRunId}': missing/invalid repo_name` };
207
+ }
208
+ if (repoName !== access.repoName) {
209
+ return {
210
+ ok: false,
211
+ error: `could not resolve run '${epicKeyOrRunId}': it belongs to a different repository`,
212
+ };
213
+ }
214
+ return { ok: true, run: { epicRunId, epicKey, repoName } };
215
+ }
216
+ /**
217
+ * `GET /jira/epic-runs/runs/{epicRunId}/notifications?repo_name=...`
218
+ * (`&after_id=...` only when a cursor is supplied, `&limit=...` always).
219
+ * URL-encodes the run id; sends the API key ONLY as the `X-API-Key` header,
220
+ * matching the existing conductor Bridge API client convention — never in the
221
+ * URL. Validates the parsed response defensively via
222
+ * {@link validateNotificationsResponse} rather than casting it.
223
+ */
224
+ export async function fetchNotificationsPage(access, epicRunId, options, fetchImpl = globalThis.fetch) {
225
+ const params = {
226
+ repo_name: access.repoName,
227
+ limit: String(options.limit),
228
+ };
229
+ if (options.afterId !== undefined) {
230
+ params.after_id = String(options.afterId);
231
+ }
232
+ const url = buildConductorJiraUrl(access.baseUrl, `/epic-runs/runs/${encodeURIComponent(epicRunId)}/notifications`, params);
233
+ let parsed;
234
+ try {
235
+ parsed = await fetchConductorJsonWithTimeout(url, { "X-API-Key": access.apiKey }, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
236
+ }
237
+ catch (err) {
238
+ const described = describeConductorFetchError(err);
239
+ return { ok: false, recoverable: described.recoverable, error: described.message };
240
+ }
241
+ const validated = validateNotificationsResponse(parsed);
242
+ if (!validated.ok) {
243
+ return { ok: false, recoverable: false, error: validated.error };
244
+ }
245
+ return { ok: true, notifications: validated.notifications };
246
+ }
247
+ // ---------------------------------------------------------------------------
248
+ // Line formatting
249
+ // ---------------------------------------------------------------------------
250
+ /**
251
+ * Bound on the rendered `reason=` segment. The server already bounds stored
252
+ * reason text (BAPI-755 backend); this is a client-side belt-and-suspenders
253
+ * cap so a single notification line can never grow unboundedly even if that
254
+ * server-side bound changes. ~200 chars keeps one line legible in a terminal.
255
+ */
256
+ const REASON_MAX_LENGTH = 200;
257
+ /** Collapse embedded newlines/control chars to single spaces and bound the length. */
258
+ function normalizeReasonText(raw) {
259
+ // Control characters (\x00-\x1f, \x7f) become a single space -- this
260
+ // covers \n, \r, \t and anything else that could break a single terminal
261
+ // line -- then any remaining run of whitespace collapses to one space.
262
+ // eslint-disable-next-line no-control-regex -- deliberately matching control chars
263
+ const collapsed = raw.replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim();
264
+ return collapsed.length > REASON_MAX_LENGTH ? `${collapsed.slice(0, REASON_MAX_LENGTH)}…` : collapsed;
265
+ }
266
+ /**
267
+ * Render one stable one-line grammar:
268
+ * `created_at | kind | ticket=... | gate=... | pr=... | sha=... | reason=...`
269
+ * `created_at` and `kind` are always first and always present. Every other
270
+ * segment is OMITTED (never rendered as `ticket=null`/`ticket=undefined`) when
271
+ * the corresponding field is absent.
272
+ */
273
+ export function formatNotificationLine(notification) {
274
+ const segments = [notification.created_at, notification.kind];
275
+ if (notification.ticket_key)
276
+ segments.push(`ticket=${notification.ticket_key}`);
277
+ if (notification.gate)
278
+ segments.push(`gate=${notification.gate}`);
279
+ if (notification.pr_number !== null)
280
+ segments.push(`pr=${notification.pr_number}`);
281
+ if (notification.head_sha)
282
+ segments.push(`sha=${notification.head_sha}`);
283
+ if (notification.reason)
284
+ segments.push(`reason=${normalizeReasonText(notification.reason)}`);
285
+ return segments.join(" | ");
286
+ }
287
+ /** User-facing usage text for `notifications`, including `-h`/`--help`. */
288
+ export function getNotificationsUsage() {
289
+ return [
290
+ "Usage: mcp-server notifications --repo <repo> --run <epic-key|run-id> [--follow]",
291
+ "",
292
+ "Read-only: prints an epic run's durable notification feed (BAPI-755), one line",
293
+ "per notification, oldest first. <epic-key|run-id> is resolved to the canonical",
294
+ "epic run via the existing run-read endpoint, exactly like setup-epic does.",
295
+ "",
296
+ "Required:",
297
+ " --repo <name> Repo name",
298
+ " --run <epic-key|id> Epic key (e.g. BAPI-405) or a concrete run id",
299
+ "",
300
+ "Options:",
301
+ " --follow Keep polling for new notifications (~60s cadence,",
302
+ " catching up immediately on any backlog) and print",
303
+ " each new one as it arrives, until interrupted",
304
+ " (Ctrl-C). Without it, prints the latest page once",
305
+ " and exits.",
306
+ " -h, --help Show this help",
307
+ "",
308
+ "Each line has the form:",
309
+ " <created_at> | <kind> | ticket=... | gate=... | pr=... | sha=... | reason=...",
310
+ "Every ticket=/gate=/pr=/sha=/reason= segment is OMITTED when the",
311
+ "corresponding field is absent — never rendered as 'null' or 'undefined'.",
312
+ "",
313
+ "All notification data is written to stdout (safe to pipe/parse); progress",
314
+ "and diagnostic text is written to stderr.",
315
+ ].join("\n");
316
+ }
317
+ const NOTIFICATIONS_KNOWN_FLAGS = new Set(["--repo", "--run", "--follow"]);
318
+ /**
319
+ * Parse `notifications` argv. Requires non-blank `--repo` and `--run`, accepts
320
+ * an optional `--follow` boolean flag (and its `--help`), and REJECTS unknown
321
+ * flags, duplicate flags, a flag with a missing/blank value, and any
322
+ * positional (non-flag) argument. Mirrors the flag-parsing shape already used
323
+ * by `parseMcpInvokeArgs` (`mcp-invoke.ts`): `--flag value` or `--flag=value`,
324
+ * duplicate detection per flag, missing-value detection before the flag is
325
+ * consumed.
326
+ */
327
+ export function parseNotificationsArgs(argv) {
328
+ let repo;
329
+ let run;
330
+ let follow = false;
331
+ for (let i = 0; i < argv.length; i++) {
332
+ const token = argv[i];
333
+ if (token === "-h" || token === "--help") {
334
+ return { ok: true, help: true };
335
+ }
336
+ let flag = token;
337
+ let value;
338
+ const eq = token.indexOf("=");
339
+ if (token.startsWith("--") && eq !== -1) {
340
+ flag = token.slice(0, eq);
341
+ value = token.slice(eq + 1);
342
+ }
343
+ if (!NOTIFICATIONS_KNOWN_FLAGS.has(flag)) {
344
+ if (token.startsWith("-")) {
345
+ return { ok: false, error: `Unknown flag: ${flag}` };
346
+ }
347
+ return { ok: false, error: `Unexpected positional argument: ${token}` };
348
+ }
349
+ if (flag === "--follow") {
350
+ if (value !== undefined) {
351
+ return { ok: false, error: "--follow does not take a value" };
352
+ }
353
+ if (follow) {
354
+ return { ok: false, error: "Duplicate --follow flag" };
355
+ }
356
+ follow = true;
357
+ continue;
358
+ }
359
+ if (value === undefined) {
360
+ const next = argv[i + 1];
361
+ if (next === undefined || next.startsWith("-")) {
362
+ return { ok: false, error: `Missing value for ${flag}` };
363
+ }
364
+ value = next;
365
+ i++;
366
+ }
367
+ const trimmed = value.trim();
368
+ if (trimmed === "") {
369
+ return { ok: false, error: `${flag} requires a non-blank value` };
370
+ }
371
+ if (flag === "--repo") {
372
+ if (repo !== undefined)
373
+ return { ok: false, error: "Duplicate --repo flag" };
374
+ repo = trimmed;
375
+ }
376
+ else {
377
+ if (run !== undefined)
378
+ return { ok: false, error: "Duplicate --run flag" };
379
+ run = trimmed;
380
+ }
381
+ }
382
+ if (repo === undefined)
383
+ return { ok: false, error: "Missing required --repo flag" };
384
+ if (run === undefined)
385
+ return { ok: false, error: "Missing required --run flag" };
386
+ return { ok: true, args: { repo, run, follow } };
387
+ }
388
+ export function createDefaultNotificationsCliDeps() {
389
+ return {
390
+ env: process.env,
391
+ cwd: process.cwd(),
392
+ platform: process.platform,
393
+ homedir: os.homedir,
394
+ readFile: (p) => fsReadFile(p, "utf-8"),
395
+ stat: (p) => fsStat(p),
396
+ fetch: globalThis.fetch,
397
+ stdout: (line) => {
398
+ process.stdout.write(`${line}\n`);
399
+ },
400
+ stderr: (line) => {
401
+ process.stderr.write(`${line}\n`);
402
+ },
403
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
404
+ now: () => new Date(),
405
+ isTTY: Boolean(process.stderr.isTTY),
406
+ onSignal: (signal, handler) => {
407
+ process.on(signal, handler);
408
+ return () => {
409
+ process.off(signal, handler);
410
+ };
411
+ },
412
+ pollIntervalMs: NOTIFICATIONS_FOLLOW_POLL_INTERVAL_MS,
413
+ pageLimit: NOTIFICATIONS_DEFAULT_PAGE_LIMIT,
414
+ };
415
+ }
416
+ /** Print each notification whose id advances `cursor`; returns the new cursor. */
417
+ function emitNewRows(page, cursor, stdout) {
418
+ let next = cursor;
419
+ for (const n of page) {
420
+ if (next !== undefined && n.id <= next)
421
+ continue;
422
+ stdout(formatNotificationLine(n));
423
+ next = next === undefined ? n.id : Math.max(next, n.id);
424
+ }
425
+ return next;
426
+ }
427
+ /**
428
+ * CLI entry for the read-only `notifications` subcommand. Returns a process
429
+ * exit code (never calls `process.exit` itself — the caller in `index.ts`
430
+ * assigns the return value to `process.exitCode`, matching every other
431
+ * subcommand here).
432
+ *
433
+ * Non-follow: fetches the latest (no-cursor) page once, prints it in
434
+ * ascending id order, exits 0. An empty result is reported on stderr, naming
435
+ * the resolved run and pointing at `--follow` — never implying data loss.
436
+ *
437
+ * Follow: repeatedly fetches (cursor-advancing), printing only new rows.
438
+ * A page that comes back at `pageLimit` is drained immediately (no sleep)
439
+ * until a page comes back under the limit, then normal `pollIntervalMs`
440
+ * cadence resumes. A RECOVERABLE fetch failure (network/timeout/5xx/429) is a
441
+ * stderr warning that keeps the last confirmed cursor and retries after the
442
+ * normal wait; a TERMINAL failure (401/403/404/malformed response) exits 1
443
+ * immediately with no further polling. A clean interruption (SIGINT/SIGTERM)
444
+ * exits 0 with no stack trace.
445
+ */
446
+ export async function runNotificationsCli(argv, overrides = {}) {
447
+ const deps = { ...createDefaultNotificationsCliDeps(), ...overrides };
448
+ const parsed = parseNotificationsArgs(argv);
449
+ if (!parsed.ok) {
450
+ deps.stderr(`Error: ${parsed.error}`);
451
+ deps.stderr("");
452
+ deps.stderr(getNotificationsUsage());
453
+ return 1;
454
+ }
455
+ // Narrowing on a single `in` check (rather than the prior compound
456
+ // `"help" in parsed && parsed.help`) so TypeScript can actually exclude the
457
+ // `{ ok: true; help: true }` branch below — a compound `&&` condition's
458
+ // negation isn't narrowed the same way by control-flow analysis.
459
+ if ("help" in parsed) {
460
+ deps.stdout(getNotificationsUsage());
461
+ return 0;
462
+ }
463
+ const { repo, run, follow } = parsed.args;
464
+ const accessResult = await resolveConductorBridgeApiAccess({
465
+ env: deps.env,
466
+ cwd: deps.cwd,
467
+ homedir: deps.homedir,
468
+ platform: deps.platform,
469
+ readFile: deps.readFile,
470
+ stat: deps.stat,
471
+ repoName: repo,
472
+ });
473
+ if (!accessResult.ok) {
474
+ deps.stderr(`Error: cannot reach the Bridge API: ${accessResult.error}`);
475
+ return 1;
476
+ }
477
+ const access = accessResult.access;
478
+ const fetchImpl = deps.fetch;
479
+ if (deps.isTTY)
480
+ deps.stderr("Checking...");
481
+ const resolved = await resolveNotificationRun(access, run, fetchImpl);
482
+ if (!resolved.ok) {
483
+ deps.stderr(`Error: ${resolved.error}`);
484
+ return 1;
485
+ }
486
+ const { epicRunId, epicKey } = resolved.run;
487
+ if (!follow) {
488
+ const page = await fetchNotificationsPage(access, epicRunId, { limit: deps.pageLimit }, fetchImpl);
489
+ if (!page.ok) {
490
+ deps.stderr(`Error: could not fetch notifications for ${epicKey} (${epicRunId}): ${page.error}`);
491
+ return 1;
492
+ }
493
+ if (page.notifications.length === 0) {
494
+ deps.stderr(`No notifications yet for ${epicKey} (${epicRunId}). Re-run with --follow to wait for future ones.`);
495
+ return 0;
496
+ }
497
+ emitNewRows(page.notifications, undefined, deps.stdout);
498
+ return 0;
499
+ }
500
+ // --- Follow mode -----------------------------------------------------
501
+ let stopped = false;
502
+ let resolveStop;
503
+ const stopPromise = new Promise((resolve) => {
504
+ resolveStop = resolve;
505
+ });
506
+ const requestStop = () => {
507
+ if (!stopped) {
508
+ stopped = true;
509
+ resolveStop?.();
510
+ }
511
+ };
512
+ const disposers = [deps.onSignal("SIGINT", requestStop), deps.onSignal("SIGTERM", requestStop)];
513
+ try {
514
+ let cursor;
515
+ let announcedWaiting = false;
516
+ while (!stopped) {
517
+ // Drain loop: keep fetching immediately (no sleep) while pages come
518
+ // back full — catches up on a backlog fast before returning to the
519
+ // normal poll cadence.
520
+ for (;;) {
521
+ const page = await fetchNotificationsPage(access, epicRunId, { afterId: cursor, limit: deps.pageLimit }, fetchImpl);
522
+ if (!page.ok) {
523
+ if (!page.recoverable) {
524
+ deps.stderr(`Error: notifications fetch failed for ${epicKey} (${epicRunId}): ${page.error}`);
525
+ return 1;
526
+ }
527
+ deps.stderr(`Warning: notifications fetch failed, retrying: ${page.error}`);
528
+ break; // Exit the drain loop; the outer loop waits, then retries.
529
+ }
530
+ if (page.notifications.length === 0) {
531
+ if (cursor === undefined && !announcedWaiting) {
532
+ deps.stderr(`No notifications yet for ${epicKey} (${epicRunId}) — waiting (--follow).`);
533
+ announcedWaiting = true;
534
+ }
535
+ break; // Nothing to drain.
536
+ }
537
+ cursor = emitNewRows(page.notifications, cursor, deps.stdout);
538
+ if (page.notifications.length < deps.pageLimit)
539
+ break; // Caught up.
540
+ if (stopped)
541
+ break;
542
+ }
543
+ if (stopped)
544
+ break;
545
+ await Promise.race([deps.sleep(deps.pollIntervalMs), stopPromise]);
546
+ }
547
+ return 0;
548
+ }
549
+ finally {
550
+ for (const dispose of disposers)
551
+ dispose();
552
+ }
553
+ }