@bridge_gpt/mcp-server 0.2.12 → 0.2.14

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.
@@ -194,7 +194,11 @@ var init_taxonomy = __esm({
194
194
  // distinct from the review.* PR-review verdicts above so the conductor never
195
195
  // mis-folds a spec-review outcome as the implementation PR's review state.
196
196
  "spec_review.passed",
197
- "spec_review.changes_requested"
197
+ "spec_review.changes_requested",
198
+ // Durable parse-after-merge marker. Emitted by epic-tick when it triggers a
199
+ // post-merge repository re-index, so the (stateless) reconcile loop can fold a
200
+ // merged ticket to `done` from the ledger instead of an in-memory wait map.
201
+ "parse.triggered"
198
202
  ];
199
203
  }
200
204
  });
@@ -1529,7 +1533,7 @@ var init_store = __esm({
1529
1533
  WAIT_TIMEOUT_MAX_MS = 12e4;
1530
1534
  WAIT_POLL_INTERVAL_MS = 500;
1531
1535
  SUMMARY_FIELD_MAX_CHARS = 500;
1532
- CURRENT_CONDUCTOR_SCHEMA_VERSION = 6;
1536
+ CURRENT_CONDUCTOR_SCHEMA_VERSION = 7;
1533
1537
  MESSAGE_TYPE_PATTERN = /^[A-Za-z0-9._:-]{1,100}$/;
1534
1538
  }
1535
1539
  });
@@ -1585,6 +1589,89 @@ var init_git_ci_types = __esm({
1585
1589
  }
1586
1590
  });
1587
1591
 
1592
+ // src/conductor/producer-ledger.ts
1593
+ import { createHash as createHash2 } from "node:crypto";
1594
+ function makeProducerDedupeKey(dimensions) {
1595
+ const canonical = {};
1596
+ for (const [key, value] of Object.entries(dimensions)) {
1597
+ if (value !== void 0 && value !== null) canonical[key] = value;
1598
+ }
1599
+ return stableJsonHash(canonical);
1600
+ }
1601
+ function makeStableProducerEventId(dedupeKey) {
1602
+ const h = createHash2("sha256").update(`conductor-producer:${dedupeKey}`).digest("hex");
1603
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
1604
+ }
1605
+ function isDuplicateConstraintError2(error) {
1606
+ if (!error || typeof error !== "object") return false;
1607
+ const code = error.code;
1608
+ if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT")) return true;
1609
+ const message = error.message;
1610
+ if (typeof message === "string") {
1611
+ const lowered = message.toLowerCase();
1612
+ if (lowered.includes("unique constraint") || lowered.includes("constraint failed")) return true;
1613
+ }
1614
+ return false;
1615
+ }
1616
+ async function eventAlreadyExists(dedupeKey, deps = {}) {
1617
+ const pollEvents = deps.pollEvents ?? ((options) => pollConductorEvents(options));
1618
+ let sinceSeq = 1;
1619
+ for (let page = 0; page < LEDGER_SCAN_MAX_PAGES; page += 1) {
1620
+ let result;
1621
+ try {
1622
+ result = await pollEvents({ since_seq: sinceSeq, data_mode: "full", limit: LEDGER_SCAN_PAGE_LIMIT });
1623
+ } catch {
1624
+ return false;
1625
+ }
1626
+ for (const event of result.events) {
1627
+ if (!event || typeof event !== "object") continue;
1628
+ const data = event.data;
1629
+ if (data && typeof data === "object") {
1630
+ const details = data.details;
1631
+ if (details && typeof details === "object" && details.dedupe_key === dedupeKey) {
1632
+ return true;
1633
+ }
1634
+ }
1635
+ }
1636
+ if (result.count === 0 || result.next_seq <= sinceSeq) break;
1637
+ sinceSeq = result.next_seq;
1638
+ }
1639
+ return false;
1640
+ }
1641
+ async function emitConductorEventIfNew(input, dimensions, deps = {}) {
1642
+ const emitEvent = deps.emitEvent ?? emitConductorEvent;
1643
+ const dedupeKey = makeProducerDedupeKey(dimensions);
1644
+ if (await eventAlreadyExists(dedupeKey, deps)) {
1645
+ return { emitted: false, reason: "duplicate" };
1646
+ }
1647
+ const eventId = makeStableProducerEventId(dedupeKey);
1648
+ const existingData = input.data ?? {};
1649
+ const existingDetails = existingData.details && typeof existingData.details === "object" && !Array.isArray(existingData.details) ? existingData.details : {};
1650
+ const data = {
1651
+ ...existingData,
1652
+ details: { ...existingDetails, dedupe_key: dedupeKey }
1653
+ };
1654
+ try {
1655
+ await emitEvent({ ...input, id: eventId, data });
1656
+ return { emitted: true, event_id: eventId };
1657
+ } catch (error) {
1658
+ if (isDuplicateConstraintError2(error)) {
1659
+ return { emitted: false, reason: "duplicate" };
1660
+ }
1661
+ throw error;
1662
+ }
1663
+ }
1664
+ var LEDGER_SCAN_PAGE_LIMIT, LEDGER_SCAN_MAX_PAGES;
1665
+ var init_producer_ledger = __esm({
1666
+ "src/conductor/producer-ledger.ts"() {
1667
+ "use strict";
1668
+ init_store();
1669
+ init_git_ci_types();
1670
+ LEDGER_SCAN_PAGE_LIMIT = 500;
1671
+ LEDGER_SCAN_MAX_PAGES = 200;
1672
+ }
1673
+ });
1674
+
1588
1675
  // src/scheduler-backends/types.ts
