@adhdev/daemon-core 0.9.82-rc.485 → 0.9.82-rc.487

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.
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
2
2
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
3
3
  import { MESH_CONNECT_TIMEOUT_MS } from '../runtime-defaults.js';
4
4
  import { loadConfig } from '../config/config.js';
5
- import { getMesh } from '../config/mesh-config.js';
5
+ import { getMesh, getDifficultyBrains } from '../config/mesh-config.js';
6
6
  import { detectCLI } from '../detection/cli-detector.js';
7
7
  import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry } from './mesh-ledger.js';
@@ -15,7 +15,7 @@ import { traceMeshEventDrop } from './mesh-event-trace.js';
15
15
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
16
  import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
17
17
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
- import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
18
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, deriveSlotsFromLegacy, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
19
19
  import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
20
20
  import { readNonEmptyString } from './mesh-events-utils.js';
21
21
  import { readMeshNodeDaemonId } from './mesh-node-identity.js';
@@ -1141,17 +1141,36 @@ function nodeActiveLoad(meshId: string, nodeId: string): number {
1141
1141
  return MeshRuntimeStore.getInstance().nodeActiveAssignmentCount(meshId, nodeId);
1142
1142
  }
1143
1143
 
1144
+ /** True when any node in the mesh has an EXPLICIT capability-slot list configured
1145
+ * (policy.slots). Legacy-derived slots don't count — only an operator (or the
1146
+ * orchestrator, with approval) authoring slots signals intent to route by fitness. */
1147
+ function meshHasExplicitSlots(mesh: any): boolean {
1148
+ const nodes = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
1149
+ return nodes.some((n: any) => normalizeNodeCapabilitySlots(n?.policy?.slots).length > 0);
1150
+ }
1151
+
1144
1152
  /**
1145
1153
  * The mesh-wide scheduling strategy, read from the MACHINE-LOCAL stored mesh
1146
1154
  * policy. Resolution order:
1147
- * 1. the stored mesh policy schedulingStrategy raw 4-union, then
1148
- * 2. 'first_eligible' (strict no-change default).
1149
- * Policy is machine-local only there is no repo-file (`.adhdev/mesh.json`)
1150
- * overlay. Only governs the final tie-break; eligibility, capacity, and priority
1151
- * gates apply identically to every strategy.
1155
+ * 1. an EXPLICITLY stored schedulingStrategy (operator picked a mode) wins, else
1156
+ * 2. 'fitness' AUTO when the mesh has any node with explicit capability slots —
1157
+ * configuring slots is itself the signal to route tasks by task→slot fitness
1158
+ * (difficulty/capability), no separate strategy toggle required, else
1159
+ * 3. 'first_eligible' (strict no-change default for slot-less meshes).
1160
+ *
1161
+ * Persistence economy drops schedulingStrategy from policy when it equals the
1162
+ * first_eligible default (repo-mesh-types normalizeMeshPolicy), so an ABSENT value
1163
+ * means "unset" — safe to auto-upgrade — while a PRESENT value means the operator
1164
+ * chose it and we never override. Policy is machine-local only; this only governs
1165
+ * the final tie-break — eligibility, capacity, and priority gates are unchanged.
1152
1166
  */
1153
1167
  function resolveSchedulingStrategy(mesh: any): RepoMeshSchedulingStrategy {
1154
- return normalizeMeshSchedulingStrategy(mesh?.policy?.schedulingStrategy);
1168
+ const raw = mesh?.policy?.schedulingStrategy;
1169
+ if (typeof raw === 'string' && raw.trim()) {
1170
+ return normalizeMeshSchedulingStrategy(raw);
1171
+ }
1172
+ // Unset → auto-fitness when slots exist, else the historical default.
1173
+ return meshHasExplicitSlots(mesh) ? 'fitness' : normalizeMeshSchedulingStrategy(undefined);
1155
1174
  }
1156
1175
 
