@adhdev/daemon-core 0.9.82-rc.514 → 0.9.82-rc.515

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,7 @@
11
11
  * (e.g. `from '.../commands/router.js'`) keep working.
12
12
  */
13
13
  import type { ProviderLoader } from '../providers/provider-loader.js';
14
+ import type { MeshReportedMemberState } from '../repo-mesh-types.js';
14
15
  import { awaitWithWarmupDeadline } from '../mesh/mesh-warmup-deadline.js';
15
16
  export declare function readProviderPriorityFromPolicy(policy: unknown): string[];
16
17
  /**
@@ -51,6 +52,7 @@ export declare function recordInlineMeshDirectGitTruth(node: any, git: Record<st
51
52
  reporterMachineNickname: string | null;
52
53
  reporterProviderVersions: Record<string, string> | null;
53
54
  reporterDaemonBuildVersion: string | null;
55
+ reportedMemberState: MeshReportedMemberState | null;
54
56
  };
55
57
  /**
56
58
  * Persist the live self-reported platform/arch onto the local meshes.json node
@@ -69,6 +71,7 @@ export declare function persistNodeReporterPlatform(meshSource: 'inline_cache' |
69
71
  reporterMachineNickname?: string | null;
70
72
  reporterProviderVersions?: Record<string, string> | null;
71
73
  reporterDaemonBuildVersion?: string | null;
74
+ reportedMemberState?: MeshReportedMemberState | null;
72
75
  }): void;
73
76
  export declare function inlineMeshCarriesTransientNodeTruth(inlineMesh: any): boolean;
74
77
  export declare function readInlineMeshNodeId(node: any): string;
@@ -13,10 +13,37 @@ import { type NodeCapabilitySlot } from '@adhdev/mesh-shared';
13
13
  /** Ordered, de-duplicated providerPriority from a node policy (defensive). */
14
14
  export declare function normalizeProviderPriority(policy: unknown): string[];
15
15
  /**
16
- * Resolve a node's capability slots: explicit `policy.slots` when present, else
17
- * derived from the legacy `providerPriority` + machine-global difficultyBrains.
16
+ * Resolve a node's capability slots with EXPLICIT self-vs-remote precedence
17
+ * (REMOTE-NODE-SLOT-CAP-CHIP fix, Direction B).
18
+ *
19
+ * Precedence, avoiding the precedence-inversion a blind `policy.slots || mirror`
20
+ * would introduce:
21
+ * 1. Local `policy.slots` — authoritative for the SELF/locally-owned node (the
22
+ * node owns its own config locally). This ALSO wins for a remote node in the
23
+ * unusual case an operator configured slots on the coordinator side.
24
+ * 2. `reportedMemberState.slots` — the remote member's OWN resolved slots,
25
+ * mirrored wholesale over P2P. Authoritative for a REMOTE mirrored node whose
26
+ * coordinator-side policy copy is the stale join-time empty `{}` (which
27
+ * normalizes to []). This is the field the coordinator's join-time copy lacks
28
+ * — the reason remote slot-cap chips were missing.
29
+ * 3. Legacy-derived from `providerPriority` + machine-global difficultyBrains.
30
+ *
31
+ * The mirror only ever fills the gap left by an empty local policy — it can never
32
+ * shadow a populated local policy — so there is no inversion. And a self node never
33
+ * carries a reportedMemberState (the ingest path stamps it ONLY for remote
34
+ * members), so a self node's local policy is always used directly.
18
35
  * (The former per-provider `providerRoles` cap has been removed; a persisted
19
36
  * meshes.json is migrated to slots on load, so by the time a node reaches routing
20
37
  * its cap already lives on `slots[].maxParallel`.)
21
38
  */
22
39
  export declare function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[];