1589
1676
  import path3 from "node:path";
1590
1677
  function pathApiForPlatform(platform) {
@@ -5718,6 +5805,162 @@ var init_supervisor_merge = __esm({
5718
5805
  }
5719
5806
  });
5720
5807
 
5808
+ // src/conductor/local-merge.ts
5809
+ import { spawnSync as spawnSync2 } from "child_process";
5810
+ function resolveLocalMergeMethod(value) {
5811
+ return typeof value === "string" && MERGE_METHODS.has(value) ? value : "squash";
5812
+ }
5813
+ function defaultRunCommand(cmd, args, env) {
5814
+ const result = spawnSync2(cmd, args, {
5815
+ encoding: "utf8",
5816
+ env: { ...process.env, ...env },
5817
+ timeout: DEFAULT_COMMAND_TIMEOUT_MS
5818
+ });
5819
+ const timedOut = result.error?.code === "ETIMEDOUT" || result.signal === "SIGTERM";
5820
+ return {
5821
+ status: result.status,
5822
+ stdout: result.stdout ?? "",
5823
+ stderr: result.stderr ?? "",
5824
+ timedOut
5825
+ };
5826
+ }
5827
+ function buildResponse(request, status, reason, terminal, ledgerEvents) {
5828
+ return {
5829
+ action_key: request.action_key,
5830
+ repo_name: request.repo_name,
5831
+ pr_number: request.pr_number,
5832
+ expected_head_sha: request.expected_head_sha,
5833
+ status,
5834
+ reason,
5835
+ terminal,
5836
+ ledger_events: ledgerEvents
5837
+ };
5838
+ }
5839
+ function allRequiredChecksGreen(pollResponse, requiredChecks) {
5840
+ if (pollResponse === null || typeof pollResponse !== "object") return false;
5841
+ let obj = pollResponse;
5842
+ const maybeDetail = obj.detail;
5843
+ if (maybeDetail && typeof maybeDetail === "object" && (Array.isArray(maybeDetail.checks) || "all_passed" in maybeDetail)) {
5844
+ obj = maybeDetail;
5845
+ }
5846
+ const rawChecks = Array.isArray(obj.checks) ? obj.checks : [];
5847
+ if (requiredChecks.length === 0) {
5848
+ return obj.all_passed === true;
5849
+ }
5850
+ const byName = /* @__PURE__ */ new Map();
5851
+ for (const c of rawChecks) {
5852
+ const name = typeof c.name === "string" ? c.name : null;
5853
+ if (name !== null) byName.set(name, c);
5854
+ }
5855
+ const isGreen = (c) => {
5856
+ if (!c) return false;
5857
+ const conclusion = typeof c.conclusion === "string" ? c.conclusion.toLowerCase() : "";
5858
+ const status = typeof c.status === "string" ? c.status.toLowerCase() : "";
5859
+ const bucket = typeof c.bucket === "string" ? c.bucket.toLowerCase() : "";
5860
+ return c.green === true || conclusion === "success" || status === "success" || bucket === "pass";
5861
+ };
5862
+ return requiredChecks.every((name) => isGreen(byName.get(name)));
5863
+ }
5864
+ function makeLocalMergeExecutor(options = {}, deps = {}) {
5865
+ const method = resolveLocalMergeMethod(options.method);
5866
+ const run = deps.runCommand ?? defaultRunCommand;
5867
+ const pollCi = deps.pollCi ?? pollCiChecksForCommit;
5868
+ const ghEnv = {
5869
+ ...deps.env,
5870
+ GH_PROMPT_DISABLED: "1",
5871
+ GH_NO_UPDATE_NOTIFIER: "1"
5872
+ };
5873
+ return async (access, request) => {
5874
+ const pr = request.pr_number;
5875
+ const expectedSha = request.expected_head_sha;
5876
+ const requiredChecks = request.gate?.required_checks ?? [];
5877
+ const baseDetails = {
5878
+ action_key: request.action_key,
5879
+ repo: request.repo_name,
5880
+ pr_number: pr,
5881
+ expected_head_sha: expectedSha,
5882
+ merge_method: method,
5883
+ executor: "local"
5884
+ };
5885
+ const fail = (reason) => buildResponse(request, "failed", reason, false, [
5886
+ { type: "merge.failed", status: "failed", reason, details: baseDetails }
5887
+ ]);
5888
+ if (options.approvalRequired) {
5889
+ return buildResponse(request, "pending_approval", "local_merge_approval_required", false, [
5890
+ {
5891
+ type: "merge.pending_approval",
5892
+ status: "pending_approval",
5893
+ reason: "local_merge_approval_required",
5894
+ details: baseDetails
5895
+ }
5896
+ ]);
5897
+ }
5898
+ const view = run("gh", ["pr", "view", String(pr), "--json", "headRefOid,state"], ghEnv);
5899
+ if (view.timedOut) return fail("gh_pr_view_timeout");
5900
+ if (view.status !== 0) return fail("gh_pr_view_failed");
5901
+ let headOid;
5902
+ let state;
5903
+ try {
5904
+ const parsed = JSON.parse(view.stdout);
5905
+ headOid = parsed.headRefOid;
5906
+ state = parsed.state;
5907
+ } catch {
5908
+ return fail("gh_pr_view_unparseable");
5909
+ }
5910
+ if (typeof state === "string" && state.toUpperCase() !== "OPEN") return fail("pr_not_open");
5911
+ if (typeof headOid !== "string" || headOid.toLowerCase() !== expectedSha.toLowerCase()) {
5912
+ return fail("head_drift");
5913
+ }
5914
+ let pollResponse;
5915
+ try {
5916
+ pollResponse = await pollCi(access, expectedSha);
5917
+ } catch {
5918
+ return fail("ci_poll_failed");
5919
+ }
5920
+ if (!allRequiredChecksGreen(pollResponse, requiredChecks)) return fail("ci_not_green");
5921
+ const merge = run(
5922
+ "gh",
5923
+ ["pr", "merge", String(pr), `--${method}`, "--match-head-commit", expectedSha],
5924
+ ghEnv
5925
+ );
5926
+ if (merge.status !== 0) {
5927
+ const mergeFailReason = merge.timedOut ? "gh_merge_timeout" : "gh_merge_failed";
5928
+ return buildResponse(request, "failed", mergeFailReason, false, [
5929
+ { type: "merge.attempted", status: "attempted", details: baseDetails },
5930
+ { type: "merge.failed", status: "failed", reason: mergeFailReason, details: baseDetails }
5931
+ ]);
5932
+ }
5933
+ let mergeCommitSha;
5934
+ const post = run("gh", ["pr", "view", String(pr), "--json", "mergeCommit"], ghEnv);
5935
+ if (post.status === 0) {
5936
+ try {
5937
+ const oid = JSON.parse(post.stdout)?.mergeCommit;
5938
+ if (oid && typeof oid === "object" && typeof oid.oid === "string") {
5939
+ mergeCommitSha = oid.oid;
5940
+ }
5941
+ } catch {
5942
+ }
5943
+ }
5944
+ const succeededDetails = {
5945
+ ...baseDetails,
5946
+ ...mergeCommitSha ? { merge_commit_sha: mergeCommitSha } : {}
5947
+ };
5948
+ return buildResponse(request, "succeeded", null, true, [
5949
+ { type: "merge.attempted", status: "attempted", details: baseDetails },
5950
+ { type: "merge.succeeded", status: "succeeded", details: succeededDetails }
5951
+ ]);
5952
+ };
5953
+ }
5954
+ var MERGE_METHODS, DEFAULT_COMMAND_TIMEOUT_MS;
5955
+ var init_local_merge = __esm({
5956
+ "src/conductor/local-merge.ts"() {
5957
+ "use strict";
5958
+ init_bridge_api_client();
5959
+ MERGE_METHODS = /* @__PURE__ */ new Set(["squash", "merge", "rebase"]);
5960
+ DEFAULT_COMMAND_TIMEOUT_MS = 6e4;
5961
+ }
5962
+ });
5963
+
5721
5964
  // src/conductor/epic-state.ts
