@ran-sh/dsh-crew 0.3.0

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.
Files changed (85) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/.claude-plugin/plugin.json +8 -0
  3. package/.mcp.json +8 -0
  4. package/LICENSE +21 -0
  5. package/README.de.md +359 -0
  6. package/README.es.md +359 -0
  7. package/README.fr.md +359 -0
  8. package/README.hi.md +359 -0
  9. package/README.id.md +359 -0
  10. package/README.ja.md +359 -0
  11. package/README.ko.md +359 -0
  12. package/README.md +360 -0
  13. package/README.pt.md +359 -0
  14. package/README.ru.md +359 -0
  15. package/README.th.md +359 -0
  16. package/README.tr.md +359 -0
  17. package/README.vi.md +359 -0
  18. package/README.zh-TW.md +359 -0
  19. package/README.zh.md +305 -0
  20. package/agents/ds-flash.md +26 -0
  21. package/agents/ds-pro.md +32 -0
  22. package/agents/ds-reviewer.md +23 -0
  23. package/agents/ds-worker.md +22 -0
  24. package/codex/agents/ds-flash.toml +30 -0
  25. package/codex/agents/ds-pro.toml +31 -0
  26. package/codex/agents/ds-reviewer.toml +28 -0
  27. package/codex/agents/ds-worker.toml +28 -0
  28. package/codex/prompts/dsh-config.md +3 -0
  29. package/codex/prompts/dsh-status.md +1 -0
  30. package/commands/config.md +11 -0
  31. package/commands/off.md +5 -0
  32. package/commands/on.md +5 -0
  33. package/commands/status.md +5 -0
  34. package/cordis.patch.yml +4 -0
  35. package/docs/images/dsh-crew-host.png +0 -0
  36. package/docs/images/dsh-crew-jobs.png +0 -0
  37. package/docs/images/dsh-crew-logo.png +0 -0
  38. package/docs/images/dsh-crew-overview.png +0 -0
  39. package/lib/client.js +2765 -0
  40. package/package.json +125 -0
  41. package/scripts/build-client.mjs +28 -0
  42. package/scripts/live-crew-smoke.mjs +39 -0
  43. package/scripts/live-policy-matrix.mjs +177 -0
  44. package/scripts/policy-probe.mjs +101 -0
  45. package/scripts/setup.mjs +294 -0
  46. package/scripts/smoke-real.mjs +110 -0
  47. package/scripts/smoke.mjs +78 -0
  48. package/scripts/verify-installer-fix.mjs +26 -0
  49. package/src/adaptive-routing.mjs +260 -0
  50. package/src/client/activation-summary.tsx +64 -0
  51. package/src/client/entry.tsx +236 -0
  52. package/src/client/index.tsx +1120 -0
  53. package/src/config-readiness.mjs +59 -0
  54. package/src/delivery.mjs +205 -0
  55. package/src/dsh-cli-runtime.mjs +251 -0
  56. package/src/failure-classification.mjs +172 -0
  57. package/src/hub/entry.mjs +98 -0
  58. package/src/hub/index.mjs +757 -0
  59. package/src/hub-client.mjs +132 -0
  60. package/src/hub-compatibility.mjs +49 -0
  61. package/src/i18n.mjs +19 -0
  62. package/src/install/cli.mjs +28 -0
  63. package/src/install/install-legacy.mjs +460 -0
  64. package/src/install/install.mjs +451 -0
  65. package/src/jobs.mjs +275 -0
  66. package/src/mcp-runtime.mjs +257 -0
  67. package/src/model-catalog.mjs +173 -0
  68. package/src/model-routing.mjs +391 -0
  69. package/src/multimodal.mjs +0 -0
  70. package/src/policy-legacy.mjs +830 -0
  71. package/src/policy.mjs +197 -0
  72. package/src/readiness-matrix.mjs +169 -0
  73. package/src/runtime-controls.mjs +90 -0
  74. package/src/runtime-identity.mjs +108 -0
  75. package/src/server.mjs +477 -0
  76. package/src/status-shard.mjs +52 -0
  77. package/src/structured-error-code.mjs +39 -0
  78. package/src/vision-route.mjs +138 -0
  79. package/src/workflow-runtime.mjs +567 -0
  80. package/src/workflow.mjs +160 -0
  81. package/src/workspace-audit.mjs +231 -0
  82. package/src/workspace-isolation.mjs +306 -0
  83. package/statusline/statusline.sh +14 -0
  84. package/statusline/worker-segment.sh +35 -0
  85. package/worker.cordis.yml +77 -0
