@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.
@@ -51,6 +51,10 @@ export declare function __resolveSchedulingStrategyForTests(mesh: any): RepoMesh
51
51
  * scheduling pipeline can be unit-tested without standing up live CLI sessions. */
52
52
  export declare function __orderEligibleNodesForTests(meshId: string, strategy: RepoMeshSchedulingStrategy, nodes: RankableNode[], opts?: {
53
53
  bumpCursor?: boolean;
54
+ task?: {
55
+ difficulty?: string;
56
+ requiredTags?: string[];
57
+ };
54
58
  }): RankableNode[];
55
59
  /** One idle session eligible to claim a queued task, together with the resolved
56
60
  * mesh node record it belongs to. Local candidates come from live CLI instances,
@@ -11,6 +11,7 @@
11
11
  * keep working. `CommandRouterResult` is imported type-only from router.ts
12
12
  * (erased at compile time — no runtime import cycle).
13
13
  */
14
+ import type { ChangedPackageClassification } from '../git/git-status.js';
14
15
  import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
15
16
  import type { CommandRouterResult } from '../commands/router.js';
16
17
  export type MeshCoordinatorConfigFormat = 'claude_mcp_json' | 'hermes_config_yaml';
@@ -53,6 +54,19 @@ type MeshRefineValidationSummary = {
53
54
  };
54
55
  /** M2-2: deprecation notices from the refine config (e.g. bootstrapCommands). */
55
56
  deprecationWarnings?: string[];
57
+ /**
58
+ * Coarse daemon-vs-web change-impact used to scope the validation command set.
59
+ * When `isDaemonAffecting === false`, daemon-scoped commands are recorded in
60
+ * `commandsRun` with `skipped: true, skipReason: 'unaffected_daemon_scope'`
61
+ * rather than executed; web + typecheck commands always run. Absent when no
62
+ * change-impact was threaded in (legacy: full command set runs).
63
+ */
64
+ changeImpact?: {
65
+ isDaemonAffecting: boolean;
66
+ affectedPackages: string[];
67
+ /** displayCommands skipped because the daemon scope is unaffected. */
68
+ skippedDaemonCommands?: string[];
69
+ };
56
70
  };
57
71
  type MeshRefineStageStatus = 'passed' | 'failed' | 'skipped';