1157
1176
  /**
@@ -1193,7 +1212,7 @@ export function __orderEligibleNodesForTests(
1193
1212
  meshId: string,
1194
1213
  strategy: RepoMeshSchedulingStrategy,
1195
1214
  nodes: RankableNode[],
1196
- opts?: { bumpCursor?: boolean },
1215
+ opts?: { bumpCursor?: boolean; task?: { difficulty?: string; requiredTags?: string[] } },
1197
1216
  ): RankableNode[] {
1198
1217
  return orderEligibleNodes(meshId, strategy, nodes, opts);
1199
1218
  }
@@ -1249,16 +1268,107 @@ export function __buildSchedulingPoolForTests(
1249
1268
  return buildSchedulingPool(localCandidates, remoteCandidates);
1250
1269
  }
1251
1270
 
1271
+ // ─────────────────────────────────────────────────────────────────────────────
1272
+ // Node capability slots — task→node/slot fitness (ORCHESTRATION_NODE_SLOTS.md)
1273
+ //
1274
+ // A node's capability slots are the single source of truth for routing. When a
1275
+ // node has explicit `policy.slots` we use them; otherwise we derive slots from the
1276
+ // legacy providerPriority/providerRoles + the machine-global difficultyBrains so
1277
+ // existing nodes keep working (back-compat). The fitness scorer ranks a node for a
1278
+ // specific task by how well its best slot matches the task's difficulty and
1279
+ // required tags — with graceful fallback so a task is never blocked by a missing
1280
+ // exact match.
1281
+ // ─────────────────────────────────────────────────────────────────────────────
1282
+
1283
+ /** The task shape the fitness scorer reads (a subset of MeshWorkQueueEntry). */
1284
+ interface FitnessTask {
1285
+ difficulty?: string;
1286
+ requiredTags?: string[];
1287
+ }
1288
+
1289
+ /** Resolve a node's capability slots: explicit policy.slots, else derived from legacy. */
1290
+ function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[] {
1291
+ const explicit = normalizeNodeCapabilitySlots(node?.policy?.slots);
1292
+ if (explicit.length) return explicit;
1293
+ let difficultyBrains: any;
1294
+ try { difficultyBrains = getDifficultyBrains(); } catch { difficultyBrains = undefined; }
1295
+ return deriveSlotsFromLegacy({
1296
+ providerPriority: normalizeProviderPriority(node?.policy),
1297
+ providerRoles: Array.isArray(node?.policy?.providerRoles) ? node.policy.providerRoles : undefined,
1298
+ difficultyBrains,
1299
+ });
1300
+ }
1301
+
1302
+ /**
1303
+ * Score how well one slot fits a task. Higher = better. A slot whose difficulty
1304
+ * range contains the task's difficulty scores highest; a general-purpose slot
1305
+ * (no declared difficulty) is a valid fallback; a slot whose capability tags cover
1306
+ * the task's requiredTags gets a capability bonus. Never negative — the worst a
1307
+ * slot does is score 0 (still selectable as a last-resort fallback).
1308
+ */
1309
+ function scoreSlotForTask(slot: NodeCapabilitySlot, task: FitnessTask): number {
1310
+ let score = 1; // base: any slot can run the task (fallback floor)
1311
+ const diff = isMeshTaskDifficulty(task.difficulty) ? task.difficulty as MeshTaskDifficulty : undefined;
1312
+ if (diff) {
1313
+ if (slot.difficulty?.length) {
1314
+ score += slot.difficulty.includes(diff) ? 100 : 0; // exact difficulty match dominates
1315
+ } else {
1316
+ score += 20; // general-purpose slot: decent fallback for any difficulty
1317
+ }
1318
+ }
1319
+ const req = task.requiredTags?.filter(t => !!t) ?? [];
1320
+ if (req.length) {
1321
+ const cap = new Set(slot.capability ?? []);
1322
+ const covered = req.every(t => cap.has(t));
1323
+ score += covered ? 30 : 0; // capability coverage bonus (hard filter is applied elsewhere)
1324
+ }
1325
+ return score;
1326
+ }
1327
+
1328
+ /** Best (slot, score) for a task on a node, or null when the node has no slots. */
1329
+ function bestSlotForTask(node: any, task: FitnessTask): { slot: NodeCapabilitySlot; score: number } | null {
1330
+ const slots = resolveNodeCapabilitySlots(node);
1331
+ if (!slots.length) return null;
1332
+ let best: { slot: NodeCapabilitySlot; score: number } | null = null;
1333
+ for (const slot of slots) {
1334
+ const score = scoreSlotForTask(slot, task);
1335
+ if (!best || score > best.score) best = { slot, score };
1336
+ }
1337
+ return best;
1338
+ }
1339
+
1340
+ /** Node-level fitness for a task = its best slot's score (0 when the node has no slots). */
1341
+ function nodeFitnessForTask(node: any, task: FitnessTask): number {
1342
+ return bestSlotForTask(node, task)?.score ?? 0;
1343
+ }
1344
+
1252
1345
  function orderEligibleNodes(
1253
1346
  meshId: string,
1254
1347
  strategy: RepoMeshSchedulingStrategy,
1255
1348
  nodes: RankableNode[],
1256
- opts?: { bumpCursor?: boolean },
1349
+ opts?: { bumpCursor?: boolean; task?: FitnessTask },
1257
1350
  ): RankableNode[] {
1258
1351
  if (strategy === 'first_eligible' || nodes.length <= 1) {
1259
1352
  return nodes;
1260
1353
  }
1261
1354
 
1355
+ // Fitness strategy: rank by task→slot fit first (when a task is in scope —
1356
+ // auto-launch drains per-task), then fall through to priority/load/rotation for
1357
+ // ties. Without a task (idle-session drain ranks task-independently) fitness is
1358
+ // inert and this behaves like least_loaded.
1359
+ if (strategy === 'fitness' && opts?.task) {
1360
+ const task = opts.task;
1361
+ return [...nodes].sort((a, b) => {
1362
+ const fitDelta = nodeFitnessForTask(b.node, task) - nodeFitnessForTask(a.node, task);
1363
+ if (fitDelta !== 0) return fitDelta; // higher fitness first
1364
+ const prioDelta = resolveNodeSchedulingPriority(b.node?.policy) - resolveNodeSchedulingPriority(a.node?.policy);
1365
+ if (prioDelta !== 0) return prioDelta;
1366
+ const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
1367
+ if (loadDelta !== 0) return loadDelta;
1368
+ return a.index - b.index;
1369
+ });
1370
+ }
1371
+
1262
1372
  const priorityOf = (n: { node: any }) => resolveNodeSchedulingPriority(n.node?.policy);
1263
1373
 
1264
1374
  // Round-robin rotation offset: rotate the deterministic input order by a
@@ -1542,19 +1652,29 @@ async function resolveUsableProvider(
1542
1652
  nodeId: string,
1543
1653
  node: any,
1544
1654
  requiredTags?: string[],
1545
- ): Promise<{ providerType?: string; reason?: string }> {
1546
- const providerPriority = normalizeProviderPriority(node?.policy);
1547
- if (!providerPriority.length) return { reason: 'missing_provider_priority' };
1655
+ task?: FitnessTask,
1656
+ ): Promise<{ providerType?: string; model?: string; thinkingLevel?: string; reason?: string }> {
1548
1657
  const providerLoader = components.providerLoader;
1549
1658
  if (!providerLoader) return { reason: 'provider_loader_unavailable' };
1550
1659
 
1660
+ // Slot-based order (ORCHESTRATION_NODE_SLOTS.md): rank the node's capability
1661
+ // slots by task→slot fitness (difficulty/requiredTags) so the best-fit slot's
1662
+ // provider is tried first, and its model/thinkingLevel ride along. Falls back
1663
+ // to the legacy providerPriority-derived slots when no explicit slots exist.
1664
+ const slots = resolveNodeCapabilitySlots(node);
1665
+ if (!slots.length) return { reason: 'missing_provider_priority' };
1666
+ const orderedSlots = task
1667
+ ? [...slots].sort((a, b) => scoreSlotForTask(b, task) - scoreSlotForTask(a, task))
1668
+ : slots;
1669
+
1551
1670
  const failed: string[] = [];
1552
- for (const requestedType of providerPriority) {
1671
+ for (const slot of orderedSlots) {
1672
+ const requestedType = slot.provider;
1553
1673
  const normalizedType = typeof providerLoader.resolveAlias === 'function'
1554
1674
  ? providerLoader.resolveAlias(requestedType)
1555
1675
  : requestedType;
1556
1676
  // Skip providers that can't satisfy the task's requiredTags (e.g. provider=hermes-cli
1557
- // means only hermes-cli qualifies, not any other type in providerPriority).
1677
+ // means only hermes-cli qualifies, not any other slot's provider).
1558
1678
  if (requiredTags?.length && !nodeSatisfiesRequiredTags(requiredTags, buildMeshNodeCapabilityTags(node, normalizedType))) {
1559
1679
  failed.push(`${requestedType}: required_tags_mismatch`);
1560
1680
  continue;
@@ -1578,7 +1698,13 @@ async function resolveUsableProvider(
1578
1698
  }], false);
1579
1699
  }
1580
1700
  (components as any).onStatusChange?.();
1581
- if (detected) return { providerType: normalizedType };
1701
+ if (detected) {
1702
+ return {
1703
+ providerType: normalizedType,
1704
+ ...(slot.model ? { model: slot.model } : {}),
1705
+ ...(slot.thinkingLevel ? { thinkingLevel: slot.thinkingLevel } : {}),
1706
+ };
1707
+ }
1582
1708
  failed.push(`${requestedType}: not detected`);
1583
1709
  }
1584
1710
  return { reason: `provider_priority_unusable: ${failed.join('; ') || nodeId}` };
@@ -1850,7 +1976,9 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1850
1976
  candidateNodes
1851
1977
  .map((node: any, index: number) => ({ nodeId: readMeshNodeId(node), node, index }))
1852
1978
  .filter((c: RankableNode) => c.nodeId),
1853
- { bumpCursor: true },
1979
+ // Auto-launch drains one task at a time, so the task IS in scope here —
1980
+ // pass it through for the 'fitness' strategy's task→slot ranking.
1981
+ { bumpCursor: true, task: { difficulty: (task as any).difficulty, requiredTags: task.requiredTags } },
1854
1982
  ).map((c: RankableNode) => c.node);