@@ -0,0 +1,830 @@
1
+ // Configurable-crew policy: pure normalization + routing decisions for the
2
+ // worker tier orchestration. No I/O, no worker runtime — everything here is a
3
+ // pure function so it can be unit-tested without starting DSH, a hub, or a
4
+ // worker process.
5
+ //
6
+ // Two config layers feed this module:
7
+ // - global: ~/.config/dsh-crew/config.json (Settings page)
8
+ // - session: the per-session overrides managed by dsh_worker_config
9
+ //
10
+ // The hard-enforcement rule is deliberately small: a worker tier may only be
11
+ // dispatched when the *effective* policy marks it usable (enabled + auto, or
12
+ // explicitly requested when manual), and the effective policy is computed
13
+ // identically for dsh_run_worker, dsh_spawn_worker, hub and standalone paths.
14
+ //
15
+ // tier_policy (legacy, session level) remains the strongest clamp:
16
+ // flash-only / pro-only pin every dispatch to one tier, whatever the global
17
+ // collaboration mode says. Global tier_policy is only consulted when
18
+ // migrating old configs; new decisions run on collaboration_mode + states.
19
+
20
+ import { normalizeModelPriority } from './model-routing.mjs';
21
+
22
+ export const TIER_STATES = ['disabled', 'manual', 'auto'];
23
+ export const COLLABORATION_MODES = ['flash-only', 'pro-only', 'balanced', 'review-pipeline', 'custom'];
24
+ export const MAIN_AGENT_MODES = ['direct-allowed', 'coordinator-first', 'dispatcher-only'];
25
+ export const ROLE_IDS = [
26
+ 'implementation',
27
+ 'simple_fix',
28
+ 'tests',
29
+ 'search_inspection',
30
+ 'architecture',
31
+ 'complex_debugging',
32
+ 'refactor',
33
+ 'code_review',
34
+ ];
35
+
36
+ export const DEFAULT_FLASH_ROLES = ['implementation', 'simple_fix', 'tests', 'search_inspection'];
37
+ export const DEFAULT_PRO_ROLES = ['architecture', 'complex_debugging', 'refactor', 'code_review', 'implementation'];
38
+
39
+ export const POLICY_ERROR_CODES = {
40
+ SUBAGENTS_DISABLED: 'SUBAGENTS_DISABLED',
41
+ TIER_DISABLED: 'TIER_DISABLED',
42
+ NO_AUTO_TIER: 'NO_AUTO_TIER',
43
+ NO_WORKER_TIER: 'NO_WORKER_TIER',
44
+ PRO_NOT_AUTO: 'PRO_NOT_AUTO',
45
+ VISION_DISABLED: 'VISION_DISABLED',
46
+ NO_DSH_PROVIDER_SELECTED: 'NO_DSH_PROVIDER_SELECTED',
47
+ // v0.2 role-world errors. RE-CONFIG_REQUIRED for review dispatch that the
48
+ // automatic review workflow refused; ROLE_TIER_CONFLICT for a request that
49
+ // names both a role and an incompatible legacy tier.
50
+ ROLE_DISABLED: 'ROLE_DISABLED',
51
+ ROLE_NOT_AUTO: 'ROLE_NOT_AUTO',
52
+ ROLE_TIER_CONFLICT: 'ROLE_TIER_CONFLICT',
53
+ };
54
+
55
+ export const POLICY_ERROR_MESSAGES = {
56
+ [POLICY_ERROR_CODES.SUBAGENTS_DISABLED]:
57
+ 'DSH Crew worker dispatch is disabled.',
58
+ [POLICY_ERROR_CODES.TIER_DISABLED]:
59
+ 'DeepSeek V4 {tier} worker is disabled by the current DSH Crew policy.',
60
+ [POLICY_ERROR_CODES.NO_AUTO_TIER]:
61
+ 'No Auto worker tier is available. Enable Flash/Pro Auto, explicitly choose a Manual tier, or change Collaboration Mode.',
62
+ [POLICY_ERROR_CODES.NO_WORKER_TIER]:
63
+ 'No DSH worker tier is enabled.',
64
+ [POLICY_ERROR_CODES.PRO_NOT_AUTO]:
65
+ 'Automatic Flash→Pro escalation was skipped because Pro is Manual/Disabled.',
66
+ [POLICY_ERROR_CODES.NO_DSH_PROVIDER_SELECTED]:
67
+ 'No DSH provider is selected for Hub workers. Select a provider in DSH Models or switch Worker Provider to DeepSeek Official.',
68
+ [POLICY_ERROR_CODES.ROLE_DISABLED]:
69
+ 'DeepSeek {tier} role is disabled by the current DSH Crew policy.',
70
+ [POLICY_ERROR_CODES.ROLE_NOT_AUTO]:
71
+ 'The {tier} role is not an Auto role; it runs only when explicitly requested.',
72
+ [POLICY_ERROR_CODES.ROLE_TIER_CONFLICT]:
73
+ 'A request cannot name both a role and a legacy tier that contradict it: role={role} conflicts with tier={tier}.',
74
+ };
75
+
76
+ // ---------- v0.2 role abstraction (worker / reviewer) ----------
77
+ //
78
+ // v0.1 expressed every dispatch as a flash/pro *tier*: the tier doubled as
79
+ // both "who does the work" (role) and "which model class" (policy). v0.2
80
+ // separates the two:
81
+ //
82
+ // - Role: worker (execute / fix / test / search) and reviewer (independent
83
+ // review + verdict). A coding request defaults to worker; reviewer is
84
+ // produced by the review workflow or an explicit request.
85
+ // - Model Policy: which provider/model candidates back a role, with
86
+ // preferred -> priority -> escalation -> Harness Default fallback.
87
+ //
88
+ // Flash / Pro survive only as legacy model-hint + config-migration concepts.
89
+ // Everything new here reads a canonical config shape produced by
90
+ // migrateLegacyConfig; the legacy collab/tier functions stay for compatibility.
91
+
92
+ export const DISPATCH_ROLES = ['worker', 'reviewer'];
93
+ export const ROLE_STATES = TIER_STATES; // disabled | manual | auto
94
+ export const ROLE_MODEL_STRATEGIES = ['economy', 'balanced', 'quality', 'strong'];
95
+ export const DEFAULT_WORKER_STRATEGY = 'balanced';
96
+ export const DEFAULT_REVIEWER_STRATEGY = 'strong';
97
+ export const DEFAULT_MAX_PARALLEL = 3;
98
+
99
+ export function normalizeRoleState(raw) {
100
+ return normalizeState(raw);
101
+ }
102
+
103
+ export function normalizeIsolation(raw) {
104
+ return raw === 'worktree' || raw === 'shared' ? raw : 'worktree';
105
+ }
106
+
107
+ function normalizeMaxParallel(raw) {
108
+ const n = Number.isInteger(raw) ? raw : DEFAULT_MAX_PARALLEL;
109
+ if (n < 1) return 1;
110
+ if (n > 16) return 16;
111
+ return n;
112
+ }
113
+
114
+ /**
115
+ * Normalize one role's model policy into the canonical shape:
116
+ * { role, strategy, priority, priorityConfigured, escalation_priority,
117
+ * escalation_priority_configured, fallback, escalation: { enabled, max_attempts } }.
118
+ * Pure; unknown fields drop, empty priority falls back to the role default.
119
+ */
120
+ export function normalizeModelPolicy(raw = {}) {
121
+ return {
122
+ role: raw.role === 'reviewer' ? 'reviewer' : 'worker',
123
+ strategy: ROLE_MODEL_STRATEGIES.includes(raw.strategy)
124
+ ? raw.strategy
125
+ : (raw.role === 'reviewer' ? DEFAULT_REVIEWER_STRATEGY : DEFAULT_WORKER_STRATEGY),
126
+ priority: normalizeModelPriority(raw.priority),
127
+ priorityConfigured: raw.priorityConfigured === true,
128
+ escalation_priority: normalizeModelPriority(raw.escalation_priority),
129
+ escalation_priority_configured: raw.escalation_priority_configured === true,
130
+ fallback: raw.fallback === 'harness-default' ? 'harness-default' : 'harness-default',
131
+ escalation: {
132
+ enabled: normalizeBool(raw.escalation?.enabled, false),
133
+ max_attempts: Number.isInteger(raw.escalation?.max_attempts) && raw.escalation.max_attempts > 0
134
+ ? Math.min(raw.escalation.max_attempts, 5)
135
+ : 2,
136
+ },
137
+ };
138
+ }
139
+
140
+ function deriveCollaboration(raw) {
141
+ if (COLLABORATION_MODES.includes(raw.collaboration_mode)) return raw.collaboration_mode;
142
+ if (raw.tier_policy === 'flash-only') return 'flash-only';
143
+ if (raw.tier_policy === 'pro-only') return 'pro-only';
144
+ return 'balanced';
145
+ }
146
+
147
+ function deriveTierState(raw, tier) {
148
+ if (TIER_STATES.includes(raw[`${tier}_state`])) return raw[`${tier}_state`];
149
+ if (raw.tier_policy === 'flash-only') return tier === 'flash' ? 'auto' : 'disabled';
150
+ if (raw.tier_policy === 'pro-only') return tier === 'pro' ? 'auto' : 'disabled';
151
+ return 'auto';
152
+ }
153
+
154
+ function migrationWorkerState(collab, flashState, proState) {
155
+ if (collab === 'pro-only') return proState === 'disabled' ? 'disabled' : 'auto';
156
+ if (collab === 'flash-only') return flashState === 'disabled' ? 'disabled' : 'auto';
157
+ // balanced / review-pipeline / custom: worker is the default coding entry.
158
+ if (flashState === 'disabled' && proState === 'disabled') return 'disabled';
159
+ if (flashState === 'manual' && proState === 'manual') return 'manual';
160
+ if (flashState === 'manual') return 'manual';
161
+ return 'auto';
162
+ }
163
+
164
+ function migrationReviewState(collab, proState, autoReview) {
165
+ if (collab === 'review-pipeline') return 'auto';
166
+ if (collab === 'pro-only' || collab === 'flash-only') return 'disabled';
167
+ if (autoReview) return proState === 'disabled' ? 'disabled' : 'auto';
168
+ // balanced / custom: the reviewer is available on request while the strong
169
+ // (pro) model class is an Auto tier; otherwise it stays disabled.
170
+ return proState === 'auto' ? 'manual' : 'disabled';
171
+ }
172
+
173
+ /**
174
+ * Centralized v0.1 → v0.2 config migration. Pure and single-source: converts
175
+ * any legacy (or partially canonical) config into the canonical worker /
176
+ * reviewer / execution shape the role helpers consume. Never reads
177
+ * credentials, never touches provider selection directly, never writes.
178
+ */
179
+ export function migrateLegacyConfig(raw = {}) {
180
+ const collab = deriveCollaboration(raw);
181
+ const flashState = deriveTierState(raw, 'flash');
182
+ const proState = deriveTierState(raw, 'pro');
183
+ const escalationEnabled = normalizeBool(raw.escalate_on_failure, false)
184
+ || normalizeBool(raw.worker?.model_policy?.escalation?.enabled, false);
185
+ // Explicit v0.2 auto-review switch wins over the legacy opt-in / pipeline.
186
+ const autoReview = raw.auto_review !== undefined
187
+ ? normalizeBool(raw.auto_review)
188
+ : collab === 'review-pipeline'
189
+ || normalizeBool(raw.pro_reviews_flash, false)
190
+ || normalizeBool(raw.review?.auto_review, false);
191
+
192
+ const worker = {
193
+ state: normalizeRoleState(raw.worker_state ?? migrationWorkerState(collab, flashState, proState)),
194
+ provider_mode: normalizeWorkerProviderMode(raw.worker_provider_mode ?? raw.worker?.provider_mode),
195
+ model_policy: normalizeModelPolicy({
196
+ role: 'worker',
197
+ strategy: collab === 'pro-only' ? 'quality' : collab === 'flash-only' ? 'economy' : 'balanced',
198
+ priority: raw.flash_model_priority ?? raw.worker?.model_policy?.priority,
199
+ priorityConfigured: raw.flash_model_priority_configured === true || raw.worker?.model_policy?.priorityConfigured === true,
200
+ escalation_priority: raw.pro_model_priority ?? raw.worker?.model_policy?.escalation_priority,
201
+ escalation_priority_configured: raw.pro_model_priority_configured === true || raw.worker?.model_policy?.escalation_priority_configured === true,
202
+ fallback: 'harness-default',
203
+ escalation: {
204
+ enabled: escalationEnabled,
205
+ max_attempts: raw.worker?.model_policy?.escalation?.max_attempts ?? 2,
206
+ },
207
+ }),
208
+ };
209
+
210
+ const review = {
211
+ state: normalizeRoleState(raw.review_state ?? migrationReviewState(collab, proState, autoReview)),
212
+ mode: 'auto',
213
+ auto_review: autoReview,
214
+ provider_mode: normalizeWorkerProviderMode(raw.worker_provider_mode ?? raw.review?.provider_mode),
215
+ model_policy: normalizeModelPolicy({
216
+ role: 'reviewer',
217
+ strategy: 'strong',
218
+ priority: raw.pro_model_priority ?? raw.review?.model_policy?.priority,
219
+ priorityConfigured: raw.pro_model_priority_configured === true || raw.review?.model_policy?.priorityConfigured === true,
220
+ fallback: 'harness-default',
221
+ }),
222
+ };
223
+
224
+ return {
225
+ subagents_enabled: normalizeEnabled(raw.subagents_enabled),
226
+ main_agent_mode: MAIN_AGENT_MODES.includes(raw.main_agent_mode) ? raw.main_agent_mode : 'coordinator-first',
227
+ execution: {
228
+ enabled: normalizeEnabled(raw.subagents_enabled),
229
+ default_effort: ['off', 'high', 'max'].includes(raw.default_effort) ? raw.default_effort : 'max',
230
+ default_timeout_seconds: Number.isInteger(raw.default_timeout_seconds) && raw.default_timeout_seconds > 0
231
+ ? raw.default_timeout_seconds : 1800,
232
+ mode: ['auto', 'hub', 'standalone'].includes(raw.mode) ? raw.mode : 'auto',
233
+ max_parallel: normalizeMaxParallel(raw.execution?.max_parallel ?? raw.max_parallel),
234
+ isolation: normalizeIsolation(raw.execution?.isolation ?? raw.isolation),
235
+ },
236
+ worker,
237
+ review,
238
+ // The legacy view is retained so config rounds-trips and old fields never
239
+ // drop silently; it is explicitly marked legacy.
240
+ legacy: {
241
+ collaboration_mode: collab,
242
+ flash_state: flashState,
243
+ pro_state: proState,
244
+ tier_policy: raw.tier_policy,
245
+ },
246
+ };
247
+ }
248
+
249
+ /** Canonical view of any config (legacy-normalized or already canonical). */
250
+ export function getCanonical(config = {}) {
251
+ if (config?.worker && config?.review) {
252
+ return {
253
+ subagents_enabled: normalizeEnabled(config.subagents_enabled),
254
+ main_agent_mode: MAIN_AGENT_MODES.includes(config.main_agent_mode) ? config.main_agent_mode : 'coordinator-first',
255
+ execution: {
256
+ enabled: normalizeEnabled(config.execution?.enabled ?? config.subagents_enabled),
257
+ default_effort: ['off', 'high', 'max'].includes(config.execution?.default_effort) ? config.execution.default_effort : 'max',
258
+ default_timeout_seconds: Number.isInteger(config.execution?.default_timeout_seconds) && config.execution.default_timeout_seconds > 0
259
+ ? config.execution.default_timeout_seconds : 1800,
260
+ mode: ['auto', 'hub', 'standalone'].includes(config.execution?.mode) ? config.execution.mode : 'auto',
261
+ max_parallel: normalizeMaxParallel(config.execution?.max_parallel),
262
+ isolation: normalizeIsolation(config.execution?.isolation),
263
+ },
264
+ worker: { ...config.worker, state: normalizeRoleState(config.worker.state), model_policy: normalizeModelPolicy(config.worker.model_policy) },
265
+ review: { ...config.review, state: normalizeRoleState(config.review.state), model_policy: normalizeModelPolicy(config.review.model_policy) },
266
+ };
267
+ }
268
+ return migrateLegacyConfig(config);
269
+ }
270
+
271
+ // ---------- v0.2 role helpers ----------
272
+
273
+ /**
274
+ * Effective state of a dispatch role. Session `${role}_state` overrides the
275
+ * canonical role state; absent canonical config falls back to the legacy
276
+ * migration (so a stock v0.1 config drives the same gate it always did).
277
+ */
278
+ export function getRoleState(config = {}, role = 'worker', session = {}) {
279
+ const override = session[`${role}_state`];
280
+ if (override === 'disabled' || override === 'manual' || override === 'auto') return override;
281
+ const canon = getCanonical(config);
282
+ if (role === 'reviewer') return canon.review.state ?? 'auto';
283
+ return canon.worker.state ?? 'auto';
284
+ }
285
+
286
+ export function isRoleEnabled(config = {}, role = 'worker', session = {}) {
287
+ return getRoleState(config, role, session) !== 'disabled';
288
+ }
289
+
290
+ export function isRoleAutoEligible(config = {}, role = 'worker', session = {}) {
291
+ return getRoleState(config, role, session) === 'auto';
292
+ }
293
+
294
+ /**
295
+ * Deterministic dispatch decision for one role. A disabled role refuses every
296
+ * request; a manual role runs only when explicitly requested; an auto role is
297
+ * callable automatically and on request.
298
+ */
299
+ export function canDispatchRole(config = {}, role = 'worker', explicitRequest = false, session = {}) {
300
+ if (session.enabled === false || config.subagents_enabled === false) {
301
+ return { ok: false, error: policyError(POLICY_ERROR_CODES.SUBAGENTS_DISABLED) };
302
+ }
303
+ const state = getRoleState(config, role, session);
304
+ if (state === 'disabled') return { ok: false, error: policyError(POLICY_ERROR_CODES.ROLE_DISABLED, { tier: role }) };
305
+ if (state === 'manual' && !explicitRequest) {
306
+ if (role === 'reviewer') return { ok: false, error: policyError(POLICY_ERROR_CODES.ROLE_NOT_AUTO, { tier: role }) };
307
+ return { ok: false, error: policyError(POLICY_ERROR_CODES.NO_AUTO_TIER) };
308
+ }
309
+ return { ok: true, role, guidance: explicitRequest ? 'explicit request' : 'auto role' };
310
+ }
311
+
312
+ /**
313
+ * Default role for a coding (or generic) request. The worker is the default
314
+ * coding role; the reviewer is only reachable through an explicit request or
315
+ * the review workflow. Mirrors the v0.1 chooseDefaultTier gate.
316
+ */
317
+ export function chooseRole(config = {}, requestedRole, session = {}) {
318
+ if (requestedRole === 'worker' || requestedRole === 'reviewer') {
319
+ return canDispatchRole(config, requestedRole, true, session);
320
+ }
321
+ return canDispatchRole(config, 'worker', false, session);
322
+ }
323
+
324
+ /**
325
+ * Bridge a role + legacy tier pair. reviewer pairs with no tier or tier=pro
326
+ * (the strong benchmark slot); worker pairs with any tier (tier becomes a
327
+ * model-class hint). A reviewer+flash pair is a hard conflict, never guessed.
328
+ */
329
+ export function resolveRoleTierHint(role, legacyTier) {
330
+ const wantsReviewer = role === 'reviewer';
331
+ const wantsWorker = role === undefined || role === 'worker';
332
+ if (wantsWorker && (legacyTier === undefined || legacyTier === 'flash' || legacyTier === 'pro')) {
333
+ return { ok: true, role: 'worker', tier: legacyTier ?? 'flash' };
334
+ }
335
+ if (wantsReviewer && (legacyTier === undefined || legacyTier === 'pro')) {
336
+ return { ok: true, role: 'reviewer', tier: 'pro' };
337
+ }
338
+ return {
339
+ ok: false,
340
+ code: POLICY_ERROR_CODES.ROLE_TIER_CONFLICT,
341
+ error: POLICY_ERROR_MESSAGES[POLICY_ERROR_CODES.ROLE_TIER_CONFLICT]
342
+ .replace('{role}', role === 'reviewer' ? 'reviewer' : 'worker')
343
+ .replace('{tier}', legacyTier ?? '(none)'),
344
+ };
345
+ }
346
+
347
+ /** Model policy for a role (canonical config or derived from legacy). */
348
+ export function resolveModelPolicy(config = {}, role = 'worker', context = {}) {
349
+ const canon = getCanonical(config);
350
+ const policy = role === 'reviewer' ? canon.review.model_policy : canon.worker.model_policy;
351
+ return {
352
+ within: role,
353
+ role,
354
+ ...policy,
355
+ attempt: Number.isInteger(context?.attempt) ? context.attempt : 0,
356
+ };
357
+ }
358
+
359
+ /** Should a successful worker run be followed by one automatic reviewer pass? */
360
+ export function shouldAutoReview(config = {}, session = {}) {
361
+ const sessionOverride = session.auto_review;
362
+ if (sessionOverride === true || sessionOverride === false) return sessionOverride;
363
+ if (getRoleState(config, 'reviewer', session) !== 'auto') return false;
364
+ const canon = getCanonical(config);
365
+ return canon.review.auto_review === true;
366
+ }
367
+
368
+ /**
369
+ * Decision about whether some evidence warrants another (stronger) worker
370
+ * attempt. PR2 keeps the workflow-visible rules here as a pure function; PR1
371
+ * uses it to centralize the legacy "escalate on failure" boolean so the
372
+ * blocking path and future async path share one rule.
373
+ */
374
+ export function evaluateAttempt({
375
+ execution = 'completed',
376
+ taskStatus = 'success',
377
+ testsStatus,
378
+ deliveryComplete = true,
379
+ workspaceEvidenceOK = true,
380
+ policy = {},
381
+ attempt = 0,
382
+ } = {}) {
383
+ const maxAttempts = policy?.escalation?.max_attempts ?? 2;
384
+ const canEscalate = policy?.escalation?.enabled === true;
385
+ // Clean verified path short-circuits: there is nothing to escalate.
386
+ const clean = execution !== 'failed'
387
+ && (taskStatus === 'success' || taskStatus === undefined)
388
+ && testsStatus !== 'FAIL'
389
+ && deliveryComplete === true
390
+ && workspaceEvidenceOK !== false;
391
+ if (clean) return { decision: 'accept', reason: 'verified', escalate: false };
392
+ // Something needs attention. Escalation-disabled and max-attempts only stop
393
+ // an up-grade, never turn a clean run into a failure.
394
+ let reason;
395
+ if (execution === 'failed') reason = 'execution_failed';
396
+ else if (taskStatus === 'blocked') reason = 'task_blocked';
397
+ else if (testsStatus === 'FAIL') reason = 'tests_failed';
398
+ else if (deliveryComplete === false) reason = 'delivery_incomplete';
399
+ else if (workspaceEvidenceOK === false) reason = 'workspace_mismatch';
400
+ else if (taskStatus === 'partial') reason = 'task_partial';
401
+ else reason = 'unverified';
402
+ if (!canEscalate) return { decision: 'fail', reason: 'escalation_disabled', escalate: false };
403
+ // max_attempts is the TOTAL number of attempts (0..max-1); escalation only
404
+ // proceeds while there is room for one more attempt.
405
+ if (attempt + 1 >= maxAttempts) return { decision: 'fail', reason: 'max_attempts_reached', escalate: false };
406
+ return { decision: 'escalate', reason, escalate: true };
407
+ }
408
+
409
+ // ---------- worker provider routing ----------
410
+
411
+ /**
412
+ * Worker provider modes for Hub workers:
413
+ * - follow-dsh: use whatever provider is selected in DSH Models for the
414
+ * current session; the tier still maps to the DeepSeek V4 model slot.
415
+ * - deepseek-official: always use the built-in deepseek-official provider
416
+ * (the legacy behavior; also the safe default for upgraded configs).
417
+ */
418
+ export const WORKER_PROVIDER_MODES = ['follow-dsh', 'deepseek-official'];
419
+ export const DEFAULT_WORKER_PROVIDER_MODE = 'deepseek-official';
420
+
421
+ export function normalizeWorkerProviderMode(raw) {
422
+ return WORKER_PROVIDER_MODES.includes(raw) ? raw : DEFAULT_WORKER_PROVIDER_MODE;
423
+ }
424
+
425
+ /**
426
+ * Resolve the provider for a Hub worker. Pure: `getCurrentSelection` (the DSH
427
+ * agentDefaultModel accessor) is injected so tests can stub it, and no
428
+ * credential ever flows through here.
429
+ */
430
+ export function resolveHubWorkerProvider({ worker_provider_mode, getCurrentSelection }) {
431
+ const mode = normalizeWorkerProviderMode(worker_provider_mode);
432
+ if (mode === 'deepseek-official') return { ok: true, provider: 'deepseek-official', mode };
433
+ const selection = typeof getCurrentSelection === 'function' ? getCurrentSelection() : undefined;
434
+ const provider = selection?.provider;
435
+ if (!provider) {
436
+ return {
437
+ ok: false,
438
+ code: POLICY_ERROR_CODES.NO_DSH_PROVIDER_SELECTED,
439
+ error: POLICY_ERROR_MESSAGES[POLICY_ERROR_CODES.NO_DSH_PROVIDER_SELECTED],
440
+ mode,
441
+ };
442
+ }
443
+ return { ok: true, provider, mode };
444
+ }
445
+
446
+ /** Structured policy error; `code` is machine-readable, `message` user-facing. */
447
+ export function policyError(code, extra = {}) {
448
+ const base = POLICY_ERROR_MESSAGES[code] ?? code;
449
+ return Object.assign(new Error(base.replace('{tier}', extra.tier ?? 'pro')), { policyCode: code });
450
+ }
451
+
452
+ // ---------- normalization ----------
453
+
454
+ function pickString(raw, fallback) {
455
+ return typeof raw === 'string' && raw !== '' ? raw : fallback;
456
+ }
457
+
458
+ function normalizeState(raw) {
459
+ const v = pickString(raw, 'auto');
460
+ return TIER_STATES.includes(v) ? v : 'auto';
461
+ }
462
+
463
+ function normalizeRoles(raw, fallback) {
464
+ if (!Array.isArray(raw)) return [...fallback];
465
+ const seen = new Set();
466
+ const out = [];
467
+ for (const r of raw) if (typeof r === 'string' && ROLE_IDS.includes(r) && !seen.has(r)) {
468
+ seen.add(r);
469
+ out.push(r);
470
+ }
471
+ // Unknown values are dropped silently (stable order preserved); an empty
472
+ // result falls back to the tier's defaults so a worker never loses all roles.
473
+ return out.length > 0 ? out : [...fallback];
474
+ }
475
+
476
+ function normalizeEnabled(raw) {
477
+ return raw === undefined || raw === null ? true : Boolean(raw);
478
+ }
479
+
480
+ function normalizeBool(raw, fallback = false) {
481
+ return raw === undefined || raw === null ? fallback : Boolean(raw);
482
+ }
483
+
484
+ /**
485
+ * Normalize a raw (possibly legacy or partial) global config into the new
486
+ * schema. Pure: returns a new object, never mutates or writes anything.
487
+ * Unknown fields are carried through untouched for forward compatibility.
488
+ */
489
+ export function normalizeGlobalConfig(raw = {}) {
490
+ const has = (k) => raw[k] !== undefined;
491
+
492
+ // Legacy migration: collaboration_mode derives from tier_policy when absent.
493
+ let collaborationMode = raw.collaboration_mode;
494
+ if (!COLLABORATION_MODES.includes(collaborationMode)) {
495
+ const tp = raw.tier_policy;
496
+ collaborationMode = tp === 'flash-only' ? 'flash-only' : tp === 'pro-only' ? 'pro-only' : 'balanced';
497
+ }
498
+
499
+ // Legacy migration: tier states derive from tier_policy when absent.
500
+ let flashState = has('flash_state') ? normalizeState(raw.flash_state) : undefined;
501
+ let proState = has('pro_state') ? normalizeState(raw.pro_state) : undefined;
502
+ if (flashState === undefined || proState === undefined) {
503
+ const tp = raw.tier_policy;
504
+ if (tp === 'flash-only') { flashState = flashState ?? 'auto'; proState = proState ?? 'disabled'; }
505
+ else if (tp === 'pro-only') { flashState = flashState ?? 'disabled'; proState = proState ?? 'auto'; }
506
+ else { flashState = flashState ?? 'auto'; proState = proState ?? 'auto'; }
507
+ }
508
+
509
+ // Capability switches: an old config with provider=off must not flip the
510
+ // capability back on; anything else keeps the capability enabled.
511
+ const visionEnabled = has('vision_enabled')
512
+ ? normalizeEnabled(raw.vision_enabled)
513
+ : raw.vision_provider !== 'off';
514
+ const imagegenEnabled = has('imagegen_enabled')
515
+ ? normalizeEnabled(raw.imagegen_enabled)
516
+ : raw.imagegen_provider !== 'off';
517
+
518
+ const normalized = {
519
+ ...raw,
520
+ subagents_enabled: normalizeEnabled(raw.subagents_enabled),
521
+ collaboration_mode: collaborationMode,
522
+ main_agent_mode: MAIN_AGENT_MODES.includes(raw.main_agent_mode) ? raw.main_agent_mode : 'coordinator-first',
523
+ flash_state: flashState,
524
+ pro_state: proState,
525
+ flash_roles: normalizeRoles(raw.flash_roles, DEFAULT_FLASH_ROLES),
526
+ pro_roles: normalizeRoles(raw.pro_roles, DEFAULT_PRO_ROLES),
527
+ pro_reviews_flash: normalizeBool(raw.pro_reviews_flash, false),
528
+ // Worker provider routing: legacy-friendly default keeps deepseek-official
529
+ // unless the user explicitly chooses follow-dsh (never silently switches).
530
+ worker_provider_mode: normalizeWorkerProviderMode(raw.worker_provider_mode),
531
+ flash_model_priority: normalizeModelPriority(raw.flash_model_priority),
532
+ flash_model_priority_configured: raw.flash_model_priority_configured === true || normalizeModelPriority(raw.flash_model_priority).length > 0,
533
+ flash_model_fallback: raw.flash_model_fallback === 'harness-default' ? raw.flash_model_fallback : 'harness-default',
534
+ pro_model_priority: normalizeModelPriority(raw.pro_model_priority),
535
+ pro_model_priority_configured: raw.pro_model_priority_configured === true || normalizeModelPriority(raw.pro_model_priority).length > 0,
536
+ pro_model_fallback: raw.pro_model_fallback === 'harness-default' ? raw.pro_model_fallback : 'harness-default',
537
+ vision_enabled: visionEnabled,
538
+ imagegen_enabled: imagegenEnabled,
539
+ };
540
+ // This normalized legacy view is the single input to the v0.2 canonical
541
+ // migration, so every consumer sees the same worker/reviewer model policy
542
+ // the role helpers compute from any raw config.
543
+ const canonical = migrateLegacyConfig(normalized);
544
+ return { ...normalized, execution: canonical.execution, worker: canonical.worker, review: canonical.review };
545
+ }
546
+
547
+ /** Legacy view of a normalized config: keeps tier_policy-shaped consumers working. */
548
+ export function deriveLegacyConfig(config) {
549
+ const c = config;
550
+ const tierPolicy = c.tier_policy !== undefined
551
+ ? c.tier_policy
552
+ : (c.collaboration_mode === 'flash-only'
553
+ ? 'flash-only'
554
+ : c.collaboration_mode === 'pro-only' ? 'pro-only' : 'auto');
555
+ return {
556
+ default_tier: c.default_tier ?? 'flash',
557
+ default_effort: c.default_effort ?? 'max',
558
+ mode: c.mode ?? 'auto',
559
+ default_timeout_seconds: c.default_timeout_seconds ?? 1800,
560
+ tier_policy: tierPolicy,
561
+ escalate_on_failure: normalizeBool(c.escalate_on_failure),
562
+ preset_flash: c.preset_flash ?? 'default',
563
+ preset_pro: c.preset_pro ?? 'default',
564
+ };
565
+ }
566
+
567
+ // ---------- preset resolution ----------
568
+
569
+ /**
570
+ * Effective per-tier state for a collaboration preset. Presets own both
571
+ * states; the custom mode defers to the configured flash_state / pro_state.
572
+ */
573
+ export function resolveCollaborationPreset(config) {
574
+ const mode = config.collaboration_mode ?? 'balanced';
575
+ if (mode === 'flash-only') return { flash: 'auto', pro: 'disabled' };
576
+ if (mode === 'pro-only') return { flash: 'disabled', pro: 'auto' };
577
+ if (mode === 'review-pipeline') return { flash: 'auto', pro: 'auto' };
578
+ if (mode === 'custom') {
579
+ return { flash: normalizeState(config.flash_state), pro: normalizeState(config.pro_state) };
580
+ }
581
+ return { flash: 'auto', pro: 'auto' }; // balanced
582
+ }
583
+
584
+ // ---------- effective policy ----------
585
+
586
+ /**
587
+ * Effective tier state: session tier_policy (hard clamp) > session state
588
+ * overrides > global collaboration preset / custom states.
589
+ */
590
+ export function getEffectiveTierState(config, tier, session = {}) {
591
+ const tp = session.tier_policy;
592
+ if (tp === 'flash-only') return tier === 'flash' ? 'auto' : 'disabled';
593
+ if (tp === 'pro-only') return tier === 'pro' ? 'auto' : 'disabled';
594
+ const effectiveConfig = session.collaboration_mode
595
+ ? { ...config, collaboration_mode: session.collaboration_mode }
596
+ : config;
597
+ const preset = resolveCollaborationPreset(effectiveConfig);
598
+ const state = tier === 'flash' ? preset.flash : preset.pro;
599
+ const override = session[`${tier}_state`];
600
+ if (override === 'disabled' || override === 'manual' || override === 'auto') return override;
601
+ return state;
602
+ }
603
+
604
+ export function isTierEnabled(config, tier, session = {}) {
605
+ return getEffectiveTierState(config, tier, session) !== 'disabled';
606
+ }
607
+
608
+ /** Manual tiers are callable when explicitly requested, never chosen automatically. */
609
+ export function isTierAutoEligible(config, tier, session = {}) {
610
+ return getEffectiveTierState(config, tier, session) === 'auto';
611
+ }
612
+
613
+ /**
614
+ * Deterministic dispatch decision for one request.
615
+ *
616
+ * Priority order (highest first):
617
+ * 1. session enabled=false
618
+ * 2. global subagents_enabled=false
619
+ * 3. session tier_policy hard clamp
620
+ * 4. collaboration preset / custom states
621
+ * 5. explicit requestedTier (manual tiers are callable when named)
622
+ * 6. default_tier (only when its tier is auto-eligible)
623
+ * 7. the single auto-eligible tier, if exactly one exists
624
+ * 8. otherwise: no tier available
625
+ *
626
+ * Returns { ok: true, tier, guidance } or { ok: false, error }.
627
+ */
628
+ export function chooseDefaultTier(config, requestedTier, session = {}) {
629
+ if (session.enabled === false || config.subagents_enabled === false) {
630
+ return { ok: false, error: policyError(POLICY_ERROR_CODES.SUBAGENTS_DISABLED) };
631
+ }
632
+ const flash = getEffectiveTierState(config, 'flash', session);
633
+ const pro = getEffectiveTierState(config, 'pro', session);
634
+
635
+ const tierUsable = (t) => (t === 'flash' ? flash : pro) !== 'disabled';
636
+ const tierAuto = (t) => (t === 'flash' ? flash : pro) === 'auto';
637
+
638
+ // 5. explicit request: manual tiers are callable, disabled tiers are not.
639
+ if (requestedTier === 'flash' || requestedTier === 'pro') {
640
+ if (!tierUsable(requestedTier)) {
641
+ return { ok: false, error: policyError(POLICY_ERROR_CODES.TIER_DISABLED, { tier: requestedTier }) };
642
+ }
643
+ return { ok: true, tier: requestedTier, guidance: 'explicit request' };
644
+ }
645
+
646
+ const autoTiers = ['flash', 'pro'].filter(tierAuto);
647
+ // 6. default_tier, but never as an automatic choice for a manual tier.
648
+ const def = config.default_tier;
649
+ if (def === 'flash' || def === 'pro') {
650
+ if (tierAuto(def)) return { ok: true, tier: def, guidance: 'session/global default' };
651
+ // A disabled default tier is skipped, not auto-failed: fall through so a
652
+ // configured alternate tier can still serve.
653
+ }
654
+ // 7. the only auto tier available.
655
+ if (autoTiers.length === 1) return { ok: true, tier: autoTiers[0], guidance: 'only auto tier' };
656
+ // Manual-only setup: name one explicitly, or fix the configuration.
657
+ if (autoTiers.length === 0) {
658
+ if (tierUsable('flash') || tierUsable('pro')) {
659
+ return { ok: false, error: policyError(POLICY_ERROR_CODES.NO_AUTO_TIER) };
660
+ }
661
+ return { ok: false, error: policyError(POLICY_ERROR_CODES.NO_WORKER_TIER) };
662
+ }
663
+ // Balanced with both auto and no usable default: pick flash (cheapest).
664
+ if (tierAuto('flash')) return { ok: true, tier: 'flash', guidance: 'first auto tier' };
665
+ return { ok: true, tier: 'pro', guidance: 'first auto tier' };
666
+ }
667
+
668
+ /** May a failed flash blocking job escalate to pro? Pro must be auto. */
669
+ export function canEscalateFlashToPro(config, session = {}) {
670
+ if (session.escalate_on_failure !== undefined && !session.escalate_on_failure) return false;
671
+ const escalate = session.escalate_on_failure ?? normalizeBool(config.escalate_on_failure);
672
+ if (!escalate) return false;
673
+ const tp = session.tier_policy;
674
+ if (tp === 'flash-only') return false;
675
+ return getEffectiveTierState(config, 'pro', session) === 'auto';
676
+ }
677
+
678
+ /** Should a successful flash job be followed by one automatic pro review? */
679
+ export function shouldRunProReview(config, session = {}) {
680
+ const mode = session.collaboration_mode ?? config.collaboration_mode ?? 'balanced';
681
+ const optedIn = session.pro_reviews_flash ?? config.pro_reviews_flash;
682
+ const proState = getEffectiveTierState(config, 'pro', session);
683
+ if (proState !== 'auto') return false;
684
+ if (session.tier_policy === 'flash-only') return false;
685
+ if (mode === 'review-pipeline') return true;
686
+ // Balanced / custom: only when explicitly opted in via pro_reviews_flash.
687
+ return normalizeBool(optedIn);
688
+ }
689
+
690
+ /** Roles for a tier (host guidance for who does what, not a hard classifier). */
691
+ export function getTierRoles(config, tier) {
692
+ const roles = tier === 'flash' ? config.flash_roles : config.pro_roles;
693
+ return normalizeRoles(roles, tier === 'flash' ? DEFAULT_FLASH_ROLES : DEFAULT_PRO_ROLES);
694
+ }
695
+
696
+ // ---------- routing guidance ----------
697
+
698
+ const MAIN_MODE_GUIDANCE = {
699
+ 'direct-allowed':
700
+ 'Host agent may implement directly or delegate. Use enabled DSH workers when helpful. No preference to delegate everything.',
701
+ 'coordinator-first':
702
+ 'Prefer: (1) understand the goal, (2) decompose the task, (3) delegate suitable coding work to enabled Auto workers, (4) inspect results, (5) verify, (6) integrate. Direct implementation remains allowed for tiny changes, recovery, unavailable tiers, or tasks better handled by the host.',
703
+ 'dispatcher-only':
704
+ 'Prefer: (1) planning, (2) dispatch, (3) supervision, (4) review, (5) final integration. Delegate implementation whenever practical to enabled workers. This is routing guidance, not a hard restriction on host tools.',
705
+ };
706
+
707
+ const COLLABORATION_GUIDANCE = {
708
+ 'flash-only': 'Flash is the only Auto tier. Dispatch implementation, tests, simple fixes and search to flash; Pro is unavailable and must not be used.',
709
+ 'pro-only': 'Pro is the only Auto tier. Dispatch implementation, analysis, debugging, refactor and review to pro; Flash is unavailable and must not be used.',
710
+ balanced:
711
+ 'Flash (Auto): mechanical/simple changes, implementation, tests, search/inspection, straightforward fixes. Pro (Auto): architecture, complex debugging, multi-file refactor, difficult reasoning, code review. An explicit tier request wins unless that tier is disabled.',
712
+ 'review-pipeline':
713
+ 'Implementation goes to Flash (Auto); after a successful Flash run, one Pro (Auto) review may run automatically. Pro review is read-only guidance and must not edit files unless the user asks.',
714
+ custom:
715
+ 'Custom policy: dispatch only to Auto tiers; Manual tiers are callable only when the user explicitly names the tier; Disabled tiers must not be used.',
716
+ };
717
+
718
+ function stateLine(config, tier, session) {
719
+ const state = getEffectiveTierState(config, tier, session);
720
+ const label = tier === 'flash' ? 'Flash' : 'Pro';
721
+ const roles = getTierRoles(config, tier).join(', ');
722
+ if (state === 'disabled') return `${label}: Disabled (not available).`;
723
+ const roleNote = state === 'auto' ? ` Typical work: ${roles}.` : ` Suitable for: ${roles}.`;
724
+ return `${label}: ${state === 'auto' ? 'Auto' : 'Manual'} — ${state === 'auto' ? 'the orchestrator may delegate to it automatically' : 'use only when the user explicitly requests this tier or picks the ds-' + tier + ' subagent'}.${roleNote}`;
725
+ }
726
+
727
+ /**
728
+ * Short, stable policy text for the orchestrator. Returned by dsh_worker_config
729
+ * and referenced by the ds-flash / ds-pro agent descriptions. Describes the
730
+ * hard parts (disabled tiers) and the soft parts (main agent mode, roles)
731
+ * without pretending the host's own tools are restricted.
732
+ */
733
+ export function getRoutingGuidance(config, session = {}) {
734
+ const mode = session.collaboration_mode ?? config.collaboration_mode ?? 'balanced';
735
+ const requestedMainMode = session.main_agent_mode ?? config.main_agent_mode;
736
+ const mainMode = MAIN_AGENT_MODES.includes(requestedMainMode) ? requestedMainMode : 'coordinator-first';
737
+ const parts = [];
738
+ if (session.enabled === false || config.subagents_enabled === false) {
739
+ parts.push('DSH worker dispatch is DISABLED. Do not call dsh_run_worker / dsh_spawn_worker; report the disablement to the user instead of doing the task yourself.');
740
+ } else {
741
+ parts.push(COLLABORATION_GUIDANCE[mode] ?? COLLABORATION_GUIDANCE.balanced);
742
+ parts.push(stateLine(config, 'flash', session));
743
+ parts.push(stateLine(config, 'pro', session));
744
+ if (canEscalateFlashToPro(config, session)) parts.push('A failed blocking Flash job may automatically escalate to Pro once.');
745
+ if (shouldRunProReview(config, session)) parts.push('A successful Flash implementation may be followed by one automatic Pro review.');
746
+ // v0.2 role view: worker = execution, reviewer = independent review. The
747
+ // legacy tier lines above stay for compatibility with older orchestrators.
748
+ const workerState = getRoleState(config, 'worker', session);
749
+ const reviewState = getRoleState(config, 'reviewer', session);
750
+ parts.push(`Roles: worker=${workerState} (implementation / fixes / tests / search), reviewer=${reviewState} (independent review). Default coding role is worker; the reviewer joins via explicit request or the automatic review workflow.`);
751
+ if (shouldAutoReview(config, session)) parts.push('A successful worker run is followed by one automatic reviewer pass (read-only).');
752
+ }
753
+ parts.push(`Main agent mode (host guidance only — does not restrict host tools): ${mainMode}. ${MAIN_MODE_GUIDANCE[mainMode]}`);
754
+ parts.push('After a worker returns, check its delivery metadata (delivery_complete / delivery_missing / delivery.tests_status) and redacted workspace diff (workspace_diff_available). delivery.complete=true does not mean the task succeeded: tests_status=FAIL requires another fix or an explicit failure report, and tests_status=NOT RUN requires disclosure of the unverified work. If the Delivery Report is missing or files changed outside scope, do not accept the result as final — request a follow-up worker run.');
755
+ return parts.join(' ');
756
+ }
757
+
758
+ // ---------- validation ----------
759
+
760
+ /** Validation result: { ok, errors, warnings, config } — never throws. */
761
+ export function validateConfig(raw) {
762
+ const errors = [];
763
+ const warnings = [];
764
+ const config = normalizeGlobalConfig(raw);
765
+
766
+ if (config.collaboration_mode === 'review-pipeline') {
767
+ if (config.flash_state === 'disabled' || config.pro_state === 'disabled') {
768
+ errors.push('review-pipeline requires both Flash and Pro to be usable; switch to custom or enable both tiers');
769
+ warnings.push('review-pipeline degraded: an Auto tier is missing');
770
+ }
771
+ }
772
+ if (config.collaboration_mode === 'review-pipeline' && config.subagents_enabled === false) {
773
+ warnings.push('subagents_enabled=false disables the review pipeline too');
774
+ }
775
+ return { ok: errors.length === 0, errors, warnings, config };
776
+ }
777
+
778
+ /** Roles validation used by tests and by any future session-role setter. */
779
+ export function validateRoles(raw) {
780
+ const seen = new Set();
781
+ const out = [];
782
+ const dropped = [];
783
+ for (const r of Array.isArray(raw) ? raw : []) {
784
+ if (typeof r !== 'string' || !ROLE_IDS.includes(r)) {
785
+ if (r !== undefined && r !== null) dropped.push(String(r));
786
+ continue;
787
+ }
788
+ if (!seen.has(r)) { seen.add(r); out.push(r); }
789
+ }
790
+ return { roles: out, dropped };
791
+ }
792
+
793
+ // ---------- multimodal capability ----------
794
+
795
+ /** Capability availability after both the switch and the provider are checked. */
796
+ export function getCapabilities(config) {
797
+ const c = config;
798
+ const vision = {
799
+ enabled: normalizeBool(c.vision_enabled, true),
800
+ provider: c.vision_provider ?? 'claude-code',
801
+ providerOff: c.vision_provider === 'off',
802
+ usable: normalizeBool(c.vision_enabled, true) && c.vision_provider !== 'off',
803
+ };
804
+ const imagegen = {
805
+ enabled: normalizeBool(c.imagegen_enabled, true),
806
+ provider: c.imagegen_provider ?? 'codex',
807
+ providerOff: c.imagegen_provider === 'off',
808
+ usable: normalizeBool(c.imagegen_enabled, true) && c.imagegen_provider !== 'off',
809
+ };
810
+ return { vision, imagegen };
811
+ }
812
+
813
+ /**
814
+ * Registration plan for the hub's multimodal bridge: which tools to register
815
+ * and whether the vision route (deepseek-vision adapter + transcription
816
+ * waterfall) should be installed. Tool names are the decision points; the
817
+ * hub applies the plan at plugin boot, so capability switches take effect
818
+ * after a DSH restart.
819
+ */
820
+ export function getMultimodalRegistrationPlan(config) {
821
+ const { vision, imagegen } = getCapabilities(config);
822
+ return {
823
+ tools: {
824
+ describe_image: vision.usable,
825
+ generate_image: imagegen.usable,
826
+ },
827
+ visionRoute: vision.usable,
828
+ requiresRestart: true,
829
+ };
830
+ }