@bridge_gpt/mcp-server 0.2.68 → 0.2.70

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 (166) hide show
  1. package/README.md +226 -320
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +650 -98
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-review-workflow-drift.js +3 -3
  9. package/build/claude-settings.js +84 -2
  10. package/build/cli-help.js +7 -1
  11. package/build/codex-bridge-profile.js +1 -1
  12. package/build/codex-home.js +9 -1
  13. package/build/codex-launcher-pin.js +598 -0
  14. package/build/commands.generated.js +8 -8
  15. package/build/conductor/bridge-api-client.js +598 -19
  16. package/build/conductor/bring-up-facts.js +9 -9
  17. package/build/conductor/doctor.js +466 -18
  18. package/build/conductor/epic-child-continuation.js +54 -3
  19. package/build/conductor/epic-implementer-cli.js +132 -34
  20. package/build/conductor/epic-runtime.js +2 -2
  21. package/build/conductor/epic-state.js +1 -1
  22. package/build/conductor/hook-runtime-qualification.js +1 -1
  23. package/build/conductor/install-doctor.js +4 -2
  24. package/build/conductor/launch-need-probes.js +337 -0
  25. package/build/conductor/local-merge.js +241 -56
  26. package/build/conductor/merge-credential.js +172 -0
  27. package/build/conductor/merge-execution-eligibility.js +1 -1
  28. package/build/conductor/merged-child-check.js +95 -0
  29. package/build/conductor/plan-trim.js +30 -0
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +11 -6
  32. package/build/conductor/readiness-prerequisites.js +41 -0
  33. package/build/conductor/readiness-sections.js +345 -55
  34. package/build/conductor/readiness.js +99 -87
  35. package/build/conductor/recovery-cli.js +606 -21
  36. package/build/conductor/recovery-operations.js +306 -7
  37. package/build/conductor/repair-contract.js +47 -2
  38. package/build/conductor/repository-workflow-remedy.js +8 -6
  39. package/build/conductor/stored-merge-credential.js +76 -0
  40. package/build/conductor/stored-merge-token-probe.js +28 -0
  41. package/build/conductor/temporary-plan.js +50 -0
  42. package/build/conductor/wait-run-cli.js +759 -0
  43. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  44. package/build/conductor-bin.js +2 -2
  45. package/build/conductor-claude-hook-bin.js +2 -2
  46. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  47. package/build/conductor-removed-stub-bin.js +2 -5
  48. package/build/connect-github-api.js +33 -14
  49. package/build/credential-store.js +356 -13
  50. package/build/docs.generated.js +27 -14
  51. package/build/doctor.js +203 -38
  52. package/build/drive-epic-help.js +212 -0
  53. package/build/drive-epic.js +1840 -373
  54. package/build/epic-ci-workflow-drift.js +77 -0
  55. package/build/epic-ci-workflow-install.js +183 -0
  56. package/build/epic-ci-workflow.js +157 -0
  57. package/build/epic-contract-context.js +326 -0
  58. package/build/epic-implementer-bin.js +131 -120
  59. package/build/epic-implementer-claude-hook-bin.js +2 -2
  60. package/build/escalation-reachability.js +134 -0
  61. package/build/executor/cleanup-report.js +99 -0
  62. package/build/executor/cli.js +12 -1
  63. package/build/executor/completion-contract.js +68 -11
  64. package/build/executor/completion-supervision.js +99 -0
  65. package/build/executor/delivery-outcome.js +179 -0
  66. package/build/executor/deps.js +3 -0
  67. package/build/executor/env.js +71 -0
  68. package/build/executor/failure-reason-codes.js +126 -0
  69. package/build/executor/heartbeat.js +38 -0
  70. package/build/executor/http-client.js +115 -4
  71. package/build/executor/integration-job.js +298 -5
  72. package/build/executor/job-errors.js +36 -6
  73. package/build/executor/job-runner.js +720 -36
  74. package/build/executor/likely-tests.js +36 -2
  75. package/build/executor/merge-job.js +145 -2
  76. package/build/executor/observation.js +73 -4
  77. package/build/executor/permissions.js +193 -97
  78. package/build/executor/preflight.js +14 -3
  79. package/build/executor/process-containment.js +28 -2
  80. package/build/executor/prompt-spec.js +50 -5
  81. package/build/executor/rebase-finalization.js +38 -3
  82. package/build/executor/reconcile-overlap-job.js +35 -7
  83. package/build/executor/required-work-ledger.js +236 -81
  84. package/build/executor/resume-pre-spawn.js +38 -1
  85. package/build/executor/runner.js +91 -3
  86. package/build/executor/test-clock.js +8 -0
  87. package/build/executor/types.js +37 -2
  88. package/build/executor/worker-cli-launcher.js +259 -0
  89. package/build/executor/worker-command.js +41 -4
  90. package/build/executor/worker-database-target.js +91 -0
  91. package/build/executor/worker-database.js +908 -0
  92. package/build/executor/worker-finalization.js +106 -0
  93. package/build/executor/worker-guard-hook.js +203 -0
  94. package/build/executor/worker-resources.js +47 -0
  95. package/build/executor/worker-verification-guidance.js +15 -2
  96. package/build/executor/worktree.js +303 -15
  97. package/build/executor-launch.js +11 -1
  98. package/build/generated-assets-doctor.js +9 -1
  99. package/build/generated-assets-health.js +13 -4
  100. package/build/generated-assets-legacy.js +39 -0
  101. package/build/generated-assets-paths.js +12 -1
  102. package/build/generated-assets-repair.js +4 -1
  103. package/build/generated-assets-test-support.js +12 -0
  104. package/build/github-remote.js +33 -0
  105. package/build/github-sign-in.js +62 -0
  106. package/build/index.js +1214 -465
  107. package/build/init.js +39 -1
  108. package/build/install-bridge-conductor-gate.js +78 -0
  109. package/build/install-bridge-conductor-setup.js +413 -0
  110. package/build/install-bridge-conductor.js +1226 -267
  111. package/build/install-bridge.js +629 -350
  112. package/build/launcher-config-inspection.js +184 -19
  113. package/build/mcp-host-targets.js +4 -0
  114. package/build/mcp-identity.js +7 -0
  115. package/build/mcp-install-state.js +8 -2
  116. package/build/merge-pull-request.js +6 -0
  117. package/build/merge-workflow-install.js +763 -345
  118. package/build/phase-result-artifacts.js +75 -20
  119. package/build/pipeline-orchestrator.js +247 -4
  120. package/build/pipelines.generated.js +35 -35
  121. package/build/plan-discovery.js +15 -2
  122. package/build/plan-execution-ledger.js +306 -17
  123. package/build/plan-phase-routing.js +48 -13
  124. package/build/plane/cli.js +105 -51
  125. package/build/plane/defaults.js +3 -0
  126. package/build/plane/plane-freshness.js +294 -0
  127. package/build/plane/schema-compatibility.js +137 -0
  128. package/build/plane/shutdown-deps.js +38 -0
  129. package/build/plane/shutdown.js +83 -8
  130. package/build/plane/terminal-run-cleanup.js +303 -0
  131. package/build/printed-remedy-commands.js +26 -4
  132. package/build/readiness-check.js +6 -2
  133. package/build/readiness-prerequisites.generated.js +532 -0
  134. package/build/readme.generated.js +1 -1
  135. package/build/session-upgrade-notice.js +86 -0
  136. package/build/setup-epic.js +400 -30
  137. package/build/shared-resources.generated.js +5 -0
  138. package/build/start-tickets.js +43 -15
  139. package/build/test-command-detection.js +247 -0
  140. package/build/test-command-validation.js +73 -0
  141. package/build/testing-config-client.js +150 -0
  142. package/build/ticket-delivery-artifacts.js +258 -0
  143. package/build/ticket-delivery-enrollment.js +306 -0
  144. package/build/ticket-delivery.js +205 -0
  145. package/build/ticket-review-summary.js +14 -0
  146. package/build/tool-call-advisory.js +162 -0
  147. package/build/tool-surface-gating.js +28 -89
  148. package/build/update-check.js +97 -16
  149. package/build/update-status.js +61 -71
  150. package/build/upgrade-advice.js +15 -11
  151. package/build/upgrade-cli.js +360 -20
  152. package/build/v2-readiness-requirements.js +552 -0
  153. package/build/version.generated.js +3 -3
  154. package/build/worker-guard-hook-bin.js +2 -2
  155. package/build/worktree-core.js +22 -7
  156. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  157. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  158. package/docs/EPIC-IMPLEMENTER.md +104 -63
  159. package/docs/epic-implementer/getting-started.md +131 -0
  160. package/docs/epic-implementer/self-hosting.md +133 -0
  161. package/package.json +3 -3
  162. package/pipelines/idea-to-ticket.json +1 -1
  163. package/pipelines/implement-ticket.json +8 -8
  164. package/pipelines/review-ticket.json +2 -2
  165. package/design-assets/favicon/favicon-32x32.png +0 -0
  166. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -0,0 +1,759 @@