1855
1983
 
1856
1984
  for (const node of orderedCandidateNodes) {
@@ -1914,11 +2042,16 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1914
2042
 
1915
2043
  autoLaunchInProgress.add(launchKey);
1916
2044
  try {
1917
- const resolved = await resolveUsableProvider(components, nodeId, node, task.requiredTags);
2045
+ const resolved = await resolveUsableProvider(components, nodeId, node, task.requiredTags, { difficulty: (task as any).difficulty, requiredTags: task.requiredTags });
1918
2046
  if (!resolved.providerType) {
1919
2047
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: resolved.reason || 'provider_unusable', nodeId });
1920
2048
  continue;
1921
2049
  }
2050
+ // Slot-derived model/thinking: an explicit task.model/thinkingLevel
2051
+ // (resolved from the enqueue-time brain) still wins; the matched
2052
+ // slot fills only what the task left blank (ORCHESTRATION_NODE_SLOTS.md).
2053
+ const effectiveModel = (typeof task.model === 'string' && task.model.trim()) ? task.model.trim() : resolved.model;
2054
+ const effectiveThinkingLevel = (typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim()) ? task.thinkingLevel.trim() : resolved.thinkingLevel;
1922
2055
 
1923
2056
  // Don't spawn a session for a (node, provider) already at its declared