58
72
  type MeshRefinePatchEquivalenceSummary = {
@@ -277,6 +291,13 @@ export interface RefineContext {
277
291
  baseBranch: string;
278
292
  baseHead: string;
279
293
  branchHead: string;
294
+ /**
295
+ * Coarse daemon-vs-web change-impact for baseHead..branchHead, resolved in the
296
+ * resolve_refs stage and threaded into the validation gate to scope its command
297
+ * set. `undefined` means "could not classify" → the gate fails open and runs ALL
298
+ * commands (never skip on uncertainty).
299
+ */
300
+ changeImpact?: ChangedPackageClassification;
280
301
  validationSummary: Awaited<ReturnType<typeof runMeshRefineValidationGate>>;
281
302
  patchEquivalence: Awaited<ReturnType<typeof runMeshRefinePatchEquivalenceGate>>;
282
303
  submoduleReachability: Awaited<ReturnType<typeof runMeshRefineSubmoduleReachabilityGate>>;
@@ -424,5 +445,13 @@ export declare function runMeshRefineValidationGate(mesh: any, workspace: string
424
445
  persistedBootstrapState?: WorktreeBootstrapState | null;
425
446
  /** M2-2: called after an inherit-mode bootstrap run so the caller can persist the new state. */
426
447
  onBootstrapStateChange?: (state: WorktreeBootstrapState) => void;
448
+ /**
449
+ * Coarse daemon-vs-web change-impact for the branch (resolve_refs computes it
450
+ * over baseHead..branchHead). When provided and `isDaemonAffecting === false`,
451
+ * daemon-scoped validation commands are skipped (web + typecheck still run).
452
+ * When omitted or `isDaemonAffecting === true`, the full command set runs —
453
+ * fail-open to full validation on any uncertainty.
454
+ */
455
+ changeImpact?: ChangedPackageClassification;
427
456
  }): Promise<MeshRefineValidationSummary>;
428
457
  export {};
@@ -126,6 +126,15 @@ export interface MeshWorkQueueEntry {
126
126
  * setConfigOption('thought_level')). Rides in payload JSON. Best-effort like model.
127
127
  */
128
128
  thinkingLevel?: string;
129
+ /**
130
+ * SLOT-ROUTING (ORCHESTRATION_NODE_SLOTS.md): the coordinator's difficulty
131
+ * classification for this task ('easy'|'medium'|'difficult'|'freeform'),
132
+ * PERSISTED on the entry so the scheduler can match it against node capability
133
+ * slots at assignment time. Previously an enqueue-only option consumed to
134
+ * resolve model/thinkingLevel and then discarded; keeping it lets task→node
135
+ * fitness matching run. Absent on tasks enqueued without a difficulty.
136
+ */
137
+ difficulty?: string;
129
138
  /**
130
139
  * M1: why this task is held back (e.g. "dependency_failed:<taskId>").
131
140
  * Only set by the system on dependency failure under the 'block' policy;
@@ -13,7 +13,7 @@
13
13
  import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
14
14
  import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
15
15
  import type { MeshMagiActivitySummary } from './mesh/mesh-magi-status.js';
16
- import type { MagiKindPanelMap, DifficultyBrainMap } from '@adhdev/mesh-shared';
16
+ import type { MagiKindPanelMap, DifficultyBrainMap, NodeCapabilitySlot } from '@adhdev/mesh-shared';
17
17
  export interface RepoMesh {
18
18
  id: string;
19
19
  name: string;
@@ -114,7 +114,7 @@ export type RepoMeshMagiSessionCleanupMode = 'preserve' | 'stop_and_delete';
114
114
  * Distribution is explicit opt-in: a strategy other than 'first_eligible' must be
115
115
  * configured for any load-spreading to occur.
116
116
  */
117
- export type RepoMeshSchedulingStrategy = 'first_eligible' | 'least_loaded' | 'round_robin' | 'priority_only';
117
+ export type RepoMeshSchedulingStrategy = 'first_eligible' | 'least_loaded' | 'round_robin' | 'priority_only' | 'fitness';
118
118
  export declare const MESH_SCHEDULING_STRATEGIES: RepoMeshSchedulingStrategy[];
119
119
  export declare const DEFAULT_MESH_SCHEDULING_STRATEGY: RepoMeshSchedulingStrategy;
120
120
  /**
@@ -310,8 +310,22 @@ export interface RepoMeshNodePolicy {
310
310
  * enforced as an additional, stricter-wins constraint on top of the global
311
311
  * maxParallelTasks/taskMode caps. Missing/empty: the node behaves exactly as
312
312
  * before (global caps only). Routing is governed solely by required_tags.
313
+ *
314
+ * SUPERSEDED by `slots` (ORCHESTRATION_NODE_SLOTS.md). Kept for back-compat:
315
+ * when `slots` is absent, providerRoles + providerPriority + the machine-global
316
+ * difficultyBrains are auto-derived into slots via deriveSlotsFromLegacy.
313
317
  */
314
318
  providerRoles?: RepoMeshProviderRole[];
319
+ /**
320
+ * Node capability slots (ORCHESTRATION_NODE_SLOTS.md) — the ordered "Preferred
321
+ * AI tools" profile that is the single source of truth for task routing, MAGI
322
+ * fan-out, and orchestrator-proposed edits. Each slot bundles provider + model
323
+ * + thinkingLevel + difficulty range + capability tags + per-slot maxParallel.
324
+ * Order = preference. When absent, the scheduler derives slots from the legacy
325
+ * providerPriority/providerRoles/difficultyBrains (deriveSlotsFromLegacy) so
326
+ * existing nodes keep working without reconfiguration.
327
+ */
328
+ slots?: NodeCapabilitySlot[];
315
329
  /**
316
330
  * Per-node override for RepoMeshPolicy.delegatedWorkerAutoApprove. When set, takes
317
331
  * precedence over the mesh-level policy for worker sessions launched onto this node.
@@ -858,6 +872,8 @@ export interface RepoMeshNodeStatus {
858
872
  activeSessions: string[];
859
873
  activeSessionDetails?: RepoMeshSessionStatus[];
860
874
  providerPriority?: string[];
875
+ /** Explicitly-configured node capability slots (ORCHESTRATION_NODE_SLOTS.md). */
876
+ slots?: NodeCapabilitySlot[];
861
877
  launchReady?: boolean;
862
878
  /** True when the node is clean, ahead=0, behind>0, and safe for fast-forward consideration. */
863
879
  autoFastForwardEligible?: boolean;
@@ -287,6 +287,13 @@ export interface SessionEntry {
287
287
  completionMarker?: string;
288
288
  seenCompletionMarker?: string;
289
289
  surfaceHidden?: boolean;
290
+ /**
291
+ * User (or coordinator-policy) muted: suppress attention side-effects
292
+ * (notifications, toasts, completion audio) for this session WITHOUT removing
293
+ * it from the inbox list. Distinct from surfaceHidden (which collapses it from
294
+ * the list). Daemon-owned, in-memory; rides the status snapshot.
295
+ */
296
+ muted?: boolean;
290
297
  settings?: Record<string, any>;
291
298
  /**
292
299
  * True owning-daemon id for a session a coordinator synthesises into its own
@@ -361,6 +368,7 @@ export interface CompactSessionEntry {
361
368
  completionMarker?: string;
362
369
  seenCompletionMarker?: string;
363
370
  surfaceHidden?: boolean;
371
+ muted?: boolean;
364
372
  controlValues?: Record<string, string | number | boolean>;
365
373
  providerControls?: ProviderControlSchema[];
366
374
  summaryMetadata?: ProviderSummaryMetadata;
@@ -64,6 +64,7 @@ export interface RecentReadDebugSnapshot {
64
64
  messageUpdatedAt: number;
65
65
  }
66
66
  export declare function shouldEmitRecentReadDebugLog(cache: Map<string, string>, snapshot: RecentReadDebugSnapshot): boolean;
67
+ export declare function buildAvailableProviders(providerLoader: StatusSnapshotOptions['providerLoader']): AvailableProviderInfo[];
67
68
  export declare function buildMachineInfo(profile?: 'full' | 'live' | 'metadata'): MachineInfo;
68
69
  /**
69
70
  * Resolve the last user-visible (non-system) message for a session as a preview.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.485",
3
+ "version": "0.9.82-rc.487",
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.485",
51
- "@adhdev/session-host-core": "0.9.82-rc.485",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.487",
51
+ "@adhdev/session-host-core": "0.9.82-rc.487",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -1080,7 +1080,14 @@ export class DaemonCommandHandler implements CommandHelpers {
1080
1080
  if (!fs.existsSync(installRoot)) return { success: true, providers: [] };
1081
1081
 
1082
1082
  const CATEGORIES = ['cli', 'ide', 'extension', 'acp'] as const;
1083
- const items: Array<{ type: string; category: string; version: string; path: string }> = [];
1083
+ const items: Array<{
1084
+ type: string;
1085
+ category: string;
1086
+ version: string;
1087
+ path: string;
1088
+ modelOptions?: string[];
1089
+ thinkingLevelOptions?: string[];
1090
+ }> = [];
1084
1091
 
1085
1092
  for (const category of CATEGORIES) {
1086
1093
  const categoryDir = path.join(installRoot, category);
@@ -1095,11 +1102,24 @@ export class DaemonCommandHandler implements CommandHelpers {
1095
1102
  if (!manifestPath) continue;
1096
1103
  try {
1097
1104
  const m = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
1105
+ // Surface the manifest's advisory model / thinking-level lists so
1106
+ // consumers of this endpoint (standalone New-session dialog, mesh
1107
+ // node slot editor) get the same provider-specific dropdowns the
1108
+ // status-snapshot path already carries — otherwise every provider
1109
+ // (codex included) falls back to a free-text Model field.
1110
+ const modelOptions = Array.isArray(m.modelOptions)
1111
+ ? m.modelOptions.filter((x: unknown): x is string => typeof x === 'string' && !!x.trim())
1112
+ : [];
1113
+ const thinkingLevelOptions = Array.isArray(m.thinkingLevelOptions)
1114
+ ? m.thinkingLevelOptions.filter((x: unknown): x is string => typeof x === 'string' && !!x.trim())
1115
+ : [];
1098
1116
  items.push({
1099
1117
  type,
1100
1118
  category,
1101
1119
  version: typeof m.providerVersion === 'string' ? m.providerVersion : '0.0.0',
1102
1120
  path: manifestPath,
1121
+ ...(modelOptions.length ? { modelOptions } : {}),
1122
+ ...(thinkingLevelOptions.length ? { thinkingLevelOptions } : {}),
1103
1123
  });
1104
1124
  } catch {
1105
1125
  // Corrupt manifest — skip but don't fail the whole listing.
@@ -48,6 +48,18 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
48
48
  const extraSystemPrompt = typeof args?.extraSystemPrompt === 'string'
49
49
  ? args.extraSystemPrompt.trim()
50
50
  : '';
51
+ // Optional per-launch model / thinking-level override for the
52
+ // coordinator session. Passed straight through to launch_cli,
53
+ // which applies them best-effort for providers that support it
54
+ // (claude --effort, codex model_reasoning_effort). Blank / absent
55
+ // => provider default. This is a launch-time override only; it is
56
+ // NOT persisted to the mesh coordinator config.
57
+ const initialModel = typeof args?.initialModel === 'string' && args.initialModel.trim()
58
+ ? args.initialModel.trim()
59
+ : null;
60
+ const initialThinkingLevel = typeof args?.initialThinkingLevel === 'string' && args.initialThinkingLevel.trim()
61
+ ? args.initialThinkingLevel.trim()
62
+ : null;
51
63
  if (!meshId) return { success: false, error: 'meshId required' };
52
64
 
53
65
  try {
@@ -390,6 +402,8 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
390
402
  cliArgs: cliCmdArgs.length > 0 ? cliCmdArgs : undefined,
391
403
  env: Object.keys(cliCmdEnv).length > 0 ? cliCmdEnv : undefined,
392
404
  settings: { meshCoordinatorFor: meshId },
405
+ ...(initialModel ? { initialModel } : {}),
406
+ ...(initialThinkingLevel ? { initialThinkingLevel } : {}),
393
407
  });
394
408
 
395
409
  // R48 inject-then-remove. Spawn was just kicked off above; agy and
@@ -609,7 +623,9 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
609
623
  env: Object.keys(launchEnv).length > 0 ? launchEnv : undefined,
610
624
  settings: {
611
625
  meshCoordinatorFor: meshId
612
- }
626
+ },
627
+ ...(initialModel ? { initialModel } : {}),
628
+ ...(initialThinkingLevel ? { initialThinkingLevel } : {}),
613
629
  });
614
630
 
615
631
  // R48 inject-then-remove. See the cli_command branch for context;
@@ -18,6 +18,7 @@ import { getGitRepoStatus } from '../../git/git-status.js';
18
18
  import {
19
19
  normalizeMeshNodeId,
20
20
  daemonIdsEquivalent,
21
+ normalizeNodeCapabilitySlots,
21
22
  } from '@adhdev/mesh-shared';
22
23
  import {
23
24
  getPendingMeshCoordinatorEvents,
@@ -357,6 +358,13 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
357
358
  ? { daemonBuildVersion: node.reportedDaemonBuildVersion }
358
359
  : {}),
359
360
  providerPriority,
361
+ // ORCHESTRATION_NODE_SLOTS.md: surface the node's capability
362
+ // slots so the dashboard slot editor can read them. Only
363
+ // emitted when explicitly configured (derived-from-legacy
364
+ // slots stay implicit — the editor shows the legacy fields).
365
+ ...(Array.isArray((node.policy as any)?.slots) && (node.policy as any).slots.length
366
+ ? { slots: normalizeNodeCapabilitySlots((node.policy as any).slots) }
367
+ : {}),
360
368
  activeSessions: [],
361
369
  activeSessionDetails: [],
362
370
  launchReady: false,
@@ -64,6 +64,35 @@ export const cliAgentHandlers: Record<string, MedFamilyHandler> = {
64
64
  return ctx.deps.cliManager.handleCliCommand('record_provider_pty', args);
65
65
  },
66
66
 
67
+ // Daemon-owned per-session user Mute/Hide. Replaces the old browser-local
68
+ // localStorage layer: the user's manual hide/mute for a conversation is stored
69
+ // in-memory on the live session's settings (userHidden / userMuted) and rides
70
+ // the SAME status snapshot pipeline as the coordinator-policy surfaceHidden
71
+ // flag, so every client of this daemon sees the same state. In-memory only —
72
+ // resets on daemon restart (coordinator-spawned sessions re-derive their hidden
73
+ // default from mesh policy on relaunch). Passing null/undefined for a field
74
+ // leaves it unchanged; pass an explicit boolean to set, or false to clear an
75
+ // earlier hide/mute (e.g. unmute a coordinator-spawned worker overrides the
76
+ // policy default until restart).
77
+ set_conversation_prefs: async (ctx: MedFamilyContext, args: any) => {
78
+ const sessionId = readStringValue(args?.sessionId, (args as any)?.targetSessionId, (args as any)?.instanceId);
79
+ if (!sessionId) return { success: false, error: 'sessionId required' };
80
+ const inst = ctx.deps.instanceManager.getInstance(sessionId);
81
+ if (!inst || typeof inst.updateSettings !== 'function') {
82
+ return { success: false, error: 'Session not found or does not support preferences' };
83
+ }
84
+ const patch: Record<string, unknown> = {};
85
+ if (typeof args?.hidden === 'boolean') patch.userHidden = args.hidden;
86
+ if (typeof args?.muted === 'boolean') patch.userMuted = args.muted;
87
+ if (!Object.keys(patch).length) return { success: false, error: 'Nothing to update (hidden and/or muted required)' };
88
+ inst.updateSettings(patch);
89
+ // Push a fresh status snapshot so all clients see the updated
90
+ // surfaceHidden/muted immediately (cloud path). The standalone server has a
91
+ // parallel broadcast gate keyed on the command name (see daemon-standalone).
92
+ ctx.deps.onStatusChange?.();
93
+ return { success: true, sessionId, ...patch };
94
+ },
95
+
67
96
  agent_command: async (ctx: MedFamilyContext, args: any) => {
68
97
  // Relay-safety stamp: a dispatch carrying meshContext.coordinatorDaemonId
69
98
  // (mesh_send_task / queue assignment over P2P) is the worker daemon's chance
@@ -17,6 +17,8 @@ import { handleMeshForwardEvent, queuePendingMeshCoordinatorEvent } from '../mes
17
17
  import { analyzeMeshRefineNodeChangeArea, orderMeshRefineBatchNodes } from '../mesh/mesh-refine-batch.js';
18
18
  import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
19
19
  import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
20
+ import { classifyChangedPackages } from '../git/git-status.js';
21
+ import type { ChangedPackageClassification } from '../git/git-status.js';
20
22
  import { readStringValue } from '../mesh/mesh-node-identity.js';
21
23
  import {
22
24
  alignRefinerySubmodulesAfterMerge,
@@ -312,7 +314,24 @@ export async function refineResolveRefsStage(self: DaemonCommandRouter,
312
314
  const { stdout: branchHeadStdout } = await execFileAsync('git', ['rev-parse', branch], { cwd: node.workspace, encoding: 'utf8' });
313
315
  const baseHead = baseHeadRaw;
314
316
  const branchHead = branchHeadStdout.trim();
315
- recordMeshRefineStage(refineStages, 'resolve_refs', 'passed', resolveStarted, { branch, baseBranch, baseHead, branchHead, ...(fetchWarning ? { fetchWarning } : {}) });
317
+
318
+ // Coarse daemon-vs-web change-impact for baseHead..branchHead, computed
319
+ // against the worktree so the same policy (.adhdev/change-impact.*) as the
320
+ // stale-build detector applies. Threaded onto ctx so the validation gate
321
+ // can scope its command set: a web-only branch skips daemon-scoped commands.
322
+ // FAIL-OPEN: any classification error leaves changeImpact undefined → the
323
+ // gate runs the full command set (never skip on uncertainty).
324
+ let changeImpact: ChangedPackageClassification | undefined;
325
+ try {
326
+ changeImpact = await classifyChangedPackages(node.workspace, baseHead, branchHead);
327
+ } catch {
328
+ changeImpact = undefined;
329
+ }
330
+ recordMeshRefineStage(refineStages, 'resolve_refs', 'passed', resolveStarted, {
331
+ branch, baseBranch, baseHead, branchHead,
332
+ ...(changeImpact ? { changeImpact } : {}),
333
+ ...(fetchWarning ? { fetchWarning } : {}),
334
+ });
316
335
 
317
336
  return {
318
337
  kind: 'continue',
@@ -330,6 +349,7 @@ export async function refineResolveRefsStage(self: DaemonCommandRouter,
330
349
  baseBranch,
331
350
  baseHead,
332
351
  branchHead,
352
+ changeImpact,
333
353
  validationSummary: undefined as any,
334
354
  patchEquivalence: undefined as any,
335
355
  submoduleReachability: undefined as any,
@@ -346,6 +366,9 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
346
366
  const { mesh, node, branch, baseBranch, refineStages } = ctx;
347
367
  const validationStarted = Date.now();
348
368
  const validationSummary = await runMeshRefineValidationGate(mesh, node.workspace, {
369
+ // (a) Scope the validation command set by coarse change-impact (resolved
370
+ // in resolve_refs). Undefined → gate runs the full command set (fail-open).
371
+ changeImpact: ctx.changeImpact,
349
372
  // M2-2: consume the node's persisted bootstrap state; persist re-runs.
350
373
  persistedBootstrapState: (node as any).worktreeBootstrap as WorktreeBootstrapState | undefined,
351
374
  onBootstrapStateChange: (state) => {
@@ -369,7 +392,9 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
369
392
  : undefined;
370
393
  const buildValidationFailedError = (): string => {
371
394
  const base = validationSummary.failureCode === 'missing_dependencies'
372
- ? 'Refinery validation dependencies are missing; merge/refine was not attempted. Configure validation.bootstrapCommands if Refinery should bootstrap dependencies before validation.'
395
+ ? 'Refinery validation dependencies are missing for a change-affected package; merge/refine was not attempted. '
396
+ + 'To make this self-service, either (1) configure .adhdev/worktree_bootstrap.json (or validation.bootstrapCommands in .adhdev/refine.json) so Refinery installs deps before validation, '
397
+ + 'or (2) converge the branch via the documented manual fast-forward-only bypass (rebase onto the fetched base, verify strict ancestry, then push ff-only) instead of the refine gate.'
373
398
  : validationSummary.failureCode === 'dependency_bootstrap_failed'
374
399
  ? 'Refinery dependency/bootstrap command failed; merge/refine was not attempted.'
375
400
  : validationSummary.failureCode === 'spawn_resolution_failed'
@@ -438,6 +438,54 @@ function isNonRuntimeRootFile(file: string, policy: ResolvedChangeImpactPolicy):
438
438
  return false;
439
439
  }
440
440
 
441
+ /** Coarse change-impact verdict produced from a changed-file list. */
442
+ export interface ChangedPackageClassification {
443
+ isDaemonAffecting: boolean;
444
+ affectedPackages: string[];
445
+ }
446
+
447
+ /**
448
+ * Pure bucketer: classify an already-collected changed-file list into the coarse
449
+ * daemon-vs-web verdict, per the resolved policy. Shared by classifyDaemonBuildChange
450
+ * (buildCommit..HEAD) and classifyChangedPackages (arbitrary ref range) so the
451
+ * daemon/web boundary logic lives in exactly one place. An empty list stays
452
+ * conservative (daemon-affecting) so an actionable warning is never suppressed.
453
+ */
454
+ function classifyChangedFileList(
455
+ files: string[],
456
+ policy: ResolvedChangeImpactPolicy,
457
+ ): ChangedPackageClassification {
458
+ if (files.length === 0) {
459
+ // No file diff (e.g. only merge metadata) — nothing actionable, but stay
460
+ // conservative and treat as daemon-affecting so we don't suppress a real warning.
461
+ return { isDaemonAffecting: true, affectedPackages: [] };
462
+ }
463
+ const pkgs = new Set<string>();
464
+ // A non-package path that is NOT a recognized benign root file (marker/doc).
465
+ // Only these force daemon-affecting; benign markers/docs are ignored so a
466
+ // gitlink-moving root commit over a marker-only oss commit no longer over-warns.
467
+ let sawRuntimeAmbiguousNonPackage = false;
468
+ for (const file of files) {
469
+ const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
470
+ if (!match) {
471
+ if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
472
+ continue;
473
+ }
474
+ pkgs.add(match[1]);
475
+ }
476
+ const affectedPackages = [...pkgs].sort();
477
+ // Daemon-affecting if: any runtime-ambiguous non-package file changed, any
478
+ // unknown package changed, or any explicit daemon-runtime package changed.
479
+ // The daemon is unaffected only when every changed file is either a known
480
+ // web-only package or a recognized benign root file (and at least one such
481
+ // file changed) — i.e. nothing runtime-ambiguous remains. Unlisted/new packages
482
+ // therefore stay daemon-affecting (fail-safe default preserved).
483
+ const allBenign =
484
+ !sawRuntimeAmbiguousNonPackage &&
485
+ affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
486
+ return { isDaemonAffecting: !allBenign, affectedPackages };
487
+ }
488
+
441
489
  /**
442
490
  * Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
443
491
  * package, per the resolved policy. Returns isDaemonAffecting:true conservatively
@@ -449,47 +497,54 @@ async function classifyDaemonBuildChange(
449
497
  buildCommit: string,
450
498
  options: GitStatusOptions,
451
499
  policy: ResolvedChangeImpactPolicy,
452
- ): Promise<{ isDaemonAffecting: boolean; affectedPackages: string[] }> {
500
+ ): Promise<ChangedPackageClassification> {
453
501
  try {
454
502
  const diff = await runGit(repoPath, ['diff', '--name-only', `${buildCommit}..HEAD`], options);
455
503
  const files = diff.stdout
456
504
  .split('\n')
457
505
  .map((line) => line.trim())
458
506
  .filter(Boolean);
459
- if (files.length === 0) {
460
- // No file diff (e.g. only merge metadata) — nothing actionable, but stay
461
- // conservative and treat as daemon-affecting so we don't suppress a real warning.
462
- return { isDaemonAffecting: true, affectedPackages: [] };
463
- }
464
- const pkgs = new Set<string>();
465
- // A non-package path that is NOT a recognized benign root file (marker/doc).
466
- // Only these force daemon-affecting; benign markers/docs are ignored so a
467
- // gitlink-moving root commit over a marker-only oss commit no longer over-warns.
468
- let sawRuntimeAmbiguousNonPackage = false;
469
- for (const file of files) {
470
- const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
471
- if (!match) {
472
- if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
473
- continue;
474
- }
475
- pkgs.add(match[1]);
476
- }
477
- const affectedPackages = [...pkgs].sort();
478
- // Daemon-affecting if: any runtime-ambiguous non-package file changed, any
479
- // unknown package changed, or any explicit daemon-runtime package changed.
480
- // The daemon is unaffected only when every changed file is either a known
481
- // web-only package or a recognized benign root file (and at least one such
482
- // file changed) — i.e. nothing runtime-ambiguous remains.
483
- const allBenign =
484
- !sawRuntimeAmbiguousNonPackage &&
485
- affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
486
- return { isDaemonAffecting: !allBenign, affectedPackages };
507
+ return classifyChangedFileList(files, policy);
487
508
  } catch {
488
509
  // diff probe failed → can't prove web-only; stay conservative.
489
510
  return { isDaemonAffecting: true, affectedPackages: [] };
490
511
  }
491
512
  }
492
513
 
514
+ /**
515
+ * Ref-parameterized change-impact classification for a repo/worktree, reusing the
516
+ * exact daemon-vs-web bucketing that the stale-build detector uses — but over an
517
+ * arbitrary `fromRef..toRef` range (e.g. a refine base head → branch head) instead
518
+ * of the live daemon's build commit → HEAD, and WITHOUT any daemonBuildInfo caching.
519
+ *
520
+ * Policy is resolved the same way as getGitRepoStatus: an explicit
521
+ * `options.changeImpactConfig` wins; otherwise the repo's `.adhdev/change-impact.*`
522
+ * is auto-loaded; otherwise the built-in ADHDev default policy applies. The
523
+ * classification uses `git diff --name-only fromRef..toRef`.
524
+ *
525
+ * FAIL-OPEN on error: if the diff can't be collected (bad ref, not a repo), the
526
+ * caller should treat "no verdict" as "run everything" — so we throw rather than
527
+ * returning a misleading benign verdict. Callers wrap this in try/catch and leave
528
+ * changeImpact undefined on failure. Unclassified/new packages still default to
529
+ * isDaemonAffecting:true (never silently skipped).
530
+ */
531
+ export async function classifyChangedPackages(
532
+ repoPath: string,
533
+ fromRef: string,
534
+ toRef: string,
535
+ options: GitStatusOptions = {},
536
+ ): Promise<ChangedPackageClassification> {
537
+ const repo = await resolveGitRepository(repoPath, options);
538
+ const { config } = resolveChangeImpactConfigForRepo(repo.repoRoot, options);
539
+ const policy = resolveChangeImpactPolicy(config);
540
+ const diff = await runGit(repoPath, ['diff', '--name-only', `${fromRef}..${toRef}`], options);
541
+ const files = diff.stdout
542
+ .split('\n')
543
+ .map((line) => line.trim())
544
+ .filter(Boolean);
545
+ return classifyChangedFileList(files, policy);
546
+ }
547
+
493
548
  /**
494
549
  * Resolve the Change Impact config to apply for this status read. Priority:
495
550
  * - options.changeImpactConfig === null → force built-in default policy (no load).
package/src/index.ts CHANGED
@@ -381,7 +381,7 @@ export type {
381
381
  // ── Status ──
382
382
  export { DaemonStatusReporter } from './status/reporter.js';
383
383
  export { buildSessionEntries, findCdpManager, hasCdpManager, isCdpConnected } from './status/builders.js';
384
- export { buildStatusSnapshot, buildMachineInfo, getLastDisplayMessage } from './status/snapshot.js';
384
+ export { buildStatusSnapshot, buildMachineInfo, buildAvailableProviders, getLastDisplayMessage } from './status/snapshot.js';
385
385
  export { getDaemonBuildInfo } from './build-info.js';
386
386
  export type { DaemonBuildInfo } from './build-info.js';
387
387
  export { normalizeManagedStatus, isManagedStatusWorking, isManagedStatusWaiting, normalizeActiveChatData } from './status/normalize.js';
@@ -702,7 +702,9 @@ const TOOLS_SECTION = `## Available Tools
702
702
  | \`mesh_magi_review\` | Cross-verify a read-only investigation across a standing panel of independent mesh agents (different machines/providers) instead of a single worker |
703
703
  | \`mesh_magi_collect\` | Collect + synthesize a previously dispatched MAGI fan-out by its consensus group id (async companion to mesh_magi_review wait:false) |
704
704
  | \`mesh_magi_kind_panel_set\` | Bind a task_kind → MAGI kind-panel slots (the SOLE MAGI panel-resolution surface; machine-local, wholesale replacement — approve current-vs-new first) |
705
- | \`mesh_magi_kind_panel_list\` | List configured task_kind → MAGI kind-panel slot bindings (machine-local, read-only) |`;
705
+ | \`mesh_magi_kind_panel_list\` | List configured task_kind → MAGI kind-panel slot bindings (machine-local, read-only) |
706
+ | \`mesh_node_slots_list\` | List a node's capability slots (its AI-tool profile: provider/model/thinking + difficulty range + capability tags), read-only |
707
+ | \`mesh_node_slots_set\` | PROPOSE (dry-run) or APPLY a node's capability slots — how you autonomously retune a node's tool profile; WHOLESALE replacement, present current-vs-proposed and get user approval before write=true |`;
706
708
 
707
709
  const TOOL_EXPOSURE_PREFLIGHT_SECTION = `## Tool Exposure Preflight
708
710
 
@@ -773,6 +775,7 @@ function buildRulesSection(coordinatorCliType?: string): string {
773
775
  - **Reuse idle sessions.** For follow-up, retry, commit/push, or cleanup on the same issue, send only the delta to the existing idle session. Start fresh only for independent work, provider mismatch, transcript contamination, or required worktree isolation.
774
776
  - **Worktree affinity.** A worktree is a durable per-branch workspace; keep all of a branch's code_change/fix/review work on its worktree node by targeting \`required_tags: ["worktree=<branch>"]\` or \`target_node_id\`. Get the id/branch from the \`mesh_clone_node\` result or a live \`mesh_status\` — the Configured Nodes snapshot won't list a worktree cloned after launch. Untargeted same-branch follow-ups drift to the base node. Only \`convergence\` (merge/push) runs on the base, never pinned to the worktree.
775
777
  - **Classify task difficulty to save tokens.** For each task you enqueue, judge its execution difficulty and pass \`difficulty\`: \`easy\` (extraction, renames, doc tweaks, trivial fixes), \`medium\` (ordinary feature/bugfix work), \`difficult\` (architecture, tricky debugging, multi-file refactors, subtle reasoning), or \`freeform\`. The mesh's per-difficulty brain preset then runs easy tasks on a cheaper model at low reasoning effort and hard tasks on a stronger model at high effort — real token savings on simple work. The current presets are shown in the "Brain presets" section below. You may still pass an explicit \`model\`/\`thinkingLevel\` to override the preset for one task.
778
+ - **Retune node profiles when routing is a poor fit — but only with approval.** A node's capability slots (its provider/model/thinking + difficulty range + capability tags, seen via \`mesh_node_slots_list\`) are what task→node fitness routing matches against. If you notice a persistent mismatch — e.g. every \`difficult\` task lands on a node whose only slot is a cheap model, or a capability a node clearly has isn't declared — you MAY propose a slot change with \`mesh_node_slots_set\` (write=false). That returns current-vs-proposed; present that diff to the user with a one-line reason and apply (write=true) ONLY after they approve. It is a WHOLESALE replacement of the node's slots, so include the slots you want to keep. Never rewrite a node's profile silently or without a clear routing reason.
776
779
  - **Respect explicit provider requests.** Map: Hermes → \`hermes-cli\`, Claude/Claude Code → \`claude-cli\`, Codex → \`codex-cli\`, Gemini → \`gemini-cli\`, Antigravity → \`antigravity-cli\`. Never substitute the coordinator's own runtime.
777
780
  - **Verify via git, not source.** Use \`mesh_git_status\` to confirm side effects. Treat agent summaries as self-reports, not verification.
778
781
  - **Limit parallelism.** Start with 1–2 tasks; scale only on success. Never duplicate a session because \`mesh_read_chat\` shows no final message while tool/terminal activity is ongoing. This caps *concurrent* load — it does not mean serialize independent work: when a new, independent request arrives and there is headroom under \`maxParallelTasks\`, dispatch it right away rather than waiting for an in-flight task or a user nudge (read-only diagnosis especially, since it has no merge cost).