@bridge_gpt/mcp-server 0.2.14 → 0.2.18

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 (51) hide show
  1. package/README.md +2 -2
  2. package/build/agents.generated.js +19 -1
  3. package/build/commands.generated.js +6 -5
  4. package/build/conductor/bridge-api-client.js +191 -11
  5. package/build/conductor/claude-hook.js +22 -4
  6. package/build/conductor/cli.js +11 -13
  7. package/build/conductor/done-gate.js +5 -0
  8. package/build/conductor/epic-reconcile.js +62 -13
  9. package/build/conductor/epic-runtime.js +447 -35
  10. package/build/conductor/epic-state.js +517 -63
  11. package/build/conductor/errors.js +41 -0
  12. package/build/conductor/event-accessors.js +234 -0
  13. package/build/conductor/file-scope-guard.js +201 -0
  14. package/build/conductor/github-mergeability.js +85 -0
  15. package/build/conductor/local-merge.js +47 -1
  16. package/build/conductor/merge-identity.js +41 -0
  17. package/build/conductor/merge-ledger.js +13 -68
  18. package/build/conductor/plan.js +12 -2
  19. package/build/conductor/pr-discovery.js +11 -1
  20. package/build/conductor/supervisor-config.js +4 -39
  21. package/build/conductor/supervisor-escalation.js +10 -26
  22. package/build/conductor/supervisor-ledger.js +5 -12
  23. package/build/conductor/supervisor-message-relay.js +2 -5
  24. package/build/conductor/supervisor-notification.js +1 -1
  25. package/build/conductor/supervisor-runtime.js +12 -54
  26. package/build/conductor/supervisor-state.js +4 -18
  27. package/build/conductor/supervisor-types.js +2 -2
  28. package/build/conductor/taxonomy.js +4 -0
  29. package/build/conductor-bin.js +2333 -666
  30. package/build/conductor-claude-hook-bin.js +4 -2
  31. package/build/doctor.js +32 -0
  32. package/build/index.js +9985 -7579
  33. package/build/install-bridge.js +25 -8
  34. package/build/install-doctor.js +387 -0
  35. package/build/pipelines.generated.js +30 -5
  36. package/build/readme.generated.js +1 -1
  37. package/build/regression-check.js +872 -0
  38. package/build/review-tickets.js +175 -21
  39. package/build/sfcc/permissions.js +13 -1
  40. package/build/sfcc/reads-custom-object-def.js +56 -39
  41. package/build/sfcc/register.js +1 -1
  42. package/build/sfcc/tool-wrapper.js +5 -1
  43. package/build/start-tickets-conductor.js +22 -6
  44. package/build/start-tickets-prereqs.js +73 -0
  45. package/build/start-tickets.js +122 -22
  46. package/build/version.generated.js +1 -1
  47. package/package.json +5 -5
  48. package/pipelines/review-ticket.json +24 -2
  49. package/public/css/main.min.css +3272 -1
  50. package/public/css/main.min.css.map +1 -1
  51. package/smoke-test/SMOKE-TEST.md +4 -2
@@ -74,16 +74,174 @@ export function buildConductorJiraUrl(baseUrl, apiPath, params = {}) {
74
74
  }
75
75
  return url.toString();
76
76
  }