5722
5965
  function isNonTerminal(status) {
5723
5966
  return NON_TERMINAL_STATUSES.has(status);
@@ -5787,6 +6030,7 @@ function rebuildObservedState(postgresState, events, _now) {
5787
6030
  const unfoldedSignals = [];
5788
6031
  const pendingMergeEvents = [];
5789
6032
  const foldedTicketKeys = /* @__PURE__ */ new Set();
6033
+ const mergeQueuedTicketKeys = /* @__PURE__ */ new Set();
5790
6034
  const ticketBlockedReasons = /* @__PURE__ */ new Map();
5791
6035
  for (const event of events) {
5792
6036
  if (!TERMINAL_SIGNAL_TYPES.has(event.type)) continue;
@@ -5804,8 +6048,9 @@ function rebuildObservedState(postgresState, events, _now) {
5804
6048
  }
5805
6049
  const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
5806
6050
  if (!isNonTerminal(postgresStatus)) continue;
5807
- if (event.type === "gate.met" && !foldedTicketKeys.has(ticketKey)) {
6051
+ if (event.type === "gate.met" && postgresStatus !== "blocked" && !mergeQueuedTicketKeys.has(ticketKey)) {
5808
6052
  pendingMergeEvents.push(event);
6053
+ mergeQueuedTicketKeys.add(ticketKey);
5809
6054
  }
5810
6055
  const signalType = event.type;
5811
6056
  const nextStatus = signalToNextStatus(signalType, isReview);
@@ -6460,7 +6705,7 @@ var VERSION;
6460
6705
  var init_version_generated = __esm({
6461
6706
  "src/version.generated.ts"() {
6462
6707
  "use strict";
6463
- VERSION = "0.2.12";
6708
+ VERSION = "0.2.14";
6464
6709
  }
6465
6710
  });
6466
6711
 
@@ -7035,6 +7280,36 @@ var init_agent_registry = __esm({
7035
7280
  }
7036
7281
  });
7037
7282
 
7283
+ // src/mcp-profile.ts
7284
+ function resolveProfiles(raw) {
7285
+ const baseline = /* @__PURE__ */ new Set(["core"]);
7286
+ if (raw === void 0) return baseline;
7287
+ const trimmed = raw.trim();
7288
+ if (trimmed === "") return baseline;
7289
+ const tokens = trimmed.split(",").map((t) => t.trim().toLowerCase());
7290
+ if (tokens.some((t) => t === "full")) {
7291
+ return /* @__PURE__ */ new Set(["core", "conductor", "pipeline-authoring", "sfcc"]);
7292
+ }
7293
+ for (const token of tokens) {
7294
+ if (VALID_GROUPS.has(token)) {
7295
+ baseline.add(token);
7296
+ }
7297
+ }
7298
+ return baseline;
7299
+ }
7300
+ var VALID_GROUPS;
7301
+ var init_mcp_profile = __esm({
7302
+ "src/mcp-profile.ts"() {
7303
+ "use strict";
7304
+ VALID_GROUPS = /* @__PURE__ */ new Set([
7305
+ "core",
7306
+ "conductor",
7307
+ "pipeline-authoring",
7308
+ "sfcc"
7309
+ ]);
7310
+ }
7311
+ });
7312
+
7038
7313
  // src/start-tickets-conductor.ts
7039
7314
  import { randomBytes } from "node:crypto";
7040
7315
  import path7 from "node:path";
@@ -7096,9 +7371,12 @@ function isConductorFlagEnabled(value) {
7096
7371
  return v === "1" || v === "true";
7097
7372
  }
7098
7373
  function buildConductorWorkerEnv(context, worker, parentEnv) {
7374
+ const parentActiveGroups = new Set(resolveProfiles(parentEnv.BRIDGE_MCP_PROFILE));
7375
+ parentActiveGroups.add("conductor");
7376
+ const mergedProfile = Array.from(parentActiveGroups).join(",");
7099
7377
  const env = {
7100
7378
  BAPI_CONDUCTOR_ENABLED: "1",
7101
- BRIDGE_MCP_PROFILE: "conductor",
7379
+ BRIDGE_MCP_PROFILE: mergedProfile,
7102
7380
  BAPI_CONDUCTOR_RUN_ID: context.runId,
7103
7381
  BAPI_CONDUCTOR_WORKER_ID: worker.workerId,
7104
7382
  BAPI_CONDUCTOR_TICKET_KEY: worker.ticketKey,
@@ -7353,6 +7631,7 @@ var DEFAULT_CONDUCTOR_GATE_NAME, CONDUCTOR_TUNING_ENV_KEYS, CONDUCTOR_HOOK_LIFEC
7353
7631
  var init_start_tickets_conductor = __esm({
7354
7632
  "src/start-tickets-conductor.ts"() {
7355
7633
  "use strict";
7634
+ init_mcp_profile();
7356
7635
  init_start_tickets_repo();
7357
7636
  DEFAULT_CONDUCTOR_GATE_NAME = "implement-ticket";
7358
7637
  CONDUCTOR_TUNING_ENV_KEYS = [
@@ -7649,10 +7928,41 @@ function pickWorktreePathField(parsed) {
7649
7928
  }
7650
7929
  return void 0;
7651
7930
  }
7652
- async function createWorktreeForTicket(deps, key, branchOverrides, worktrunkBinary, baseBranch = "main") {
7931
+ async function isExistingBranchSafeToReuse(deps, branch, baseBranch) {
7932
+ let baseRef = baseBranch;
7933
+ const originRef = `origin/${baseBranch}`;
7934
+ const originExists = await deps.runCommand(
7935
+ "git",
7936
+ ["rev-parse", "--verify", "--quiet", originRef],
7937
+ { cwd: deps.cwd }
7938
+ );
7939
+ if (commandSucceeded2(originExists)) baseRef = originRef;
7940
+ const ancestor = await deps.runCommand(
7941
+ "git",
7942
+ ["merge-base", "--is-ancestor", branch, baseRef],
7943
+ { cwd: deps.cwd }
7944
+ );
7945
+ if (commandSucceeded2(ancestor)) return { safe: true };
7946
+ return {
7947
+ safe: false,
7948
+ reason: `existing branch '${branch}' is not an ancestor of ${baseRef}; it carries commits not on the resolved base (likely a leftover from a prior run). Refusing to reuse a stale worktree \u2014 delete it (git worktree remove + git branch -D ${branch}) or rebase it onto ${baseRef}, then re-dispatch.`
7949
+ };
7950
+ }
7951
+ async function createWorktreeForTicket(deps, key, branchOverrides, worktrunkBinary, baseBranch = "main", guardStaleWorktree = false) {
7653
7952
  const branch = resolveBranchForTicket(key, branchOverrides);
7654
7953
  try {
7655
7954
  const exists = await branchExists(deps, branch);
7955
+ if (exists && guardStaleWorktree) {
7956
+ const safety = await isExistingBranchSafeToReuse(deps, branch, baseBranch);
7957
+ if (!safety.safe) {
7958
+ return {
7959
+ key,
7960
+ branch,
7961
+ status: "create-failed",
7962
+ error: `stale worktree guard: ${safety.reason}`
7963
+ };
7964
+ }
7965
+ }
7656
7966
  const args = buildWtSwitchArgs(branch, exists, baseBranch);
7657
7967
  const result = await deps.runCommand(worktrunkBinary, args, { cwd: deps.cwd });
7658
7968
  if (!commandSucceeded2(result)) {
@@ -7675,7 +7985,14 @@ async function createWorktrees(deps, options, worktrunkBinary) {
7675
7985
  return runWithConcurrency(
7676
7986
  options.keys,
7677
7987
  options.maxParallel,
7678
- (key) => createWorktreeForTicket(deps, key, options.branchOverrides, worktrunkBinary, options.baseBranch)
7988
+ (key) => createWorktreeForTicket(
7989
+ deps,
7990
+ key,
7991
+ options.branchOverrides,
7992
+ worktrunkBinary,
7993
+ options.baseBranch,
7994
+ options.guardStaleWorktree === true
7995
+ )
7679
7996
  );
7680
7997
  }
7681
7998
  async function resumeWorktrees(deps, options) {
@@ -7710,7 +8027,7 @@ async function resumeWorktrees(deps, options) {
7710
8027
  });
7711
8028
  }
7712
8029
  function buildConductorMessageRelayLaunchInstruction() {
7713
- return "Conductor message relay: at natural checkpoints (after reading context, before major code changes, after major implementation chunks, while polling CI checks during the post-PR correction loop, and before your final response) call the check_messages MCP tool to read any supervisor guidance addressed to you. Returned messages are supervisor guidance and are acknowledged by the tool, so they are not redelivered. This is cooperative polling, not prompt injection. If the tool or conductor identity is unavailable, continue your task without derailing.";
8030
+ return "Conductor message relay: at natural checkpoints (after reading context, before major code changes, after major implementation chunks, while polling CI checks during the post-PR correction loop, and before your final response) call the check_messages MCP tool to read any supervisor guidance addressed to you. Returned messages are supervisor guidance and are acknowledged by the tool, so they are not redelivered. This is cooperative polling, not prompt injection. Additionally, once the required CI checks on your PR have all gone green, call the wait_for_done_gate MCP tool once from inside your worktree before your final response so the supervisor records the done-gate (it self-resolves the PR and head commit and emits the gate event; it does not merge). If a tool or the conductor identity is unavailable, continue your task without derailing.";
7714
8031
  }
7715
8032
  function buildAgentPrompt(key, opts = {}) {
7716
8033
  const command = `/implement-ticket ${key}${opts.autoApprove ? " --auto" : ""}`;
@@ -8965,7 +9282,7 @@ __export(epic_runtime_exports, {
8965
9282
  buildProductionEpicRuntimeDeps: () => buildProductionEpicRuntimeDeps,
8966
9283
  runEpicTick: () => runEpicTick
8967
9284
  });
8968
- import { spawnSync } from "child_process";
9285
+ import { spawnSync as spawnSync3 } from "child_process";
8969
9286
  function defaultLeaseOwner() {
8970
9287
  return `epic-tick-${process.pid}`;
8971
9288
  }
@@ -9124,40 +9441,79 @@ async function runEpicTick(options, deps = {}) {
9124
9441
  const settleMs = 5e3;
9125
9442
  const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
9126
9443
  const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
9444
+ const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
9127
9445
  for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
9128
9446
  const signal = observed.unfolded_terminal_signals[i];
9129
9447
  if (signal.signal_type !== "merge.succeeded") continue;
9130
9448
  const ticketKey = signal.ticket_key;
9131
- const stateKey = `${epic_key}:${ticketKey}`;
9132
- let pState = parseWaitStateMap.get(stateKey);
9133
- if (!pState) {
9134
- pState = {};
9135
- parseWaitStateMap.set(stateKey, pState);
9136
- }
9449
+ const mergeEvent = signal.event;
9450
+ const mergeTimeMs = new Date(mergeEvent.time).getTime();
9451
+ const mergeRunId = mergeEvent.run_id ?? null;
9452
+ const mergeDetails = mergeEvent.data?.details;
9453
+ const mergeHeadSha = typeof mergeDetails?.head_sha === "string" ? mergeDetails.head_sha : void 0;
9137
9454
  const revertSignal = () => {
9138
9455
  const origStatus = epicRunState.ticket_statuses.find((ts) => ts.ticket_key === ticketKey)?.status ?? "running";
9139
9456
  observed.ticket_statuses.set(ticketKey, origStatus);
9140
9457
  observed.unfolded_terminal_signals.splice(i, 1);
9141
9458
  i -= 1;
9142
9459
  };
9143
- const elapsedMs = nowFn() - new Date(signal.event.time).getTime();
9460
+ const currentPgStatus = epicRunState.ticket_statuses.find((ts) => ts.ticket_key === ticketKey)?.status ?? null;
9461
+ const parseTriggeredEvent = localEvents.find(
9462
+ (e) => e.type === "parse.triggered" && e.subject === ticketKey && e.run_id === mergeRunId && new Date(e.time).getTime() >= mergeTimeMs
9463
+ );
9464
+ const elapsedMs = nowFn() - mergeTimeMs;
9144
9465
  if (elapsedMs > maxWaitMs) {
9145
- if (!pState.escalated) {
9146
- await escalateOnce(
9147
- epic_key,
9148
- `parse-after-merge budget exhausted for ${ticketKey}`
9149
- );
9150
- pState.escalated = true;
9151
- signal.next_status = "blocked";
9466
+ if (currentPgStatus === "blocked") {
9152
9467
  observed.ticket_statuses.set(ticketKey, "blocked");
9153
- continue;
9154
- } else {
9155
- observed.ticket_statuses.set(ticketKey, "blocked");
9156
- parseWaitStateMap.delete(stateKey);
9157
9468
  observed.unfolded_terminal_signals.splice(i, 1);
9158
9469
  i -= 1;
9159
9470
  continue;
9160
9471
  }
9472
+ await escalateOnce(
9473
+ epic_key,
9474
+ `parse-after-merge budget exhausted for ${ticketKey}`
9475
+ );
9476
+ signal.next_status = "blocked";
9477
+ observed.ticket_statuses.set(ticketKey, "blocked");
9478
+ continue;
9479
+ }
9480
+ if (!parseTriggeredEvent) {
9481
+ try {
9482
+ await triggerParseFn(access);
9483
+ emitConductorEventFn(
9484
+ {
9485
+ source: PARSE_WAIT_EVENT_SOURCE,
9486
+ type: "parse.triggered",
9487
+ subject: ticketKey,
9488
+ run_id: mergeRunId,
9489
+ worker_id: mergeEvent.worker_id ?? null,
9490
+ producer: PARSE_WAIT_EVENT_PRODUCER,
9491
+ observed_via: "supervisor",
9492
+ time: new Date(nowFn()).toISOString(),
9493
+ data: {
9494
+ summary: `parse-after-merge triggered for ${ticketKey}`,
9495
+ details: {
9496
+ epic_key,
9497
+ ticket_key: ticketKey,
9498
+ ...mergeHeadSha ? { head_sha: mergeHeadSha } : {}
9499
+ }
9500
+ }
9501
+ },
9502
+ {
9503
+ event_type: "parse.triggered",
9504
+ run_id: mergeRunId ?? void 0,
9505
+ commit_sha: mergeHeadSha
9506
+ }
9507
+ );
9508
+ log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
9509
+ } catch (err) {
9510
+ const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
9511
+ errorLog(
9512
+ `[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`
9513
+ );
9514
+ }
9515
+ revertSignal();
9516
+ continue;
9161
9517
  }
9162
9518
  let parseStatusResult;
9163
9519
  try {
@@ -9171,34 +9527,14 @@ async function runEpicTick(options, deps = {}) {
9171
9527
  continue;
9172
9528
  }
9173
9529
  if (parseStatusResult.status === "in_progress") {
9174
- pState.seenInProgress = true;
9175
9530
  revertSignal();
9176
9531
  continue;
9177
9532
  }
9178
- if (pState.seenInProgress) {
9179
- parseWaitStateMap.delete(stateKey);
9180
- continue;
9181
- }
9182
- if (pState.triggeredAt !== void 0) {
9183
- const msSinceTrigger = nowFn() - pState.triggeredAt;
9184
- if (msSinceTrigger < settleMs) {
9185
- revertSignal();
9186
- continue;
9187
- }
9188
- parseWaitStateMap.delete(stateKey);
9533
+ const msSinceTrigger = nowFn() - new Date(parseTriggeredEvent.time).getTime();
9534
+ if (msSinceTrigger < settleMs) {
9535
+ revertSignal();
9189
9536
  continue;
9190
9537
  }
9191
- try {
9192
- await triggerParseFn(access);
9193
- pState.triggeredAt = nowFn();
9194
- log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
9195
- } catch (err) {
9196
- const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
9197
- errorLog(
9198
- `[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`
9199
- );
9200
- }
9201
- revertSignal();
9202
9538
  }
9203
9539
  }
9204
9540
  const fetchPlanFn = deps.fetchPlan;
@@ -9364,7 +9700,23 @@ async function runEpicTick(options, deps = {}) {
9364
9700
  });
9365
9701
  },
9366
9702
  dispatchSeam: async (ek, tk, attempt = 0) => dispatchSeam(ek, tk, attempt),
9367
- processMerge: async (acc, event) => processMergeFn(acc, event),
9703
+ processMerge: async (acc, event) => {
9704
+ if (deps.processMerge === void 0) {
9705
+ const localCfg = epicRunState.epic_run.policy_json?.local_merge;
9706
+ if (localCfg?.enabled === true) {
9707
+ return processGateMetMerge(acc, event, {
9708
+ merge: makeLocalMergeExecutor(
9709
+ {
9710
+ method: resolveLocalMergeMethod(localCfg.method),
9711
+ approvalRequired: localCfg.approval_required === true
9712
+ },
9713
+ { env: process.env }
9714
+ )
9715
+ });
9716
+ }
9717
+ }
9718
+ return processMergeFn(acc, event);
9719
+ },
9368
9720
  postActionWaitSeam: async (ek, tk) => postActionWaitSeam(ek, tk),
9369
9721
  escalateOnce: async (ek, reason2) => escalateOnce(ek, reason2),
9370
9722
  log,
@@ -9393,7 +9745,7 @@ async function runEpicTick(options, deps = {}) {
9393
9745
  errorLog(`[epic-tick] teardown: branch-delete failed (${safeMsg}) for ${tk}`);
9394
9746
  }
9395
9747
  try {
9396
- spawnSync("git", ["worktree", "remove", "--force", tk], { stdio: "ignore" });
9748
+ spawnSync3("git", ["worktree", "remove", "--force", tk], { stdio: "ignore" });
9397
9749
  log(`[epic-tick] teardown: worktree removed for ${tk}`);
9398
9750
  } catch {
9399
9751
  }
@@ -9622,12 +9974,21 @@ async function buildProductionEpicRuntimeDeps(epicKey) {
9622
9974
  dryRun: dispatchDryRun,
9623
9975
  autoApprove: true,
9624
9976
  maxParallel: 1,
9625
- refreshMain: false,
9977
+ // F-base: a merge-gated dependent MUST cut from the predecessor's merged
9978
+ // code. With refreshMain:false the worktree was cut from a STALE local
9979
+ // `main` (never fetched/ff'd after the predecessor merged on origin), so
9980
+ // dependents built without the predecessor's code — defeating the whole
9981
+ // merge-gated handoff. Refresh (fetch origin + ff local base) before cut.
9982
+ refreshMain: true,
9626
9983
  branchOverrides: {},
9627
9984
  baseBranch: "main",
9628
9985
  conductorEnabled: true,
9629
9986
  // BAPI-441: re-dispatch reuses the existing branch/worktree.
9630
- resumeMode: isResume
9987
+ resumeMode: isResume,
9988
+ // F7: on a FRESH dispatch, refuse a stale leftover `feature/<KEY>` branch
9989
+ // (e.g. a prior run's worktree) rather than silently building on it. No
9990
+ // effect on resume (which reuses a located worktree via a different path).
9991
+ guardStaleWorktree: !isResume
9631
9992
  }, {
9632
9993
  createConductorContext: createStartTicketsConductorContext,
9633
9994
  provisionConductorHooksForRows,
@@ -9755,12 +10116,14 @@ async function buildProductionEpicRuntimeDeps(epicKey) {
9755
10116
  // defined inline in the reconcileDeps object in runEpicTick.
9756
10117
  };
9757
10118
  }
9758
- var DEFAULT_LEASE_TTL_SECONDS, DEFAULT_MAX_DRIFT_MS, DEFAULT_DISPATCH_KEY_TTL_SECONDS, ACTIVE_WORKER_STATUSES, parseWaitStateMap;
10119
+ var DEFAULT_LEASE_TTL_SECONDS, DEFAULT_MAX_DRIFT_MS, DEFAULT_DISPATCH_KEY_TTL_SECONDS, ACTIVE_WORKER_STATUSES, PARSE_WAIT_EVENT_SOURCE, PARSE_WAIT_EVENT_PRODUCER;
9759
10120
  var init_epic_runtime = __esm({
9760
10121
  "src/conductor/epic-runtime.ts"() {
9761
10122
  "use strict";
9762
10123
  init_bridge_api_client();
9763
10124
  init_supervisor_merge();
10125
+ init_local_merge();
10126
+ init_producer_ledger();
9764
10127
  init_epic_state();
9765
10128
  init_epic_reconcile();
9766
10129
  init_supervisor_message_relay();
@@ -9776,7 +10139,8 @@ var init_epic_runtime = __esm({
9776
10139
  DEFAULT_MAX_DRIFT_MS = 3e4;
9777
10140
  DEFAULT_DISPATCH_KEY_TTL_SECONDS = 300;
9778
10141
  ACTIVE_WORKER_STATUSES = /* @__PURE__ */ new Set(["dispatched", "running"]);
9779
- parseWaitStateMap = /* @__PURE__ */ new Map();
10142
+ PARSE_WAIT_EVENT_SOURCE = "conductor-supervisor";
10143
+ PARSE_WAIT_EVENT_PRODUCER = "epic-parse-wait";
9780
10144
  }
9781
10145
  });
9782
10146
 
@@ -11170,85 +11534,7 @@ function inspectConductorGitHooks(deps = {}) {
11170
11534
 
11171
11535
  // src/conductor/git-producer.ts
11172
11536
  init_git_ci_types();
11173
-
11174
- // src/conductor/producer-ledger.ts
11175
- init_store();
11176
- init_git_ci_types();
11177
- import { createHash as createHash2 } from "node:crypto";
11178
- function makeProducerDedupeKey(dimensions) {
11179
- const canonical = {};
11180
- for (const [key, value] of Object.entries(dimensions)) {
11181
- if (value !== void 0 && value !== null) canonical[key] = value;
11182
- }
11183
- return stableJsonHash(canonical);
11184
- }
11185
- function makeStableProducerEventId(dedupeKey) {
11186
- const h = createHash2("sha256").update(`conductor-producer:${dedupeKey}`).digest("hex");
11187
- return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
11188
- }
11189
- function isDuplicateConstraintError2(error) {
11190
- if (!error || typeof error !== "object") return false;
11191
- const code = error.code;
11192
- if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT")) return true;
11193
- const message = error.message;
11194
- if (typeof message === "string") {
11195
- const lowered = message.toLowerCase();
11196
- if (lowered.includes("unique constraint") || lowered.includes("constraint failed")) return true;
11197
- }
11198
- return false;
11199
- }
11200
- var LEDGER_SCAN_PAGE_LIMIT = 500;
11201
- var LEDGER_SCAN_MAX_PAGES = 200;
11202
- async function eventAlreadyExists(dedupeKey, deps = {}) {
11203
- const pollEvents = deps.pollEvents ?? ((options) => pollConductorEvents(options));
11204
- let sinceSeq = 1;
11205
- for (let page = 0; page < LEDGER_SCAN_MAX_PAGES; page += 1) {
11206
- let result;
11207
- try {
11208
- result = await pollEvents({ since_seq: sinceSeq, data_mode: "full", limit: LEDGER_SCAN_PAGE_LIMIT });
11209
- } catch {
11210
- return false;
11211
- }
11212
- for (const event of result.events) {
11213
- if (!event || typeof event !== "object") continue;
11214
- const data = event.data;
11215
- if (data && typeof data === "object") {
11216
- const details = data.details;
11217
- if (details && typeof details === "object" && details.dedupe_key === dedupeKey) {
11218
- return true;
11219
- }
11220
- }
11221
- }
11222
- if (result.count === 0 || result.next_seq <= sinceSeq) break;
11223
- sinceSeq = result.next_seq;
11224
- }
11225
- return false;
11226
- }
11227
- async function emitConductorEventIfNew(input, dimensions, deps = {}) {
11228
- const emitEvent = deps.emitEvent ?? emitConductorEvent;
11229
- const dedupeKey = makeProducerDedupeKey(dimensions);
11230
- if (await eventAlreadyExists(dedupeKey, deps)) {
11231
- return { emitted: false, reason: "duplicate" };
11232
- }
11233
- const eventId = makeStableProducerEventId(dedupeKey);
11234
- const existingData = input.data ?? {};
11235
- const existingDetails = existingData.details && typeof existingData.details === "object" && !Array.isArray(existingData.details) ? existingData.details : {};
11236
- const data = {
11237
- ...existingData,
11238
- details: { ...existingDetails, dedupe_key: dedupeKey }
11239
- };
11240
- try {
11241
- await emitEvent({ ...input, id: eventId, data });
11242
- return { emitted: true, event_id: eventId };
11243
- } catch (error) {
11244
- if (isDuplicateConstraintError2(error)) {
11245
- return { emitted: false, reason: "duplicate" };
11246
- }
11247
- throw error;
11248
- }
11249
- }
11250
-
11251
- // src/conductor/git-producer.ts
11537
+ init_producer_ledger();
11252
11538
  var COMMITTED_REF_PHASE = "committed";
11253
11539
  function buildCommitCreatedEventInput(context, metadata) {
11254
11540
  const details = {
@@ -11350,6 +11636,7 @@ async function runReferenceTransactionHookProducer(args, deps = {}) {
11350
11636
 
11351
11637
  // src/conductor/doctor.ts
11352
11638
  init_store();
11639
+ import { spawnSync } from "node:child_process";
11353
11640
  async function inspectEpicTickSchedule(deps, orchestrateListOverride) {
11354
11641
  try {
11355
11642
  const schedRun = orchestrateListOverride ? null : await Promise.resolve().then(() => (init_schedule_run(), schedule_run_exports));
@@ -11416,6 +11703,46 @@ function inspectMcpProfile(env, epicTick) {
11416
11703
  }
11417
11704
  return { resolved_profile, conductor_context_detected, degraded, warnings };
11418
11705
  }
11706
+ function inspectLocalMerge(runCommand) {
11707
+ const run = runCommand ?? ((cmd, args) => {
11708
+ try {
11709
+ const r = spawnSync(cmd, args, {
11710
+ encoding: "utf8",
11711
+ timeout: 1e4,
11712
+ env: { ...process.env, GH_PROMPT_DISABLED: "1" }
11713
+ });
11714
+ return { status: r.status };
11715
+ } catch {
11716
+ return { status: null };
11717
+ }
11718
+ });
11719
+ let gh_available = false;
11720
+ let gh_authed = false;
11721
+ try {
11722
+ gh_available = run("gh", ["--version"]).status === 0;
11723
+ } catch {
11724
+ gh_available = false;
11725
+ }
11726
+ if (gh_available) {
11727
+ try {
11728
+ gh_authed = run("gh", ["auth", "status"]).status === 0;
11729
+ } catch {
11730
+ gh_authed = false;
11731
+ }
11732
+ }
11733
+ const warnings = [];
11734
+ if (!gh_available) {
11735
+ warnings.push(
11736
+ "`gh` is not installed or not on PATH. Local merge (policy_json.local_merge.enabled) cannot run; install the GitHub CLI to enable conductor-driven merges."
11737
+ );
11738
+ } else if (!gh_authed) {
11739
+ warnings.push(
11740
+ "`gh` is installed but not authenticated (`gh auth status` failed). Run `gh auth login` to grant the conductor merge permission; otherwise local merge will emit merge.failed/skip."
11741
+ );
11742
+ }
11743
+ const degraded = !gh_available || !gh_authed;
11744
+ return { gh_available, gh_authed, degraded, warnings };
11745
+ }
11419
11746
  async function buildConductorDoctorReport(deps = {}) {
11420
11747
  const doctorLedger = deps.doctorLedger ?? doctorConductorLedger;
11421
11748
  const inspectHooks = deps.inspectHooks ?? inspectConductorGitHooks;
@@ -11425,11 +11752,12 @@ async function buildConductorDoctorReport(deps = {}) {
11425
11752
  ledger: await doctorLedger(),
11426
11753
  git_hooks: inspectHooks(deps.hooksDeps),
11427
11754
  epic_tick: epicTick,
11428
- mcp_profile
11755
+ mcp_profile,
11756
+ local_merge: inspectLocalMerge(deps.runCommand)
11429
11757
  };
11430
11758
  }
11431
11759
  function formatConductorDoctorReport(report) {
11432
- const { ledger, git_hooks, epic_tick, mcp_profile } = report;
11760
+ const { ledger, git_hooks, epic_tick, mcp_profile, local_merge } = report;
11433
11761
  const lines = [
11434
11762
  "Conductor ledger doctor",
11435
11763
  "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
@@ -11492,6 +11820,17 @@ function formatConductorDoctorReport(report) {
11492
11820
  lines.push("mcp profile warnings:");
11493
11821
  for (const w of mcp_profile.warnings) lines.push(` - ${w}`);
11494
11822
  }
11823
+ lines.push("");
11824
+ lines.push("Local merge capability (optional, opt-in)");
11825
+ lines.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
11826
+ const ghTag = local_merge.gh_available ? local_merge.gh_authed ? "[OK]" : "[WARNING] not authenticated" : "[WARNING] not installed";
11827
+ lines.push(`gh available: ${local_merge.gh_available} ${ghTag}`);
11828
+ lines.push(`gh authenticated: ${local_merge.gh_authed}`);
11829
+ lines.push(`degraded: ${local_merge.degraded}`);
11830
+ if (local_merge.warnings.length > 0) {
11831
+ lines.push("local merge warnings:");
11832
+ for (const w of local_merge.warnings) lines.push(` - ${w}`);
11833
+ }
11495
11834
  return lines.join("\n");
11496
11835
  }
11497
11836