1
+ /**
2
+ * `epic-implementer wait-run` — a read-only, bounded wait on an epic run's
3
+ * observable state (BAPI-1469).
4
+ *
5
+ * WHY THIS EXISTS. A `/drive-epic-run` supervisor between passes has nothing to
6
+ * do until the run changes, and it used to fill that time by re-reading the
7
+ * full explain-run projection, or by inventing its own watcher. Both burn the
8
+ * session's context on output that has not changed. This command waits instead:
9
+ * it reads the run's state through the existing authenticated state route,
10
+ * reduces it to a small stable projection, and returns as soon as that
11
+ * projection changes, the run finishes, or the bounded window closes.
12
+ *
13
+ * WHAT IT IS NOT. It is not a stop path, a control verb, or an MCP tool. It
14
+ * performs exactly one kind of request — `GET /jira/epic-runs/runs/{id}/state`
15
+ * through {@link fetchEpicRunState} — and writes nothing anywhere. It never opens
16
+ * a database, never reads a database connection string, and never reads
17
+ * credentials itself: access comes only from
18
+ * {@link resolveConductorBridgeApiAccess}, the shared resolver every
19
+ * credentialed conductor command uses.
20
+ *
21
+ * THE TOKEN. Every successful result carries a `v1:` token: a short hash of the
22
+ * stable projection. A caller passes it back as `--since` on the next call, with
23
+ * the same run and the same `--ticket-key` selector. A token from a different
24
+ * run or selector is simply a different value — it reports `changed`, never an
25
+ * error. The token is not an event cursor: a `--since` mismatch says only that
26
+ * something changed since that token, never what, so that result carries no
27
+ * field history.
28
+ *
29
+ * THE WAIT. The window is governed entirely by the shared {@link boundedWait}
30
+ * primitive (BAPI-1104): its immediate first probe, its 15 s / 30 s polling
31
+ * curve, its per-probe cap, and its three-consecutive-failure transient rule.
32
+ * This module adds no deadline logic of its own.
33
+ *
34
+ * OUTPUT. A successful result is ONE final stdout write (one JSON line with
35
+ * `--json`, or one status line plus at most 20 change lines) and exits 0. A
36
+ * failure is exactly one bounded stderr line, no stdout, and exits 1. No
37
+ * failure line ever carries exception text, a response body, a header, a URL,
38
+ * or an access object.
39
+ */
40
+ import { createHash } from "node:crypto";
41
+ import { boundedWait as defaultBoundedWait, } from "../bounded-wait.js";
42
+ import { ConductorBridgeApiError, fetchEpicRunState, resolveConductorBridgeApiAccess, } from "./bridge-api-client.js";
43
+ import { ConductorValidationError } from "./errors.js";
44
+ import { writeJsonDocument } from "./json-output.js";
45
+ // ---------------------------------------------------------------------------
46
+ // Constants
47
+ // ---------------------------------------------------------------------------
48
+ /** Default wait window, in seconds. Leaves room under a 120 s host timeout. */
49
+ export const WAIT_RUN_DEFAULT_TIMEOUT_SECONDS = 110;
50
+ /** Largest accepted window, in seconds — the bounded-wait primitive's own ceiling. */
51
+ export const WAIT_RUN_MAX_TIMEOUT_SECONDS = 240;
52
+ /** At most this many change lines are rendered per result. */
53
+ export const WAIT_RUN_MAX_CHANGES = 20;
54
+ /** BAPI-1532 — at most this many tickets are listed in a since-mismatch `current` block. */
55
+ export const WAIT_RUN_MAX_CURRENT_TICKETS = 20;
56
+ /** BAPI-1532 — a listed park reason is capped at this length. */
57
+ export const WAIT_RUN_MAX_CURRENT_REASON_CHARS = 120;
58
+ /** Every rendered string value, identifier, and field segment is capped at this length. */
59
+ export const WAIT_RUN_MAX_DISPLAY_CHARS = 80;
60
+ /** Hard cap on the single stderr failure line. */
61
+ const WAIT_RUN_MAX_ERROR_CHARS = 200;
62
+ /** The token's exact shape: a version prefix and 16 lowercase hex characters. */
63
+ export const WAIT_RUN_TOKEN_PATTERN = /^v1:[0-9a-f]{16}$/;
64
+ /** Run statuses after which nothing more will happen. */
65
+ const TERMINAL_RUN_STATUSES = new Set(["done", "abandoned"]);
66
+ /** Fixed fallback wording for any exception this command did not expect. */
67
+ const UNEXPECTED_FAILURE_MESSAGE = "wait-run failed unexpectedly; no run state was reported.";
68
+ // ---------------------------------------------------------------------------
69
+ // Argument parsing
70
+ // ---------------------------------------------------------------------------
71
+ const WAIT_RUN_VALUE_FLAGS = new Set([
72
+ "--epic-run-id",
73
+ "--ticket-key",
74
+ "--since",
75
+ "--timeout",
76
+ ]);
77
+ const WAIT_RUN_BOOL_FLAGS = new Set(["--json", "--help"]);
78
+ /**
79
+ * Parse a `--timeout` spelling into the effective window in seconds.
80
+ *
81
+ * Only a complete positive decimal integer is accepted. Values above the
82
+ * ceiling clamp to it; an arbitrarily long spelling clamps rather than
83
+ * overflowing, because its length alone proves it is above the ceiling.
84
+ */
85
+ function parseTimeoutSeconds(raw) {
86
+ if (!/^[0-9]+$/.test(raw) || /^0+$/.test(raw)) {
87
+ throw new ConductorValidationError('Flag "--timeout" must be a positive whole number of seconds.');
88
+ }
89
+ const digits = raw.replace(/^0+/, "");
90
+ if (digits.length > 6)
91
+ return WAIT_RUN_MAX_TIMEOUT_SECONDS;
92
+ return Math.min(Number(digits), WAIT_RUN_MAX_TIMEOUT_SECONDS);
93
+ }
94
+ /**
95
+ * Parse `wait-run` flags. Every validation message is fixed text: no argv
96
+ * content is ever interpolated, so a hostile or secret-bearing argument cannot
97
+ * reach stderr.
98
+ */
99
+ export function parseWaitRunArgs(argv) {
100
+ const values = new Map();
101
+ let json = false;
102
+ for (let i = 0; i < argv.length; i += 1) {
103
+ const token = argv[i];
104
+ if (token === "-h" || token === "--help") {
105
+ return {
106
+ help: true,
107
+ epicRunId: "",
108
+ timeoutSeconds: WAIT_RUN_DEFAULT_TIMEOUT_SECONDS,
109
+ json: false,
110
+ };
111
+ }
112
+ if (!token.startsWith("--")) {
113
+ throw new ConductorValidationError("wait-run takes no positional arguments.");
114
+ }
115
+ const eq = token.indexOf("=");
116
+ const name = eq >= 0 ? token.slice(0, eq) : token;
117
+ if (WAIT_RUN_BOOL_FLAGS.has(name)) {
118
+ if (eq >= 0) {
119
+ throw new ConductorValidationError("A wait-run boolean flag does not take a value.");
120
+ }
121
+ json = true;
122
+ continue;
123
+ }
124
+ if (!WAIT_RUN_VALUE_FLAGS.has(name)) {
125
+ throw new ConductorValidationError('Unknown wait-run option. Run "epic-implementer wait-run --help" for usage.');
126
+ }
127
+ let value;
128
+ if (eq >= 0) {
129
+ value = token.slice(eq + 1);
130
+ }
131
+ else {
132
+ const next = argv[i + 1];
133
+ if (next === undefined) {
134
+ throw new ConductorValidationError(`Flag "${name}" requires a value.`);
135
+ }
136
+ value = next;
137
+ i += 1;
138
+ }
139
+ values.set(name, value);
140
+ }
141
+ const epicRunId = values.get("--epic-run-id");
142
+ if (epicRunId === undefined || epicRunId.trim().length === 0) {
143
+ throw new ConductorValidationError('Flag "--epic-run-id" is required for wait-run and must be non-empty.');
144
+ }
145
+ const parsed = {
146
+ help: false,
147
+ epicRunId: epicRunId.trim(),
148
+ timeoutSeconds: WAIT_RUN_DEFAULT_TIMEOUT_SECONDS,
149
+ json,
150
+ };
151
+ const ticketKey = values.get("--ticket-key");
152
+ if (ticketKey !== undefined) {
153
+ if (ticketKey.trim().length === 0) {
154
+ throw new ConductorValidationError('Flag "--ticket-key" must be non-empty when supplied.');
155
+ }
156
+ parsed.ticketKey = ticketKey.trim();
157
+ }
158
+ const since = values.get("--since");
159
+ if (since !== undefined) {
160
+ if (!WAIT_RUN_TOKEN_PATTERN.test(since)) {
161
+ throw new ConductorValidationError('Flag "--since" must be a token from a previous wait-run result (v1: plus 16 hex characters).');
162
+ }
163
+ parsed.since = since;
164
+ }
165
+ const timeout = values.get("--timeout");
166
+ if (timeout !== undefined) {
167
+ parsed.timeoutSeconds = parseTimeoutSeconds(timeout);
168
+ }
169
+ return parsed;
170
+ }
171
+ /** Command-specific help. Printing it resolves no credentials and makes no request. */
172
+ export function getWaitRunUsage() {
173
+ return [
174
+ "Usage: epic-implementer wait-run --epic-run-id <id> [--ticket-key <key>] [--since <token>] [--timeout <seconds>] [--json]",
175
+ "",
176
+ "Read-only. Wait until an epic run's observable state changes, the run finishes,",
177
+ "or the window closes. Reads only the run's state route; changes nothing.",
178
+ "",
179
+ "Options:",
180
+ " --epic-run-id <id> Run id or epic key (required)",
181
+ " --ticket-key <key> Watch one ticket (plus run-level fields) instead of the whole run",
182
+ " --since <token> The token from the previous result. Reuse it only with the same",
183
+ " run and the same --ticket-key; a mismatch reports changed with the",
184
+ " bounded Current state (up to 20 tickets, parked first), not a diff",
185
+ ` --timeout <seconds> Wait window (default ${WAIT_RUN_DEFAULT_TIMEOUT_SECONDS}; values above ${WAIT_RUN_MAX_TIMEOUT_SECONDS} are clamped to ${WAIT_RUN_MAX_TIMEOUT_SECONDS})`,
186
+ " --json Print one JSON line",
187
+ "",
188
+ "Outcomes (all exit 0): changed, terminal (run done or abandoned), pending",
189
+ "(WAIT_WINDOW_ELAPSED or UPSTREAM_TRANSIENT — run wait-run again).",
190
+ "Failures exit 1 with one line on stderr and nothing on stdout.",
191
+ "",
192
+ "Example:",
193
+ " epic-implementer wait-run --epic-run-id BAPI-1425 --since v1:0123456789abcdef",
194
+ ].join("\n");
195
+ }
196
+ // ---------------------------------------------------------------------------
197
+ // Snapshot projection and token
198
+ // ---------------------------------------------------------------------------
199
+ /** A body the snapshot builder cannot read safely. */
200
+ class WaitRunMalformedError extends Error {
201
+ constructor() {
202
+ super("malformed run-state response");
203
+ this.name = "WaitRunMalformedError";
204
+ }
205
+ }
206
+ /** `--ticket-key` names no ticket in the resolved run. */
207
+ class WaitRunSelectorError extends Error {
208
+ constructor() {
209
+ super("ticket selector not found");
210
+ this.name = "WaitRunSelectorError";
211
+ }
212
+ }
213
+ function isRecord(value) {
214
+ return typeof value === "object" && value !== null && !Array.isArray(value);
215
+ }
216
+ /** Absent and explicit `null` are the same observation; both become `null`. */
217
+ function orNull(value) {
218
+ return value === undefined ? null : value;
219
+ }
220
+ /** An optional field that, when present, must be a primitive the diff can render. */
221
+ function optionalPrimitive(value) {
222
+ if (value === undefined || value === null)
223
+ return null;
224
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
225
+ return value;
226
+ }
227
+ throw new WaitRunMalformedError();
228
+ }
229
+ /**
230
+ * Recursively drop every key ending in `_at`, including inside arrays. Those
231
+ * keys are clocks (`next_probe_at`, `observed_at`, ...) that move without the
232
+ * run moving; hashing them would turn every reconciler tick into a "change".
233
+ */
234
+ export function stripTimestampKeys(value) {
235
+ if (Array.isArray(value))
236
+ return value.map(stripTimestampKeys);
237
+ if (isRecord(value)) {
238
+ const out = {};
239
+ for (const [key, inner] of Object.entries(value)) {
240
+ if (key.endsWith("_at"))
241
+ continue;
242
+ out[key] = stripTimestampKeys(orNull(inner));
243
+ }
244
+ return out;
245
+ }
246
+ return value;
247
+ }
248
+ function compareStrings(a, b) {
249
+ return a < b ? -1 : a > b ? 1 : 0;
250
+ }
251
+ function buildGateSnapshot(raw) {
252
+ const gate = isRecord(raw) ? raw : {};
253
+ const observation = gate.last_observation;
254
+ const obs = isRecord(observation) ? observation : {};
255
+ const nested = isRecord(obs.observation) ? obs.observation : {};
256
+ return {
257
+ status: optionalPrimitive(gate.status),
258
+ attempts: optionalPrimitive(gate.attempts),
259
+ active_job_id: optionalPrimitive(gate.active_job_id),
260
+ last_observation: {
261
+ status: optionalPrimitive(obs.status),
262
+ reason: optionalPrimitive(obs.reason),
263
+ reason_code: optionalPrimitive(nested.reason_code ?? obs.reason_code),
264
+ },
265
+ };
266
+ }
267
+ function buildTicketSnapshot(raw) {
268
+ const gatesRaw = raw.gates;
269
+ if (gatesRaw !== undefined && gatesRaw !== null && !isRecord(gatesRaw)) {
270
+ throw new WaitRunMalformedError();
271
+ }
272
+ const gates = {};
273
+ if (isRecord(gatesRaw)) {
274
+ for (const name of Object.keys(gatesRaw).sort(compareStrings)) {
275
+ gates[name] = buildGateSnapshot(gatesRaw[name]);
276
+ }
277
+ }
278
+ return {
279
+ ticket_key: raw.ticket_key,
280
+ status: optionalPrimitive(raw.status),
281
+ needs_human_reason: optionalPrimitive(raw.needs_human_reason),
282
+ // Kept on purpose, unlike the stripped clocks: parking is itself progress.
283
+ parked_at: optionalPrimitive(raw.parked_at),
284
+ pr_number: optionalPrimitive(raw.pr_number),
285
+ expected_head_sha: optionalPrimitive(raw.expected_head_sha),
286
+ gates,
287
+ };
288
+ }
289
+ function buildJobSnapshot(raw) {
290
+ if (!isRecord(raw))
291
+ throw new WaitRunMalformedError();
292
+ const { id, ticket_key: ticketKey, job_type: jobType, status } = raw;
293
+ if (typeof id !== "number" || !Number.isFinite(id))
294
+ throw new WaitRunMalformedError();
295
+ if (typeof jobType !== "string" || typeof status !== "string")
296
+ throw new WaitRunMalformedError();
297
+ if (ticketKey !== undefined && ticketKey !== null && typeof ticketKey !== "string") {
298
+ throw new WaitRunMalformedError();
299
+ }
300
+ return { id, ticket_key: ticketKey ?? null, job_type: jobType, status };
301
+ }
302
+ /**
303
+ * Validate a state-route body and reduce it to the stable projection.
304
+ *
305
+ * Only the fields the projection consumes are validated: a required structure
306
+ * that is missing or mistyped throws {@link WaitRunMalformedError}, while an
307
+ * optional field that is simply absent becomes `null`. With `ticketKey`, the
308
+ * selector is checked against the run's COMPLETE ticket list first (throwing
309
+ * {@link WaitRunSelectorError} when absent), then tickets and jobs are filtered
310
+ * to it; run-level fields and the job-source state are always kept.
311
+ *
312
+ * `last_progress_at` is deliberately not projected. It advances only on
313
+ * progress today, but the token must not depend on that staying true — every
314
+ * field that IS progress is already projected directly.
315
+ */
316
+ export function buildWaitRunSnapshot(body, ticketKey) {
317
+ if (!isRecord(body))
318
+ throw new WaitRunMalformedError();
319
+ const epicRun = body.epic_run;
320
+ if (!isRecord(epicRun) || typeof epicRun.status !== "string") {
321
+ throw new WaitRunMalformedError();
322
+ }
323
+ if (typeof epicRun.current_plan_version !== "number")
324
+ throw new WaitRunMalformedError();
325
+ const repairRevision = orNull(epicRun.repair_revision);
326
+ if (repairRevision !== null && typeof repairRevision !== "number") {
327
+ throw new WaitRunMalformedError();
328
+ }
329
+ const runControl = orNull(epicRun.run_control);
330
+ if (runControl !== null && !isRecord(runControl))
331
+ throw new WaitRunMalformedError();
332
+ const ticketRows = body.ticket_statuses;
333
+ if (!Array.isArray(ticketRows))
334
+ throw new WaitRunMalformedError();
335
+ for (const row of ticketRows) {
336
+ if (!isRecord(row) || typeof row.ticket_key !== "string" || typeof row.status !== "string") {
337
+ throw new WaitRunMalformedError();
338
+ }
339
+ }
340
+ const rows = ticketRows;
341
+ if (ticketKey !== undefined && !rows.some((row) => row.ticket_key === ticketKey)) {
342
+ throw new WaitRunSelectorError();
343
+ }
344
+ let executorJobsState = null;
345
+ let jobs = [];
346
+ const block = body.executor_jobs;
347
+ if (block !== undefined && block !== null) {
348
+ if (!isRecord(block) || typeof block.state !== "string")
349
+ throw new WaitRunMalformedError();
350
+ executorJobsState = block.state;
351
+ const jobRows = orNull(block.jobs);
352
+ if (jobRows !== null) {
353
+ if (!Array.isArray(jobRows))
354
+ throw new WaitRunMalformedError();
355
+ jobs = jobRows.map(buildJobSnapshot);
356
+ }
357
+ }
358
+ const tickets = rows
359
+ .filter((row) => ticketKey === undefined || row.ticket_key === ticketKey)
360
+ .map(buildTicketSnapshot)
361
+ .sort((a, b) => compareStrings(a.ticket_key, b.ticket_key));
362
+ const selectedJobs = jobs
363
+ .filter((job) => ticketKey === undefined || job.ticket_key === ticketKey)
364
+ .sort((a, b) => a.id - b.id);
365
+ return {
366
+ run: {
367
+ status: epicRun.status,
368
+ current_plan_version: epicRun.current_plan_version,
369
+ repair_revision: repairRevision,
370
+ run_control: stripTimestampKeys(runControl),
371
+ },
372
+ executor_jobs_state: executorJobsState,
373
+ tickets,
374
+ jobs: selectedJobs,
375
+ };
376
+ }
377
+ /** JSON with object keys sorted at every depth, so key order never moves the hash. */
378
+ function canonicalJson(value) {
379
+ if (Array.isArray(value))
380
+ return `[${value.map(canonicalJson).join(",")}]`;
381
+ if (isRecord(value)) {
382
+ const keys = Object.keys(value).sort(compareStrings);
383
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
384
+ }
385
+ return JSON.stringify(value ?? null);
386
+ }
387
+ function boundedReason(value) {
388
+ if (typeof value !== "string")
389
+ return null;
390
+ // eslint-disable-next-line no-control-regex
391
+ const single = value.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
392
+ if (single === "")
393
+ return null;
394
+ return single.length > WAIT_RUN_MAX_CURRENT_REASON_CHARS
395
+ ? `${single.slice(0, WAIT_RUN_MAX_CURRENT_REASON_CHARS - 1)}…`
396
+ : single;
397
+ }
398
+ /**
399
+ * BAPI-1532 — project the snapshot already in hand onto the bounded `current`
400
+ * block of a since-mismatch result. No additional read is made. Parked
401
+ * (`needs_human`) tickets are listed first; each group keeps the snapshot's own
402
+ * `ticket_key` order, so two reads of unchanged state render identically.
403
+ */
404
+ export function buildWaitRunCurrent(snapshot) {
405
+ const project = (ticket) => ({
406
+ ticket_key: ticket.ticket_key,
407
+ status: typeof ticket.status === "string" ? ticket.status : "unknown",
408
+ needs_human_reason: boundedReason(ticket.needs_human_reason),
409
+ parked_at: typeof ticket.parked_at === "string" ? ticket.parked_at : null,
410
+ pr_number: typeof ticket.pr_number === "number" && Number.isSafeInteger(ticket.pr_number)
411
+ ? ticket.pr_number
412
+ : null,
413
+ });
414
+ const parked = snapshot.tickets.filter((t) => t.status === "needs_human");
415
+ const rest = snapshot.tickets.filter((t) => t.status !== "needs_human");
416
+ const ordered = [...parked, ...rest];
417
+ const tickets = ordered.slice(0, WAIT_RUN_MAX_CURRENT_TICKETS).map(project);
418
+ return {
419
+ tickets,
420
+ total_count: ordered.length,
421
+ returned_count: tickets.length,
422
+ truncated: ordered.length > tickets.length,
423
+ };
424
+ }
425
+ /** `v1:` plus the first 16 hex characters of the canonical projection's SHA-256. */
426
+ export function buildWaitRunToken(snapshot) {
427
+ const digest = createHash("sha256").update(canonicalJson(snapshot)).digest("hex");
428
+ return `v1:${digest.slice(0, 16)}`;
429
+ }
430
+ /** Bound a string for display: control characters removed, length capped. */
431
+ function displayString(value) {
432
+ // eslint-disable-next-line no-control-regex
433
+ const cleaned = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
434
+ return cleaned.length > WAIT_RUN_MAX_DISPLAY_CHARS
435
+ ? `${cleaned.slice(0, WAIT_RUN_MAX_DISPLAY_CHARS - 1)}…`
436
+ : cleaned;
437
+ }
438
+ function displayValue(value) {
439
+ if (value === null || value === undefined)
440
+ return null;
441
+ if (typeof value === "number" || typeof value === "boolean")
442
+ return value;
443
+ return displayString(String(value));
444
+ }
445
+ /** Flatten a nested value into primitive leaves under dotted paths. */
446
+ function flattenInto(out, ticketKey, path, value) {
447
+ if (Array.isArray(value)) {
448
+ value.forEach((inner, index) => flattenInto(out, ticketKey, `${path}.${index}`, inner));
449
+ return;
450
+ }
451
+ if (isRecord(value)) {
452
+ for (const key of Object.keys(value).sort(compareStrings)) {
453
+ flattenInto(out, ticketKey, `${path}.${key}`, value[key]);
454
+ }
455
+ return;
456
+ }
457
+ const leafKey = `${ticketKey ?? ""}\u0000${path}`;
458
+ out.set(leafKey, { ticketKey, field: path, value: (value ?? null) });
459
+ }
460
+ function flattenSnapshot(snapshot) {
461
+ const out = new Map();
462
+ flattenInto(out, null, "status", snapshot.run.status);
463
+ flattenInto(out, null, "current_plan_version", snapshot.run.current_plan_version);
464
+ flattenInto(out, null, "repair_revision", snapshot.run.repair_revision);
465
+ flattenInto(out, null, "run_control", snapshot.run.run_control);
466
+ flattenInto(out, null, "executor_jobs.state", snapshot.executor_jobs_state);
467
+ for (const ticket of snapshot.tickets) {
468
+ const { ticket_key: key, gates, ...fields } = ticket;
469
+ for (const [name, value] of Object.entries(fields))
470
+ flattenInto(out, key, name, value);
471
+ for (const [gate, state] of Object.entries(gates)) {
472
+ flattenInto(out, key, `gates.${gate}`, state);
473
+ }
474
+ }
475
+ for (const job of snapshot.jobs) {
476
+ flattenInto(out, job.ticket_key, `jobs.${job.id}.job_type`, job.job_type);
477
+ flattenInto(out, job.ticket_key, `jobs.${job.id}.status`, job.status);
478
+ }
479
+ return out;
480
+ }
481
+ function compareChanges(a, b) {
482
+ if (a.ticketKey !== b.ticketKey) {
483
+ if (a.ticketKey === null)
484
+ return -1;
485
+ if (b.ticketKey === null)
486
+ return 1;
487
+ return compareStrings(a.ticketKey, b.ticketKey);
488
+ }
489
+ return compareStrings(a.field, b.field);
490
+ }
491
+ /**
492
+ * Leaf-level changes between two normalized snapshots, ordered by ticket then
493
+ * field path and capped at {@link WAIT_RUN_MAX_CHANGES}.
494
+ *
495
+ * A leaf present on one side only is reported with `null` on the other, so an
496
+ * added or removed ticket or job never renders as a whole object. A removed
497
+ * job is attributed through the earlier snapshot and an added one through the
498
+ * later, so its ticket association survives either way.
499
+ */
500
+ export function diffWaitRunSnapshots(previous, current) {
501
+ const before = flattenSnapshot(previous);
502
+ const after = flattenSnapshot(current);
503
+ const changed = [];
504
+ const keys = new Set([...before.keys(), ...after.keys()]);
505
+ for (const key of keys) {
506
+ const old = before.get(key);
507
+ const next = after.get(key);
508
+ const from = old?.value ?? null;
509
+ const to = next?.value ?? null;
510
+ if (from === to)
511
+ continue;
512
+ const anchor = (next ?? old);
513
+ changed.push({ ...anchor, from, to });
514
+ }
515
+ changed.sort(compareChanges);
516
+ const kept = changed.slice(0, WAIT_RUN_MAX_CHANGES).map((leaf) => ({
517
+ ticket_key: leaf.ticketKey === null ? null : displayString(leaf.ticketKey),
518
+ field: displayString(leaf.field),
519
+ from: displayValue(leaf.from),
520
+ to: displayValue(leaf.to),
521
+ }));
522
+ const diff = { changes: kept };
523
+ if (changed.length > WAIT_RUN_MAX_CHANGES)
524
+ diff.truncated = true;
525
+ return diff;
526
+ }
527
+ function renderValue(value) {
528
+ return value === null ? "null" : String(value);
529
+ }
530
+ /** Render a successful result: one JSON line, or one status line plus change lines. */
531
+ export function renderWaitRunResult(result, json) {
532
+ if (json)
533
+ return JSON.stringify(result);
534
+ const runStatus = result.run_status === null ? "unknown" : displayString(result.run_status);
535
+ const token = result.token ?? "none";
536
+ const window = `window ${result.window_seconds}s`;
537
+ if (result.outcome === "pending") {
538
+ return `wait-run: pending ${result.reason} (run ${runStatus}, ${window}, token ${token})`;
539
+ }
540
+ if (result.outcome === "terminal") {
541
+ return `wait-run: terminal (run ${runStatus}, ${window}, token ${token})`;
542
+ }
543
+ if ("baseline" in result) {
544
+ // BAPI-1532: a labeled snapshot, stacked per ticket so a narrow terminal stays
545
+ // readable. It is the state NOW; what changed since the token is not reported.
546
+ const nil = "\u2014";
547
+ const lines = [
548
+ `wait-run: changed since the supplied token (run ${runStatus}, ${window}, token ${token})`,
549
+ "Current state (a snapshot, not a diff; what changed is not reported):",
550
+ ];
551
+ for (const ticket of result.current.tickets) {
552
+ lines.push(` ${displayString(ticket.ticket_key)}`, ` status: ${displayString(ticket.status)}`, ` reason: ${ticket.needs_human_reason === null ? nil : displayString(ticket.needs_human_reason)}`, ` parked: ${ticket.parked_at === null ? nil : displayString(ticket.parked_at)}`, ` pr: ${ticket.pr_number === null ? nil : `#${ticket.pr_number}`}`);
553
+ }
554
+ if (result.current.truncated) {
555
+ lines.push(` Showing ${result.current.returned_count} of ${result.current.total_count} tickets ` +
556
+ "(parked first); narrow the view with --ticket-key.");
557
+ }
558
+ return lines.join("\n");
559
+ }
560
+ const lines = [
561
+ `wait-run: changed (run ${runStatus}, ${window}, token ${token}` +
562
+ `${result.truncated ? `, first ${result.changes.length} changes shown` : ""})`,
563
+ ];
564
+ for (const change of result.changes) {
565
+ const subject = change.ticket_key ?? "run";
566
+ lines.push(` ${subject} ${change.field}: ${renderValue(change.from)} -> ${renderValue(change.to)}`);
567
+ }
568
+ return lines.join("\n");
569
+ }
570
+ // ---------------------------------------------------------------------------
571
+ // Probe classification
572
+ // ---------------------------------------------------------------------------
573
+ /** A read that should be retried by the bounded wait rather than reported. */
574
+ function isTransientReadFailure(err) {
575
+ if (!(err instanceof ConductorBridgeApiError))
576
+ return true;
577
+ if (err.kind === "network" || err.kind === "timeout" || err.kind === "server")
578
+ return true;
579
+ return err.kind === "http" && (err.status === 408 || err.status === 429);
580
+ }
581
+ /** Fixed operator wording for a non-retryable read failure. Only the status number is echoed. */
582
+ function describeTerminalReadFailure(err) {
583
+ if (err instanceof ConductorBridgeApiError) {
584
+ const status = typeof err.status === "number" ? ` (HTTP ${err.status})` : "";
585
+ if (err.kind === "unauthorized") {
586
+ return `wait-run: the Bridge API refused the run-state read${status}; check credentials and repository access.`;
587
+ }
588
+ if (err.kind === "invalid-input") {
589
+ return "wait-run: the run identifier could not be used for a run-state read.";
590
+ }
591
+ if (err.status === 404) {
592
+ return "wait-run: no epic run was found for --epic-run-id (HTTP 404).";
593
+ }
594
+ return `wait-run: the run-state read failed${status}.`;
595
+ }
596
+ return UNEXPECTED_FAILURE_MESSAGE;
597
+ }
598
+ /** Reject promptly when the bounded wait aborts this probe, so a hung read costs one probe. */
599
+ function untilAborted(work, signal) {
600
+ if (signal.aborted)
601
+ return Promise.reject(new Error("aborted"));
602
+ return new Promise((resolve, reject) => {
603
+ const onAbort = () => reject(new Error("aborted"));
604
+ signal.addEventListener("abort", onAbort, { once: true });
605
+ work.then((value) => {
606
+ signal.removeEventListener("abort", onAbort);
607
+ resolve(value);
608
+ }, (error) => {
609
+ signal.removeEventListener("abort", onAbort);
610
+ reject(error);
611
+ });
612
+ });
613
+ }
614
+ // ---------------------------------------------------------------------------
615
+ // Command
616
+ // ---------------------------------------------------------------------------
617
+ function boundedLine(text) {
618
+ // eslint-disable-next-line no-control-regex
619
+ const single = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
620
+ return single.length > WAIT_RUN_MAX_ERROR_CHARS
621
+ ? `${single.slice(0, WAIT_RUN_MAX_ERROR_CHARS - 1)}…`
622
+ : single;
623
+ }
624
+ /**
625
+ * Run `wait-run`. Returns a process exit code: 0 for every successful outcome
626
+ * (`changed`, `terminal`, `pending`), 1 for any failure. Never throws.
627
+ */
628
+ export async function runWaitRunCommand(argv, deps = {}) {
629
+ const writeStdout = deps.writeStdout ?? ((text) => writeJsonDocument(text));
630
+ const writeStderr = deps.writeStderr ?? ((line) => console.error(line));
631
+ const fail = (message) => {
632
+ writeStderr(`Error: ${boundedLine(message)}`);
633
+ return 1;
634
+ };
635
+ try {
636
+ let parsed;
637
+ try {
638
+ parsed = parseWaitRunArgs(argv);
639
+ }
640
+ catch (err) {
641
+ // Validation messages are fixed text; anything else gets the fallback.
642
+ return fail(err instanceof ConductorValidationError ? err.message : UNEXPECTED_FAILURE_MESSAGE);
643
+ }
644
+ if (parsed.help) {
645
+ if (await writeStdout(getWaitRunUsage()))
646
+ return 0;
647
+ return fail("wait-run: the help text could not be written to stdout.");
648
+ }
649
+ const resolveAccess = deps.resolveAccess ?? (() => resolveConductorBridgeApiAccess());
650
+ const accessResult = await resolveAccess();
651
+ if (!accessResult.ok) {
652
+ // The shared resolver's messages are fixed, secret-free text.
653
+ return fail(`wait-run: ${accessResult.error}`);
654
+ }
655
+ const access = accessResult.access;
656
+ const fetchState = deps.fetchState ?? ((a, id) => fetchEpicRunState(a, id));
657
+ const wait = deps.boundedWait ?? defaultBoundedWait;
658
+ const windowSeconds = parsed.timeoutSeconds;
659
+ let latestToken = parsed.since ?? null;
660
+ let latestRunStatus = null;
661
+ let baseline = null;
662
+ let readFailure = null;
663
+ const probe = async (signal) => {
664
+ let body;
665
+ try {
666
+ body = await untilAborted(fetchState(access, parsed.epicRunId), signal);
667
+ }
668
+ catch (err) {
669
+ // A transient failure is thrown back to the primitive, which counts it
670
+ // toward its three-in-a-row rule. Its text never reaches a result.
671
+ if (isTransientReadFailure(err))
672
+ throw err;
673
+ readFailure = describeTerminalReadFailure(err);
674
+ return { kind: "terminal" };
675
+ }
676
+ let snapshot;
677
+ try {
678
+ snapshot = buildWaitRunSnapshot(body, parsed.ticketKey);
679
+ }
680
+ catch (err) {
681
+ readFailure =
682
+ err instanceof WaitRunSelectorError
683
+ ? "wait-run: --ticket-key does not name a ticket in this run."
684
+ : err instanceof WaitRunMalformedError
685
+ ? "wait-run: the run-state response was malformed."
686
+ : UNEXPECTED_FAILURE_MESSAGE;
687
+ return { kind: "terminal" };
688
+ }
689
+ const token = buildWaitRunToken(snapshot);
690
+ latestToken = token;
691
+ latestRunStatus = snapshot.run.status;
692
+ // A finished run outranks any token comparison: there is nothing left to wait for.
693
+ if (TERMINAL_RUN_STATUSES.has(snapshot.run.status)) {
694
+ const value = {
695
+ outcome: "terminal",
696
+ token,
697
+ window_seconds: windowSeconds,
698
+ run_status: snapshot.run.status,
699
+ };
700
+ return { kind: "observation", ready: true, value };
701
+ }
702
+ if (baseline === null) {
703
+ if (parsed.since !== undefined && parsed.since !== token) {
704
+ const value = {
705
+ outcome: "changed",
706
+ token,
707
+ baseline: "since",
708
+ window_seconds: windowSeconds,
709
+ run_status: snapshot.run.status,
710
+ current: buildWaitRunCurrent(snapshot),
711
+ };
712
+ return { kind: "observation", ready: true, value };
713
+ }
714
+ baseline = { snapshot, token };
715
+ return { kind: "observation", ready: false };
716
+ }
717
+ if (token !== baseline.token) {
718
+ const diff = diffWaitRunSnapshots(baseline.snapshot, snapshot);
719
+ const value = {
720
+ outcome: "changed",
721
+ token,
722
+ window_seconds: windowSeconds,
723
+ run_status: snapshot.run.status,
724
+ changes: diff.changes,
725
+ ...(diff.truncated ? { truncated: true } : {}),
726
+ };
727
+ return { kind: "observation", ready: true, value };
728
+ }
729
+ return { kind: "observation", ready: false };
730
+ };
731
+ const outcome = await wait({
732
+ budgetMs: windowSeconds * 1000,
733
+ probe,
734
+ ...(deps.waitDeps ? { deps: deps.waitDeps } : {}),
735
+ });
736
+ let result;
737
+ if (outcome.state === "ready") {
738
+ result = outcome.result;
739
+ }
740
+ else if (outcome.state === "pending") {
741
+ result = {
742
+ outcome: "pending",
743
+ reason: outcome.reason,
744
+ token: latestToken,
745
+ window_seconds: windowSeconds,
746
+ run_status: latestRunStatus,
747
+ };
748
+ }
749
+ else {
750
+ return fail(readFailure ?? UNEXPECTED_FAILURE_MESSAGE);
751
+ }
752
+ if (await writeStdout(renderWaitRunResult(result, parsed.json)))
753
+ return 0;
754
+ return fail("wait-run: the result could not be written to stdout.");
755
+ }
756
+ catch {
757
+ return fail(UNEXPECTED_FAILURE_MESSAGE);
758
+ }
759
+ }