77
- /** Sanitized HTTP error for conductor Bridge API calls — never leaks secrets. */
77
+ const CONDUCTOR_BRIDGE_API_ERROR_KINDS = [
78
+ "invalid-input",
79
+ "network",
80
+ "timeout",
81
+ "unauthorized",
82
+ "server",
83
+ "http",
84
+ ];
85
+ /** Max length of the sanitized body preview embedded in error diagnostics. */
86
+ const CONDUCTOR_ERROR_PREVIEW_MAX = 200;
87
+ /**
88
+ * Redact obvious secret-shaped tokens from a diagnostic preview string so a
89
+ * backend message that happens to echo a token/header never reaches a log line.
90
+ */
91
+ function redactErrorPreview(text) {
92
+ return text
93
+ .replace(/sk-[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
94
+ .replace(/(Bearer|X-API-Key|api[_-]?key)\b\s*[:=]?\s*\S+/gi, "$1 [REDACTED]");
95
+ }
96
+ /** Collapse whitespace, redact secrets, and bound a string to the preview cap. */
97
+ function boundedErrorPreview(text) {
98
+ const redacted = redactErrorPreview(text).replace(/\s+/g, " ").trim();
99
+ return redacted.length > CONDUCTOR_ERROR_PREVIEW_MAX
100
+ ? `${redacted.slice(0, CONDUCTOR_ERROR_PREVIEW_MAX)}…`
101
+ : redacted;
102
+ }
103
+ /**
104
+ * Extract sanitized diagnostics from a parsed backend error body. Recognizes the
105
+ * FastAPI/backend shapes `{ detail: { error_code, message } }`,
106
+ * `{ detail: "..." }`, and `{ error_code, message }`; any other shape yields a
107
+ * bounded stringified preview. Never throws and never returns raw/unbounded text.
108
+ */
109
+ export function extractSanitizedErrorDiagnostics(body) {
110
+ if (typeof body === "string") {
111
+ const trimmed = body.trim();
112
+ return trimmed ? { bodyPreview: boundedErrorPreview(trimmed) } : {};
113
+ }
114
+ if (!body || typeof body !== "object") {
115
+ return {};
116
+ }
117
+ const record = body;
118
+ const detail = record["detail"];
119
+ let errorCode;
120
+ let message;
121
+ if (detail && typeof detail === "object") {
122
+ const d = detail;
123
+ if (typeof d["error_code"] === "string")
124
+ errorCode = d["error_code"];
125
+ if (typeof d["message"] === "string")
126
+ message = d["message"];
127
+ }
128
+ else if (typeof detail === "string") {
129
+ message = detail;
130
+ }
131
+ if (!errorCode && typeof record["error_code"] === "string") {
132
+ errorCode = record["error_code"];
133
+ }
134
+ if (!message && typeof record["message"] === "string") {
135
+ message = record["message"];
136
+ }
137
+ const diagnostics = {};
138
+ if (errorCode)
139
+ diagnostics.errorCode = boundedErrorPreview(errorCode);
140
+ if (message)
141
+ diagnostics.bodyPreview = boundedErrorPreview(message);
142
+ return diagnostics;
143
+ }
144
+ /** Redact exact secret substrings (e.g. the API key from the request headers). */
145
+ function redactDiagnosticValues(diagnostics, secrets) {
146
+ const scrub = (text) => {
147
+ let out = text;
148
+ for (const secret of secrets) {
149
+ if (secret && secret.length >= 4)
150
+ out = out.split(secret).join("[REDACTED]");
151
+ }
152
+ return out;
153
+ };
154
+ const out = {};
155
+ if (diagnostics.errorCode)
156
+ out.errorCode = scrub(diagnostics.errorCode);
157
+ if (diagnostics.bodyPreview)
158
+ out.bodyPreview = scrub(diagnostics.bodyPreview);
159
+ return out;
160
+ }
161
+ /**
162
+ * Best-effort read of a non-2xx response body into sanitized diagnostics. Never
163
+ * throws — a missing/invalid/non-JSON body simply yields `{}`. Any auth-header
164
+ * values in `headers` (e.g. `X-API-Key`) are additionally redacted from the
165
+ * preview so a body that echoes the credential can never surface it.
166
+ */
167
+ async function readSanitizedErrorDiagnostics(resp, headers = {}) {
168
+ try {
169
+ const diagnostics = extractSanitizedErrorDiagnostics(await resp.json());
170
+ const secrets = Object.entries(headers)
171
+ .filter(([k]) => /key|authorization|token/i.test(k))
172
+ .map(([, v]) => v);
173
+ return redactDiagnosticValues(diagnostics, secrets);
174
+ }
175
+ catch {
176
+ return {};
177
+ }
178
+ }
179
+ /**
180
+ * Sanitized HTTP error for conductor Bridge API calls — never leaks secrets.
181
+ *
182
+ * Construction is backward-compatible in two forms:
183
+ * - `new ConductorBridgeApiError(kind, status?, diagnostics?)` — the canonical
184
+ * form; builds a message from the coarse kind plus optional HTTP status,
185
+ * backend `error_code`, and a bounded secret-redacted message preview.
186
+ * - `new ConductorBridgeApiError("some legacy message")` — a bare message
187
+ * string (not one of the known kinds) is preserved verbatim as the error
188
+ * message, with `kind` defaulting to `"http"` and no diagnostic fields set.
189
+ */
78
190
  export class ConductorBridgeApiError extends Error {
79
191
  kind;
80
192
  status;
81
- constructor(kind, status) {
82
- super(`Conductor Bridge API request failed (${kind}${typeof status === "number" ? `, status ${status}` : ""})`);
193
+ errorCode;
194
+ bodyPreview;
195
+ constructor(kindOrMessage, status, diagnostics) {
196
+ const isKnownKind = CONDUCTOR_BRIDGE_API_ERROR_KINDS.includes(kindOrMessage);
197
+ const errorCode = diagnostics?.errorCode;
198
+ const bodyPreview = diagnostics?.bodyPreview;
199
+ if (isKnownKind) {
200
+ const parts = [
201
+ `Conductor Bridge API request failed (${kindOrMessage}${typeof status === "number" ? `, status ${status}` : ""})`,
202
+ ];
203
+ if (errorCode)
204
+ parts.push(`code=${errorCode}`);
205
+ if (bodyPreview)
206
+ parts.push(bodyPreview);
207
+ super(parts.join(": "));
208
+ }
209
+ else {
210
+ // Legacy/backward-compatible bare-message construction.
211
+ super(kindOrMessage);
212
+ }
83
213
  this.name = "ConductorBridgeApiError";
84
- this.kind = kind;
85
- this.status = status;
214
+ this.kind = (isKnownKind ? kindOrMessage : "http");
215
+ if (typeof status === "number")
216
+ this.status = status;
217
+ if (errorCode)
218
+ this.errorCode = errorCode;
219
+ if (bodyPreview)
220
+ this.bodyPreview = bodyPreview;
221
+ }
222
+ }
223
+ /**
224
+ * Build a bounded, secret-free diagnostic string from an unknown thrown error
225
+ * for tick warnings/logs. For a {@link ConductorBridgeApiError} it surfaces the
226
+ * coarse kind, HTTP status, backend error code, and sanitized message preview
227
+ * (never a stack, raw body, or secret). For any other Error it returns the
228
+ * constructor name; non-Errors fall back to `fallback`.
229
+ */
230
+ export function safeDiagnosticMessage(err, fallback) {
231
+ if (err instanceof ConductorBridgeApiError) {
232
+ const parts = [`kind=${err.kind}`];
233
+ if (typeof err.status === "number")
234
+ parts.push(`status=${err.status}`);
235
+ if (err.errorCode)
236
+ parts.push(`code=${err.errorCode}`);
237
+ if (err.bodyPreview)
238
+ parts.push(err.bodyPreview);
239
+ return parts.join(" ");
240
+ }
241
+ if (err instanceof Error) {
242
+ return err.constructor.name;
86
243
  }
244
+ return fallback;
87
245
  }
88
246
  /** GET auth headers. The API key travels ONLY in a header, never in the URL. */
89
247
  function conductorGetHeaders(access) {
@@ -108,13 +266,14 @@ export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fet
108
266
  throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
109
267
  }
110
268
  if (!resp.ok) {
269
+ const diagnostics = await readSanitizedErrorDiagnostics(resp, headers);
111
270
  if (resp.status === 401 || resp.status === 403) {
112
- throw new ConductorBridgeApiError("unauthorized", resp.status);
271
+ throw new ConductorBridgeApiError("unauthorized", resp.status, diagnostics);
113
272
  }
114
273
  if (resp.status >= 500) {
115
- throw new ConductorBridgeApiError("server", resp.status);
274
+ throw new ConductorBridgeApiError("server", resp.status, diagnostics);
116
275
  }
117
- throw new ConductorBridgeApiError("http", resp.status);
276
+ throw new ConductorBridgeApiError("http", resp.status, diagnostics);
118
277
  }
119
278
  try {
120
279
  return await resp.json();
@@ -252,13 +411,14 @@ async function fetchConductorJsonWithMethodAndTimeout(method, url, headers, body
252
411
  throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
253
412
  }
254
413
  if (!resp.ok) {
414
+ const diagnostics = await readSanitizedErrorDiagnostics(resp, headers);
255
415
  if (resp.status === 401 || resp.status === 403) {
256
- throw new ConductorBridgeApiError("unauthorized", resp.status);
416
+ throw new ConductorBridgeApiError("unauthorized", resp.status, diagnostics);
257
417
  }
258
418
  if (resp.status >= 500) {
259
- throw new ConductorBridgeApiError("server", resp.status);
419
+ throw new ConductorBridgeApiError("server", resp.status, diagnostics);
260
420
  }
261
- throw new ConductorBridgeApiError("http", resp.status);
421
+ throw new ConductorBridgeApiError("http", resp.status, diagnostics);
262
422
  }
263
423
  try {
264
424
  return await resp.json();
@@ -508,6 +668,26 @@ export async function fetchActiveEpicRuns(access, fetchImpl = globalThis.fetch)
508
668
  }
509
669
  return [];
510
670
  }
671
+ /**
672
+ * PATCH `/jira/epic-runs/runs/{identifier}` to transition an epic run's
673
+ * lifecycle status. Drives the same backend `update_epic_run` CAS path the
674
+ * manual close-out used: passing `expectedStatus` makes the transition a safe
675
+ * compare-and-swap (a stale status surfaces as a sanitized
676
+ * {@link ConductorBridgeApiError} — HTTP 400 VALIDATION — rather than clobbering
677
+ * a concurrent terminal transition). Prefer passing the concrete run UUID as
678
+ * `epicKey` when the runtime has it. Returns the normalized {@link EpicRunRecord}.
679
+ */
680
+ export async function updateEpicRunStatus(access, request, fetchImpl = globalThis.fetch) {
681
+ requireNonEmptyString(request.epicKey);
682
+ const url = buildConductorJiraUrl(access.baseUrl, epicRunApiPath(request.epicKey));
683
+ const body = JSON.stringify({
684
+ repo_name: access.repoName,
685
+ status: request.status,
686
+ ...(request.expectedStatus ? { expected_status: request.expectedStatus } : {}),
687
+ });
688
+ const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
689
+ return parsed;
690
+ }
511
691
  // ---------------------------------------------------------------------------
512
692
  // Per-ticket CAS status advancement
513
693
  // ---------------------------------------------------------------------------
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * This is a dependency-light command-hook entrypoint registered into a spawned
5
5
  * Claude worker's `.claude/settings.local.json` by `start-tickets`. Claude Code
6
- * invokes it for lifecycle events (SessionStart, Stop, SubagentStop,
7
- * Notification, optionally PreToolUse), passing the native hook JSON on stdin.
6
+ * invokes it for lifecycle events (SessionStart, SessionEnd, Notification,
7
+ * optionally PreToolUse), passing the native hook JSON on stdin. `Stop` and
8
+ * `SubagentStop` are intentionally NOT registered/emitted: they are per-turn /
9
+ * per-subagent events, not session-terminal (BAPI-507 N-1); only `SessionEnd`
10
+ * is a true session end and maps to `run.stopped`.
8
11
  * The writer:
9
12
  *
10
13
  * 1. reads + parses the native Claude hook payload from stdin,
@@ -50,15 +53,30 @@ export function resolveClaudeHookEventName(payload) {
50
53
  /**
51
54
  * Map a Claude lifecycle event name to a canonical conductor semantic type.
52
55
  * Unknown events map to `null` (the caller then skips emission).
56
+ *
57
+ * BAPI-507 (N-1): `Stop` and `SubagentStop` are intentionally ignored (→ null).
58
+ * Claude Code fires `Stop` at the end of EACH assistant turn and `SubagentStop`
59
+ * at each Task-subagent completion — NOT only at session termination. Mapping
60
+ * them to `run.stopped` folded a ticket to `ready_for_review` from minutes into
61
+ * the session, while the worker was still implementing with no branch pushed and
62
+ * no PR. The only true session-terminal event is `SessionEnd`, which maps to
63
+ * `run.stopped`. (Verified against the Claude Code lifecycle-hook contract:
64
+ * `SessionEnd` fires once when the session ends; `Stop`/`SubagentStop` are
65
+ * per-turn/per-subagent.)
53
66
  */
54
67
  export function mapClaudeHookEventToSemanticType(eventName) {
55
68
  switch (eventName) {
56
69
  case "SessionStart":
57
70
  return "run.started";
58
- case "Stop":
71
+ case "SessionEnd":
72
+ // The one true session-terminal event: the worker's session has ended.
59
73
  return "run.stopped";
74
+ case "Stop":
75
+ // Per-turn boundary, NOT session end — deliberately ignored (BAPI-507 N-1).
76
+ return null;
60
77
  case "SubagentStop":
61
- return "run.stopped";
78
+ // Per-subagent completion, NOT session end — deliberately ignored.
79
+ return null;
62
80
  case "Notification":
63
81
  return "agent.notification";
64
82
  case "PreToolUse":
@@ -18,6 +18,7 @@ import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js"
18
18
  import { emitConductorEvent, purgeConductorLedger, sendWorkerMessage, checkWorkerMessages, } from "./store.js";
19
19
  import { SEMANTIC_EVENT_TYPES } from "./taxonomy.js";
20
20
  import { installConductorGitHooks } from "./git-hooks.js";
21
+ import { runFileScopeGuardCli } from "./file-scope-guard.js";
21
22
  import { runPostCommitHookProducer, runReferenceTransactionHookProducer } from "./git-producer.js";
22
23
  import { buildConductorDoctorReport, formatConductorDoctorReport } from "./doctor.js";
23
24
  import { resolveSupervisorConfig } from "./supervisor-config.js";
@@ -46,14 +47,14 @@ export function getConductorUsage() {
46
47
  " git-hook post-commit Run the post-commit producer (invoked by the installed hook)",
47
48
  " git-hook reference-transaction --phase <p> --stdin-file <f>",
48
49
  " Run the reference-transaction producer (invoked by the hook)",
50
+ " file-scope-guard Warn-only: compare the branch diff against the declared",
51
+ " touched-file set (always exits 0; never blocks a PR)",
49
52
  "",
50
53
  "supervise options:",
51
54
  " --run-id <id> Run/session identifier to supervise (required)",
52
55
  " --wake-interval-ms <n> Deterministic event-poll cadence (clamped 30000..60000)",
53
56
  " --global-timeout-ms <n> Total wall-clock ceiling for the run",
54
- " --llm-budget-calls <n> Max LLM judgment calls per run before degraded-only mode",
55
57
  " --escalation-cooldown-ms <n> Min gap between escalations for the same worker+reason",
56
- " --no-llm Deterministic-only mode (never call the LLM judgment boundary)",
57
58
  "",
58
59
  "install-git-hooks notes:",
59
60
  " Hooks are LOCAL, unversioned, opportunistic, and bypassable. Missing hooks are a",
@@ -151,6 +152,7 @@ const VALID_COMMANDS = new Set([
151
152
  "purge",
152
153
  "install-git-hooks",
153
154
  "git-hook",
155
+ "file-scope-guard",
154
156
  ]);
155
157
  /**
156
158
  * Parse the top-level conductor argv into a subcommand (without a CLI
@@ -925,10 +927,9 @@ const SUPERVISE_VALUE_FLAGS = new Set([
925
927
  "--run-id",
926
928
  "--wake-interval-ms",
927
929
  "--global-timeout-ms",
928
- "--llm-budget-calls",
929
930
  "--escalation-cooldown-ms",
930
931
  ]);
931
- const SUPERVISE_BOOL_FLAGS = new Set(["--no-llm", "--help"]);
932
+ const SUPERVISE_BOOL_FLAGS = new Set(["--help"]);
932
933
  /** Parse a required positive-integer flag, raising a sanitized error otherwise. */
933
934
  function parsePositiveIntFlag(values, flag) {
934
935
  const raw = values.get(flag);
@@ -946,9 +947,8 @@ function parsePositiveIntFlag(values, flag) {
946
947
  /**
947
948
  * Parse `supervise` flags. `--run-id` is required and must be non-empty /
948
949
  * non-whitespace. Numeric overrides are validated as non-negative integers (the
949
- * config resolver clamps them to safe bounds); `--no-llm` selects
950
- * deterministic-only mode. Malformed input raises a sanitized
951
- * {@link ConductorValidationError}.
950
+ * config resolver clamps them to safe bounds). Malformed input raises a
951
+ * sanitized {@link ConductorValidationError}.
952
952
  */
953
953
  export function parseSuperviseArgs(argv) {
954
954
  const { values, bools } = tokenizeFlags(argv, SUPERVISE_VALUE_FLAGS, SUPERVISE_BOOL_FLAGS);
@@ -966,14 +966,9 @@ export function parseSuperviseArgs(argv) {
966
966
  const globalTimeout = parsePositiveIntFlag(values, "--global-timeout-ms");
967
967
  if (globalTimeout !== undefined)
968
968
  overrides.global_timeout_ms = globalTimeout;
969
- const llmCalls = parsePositiveIntFlag(values, "--llm-budget-calls");
970
- if (llmCalls !== undefined)
971
- overrides.llm_max_calls = llmCalls;
972
969
  const cooldown = parsePositiveIntFlag(values, "--escalation-cooldown-ms");
973
970
  if (cooldown !== undefined)
974
971
  overrides.escalation_cooldown_ms = cooldown;
975
- if (bools.has("--no-llm"))
976
- overrides.llm_enabled = false;
977
972
  return { runId: runIdRaw.trim(), overrides, help: false };
978
973
  }
979
974
  /**
@@ -990,7 +985,7 @@ export async function runSuperviseCommand(argv) {
990
985
  }
991
986
  const config = resolveSupervisorConfig(parsed.overrides);
992
987
  console.log(`[supervisor] starting run=${parsed.runId} wake=${config.wake_interval_ms}ms ` +
993
- `global_timeout=${config.global_timeout_ms}ms llm=${config.llm_enabled ? `on(${config.llm_max_calls})` : "off"}`);
988
+ `global_timeout=${config.global_timeout_ms}ms`);
994
989
  const { runSupervisor } = await import("./supervisor-runtime.js");
995
990
  const result = await runSupervisor({ run_id: parsed.runId, config });
996
991
  return result.exit_code;
@@ -1056,6 +1051,9 @@ export async function runConductorCli(argv) {
1056
1051
  return runInstallGitHooksCommand(parsed.argv);
1057
1052
  case "git-hook":
1058
1053
  return await runGitHookCommand(parsed.argv);
1054
+ case "file-scope-guard":
1055
+ // BAPI-507 (N-2): warn-only worker file-scope guard. Always exits 0.
1056
+ return runFileScopeGuardCli();
1059
1057
  default:
1060
1058
  console.error('Error: Unknown command. Run "conductor --help" for usage.');
1061
1059
  return 1;
@@ -403,10 +403,15 @@ export function normalizeReviewSnapshot(raw) {
403
403
  const headSha = typeof detail.head_sha === "string" && detail.head_sha.trim().length > 0
404
404
  ? detail.head_sha.trim()
405
405
  : null;
406
+ // BAPI-493 (D-5): head-scope the dedupe hash so two otherwise-identical review
407
+ // snapshots observed on DIFFERENT PR heads hash differently (the producer dedupe
408
+ // dimension was not head-scoped before). Uses the already-normalized local
409
+ // `headSha` (trimmed string or null) so a missing head stays deterministic.
406
410
  const reviewStateHash = stableJsonHash({
407
411
  review_decision: reviewDecision,
408
412
  approvals,
409
413
  sticky_verdict: stickyVerdict,
414
+ head_sha: headSha,
410
415
  });
411
416
  return { review_decision: reviewDecision, approvals, sticky_verdict: stickyVerdict, head_sha: headSha, review_state_hash: reviewStateHash };
412
417
  }
@@ -19,6 +19,21 @@
19
19
  */
20
20
  import { computeReadySet, decideRemediation, DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS } from "./epic-state.js";
21
21
  import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
22
+ /**
23
+ * Sanitize a thrown value into a single-line, bounded diagnostic string for a
24
+ * tick warning (BAPI-487 Requirement 2). Surfaces the real `err.message` (the
25
+ * remediation messages — `remediate: no PR binding for <KEY>`, `remediate: no
26
+ * head_sha for PR <N>` — are safe, secret-free text) instead of the opaque
27
+ * constructor name that hid the true failure for an entire smoke test. Collapses
28
+ * whitespace (defeating multiline log injection), caps length, and falls back to
29
+ * the constructor name for an empty message or `fallback` for a non-Error throw.
30
+ */
31
+ export function safeDiagnosticMessage(err, fallback) {
32
+ const raw = err instanceof Error
33
+ ? (err.message.trim() || err.constructor.name)
34
+ : fallback;
35
+ return raw.replace(/\s+/g, " ").slice(0, 500);
36
+ }
22
37
  // ---------------------------------------------------------------------------
23
38
  // reconcileEpic
24
39
  // ---------------------------------------------------------------------------
@@ -43,10 +58,23 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
43
58
  catch (err) {
44
59
  const safeMsg = err instanceof Error ? err.constructor.name : "cas error";
45
60
  result.warnings.push(`cas-error folding ${signal.signal_type} for ${signal.ticket_key}: ${safeMsg}`);
61
+ // BAPI-500: the fold CAS did not durably run, so any PREDICTED post-fold
62
+ // row version for this ticket is unconfirmed. Clear it so the same-pass
63
+ // remediation CAS falls back to the tick-start snapshot instead of trusting
64
+ // a snapshot+1 that was never written.
65
+ observed.ticket_post_fold_row_versions?.delete(signal.ticket_key);
46
66
  continue;
47
67
  }
48
68
  if (casResult.ok) {
49
69
  result.signals_folded += 1;
70
+ // BAPI-500: confirm the post-fold row version from the ACTUAL CAS response
71
+ // (never the tick-start snapshot or a re-derived `+ 1`), so a follow-on
72
+ // remediation CAS in this same pass CASes against the durable snapshot+1.
73
+ // Keyed per ticket — one status CAS per ticket per tick. Do NOT touch
74
+ // `observed.ticket_row_versions`; it stays the tick-start snapshot so
75
+ // callers can distinguish fallback values from same-pass post-fold values.
76
+ observed.ticket_post_fold_row_versions ??= new Map();
77
+ observed.ticket_post_fold_row_versions.set(signal.ticket_key, casResult.ticket_status.row_version);
50
78
  deps.log(`[epic-reconcile] folded ${signal.signal_type} for ${signal.ticket_key} → ${signal.next_status}`);
51
79
  // BAPI-442: fire teardown + Jira transition strictly after merge.succeeded
52
80
  // CAS → done. Both are fail-open: errors are logged and never abort the pass.
@@ -66,18 +94,25 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
66
94
  }
67
95
  }
68
96
  else {
69
- // CAS conflict: another tick already advanced this ticket — non-fatal
97
+ // CAS conflict: another tick already advanced this ticket — non-fatal.
98
+ // BAPI-500: the predicted post-fold version is unconfirmed (the real row
99
+ // version is whatever the other writer left), so clear it and let the
100
+ // same-pass remediation CAS fall back to the tick-start snapshot.
101
+ observed.ticket_post_fold_row_versions?.delete(signal.ticket_key);
70
102
  result.warnings.push(`cas-conflict folding ${signal.signal_type} for ${signal.ticket_key}`);
71
103
  }
72
104
  }
73
- // Step 1.5: Seed planned status rows for every plan node (idempotent — ON CONFLICT DO NOTHING)
105
+ // Step 1.5: Seed planned status rows for every plan node. Idempotent: the
106
+ // backend read-back no-ops a re-seed of an existing row (A6/BAPI-507), so a
107
+ // normal multi-tick run no longer produces per-node seed warnings. When a seed
108
+ // DOES fail, the warning carries safe backend diagnostics (status/code/message
109
+ // preview) rather than the opaque `ConductorBridgeApiError` constructor name.
74
110
  for (const ticket of plan.tickets) {
75
111
  try {
76
112
  await deps.seedTicketStatus(observed.epic_key, ticket.ticket_key, plan.plan_version);
77
113
  }
78
114
  catch (err) {
79
- const safeMsg = err instanceof Error ? err.constructor.name : "seed error";
80
- result.warnings.push(`seed-error for ${ticket.ticket_key}: ${safeMsg}`);
115
+ result.warnings.push(`seed-error for ${ticket.ticket_key}: ${safeDiagnosticMessage(err, "seed error")}`);
81
116
  }
82
117
  }
83
118
  // Step 2: Compute the ready-set (pure — never calls LLM)
@@ -307,18 +342,30 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
307
342
  }
308
343
  // The attempt being recorded is the next one (1-based).
309
344
  const attempt = counters.attempts + 1;
310
- const attemptKind = decision;
311
345
  // The folding reason frames the nudge (message type + digest). Default to
312
346
  // the review path when the ledger no longer carries the blocking event.
313
347
  // spec_review.changes_requested is impossible here (skipped above) but
314
348
  // narrow explicitly so the remediation seams keep their tight reason type.
349
+ // BAPI-494: `merge.conflict` is normalized as a first-class reason.
315
350
  const blockedReason = observed.ticket_blocked_reasons?.get(ticketKey);
316
- const reason = blockedReason === "ci.failed" ? "ci.failed" : "review.changes_requested";
351
+ const normalizedReason = blockedReason === "ci.failed"
352
+ ? "ci.failed"
353
+ : blockedReason === "merge.conflict"
354
+ ? "merge.conflict"
355
+ : "review.changes_requested";
356
+ // BAPI-494: a conflict ALWAYS redispatches a resume-mode worker (it needs a
357
+ // working tree to rebase + resolve, not a message), regardless of whether the
358
+ // original worker is still alive — so the alive-worker nudge is forced to a
359
+ // redispatch here. Budget-exhaustion escalation already fired above, so at this
360
+ // point the budget is not exhausted. CI/review keep their liveness-driven
361
+ // nudge-vs-redispatch decision unchanged.
362
+ const attemptKind = normalizedReason === "merge.conflict" ? "redispatch" : decision;
317
363
  // A nudge needs a worker to address it to. The liveness scan already
318
364
  // resolved the worker id from the same heartbeat that proved the worker
319
365
  // alive; if it is missing we cannot relay, so skip BEFORE recording an
320
366
  // attempt — otherwise the CAS would burn a budget unit with nothing sent.
321
- if (decision === "nudge" && !liveness.workerId) {
367
+ // A redispatch (including every conflict) needs no worker id, so it is exempt.
368
+ if (attemptKind === "nudge" && !liveness.workerId) {
322
369
  result.warnings.push(`remediation nudge skipped for ${ticketKey}: alive worker has no worker_id`);
323
370
  continue;
324
371
  }
@@ -329,10 +376,10 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
329
376
  // the pass still runs (crash-replay safe).
330
377
  let casOutcome;
331
378
  try {
332
- casOutcome = await remediateCas(observed.epic_key, ticketKey, attemptKind, reason);
379
+ casOutcome = await remediateCas(observed.epic_key, ticketKey, attemptKind, normalizedReason);
333
380
  }
334
381
  catch (err) {
335
- const safeMsg = err instanceof Error ? err.constructor.name : "remediate error";
382
+ const safeMsg = safeDiagnosticMessage(err, "remediate error");
336
383
  result.warnings.push(`remediate-cas-failed for ${ticketKey} (${attemptKind}): ${safeMsg}`);
337
384
  continue;
338
385
  }
@@ -342,16 +389,18 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
342
389
  result.warnings.push(`remediation replay swallowed for ${ticketKey} (${attemptKind})`);
343
390
  continue;
344
391
  }
345
- if (decision === "nudge") {
346
- await sendNudge(observed.epic_key, ticketKey, attempt, casOutcome.reviewDigest, casOutcome.truncated, reason, liveness.workerId);
392
+ if (attemptKind === "nudge") {
393
+ // Nudge is CI/review only (conflict never nudges), so narrow the reason.
394
+ const nudgeReason = normalizedReason === "ci.failed" ? "ci.failed" : "review.changes_requested";
395
+ await sendNudge(observed.epic_key, ticketKey, attempt, casOutcome.reviewDigest, casOutcome.truncated, nudgeReason, liveness.workerId);
347
396
  }
348
397
  else {
349
398
  await resumeDispatch(observed.epic_key, ticketKey, attempt);
350
399
  }
351
- deps.log(`[epic-reconcile] remediation ${decision} ${ticketKey} attempt=${attempt}`);
400
+ deps.log(`[epic-reconcile] remediation ${attemptKind} ${ticketKey} attempt=${attempt} reason=${normalizedReason}`);
352
401
  }
353
402
  catch (err) {
354
- const safeMsg = err instanceof Error ? err.constructor.name : "remediation error";
403
+ const safeMsg = safeDiagnosticMessage(err, "remediation error");
355
404
  result.warnings.push(`remediation-error for ${ticketKey}: ${safeMsg}`);
356
405
  }
357
406
  }