@adhdev/daemon-core 0.9.82-rc.260 → 0.9.82-rc.262

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.
package/dist/index.js CHANGED
@@ -28744,8 +28744,12 @@ var TerminalAdapter = class {
28744
28744
  this.rows = opts.rows ?? import_session_host_core6.DEFAULT_SESSION_HOST_ROWS;
28745
28745
  this.screenDebounceMs = opts.screenChangeDebounceMs ?? 80;
28746
28746
  this.tickIntervalMs = opts.tickIntervalMs ?? 0;
28747
- const { NodePtyTransportFactory: NodePtyTransportFactory2 } = (init_pty_transport(), __toCommonJS(pty_transport_exports));
28748
- this.factory = opts.transportFactory ?? new NodePtyTransportFactory2();
28747
+ if (opts.transportFactory) {
28748
+ this.factory = opts.transportFactory;
28749
+ } else {
28750
+ const { NodePtyTransportFactory: NodePtyTransportFactory2 } = (init_pty_transport(), __toCommonJS(pty_transport_exports));
28751
+ this.factory = new NodePtyTransportFactory2();
28752
+ }
28749
28753
  this.screen = new TerminalScreen(this.rows, this.cols);
28750
28754
  }
28751
28755
  rows;
@@ -28902,6 +28906,10 @@ var FsmDriver = class {
28902
28906
  specWatcher = null;
28903
28907
  /** Last full FSM evaluation, kept for the debugger. */
28904
28908
  lastFsmEval = null;
28909
+ /** Ring buffer (max 20) of the full FSM evaluation captured at each
28910
+ * transition — the rich pre-transition table that lastFsmEval only keeps
28911
+ * for the single most recent evaluation. Separate from stateHistory. */
28912
+ fsmSnapshotHistory = [];
28905
28913
  subscribe(listener) {
28906
28914
  this.listeners.add(listener);
28907
28915
  return () => {
@@ -28992,6 +29000,9 @@ var FsmDriver = class {
28992
29000
  getStateHistory() {
28993
29001
  return this.stateHistory;
28994
29002
  }
29003
+ getFsmSnapshotHistory() {
29004
+ return this.fsmSnapshotHistory;
29005
+ }
28995
29006
  getSections() {
28996
29007
  try {
28997
29008
  const screen = this.adapter.snapshot();
@@ -29087,7 +29098,7 @@ var FsmDriver = class {
29087
29098
  this.lastFsmEval = ev;
29088
29099
  this.prevScreenLines = currentLines;
29089
29100
  if (ev.fired) {
29090
- this.commitTransition(ev.fired, now);
29101
+ this.commitTransition(ev.fired, now, ev);
29091
29102
  this.emitStateChanged(forceEmit);
29092
29103
  this.scheduleWakeForState();
29093
29104
  return;
@@ -29096,8 +29107,9 @@ var FsmDriver = class {
29096
29107
  this.scheduleWakeForState();
29097
29108
  this.maybeMarkReady();
29098
29109
  }
29099
- commitTransition(fired, now) {
29110
+ commitTransition(fired, now, ev) {
29100
29111
  const from = this.currentStateId;
29112
+ this.pushFsmSnapshot(from, fired, now, ev);
29101
29113
  this.currentStateId = fired.to;
29102
29114
  this.stateEnteredAt = now;
29103
29115
  this.regionLastChangedAt.clear();
@@ -29108,6 +29120,22 @@ var FsmDriver = class {
29108
29120
  });
29109
29121
  LOG.info("FsmDriver", `[${this.specTag()}] ${from} \u2192 ${fired.to} (${fired.label})`);
29110
29122
  }
29123
+ /** Snapshot the full FSM evaluation that produced a transition into the
29124
+ * separate fsmSnapshotHistory ring buffer (max 20). The transitions[]
29125
+ * table is captured by reference — it is freshly built per evaluation in
29126
+ * evaluateFsm and never mutated after, so no clone is needed. */
29127
+ pushFsmSnapshot(from, fired, now, ev) {
29128
+ this.fsmSnapshotHistory.push({
29129
+ stateFrom: from,
29130
+ stateTo: fired.to,
29131
+ at: now,
29132
+ firedTo: fired.to,
29133
+ firedLabel: fired.label,
29134
+ reason: summarizeTransition(fired),
29135
+ transitions: ev.transitions
29136
+ });
29137
+ if (this.fsmSnapshotHistory.length > 20) this.fsmSnapshotHistory.shift();
29138
+ }
29111
29139
  /** Re-derive the visible modal + controls for the current state and emit a
29112
29140
  * state_changed if anything differs from the last emit. */
29113
29141
  emitStateChanged(forceEmit) {
@@ -30140,7 +30168,7 @@ init_logger();
30140
30168
  function stripAnsi3(text) {
30141
30169
  return String(text || "").replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, "").replace(/\x1B[P^_X][\s\S]*?(?:\x07|\x1B\\)/g, "").replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
30142
30170
  }
30143
- var SpecCliAdapter = class {
30171
+ var SpecCliAdapter = class _SpecCliAdapter {
30144
30172
  cliType;
30145
30173
  cliName;
30146
30174
  workingDir;
@@ -30165,6 +30193,23 @@ var SpecCliAdapter = class {
30165
30193
  activeInteractivePrompt = null;
30166
30194
  interactivePromptTransport = null;
30167
30195
  claudeTuiPromptCaptureInFlight = false;
30196
+ /**
30197
+ * Wall clock of the first frame on which a held interactive prompt was
30198
+ * observed to have left the screen. Mirrors the approval FSM's
30199
+ * `modalLostAt` hysteresis (see cli-state-engine.ts): claude-cli's TUI
30200
+ * repaints the choice picker as several PTY chunks, so a single frame
30201
+ * with no "Enter to select" footer is not proof the prompt is gone — it
30202
+ * may just be mid-repaint. We only clear the held prompt once it has
30203
+ * been absent across a short grace window. Reset to null the moment the
30204
+ * prompt footer reappears.
30205
+ *
30206
+ * Without this, a choice prompt resolved *directly in the terminal* (the
30207
+ * user picked an option without going through ADHDev's
30208
+ * setInteractivePromptResponse) was never cleared from
30209
+ * `activeInteractivePrompt`, so getStatus() re-emitted the same prompt
30210
+ * forever — the choice-resolve-stuck bug.
30211
+ */
30212
+ interactivePromptLostAt = null;
30168
30213
  jsonLineTail = "";
30169
30214
  exited = false;
30170
30215
  spawned = false;
@@ -30425,6 +30470,11 @@ var SpecCliAdapter = class {
30425
30470
  // transition from the current state with its per-condition match
30426
30471
  // result + countdown — the canonical "why isn't it moving" answer.
30427
30472
  fsm: this.driver.getFsmDebug?.() ?? null,
30473
+ // v4 FSM transition snapshot history (null for v3 specs). The full
30474
+ // pre-transition evaluation table captured at each transition —
30475
+ // answers "why did this rule fire" after the fact, unlike the live
30476
+ // `fsm` field which only reflects the current instant.
30477
+ fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
30428
30478
  // Extended fields
30429
30479
  name: this.cliName,
30430
30480
  status: this.getStatus().status,
@@ -30462,11 +30512,13 @@ var SpecCliAdapter = class {
30462
30512
  if (ev.state.title) {
30463
30513
  LOG.debug("SpecAdapter", `[${this.cliType}] state.title=${JSON.stringify(ev.state.title)}`);
30464
30514
  }
30515
+ this.maybeClearResolvedClaudeTuiPrompt();
30465
30516
  this.maybeCaptureClaudeTuiPrompt();
30466
30517
  this.statusCallback?.();
30467
30518
  return;
30468
30519
  case "pty_data":
30469
30520
  this.detectInteractivePromptFromPtyChunk(ev.chunk);
30521
+ this.maybeClearResolvedClaudeTuiPrompt();
30470
30522
  this.maybeCaptureClaudeTuiPrompt();
30471
30523
  try {
30472
30524
  this.ptyDataCallback?.(ev.chunk);
@@ -30499,6 +30551,7 @@ var SpecCliAdapter = class {
30499
30551
  if (!prompt) continue;
30500
30552
  this.activeInteractivePrompt = prompt;
30501
30553
  this.interactivePromptTransport = "stream-json";
30554
+ this.interactivePromptLostAt = null;
30502
30555
  this.statusCallback?.();
30503
30556
  } catch {
30504
30557
  }
@@ -30552,6 +30605,47 @@ var SpecCliAdapter = class {
30552
30605
  }
30553
30606
  return messages;
30554
30607
  }
30608
+ /**
30609
+ * Grace window a held interactive prompt must be absent from the screen
30610
+ * before we treat it as resolved-in-terminal and clear it. claude-cli
30611
+ * repaints the picker across multiple PTY chunks, so a single
30612
+ * footer-less frame is not proof the prompt is gone. Sized in the same
30613
+ * spirit as the approval FSM's `approvalCooldown` modal-lost hysteresis.
30614
+ */
30615
+ static INTERACTIVE_PROMPT_LOST_GRACE_MS = 1500;
30616
+ /**
30617
+ * Clear a held interactive prompt once the user has resolved it directly
30618
+ * in the terminal (the choice picker leaves the screen without going
30619
+ * through setInteractivePromptResponse). The approval path already does
30620
+ * this via the FSM's modal-lost hysteresis; the interactive-prompt path
30621
+ * had no equivalent, so a terminal-side answer left activeInteractivePrompt
30622
+ * set and getStatus() re-emitted the same choice modal forever.
30623
+ *
30624
+ * Detection mirrors capture: the claude TUI picker is on-screen exactly
30625
+ * while its "Enter to select" footer is rendered. When the footer is gone
30626
+ * for INTERACTIVE_PROMPT_LOST_GRACE_MS the prompt is genuinely resolved.
30627
+ */
30628
+ maybeClearResolvedClaudeTuiPrompt() {
30629
+ if (this.cliType !== "claude-cli" || !this.activeInteractivePrompt) return;
30630
+ let screenText = "";
30631
+ try {
30632
+ screenText = this.driver.snapshot();
30633
+ } catch {
30634
+ return;
30635
+ }
30636
+ const stillOnScreen = screenText.includes("Enter to select");
30637
+ if (stillOnScreen) {
30638
+ this.interactivePromptLostAt = null;
30639
+ return;
30640
+ }
30641
+ const lostAt = this.interactivePromptLostAt ?? Date.now();
30642
+ if (this.interactivePromptLostAt === null) this.interactivePromptLostAt = lostAt;
30643
+ if (Date.now() - lostAt < _SpecCliAdapter.INTERACTIVE_PROMPT_LOST_GRACE_MS) return;
30644
+ this.activeInteractivePrompt = null;
30645
+ this.interactivePromptTransport = null;
30646
+ this.interactivePromptLostAt = null;
30647
+ this.statusCallback?.();
30648
+ }
30555
30649
  maybeCaptureClaudeTuiPrompt() {
30556
30650
  if (this.cliType !== "claude-cli" || this.activeInteractivePrompt || this.claudeTuiPromptCaptureInFlight) return;
30557
30651
  const screenText = this.driver.snapshot();
@@ -30565,6 +30659,7 @@ var SpecCliAdapter = class {
30565
30659
  if (!prompt) return;
30566
30660
  this.activeInteractivePrompt = prompt;
30567
30661
  this.interactivePromptTransport = "tui";
30662
+ this.interactivePromptLostAt = null;
30568
30663
  this.statusCallback?.();
30569
30664
  return;
30570
30665
  }
@@ -30601,6 +30696,7 @@ var SpecCliAdapter = class {
30601
30696
  if (!prompt) return;
30602
30697
  this.activeInteractivePrompt = prompt;
30603
30698
  this.interactivePromptTransport = "tui";
30699
+ this.interactivePromptLostAt = null;
30604
30700
  this.statusCallback?.();
30605
30701
  }
30606
30702
  getDebugState() {
@@ -30655,6 +30751,9 @@ var SpecCliAdapter = class {
30655
30751
  // and countdown. This is the canonical "why isn't it transitioning"
30656
30752
  // answer — no screenshots needed.
30657
30753
  fsm: this.driver.getFsmDebug?.() ?? null,
30754
+ // v4 FSM transition snapshot history — the captured pre-transition
30755
+ // evaluation table at each transition (null for v3 specs).
30756
+ fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
30658
30757
  messages,
30659
30758
  committedMessages: messages
30660
30759
  };
@@ -40650,8 +40749,37 @@ async function runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead)
40650
40749
  maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES
40651
40750
  });
40652
40751
  const mergeBase = git(["merge-base", baseHead, branchHead]).trim();
40653
- const mergeTreeStdout = git(["merge-tree", "--write-tree", baseHead, branchHead]);
40654
- const mergedTree = mergeTreeStdout.trim().split(/\s+/)[0] || "";
40752
+ let mergedTree = "";
40753
+ let mergeTreeStdout = "";
40754
+ let gitlinkTrivialFastForward;
40755
+ try {
40756
+ mergeTreeStdout = git(["merge-tree", "--write-tree", baseHead, branchHead]);
40757
+ mergedTree = mergeTreeStdout.trim().split(/\s+/)[0] || "";
40758
+ } catch (mergeTreeErr) {
40759
+ const output = `${mergeTreeErr?.message || ""}
40760
+ ${mergeTreeErr?.stdout || ""}
40761
+ ${mergeTreeErr?.stderr || ""}`;
40762
+ const isSubmoduleConflict = /(submodule|160000)/i.test(output) || /Recursive merging with submodules/i.test(output);
40763
+ if (!isSubmoduleConflict) throw mergeTreeErr;
40764
+ const evaluation = evaluateGitlinkTrivialFastForward(repoRoot, baseHead, branchHead);
40765
+ if (!evaluation.trivial) {
40766
+ return {
40767
+ status: "failed",
40768
+ equivalent: false,
40769
+ baseHead,
40770
+ branchHead,
40771
+ mergeBase: mergeBase || void 0,
40772
+ durationMs: Date.now() - startedAt,
40773
+ error: mergeTreeErr?.message || String(mergeTreeErr),
40774
+ stdout: truncateValidationOutput(mergeTreeErr?.stdout),
40775
+ stderr: truncateValidationOutput(mergeTreeErr?.stderr),
40776
+ gitlinkTrivialFastForward: { resolved: false, gitlinks: evaluation.gitlinks, reason: evaluation.reason },
40777
+ actionableHint: buildPatchEquivalenceSubmoduleConflictHint(repoRoot, baseHead, branchHead, output)
40778
+ };
40779
+ }
40780
+ mergedTree = synthesizeTrivialFastForwardMergeTree(repoRoot, baseHead, branchHead, evaluation.gitlinks) || "";
40781
+ gitlinkTrivialFastForward = { resolved: true, gitlinks: evaluation.gitlinks };
40782
+ }
40655
40783
  if (!mergeBase || !mergedTree) {
40656
40784
  return {
40657
40785
  status: "failed",
@@ -40662,7 +40790,8 @@ async function runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead)
40662
40790
  mergedTree: mergedTree || void 0,
40663
40791
  durationMs: Date.now() - startedAt,
40664
40792
  error: "patch equivalence preflight could not resolve merge-base or synthetic merge tree",
40665
- stdout: truncateValidationOutput(mergeTreeStdout)
40793
+ stdout: truncateValidationOutput(mergeTreeStdout),
40794
+ gitlinkTrivialFastForward
40666
40795
  };
40667
40796
  }
40668
40797
  const expectedPatchId = await computeGitPatchId(repoRoot, mergeBase, branchHead);
@@ -40677,7 +40806,8 @@ async function runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead)
40677
40806
  mergedTree,
40678
40807
  expectedPatchId,
40679
40808
  actualPatchId,
40680
- durationMs: Date.now() - startedAt
40809
+ durationMs: Date.now() - startedAt,
40810
+ gitlinkTrivialFastForward
40681
40811
  };
40682
40812
  } catch (e) {
40683
40813
  return {
@@ -40700,6 +40830,65 @@ ${e?.stderr || ""}`
40700
40830
  };
40701
40831
  }
40702
40832
  }
40833
+ async function runMeshRefineEffectiveDiffGate(repoRoot, baseHead, branchHead) {
40834
+ const startedAt = Date.now();
40835
+ try {
40836
+ const { execFileSync: execFileSync6 } = await import("child_process");
40837
+ const git = (args, opts) => execFileSync6("git", args, {
40838
+ cwd: opts?.cwd || repoRoot,
40839
+ encoding: "utf8",
40840
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES
40841
+ });
40842
+ const rawDiff = git(["diff", "--raw", baseHead, branchHead]).trim();
40843
+ if (rawDiff) {
40844
+ const changedPaths = rawDiff.split("\n").map((line) => line.split(" ").slice(1).join(" ").trim()).filter(Boolean).slice(0, 50);
40845
+ return {
40846
+ status: "passed",
40847
+ hasEffectiveDiff: true,
40848
+ baseHead,
40849
+ branchHead,
40850
+ changedPaths,
40851
+ durationMs: Date.now() - startedAt
40852
+ };
40853
+ }
40854
+ const submoduleHints = [];
40855
+ try {
40856
+ const status = git(["submodule", "status"]);
40857
+ for (const line of status.split("\n")) {
40858
+ const trimmed = line.trimEnd();
40859
+ if (!trimmed) continue;
40860
+ if (trimmed.startsWith("+")) {
40861
+ const parts = trimmed.slice(1).trim().split(/\s+/);
40862
+ const path39 = parts[1] || parts[0] || "(unknown)";
40863
+ submoduleHints.push({
40864
+ path: path39,
40865
+ reason: "submodule checked-out commit differs from the committed gitlink (pointer bump not committed on the root branch)"
40866
+ });
40867
+ }
40868
+ }
40869
+ } catch {
40870
+ }
40871
+ return {
40872
+ status: "failed",
40873
+ hasEffectiveDiff: false,
40874
+ baseHead,
40875
+ branchHead,
40876
+ ...submoduleHints.length ? { submoduleHints } : {},
40877
+ durationMs: Date.now() - startedAt
40878
+ };
40879
+ } catch (e) {
40880
+ return {
40881
+ status: "skipped",
40882
+ hasEffectiveDiff: true,
40883
+ baseHead,
40884
+ branchHead,
40885
+ durationMs: Date.now() - startedAt,
40886
+ error: e?.message || String(e),
40887
+ stdout: truncateValidationOutput(e?.stdout),
40888
+ stderr: truncateValidationOutput(e?.stderr)
40889
+ };
40890
+ }
40891
+ }
40703
40892
  function buildPatchEquivalenceSubmoduleConflictHint(repoRoot, baseHead, branchHead, output) {
40704
40893
  if (!/(submodule|160000)/i.test(output) || !/(conflict|failed to merge)/i.test(output)) return void 0;
40705
40894
  const conflicts = readChangedGitlinkPaths(repoRoot, baseHead, branchHead).map((path39) => ({
@@ -40756,6 +40945,135 @@ function readTreeObject(repoRoot, ref, path39) {
40756
40945
  return void 0;
40757
40946
  }
40758
40947
  }
40948
+ function resolveGitDir(repoRoot) {
40949
+ const out = (0, import_node_child_process6.execFileSync)("git", ["rev-parse", "--absolute-git-dir"], {
40950
+ cwd: repoRoot,
40951
+ encoding: "utf8",
40952
+ maxBuffer: 1024 * 1024
40953
+ }).trim();
40954
+ return out;
40955
+ }
40956
+ function isSubmoduleFastForward(submoduleRepoPath, baseCommit, branchCommit) {
40957
+ if (!baseCommit || !branchCommit) return false;
40958
+ if (baseCommit === branchCommit) return true;
40959
+ try {
40960
+ if (!fs23.existsSync(submoduleRepoPath)) return false;
40961
+ (0, import_node_child_process6.execFileSync)("git", ["cat-file", "-e", `${baseCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: "ignore" });
40962
+ (0, import_node_child_process6.execFileSync)("git", ["cat-file", "-e", `${branchCommit}^{commit}`], { cwd: submoduleRepoPath, stdio: "ignore" });
40963
+ (0, import_node_child_process6.execFileSync)("git", ["merge-base", "--is-ancestor", baseCommit, branchCommit], { cwd: submoduleRepoPath, stdio: "ignore" });
40964
+ return true;
40965
+ } catch {
40966
+ return false;
40967
+ }
40968
+ }
40969
+ function readChangedPathKinds(repoRoot, fromRef, toRef) {
40970
+ try {
40971
+ const output = (0, import_node_child_process6.execFileSync)("git", ["diff", "--raw", "--no-abbrev", fromRef, toRef], {
40972
+ cwd: repoRoot,
40973
+ encoding: "utf8",
40974
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES
40975
+ });
40976
+ const result = [];
40977
+ const seen = /* @__PURE__ */ new Set();
40978
+ for (const line of output.split("\n")) {
40979
+ if (!line.trim()) continue;
40980
+ const metaAndPath = line.split(" ");
40981
+ const meta = metaAndPath[0] || "";
40982
+ const path39 = metaAndPath[metaAndPath.length - 1]?.trim();
40983
+ if (!path39 || seen.has(path39)) continue;
40984
+ seen.add(path39);
40985
+ const parts = meta.split(/\s+/);
40986
+ const isGitlink = !!(parts[0]?.includes("160000") || parts[1]?.includes("160000"));
40987
+ result.push({ path: path39, isGitlink });
40988
+ }
40989
+ return result;
40990
+ } catch {
40991
+ return [];
40992
+ }
40993
+ }
40994
+ function evaluateGitlinkTrivialFastForward(repoRoot, baseHead, branchHead) {
40995
+ const changedGitlinks = readChangedGitlinkPaths(repoRoot, baseHead, branchHead).map((path39) => {
40996
+ const baseCommit = readTreeObject(repoRoot, baseHead, path39);
40997
+ const branchCommit = readTreeObject(repoRoot, branchHead, path39);
40998
+ const submoduleRepoPath = (0, import_path10.resolve)(repoRoot, path39);
40999
+ const fastForward = !!baseCommit && !!branchCommit && isSubmoduleFastForward(submoduleRepoPath, baseCommit, branchCommit);
41000
+ return { path: path39, baseCommit, branchCommit, fastForward };
41001
+ });
41002
+ if (changedGitlinks.length === 0) {
41003
+ return { trivial: false, reason: "no_changed_gitlinks", gitlinks: changedGitlinks };
41004
+ }
41005
+ const nonFastForward = changedGitlinks.filter((entry) => !entry.fastForward);
41006
+ if (nonFastForward.length > 0) {
41007
+ return {
41008
+ trivial: false,
41009
+ reason: `diverged_gitlinks:${nonFastForward.map((entry) => entry.path).join(",")}`,
41010
+ gitlinks: changedGitlinks
41011
+ };
41012
+ }
41013
+ let mergeBase = "";
41014
+ try {
41015
+ mergeBase = (0, import_node_child_process6.execFileSync)("git", ["merge-base", baseHead, branchHead], {
41016
+ cwd: repoRoot,
41017
+ encoding: "utf8",
41018
+ maxBuffer: 1024 * 1024
41019
+ }).trim();
41020
+ } catch {
41021
+ return { trivial: false, reason: "merge_base_unresolved", gitlinks: changedGitlinks };
41022
+ }
41023
+ if (!mergeBase) {
41024
+ return { trivial: false, reason: "merge_base_unresolved", gitlinks: changedGitlinks };
41025
+ }
41026
+ const baseSideChanges = readChangedPathKinds(repoRoot, mergeBase, baseHead);
41027
+ const branchSideChanges = readChangedPathKinds(repoRoot, mergeBase, branchHead);
41028
+ const baseChangedPaths = new Map(baseSideChanges.map((entry) => [entry.path, entry]));
41029
+ const overlapping = branchSideChanges.filter((entry) => baseChangedPaths.has(entry.path));
41030
+ const nonGitlinkOverlap = overlapping.filter((entry) => {
41031
+ const baseEntry = baseChangedPaths.get(entry.path);
41032
+ return !(entry.isGitlink && baseEntry?.isGitlink);
41033
+ });
41034
+ if (nonGitlinkOverlap.length > 0) {
41035
+ return {
41036
+ trivial: false,
41037
+ reason: `non_gitlink_overlap:${nonGitlinkOverlap.map((entry) => entry.path).join(",")}`,
41038
+ gitlinks: changedGitlinks
41039
+ };
41040
+ }
41041
+ return { trivial: true, gitlinks: changedGitlinks };
41042
+ }
41043
+ function synthesizeTrivialFastForwardMergeTree(repoRoot, baseHead, branchHead, gitlinks) {
41044
+ try {
41045
+ const baseTree = (0, import_node_child_process6.execFileSync)("git", ["rev-parse", `${baseHead}^{tree}`], {
41046
+ cwd: repoRoot,
41047
+ encoding: "utf8",
41048
+ maxBuffer: 1024 * 1024
41049
+ }).trim();
41050
+ if (!baseTree) return void 0;
41051
+ const updates = gitlinks.filter((entry) => entry.branchCommit).map((entry) => `160000 commit ${entry.branchCommit} ${entry.path}`).join("\n");
41052
+ if (!updates) return baseTree;
41053
+ const tmpIndex = (0, import_path10.join)(resolveGitDir(repoRoot), `adhdev-refine-ff-${baseHead.slice(0, 12)}-${branchHead.slice(0, 12)}.index`);
41054
+ const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
41055
+ try {
41056
+ (0, import_node_child_process6.execFileSync)("git", ["read-tree", baseTree], { cwd: repoRoot, env, stdio: "ignore" });
41057
+ (0, import_node_child_process6.execFileSync)("git", ["update-index", "--index-info"], {
41058
+ cwd: repoRoot,
41059
+ env,
41060
+ input: `${updates}
41061
+ `,
41062
+ encoding: "utf8",
41063
+ stdio: ["pipe", "ignore", "ignore"]
41064
+ });
41065
+ const newTree = (0, import_node_child_process6.execFileSync)("git", ["write-tree"], { cwd: repoRoot, env, encoding: "utf8" }).trim();
41066
+ return newTree || void 0;
41067
+ } finally {
41068
+ try {
41069
+ fs23.rmSync(tmpIndex, { force: true });
41070
+ } catch {
41071
+ }
41072
+ }
41073
+ } catch {
41074
+ return void 0;
41075
+ }
41076
+ }
40759
41077
  async function alignRefinerySubmodulesAfterMerge(repoRoot, previousBaseHead, currentHead, options = {}) {
40760
41078
  const startedAt = Date.now();
40761
41079
  const changedGitlinkPaths = readChangedGitlinkPaths(repoRoot, previousBaseHead, currentHead).filter((path39) => !(options.submoduleIgnorePaths || []).includes(path39));
@@ -41423,6 +41741,10 @@ var DaemonCommandRouter = class {
41423
41741
  runningRefineJobs = /* @__PURE__ */ new Map();
41424
41742
  /** Terminal async Refinery jobs preserve a clear answer after the worktree node has been removed. */
41425
41743
  terminalRefineJobs = /* @__PURE__ */ new Map();
41744
+ /** In-memory async batch Refinery jobs keyed by meshId (one batch convergence per mesh at a time). */
41745
+ runningRefineBatchJobs = /* @__PURE__ */ new Map();
41746
+ /** Terminal async batch Refinery jobs preserve the last batch outcome for late readers. */
41747
+ terminalRefineBatchJobs = /* @__PURE__ */ new Map();
41426
41748
  constructor(deps) {
41427
41749
  this.deps = deps;
41428
41750
  }
@@ -42652,6 +42974,48 @@ ${tail}` : ""
42652
42974
  }
42653
42975
  };
42654
42976
  }
42977
+ const effectiveDiffStarted = Date.now();
42978
+ const effectiveDiff = await runMeshRefineEffectiveDiffGate(repoRoot, baseHead, branchHead);
42979
+ recordMeshRefineStage(refineStages, "effective_diff", effectiveDiff.status, effectiveDiffStarted, {
42980
+ hasEffectiveDiff: effectiveDiff.hasEffectiveDiff,
42981
+ changedPaths: effectiveDiff.changedPaths,
42982
+ submoduleHints: effectiveDiff.submoduleHints,
42983
+ ...effectiveDiff.error ? { error: effectiveDiff.error } : {}
42984
+ });
42985
+ if (effectiveDiff.status === "failed" && !effectiveDiff.hasEffectiveDiff) {
42986
+ const hintLines = (effectiveDiff.submoduleHints || []).map((h) => ` - ${h.path}: ${h.reason}`);
42987
+ const message = [
42988
+ `Refinery no-op guard: branch '${branch}' has no effective root-tree diff against '${baseBranch}' (${baseHead.slice(0, 12)}); nothing would merge.`,
42989
+ "This usually means a submodule (e.g. oss) has commits but the root branch never committed the gitlink (pointer) bump, so the merge would be a silent no-op while the real change never reaches main.",
42990
+ hintLines.length ? `Submodules with uncommitted pointer bumps:
42991
+ ${hintLines.join("\n")}` : "",
42992
+ `Fix: commit the submodule pointer bump on '${branch}' (git add <submodule-path> && git commit), then re-run refine.`
42993
+ ].filter(Boolean).join("\n");
42994
+ return {
42995
+ success: false,
42996
+ code: "no_effective_diff",
42997
+ convergenceStatus: "blocked_review",
42998
+ error: message,
42999
+ branch,
43000
+ into: baseBranch,
43001
+ validationSummary,
43002
+ patchEquivalence,
43003
+ effectiveDiff,
43004
+ refineStages,
43005
+ finalBranchConvergenceState: {
43006
+ branch,
43007
+ baseBranch,
43008
+ merged: false,
43009
+ removed: false,
43010
+ validation: "passed",
43011
+ patchEquivalence: "passed",
43012
+ effectiveDiff: "no_effective_diff",
43013
+ status: "blocked_review",
43014
+ reason: "no_effective_diff",
43015
+ ...effectiveDiff.submoduleHints?.length ? { submoduleHints: effectiveDiff.submoduleHints } : {}
43016
+ }
43017
+ };
43018
+ }
42655
43019
  let mergeResult;
42656
43020
  const mergeStarted = Date.now();
42657
43021
  try {
@@ -42993,6 +43357,17 @@ ${tail}` : ""
42993
43357
  note: "Dry-run: no validation, rebase, or merge was executed. Re-run with execute=true to converge nodes in this order."
42994
43358
  };
42995
43359
  }
43360
+ return this.runMeshRefineBatchConvergence(meshId, orderedNodes, ordering, args);
43361
+ }
43362
+ /**
43363
+ * Convergence core shared by the synchronous batch entry and the async batch job.
43364
+ * Refines each node in order: the per-node refine pipeline fetches origin/<base>
43365
+ * fresh, so each merged sibling advances the base before the next node's auto-rebase
43366
+ * + patch-equivalence re-check. A blocked/failed node is isolated; the batch
43367
+ * continues with the remaining nodes. Does NOT touch the per-node merge logic — it
43368
+ * only sequences calls to executeMeshRefineNodeSynchronously and aggregates outcomes.
43369
+ */
43370
+ async runMeshRefineBatchConvergence(meshId, orderedNodes, ordering, args) {
42996
43371
  const results = [];
42997
43372
  for (const node of orderedNodes) {
42998
43373
  let result;
@@ -43047,6 +43422,204 @@ ${tail}` : ""
43047
43422
  }
43048
43423
  };
43049
43424
  }
43425
+ buildRefineBatchJobKey(meshId) {
43426
+ return `${meshId}::batch`;
43427
+ }
43428
+ buildRefineBatchJobHandle(args) {
43429
+ return {
43430
+ success: true,
43431
+ async: true,
43432
+ batch: true,
43433
+ status: args.status || "accepted",
43434
+ jobId: args.jobId || `refine_batch_${createInteractionId()}`,
43435
+ interactionId: args.interactionId || createInteractionId(),
43436
+ meshId: args.meshId,
43437
+ batchLabel: `batch:${args.nodeIds.length} node${args.nodeIds.length === 1 ? "" : "s"}`,
43438
+ nodeIds: args.nodeIds,
43439
+ nodeCount: args.nodeIds.length,
43440
+ order: args.order,
43441
+ startedAt: args.startedAt || (/* @__PURE__ */ new Date()).toISOString(),
43442
+ ...args.completedAt ? { completedAt: args.completedAt } : {},
43443
+ ...args.coordinatorDaemonId ? { targetCoordinatorDaemonId: args.coordinatorDaemonId } : {},
43444
+ eventDelivery: { pendingEvents: true, ledger: true },
43445
+ evidence: {
43446
+ pendingEventsCommand: "get_pending_mesh_events",
43447
+ ledgerCommand: "get_mesh_ledger_slice",
43448
+ taskHistoryKind: args.status === "completed" ? "task_completed" : args.status === "failed" ? "task_failed" : "task_dispatched"
43449
+ }
43450
+ };
43451
+ }
43452
+ /**
43453
+ * Emit a batch Refinery terminal/accepted event through the SAME pending-event +
43454
+ * forward mechanism single-node refine uses (queueRefineJobEvent), so the
43455
+ * coordinator's existing refine:accepted/completed/failed handling and message
43456
+ * renderer apply unchanged. The aggregate per-node results ride along in `result`.
43457
+ */
43458
+ queueRefineBatchJobEvent(event, handle, result) {
43459
+ const metadataEvent = {
43460
+ source: "refine_mesh_node_async_job",
43461
+ batch: true,
43462
+ jobId: handle.jobId,
43463
+ interactionId: handle.interactionId,
43464
+ meshId: handle.meshId,
43465
+ nodeId: handle.batchLabel,
43466
+ nodeIds: handle.nodeIds,
43467
+ workspace: void 0,
43468
+ status: handle.status,
43469
+ startedAt: handle.startedAt,
43470
+ completedAt: handle.completedAt,
43471
+ order: handle.order,
43472
+ ...result ? { result } : {}
43473
+ };
43474
+ const eventPayload = {
43475
+ event,
43476
+ meshId: handle.meshId,
43477
+ nodeLabel: handle.batchLabel,
43478
+ nodeId: handle.batchLabel,
43479
+ metadataEvent,
43480
+ queuedAt: Date.now(),
43481
+ ...handle.targetCoordinatorDaemonId ? { targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId } : {}
43482
+ };
43483
+ if (typeof this.deps.instanceManager?.getByCategory === "function") {
43484
+ const forwarded = handleMeshForwardEvent(
43485
+ { instanceManager: this.deps.instanceManager },
43486
+ {
43487
+ event,
43488
+ meshId: handle.meshId,
43489
+ nodeId: handle.batchLabel,
43490
+ jobId: handle.jobId,
43491
+ interactionId: handle.interactionId,
43492
+ status: handle.status,
43493
+ startedAt: handle.startedAt,
43494
+ completedAt: handle.completedAt,
43495
+ ...result ? { result } : {}
43496
+ }
43497
+ );
43498
+ if (forwarded?.success === true) return;
43499
+ LOG.warn("Mesh", `[Refinery] Failed to forward async refine batch event ${event}: ${forwarded?.error || "unknown error"}`);
43500
+ }
43501
+ queuePendingMeshCoordinatorEvent(eventPayload);
43502
+ }
43503
+ async appendRefineBatchJobLedger(kind, handle, result) {
43504
+ try {
43505
+ const { appendLedgerEntry: appendLedgerEntry2 } = await Promise.resolve().then(() => (init_mesh_ledger(), mesh_ledger_exports));
43506
+ appendLedgerEntry2(handle.meshId, {
43507
+ kind,
43508
+ nodeId: handle.batchLabel,
43509
+ payload: {
43510
+ source: "refine_mesh_node_async_job",
43511
+ refineJob: {
43512
+ batch: true,
43513
+ jobId: handle.jobId,
43514
+ interactionId: handle.interactionId,
43515
+ status: handle.status,
43516
+ meshId: handle.meshId,
43517
+ nodeIds: handle.nodeIds,
43518
+ order: handle.order,
43519
+ targetCoordinatorDaemonId: handle.targetCoordinatorDaemonId,
43520
+ startedAt: handle.startedAt,
43521
+ completedAt: handle.completedAt
43522
+ },
43523
+ async: true,
43524
+ batch: true,
43525
+ ...result ? {
43526
+ success: result.success === true,
43527
+ result
43528
+ } : {}
43529
+ }
43530
+ });
43531
+ } catch (e) {
43532
+ LOG.warn("Mesh", `[Refinery] Failed to append async refine batch ledger entry: ${e?.message || e}`);
43533
+ }
43534
+ }
43535
+ async finishMeshRefineBatchJob(handle, orderedNodes, ordering, args) {
43536
+ const key = this.buildRefineBatchJobKey(handle.meshId);
43537
+ let result;
43538
+ try {
43539
+ result = await this.runMeshRefineBatchConvergence(handle.meshId, orderedNodes, ordering, args);
43540
+ } catch (e) {
43541
+ result = { success: false, error: e?.message || String(e), batch: true };
43542
+ }
43543
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
43544
+ const summary = result.summary && typeof result.summary === "object" ? result.summary : void 0;
43545
+ const allConverged = result.allConverged === true;
43546
+ const isTerminalSuccess = result.success === true && allConverged;
43547
+ const nextStep = typeof result.nextStep === "string" && result.nextStep ? result.nextStep : isTerminalSuccess ? "All batched nodes converged onto base. Continue from the updated mesh state." : "Resolve blocked_review / not_mergeable nodes (see per-node code/stage/error in result.results), then re-run mesh_refine_batch for the remaining nodes.";
43548
+ const normalizedResult = {
43549
+ ...result,
43550
+ batch: true,
43551
+ nextStep,
43552
+ ...summary ? {
43553
+ convergenceStatus: allConverged ? "all_converged" : "partial"
43554
+ } : {}
43555
+ };
43556
+ const terminalHandle = this.buildRefineBatchJobHandle({
43557
+ meshId: handle.meshId,
43558
+ nodeIds: handle.nodeIds,
43559
+ order: handle.order,
43560
+ status: isTerminalSuccess ? "completed" : "failed",
43561
+ startedAt: handle.startedAt,
43562
+ completedAt,
43563
+ jobId: handle.jobId,
43564
+ interactionId: handle.interactionId,
43565
+ coordinatorDaemonId: handle.targetCoordinatorDaemonId
43566
+ });
43567
+ const terminal = { ...terminalHandle, result: normalizedResult };
43568
+ this.terminalRefineBatchJobs.set(key, terminal);
43569
+ this.runningRefineBatchJobs.delete(key);
43570
+ this.invalidateAggregateMeshStatus(handle.meshId);
43571
+ await this.appendRefineBatchJobLedger(isTerminalSuccess ? "task_completed" : "task_failed", terminalHandle, normalizedResult);
43572
+ this.queueRefineBatchJobEvent(isTerminalSuccess ? "refine:completed" : "refine:failed", terminalHandle, normalizedResult);
43573
+ }
43574
+ /**
43575
+ * Async entry for the batch Refinery execute path. Mirrors startMeshRefineJob:
43576
+ * resolves the plan synchronously (so target/ordering errors and the dry-run shape
43577
+ * stay synchronous), then for execute=true registers an in-flight batch job, returns
43578
+ * {async:true, status:'accepted', batch:true, ...plan} immediately, and runs the
43579
+ * convergence loop in the background — emitting the same terminal refine event.
43580
+ * Idempotent: a batch already in flight for this mesh returns the running handle
43581
+ * with duplicate:true rather than spawning a second background job.
43582
+ */
43583
+ async startMeshRefineBatchJob(meshId, requestedNodeIds, args) {
43584
+ const plan = await this.batchRefineMeshNodes(meshId, requestedNodeIds, { ...args, dryRun: true, execute: false });
43585
+ const planRecord = plan;
43586
+ if (planRecord.success !== true) return plan;
43587
+ if (args?.dryRun === true && args?.execute !== true) return plan;
43588
+ const order = Array.isArray(planRecord.order) ? planRecord.order.filter((v) => typeof v === "string") : [];
43589
+ const nodeIds = order.slice();
43590
+ if (nodeIds.length === 0) {
43591
+ return { ...planRecord, success: true, batch: true, dryRun: false, async: false };
43592
+ }
43593
+ const key = this.buildRefineBatchJobKey(meshId);
43594
+ const running = this.runningRefineBatchJobs.get(key);
43595
+ if (running) return { ...running, duplicate: true };
43596
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
43597
+ const mesh = meshRecord?.mesh;
43598
+ const allNodes = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
43599
+ const orderedNodes = nodeIds.map((id) => allNodes.find((n) => n.id === id || n.nodeId === id)).filter((n) => !!n);
43600
+ if (orderedNodes.length === 0) {
43601
+ return { success: false, error: "Batch nodes no longer resolvable in mesh", batch: true };
43602
+ }
43603
+ const ordering = {
43604
+ order,
43605
+ rationale: planRecord.orderingRationale
43606
+ };
43607
+ const coordinatorDaemonId = typeof args?.coordinatorDaemonId === "string" && args.coordinatorDaemonId.trim() ? args.coordinatorDaemonId.trim() : this.deps.statusInstanceId || void 0;
43608
+ const handle = this.buildRefineBatchJobHandle({ meshId, nodeIds, order, coordinatorDaemonId });
43609
+ this.runningRefineBatchJobs.set(key, handle);
43610
+ await this.appendRefineBatchJobLedger("task_dispatched", handle);
43611
+ this.queueRefineBatchJobEvent("refine:accepted", handle);
43612
+ setImmediate(() => {
43613
+ void this.finishMeshRefineBatchJob(handle, orderedNodes, ordering, args);
43614
+ });
43615
+ return {
43616
+ ...handle,
43617
+ order,
43618
+ orderingRationale: planRecord.orderingRationale,
43619
+ plan: planRecord.plan,
43620
+ note: "Batch convergence accepted and running in the background. Completion/failure (with per-node results) will be delivered as a terminal refine event; do not poll repeatedly."
43621
+ };
43622
+ }
43050
43623
  async finishMeshRefineJob(handle, args) {
43051
43624
  const key = this.buildRefineJobKey(handle.meshId, handle.targetNodeId);
43052
43625
  let result;
@@ -44594,7 +45167,9 @@ ${tail}` : ""
44594
45167
  const meshId = typeof args?.meshId === "string" ? args.meshId.trim() : "";
44595
45168
  if (!meshId) return { success: false, error: "meshId required" };
44596
45169
  const requestedNodeIds = Array.isArray(args?.nodeIds) ? args.nodeIds.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim()) : void 0;
44597
- return this.batchRefineMeshNodes(meshId, requestedNodeIds, args);
45170
+ const isDryRun = args?.dryRun !== false && args?.execute !== true;
45171
+ if (isDryRun) return this.batchRefineMeshNodes(meshId, requestedNodeIds, args);
45172
+ return this.startMeshRefineBatchJob(meshId, requestedNodeIds, args);
44598
45173
  }
44599
45174
  case "remove_mesh_node": {
44600
45175
  const meshId = typeof args?.meshId === "string" ? args.meshId.trim() : "";