@adhdev/mesh-shared 1.0.30-rc.1 → 1.0.30

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.
@@ -70,6 +70,7 @@ export const CANONICAL_MESH_TOOL_NAMES = [
70
70
  'mesh_magi_kind_panel_list',
71
71
  'mesh_node_slots_set',
72
72
  'mesh_node_slots_list',
73
+ 'mesh_node_slots_propose',
73
74
  ] as const;
74
75
 
75
76
  export type CanonicalMeshToolName = typeof CANONICAL_MESH_TOOL_NAMES[number];
@@ -0,0 +1,327 @@
1
+ /**
2
+ * CLI auto-detect → capability-slot / MAGI-panel PROPOSAL generator.
3
+ *
4
+ * Detection of installed CLI providers already exists per node (the status
5
+ * snapshot's `availableProviders`), and applying a slot profile already exists
6
+ * (`mesh_node_slots_set`, dry-run by default). What was missing is the bit in
7
+ * between: turning "these CLIs are installed on this node" into a concrete
8
+ * `NodeCapabilitySlot[]` draft the operator can review. This module is that
9
+ * bridge, and nothing more — it is a PURE proposal generator. It never writes;
10
+ * the caller feeds its output into the existing dry-run/approve tools.
11
+ *
12
+ * ─── Why the mapping is a static table ───────────────────────────────────────
13
+ *
14
+ * Provider manifests carry NO difficulty or capability-grade information. The
15
+ * fields that look like they might (`modelOptions`, `thinkingLevelOptions`)
16
+ * describe what a provider ACCEPTS, not what it is GOOD AT — a provider listing
17
+ * `opus` says nothing about whether opus should get the hard tasks. So there is
18
+ * no honest way to derive difficulty from the manifest today.
19
+ *
20
+ * The table below is therefore seeded from the operator's real, in-use slot
21
+ * configuration rather than from a guess. That makes it a starting point with
22
+ * actual provenance, and it is deliberately isolated in ONE constant so the
23
+ * planned usage-data-driven replacement has a single, obvious swap point.
24
+ *
25
+ * Kept in the dependency-free mesh-shared leaf because both daemon-core (which
26
+ * has the detection data) and mcp-server (which owns the propose/apply tools)
27
+ * need it, and it is pure data + pure functions on plain objects.
28
+ */
29
+ import {
30
+ normalizeNodeCapabilitySlot,
31
+ type MeshTaskDifficulty,
32
+ type NodeCapabilitySlot,
33
+ } from './brain-routing'
34
+ import type { MagiSlot } from './magi'
35
+
36
+ /**
37
+ * One provider's seeded slot recipe. A provider may map to MORE THAN ONE slot
38
+ * (claude-cli does: a wide sonnet slot plus a narrow opus slot for the hard
39
+ * work), which is why the table's values are arrays.
40
+ */
41
+ export interface CliSlotRecipe {
42
+ /** Optional model to pin on the slot. Best-effort at launch. */
43
+ model?: string
44
+ /** Optional thinking level, in the provider's own vocabulary. */
45
+ thinkingLevel?: string
46
+ /** Difficulty range this slot handles. Empty = general-purpose. */
47
+ difficulty?: MeshTaskDifficulty[]
48
+ /** Per-slot concurrency cap. */
49
+ maxParallel?: number
50
+ /**
51
+ * Set when this recipe is an unvalidated guess rather than a transcription
52
+ * of a slot the operator actually runs. Surfaced on the proposal so the
53
+ * reviewer knows which lines to scrutinize.
54
+ */
55
+ provisional?: boolean
56
+ /** Short human-readable justification, echoed into the proposal. */
57
+ rationale?: string
58
+ }
59
+
60
+ /**
61
+ * ★ THE MAPPING TABLE — the single swap point.
62
+ *
63
+ * Seeded (2026-08-03) from the operator's live slot configuration, on the
64
+ * reasoning that a transcription of what is actually in production beats an
65
+ * invented heuristic. Replace wholesale once usage data (success rate, cost,
66
+ * turn latency per provider×difficulty) can drive it.
67
+ *
68
+ * Every entry except `hermes-cli` reflects a real configured slot. `hermes-cli`
69
+ * is a conservative GUESS — see its `provisional` flag.
70
+ */
71
+ export const CLI_SLOT_RECIPES: Readonly<Record<string, readonly CliSlotRecipe[]>> = Object.freeze<Record<string, readonly CliSlotRecipe[]>>({
72
+ 'claude-cli': [
73
+ {
74
+ model: 'sonnet',
75
+ thinkingLevel: 'high',
76
+ difficulty: ['medium', 'easy'],
77
+ maxParallel: 5,
78
+ rationale: 'Primary workhorse — widest parallelism for routine work.',
79
+ },
80
+ {
81
+ model: 'opus',
82
+ thinkingLevel: 'high',
83
+ difficulty: ['difficult'],
84
+ maxParallel: 1,
85
+ rationale: 'Reserved for hard tasks; capped at 1 to bound cost.',
86
+ },
87
+ ],
88
+ 'kimi': [
89
+ {
90
+ model: 'kimi-code/k3',
91
+ difficulty: ['medium', 'difficult'],
92
+ maxParallel: 2,
93
+ rationale: 'Independent second opinion on mid/hard work.',
94
+ },
95
+ ],
96
+ 'codex-cli': [
97
+ {
98
+ difficulty: ['medium', 'difficult', 'freeform'],
99
+ maxParallel: 2,
100
+ rationale: 'Broad range including freeform; no model pin.',
101
+ },
102
+ ],
103
+ 'antigravity-cli': [
104
+ {
105
+ model: 'Gemini 3.1 Pro (High)',
106
+ difficulty: ['easy'],
107
+ maxParallel: 2,
108
+ rationale: 'Cheap capacity for easy tasks.',
109
+ },
110
+ ],
111
+ 'cursor-cli': [
112
+ {
113
+ model: 'auto',
114
+ difficulty: ['easy'],
115
+ maxParallel: 1,
116
+ rationale: 'Easy tasks only; auto model selection.',
117
+ },
118
+ ],
119
+ 'hermes-cli': [
120
+ {
121
+ difficulty: ['medium'],
122
+ maxParallel: 2,
123
+ provisional: true,
124
+ // NOTE: ESTIMATE, NOT OBSERVED. hermes-cli is absent from the live
125
+ // slot configuration this table was seeded from, so `medium` is a
126
+ // conservative placement rather than a transcription. Revisit once
127
+ // it has real usage data.
128
+ rationale: 'ESTIMATE — no live slot to transcribe; conservative mid placement. Adjust after real use.',
129
+ },
130
+ ],
131
+ })
132
+
133
+ /**
134
+ * Fallback for a detected CLI provider absent from {@link CLI_SLOT_RECIPES}.
135
+ * Deliberately timid: one general-purpose slot at the lowest parallelism, so an
136
+ * unrecognized provider can be used but never silently soaks up the queue.
137
+ */
138
+ export const UNKNOWN_CLI_SLOT_RECIPE: Readonly<CliSlotRecipe> = Object.freeze<CliSlotRecipe>({
139
+ difficulty: ['medium'],
140
+ maxParallel: 1,
141
+ provisional: true,
142
+ rationale: 'Unrecognized provider — conservative default (medium, maxParallel 1). Review before relying on it.',
143
+ })
144
+
145
+ /** A detected, installed CLI provider on one node — the generator's input. */
146
+ export interface DetectedCliProvider {
147
+ /** Provider type id, e.g. 'claude-cli'. */
148
+ type: string
149
+ /** Human-readable name, for display in the proposal. */
150
+ displayName?: string
151
+ /** Detected version, when known. Display only — never affects the mapping. */
152
+ version?: string
153
+ }
154
+
155
+ /** One proposed slot plus why it was proposed. */
156
+ export interface ProposedSlotEntry {
157
+ slot: NodeCapabilitySlot
158
+ /** True when the provider had no table entry and took the conservative fallback. */
159
+ unknownProvider: boolean
160
+ /** True when the recipe behind this slot is flagged as an unvalidated estimate. */
161
+ provisional: boolean
162
+ rationale?: string
163
+ }
164
+
165
+ /** The full slot proposal for one node, including what a write would DESTROY. */
166
+ export interface SlotProposal {
167
+ /** The draft slot list — a WHOLESALE replacement for the node's policy.slots. */
168
+ proposedSlots: NodeCapabilitySlot[]
169
+ /** Per-slot provenance, index-aligned with `proposedSlots`. */
170
+ entries: ProposedSlotEntry[]
171
+ /** Provider types detected but not present in the mapping table. */
172
+ unknownProviders: string[]
173
+ /** Provider types whose proposal rests on an unvalidated estimate. */
174
+ provisionalProviders: string[]
175
+ /**
176
+ * Slots currently configured on the node that the proposal does NOT
177
+ * reproduce — i.e. what applying this proposal would DELETE. Slot writes are
178
+ * wholesale replacements, so an operator-hand-tuned slot absent from the
179
+ * detection-derived draft is silently destroyed unless it is named here.
180
+ */
181
+ droppedSlots: NodeCapabilitySlot[]
182
+ /** Provider types that appear in `droppedSlots` but in no proposed slot at all. */
183
+ droppedProviders: string[]
184
+ /** True when applying the proposal would remove at least one existing slot. */
185
+ destructive: boolean
186
+ }
187
+
188
+ /** Stable key identifying a slot's identity for current-vs-proposed diffing. */
189
+ function slotKey(slot: NodeCapabilitySlot): string {
190
+ return [
191
+ slot.provider,
192
+ slot.model ?? '',
193
+ slot.thinkingLevel ?? '',
194
+ [...(slot.difficulty ?? [])].sort().join('|'),
195
+ [...(slot.capability ?? [])].sort().join('|'),
196
+ slot.maxParallel ?? '',
197
+ ].join('')
198
+ }
199
+
200
+ /** Dedupe detected providers by type, preserving first-seen order. */
201
+ function dedupeDetected(detected: readonly DetectedCliProvider[]): DetectedCliProvider[] {
202
+ const seen = new Set<string>()
203
+ const out: DetectedCliProvider[] = []
204
+ for (const d of detected) {
205
+ const type = typeof d?.type === 'string' ? d.type.trim() : ''
206
+ if (!type || seen.has(type)) continue
207
+ seen.add(type)
208
+ out.push({ ...d, type })
209
+ }
210
+ return out
211
+ }
212
+
213
+ /**
214
+ * Build a capability-slot proposal from a node's detected CLI providers.
215
+ *
216
+ * Pure and total: zero detections yields an empty proposal (never throws), which
217
+ * the caller should treat as "nothing to propose" rather than "replace the
218
+ * node's slots with nothing".
219
+ *
220
+ * `currentSlots` is optional but strongly recommended — it is the only way the
221
+ * returned proposal can report what a wholesale write would destroy.
222
+ */
223
+ export function buildSlotProposal(
224
+ detected: readonly DetectedCliProvider[],
225
+ currentSlots: readonly NodeCapabilitySlot[] = [],
226
+ ): SlotProposal {
227
+ const providers = dedupeDetected(detected ?? [])
228
+ const proposedSlots: NodeCapabilitySlot[] = []
229
+ const entries: ProposedSlotEntry[] = []
230
+ const unknownProviders: string[] = []
231
+ const provisionalProviders: string[] = []
232
+
233
+ for (const provider of providers) {
234
+ const known = CLI_SLOT_RECIPES[provider.type]
235
+ const recipes: readonly CliSlotRecipe[] = known ?? [UNKNOWN_CLI_SLOT_RECIPE]
236
+ const isUnknown = !known
237
+ if (isUnknown) unknownProviders.push(provider.type)
238
+
239
+ let providerProvisional = false
240
+ for (const recipe of recipes) {
241
+ // Normalize through the SAME normalizer the daemon applies on write, so
242
+ // a proposal can never preview a shape the write would reshape.
243
+ const slot = normalizeNodeCapabilitySlot({
244
+ provider: provider.type,
245
+ model: recipe.model,
246
+ thinkingLevel: recipe.thinkingLevel,
247
+ difficulty: recipe.difficulty,
248
+ maxParallel: recipe.maxParallel,
249
+ })
250
+ if (!slot) continue
251
+ const provisional = recipe.provisional === true
252
+ if (provisional) providerProvisional = true
253
+ proposedSlots.push(slot)
254
+ entries.push({
255
+ slot,
256
+ unknownProvider: isUnknown,
257
+ provisional,
258
+ ...(recipe.rationale ? { rationale: recipe.rationale } : {}),
259
+ })
260
+ }
261
+ if (providerProvisional) provisionalProviders.push(provider.type)
262
+ }
263
+
264
+ // What a wholesale write would destroy: every currently-configured slot with
265
+ // no exact counterpart in the draft.
266
+ const proposedKeys = new Set(proposedSlots.map(slotKey))
267
+ const droppedSlots = currentSlots.filter(slot => !proposedKeys.has(slotKey(slot)))
268
+ const proposedProviders = new Set(proposedSlots.map(s => s.provider))
269
+ const droppedProviders = [...new Set(
270
+ droppedSlots.map(s => s.provider).filter(p => !proposedProviders.has(p)),
271
+ )]
272
+
273
+ return {
274
+ proposedSlots,
275
+ entries,
276
+ unknownProviders,
277
+ provisionalProviders,
278
+ droppedSlots,
279
+ droppedProviders,
280
+ destructive: droppedSlots.length > 0,
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Build a MAGI panel proposal from the same detections.
286
+ *
287
+ * ─── Deliberately narrow ──────────────────────────────────────────────────────
288
+ *
289
+ * MAGI's value is provider INDEPENDENCE: replicas from different providers
290
+ * (ideally different machines) answering the same question, so agreement means
291
+ * something. Detection tells us which providers exist — that is exactly enough
292
+ * to propose one panel of distinct providers, and no more.
293
+ *
294
+ * What detection does NOT tell us is which provider suits which review KIND
295
+ * (rca vs design vs claim_audit). Nothing in any manifest grades a provider for
296
+ * root-cause analysis over design review, and inventing a per-kind assignment
297
+ * would fabricate a rationale that does not exist. So this proposes ONE panel of
298
+ * the detected providers and leaves the kind binding to the operator; the caller
299
+ * decides which `task_kind` to bind it to via the existing dry-run tool.
300
+ *
301
+ * Ordering follows {@link CLI_SLOT_RECIPES} insertion order (recipe-known
302
+ * providers first, in table order), so the panel leads with the providers whose
303
+ * suitability is actually attested.
304
+ */
305
+ export function buildMagiPanelProposal(
306
+ detected: readonly DetectedCliProvider[],
307
+ opts: { nodeId?: string; maxSlots?: number } = {},
308
+ ): MagiSlot[] {
309
+ const providers = dedupeDetected(detected ?? [])
310
+ const tableOrder = Object.keys(CLI_SLOT_RECIPES)
311
+ const rank = (type: string): number => {
312
+ const i = tableOrder.indexOf(type)
313
+ return i === -1 ? Number.MAX_SAFE_INTEGER : i
314
+ }
315
+ const ordered = [...providers].sort((a, b) => rank(a.type) - rank(b.type))
316
+ const limit = Number.isFinite(opts.maxSlots) && (opts.maxSlots as number) > 0
317
+ ? Math.floor(opts.maxSlots as number)
318
+ : ordered.length
319
+
320
+ return ordered.slice(0, limit).map((p): MagiSlot => ({
321
+ ...(opts.nodeId ? { nodeId: opts.nodeId } : {}),
322
+ provider: p.type,
323
+ // A model is intentionally NOT pinned: the panel's job is cross-provider
324
+ // independence, and pinning models here would silently couple the panel
325
+ // to this table's cost assumptions rather than to review quality.
326
+ }))
327
+ }