40
+ /**
41
+ * Reporter-side helper (Direction B): resolve THIS daemon's own capability slots for
42
+ * a given workspace by finding the local meshes.json node whose workspace matches
43
+ * and running the same {@link resolveNodeCapabilitySlots} over its real local policy.
44
+ * Used at the git_status reporter wiring so a member node self-reports its own
45
+ * resolved slots inside the unified reporterMemberState envelope. Returns [] when no
46
+ * local node matches the workspace or slots can't be resolved (best-effort, never
47
+ * throws) so the reporter simply omits the field.
48
+ */
49
+ export declare function resolveLocalNodeSlotsForWorkspace(workspace: string | undefined): NodeCapabilitySlot[];
@@ -300,6 +300,39 @@ export interface RepoMeshRelatedRepo {
300
300
  * persisted meshes.json that still carries it is migrated to `slots` on load
301
301
  * (see migrateLoadedMeshConfig).
302
302
  */
303
+ /**
304
+ * Direction-B unified mirrored member state (REMOTE-NODE-SLOT-CAP-CHIP fix).
305
+ *
306
+ * A single self-reported observability envelope stamped by the daemon that OWNS a
307
+ * node's workspace and carried wholesale on the git_status envelope
308
+ * (reporterMemberState), replacing the fragmented per-field self-heal that mirrored
309
+ * providerVersions and daemonBuildVersion ad-hoc but silently dropped `slots`. The
310
+ * coordinator ingests this whole object onto the mirrored remote node in one place
311
+ * (mesh-node-identity.recordInlineMeshDirectGitTruth).
312
+ *
313
+ * Source-of-truth model: EACH NODE owns its own slots/versions locally; the
314
+ * coordinator is a pure mirror of a remote member's reported state. Its own
315
+ * join-time `policy` copy for a remote member is a stale empty `{}` and must never
316
+ * shadow this mirror (that would be a precedence inversion — see
317
+ * resolveNodeCapabilitySlots).
318
+ */
319
+ export interface MeshReportedMemberState {
320
+ /** Reporter node's detected provider CLI/ACP versions (keyed by provider id). */
321
+ providerVersions?: Record<string, string>;
322
+ /** Reporter daemon's build version (getDaemonBuildInfo().version). */
323
+ daemonBuildVersion?: string;
324
+ /**
325
+ * Reporter node's OWN resolved capability slots (resolveNodeCapabilitySlots over
326
+ * its LOCAL real policy). This is the field the coordinator's join-time policy
327
+ * copy lacks for a remote member, and the reason remote slot-cap chips were
328
+ * missing. For a remote mirrored node this is authoritative over the (empty)
329
+ * local policy.slots; for the coordinator's own node the local policy is used
330
+ * directly (never mirrored here).
331
+ */
332
+ slots?: NodeCapabilitySlot[];
333
+ /** Epoch ms the report was stamped; used for mirror staleness reasoning. */
334
+ lastReportedAt?: number;
335
+ }
303
336
  export interface RepoMeshNodePolicy {
304
337
  readOnly?: boolean;
305
338
  canPush?: boolean;
@@ -634,6 +667,24 @@ export interface LocalMeshNodeEntry {
634
667
  * owning daemon, carried on the git_status envelope (reporterDaemonBuildVersion)
635
668
  * alongside the provider versions. Absent until first reported. */
636
669
  reportedDaemonBuildVersion?: string;
670
+ /**
671
+ * Unified mirrored "member state" self-reported by the daemon that owns this
672
+ * node's workspace, carried wholesale on the git_status envelope
673
+ * (reporterMemberState) and ingested by the coordinator in one place. This is
674
+ * the Direction-B consolidation of the previously per-field self-heal
675
+ * (reportedProviderVersions, reportedDaemonBuildVersion — and now the node's
676
+ * OWN resolved capability `slots`, which the coordinator's join-time policy copy
677
+ * for a remote member does NOT carry). Each node owns its own slots/versions
678
+ * locally; the coordinator is a pure mirror of what a remote member reports over
679
+ * P2P — it must never treat its stale join-time policy copy as authoritative for
680
+ * a remote node's slots. The legacy flat fields above stay populated in parallel
681
+ * during rollout for back-compat (see mesh-node-identity ingest). `slots` is the
682
+ * reporter node's own resolved capability slots (resolveNodeCapabilitySlots over
683
+ * its LOCAL real policy). `lastReportedAt` is the epoch ms the report was
684
+ * stamped, so a partitioned node's mirror does not linger as fresh forever.
685
+ * Absent until the first envelope carrying it is ingested.
686
+ */
687
+ reportedMemberState?: MeshReportedMemberState;
637
688
  /**
638
689
  * The operator-set machine nickname (config.machineNickname) of the daemon
639
690
  * that owns this node's workspace. The local coordinator stamps its own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.514",
3
+ "version": "0.9.82-rc.515",
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.514",
51
- "@adhdev/session-host-core": "0.9.82-rc.514",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.515",
51
+ "@adhdev/session-host-core": "0.9.82-rc.515",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -37,6 +37,7 @@ import { loadConfig } from '../config/config.js';
37
37
  import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
38
38
  import type { IdeProviderInstance } from '../providers/ide-provider-instance.js';
39
39
  import { createDefaultGitCommandServices } from '../git/git-commands.js';
40
+ import { resolveLocalNodeSlotsForWorkspace } from '../mesh/mesh-node-slots.js';
40
41
  import { setupMeshEventForwarding } from '../mesh/mesh-events.js';
41
42
  import { setupMeshReconcileLoop } from '../mesh/mesh-reconcile-loop.js';
42
43
  import { loadMeshCoordinatorRegistry } from '../mesh/coordinator-registry.js';
@@ -312,15 +313,24 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
312
313
  instanceManager,
313
314
  sessionRegistry,
314
315
  gitCommandServices: createDefaultGitCommandServices({
315
- // T7: fold this daemon's cached provider versions + build version onto the
316
- // git_status envelope so the mesh coordinator self-heals each node's
317
- // providerVersions. Non-blocking: reads a TTL cache, lazily refreshed.
318
- getReporterProviderVersions: () => {
316
+ // T7 + Direction-B: fold this daemon's cached provider versions + build
317
+ // version AND its OWN resolved capability slots for the reported workspace
318
+ // onto the git_status envelope so the mesh coordinator self-heals each
319
+ // node's providerVersions AND its slot-cap chips (REMOTE-NODE-SLOT-CAP-CHIP
320
+ // fix). Non-blocking: reads a TTL cache + local meshes.json, lazily
321
+ // refreshed. The slots the reporter resolves are its LOCAL real policy's
322
+ // slots — the coordinator mirrors them wholesale for a remote member whose
323
+ // join-time policy copy is empty. (setDefaultProviderLoader is registered
324
+ // above so getCachedProviderVersions/slot resolution is not empty — see
325
+ // the 380556d3 version-chip lesson.)
326
+ getReporterProviderVersions: (workspace?: string) => {
319
327
  const providerVersions = getCachedProviderVersions(providerLoader);
320
328
  const daemonBuildVersion = getDaemonBuildInfo().version;
329
+ const slots = resolveLocalNodeSlotsForWorkspace(workspace);
321
330
  return {
322
331
  ...(Object.keys(providerVersions).length > 0 ? { providerVersions } : {}),
323
332
  ...(daemonBuildVersion && daemonBuildVersion !== 'unknown' ? { daemonBuildVersion } : {}),
333
+ ...(slots.length > 0 ? { slots } : {}),
324
334
  };
325
335
  },
326
336
  }),
@@ -21,6 +21,7 @@ import type {
21
21
  RepoMeshCoordinatorConfig,
22
22
  RepoMeshHostMetadata,
23
23
  RepoMeshDaemonRole,
24
+ MeshReportedMemberState,
24
25
  } from '../repo-mesh-types.js';
25
26
  import type { MagiKindPanelMap, MagiSlot, MagiTaskKind, DifficultyBrainMap, NodeCapabilitySlot } from '@adhdev/mesh-shared';
26
27
  import { normalizeDifficultyBrainMap, DEFAULT_DIFFICULTY_BRAINS, normalizeNodeCapabilitySlots, deriveSlotsFromLegacy } from '@adhdev/mesh-shared';
@@ -643,6 +644,11 @@ export function updateNode(
643
644
  * is overwritten by the next report. */
644
645
  reportedProviderVersions?: Record<string, string>;
645
646
  reportedDaemonBuildVersion?: string;
647
+ /** Direction-B unified mirrored member state (versions + build + own resolved
648
+ * slots + lastReportedAt) self-reported by the owning daemon on the git_status
649
+ * envelope. Persisted wholesale so a remote node's slot-cap chips survive a
650
+ * coordinator restart, mirroring the per-field reported* self-heal. */
651
+ reportedMemberState?: MeshReportedMemberState;
646
652
  },
647
653
  ): LocalMeshNodeEntry | undefined {
648
654
  const config = loadMeshConfig();
@@ -662,6 +668,12 @@ export function updateNode(
662
668
  if (opts.reportedDaemonBuildVersion && opts.reportedDaemonBuildVersion.trim()) {
663
669
  node.reportedDaemonBuildVersion = opts.reportedDaemonBuildVersion.trim();
664
670
  }
671
+ if (opts.reportedMemberState) {
672
+ // Whole-object replace (auto-detected observability, overwritten by the next
673
+ // report so a stale mirror never sticks — same policy as the flat reported*
674
+ // fields). normalizeReportedMemberState upstream guarantees a clean shape.
675
+ node.reportedMemberState = opts.reportedMemberState;
676
+ }
665
677
  if (opts.policy) node.policy = { ...node.policy, ...opts.policy };
666
678
  if (Object.prototype.hasOwnProperty.call(opts, 'capabilities')) {
667
679
  // Explicit replace: normalize (trim/dedup/drop-empties); an empty result
@@ -109,7 +109,18 @@ export interface GitCommandServices {
109
109
  * same way it does platform/arch. Wired at daemon boot from the cached CLI
110
110
  * detection snapshot; omitted (⇒ versions not reported) when unavailable.
111
111
  */
112
- getReporterProviderVersions?: () => { providerVersions?: Record<string, string>; daemonBuildVersion?: string };
112
+ getReporterProviderVersions?: (workspace?: string) => {
113
+ providerVersions?: Record<string, string>;
114
+ daemonBuildVersion?: string;
115
+ /**
116
+ * Direction-B: the reporter node's OWN resolved capability slots
117
+ * (resolveNodeCapabilitySlots over its LOCAL policy for this workspace). Carried
118
+ * inside the unified reporterMemberState so a remote node's slot-cap chips
119
+ * self-heal on the coordinator the same way versions do. Best-effort; omitted
120
+ * when the reporter cannot resolve slots for the workspace.
121
+ */
122
+ slots?: unknown[];
123
+ };
113
124
  }
114
125
 
115
126
  type GitCommandFailure = {
@@ -124,7 +135,11 @@ type GitCommandSuccess =
124
135
  // node's userOverrides.platform/arch (the fields capability-tag routing reads).
125
136
  // reporterMachineNickname carries the responding daemon's config.machineNickname
126
137
  // so the coordinator can populate node.machineNickname → the friendly display label.
127
- | { success: true; status: GitRepoStatus; reporterPlatform?: string; reporterArch?: string; reporterMachineNickname?: string; reporterProviderVersions?: Record<string, string>; reporterDaemonBuildVersion?: string }
138
+ // reporterMemberState is the Direction-B unified envelope (versions + build + the
139
+ // reporter node's own resolved capability slots + lastReportedAt). The legacy flat
140
+ // reporterProviderVersions/reporterDaemonBuildVersion fields are ALSO emitted for
141
+ // back-compat during a mixed-version-mesh rollout.
142
+ | { success: true; status: GitRepoStatus; reporterPlatform?: string; reporterArch?: string; reporterMachineNickname?: string; reporterProviderVersions?: Record<string, string>; reporterDaemonBuildVersion?: string; reporterMemberState?: { providerVersions?: Record<string, string>; daemonBuildVersion?: string; slots?: unknown[]; lastReportedAt?: number } }
128
143
  | { success: true; diffSummary: GitDiffSummary }
129
144
  | { success: true; diff: GitFileDiff }
130
145
  | { success: true; snapshot: GitSnapshot }
@@ -346,7 +361,7 @@ export async function handleGitCommand(
346
361
  // snapshot, so a cold cache simply omits the fields on this probe.
347
362
  const reporterVersions = (() => {
348
363
  try {
349
- return services.getReporterProviderVersions?.() ?? {};
364
+ return services.getReporterProviderVersions?.(workspace) ?? {};
350
365
  } catch {
351
366
  return {};
352
367
  }
@@ -359,6 +374,23 @@ export async function handleGitCommand(
359
374
  typeof reporterVersions.daemonBuildVersion === 'string' && reporterVersions.daemonBuildVersion.trim()
360
375
  ? reporterVersions.daemonBuildVersion.trim()
361
376
  : undefined;
377
+ const reporterSlots =
378
+ Array.isArray(reporterVersions.slots) && reporterVersions.slots.length > 0
379
+ ? reporterVersions.slots
380
+ : undefined;
381
+ // Direction-B unified envelope: fold versions/build/slots into ONE object the
382
+ // coordinator ingests wholesale. Emitted only when at least one field is
383
+ // present so a fully-cold reporter doesn't ship an empty stub. The legacy flat
384
+ // fields above ride alongside for back-compat during rollout.
385
+ const reporterMemberState =
386
+ reporterProviderVersions || reporterDaemonBuildVersion || reporterSlots
387
+ ? {
388
+ ...(reporterProviderVersions ? { providerVersions: reporterProviderVersions } : {}),
389
+ ...(reporterDaemonBuildVersion ? { daemonBuildVersion: reporterDaemonBuildVersion } : {}),
390
+ ...(reporterSlots ? { slots: reporterSlots } : {}),
391
+ lastReportedAt: Date.now(),
392
+ }
393
+ : undefined;
362
394
  return {
363
395
  success: true,
364
396
  status,
@@ -367,6 +399,7 @@ export async function handleGitCommand(
367
399
  ...(reporterMachineNickname ? { reporterMachineNickname } : {}),
368
400
  ...(reporterProviderVersions ? { reporterProviderVersions } : {}),
369
401
  ...(reporterDaemonBuildVersion ? { reporterDaemonBuildVersion } : {}),
402
+ ...(reporterMemberState ? { reporterMemberState } : {}),
370
403
  };
371
404
  }
372
405
 
@@ -15,7 +15,8 @@ import type { ProviderLoader } from '../providers/provider-loader.js';
15
15
  import { detectCLI, getCachedProviderVersions } from '../detection/cli-detector.js';
16
16
  import { getDaemonBuildInfo } from '../build-info.js';
17
17
  import { getGitRepoStatus } from '../git/git-status.js';
18
- import { normalizeGitStatus as sharedNormalizeGitStatus, pickBestTransitGitStatus as sharedPickBestTransitGitStatus, summarizeGitShape as sharedSummarizeGitShape, normalizeMeshNodeId, daemonIdsEquivalent, meshWorkspacesEquivalent, sessionIdsEquivalent, withStatusProbeMarker } from '@adhdev/mesh-shared';
18
+ import { normalizeGitStatus as sharedNormalizeGitStatus, pickBestTransitGitStatus as sharedPickBestTransitGitStatus, summarizeGitShape as sharedSummarizeGitShape, normalizeMeshNodeId, daemonIdsEquivalent, meshWorkspacesEquivalent, sessionIdsEquivalent, withStatusProbeMarker, normalizeNodeCapabilitySlots, type NodeCapabilitySlot } from '@adhdev/mesh-shared';
19
+ import type { MeshReportedMemberState } from '../repo-mesh-types.js';
19
20
  import { LOG } from '../logging/logger.js';
20
21
  import { getSessionHostSurfaceKind } from '../session-host/runtime-surface.js';
21
22
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from '../mesh/mesh-warmup-deadline.js';
@@ -326,6 +327,7 @@ export function recordInlineMeshDirectGitTruth(
326
327
  reporterMachineNickname: string | null;
327
328
  reporterProviderVersions: Record<string, string> | null;
328
329
  reporterDaemonBuildVersion: string | null;
330
+ reportedMemberState: MeshReportedMemberState | null;
329
331
  } {
330
332
  if (!node || typeof node !== 'object' || Array.isArray(node)) {
331
333
  return {
@@ -334,6 +336,7 @@ export function recordInlineMeshDirectGitTruth(
334
336
  reporterMachineNickname: null,
335
337
  reporterProviderVersions: null,
336
338
  reporterDaemonBuildVersion: null,
339
+ reportedMemberState: null,
337
340
  };
338
341
  }
339
342
  const checkedAt = readNumberValue(git.lastCheckedAt) ?? Date.now();
@@ -389,21 +392,81 @@ export function recordInlineMeshDirectGitTruth(
389
392
  // carries no reporter* versions — mirror the platform/arch self-heal above and read
390
393
  // this daemon's own warm version cache directly, so the coordinator's self node gets
391
394
  // the same provider/build chips a remote node does.
395
+ // Direction-B: parse the UNIFIED reportedMemberState envelope first (versions +
396
+ // build + the member's own resolved slots), falling back to the flat legacy
397
+ // fields so a mixed-version mesh during rollout still ingests. This is the single
398
+ // place the coordinator mirrors a remote member's reported state wholesale.
399
+ const unified = normalizeReportedMemberState(git.reporterMemberState);
392
400
  const reporterProviderVersions =
393
- readProviderVersionsRecord(git.reporterProviderVersions)
401
+ (unified?.providerVersions ? readProviderVersionsRecord(unified.providerVersions) : null)
402
+ ?? readProviderVersionsRecord(git.reporterProviderVersions)
394
403
  ?? (isLocalSource ? readLocalReporterProviderVersions() : null);
395
404
  if (reporterProviderVersions) node.reportedProviderVersions = reporterProviderVersions;
396
405
  const reporterDaemonBuildVersion =
397
- (readStringValue(git.reporterDaemonBuildVersion)
406
+ (readStringValue(unified?.daemonBuildVersion)
407
+ ?? readStringValue(git.reporterDaemonBuildVersion)
398
408
  ?? (isLocalSource ? readLocalReporterDaemonBuildVersion() : null))
399
409
  ?? null;
400
410
  if (reporterDaemonBuildVersion) node.reportedDaemonBuildVersion = reporterDaemonBuildVersion;
411
+ // Mirror the unified state onto the node record so resolveNodeCapabilitySlots can
412
+ // read the remote member's OWN resolved slots. Only stamped for a REMOTE member
413
+ // (!isLocalSource): the coordinator's own / worktree nodes own their slots via
414
+ // local policy.slots, so a self node deliberately carries NO reportedMemberState
415
+ // — this is what keeps the resolver's precedence inversion-free (a self node's
416
+ // local policy is never shadowed by a mirror it doesn't have). Synthesize from
417
+ // the legacy flat fields when a node reports only those (no unified envelope yet).
418
+ if (!isLocalSource) {
419
+ const reportedSlots = normalizeNodeCapabilitySlots(unified?.slots);
420
+ const mirrored: {
421
+ providerVersions?: Record<string, string>;
422
+ daemonBuildVersion?: string;
423
+ slots?: NodeCapabilitySlot[];
424
+ lastReportedAt?: number;
425
+ } = {
426
+ ...(reporterProviderVersions ? { providerVersions: reporterProviderVersions } : {}),
427
+ ...(reporterDaemonBuildVersion ? { daemonBuildVersion: reporterDaemonBuildVersion } : {}),
428
+ ...(reportedSlots.length ? { slots: reportedSlots } : {}),
429
+ lastReportedAt: readNumberValue(unified?.lastReportedAt) ?? checkedAt,
430
+ };
431
+ // Only stamp when there's actual reported content beyond the timestamp — a
432
+ // bare {lastReportedAt} carries no observability and would just churn the
433
+ // record on every probe.
434
+ if (mirrored.providerVersions || mirrored.daemonBuildVersion || mirrored.slots) {
435
+ node.reportedMemberState = mirrored;
436
+ }
437
+ }
401
438
  return {
402
439
  reporterPlatform,
403
440
  reporterArch,
404
441
  reporterMachineNickname,
405
442
  reporterProviderVersions,
406
443
  reporterDaemonBuildVersion,
444
+ reportedMemberState: (!isLocalSource ? (node.reportedMemberState ?? null) : null) as MeshReportedMemberState | null,
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Direction-B: coerce an unknown git-envelope `reporterMemberState` into a clean
450
+ * {@link MeshReportedMemberState}. Reuses readProviderVersionsRecord for the version
451
+ * map and normalizeNodeCapabilitySlots for slots (dropping provider-less entries).
452
+ * Returns null when nothing usable is present (bare/absent envelope) so callers fall
453
+ * back to the legacy flat fields. Best-effort, never throws.
454
+ */
455
+ function normalizeReportedMemberState(value: unknown): MeshReportedMemberState | null {
456
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
457
+ const raw = value as Record<string, unknown>;
458
+ const providerVersions = readProviderVersionsRecord(raw.providerVersions);
459
+ const daemonBuildVersion = readStringValue(raw.daemonBuildVersion);
460
+ const slots = normalizeNodeCapabilitySlots(raw.slots);
461
+ const lastReportedAt = readNumberValue(raw.lastReportedAt);
462
+ if (!providerVersions && !daemonBuildVersion && !slots.length && lastReportedAt === undefined) {
463
+ return null;
464
+ }
465
+ return {
466
+ ...(providerVersions ? { providerVersions } : {}),
467
+ ...(daemonBuildVersion ? { daemonBuildVersion } : {}),
468
+ ...(slots.length ? { slots } : {}),
469
+ ...(lastReportedAt !== undefined ? { lastReportedAt } : {}),
407
470
  };
408
471
  }
409
472
 
@@ -493,6 +556,7 @@ export function persistNodeReporterPlatform(
493
556
  reporterMachineNickname?: string | null;
494
557
  reporterProviderVersions?: Record<string, string> | null;
495
558
  reporterDaemonBuildVersion?: string | null;
559
+ reportedMemberState?: MeshReportedMemberState | null;
496
560
  },
497
561
  ): void {
498
562
  if (meshSource !== 'local_config') return;
@@ -503,12 +567,16 @@ export function persistNodeReporterPlatform(
503
567
  const reportedMachineNickname = reporter.reporterMachineNickname ?? undefined;
504
568
  const reportedProviderVersions = reporter.reporterProviderVersions ?? undefined;
505
569
  const reportedDaemonBuildVersion = reporter.reporterDaemonBuildVersion ?? undefined;
570
+ // Direction-B: persist the unified mirrored member state so a remote node's
571
+ // slot-cap chips survive a coordinator restart (mirrors the per-field self-heal).
572
+ const reportedMemberState = reporter.reportedMemberState ?? undefined;
506
573
  if (
507
574
  !reportedPlatform &&
508
575
  !reportedArch &&
509
576
  !reportedMachineNickname &&
510
577
  !reportedProviderVersions &&
511
- !reportedDaemonBuildVersion
578
+ !reportedDaemonBuildVersion &&
579
+ !reportedMemberState
512
580
  ) {
513
581
  return;
514
582
  }
@@ -519,6 +587,7 @@ export function persistNodeReporterPlatform(
519
587
  reportedMachineNickname,
520
588
  reportedProviderVersions,
521
589
  reportedDaemonBuildVersion,
590
+ reportedMemberState,
522
591
  }))
523
592
  .catch(() => { /* best-effort self-heal; never block status assembly */ });
524
593
  }
@@ -1549,6 +1618,12 @@ async function probeRemoteMeshGitStatus(args: {
1549
1618
  if (reporterProviderVersions) git.reporterProviderVersions = reporterProviderVersions;
1550
1619
  const reporterDaemonBuildVersion = readStringValue(remoteResult?.reporterDaemonBuildVersion);
1551
1620
  if (reporterDaemonBuildVersion) git.reporterDaemonBuildVersion = reporterDaemonBuildVersion;
1621
+ // Direction-B: propagate the member's UNIFIED reportedMemberState (versions +
1622
+ // build + its own resolved slots + lastReportedAt) on the same channel so the
1623
+ // coordinator can ingest it wholesale. Carried alongside the legacy flat fields
1624
+ // above for a mixed-version-mesh rollout.
1625
+ const reporterMemberState = normalizeReportedMemberState(remoteResult?.reporterMemberState);
1626
+ if (reporterMemberState) git.reporterMemberState = reporterMemberState;
1552
1627
  return git;
1553
1628
  }
1554
1629
 
@@ -12,9 +12,10 @@
12
12
  import {
13
13
  deriveSlotsFromLegacy,
14
14
  normalizeNodeCapabilitySlots,
15
+ meshWorkspacesEquivalent,
15
16
  type NodeCapabilitySlot,
16
17
  } from '@adhdev/mesh-shared';
17
- import { getDifficultyBrains } from '../config/mesh-config.js';
18
+ import { getDifficultyBrains, listMeshes } from '../config/mesh-config.js';
18
19
 
19
20
  /** Ordered, de-duplicated providerPriority from a node policy (defensive). */
20
21
  export function normalizeProviderPriority(policy: unknown): string[] {
@@ -34,8 +35,25 @@ export function normalizeProviderPriority(policy: unknown): string[] {
34
35
  }
35
36
 
36
37
  /**
37
- * Resolve a node's capability slots: explicit `policy.slots` when present, else
38
- * derived from the legacy `providerPriority` + machine-global difficultyBrains.
38
+ * Resolve a node's capability slots with EXPLICIT self-vs-remote precedence
39
+ * (REMOTE-NODE-SLOT-CAP-CHIP fix, Direction B).
40
+ *
41
+ * Precedence, avoiding the precedence-inversion a blind `policy.slots || mirror`
42
+ * would introduce:
43
+ * 1. Local `policy.slots` — authoritative for the SELF/locally-owned node (the
44
+ * node owns its own config locally). This ALSO wins for a remote node in the
45
+ * unusual case an operator configured slots on the coordinator side.
46
+ * 2. `reportedMemberState.slots` — the remote member's OWN resolved slots,
47
+ * mirrored wholesale over P2P. Authoritative for a REMOTE mirrored node whose
48
+ * coordinator-side policy copy is the stale join-time empty `{}` (which
49
+ * normalizes to []). This is the field the coordinator's join-time copy lacks
50
+ * — the reason remote slot-cap chips were missing.
51
+ * 3. Legacy-derived from `providerPriority` + machine-global difficultyBrains.
52
+ *
53
+ * The mirror only ever fills the gap left by an empty local policy — it can never
54
+ * shadow a populated local policy — so there is no inversion. And a self node never
55
+ * carries a reportedMemberState (the ingest path stamps it ONLY for remote
56
+ * members), so a self node's local policy is always used directly.
39
57
  * (The former per-provider `providerRoles` cap has been removed; a persisted
40
58
  * meshes.json is migrated to slots on load, so by the time a node reaches routing
41
59
  * its cap already lives on `slots[].maxParallel`.)
@@ -43,6 +61,13 @@ export function normalizeProviderPriority(policy: unknown): string[] {
43
61
  export function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[] {
44
62
  const explicit = normalizeNodeCapabilitySlots(node?.policy?.slots);
45
63
  if (explicit.length) return explicit;
64
+ // Remote mirror: the member's own resolved slots, carried on the git_status
65
+ // envelope and ingested onto the node record. Preferred over the legacy derive
66
+ // (it is the member's real resolved profile), but only when the local policy is
67
+ // empty — so it fills the coordinator's join-time gap without overriding an
68
+ // explicit local override.
69
+ const mirrored = normalizeNodeCapabilitySlots(node?.reportedMemberState?.slots);
70
+ if (mirrored.length) return mirrored;
46
71
  let difficultyBrains: any;
47
72
  try { difficultyBrains = getDifficultyBrains(); } catch { difficultyBrains = undefined; }
48
73
  return deriveSlotsFromLegacy({
@@ -50,3 +75,31 @@ export function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[] {
50
75
  difficultyBrains,
51
76
  });
52
77
  }
78
+
79
+ /**
80
+ * Reporter-side helper (Direction B): resolve THIS daemon's own capability slots for
81
+ * a given workspace by finding the local meshes.json node whose workspace matches
82
+ * and running the same {@link resolveNodeCapabilitySlots} over its real local policy.
83
+ * Used at the git_status reporter wiring so a member node self-reports its own
84
+ * resolved slots inside the unified reporterMemberState envelope. Returns [] when no
85
+ * local node matches the workspace or slots can't be resolved (best-effort, never
86
+ * throws) so the reporter simply omits the field.
87
+ */
88
+ export function resolveLocalNodeSlotsForWorkspace(workspace: string | undefined): NodeCapabilitySlot[] {
89
+ const ws = typeof workspace === 'string' ? workspace.trim() : '';
90
+ if (!ws) return [];
91
+ let meshes: any[];
92
+ try { meshes = listMeshes(); } catch { return []; }
93
+ for (const mesh of Array.isArray(meshes) ? meshes : []) {
94
+ for (const node of Array.isArray(mesh?.nodes) ? mesh.nodes : []) {
95
+ const nodeWs = typeof node?.workspace === 'string' ? node.workspace : '';
96
+ if (nodeWs && meshWorkspacesEquivalent(nodeWs, ws)) {
97
+ // Resolve from the LOCAL node's real policy only — never re-read its
98
+ // own reportedMemberState (a member does not mirror itself).
99
+ const slots = resolveNodeCapabilitySlots({ policy: node?.policy });
100
+ if (slots.length) return slots;
101
+ }
102
+ }
103
+ }
104
+ return [];
105
+ }
@@ -398,6 +398,40 @@ export interface RepoMeshRelatedRepo {
398
398
  * (see migrateLoadedMeshConfig).
399
399
  */
400
400
 
401
+ /**
402
+ * Direction-B unified mirrored member state (REMOTE-NODE-SLOT-CAP-CHIP fix).
403
+ *
404
+ * A single self-reported observability envelope stamped by the daemon that OWNS a
405
+ * node's workspace and carried wholesale on the git_status envelope
406
+ * (reporterMemberState), replacing the fragmented per-field self-heal that mirrored
407
+ * providerVersions and daemonBuildVersion ad-hoc but silently dropped `slots`. The
408
+ * coordinator ingests this whole object onto the mirrored remote node in one place
409
+ * (mesh-node-identity.recordInlineMeshDirectGitTruth).
410
+ *
411
+ * Source-of-truth model: EACH NODE owns its own slots/versions locally; the
412
+ * coordinator is a pure mirror of a remote member's reported state. Its own
413
+ * join-time `policy` copy for a remote member is a stale empty `{}` and must never
414
+ * shadow this mirror (that would be a precedence inversion — see
415
+ * resolveNodeCapabilitySlots).
416
+ */
417
+ export interface MeshReportedMemberState {
418
+ /** Reporter node's detected provider CLI/ACP versions (keyed by provider id). */
419
+ providerVersions?: Record<string, string>;
420
+ /** Reporter daemon's build version (getDaemonBuildInfo().version). */
421
+ daemonBuildVersion?: string;
422
+ /**
423
+ * Reporter node's OWN resolved capability slots (resolveNodeCapabilitySlots over
424
+ * its LOCAL real policy). This is the field the coordinator's join-time policy
425
+ * copy lacks for a remote member, and the reason remote slot-cap chips were
426
+ * missing. For a remote mirrored node this is authoritative over the (empty)
427
+ * local policy.slots; for the coordinator's own node the local policy is used
428
+ * directly (never mirrored here).
429
+ */
430
+ slots?: NodeCapabilitySlot[];
431
+ /** Epoch ms the report was stamped; used for mirror staleness reasoning. */
432
+ lastReportedAt?: number;
433
+ }
434
+
401
435
  export interface RepoMeshNodePolicy {
402
436
  readOnly?: boolean;
403
437
  canPush?: boolean;
@@ -915,6 +949,24 @@ export interface LocalMeshNodeEntry {
915
949
  * owning daemon, carried on the git_status envelope (reporterDaemonBuildVersion)
916
950
  * alongside the provider versions. Absent until first reported. */
917
951
  reportedDaemonBuildVersion?: string;
952
+ /**
953
+ * Unified mirrored "member state" self-reported by the daemon that owns this
954
+ * node's workspace, carried wholesale on the git_status envelope
955
+ * (reporterMemberState) and ingested by the coordinator in one place. This is
956
+ * the Direction-B consolidation of the previously per-field self-heal
957
+ * (reportedProviderVersions, reportedDaemonBuildVersion — and now the node's
958
+ * OWN resolved capability `slots`, which the coordinator's join-time policy copy
959
+ * for a remote member does NOT carry). Each node owns its own slots/versions
960
+ * locally; the coordinator is a pure mirror of what a remote member reports over
961
+ * P2P — it must never treat its stale join-time policy copy as authoritative for
962
+ * a remote node's slots. The legacy flat fields above stay populated in parallel
963
+ * during rollout for back-compat (see mesh-node-identity ingest). `slots` is the
964
+ * reporter node's own resolved capability slots (resolveNodeCapabilitySlots over
965
+ * its LOCAL real policy). `lastReportedAt` is the epoch ms the report was
966
+ * stamped, so a partitioned node's mirror does not linger as fresh forever.
967
+ * Absent until the first envelope carrying it is ingested.
968
+ */
969
+ reportedMemberState?: MeshReportedMemberState;
918
970
  /**
919
971
  * The operator-set machine nickname (config.machineNickname) of the daemon
920
972
  * that owns this node's workspace. The local coordinator stamps its own