1924
2057
  // maxParallel cap — it would launch only to fail the claim. The claim
@@ -1967,9 +2100,10 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1967
2100
  settings: remoteSettings,
1968
2101
  // MAGI-KIND-PANEL model axis: forward the task's model override so the
1969
2102
  // remote worker session launches with it (initialModel). Best-effort.
1970
- ...(typeof task.model === 'string' && task.model.trim() ? { initialModel: task.model.trim() } : {}),
1971
- // BRAIN-ROUTING thinking axis: forward the task's thinking level (initialThinkingLevel).
1972
- ...(typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim() ? { initialThinkingLevel: task.thinkingLevel.trim() } : {}),
2103
+ // Slot-aware: task override wins, else the matched slot's model.
2104
+ ...(effectiveModel ? { initialModel: effectiveModel } : {}),
2105
+ // BRAIN-ROUTING thinking axis: forward the effective thinking level (initialThinkingLevel).
2106
+ ...(effectiveThinkingLevel ? { initialThinkingLevel: effectiveThinkingLevel } : {}),
1973
2107
  });
1974
2108
  } catch (e: any) {
1975
2109
  markAutoLaunch(meshId, task.id, { status: 'failed', reason: `remote_launch_dispatch_failed: ${e?.message || String(e)}`, nodeId, providerType: resolved.providerType });
@@ -1999,11 +2133,11 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1999
2133
  cliType: resolved.providerType,
2000
2134
  dir: node.workspace,
2001
2135
  settings: launchSettings,
2002
- // MAGI-KIND-PANEL model axis: local launch forwards the task's model
2003
- // override as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
2004
- ...(typeof task.model === 'string' && task.model.trim() ? { initialModel: task.model.trim() } : {}),
2005
- // BRAIN-ROUTING thinking axis: forward the task's thinking level (initialThinkingLevel).
2006
- ...(typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim() ? { initialThinkingLevel: task.thinkingLevel.trim() } : {}),
2136
+ // MAGI-KIND-PANEL model axis: local launch forwards the effective model
2137
+ // (task override, else matched slot) as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
2138
+ ...(effectiveModel ? { initialModel: effectiveModel } : {}),
2139
+ // BRAIN-ROUTING thinking axis: forward the effective thinking level (initialThinkingLevel).
2140
+ ...(effectiveThinkingLevel ? { initialThinkingLevel: effectiveThinkingLevel } : {}),
2007
2141
  });
