@adhdev/daemon-core 1.0.28-rc.5 → 1.0.28-rc.6

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.
@@ -11,6 +11,35 @@ export interface AssignedTaskTerminalEvidence {
11
11
  nodeId?: string;
12
12
  sessionId: string;
13
13
  }
14
+ /**
15
+ * Purpose-tagged coordinator-side evidence query — the remote half of the
16
+ * transcript-authority choke point (P1). Existing callers keep the original
17
+ * poll* shells below; new consumers (the reconcile loop's early-arm / redrive
18
+ * sites in P3) should enter here so the purpose vocabulary — not another
19
+ * ad-hoc gate — expresses what is being asked of the worker transcript.
20
+ */
21
+ export type AssignedTaskEvidencePurpose = 'turn-progress' | 'terminal-evidence';
22
+ export interface AssignedTaskCompletionEvidence {
23
+ purpose: AssignedTaskEvidencePurpose;
24
+ /** "Did the worker START this task's turn" — post-dispatch agent bubble. */
25
+ inTurnProgress: boolean;
26
+ /** "Did the worker FINISH this task's turn" — populated for 'terminal-evidence'. */
27
+ terminal: AssignedTaskTerminalEvidence | null;
28
+ }
29
+ export declare function resolveAssignedTaskCompletionEvidence(components: DaemonComponents, mesh: {
30
+ id: string;
31
+ nodes?: Array<{
32
+ id: string;
33
+ daemonId?: string;
34
+ workspace?: string;
35
+ }>;
36
+ }, row: {
37
+ id: string;
38
+ assignedSessionId?: string;
39
+ assignedNodeId?: string;
40
+ assignedProviderType?: string;
41
+ dispatchTimestamp?: string;
42
+ }, purpose: AssignedTaskEvidencePurpose): Promise<AssignedTaskCompletionEvidence>;
14
43
  export declare function pollAssignedTaskInTurnProgress(components: DaemonComponents, mesh: {
15
44
  id: string;
16
45
  nodes?: Array<{
@@ -53,6 +53,7 @@ export declare function recordInlineMeshDirectGitTruth(node: any, git: Record<st
53
53
  reporterProviderVersions: Record<string, string> | null;
54
54
  reporterDaemonBuildVersion: string | null;
55
55
  reportedMemberState: MeshReportedMemberState | null;
56
+ nodeFacts: import('@adhdev/mesh-shared').MeshNodeFacts | null;
56
57
  };
57
58
  /**
58
59
  * Persist the live self-reported platform/arch onto the local meshes.json node
@@ -72,6 +73,7 @@ export declare function persistNodeReporterPlatform(meshSource: 'inline_cache' |
72
73
  reporterProviderVersions?: Record<string, string> | null;
73
74
  reporterDaemonBuildVersion?: string | null;
74
75
  reportedMemberState?: MeshReportedMemberState | null;
76
+ nodeFacts?: import('@adhdev/mesh-shared').MeshNodeFacts | null;
75
77
  }): void;
76
78
  export declare function inlineMeshCarriesTransientNodeTruth(inlineMesh: any): boolean;
77
79
  export declare function readInlineMeshNodeId(node: any): string;
@@ -504,6 +504,38 @@ type GitlinkTrivialFastForwardEvaluation = {
504
504
  * gate.
505
505
  */
506
506
  export declare function collectFastForwardGitlinkPaths(repoRoot: string, baseHead: string, branchHead: string): string[];
507
+ /**
508
+ * Collect gitlink resolutions for the *trivial fast-forward* case, so the
509
+ * gitlink-aware root rebase ({@link rootRebaseResolvingGitlinks}) can drive a
510
+ * behind>0 rebase whose changed submodule pointer would otherwise make a plain
511
+ * `git rebase baseHead` abort on the gitlink.
512
+ *
513
+ * When base has advanced the SAME submodule as the branch, git's recursive merge
514
+ * refuses to auto-merge the gitlink even when the two commits are in a strict
515
+ * ancestor/descendant relationship (a real fast-forward). That is fine for the
516
+ * patch-equivalence gate (it synthesizes the merged tree), but the sync_base
517
+ * *rebase* still runs `git rebase baseHead`, which stops on the same gitlink
518
+ * conflict and aborts → the branch is wrongly blocked. This helper produces the
519
+ * per-path resolution the root rebase needs so those paths take the gitlink-aware
520
+ * path instead of the plain rebase.
521
+ *
522
+ * Direction rule (kept consistent with the diverged path, which always resolves to
523
+ * the linear descendant): pick whichever of base/branch commit is the DESCENDANT
524
+ * of the other and resolve the gitlink to it — the more-advanced commit wins.
525
+ * - base ancestor-of branch → branch is more advanced → resolve to branch-side.
526
+ * - branch ancestor-of base → base is more advanced → resolve to base-side.
527
+ * - neither ancestor (diverged) or ambiguous → excluded (left to the diverged
528
+ * converge path / patch-equivalence gate).
529
+ *
530
+ * The base-side submodule commit is committed in the base workspace and may be
531
+ * missing from the worktree's submodule object store; a best-effort local fetch
532
+ * (identical to {@link convergeDivergedSubmoduleGitlinks}) brings it in so the
533
+ * ancestry checks and the root rebase's `checkout --detach` can see it.
534
+ */
535
+ export declare function collectTrivialFastForwardGitlinkResolutions(worktreeRoot: string, baseRepoRoot: string, baseHead: string, branchHead: string): Array<{
536
+ path: string;
537
+ rebasedCommit: string;
538
+ }>;
507
539
  export type SubmoduleGitlinkConvergeResult = {
508
540
  /** True when at least one diverged gitlink submodule was rebased onto its base-side commit. */
509
541
  converged: boolean;
@@ -112,6 +112,7 @@ export declare class MeshRuntimeStore {
112
112
  providerType?: string;
113
113
  providerMaxParallel?: number;
114
114
  nodeIsWorktree?: boolean;
115
+ assignedTranscriptProfile?: MeshWorkQueueEntry['assignedTranscriptProfile'];
115
116
  }): MeshWorkQueueEntry | null;
116
117
  getQueueStatsByStatus(meshId: string): {
117
118
  status: string;
@@ -151,6 +151,20 @@ export interface MeshWorkQueueEntry {
151
151
  * by counting active assignments grouped by node + provider.
152
152
  */
153
153
  assignedProviderType?: string;
154
+ /**
155
+ * Transcript-authority class of the claiming session's provider, stamped at
156
+ * claim time (P1 of the transcript-authority unification — root repo
157
+ * docs/design/2026-07-25-transcript-authority-unification.md). Lets the
158
+ * COORDINATOR side classify a remote worker (early-arm / redrive gates)
159
+ * without resolving the provider module locally — the structural fix for
160
+ * the "remote class unknowable → reprobe-only" blind spot. Absent on rows
161
+ * claimed by older daemons; consumers must fall back to local resolution.
162
+ */
163
+ assignedTranscriptProfile?: {
164
+ class: 'native-source' | 'pure-pty' | 'daemon-owned';
165
+ timing: 'hold' | 'floor' | 'immediate';
166
+ emitsPtyTurnEvents: boolean;
167
+ };
154
168
  /** Human/operator reason for terminal cancellation. */
155
169
  cancelReason?: string;
156
170
  cancelledAt?: string;
@@ -331,6 +345,7 @@ export declare function claimNextTask(meshId: string, nodeId: string, sessionId:
331
345
  providerType?: string;
332
346
  providerMaxParallel?: number;
333
347
  nodeIsWorktree?: boolean;
348
+ assignedTranscriptProfile?: MeshWorkQueueEntry['assignedTranscriptProfile'];
334
349
  }): MeshWorkQueueEntry | null;
335
350
  export type DependencyFailurePolicy = 'block' | 'cancel';
336
351
  /**
@@ -0,0 +1,17 @@
1
+ /**
2
+ * buildLocalNodeFacts — THE single producer of this daemon's MeshNodeFacts
3
+ * bundle (design: root repo docs/design/2026-07-25-deploy-lag-visibility.md).
4
+ *
5
+ * De-mirroring core: the reporter envelope (git-commands handleGitCommand)
6
+ * and the self/worktree node stamp (mesh-node-identity, whose local probe
7
+ * bypasses the envelope) BOTH call this one function, so a fact added here
8
+ * reaches self and remote nodes identically — the field-by-field dual-path
9
+ * plumbing that produced the mirror-defect class (version chip self-miss,
10
+ * slot-cap chip remote-miss, dead stale-build badge) cannot recur for bundle
11
+ * fields.
12
+ */
13
+ import type { MeshNodeFacts } from '@adhdev/mesh-shared';
14
+ export declare function buildLocalNodeFacts(deps?: {
15
+ providerVersions?: Record<string, string> | null;
16
+ machineNickname?: string | null;
17
+ }): MeshNodeFacts;
@@ -620,60 +620,33 @@ export declare class CliProviderInstance implements ProviderInstance {
620
620
  */
621
621
  flushMeshCompletionBeforeCleanup(): boolean;
622
622
  /**
623
- * KIMI-PURE-PTY-COMPLETION-EMIT (Fix 3): stall-path completion reconcile for the
624
- * PURE-PTY transcript class (kimi and kin no native transcript, no provider
625
- * authority; see isPurePtyTranscriptProvider). Such a worker whose
626
- * generating_completed never emitted (the onTurnStarted idle→idle collapse Fix 1
627
- * fixes at the source) sits at a STATIC idle prompt: its PTY output stops the
628
- * instant the answer is rendered, so the status-agnostic no-progress watchdog
629
- * (checkMeshWorkerStall) reads the finished-but-quiet session as a stall and would
630
- * false-fire monitor:no_progress. The native-transcript reconcile
631
- * (sampleNativeTranscriptProgress) does NOT cover this class (no native source →
632
- * null sample), so the stall path needs its own guard.
633
- *
634
- * Called from checkMeshWorkerStall just before the fire. Returns true when the
635
- * session is a finished pure-PTY turn idle, no pending response, and a PTY-parsed
636
- * in-turn final assistant summary in which case it emits the missing
637
- * generating_completed (idempotent: a real/late emit writes the terminal ledger and
638
- * the coordinator's reconcile makes any duplicate a no-op) and the caller SUPPRESSES
639
- * the stall. Returns false for every other class/state (native-source provider, a
640
- * genuinely mid-turn or wedged worker with no final assistant), so a real stall still
641
- * fires unchanged.
642
- *
643
- * Evidence bar is identical to flushMeshCompletionBeforeCleanup: injected task's turn
644
- * genuinely started + an in-turn final assistant summary. Conservative by
645
- * construction no summary no proof of completion return false and let the real
646
- * stall fire.
647
- */
648
- private tryReconcilePurePtyCompletionForStall;
649
- /**
650
- * KIMI-NATIVE-SOURCE-COMPLETION-EMIT (Fix 3): stall-path completion reconcile for a
651
- * NATIVE-SOURCE provider (transcriptAuthority=provider + nativeHistory — e.g. kimi,
652
- * whose authoritative transcript is ~/.kimi-code/.../wire.jsonl). Like the pure-PTY
653
- * class, a native-source worker that submits a prompt while already idle can collapse
654
- * idle→idle so agent:generating_completed never emits; its PTY then goes screen-quiet
655
- * the instant the answer renders, and the status-agnostic no-progress watchdog
656
- * (checkMeshWorkerStall) misreads that finished-but-static idle as a wedge and would
657
- * false-fire monitor:no_progress. sampleNativeTranscriptProgress only re-arms while the
658
- * transcript is STILL ADVANCING; once the turn is done the transcript is static and the
659
- * sample stops rescuing — so the stall path needs this decisive completion check for the
660
- * finished-and-quiet case. tryReconcilePurePtyCompletionForStall does NOT cover this
661
- * class (isPurePtyTranscriptProvider is false for a native-source provider), so it runs
662
- * as a fallback here.
663
- *
664
- * Mutually exclusive with the pure-PTY path by construction: a provider is either
665
- * native-source (this method) or pure-PTY (that method), never both. Called from
666
- * checkMeshWorkerStall only after tryReconcilePurePtyCompletionForStall returned false.
667
- *
668
- * Evidence bar is identical to the pure-PTY / pre-cleanup paths: idle with nothing
669
- * pending, the injected task's turn genuinely started, and an in-turn final assistant
670
- * summary — here resolved from the native transcript by completionFinalSummary (its
671
- * native-source branch reads readExternalCompletionMessages). No summary ⇒ no proof of
672
- * completion ⇒ return false and let the real stall fire so a genuinely-wedged worker is
673
- * still surfaced. Idempotent: a real/late emit writes the terminal ledger and the
674
- * coordinator's reconcile makes any duplicate a no-op.
675
- */
676
- private tryReconcileNativeSourceCompletionForStall;
623
+ * TRANSCRIPT-COMPLETION-STALL-RESCUE (P2 of the transcript-authority
624
+ * unificationroot repo docs/design/2026-07-25-transcript-authority-
625
+ * unification.md): stall-path completion reconcile for every class whose
626
+ * turns can finish without a generating_completed emit (idle→idle collapse)
627
+ * and then sit PTY-quiet at a static idle prompt.
628
+ *
629
+ * Historically this was TWO class-enumerated copies KIMI-PURE-PTY-
630
+ * COMPLETION-EMIT (Fix 3) gated on isPurePtyTranscriptProvider, and
631
+ * KIMI-NATIVE-SOURCE-COMPLETION-EMIT gated on isNativeSourceCanonicalHistory
632
+ * whose bodies were identical because completionFinalSummary already picks
633
+ * the class-appropriate evidence source (authoritative native transcript for
634
+ * a native-source provider, the PTY parse otherwise). The enumeration itself
635
+ * was the recurring defect: each new class had to be remembered at this site.
636
+ * The profile collapses it: any class except daemon-owned is eligible, and
637
+ * the evidence bar not the class decides.
638
+ *
639
+ * Called from checkMeshWorkerStall just before the fire. Returns true when
640
+ * the session is a finished turn idle, nothing pending, the injected
641
+ * task's turn genuinely started, and an in-turn final assistant summary —
642
+ * in which case it emits the missing generating_completed (idempotent: a
643
+ * real/late emit writes the terminal ledger and the coordinator's reconcile
644
+ * makes any duplicate a no-op) and the caller SUPPRESSES the stall. Returns
645
+ * false for a daemon-owned provider and for a genuinely mid-turn / wedged
646
+ * worker with no in-turn final assistant, so a real stall still fires
647
+ * unchanged. Evidence bar is identical to flushMeshCompletionBeforeCleanup.
648
+ */
649
+ private tryReconcileTranscriptCompletionForStall;
677
650
  /**
678
651
  * AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
679
652
  * persist before the in-progress settle gate is torn down. For a delegated
@@ -769,9 +742,9 @@ export declare class CliProviderInstance implements ProviderInstance {
769
742
  */
770
743
  private emitGeneratingCompleted;
771
744
  /**
772
- * COMPLETION-WEAK-REARM (fix1): the double-emit guard shared by the three transcript
773
- * re-emit paths (flushMeshCompletionBeforeCleanup, tryReconcilePurePtyCompletionForStall,
774
- * tryReconcileNativeSourceCompletionForStall). Returns true when a re-emit for `taskId`
745
+ * COMPLETION-WEAK-REARM (fix1): the double-emit guard shared by the transcript
746
+ * re-emit paths (flushMeshCompletionBeforeCleanup,
747
+ * tryReconcileTranscriptCompletionForStall). Returns true when a re-emit for `taskId`
775
748
  * must be SUPPRESSED because this turn's completion already fired with strong evidence.
776
749
  *
777
750
  * The defect this replaces: the old guard short-circuited on ANY prior emit for the
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Transcript authority profile — the single place that classifies a provider
3
+ * for completion/stall/redrive decisions.
4
+ *
5
+ * Historically five predicates coexisted (transcriptAuthority, nativeHistory
6
+ * presence, adapter chatMessagesOwnedExternally, isPurePtyTranscriptProvider,
7
+ * and the two completion-timing flags) and every judgment site combined a
8
+ * different subset — each recurring "pure-PTY gate excludes native-source"
9
+ * defect (kimi reconcile, antigravity tail/guard, codex floor, STARTED
10
+ * redrive) was an instance of a site enumerating only some classes. This
11
+ * module makes the enumeration itself the shared artifact: sites consume a
12
+ * profile, never the raw predicates.
13
+ *
14
+ * Design: docs/design/2026-07-25-transcript-authority-unification.md (root
15
+ * repo). This file is Phase P0 (layer A — pure, synchronous classification).
16
+ * Layer B (the evidence-query choke point) lands in later phases.
17
+ *
18
+ * Rule for NEW code: call resolveTranscriptAuthorityProfile() — do not call
19
+ * isPurePtyTranscriptProvider / isNativeSourceCanonicalHistory or read the
20
+ * timing flags directly (annotate `// authority-ok: <why>` where a raw read
21
+ * is genuinely about something other than completion classification).
22
+ */
23
+ import type { ProviderCanonicalHistoryConfig } from './contracts.js';
24
+ /** Where the authoritative transcript for completion evidence lives. */
25
+ export type TranscriptClass = 'native-source' | 'pure-pty' | 'daemon-owned';
26
+ /**
27
+ * When a completion may be emitted relative to the PTY idle verdict:
28
+ * - 'hold' — idle holds for the native transcript to land (antigravity).
29
+ * - 'floor' — idle without a final assistant holds under the CANON-C
30
+ * min-elapsed floor (codex / kimi / cursor / opencode).
31
+ * - 'immediate' — emit immediately; a write-lag native source upgrades the
32
+ * weak emit on reconcile (claude), daemon-owned emits plainly.
33
+ */
34
+ export type CompletionTiming = 'hold' | 'floor' | 'immediate';
35
+ export interface TranscriptAuthorityProfile {
36
+ class: TranscriptClass;
37
+ timing: CompletionTiming;
38
+ /** transcriptAuthority === 'provider' — provider parser output is canonical. */
39
+ providerOwnsTranscript: boolean;
40
+ /**
41
+ * False ⇒ this class may run whole turns without PTY generating_started /
42
+ * generating_completed events, so the ABSENCE of a turn-start event must
43
+ * never be read as "dispatch not consumed" (the STARTED-redrive rule,
44
+ * generalized). True only where PTY turn events are reliable.
45
+ */
46
+ emitsPtyTurnEvents: boolean;
47
+ /** The provider's native history config when the class is native-source. */
48
+ nativeHistory?: ProviderCanonicalHistoryConfig;
49
+ }
50
+ /**
51
+ * Structural input — matches how judgment sites actually hold providers
52
+ * (CliProviderModule / ProviderModule / nullable this.provider). Null or
53
+ * undefined resolves to the conservative daemon-owned/immediate default,
54
+ * matching the established null-provider ⇒ not-pure-PTY contract.
55
+ */
56
+ export interface TranscriptAuthorityInput {
57
+ transcriptAuthority?: 'provider' | 'daemon';
58
+ nativeHistory?: ProviderCanonicalHistoryConfig;
59
+ tui?: Record<string, unknown>;
60
+ requiresFinalAssistantBeforeIdle?: boolean;
61
+ holdCompletionForTranscript?: boolean;
62
+ }
63
+ export declare function resolveTranscriptAuthorityProfile(provider: TranscriptAuthorityInput | null | undefined): TranscriptAuthorityProfile;
@@ -793,6 +793,14 @@ export interface LocalMeshNodeEntry {
793
793
  * first envelope carrying it is ingested.
794
794
  */
795
795
  reportedMemberState?: MeshReportedMemberState;
796
+ /**
797
+ * Versioned per-machine runtime facts bundle (MeshNodeFacts — deploy-lag
798
+ * visibility design §a). Remote nodes: ingested wholesale from the
799
+ * git_status envelope's reporterNodeFacts. Self/worktree nodes: built by
800
+ * the SAME producer (buildLocalNodeFacts). Relayed opaquely — surfaces
801
+ * must pass the object through, never rebuild it field-by-field.
802
+ */
803
+ nodeFacts?: import('@adhdev/mesh-shared').MeshNodeFacts;
796
804
  /**
797
805
  * The operator-set machine nickname (config.machineNickname) of the daemon
798
806
  * that owns this node's workspace. The local coordinator stamps its own
@@ -904,6 +912,14 @@ export interface RepoMeshStatus {
904
912
  * an optional `stats` operational rollup (durations / retries).
905
913
  */
906
914
  missions?: (MeshMissionSummary | MeshMissionSlimSummary)[];
915
+ /**
916
+ * Preview-deploy freshness rollup (deploy-lag visibility): stamped by the
917
+ * dashboard mesh_status when the repo has a preview pipeline. Shape is
918
+ * daemon-defined (mesh/preview-freshness.ts) — `currentMainCommit` is the
919
+ * global deploy-lag anchor the dashboard compares nodeFacts.daemonBuild
920
+ * against. Omitted for repos without the pipeline and by older daemons.
921
+ */
922
+ previewFreshness?: Record<string, unknown>;
907
923
  /**
908
924
  * MAGI cross-verification activity, reconstructed from the mesh ledger
909
925
  * (magi_dispatched / magi_synthesis entries) and folded in so the dashboard's
@@ -1070,6 +1086,13 @@ export interface RepoMeshNodeStatus {
1070
1086
  * (scope/isDaemonAffecting flags). Omitted when the build is current.
1071
1087
  */
1072
1088
  staleDaemonBuild?: Record<string, unknown>;
1089
+ /**
1090
+ * Versioned per-machine runtime facts bundle (MeshNodeFacts). Opaque
1091
+ * pass-through from the node record — see the node-level field doc.
1092
+ * Carries the daemon build COMMIT (not just the version string), which is
1093
+ * the deploy-lag anchor the dashboard renders.
1094
+ */
1095
+ nodeFacts?: import('@adhdev/mesh-shared').MeshNodeFacts;
1073
1096
  error?: string;
1074
1097
  }
1075
1098
  export type RepoMeshQueueTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.28-rc.5",
3
+ "version": "1.0.28-rc.6",
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",
@@ -30,6 +30,8 @@
30
30
  "dev": "tsup --watch --no-clean",
31
31
  "lint": "eslint \"src/mesh/**/*.ts\"",
32
32
  "test": "vitest run",
33
+ "test:fast": "vitest run --config vitest.fast.config.mts",
34
+ "test:git": "vitest run --config vitest.git.config.mts",
33
35
  "test:watch": "vitest",
34
36
  "typecheck": "tsc --noEmit -p tsconfig.json"
35
37
  },
@@ -47,8 +49,8 @@
47
49
  "author": "vilmire",
48
50
  "license": "AGPL-3.0-or-later",
49
51
  "dependencies": {
50
- "@adhdev/mesh-shared": "1.0.28-rc.5",
51
- "@adhdev/session-host-core": "1.0.28-rc.5",
52
+ "@adhdev/mesh-shared": "1.0.28-rc.6",
53
+ "@adhdev/session-host-core": "1.0.28-rc.6",
52
54
  "@agentclientprotocol/sdk": "^0.16.1",
53
55
  "ajv": "^8.20.0",
54
56
  "ajv-formats": "^3.0.1",
@@ -15,7 +15,6 @@ import {
15
15
  import {
16
16
  buildCliScreenSnapshot,
17
17
  compactPromptText,
18
- isPurePtyTranscriptProvider,
19
18
  normalizePromptText,
20
19
  promptLikelyVisible,
21
20
  type CliChatMessage,
@@ -24,6 +23,7 @@ import {
24
23
  type CliTraceEntry,
25
24
  type ParsedSession,
26
25
  } from './provider-cli-shared.js';
26
+ import { resolveTranscriptAuthorityProfile } from '../providers/transcript-evidence.js';
27
27
  import type { CliScriptRunner } from './cli-script-runner.js';
28
28
 
29
29
  // ─── Types ─────────────────────────────────────────────────────────────────
@@ -375,8 +375,9 @@ export class CliStateEngine {
375
375
  // existing PTY-parsed final-assistant idle gate produce a real
376
376
  // generating→idle completion edge. Same idle safety as above: applyIdle
377
377
  // / finishResponse still own the actual idle transition.
378
- const promoteOnTurnStart = this.provider.transcriptAuthority === 'provider'
379
- || isPurePtyTranscriptProvider(this.provider);
378
+ const promoteProfile = resolveTranscriptAuthorityProfile(this.provider);
379
+ const promoteOnTurnStart = promoteProfile.providerOwnsTranscript
380
+ || promoteProfile.class === 'pure-pty';
380
381
  if (promoteOnTurnStart && this.currentStatus !== 'waiting_approval') {
381
382
  this.setStatus('generating', 'turn_started');
382
383
  this.callbacks.onStatusChange();
@@ -357,6 +357,11 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
357
357
  ...(typeof node.reportedDaemonBuildVersion === 'string' && node.reportedDaemonBuildVersion
358
358
  ? { daemonBuildVersion: node.reportedDaemonBuildVersion }
359
359
  : {}),
360
+ // Opaque pass-through of the versioned facts bundle —
361
+ // never rebuild it field-by-field (deploy-lag design §a).
362
+ ...(node.nodeFacts && typeof node.nodeFacts === 'object'
363
+ ? { nodeFacts: node.nodeFacts }
364
+ : {}),
360
365
  providerPriority,
361
366
  // ORCHESTRATION_NODE_SLOTS.md: surface the node's capability
362
367
  // slots so the dashboard slot editor can read them. Only
@@ -36,6 +36,7 @@ import {
36
36
  RefineExecFileAsync,
37
37
  RefineStageOutcome,
38
38
  classifyPatchEquivalenceFailure,
39
+ collectTrivialFastForwardGitlinkResolutions,
39
40
  convergeDivergedSubmoduleGitlinks,
40
41
  recordMeshRefineStage,
41
42
  resolveRefineryAutoPublishSubmoduleMainCommits,
@@ -626,6 +627,35 @@ export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: Refine
626
627
  }
627
628
  } catch { /* fail-open: on gate error, fall through to the rebase */ }
628
629
 
630
+ // TRIVIAL-FF GITLINK: the diverged path above only fills gitlinkResolutions
631
+ // when base and branch advanced the SAME submodule to NON-ff (sibling) commits.
632
+ // When the changed gitlink is instead a strict fast-forward (base advanced the
633
+ // submodule to an ancestor/descendant of the branch-side commit — the common
634
+ // case when a sibling branch already merged its oss bump), the pre-rebase gate
635
+ // reports NO submodule_conflict, so gitlinkResolutions stays empty and the plain
636
+ // `git rebase baseHead` below runs. That plain rebase still hits the same gitlink
637
+ // and aborts ("Recursive merging with submodules currently only supports trivial
638
+ // cases"), wrongly blocking the branch. So when behind>0 and any changed gitlink
639
+ // remains (and the diverged path did not already resolve them), collect the
640
+ // trivial-ff resolutions and take the gitlink-aware root rebase too. Direction is
641
+ // the same as the diverged rule: resolve to the more-advanced (descendant) commit.
642
+ if (gitlinkResolutions.length === 0) {
643
+ try {
644
+ const ffResolutions = collectTrivialFastForwardGitlinkResolutions(
645
+ node.workspace, repoRoot, baseHead, branchHead,
646
+ );
647
+ if (ffResolutions.length > 0) {
648
+ gitlinkResolutions = ffResolutions;
649
+ recordMeshRefineStage(refineStages, 'submodule_gitlink_converge', 'passed', syncStarted, {
650
+ reason: 'submodule_trivial_ff_gitlink_aware_rebase',
651
+ gitlinks: ffResolutions.map(r => ({ path: r.path, rebasedCommit: r.rebasedCommit })),
652
+ });
653
+ LOG.info('Mesh', `[Refinery] Trivial fast-forward submodule gitlink(s) for node ${node.id} — using gitlink-aware rebase: `
654
+ + ffResolutions.map(r => `${r.path}→${r.rebasedCommit.slice(0, 12)}`).join(', '));
655
+ }
656
+ } catch { /* fail-open: on collection error, fall through to the plain rebase */ }
657
+ }
658
+
629
659
  // behind>0: strictly-behind OR diverged. Rebase the branch onto the pinned
630
660
  // baseHead. A conflict aborts and terminates blocked_review (retryable=false —
631
661
  // a real content conflict needs human resolution, not a base-movement retry).
@@ -651,6 +651,8 @@ export function updateNode(
651
651
  * Slots are NOT carried — they are coordinator-owned config
652
652
  * (REMOTE-NODE-SLOTS-COORDINATOR-LOCAL fix). */
653
653
  reportedMemberState?: MeshReportedMemberState;
654
+ /** Versioned runtime-facts bundle — whole-object replace, opaque. */
655
+ nodeFacts?: import('@adhdev/mesh-shared').MeshNodeFacts;
654
656
  },
655
657
  ): LocalMeshNodeEntry | undefined {
656
658
  const config = loadMeshConfig();
@@ -676,6 +678,11 @@ export function updateNode(
676
678
  // fields). normalizeReportedMemberState upstream guarantees a clean shape.
677
679
  node.reportedMemberState = opts.reportedMemberState;
678
680
  }
681
+ if (opts.nodeFacts) {
682
+ // Versioned runtime-facts bundle — whole-object replace, OPAQUE (unknown
683
+ // future fields persist untouched; deploy-lag design §a).
684
+ node.nodeFacts = opts.nodeFacts;
685
+ }
679
686
  if (opts.policy) node.policy = { ...node.policy, ...opts.policy };
680
687
  if (Object.prototype.hasOwnProperty.call(opts, 'capabilities')) {
681
688
  // Explicit replace: normalize (trim/dedup/drop-empties); an empty result
@@ -4,6 +4,7 @@ import { getGitDiffSummary, getGitFileDiff } from './git-diff.js';
4
4
  import { GitCommandError, isPathInside, resolveGitRepository, runGit } from './git-executor.js';
5
5
  import { createGitSnapshotStore } from './git-snapshot-store.js';
6
6
  import { getGitRepoStatus } from './git-status.js';
7
+ import { buildLocalNodeFacts } from '../mesh/node-facts.js';
7
8
  import { loadConfig } from '../config/config.js';
8
9
  import type {
9
10
  GitCommandName,
@@ -133,7 +134,7 @@ type GitCommandSuccess =
133
134
  // (REMOTE-NODE-SLOTS-COORDINATOR-LOCAL fix). The legacy flat
134
135
  // reporterProviderVersions/reporterDaemonBuildVersion fields are ALSO emitted for
135
136
  // back-compat during a mixed-version-mesh rollout.
136
- | { success: true; status: GitRepoStatus; reporterPlatform?: string; reporterArch?: string; reporterMachineNickname?: string; reporterProviderVersions?: Record<string, string>; reporterDaemonBuildVersion?: string; reporterMemberState?: { providerVersions?: Record<string, string>; daemonBuildVersion?: string; lastReportedAt?: number } }
137
+ | { success: true; status: GitRepoStatus; reporterPlatform?: string; reporterArch?: string; reporterMachineNickname?: string; reporterProviderVersions?: Record<string, string>; reporterDaemonBuildVersion?: string; reporterMemberState?: { providerVersions?: Record<string, string>; daemonBuildVersion?: string; lastReportedAt?: number }; reporterNodeFacts?: import('@adhdev/mesh-shared').MeshNodeFacts }
137
138
  | { success: true; diffSummary: GitDiffSummary }
138
139
  | { success: true; diff: GitFileDiff }
139
140
  | { success: true; snapshot: GitSnapshot }
@@ -382,6 +383,14 @@ export async function handleGitCommand(
382
383
  lastReportedAt: Date.now(),
383
384
  }
384
385
  : undefined;
386
+ // Versioned facts bundle (deploy-lag visibility design §a): built by the
387
+ // SAME buildLocalNodeFacts the self-node stamp uses, relayed opaquely —
388
+ // never rebuild it field-by-field downstream. Legacy flat fields and
389
+ // reporterMemberState ride alongside for mixed-version meshes.
390
+ const reporterNodeFacts = buildLocalNodeFacts({
391
+ providerVersions: reporterProviderVersions ?? null,
392
+ machineNickname: reporterMachineNickname ?? null,
393
+ });
385
394
  return {
386
395
  success: true,
387
396
  status,
@@ -391,6 +400,7 @@ export async function handleGitCommand(
391
400
  ...(reporterProviderVersions ? { reporterProviderVersions } : {}),
392
401
  ...(reporterDaemonBuildVersion ? { reporterDaemonBuildVersion } : {}),
393
402
  ...(reporterMemberState ? { reporterMemberState } : {}),
403
+ reporterNodeFacts,
394
404
  };
395
405
  }
396
406