@adhdev/daemon-core 0.9.82-rc.553 → 0.9.82-rc.554

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.
@@ -327,6 +327,92 @@ export declare function resolveRefineryAutoPublishSubmoduleMainCommits(mesh: any
327
327
  source?: string;
328
328
  };
329
329
  export declare function runMeshRefinePatchEquivalenceGate(repoRoot: string, baseHead: string, branchHead: string): Promise<MeshRefinePatchEquivalenceSummary>;
330
+ /**
331
+ * Machine-readable sub-classification of a `patch_equivalence_failed` (and the
332
+ * related submodule-gitlink preflight blocks). The opaque top-level
333
+ * `patch_equivalence_failed` code is preserved for backward compatibility; this
334
+ * detailed reason is added ALONGSIDE it so coordinators no longer have to guess
335
+ * WHY the preflight blocked (the 2026-07-17 hidden-spinner convergence incident:
336
+ * the real cause was a diverged base + an unreachable submodule gitlink artifact,
337
+ * not a real patch conflict, but Refinery only returned the opaque code and the
338
+ * coordinator mis-attributed it to a stale daemon version).
339
+ */
340
+ export type MeshRefinePatchEquivalenceDetailedReasonCode =
341
+ /** Worktree base diverged from target base (HEAD is not a descendant of origin/main). */
342
+ 'base_divergence'
343
+ /** Submodule gitlink commit is not reachable from the submodule's remote main branch (publish needed). */
344
+ | 'submodule_unreachable'
345
+ /** Genuine non-equivalent content: expected tree vs actual merge diff differ. */
346
+ | 'actual_patch_diff'
347
+ /** Submodule gitlink trivial fast-forward mis-judged as non-equivalent (HEAD descends origin/main, patch-id equal, blocked only by the gitlink). */
348
+ | 'trivial_ff_misjudgment'
349
+ /** Already identical to origin/main (ahead 0 / behind 0, no diff) — should be treated as success/no-op. */
350
+ | 'already_converged'
351
+ /** Fallback when the classifier itself could not run (git error); keep the opaque code, note the reason. */
352
+ | 'unclassified';
353
+ export type MeshRefinePatchEquivalenceFailureClassification = {
354
+ detailedReason: MeshRefinePatchEquivalenceDetailedReasonCode;
355
+ /** Human-readable one-line description of the sub-cause. */
356
+ detailedReasonDescription: string;
357
+ /** Suggested next action for the coordinator/owner (free-form, actionable). */
358
+ recommendedAction: string;
359
+ /** Structured supporting evidence: SHAs, ahead/behind, submodule reachability, patch-id comparison, diff stat. */
360
+ evidence: {
361
+ baseHead?: string;
362
+ branchHead?: string;
363
+ mergeBase?: string;
364
+ /** How many commits base (origin/main) is ahead of the branch's merge-base (branch is behind). */
365
+ behind?: number;
366
+ /** How many commits the branch is ahead of the merge-base. */
367
+ ahead?: number;
368
+ /** True when HEAD is NOT a descendant of the target base (diverged). */
369
+ baseDiverged?: boolean;
370
+ expectedPatchId?: string;
371
+ actualPatchId?: string;
372
+ patchIdEqual?: boolean;
373
+ /** Compact one-line diff stat summary of the residual/actual merge diff (best-effort). */
374
+ diffStat?: string;
375
+ /** Per-submodule gitlink reachability against submodule origin/main (best-effort). */
376
+ submoduleGitlinks?: Array<{
377
+ path: string;
378
+ baseCommit?: string;
379
+ branchCommit?: string;
380
+ /** True when branchCommit descends baseCommit (a strict fast-forward advance). */
381
+ fastForward?: boolean;
382
+ /** True when branchCommit is reachable from the submodule's local origin/main. */
383
+ reachableFromOriginMain?: boolean;
384
+ }>;
385
+ /** Effective auto-publish-submodule-main-commits policy value at classification time. */
386
+ autoPublishSubmoduleMainCommits?: boolean;
387
+ /** Set when the classifier itself errored (detailedReason === 'unclassified'). */
388
+ classifierError?: string;
389
+ };
390
+ };
391
+ /**
392
+ * Classify WHY a patch-equivalence preflight blocked, turning the opaque
393
+ * `patch_equivalence_failed` code into a machine-readable {@link
394
+ * MeshRefinePatchEquivalenceDetailedReasonCode} plus a recommended action and
395
+ * structured evidence. Read-only: runs only `git` inspection commands (rev-list,
396
+ * merge-base, diff --stat, submodule reachability probes) against the already-set
397
+ * worktree — it never mutates the repo.
398
+ *
399
+ * Priority of classification (first match wins):
400
+ * 1. already_converged — ahead 0 & behind 0 & no residual diff
401
+ * 2. submodule_unreachable — a changed gitlink commit is not reachable from the
402
+ * submodule's origin/main (publish needed)
403
+ * 3. trivial_ff_misjudgment — HEAD descends origin/main AND (excl. gitlinks) the
404
+ * patch-ids match — blocked only by a ff gitlink
405
+ * 4. base_divergence — HEAD is not a descendant of the target base
406
+ * 5. actual_patch_diff — genuine content divergence (the residual case)
407
+ *
408
+ * `targetBaseRef` is the ref the branch is meant to land on (e.g. 'origin/main'
409
+ * or the pinned baseHead SHA). `autoPublishSubmoduleMainCommits` is threaded in so
410
+ * the submodule_unreachable recommendation can name the current policy value.
411
+ */
412
+ export declare function classifyPatchEquivalenceFailure(repoRoot: string, baseHead: string, branchHead: string, summary: MeshRefinePatchEquivalenceSummary, options?: {
413
+ targetBaseRef?: string;
414
+ autoPublishSubmoduleMainCommits?: boolean;
415
+ }): Promise<MeshRefinePatchEquivalenceFailureClassification>;
330
416
  export type MeshWorktreePatchContainmentSummary = {
331
417
  /** True only when merging worktreeHead into ref introduces no new patch. */
332
418
  contained: boolean;
@@ -423,6 +423,21 @@ export declare class CliProviderInstance implements ProviderInstance {
423
423
  * the dashboard tail-repair cache — a display value, not a completion decision.
424
424
  */
425
425
  private lastVisibleAssistantSummary;
426
+ /**
427
+ * NOTIF Defect-B: the final assistant summary this instance ALREADY parsed and
428
+ * cached for the current turn (lastCompletionSummary), if any. The evidence
429
+ * probe (completionFinalAssistantEvidence) is a POINT-SAMPLE: on a native-source
430
+ * provider (antigravity) the parsed screen and the native transcript can both
431
+ * momentarily yield no in-turn final assistant at the exact instant the
432
+ * completion gate fires — source='unavailable', missingEvidence=true — even
433
+ * though a prior poll already read the real answer off native-history and cached
434
+ * it here (the same value mesh_read_chat.summary shows). Consulting the cache at
435
+ * emit time lets that already-secured summary count as evidence, so the completion
436
+ * notification carries the answer instead of completion_diagnostic=missing_final_assistant
437
+ * with an empty summary. Returns '' when the cache is empty or was reset by the
438
+ * next turn (see lastCompletionSummary = null on onTurnStarted).
439
+ */
440
+ private cachedCompletionSummaryContent;
426
441
  private completionFinalAssistantEvidence;
427
442
  private completionFinalSummary;
428
443
  private buildCompletedFinalizationDiagnostic;
@@ -482,7 +497,7 @@ export declare class CliProviderInstance implements ProviderInstance {
482
497
  bytes: number;
483
498
  } | {
484
499
  ok: false;
485
- refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker';
500
+ refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker' | 'unsupported';
486
501
  keys: MeshSendKeyName[];
487
502
  hasDestructive: boolean;
488
503
  }>;
@@ -79,6 +79,13 @@ export declare class TerminalAdapter {
79
79
  row: number;
80
80
  col: number;
81
81
  };
82
+ /** Current terminal geometry (columns × rows). Tracked here rather than
83
+ * read off the screen buffer so a resize is reflected immediately, before
84
+ * the next repaint. Consumed by the mesh_read_terminal viewport read. */
85
+ getScreenSize(): {
86
+ cols: number;
87
+ rows: number;
88
+ };
82
89
  send_keys(text: string): void;
83
90
  /** Forward runtime metadata (meshNodeId, workspaceLabel, lifecycle, …) to
84
91
  * the underlying transport so it reaches the session registry. The spec
@@ -1,5 +1,6 @@
1
1
  import type { CliAdapter, CliAdapterStatus } from '../../cli-adapter-types.js';
2
2
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
3
+ import { type MeshSendKeyItem, type MeshSendKeyName } from '../../cli-adapters/provider-cli-shared.js';
3
4
  import { type InteractivePromptResponse } from '../types/interactive-prompt.js';
4
5
  export declare class SpecCliAdapter implements CliAdapter {
5
6
  readonly cliType: string;
@@ -74,6 +75,69 @@ export declare class SpecCliAdapter implements CliAdapter {
74
75
  cancel(): void;
75
76
  isProcessing(): boolean;
76
77
  isReady(): boolean;
78
+ isAlive(): boolean;
79
+ private static readonly TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES;
80
+ private static readonly TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES;
81
+ /**
82
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Least-privilege read
83
+ * of the CURRENT rendered viewport for mesh_read_terminal on the spec path
84
+ * (claude-cli / antigravity / codex-cli — the native-source providers that
85
+ * route through SpecCliAdapter). Mirrors ProviderCliAdapter.getTerminalScreenSnapshot:
86
+ * - returns ONLY the driver's current viewport snapshot, the cursor
87
+ * position and the terminal geometry — NO scrollback, NO parser/FSM
88
+ * state, NO debug buffers;
89
+ * - the payload is byte-bounded (UTF-8) with bottom-tail preservation so a
90
+ * screen of multi-byte glyphs can never exceed the MCP payload cap;
91
+ * - `hash` is over the FULL untruncated viewport so a caller can detect a
92
+ * screen change across polls even when the returned text was truncated.
93
+ *
94
+ * SECURITY: the raw viewport can carry tokens / command args / env / user
95
+ * data. Callers MUST gate this on mesh ownership and MUST NOT log the text.
96
+ */
97
+ getTerminalScreenSnapshot(maxBytes?: number): {
98
+ text: string;
99
+ cursor: {
100
+ col: number;
101
+ row: number;
102
+ };
103
+ cols: number;
104
+ rows: number;
105
+ truncated: boolean;
106
+ originalBytes: number;
107
+ returnedBytes: number;
108
+ hash: string;
109
+ };
110
+ /**
111
+ * MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key
112
+ * sequence into the spec-driven PTY for mesh_send_keys. Mirrors
113
+ * ProviderCliAdapter.injectKeys' modal fail-closed guard, then writes the
114
+ * whole encoded sequence in ONE pty_write dispatch (text+ENTER is a single
115
+ * contiguous string, so a submit key can never be separated from the text
116
+ * it submits).
117
+ *
118
+ * The spec path drives the child through the FsmDriver, not a directly-held
119
+ * ptyProcess — there is no adapter-level echo-gate/submit-retry FIFO to race
120
+ * against here (the driver serializes its own writes), so the only guard is
121
+ * the modal fail-closed: a NON-destructive injection into an actionable
122
+ * approval modal is refused (use mesh_approve) unless explicitly overridden.
123
+ * A destructive ESC/CTRL_C dismisses rather than confirms, so it is allowed
124
+ * past this gate (the tool layer owns the destructive double-gate + audit).
125
+ * This method NEVER logs the literal text — only key enums / byte length.
126
+ */
127
+ injectKeys(items: MeshSendKeyItem[], opts?: {
128
+ allowModalOverride?: boolean;
129
+ }): Promise<{
130
+ ok: true;
131
+ keys: MeshSendKeyName[];
132
+ hasDestructive: boolean;
133
+ submits: boolean;
134
+ bytes: number;
135
+ } | {
136
+ ok: false;
137
+ refused: 'submit_race' | 'actionable_modal';
138
+ keys: MeshSendKeyName[];
139
+ hasDestructive: boolean;
140
+ }>;
77
141
  setOnStatusChange(cb: () => void): void;
78
142
  setOnPtyData(cb: (data: string) => void): void;
79
143
  writeRaw(data: string): void;
@@ -126,6 +126,13 @@ export interface ISpecDriver {
126
126
  col: number;
127
127
  };
128
128
  getScreen(): string;
129
+ /** Current terminal geometry (columns × rows). Optional so a non-Fsm
130
+ * ISpecDriver implementation (test doubles) need not provide it; the
131
+ * mesh_read_terminal path falls back to a 0×0 geometry when absent. */
132
+ getScreenSize?(): {
133
+ cols: number;
134
+ rows: number;
135
+ };
129
136
  getSpecPath(): string;
130
137
  shutdown(): void;
131
138
  getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
@@ -251,6 +258,10 @@ export declare class FsmDriver implements ISpecDriver {
251
258
  col: number;
252
259
  };
253
260
  getScreen(): string;
261
+ getScreenSize(): {
262
+ cols: number;
263
+ rows: number;
264
+ };
254
265
  /** Scrollback-inclusive screen as line array — used only for modal/button
255
266
  * content extraction so a tall prompt's off-screen anchors stay matchable.
256
267
  * Falls back to the viewport snapshot if scrollback read is unavailable. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.553",
3
+ "version": "0.9.82-rc.554",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.553",
51
- "@adhdev/session-host-core": "0.9.82-rc.553",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.554",
51
+ "@adhdev/session-host-core": "0.9.82-rc.554",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { ChatMessage } from './types.js';
8
8
  import type { InteractivePrompt, InteractivePromptResponse } from './providers/types/interactive-prompt.js';
9
+ import type { MeshSendKeyItem, MeshSendKeyName } from './cli-adapters/provider-cli-shared.js';
9
10
 
10
11
  export interface CliAdapterStatus {
11
12
  status?: string;
@@ -118,6 +119,34 @@ export interface CliAdapter {
118
119
  cancel(): void;
119
120
  isProcessing(): boolean;
120
121
  isReady(): boolean;
122
+ // Liveness of the underlying process/PTY. Optional because not every adapter
123
+ // implementation exposes it (the spec-driven path historically did not); the
124
+ // MESH-STALL-WATCH watchdog must call it defensively (typeof guard) so a
125
+ // missing implementation never throws in the 5s tick.
126
+ isAlive?(): boolean;
127
+ // MESH-READ-TERMINAL (feature 2) / MESH-SEND-KEYS (feature 3). Optional
128
+ // because not every adapter implements the raw-terminal read / structured
129
+ // key-injection surface; callers (cli-provider-instance) MUST typeof-guard
130
+ // and return a clean unsupported result rather than throwing when absent.
131
+ // Both ProviderCliAdapter (PTY path) and SpecCliAdapter (native-source spec
132
+ // path — claude-cli / antigravity / codex-cli) implement them.
133
+ getTerminalScreenSnapshot?(maxBytes?: number): {
134
+ text: string;
135
+ cursor: { col: number; row: number };
136
+ cols: number;
137
+ rows: number;
138
+ truncated: boolean;
139
+ originalBytes: number;
140
+ returnedBytes: number;
141
+ hash: string;
142
+ };
143
+ injectKeys?(
144
+ items: MeshSendKeyItem[],
145
+ opts?: { allowModalOverride?: boolean },
146
+ ): Promise<
147
+ | { ok: true; keys: MeshSendKeyName[]; hasDestructive: boolean; submits: boolean; bytes: number }
148
+ | { ok: false; refused: 'submit_race' | 'actionable_modal'; keys: MeshSendKeyName[]; hasDestructive: boolean }
149
+ >;
121
150
  setOnStatusChange(callback: () => void): void;
122
151
  updateRuntimeSettings?(settings: Record<string, unknown>): void;
123
152
  setCliScripts?(scripts: Record<string, unknown>): void;
@@ -35,6 +35,7 @@ import {
35
35
  RefineContext,
36
36
  RefineExecFileAsync,
37
37
  RefineStageOutcome,
38
+ classifyPatchEquivalenceFailure,
38
39
  recordMeshRefineStage,
39
40
  resolveRefineryAutoPublishSubmoduleMainCommits,
40
41
  runMeshRefineEffectiveDiffGate,
@@ -616,9 +617,20 @@ export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: Refine
616
617
  error: submoduleHintPatchEquivalence.error,
617
618
  actionableHint: submoduleHintPatchEquivalence.actionableHint,
618
619
  });
620
+ const classification = await classifyPatchEquivalenceFailure(
621
+ repoRoot, baseHead, ctx.branchHead, submoduleHintPatchEquivalence,
622
+ {
623
+ targetBaseRef: baseHead,
624
+ autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(ctx.mesh, node.workspace).enabled,
625
+ },
626
+ );
619
627
  return { kind: 'terminal', result: {
620
628
  success: false,
621
629
  code: 'patch_equivalence_failed',
630
+ detailedReason: classification.detailedReason,
631
+ detailedReasonDescription: classification.detailedReasonDescription,
632
+ recommendedAction: classification.recommendedAction,
633
+ evidence: classification.evidence,
622
634
  convergenceStatus: 'blocked_review',
623
635
  error: 'Refinery patch-equivalence preflight failed (submodule gitlink conflict); merge/refine was not attempted.',
624
636
  branch,
@@ -801,7 +813,7 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
801
813
  export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
802
814
  // DS2: node/execFileAsync are no longer needed here — the rebase moved to
803
815
  // sync_base — and branchHead/patchEquivalence are no longer mutated in-stage.
804
- const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, validationSummary, refineStages } = ctx;
816
+ const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, mesh, node, validationSummary, refineStages } = ctx;
805
817
  const branchHead = ctx.branchHead;
806
818
  const patchEquivalenceStarted = Date.now();
807
819
  const patchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
@@ -826,9 +838,24 @@ export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx
826
838
  // branch itself has no changes (degenerate), which is NOT already-merged.
827
839
  const alreadyMergedViaOtherPath = !patchEquivalence.actualPatchId && !!patchEquivalence.expectedPatchId;
828
840
  if (!alreadyMergedViaOtherPath) {
841
+ const classification = await classifyPatchEquivalenceFailure(
842
+ repoRoot, baseHead, branchHead, patchEquivalence,
843
+ {
844
+ targetBaseRef: baseHead,
845
+ autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(mesh, node.workspace).enabled,
846
+ },
847
+ );
848
+ recordMeshRefineStage(refineStages, 'patch_equivalence_classification', 'failed', patchEquivalenceStarted, {
849
+ detailedReason: classification.detailedReason,
850
+ recommendedAction: classification.recommendedAction,
851
+ });
829
852
  return { kind: 'terminal', result: {
830
853
  success: false,
831
854
  code: 'patch_equivalence_failed',
855
+ detailedReason: classification.detailedReason,
856
+ detailedReasonDescription: classification.detailedReasonDescription,
857
+ recommendedAction: classification.recommendedAction,
858
+ evidence: classification.evidence,
832
859
  convergenceStatus: 'blocked_review',
833
860
  error: 'Refinery patch-equivalence preflight failed; merge/refine was not attempted.',
834
861
  branch,
@@ -2278,6 +2305,15 @@ export async function finishMeshRefineJob(self: DaemonCommandRouter, handle: Mes
2278
2305
  };
2279
2306
  if (typeof result.error === 'string') ctx.error = result.error;
2280
2307
  if (typeof result.blockedReason === 'string') ctx.blockedReason = result.blockedReason;
2308
+ // Detailed patch-equivalence sub-cause classification (base_divergence,
2309
+ // submodule_unreachable, actual_patch_diff, trivial_ff_misjudgment,
2310
+ // already_converged, unclassified) + recommended action + evidence.
2311
+ // Promoted onto blockerContext so coordinators reading task_failed ledger
2312
+ // entries see the cause without parsing the free-form error string.
2313
+ if (typeof result.detailedReason === 'string') ctx.detailedReason = result.detailedReason;
2314
+ if (typeof result.detailedReasonDescription === 'string') ctx.detailedReasonDescription = result.detailedReasonDescription;
2315
+ if (typeof result.recommendedAction === 'string') ctx.recommendedAction = result.recommendedAction;
2316
+ if (result.evidence && typeof result.evidence === 'object') ctx.evidence = result.evidence;
2281
2317
  // Patch equivalence details
2282
2318
  if (stage === 'patch_equivalence' && result.patchEquivalence) {
2283
2319
  const pe = result.patchEquivalence as Record<string, unknown>;
@@ -2287,6 +2323,9 @@ export async function finishMeshRefineJob(self: DaemonCommandRouter, handle: Mes
2287
2323
  status: pe.status,
2288
2324
  actionableHint: pe.actionableHint,
2289
2325
  error: pe.error,
2326
+ ...(typeof result.detailedReason === 'string' ? { detailedReason: result.detailedReason } : {}),
2327
+ ...(typeof result.recommendedAction === 'string' ? { recommendedAction: result.recommendedAction } : {}),
2328
+ ...(result.evidence && typeof result.evidence === 'object' ? { evidence: result.evidence } : {}),
2290
2329
  };
2291
2330
  }
2292
2331
  // Submodule reachability details
@@ -556,6 +556,262 @@ export async function runMeshRefinePatchEquivalenceGate(
556
556
  }
557
557
  }
558
558
 
559
+ /**
560
+ * Machine-readable sub-classification of a `patch_equivalence_failed` (and the
561
+ * related submodule-gitlink preflight blocks). The opaque top-level
562
+ * `patch_equivalence_failed` code is preserved for backward compatibility; this
563
+ * detailed reason is added ALONGSIDE it so coordinators no longer have to guess
564
+ * WHY the preflight blocked (the 2026-07-17 hidden-spinner convergence incident:
565
+ * the real cause was a diverged base + an unreachable submodule gitlink artifact,
566
+ * not a real patch conflict, but Refinery only returned the opaque code and the
567
+ * coordinator mis-attributed it to a stale daemon version).
568
+ */
569
+ export type MeshRefinePatchEquivalenceDetailedReasonCode =
570
+ /** Worktree base diverged from target base (HEAD is not a descendant of origin/main). */
571
+ | 'base_divergence'
572
+ /** Submodule gitlink commit is not reachable from the submodule's remote main branch (publish needed). */
573
+ | 'submodule_unreachable'
574
+ /** Genuine non-equivalent content: expected tree vs actual merge diff differ. */
575
+ | 'actual_patch_diff'
576
+ /** Submodule gitlink trivial fast-forward mis-judged as non-equivalent (HEAD descends origin/main, patch-id equal, blocked only by the gitlink). */
577
+ | 'trivial_ff_misjudgment'
578
+ /** Already identical to origin/main (ahead 0 / behind 0, no diff) — should be treated as success/no-op. */
579
+ | 'already_converged'
580
+ /** Fallback when the classifier itself could not run (git error); keep the opaque code, note the reason. */
581
+ | 'unclassified';
582
+
583
+ export type MeshRefinePatchEquivalenceFailureClassification = {
584
+ detailedReason: MeshRefinePatchEquivalenceDetailedReasonCode;
585
+ /** Human-readable one-line description of the sub-cause. */
586
+ detailedReasonDescription: string;
587
+ /** Suggested next action for the coordinator/owner (free-form, actionable). */
588
+ recommendedAction: string;
589
+ /** Structured supporting evidence: SHAs, ahead/behind, submodule reachability, patch-id comparison, diff stat. */
590
+ evidence: {
591
+ baseHead?: string;
592
+ branchHead?: string;
593
+ mergeBase?: string;
594
+ /** How many commits base (origin/main) is ahead of the branch's merge-base (branch is behind). */
595
+ behind?: number;
596
+ /** How many commits the branch is ahead of the merge-base. */
597
+ ahead?: number;
598
+ /** True when HEAD is NOT a descendant of the target base (diverged). */
599
+ baseDiverged?: boolean;
600
+ expectedPatchId?: string;
601
+ actualPatchId?: string;
602
+ patchIdEqual?: boolean;
603
+ /** Compact one-line diff stat summary of the residual/actual merge diff (best-effort). */
604
+ diffStat?: string;
605
+ /** Per-submodule gitlink reachability against submodule origin/main (best-effort). */
606
+ submoduleGitlinks?: Array<{
607
+ path: string;
608
+ baseCommit?: string;
609
+ branchCommit?: string;
610
+ /** True when branchCommit descends baseCommit (a strict fast-forward advance). */
611
+ fastForward?: boolean;
612
+ /** True when branchCommit is reachable from the submodule's local origin/main. */
613
+ reachableFromOriginMain?: boolean;
614
+ }>;
615
+ /** Effective auto-publish-submodule-main-commits policy value at classification time. */
616
+ autoPublishSubmoduleMainCommits?: boolean;
617
+ /** Set when the classifier itself errored (detailedReason === 'unclassified'). */
618
+ classifierError?: string;
619
+ };
620
+ };
621
+
622
+ /**
623
+ * Classify WHY a patch-equivalence preflight blocked, turning the opaque
624
+ * `patch_equivalence_failed` code into a machine-readable {@link
625
+ * MeshRefinePatchEquivalenceDetailedReasonCode} plus a recommended action and
626
+ * structured evidence. Read-only: runs only `git` inspection commands (rev-list,
627
+ * merge-base, diff --stat, submodule reachability probes) against the already-set
628
+ * worktree — it never mutates the repo.
629
+ *
630
+ * Priority of classification (first match wins):
631
+ * 1. already_converged — ahead 0 & behind 0 & no residual diff
632
+ * 2. submodule_unreachable — a changed gitlink commit is not reachable from the
633
+ * submodule's origin/main (publish needed)
634
+ * 3. trivial_ff_misjudgment — HEAD descends origin/main AND (excl. gitlinks) the
635
+ * patch-ids match — blocked only by a ff gitlink
636
+ * 4. base_divergence — HEAD is not a descendant of the target base
637
+ * 5. actual_patch_diff — genuine content divergence (the residual case)
638
+ *
639
+ * `targetBaseRef` is the ref the branch is meant to land on (e.g. 'origin/main'
640
+ * or the pinned baseHead SHA). `autoPublishSubmoduleMainCommits` is threaded in so
641
+ * the submodule_unreachable recommendation can name the current policy value.
642
+ */
643
+ export async function classifyPatchEquivalenceFailure(
644
+ repoRoot: string,
645
+ baseHead: string,
646
+ branchHead: string,
647
+ summary: MeshRefinePatchEquivalenceSummary,
648
+ options: { targetBaseRef?: string; autoPublishSubmoduleMainCommits?: boolean } = {},
649
+ ): Promise<MeshRefinePatchEquivalenceFailureClassification> {
650
+ const targetBaseRef = options.targetBaseRef || baseHead;
651
+ const autoPublish = options.autoPublishSubmoduleMainCommits;
652
+ const evidence: MeshRefinePatchEquivalenceFailureClassification['evidence'] = {
653
+ baseHead,
654
+ branchHead,
655
+ mergeBase: summary.mergeBase,
656
+ expectedPatchId: summary.expectedPatchId,
657
+ actualPatchId: summary.actualPatchId,
658
+ patchIdEqual: !!summary.expectedPatchId && summary.expectedPatchId === summary.actualPatchId,
659
+ ...(autoPublish !== undefined ? { autoPublishSubmoduleMainCommits: autoPublish } : {}),
660
+ };
661
+ try {
662
+ const git = (args: string[]): string => execFileSync(GIT, args, {
663
+ cwd: repoRoot,
664
+ encoding: 'utf8',
665
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
666
+ windowsHide: true,
667
+ });
668
+ const gitOk = (args: string[]): boolean => {
669
+ try { git(args); return true; } catch { return false; }
670
+ };
671
+
672
+ // ahead/behind of branch vs the target base ref. left = base-only (behind),
673
+ // right = branch-only (ahead).
674
+ let ahead = 0;
675
+ let behind = 0;
676
+ try {
677
+ const out = git(['rev-list', '--left-right', '--count', `${targetBaseRef}...${branchHead}`]).trim();
678
+ const [left, right] = out.split(/\s+/).map(n => Number.parseInt(n, 10));
679
+ behind = Number.isFinite(left) ? left : 0;
680
+ ahead = Number.isFinite(right) ? right : 0;
681
+ } catch { /* keep zeros */ }
682
+ evidence.ahead = ahead;
683
+ evidence.behind = behind;
684
+ // HEAD (branchHead) diverged from the target base = base is NOT an ancestor
685
+ // of the branch. behind>0 with the base ref not reachable from HEAD.
686
+ const baseIsAncestor = gitOk(['merge-base', '--is-ancestor', targetBaseRef, branchHead]);
687
+ evidence.baseDiverged = !baseIsAncestor;
688
+
689
+ // Residual/actual diff stat (best-effort): what the merge would still introduce.
690
+ let diffStat = '';
691
+ try {
692
+ if (summary.mergedTree) {
693
+ diffStat = git(['diff', '--stat', baseHead, summary.mergedTree]).trim().split('\n').filter(Boolean).slice(-1)[0] || '';
694
+ } else {
695
+ diffStat = git(['diff', '--stat', baseHead, branchHead]).trim().split('\n').filter(Boolean).slice(-1)[0] || '';
696
+ }
697
+ } catch { /* diff stat is best-effort */ }
698
+ if (diffStat) evidence.diffStat = diffStat;
699
+
700
+ // Changed gitlink reachability against each submodule's local origin/main.
701
+ const submoduleGitlinks: NonNullable<MeshRefinePatchEquivalenceFailureClassification['evidence']['submoduleGitlinks']> = [];
702
+ try {
703
+ const nameStatus = git(['diff', '--name-only', '--diff-filter=d', baseHead, branchHead]).trim();
704
+ const changedPaths = nameStatus ? nameStatus.split('\n').map(p => p.trim()).filter(Boolean) : [];
705
+ for (const p of changedPaths) {
706
+ // Only submodule (gitlink, mode 160000) entries.
707
+ let baseCommit: string | undefined;
708
+ let branchCommit: string | undefined;
709
+ try {
710
+ const baseLs = git(['ls-tree', baseHead, '--', p]).trim();
711
+ const branchLs = git(['ls-tree', branchHead, '--', p]).trim();
712
+ const isGitlink = /(^|\s)160000\s/.test(baseLs) || /(^|\s)160000\s/.test(branchLs);
713
+ if (!isGitlink) continue;
714
+ baseCommit = baseLs.split(/\s+/)[2];
715
+ branchCommit = branchLs.split(/\s+/)[2];
716
+ } catch { continue; }
717
+ const submoduleRepo = pathJoin(repoRoot, p);
718
+ let fastForward: boolean | undefined;
719
+ let reachableFromOriginMain: boolean | undefined;
720
+ if (branchCommit) {
721
+ if (baseCommit) {
722
+ fastForward = execGitOk(submoduleRepo, ['merge-base', '--is-ancestor', baseCommit, branchCommit]);
723
+ }
724
+ reachableFromOriginMain = execGitOk(submoduleRepo, ['merge-base', '--is-ancestor', branchCommit, 'refs/remotes/origin/main']);
725
+ }
726
+ submoduleGitlinks.push({ path: p, baseCommit, branchCommit, fastForward, reachableFromOriginMain });
727
+ }
728
+ } catch { /* submodule inspection is best-effort */ }
729
+ if (submoduleGitlinks.length) evidence.submoduleGitlinks = submoduleGitlinks;
730
+
731
+ // Existing gate signal: the merge-tree trivial-ff evaluation, if the gate
732
+ // captured it (a genuine non-trivial submodule conflict lands here too).
733
+ const gitlinkFf = summary.gitlinkTrivialFastForward;
734
+
735
+ // ── Classification (first match wins) ────────────────────────────────
736
+ const noResidualDiff = !evidence.diffStat && (!summary.actualPatchId || summary.actualPatchId === '');
737
+
738
+ // 1. already_converged: nothing ahead, nothing behind, no residual diff.
739
+ if (ahead === 0 && behind === 0 && noResidualDiff) {
740
+ return {
741
+ detailedReason: 'already_converged',
742
+ detailedReasonDescription: 'Branch is already identical to the target base (ahead 0, behind 0, no residual diff); the merge would be a no-op.',
743
+ recommendedAction: 'Treat as already converged — no merge needed. Verify with `git range-diff` / patch-id, then mark the branch merged (or clean up the worktree).',
744
+ evidence,
745
+ };
746
+ }
747
+
748
+ // 2. submodule_unreachable: a changed gitlink is not reachable from the
749
+ // submodule's origin/main. This is the publish-needed artifact.
750
+ const unreachable = submoduleGitlinks.filter(g => g.reachableFromOriginMain === false);
751
+ if (unreachable.length > 0) {
752
+ const paths = unreachable.map(g => g.path).join(', ');
753
+ return {
754
+ detailedReason: 'submodule_unreachable',
755
+ detailedReasonDescription: `Submodule gitlink commit(s) not reachable from submodule origin/main (publish needed): ${paths}.`,
756
+ recommendedAction: `Publish the submodule commit(s) to submodule origin/main, then retry mesh_refine_node (policy allowAutoPublishSubmoduleMainCommits=${autoPublish === undefined ? 'unknown' : autoPublish}).`,
757
+ evidence,
758
+ };
759
+ }
760
+
761
+ // 3. trivial_ff_misjudgment: HEAD descends the target base AND the non-gitlink
762
+ // patch-ids are equal, so the ONLY thing blocking is a fast-forward gitlink
763
+ // that merge-tree refused. (Either the gate flagged an unresolved gitlink
764
+ // ff, or every changed gitlink is a proven ff.)
765
+ const changedGitlinks = submoduleGitlinks.length > 0;
766
+ const allGitlinksFf = changedGitlinks && submoduleGitlinks.every(g => g.fastForward === true);
767
+ const gateSawUnresolvedGitlinkFf = gitlinkFf?.resolved === false && Array.isArray(gitlinkFf.gitlinks) && gitlinkFf.gitlinks.some(g => g.fastForward);
768
+ if (baseIsAncestor && (evidence.patchIdEqual || allGitlinksFf || gateSawUnresolvedGitlinkFf)) {
769
+ return {
770
+ detailedReason: 'trivial_ff_misjudgment',
771
+ detailedReasonDescription: 'HEAD descends the target base and the patch content matches; the block is a submodule gitlink trivial fast-forward that merge-tree refused, not a real divergence.',
772
+ recommendedAction: 'Converge via the strict fast-forward-only bypass (verify HEAD descends origin/main and patch-id equality, then merge --ff-only) instead of the refine gate.',
773
+ evidence,
774
+ };
775
+ }
776
+
777
+ // 4. base_divergence: HEAD is not a descendant of the target base.
778
+ if (!baseIsAncestor) {
779
+ return {
780
+ detailedReason: 'base_divergence',
781
+ detailedReasonDescription: `Worktree base has diverged from ${targetBaseRef} (HEAD is not a descendant; ahead ${ahead}, behind ${behind}).`,
782
+ recommendedAction: `Rebase the branch onto ${targetBaseRef}, then retry mesh_refine_node.`,
783
+ evidence,
784
+ };
785
+ }
786
+
787
+ // 5. actual_patch_diff: genuine non-equivalent content.
788
+ return {
789
+ detailedReason: 'actual_patch_diff',
790
+ detailedReasonDescription: 'The merge introduces content not equivalent to the branch\'s cumulative patch (expected tree vs actual merge diff differ).',
791
+ recommendedAction: 'Manual review required — inspect the residual diff; the branch content is not patch-equivalent to a clean merge onto the base.',
792
+ evidence,
793
+ };
794
+ } catch (e: any) {
795
+ evidence.classifierError = e?.message || String(e);
796
+ return {
797
+ detailedReason: 'unclassified',
798
+ detailedReasonDescription: 'Patch-equivalence sub-cause could not be classified (git inspection failed); see classifierError.',
799
+ recommendedAction: 'Inspect the refineStages and patchEquivalence summary manually to determine the cause.',
800
+ evidence,
801
+ };
802
+ }
803
+ }
804
+
805
+ /** Small helper: run a git command in `cwd` and return whether it exited 0. */
806
+ function execGitOk(cwd: string, args: string[]): boolean {
807
+ try {
808
+ execFileSync(GIT, args, { cwd, encoding: 'utf8', maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES, windowsHide: true });
809
+ return true;
810
+ } catch {
811
+ return false;
812
+ }
813
+ }
814
+
559
815
  export type MeshWorktreePatchContainmentSummary = {
560
816
  /** True only when merging worktreeHead into ref introduces no new patch. */
561
817
  contained: boolean;