2008
2142
  if (!launchResult?.success) {
2009
2143
  const reason = launchResult?.error || 'launch_cli_failed';
@@ -2211,7 +2345,10 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
2211
2345
  const aPrio = resolveNodeSchedulingPriority(a.node?.policy);
2212
2346
  const bPrio = resolveNodeSchedulingPriority(b.node?.policy);
2213
2347
  if (aPrio !== bPrio) return bPrio - aPrio;
2214
- if (strategy === 'least_loaded' || strategy === 'round_robin') {
2348
+ // The idle-session drain ranks task-independently (a session pulls
2349
+ // whatever task matches), so 'fitness' here reduces to load-aware
2350
+ // ordering — the same tiebreak as least_loaded/round_robin.
2351
+ if (strategy === 'least_loaded' || strategy === 'round_robin' || strategy === 'fitness') {
2215
2352
  const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
2216
2353
  if (loadDelta !== 0) return loadDelta;
2217
2354
  }
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { getGitRepoStatus } from '../git/git-status.js';
16
+ import type { ChangedPackageClassification } from '../git/git-status.js';
16
17
  import * as yaml from 'js-yaml';
17
18
  import { loadMeshRefineConfig, resolveMeshRefineValidationPlan } from '../mesh/refine-config.js';
18
19
  import type { MeshRefineValidationCommandPlan } from '../mesh/refine-config.js';
@@ -74,6 +75,19 @@ type MeshRefineValidationSummary = {
74
75
  };
75
76
  /** M2-2: deprecation notices from the refine config (e.g. bootstrapCommands). */
76
77
  deprecationWarnings?: string[];
78
+ /**
79
+ * Coarse daemon-vs-web change-impact used to scope the validation command set.
80
+ * When `isDaemonAffecting === false`, daemon-scoped commands are recorded in
81
+ * `commandsRun` with `skipped: true, skipReason: 'unaffected_daemon_scope'`
82
+ * rather than executed; web + typecheck commands always run. Absent when no
83
+ * change-impact was threaded in (legacy: full command set runs).
84
+ */
85
+ changeImpact?: {
86
+ isDaemonAffecting: boolean;
87
+ affectedPackages: string[];
88
+ /** displayCommands skipped because the daemon scope is unaffected. */
89
+ skippedDaemonCommands?: string[];
90
+ };
77
91
  };
78
92
 
79
93
  type MeshRefineStageStatus = 'passed' | 'failed' | 'skipped';
@@ -343,6 +357,13 @@ export interface RefineContext {
343
357
  baseBranch: string;
344
358
  baseHead: string;
345
359
  branchHead: string;
360
+ /**
361
+ * Coarse daemon-vs-web change-impact for baseHead..branchHead, resolved in the
362
+ * resolve_refs stage and threaded into the validation gate to scope its command
363
+ * set. `undefined` means "could not classify" → the gate fails open and runs ALL
364
+ * commands (never skip on uncertainty).
365
+ */
366
+ changeImpact?: ChangedPackageClassification;
346
367
  validationSummary: Awaited<ReturnType<typeof runMeshRefineValidationGate>>;
347
368
  patchEquivalence: Awaited<ReturnType<typeof runMeshRefinePatchEquivalenceGate>>;
348
369
  submoduleReachability: Awaited<ReturnType<typeof runMeshRefineSubmoduleReachabilityGate>>;
@@ -1477,6 +1498,14 @@ export async function runMeshRefineValidationGate(
1477
1498
  persistedBootstrapState?: WorktreeBootstrapState | null;
1478
1499
  /** M2-2: called after an inherit-mode bootstrap run so the caller can persist the new state. */
1479
1500
  onBootstrapStateChange?: (state: WorktreeBootstrapState) => void;
1501
+ /**
1502
+ * Coarse daemon-vs-web change-impact for the branch (resolve_refs computes it
1503
+ * over baseHead..branchHead). When provided and `isDaemonAffecting === false`,
1504
+ * daemon-scoped validation commands are skipped (web + typecheck still run).
1505
+ * When omitted or `isDaemonAffecting === true`, the full command set runs —
1506
+ * fail-open to full validation on any uncertainty.
1507
+ */
1508
+ changeImpact?: ChangedPackageClassification;
1480
1509
  },
1481
1510
  ): Promise<MeshRefineValidationSummary> {
1482
1511
  const { execFile } = await import('node:child_process');
@@ -1574,6 +1603,63 @@ export async function runMeshRefineValidationGate(
1574
1603
  return ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lockb', 'bun.lock']
1575
1604
  .some(lock => fs.existsSync(pathJoin(cwd, lock)));
1576
1605
  };
1606
+ // A validation command needs installed node_modules to run. Only these can hit
1607
+ // the missing-deps hard-block; non-package-manager commands (e.g. a plain
1608
+ // `node scripts/check-vendor-drift.mjs`) need no deps and must never be aborted
1609
+ // by a preceding command's missing-deps.
1610
+ const needsNodeModules = (candidate: MeshRefineValidationCommand, cwd: string): boolean =>
1611
+ isPackageManagerValidation(candidate) && dependenciesLikelyMissing(cwd);
1612
+
1613
+ // (a) Coarse change-impact scoping. When the branch is web-only
1614
+ // (changeImpact.isDaemonAffecting === false), daemon-scoped validation commands
1615
+ // are pointless — and often un-runnable in a web-only worktree that never
1616
+ // bootstrapped daemon deps. Identify daemon-scoped commands ONLY by the coarse
1617
+ // daemon-vs-web bucket: a command whose script/args reference a daemon package
1618
+ // (daemon-core / daemon-cloud) or the vendor-drift check. web-side commands
1619
+ // (test:web-core / test:web-cloud) and `typecheck` ALWAYS run — the daemon/web
1620
+ // boundary is the human-curated safe line; we deliberately do NOT do fine
1621
+ // per-package skipping (web-cloud consumes web-core, so it must still run).
1622
+ const isDaemonScopedCommand = (candidate: MeshRefineValidationCommand): boolean => {
1623
+ const haystack = [candidate.command, ...(candidate.args || []), candidate.displayCommand || '']
1624
+ .join(' ')
1625
+ .toLowerCase();
1626
+ // Never treat a typecheck or an explicit web-side command as daemon-scoped.
1627
+ if (candidate.category === 'typecheck') return false;
1628
+ if (/\btypecheck\b/.test(haystack)) return false;
1629
+ if (/\bweb-core\b|\bweb-cloud\b|\bweb-standalone\b|\btest:web\b/.test(haystack)) return false;
1630
+ // Daemon-scoped signals: a daemon package name, a daemon test script, or the
1631
+ // vendor-drift check (which validates the daemon vendor bundle).
1632
+ return /\bdaemon-core\b|\bdaemon-cloud\b|\btest:daemon\b|check-vendor-drift/.test(haystack);
1633
+ };
1634
+
1635
+ const scopeUnaffectedDaemon = opts?.changeImpact?.isDaemonAffecting === false;
1636
+ const skippedDaemonCommands: string[] = [];
1637
+ const commandsToRun: MeshRefineValidationCommand[] = [];
1638
+ for (const candidate of selection.commands) {
1639
+ if (scopeUnaffectedDaemon && isDaemonScopedCommand(candidate)) {
1640
+ skippedDaemonCommands.push(candidate.displayCommand);
1641
+ // Record the skip so it's visible in the summary, never silently dropped.
1642
+ summary.commandsRun.push({
1643
+ command: candidate.command,
1644
+ args: candidate.args,
1645
+ displayCommand: candidate.displayCommand,
1646
+ category: candidate.category,
1647
+ source: candidate.source,
1648
+ passed: true,
1649
+ skipped: true,
1650
+ skipReason: 'unaffected_daemon_scope',
1651
+ });
1652
+ continue;
1653
+ }
1654
+ commandsToRun.push(candidate);
1655
+ }
1656
+ if (opts?.changeImpact) {
1657
+ summary.changeImpact = {
1658
+ isDaemonAffecting: opts.changeImpact.isDaemonAffecting,
1659
+ affectedPackages: opts.changeImpact.affectedPackages,
1660
+ ...(skippedDaemonCommands.length ? { skippedDaemonCommands } : {}),
1661
+ };
1662
+ }
1577
1663
 
1578
1664
  if (runLegacyBootstrapCommands) {
1579
1665
  summary.bootstrap = { stage: 'legacy' };
@@ -1619,23 +1705,31 @@ export async function runMeshRefineValidationGate(
1619
1705
  }
1620
1706
  }
1621
1707
 
1622
- for (const candidate of selection.commands) {
1708
+ // (b) Track a genuine missing-deps block for an AFFECTED command. Instead of
1709
+ // aborting the whole gate at the first missing-deps hit (which also killed
1710
+ // trailing no-dep commands like check-vendor-drift.mjs), we mark the blocked
1711
+ // command and CONTINUE evaluating the rest: commands whose deps are present, or
1712
+ // which need no deps at all, still run. missing_dependencies only becomes the
1713
+ // gate failure if at least one command that truly needed deps could not run.
1714
+ let missingDepsBlocked = false;
1715
+ for (const candidate of commandsToRun) {
1623
1716
  const startedAt = Date.now();
1624
1717
  const cwd = candidate.cwd ? pathResolve(workspace, candidate.cwd) : workspace;
1625
1718
  const timeout = candidate.timeoutMs || REFINE_VALIDATION_TIMEOUT_MS;
1626
1719
  const bootstrapProvidedDependencies = summary.bootstrap?.stage === 'cached' || summary.bootstrap?.stage === 'ran' || summary.bootstrap?.stage === 'legacy';
1627
- if (!bootstrapProvidedDependencies && isPackageManagerValidation(candidate) && dependenciesLikelyMissing(cwd)) {
1720
+ if (!bootstrapProvidedDependencies && needsNodeModules(candidate, cwd)) {
1721
+ // This command genuinely needs node_modules that are absent. Mark it
1722
+ // blocked, but do NOT abort — a following no-dep command (or one in a
1723
+ // different cwd that DOES have deps) must still get its chance to run.
1628
1724
  summary.commandsRun.push(commandRecord(candidate, cwd, startedAt, {
1629
- stderr: 'Dependencies appear to be missing: package.json and a lockfile are present, but node_modules is absent. Configure validation.bootstrapCommands in repo mesh/refine config if Refinery should install/bootstrap before validation.',
1725
+ stderr: 'Dependencies appear to be missing: package.json and a lockfile are present, but node_modules is absent. Configure validation.bootstrapCommands (or .adhdev/worktree_bootstrap.json) in repo mesh/refine config if Refinery should install/bootstrap before validation.',
1630
1726
  }, false, {
1631
1727
  exitCode: null,
1632
1728
  skipped: true,
1633
1729
  failureKind: 'missing_dependencies',
1634
1730
  }));
1635
- summary.status = 'failed';
1636
- summary.failureKind = 'missing_dependencies';
1637
- summary.failureCode = 'missing_dependencies';
1638
- return summary;
1731
+ missingDepsBlocked = true;
1732
+ continue;
1639
1733
  }
1640
1734
  // See the bootstrap loop above: resolve the win32 .cmd shim to an
1641
1735
  // absolute path before handing it to the spawn boundary.
@@ -1681,6 +1775,18 @@ export async function runMeshRefineValidationGate(
1681
1775
  }
1682
1776
  }
1683
1777
 
1778
+ // (b) A command that genuinely needed deps could not run. Surface it as the
1779
+ // gate failure now (after letting no-dep / deps-present commands run), so the
1780
+ // caller can classify it blocked_review and emit a self-service hint. Every
1781
+ // daemon-scoped command in a web-only branch was already filtered above, so a
1782
+ // missing-deps block here is a real affected-command block.
1783
+ if (missingDepsBlocked) {
1784
+ summary.status = 'failed';
1785
+ summary.failureKind = 'missing_dependencies';
1786
+ summary.failureCode = 'missing_dependencies';
1787
+ return summary;
1788
+ }
1789
+
1684
1790
  summary.status = 'passed';
1685
1791
  return summary;
1686
1792
  }
@@ -591,6 +591,15 @@ export interface MeshWorkQueueEntry {
591
591
  * setConfigOption('thought_level')). Rides in payload JSON. Best-effort like model.
592
592
  */
593
593
  thinkingLevel?: string;
594
+ /**
595
+ * SLOT-ROUTING (ORCHESTRATION_NODE_SLOTS.md): the coordinator's difficulty
596
+ * classification for this task ('easy'|'medium'|'difficult'|'freeform'),
597
+ * PERSISTED on the entry so the scheduler can match it against node capability
598
+ * slots at assignment time. Previously an enqueue-only option consumed to
599
+ * resolve model/thinkingLevel and then discarded; keeping it lets task→node
600
+ * fitness matching run. Absent on tasks enqueued without a difficulty.
601
+ */
602
+ difficulty?: string;
594
603
  /**
595
604
  * M1: why this task is held back (e.g. "dependency_failed:<taskId>").
596
605
  * Only set by the system on dependency failure under the 'block' policy;
@@ -904,6 +913,10 @@ export function enqueueTask(
904
913
  // an unconfigured preset just leaves the explicit values (or none) in place.
905
914
  let effectiveModel = typeof opts?.model === 'string' && opts.model.trim() ? opts.model.trim() : undefined;
906
915
  let effectiveThinkingLevel = typeof opts?.thinkingLevel === 'string' && opts.thinkingLevel.trim() ? opts.thinkingLevel.trim() : undefined;
916
+ // SLOT-ROUTING: persist the difficulty class on the entry so the scheduler can
917
+ // match it against node capability slots at assignment time (not just resolve
918
+ // model/thinking here). Absent/invalid → undefined (task carries no difficulty).
919
+ const taskDifficulty = isMeshTaskDifficulty(opts?.difficulty) ? (opts!.difficulty as MeshTaskDifficulty) : undefined;
907
920
  if (isMeshTaskDifficulty(opts?.difficulty)) {
908
921
  try {
909
922
  const preset = getDifficultyBrains()[opts!.difficulty as MeshTaskDifficulty];
@@ -951,6 +964,7 @@ export function enqueueTask(
951
964
  ...(typeof opts?.consensusGroupId === 'string' && opts.consensusGroupId.trim() ? { consensusGroupId: opts.consensusGroupId.trim() } : {}),
952
965
  ...(effectiveModel ? { model: effectiveModel } : {}),
953
966
  ...(effectiveThinkingLevel ? { thinkingLevel: effectiveThinkingLevel } : {}),
967
+ ...(taskDifficulty ? { difficulty: taskDifficulty } : {}),
954
968
  ...(typeof opts?.sourceCoordinatorSessionId === 'string' && opts.sourceCoordinatorSessionId.trim()
955
969
  ? { sourceCoordinatorSessionId: opts.sourceCoordinatorSessionId.trim() }
956
970
  : {}),
@@ -14,7 +14,7 @@
14
14
  import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
15
15
  import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
16
16
  import type { MeshMagiActivitySummary } from './mesh/mesh-magi-status.js';
17
- import type { MagiKindPanelMap, DifficultyBrainMap } from '@adhdev/mesh-shared';
17
+ import type { MagiKindPanelMap, DifficultyBrainMap, NodeCapabilitySlot } from '@adhdev/mesh-shared';
18
18
 
19
19
  // ─── Core Mesh Types ────────────────────────────
20
20
 
@@ -139,13 +139,18 @@ export type RepoMeshSchedulingStrategy =
139
139
  | 'first_eligible'
140
140
  | 'least_loaded'
141
141
  | 'round_robin'
142
- | 'priority_only';
142
+ | 'priority_only'
143
+ // ORCHESTRATION_NODE_SLOTS.md: rank nodes by task→capability-slot fitness
144
+ // (task difficulty/requiredTags vs the node's slots), then priority/load/order.
145
+ // Falls back to load ordering when no task is in scope (idle-session drain).
146
+ | 'fitness';
143
147
 
144
148
  export const MESH_SCHEDULING_STRATEGIES: RepoMeshSchedulingStrategy[] = [
145
149
  'first_eligible',
146
150
  'least_loaded',
147
151
  'round_robin',
148
152
  'priority_only',
153
+ 'fitness',
149
154
  ];
150
155
 
151
156
  export const DEFAULT_MESH_SCHEDULING_STRATEGY: RepoMeshSchedulingStrategy = 'first_eligible';
@@ -403,8 +408,22 @@ export interface RepoMeshNodePolicy {
403
408
  * enforced as an additional, stricter-wins constraint on top of the global
404
409
  * maxParallelTasks/taskMode caps. Missing/empty: the node behaves exactly as
405
410
  * before (global caps only). Routing is governed solely by required_tags.
411
+ *
412
+ * SUPERSEDED by `slots` (ORCHESTRATION_NODE_SLOTS.md). Kept for back-compat:
413
+ * when `slots` is absent, providerRoles + providerPriority + the machine-global
414
+ * difficultyBrains are auto-derived into slots via deriveSlotsFromLegacy.
406
415
  */
407
416
  providerRoles?: RepoMeshProviderRole[];
417
+ /**
418
+ * Node capability slots (ORCHESTRATION_NODE_SLOTS.md) — the ordered "Preferred
419
+ * AI tools" profile that is the single source of truth for task routing, MAGI
420
+ * fan-out, and orchestrator-proposed edits. Each slot bundles provider + model
421
+ * + thinkingLevel + difficulty range + capability tags + per-slot maxParallel.
422
+ * Order = preference. When absent, the scheduler derives slots from the legacy
423
+ * providerPriority/providerRoles/difficultyBrains (deriveSlotsFromLegacy) so
424
+ * existing nodes keep working without reconfiguration.
425
+ */
426
+ slots?: NodeCapabilitySlot[];
408
427
  /**
409
428
  * Per-node override for RepoMeshPolicy.delegatedWorkerAutoApprove. When set, takes
410
429
  * precedence over the mesh-level policy for worker sessions launched onto this node.
@@ -440,7 +459,11 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
440
459
  allowAutoPublishSubmoduleMainCommits: false,
441
460
  requireApprovalForDestructiveGit: true,
442
461
  dirtyWorkspaceBehavior: 'warn',
443
- maxParallelTasks: 2,
462
+ // Mesh-wide task cap is effectively unlimited by default: the real concurrency
463
+ // limits live per node / per capability slot (ORCHESTRATION_NODE_SLOTS.md), so a
464
+ // global ceiling is rarely meaningful. The UI hides this control; set it via the
465
+ // API only to impose a deliberate mesh-wide cap.
466
+ maxParallelTasks: 200,
444
467
  // Coordinator-spawned worker sessions default to hidden so the dashboard is not
445
468
  // flooded with mesh noise tabs/notifications. Users can still surface or unmute
446
469
  // any specific session manually; that override is preserved per-device.
@@ -1143,6 +1166,8 @@ export interface RepoMeshNodeStatus {
1143
1166
  activeSessions: string[];
1144
1167
  activeSessionDetails?: RepoMeshSessionStatus[];
1145
1168
  providerPriority?: string[];
1169
+ /** Explicitly-configured node capability slots (ORCHESTRATION_NODE_SLOTS.md). */
1170
+ slots?: NodeCapabilitySlot[];
1146
1171
  launchReady?: boolean;
1147
1172
  /** True when the node is clean, ahead=0, behind>0, and safe for fast-forward consideration. */
1148
1173
  autoFastForwardEligible?: boolean;
@@ -416,6 +416,13 @@ export interface SessionEntry {
416
416
  completionMarker?: string;
417
417
  seenCompletionMarker?: string;
418
418
  surfaceHidden?: boolean;
419
+ /**
420
+ * User (or coordinator-policy) muted: suppress attention side-effects
421
+ * (notifications, toasts, completion audio) for this session WITHOUT removing
422
+ * it from the inbox list. Distinct from surfaceHidden (which collapses it from
423
+ * the list). Daemon-owned, in-memory; rides the status snapshot.
424
+ */
425
+ muted?: boolean;
419
426
  settings?: Record<string, any>;
420
427
  /**
421
428
  * True owning-daemon id for a session a coordinator synthesises into its own
@@ -488,6 +495,7 @@ export interface CompactSessionEntry {
488
495
  completionMarker?: string;
489
496
  seenCompletionMarker?: string;
490
497
  surfaceHidden?: boolean;
498
+ muted?: boolean;
491
499
  controlValues?: Record<string, string | number | boolean>;
492
500
  providerControls?: ProviderControlSchema[];
493
501
  summaryMetadata?: ProviderSummaryMetadata;