@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.
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.de.md +359 -0
- package/README.es.md +359 -0
- package/README.fr.md +359 -0
- package/README.hi.md +359 -0
- package/README.id.md +359 -0
- package/README.ja.md +359 -0
- package/README.ko.md +359 -0
- package/README.md +360 -0
- package/README.pt.md +359 -0
- package/README.ru.md +359 -0
- package/README.th.md +359 -0
- package/README.tr.md +359 -0
- package/README.vi.md +359 -0
- package/README.zh-TW.md +359 -0
- package/README.zh.md +305 -0
- package/agents/ds-flash.md +26 -0
- package/agents/ds-pro.md +32 -0
- package/agents/ds-reviewer.md +23 -0
- package/agents/ds-worker.md +22 -0
- package/codex/agents/ds-flash.toml +30 -0
- package/codex/agents/ds-pro.toml +31 -0
- package/codex/agents/ds-reviewer.toml +28 -0
- package/codex/agents/ds-worker.toml +28 -0
- package/codex/prompts/dsh-config.md +3 -0
- package/codex/prompts/dsh-status.md +1 -0
- package/commands/config.md +11 -0
- package/commands/off.md +5 -0
- package/commands/on.md +5 -0
- package/commands/status.md +5 -0
- package/cordis.patch.yml +4 -0
- package/docs/images/dsh-crew-host.png +0 -0
- package/docs/images/dsh-crew-jobs.png +0 -0
- package/docs/images/dsh-crew-logo.png +0 -0
- package/docs/images/dsh-crew-overview.png +0 -0
- package/lib/client.js +2765 -0
- package/package.json +125 -0
- package/scripts/build-client.mjs +28 -0
- package/scripts/live-crew-smoke.mjs +39 -0
- package/scripts/live-policy-matrix.mjs +177 -0
- package/scripts/policy-probe.mjs +101 -0
- package/scripts/setup.mjs +294 -0
- package/scripts/smoke-real.mjs +110 -0
- package/scripts/smoke.mjs +78 -0
- package/scripts/verify-installer-fix.mjs +26 -0
- package/src/adaptive-routing.mjs +260 -0
- package/src/client/activation-summary.tsx +64 -0
- package/src/client/entry.tsx +236 -0
- package/src/client/index.tsx +1120 -0
- package/src/config-readiness.mjs +59 -0
- package/src/delivery.mjs +205 -0
- package/src/dsh-cli-runtime.mjs +251 -0
- package/src/failure-classification.mjs +172 -0
- package/src/hub/entry.mjs +98 -0
- package/src/hub/index.mjs +757 -0
- package/src/hub-client.mjs +132 -0
- package/src/hub-compatibility.mjs +49 -0
- package/src/i18n.mjs +19 -0
- package/src/install/cli.mjs +28 -0
- package/src/install/install-legacy.mjs +460 -0
- package/src/install/install.mjs +451 -0
- package/src/jobs.mjs +275 -0
- package/src/mcp-runtime.mjs +257 -0
- package/src/model-catalog.mjs +173 -0
- package/src/model-routing.mjs +391 -0
- package/src/multimodal.mjs +0 -0
- package/src/policy-legacy.mjs +830 -0
- package/src/policy.mjs +197 -0
- package/src/readiness-matrix.mjs +169 -0
- package/src/runtime-controls.mjs +90 -0
- package/src/runtime-identity.mjs +108 -0
- package/src/server.mjs +477 -0
- package/src/status-shard.mjs +52 -0
- package/src/structured-error-code.mjs +39 -0
- package/src/vision-route.mjs +138 -0
- package/src/workflow-runtime.mjs +567 -0
- package/src/workflow.mjs +160 -0
- package/src/workspace-audit.mjs +231 -0
- package/src/workspace-isolation.mjs +306 -0
- package/statusline/statusline.sh +14 -0
- package/statusline/worker-segment.sh +35 -0
- 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
|
+
}
|