@ran-sh/dsh-crew 0.3.8 → 0.4.1

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.
@@ -0,0 +1,107 @@
1
+ // Versioned, narrow Worker/Reviewer profiles. Profiles configure one DSH
2
+ // delegation; they are not general Agent personas and never contain prompts or
3
+ // credentials.
4
+
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+
9
+ export const ROLE_PROFILE_SCHEMA_VERSION = 1;
10
+ export const DEFAULT_ROLE_PROFILES = Object.freeze({
11
+ 'worker-default': Object.freeze({
12
+ role: 'worker', routing: 'auto', isolation: 'worktree', fallback: true,
13
+ timeout_seconds: 1800, review_strictness: 'standard',
14
+ }),
15
+ 'reviewer-default': Object.freeze({
16
+ role: 'reviewer', routing: 'stable', isolation: 'readonly', fallback: false,
17
+ timeout_seconds: 1800, review_strictness: 'strict',
18
+ }),
19
+ });
20
+
21
+ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
22
+ const ROLES = new Set(['worker', 'reviewer']);
23
+ const ROUTING = new Set(['auto', 'priority', 'stable']);
24
+ const ISOLATION = new Set(['worktree', 'readonly', 'shared']);
25
+ const STRICTNESS = new Set(['standard', 'strict']);
26
+
27
+ function normalizeProfile(id, raw) {
28
+ if (!ID.test(id) || !raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
29
+ if (!ROLES.has(raw.role)) return null;
30
+ const base = DEFAULT_ROLE_PROFILES[`${raw.role}-default`];
31
+ const routing = raw.routing ?? base.routing;
32
+ const isolation = raw.isolation ?? base.isolation;
33
+ const reviewStrictness = raw.review_strictness ?? base.review_strictness;
34
+ const timeout = raw.timeout_seconds ?? base.timeout_seconds;
35
+ if (!ROUTING.has(routing) || !ISOLATION.has(isolation) || !STRICTNESS.has(reviewStrictness)) return null;
36
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 7200) return null;
37
+ if (raw.fallback !== undefined && typeof raw.fallback !== 'boolean') return null;
38
+ return {
39
+ role: raw.role,
40
+ routing,
41
+ isolation,
42
+ fallback: raw.fallback ?? base.fallback,
43
+ timeout_seconds: timeout,
44
+ review_strictness: reviewStrictness,
45
+ };
46
+ }
47
+
48
+ export function roleProfilesFile({ home = homedir() } = {}) {
49
+ return join(home, '.config', 'dsh-crew', 'profiles.json');
50
+ }
51
+
52
+ export function loadRoleProfiles({ home = homedir(), file = roleProfilesFile({ home }) } = {}) {
53
+ if (!existsSync(file)) {
54
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: true, source: 'defaults', profiles: { ...DEFAULT_ROLE_PROFILES }, errors: [] };
55
+ }
56
+ let raw;
57
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
58
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: false, source: 'file', profiles: { ...DEFAULT_ROLE_PROFILES }, errors: [{ code: 'PROFILE_FILE_INVALID' }] };
59
+ }
60
+ return parseRoleProfiles(raw);
61
+ }
62
+
63
+ function parseRoleProfiles(raw) {
64
+ const errors = [];
65
+ const profiles = { ...DEFAULT_ROLE_PROFILES };
66
+ if (raw?.schema_version !== ROLE_PROFILE_SCHEMA_VERSION || !raw.profiles || typeof raw.profiles !== 'object' || Array.isArray(raw.profiles)) {
67
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: false, source: 'file', profiles, errors: [{ code: 'PROFILE_FILE_INVALID' }] };
68
+ }
69
+ for (const [id, value] of Object.entries(raw.profiles)) {
70
+ if (id in DEFAULT_ROLE_PROFILES) {
71
+ const normalized = normalizeProfile(id, value);
72
+ if (!normalized || JSON.stringify(normalized) !== JSON.stringify(DEFAULT_ROLE_PROFILES[id])) {
73
+ errors.push({ code: 'PROFILE_DEFAULT_RESERVED', profile_id: id });
74
+ }
75
+ continue;
76
+ }
77
+ const profile = normalizeProfile(id, value);
78
+ if (!profile) errors.push({ code: 'PROFILE_INVALID', profile_id: ID.test(id) ? id : '<invalid>' });
79
+ else profiles[id] = profile;
80
+ }
81
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: errors.length === 0, source: 'file', profiles, errors: errors.slice(0, 32) };
82
+ }
83
+
84
+ export function saveRoleProfiles(document, { home = homedir(), file = roleProfilesFile({ home }) } = {}) {
85
+ const parsed = parseRoleProfiles(document);
86
+ if (!parsed.ok) return parsed;
87
+ const custom = Object.fromEntries(Object.entries(parsed.profiles).filter(([id]) => !(id in DEFAULT_ROLE_PROFILES)));
88
+ const payload = { schema_version: ROLE_PROFILE_SCHEMA_VERSION, profiles: custom };
89
+ mkdirSync(dirname(file), { recursive: true });
90
+ const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
91
+ try {
92
+ writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
93
+ renameSync(temp, file);
94
+ } catch (error) {
95
+ rmSync(temp, { force: true });
96
+ return { ...parsed, ok: false, errors: [{ code: 'PROFILE_FILE_WRITE_FAILED' }], error_code: 'PROFILE_FILE_WRITE_FAILED' };
97
+ }
98
+ return { ...parsed, source: 'file' };
99
+ }
100
+
101
+ export function resolveRoleProfile(registry, profileId, role = 'worker') {
102
+ const id = profileId ?? `${role}-default`;
103
+ const profile = registry?.profiles?.[id];
104
+ if (!profile) return { ok: false, code: 'PROFILE_NOT_FOUND', profile_id: id };
105
+ if (profile.role !== role) return { ok: false, code: 'PROFILE_ROLE_MISMATCH', profile_id: id, expected_role: role };
106
+ return { ok: true, profile_id: id, profile: { ...profile } };
107
+ }
@@ -8,7 +8,7 @@
8
8
  // Keep this module pure and dependency-free so Hub, MCP and tests all use the
9
9
  // exact same compatibility rules.
10
10
 
11
- export const RUNTIME_VERSION = '0.3.8';
11
+ export const RUNTIME_VERSION = '0.4.1';
12
12
  export const HUB_PROTOCOL_VERSION = 1;
13
13
 
14
14
  export const HUB_CAPABILITIES = Object.freeze([
@@ -21,6 +21,11 @@ export const HUB_CAPABILITIES = Object.freeze([
21
21
  'model-catalog',
22
22
  'presets',
23
23
  'config',
24
+ 'canonical-events',
25
+ 'evidence',
26
+ 'profiles',
27
+ 'workspace-context',
28
+ 'extension-contract',
24
29
  ]);
25
30
 
26
31
  // Capabilities the current MCP workflow depends on for full Hub execution.
package/src/server.mjs CHANGED
@@ -6,7 +6,7 @@ import { z } from 'zod';
6
6
  import { startJob, waitJob, cancelJob, listJobs, getJob, jobView } from './jobs.mjs';
7
7
  import { hubStatus, hub } from './hub-client.mjs';
8
8
  import { hubCompatibilityMessage, resolveHubExecutionMode } from './hub-compatibility.mjs';
9
- import { RUNTIME_VERSION } from './runtime-identity.mjs';
9
+ import { RUNTIME_VERSION, getHubRuntimeIdentity } from './runtime-identity.mjs';
10
10
  import { resolveWorkerModel } from './model-routing.mjs';
11
11
  import { runtimeActivationMetadata } from './runtime-controls.mjs';
12
12
  import { buildConfigReadinessMatrix } from './config-readiness.mjs';
@@ -22,12 +22,32 @@ import {
22
22
  shouldAutoReview,
23
23
  } from './policy.mjs';
24
24
  import { buildMcpWorkflowRuntime } from './mcp-runtime.mjs';
25
+ import { buildReviewTask } from './information-flow.mjs';
26
+ import { projectWorkflowView } from './job-contracts.mjs';
27
+ import { loadRoleProfiles, resolveRoleProfile } from './role-profiles.mjs';
28
+ import { loadWorkspaceContexts, resolveWorkspaceContext, buildWorkspaceTask, addContextReferences, isSafeBranchName } from './workspace-context.mjs';
29
+ import { buildExtensionContract } from './extension-contract.mjs';
30
+ import { assessWorkspaceReadiness } from './workspace-readiness.mjs';
25
31
 
26
32
  const server = new McpServer({ name: 'dsh-crew', version: RUNTIME_VERSION });
27
33
 
28
34
  const tierSchema = z.enum(['flash', 'pro']).optional().describe('Legacy worker tier (compatibility only): Flash/Pro now act as a model-class hint, not a role. Prefer role=worker / role=reviewer; the backend resolves the actual provider/model from the Model Policy.');
29
35
  const roleSchema = z.enum(['worker', 'reviewer']).optional().describe('Dispatch role: worker executes implementation / fixes / tests / search; reviewer independently reviews a completed implementation. A coding request defaults to worker; reviewer runs on explicit request or via the automatic review workflow.');
30
36
  const effortSchema = z.enum(['off', 'high', 'max']).optional().describe('Reasoning effort for the worker. Omit to use the session default.');
37
+ const detailSchema = z.enum(['compact', 'full']).default('compact').describe('compact returns the bounded Result Contract; full explicitly includes raw workflow/candidate details for debugging.');
38
+ const profileSchema = z.string().max(64).optional().describe('Versioned Worker/Reviewer profile id; defaults by role.');
39
+ const workspaceIdSchema = z.string().max(64).optional().describe('Workspace Context id from Crew-owned workspaces.json.');
40
+ const contextRefsSchema = z.array(z.string().max(256)).max(32).optional().describe('Additional workspace-relative instruction references; contents are not copied.');
41
+ const clientJobIdSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/).optional().describe('Optional caller id echoed in events and evidence; Crew still assigns its own workflow id.');
42
+ const workspaceSchema = z.object({
43
+ repo_root: z.string().optional(),
44
+ branch: z.string().refine(isSafeBranchName, 'invalid git branch name').optional(),
45
+ worktree: z.enum(['auto', 'existing', 'none']).optional(),
46
+ }).optional().describe('Per-job workspace overrides. auto isolates coding Workers; existing/none use the supplied workspace.');
47
+ const constraintsSchema = z.object({
48
+ timeout_seconds: z.number().int().positive().max(7200).optional(),
49
+ allow_fallback: z.boolean().optional(),
50
+ }).optional().describe('Per-job constraints override profile and session defaults.');
31
51
 
32
52
  // Session-level configuration. This MCP server process lives exactly as long
33
53
  // as one Claude Code / Codex session, so plain memory IS session scope.
@@ -144,29 +164,6 @@ async function resolveMode() {
144
164
  return decision.mode;
145
165
  }
146
166
 
147
- /** Reviewer prompt built only from structured outcome + sanitized candidate. */
148
- function buildReviewTask(task, view) {
149
- const parts = [
150
- 'You are the automatic reviewer of a completed worker implementation. REVIEW ONLY: inspect the candidate and report findings. Do NOT modify any files unless the user explicitly asks for fixes.',
151
- '',
152
- 'Original task:',
153
- task,
154
- ];
155
- const o = view?.outcome;
156
- if (o) {
157
- parts.push('', 'Worker outcome:');
158
- parts.push(`task_status=${o.task_status} tests_status=${o.tests_status ?? 'none'} delivery=${o.delivery?.complete ? 'complete' : 'incomplete'}`);
159
- }
160
- const c = view?.candidate;
161
- if (c) {
162
- parts.push('', 'Candidate changed files:', Array.isArray(c.changed_files) && c.changed_files.length ? c.changed_files.join('\n') : '(none)');
163
- if (c.base_revision) parts.push(`Base revision: ${c.base_revision}`);
164
- if (c.patch) parts.push('', 'Candidate patch (sanitized):', String(c.patch).slice(0, 8000));
165
- }
166
- parts.push('', 'Report: 1) does the implementation satisfy the task, 2) concrete issues (bugs, style, risks), 3) suggested fixes. End your message with ## Review Findings / ## Evidence / ## Risks / ## Verdict (approved | needs changes | rejected).');
167
- return parts.join('\n');
168
- }
169
-
170
167
  const workflowRuntime = buildMcpWorkflowRuntime({
171
168
  getSessionConfig: () => sessionConfig,
172
169
  resolveMode,
@@ -176,61 +173,107 @@ const workflowRuntime = buildMcpWorkflowRuntime({
176
173
  attemptTimeoutMs: () => (sessionConfig.default_timeout_seconds ?? 1800) * 1000,
177
174
  });
178
175
 
179
- server.registerTool('dsh_run_worker', {
180
- title: 'Run DSH worker (blocking)',
181
- description: 'Delegate a task to a DSH (DeepSeek Harness) coding agent and wait for its final result. The worker is a full DSH agent with its own tools and sandbox. Pass role=worker for implementation and role=reviewer for an independent review pass. Disabled roles are refused by the DSH Crew policy; Manual roles only run when explicitly requested. Blocks until the worker finishes.',
182
- inputSchema: {
183
- task: z.string().describe('Full task description for the worker, self-contained'),
184
- role: roleSchema,
185
- tier: tierSchema,
186
- legacy_tier: tierSchema.describe('Legacy model-class hint (flash | pro), forwarded verbatim by deprecated ds-flash/ds-pro aliases; only influences which model class backs a worker role, never the role gate'),
187
- effort: effortSchema,
188
- cwd: z.string().optional().describe('Workspace directory for the worker (defaults to current project)'),
189
- timeout_seconds: z.number().int().positive().max(7200).optional(),
190
- },
191
- }, async ({ task, role, tier, legacy_tier, effort, cwd, timeout_seconds }) => {
192
- if (!sessionConfig.enabled) return dispatchDisabled();
176
+ function dispatchError(code, details = {}) {
177
+ return { ok: false, response: text({ error: details.error ?? code, code, ...details }) };
178
+ }
179
+
180
+ function prepareDispatch({ task, role, tier, legacy_tier, effort, cwd, timeout_seconds, profile, workspace_id, context_refs, job_id, workspace: workspaceOverride, constraints }) {
193
181
  const globalConfig = currentGlobalConfig();
194
- const hint = resolveRoleTierHint(role, legacy_tier ?? tier);
195
- if (!hint.ok) return text({ error: hint.error, code: hint.code, note: 'Resolve the conflict on the caller side: pass only role, or only tier.' });
182
+ const profileRegistry = loadRoleProfiles();
183
+ if (!profileRegistry.ok) return dispatchError('PROFILE_FILE_INVALID', { errors: profileRegistry.errors });
184
+ const profileRole = profile ? profileRegistry.profiles?.[profile]?.role : undefined;
185
+ const requestedRole = role ?? profileRole;
186
+ const hint = resolveRoleTierHint(requestedRole, legacy_tier ?? tier);
187
+ if (!hint.ok) return dispatchError(hint.code, { error: hint.error });
196
188
  const effRole = hint.role;
189
+ const resolvedProfile = resolveRoleProfile(profileRegistry, profile, effRole);
190
+ if (!resolvedProfile.ok) return dispatchError(resolvedProfile.code, resolvedProfile);
191
+
197
192
  let effTier;
198
193
  let decision;
199
- if (role === undefined) {
194
+ if (requestedRole === undefined) {
200
195
  decision = chooseDefaultTier(globalConfig, legacy_tier ?? tier, sessionConfig);
201
- if (!decision.ok) return policyRejection(decision);
196
+ if (!decision.ok) return { ok: false, response: policyRejection(decision) };
202
197
  effTier = decision.tier;
203
198
  } else {
204
199
  decision = canDispatchRole(globalConfig, effRole, true, sessionConfig);
205
- if (!decision.ok) return policyRejection(decision);
200
+ if (!decision.ok) return { ok: false, response: policyRejection(decision) };
206
201
  if (effRole === 'reviewer') effTier = 'pro';
207
202
  else if (legacy_tier !== undefined) effTier = legacy_tier;
208
203
  else if (tier !== undefined) effTier = tier;
209
204
  else { const slot = chooseDefaultTier(globalConfig, undefined, sessionConfig); effTier = slot.ok ? slot.tier : 'flash'; }
210
205
  }
211
- const workDir = cwd ?? process.cwd();
212
- const e = effort ?? sessionConfig.default_effort;
213
- const timeout = timeout_seconds ?? sessionConfig.default_timeout_seconds;
214
206
 
215
- const spec = {
207
+ const workDir = workspaceOverride?.repo_root ?? cwd ?? process.cwd();
208
+ const workspace = resolveWorkspaceContext(loadWorkspaceContexts(), { workspace_id, cwd: workDir });
209
+ if (!workspace.ok) return dispatchError(workspace.code, workspace);
210
+ const withRefs = addContextReferences(workspace.context, context_refs, { cwd: workDir });
211
+ if (!withRefs.ok) return dispatchError(withRefs.code);
212
+ const profileValue = resolvedProfile.profile;
213
+ const effectiveTimeout = constraints?.timeout_seconds ?? timeout_seconds ?? profileValue.timeout_seconds ?? sessionConfig.default_timeout_seconds;
214
+ const requestedIsolation = workspaceOverride?.worktree === 'auto'
215
+ ? 'worktree'
216
+ : workspaceOverride?.worktree === 'existing' || workspaceOverride?.worktree === 'none'
217
+ ? 'shared'
218
+ : profileValue.isolation;
219
+ return {
220
+ ok: true,
216
221
  role: effRole,
217
- delivery: effRole === 'reviewer' ? 'review' : 'coding',
218
- model_class_hint: effTier,
219
- task,
220
- cwd: workDir,
221
- effort: e,
222
- source: ORCHESTRATOR,
222
+ timeout: effectiveTimeout,
223
+ spec: {
224
+ client_job_id: job_id ?? null,
225
+ role: effRole,
226
+ delivery: effRole === 'reviewer' ? 'review' : 'coding',
227
+ model_class_hint: effTier,
228
+ task: buildWorkspaceTask(task, withRefs.context),
229
+ cwd: workDir,
230
+ effort: effort ?? sessionConfig.default_effort,
231
+ source: ORCHESTRATOR,
232
+ profile_id: resolvedProfile.profile_id,
233
+ requested_isolation: requestedIsolation,
234
+ workspace_branch: workspaceOverride?.branch ?? withRefs.context?.default_branch ?? null,
235
+ timeout_seconds: effectiveTimeout,
236
+ allow_fallback: constraints?.allow_fallback ?? profileValue.fallback,
237
+ routing: profileValue.routing,
238
+ review_strictness: profileValue.review_strictness,
239
+ workspace_context: withRefs.context,
240
+ },
223
241
  };
224
- const wf = workflowRuntime.start(spec);
225
- await workflowRuntime.wait(wf.id, timeout * 1000);
242
+ }
243
+
244
+ server.registerTool('dsh_run_worker', {
245
+ title: 'Run DSH worker (blocking)',
246
+ description: 'Delegate a task to a DSH (DeepSeek Harness) coding agent and wait for its final result. The worker is a full DSH agent with its own tools and sandbox. Pass role=worker for implementation and role=reviewer for an independent review pass. Disabled roles are refused by the DSH Crew policy; Manual roles only run when explicitly requested. Blocks until the worker finishes.',
247
+ inputSchema: {
248
+ task: z.string().describe('Full task description for the worker, self-contained'),
249
+ role: roleSchema,
250
+ tier: tierSchema,
251
+ legacy_tier: tierSchema.describe('Legacy model-class hint (flash | pro), forwarded verbatim by deprecated ds-flash/ds-pro aliases; only influences which model class backs a worker role, never the role gate'),
252
+ effort: effortSchema,
253
+ cwd: z.string().optional().describe('Workspace directory for the worker (defaults to current project)'),
254
+ timeout_seconds: z.number().int().positive().max(7200).optional(),
255
+ job_id: clientJobIdSchema,
256
+ workspace: workspaceSchema,
257
+ constraints: constraintsSchema,
258
+ profile: profileSchema,
259
+ workspace_id: workspaceIdSchema,
260
+ context_refs: contextRefsSchema,
261
+ detail: detailSchema,
262
+ },
263
+ }, async ({ task, role, tier, legacy_tier, effort, cwd, timeout_seconds, profile, workspace_id, context_refs, job_id, workspace, constraints, detail }) => {
264
+ if (!sessionConfig.enabled) return dispatchDisabled();
265
+ const prepared = prepareDispatch({ task, role, tier, legacy_tier, effort, cwd, timeout_seconds, profile, workspace_id, context_refs, job_id, workspace, constraints });
266
+ if (!prepared.ok) return prepared.response;
267
+ const wf = workflowRuntime.start(prepared.spec);
268
+ await workflowRuntime.wait(wf.id, prepared.timeout * 1000);
226
269
  const view = workflowRuntime.get(wf.id, { withResult: true });
227
270
  if (view.status === 'running') {
228
- return text({ ...view, note: `still running after ${timeout}s; poll with dsh_worker_result`, status: 'running' });
271
+ return text({ ...projectWorkflowView(view, { detail }), note: `still running after ${prepared.timeout}s; poll with dsh_worker_result`, status: 'running' });
229
272
  }
230
273
  if (view.phase === 'failed') {
231
- return text({ ...view, note: 'workflow failed — see error / error_code.', status: 'failed' });
274
+ return text({ ...projectWorkflowView(view, { detail }), note: 'workflow failed — see error / error_code.', status: 'failed' });
232
275
  }
233
- return text({ ...view, note: effRole === 'reviewer' ? 'review complete' : 'workflow complete' });
276
+ return text({ ...projectWorkflowView(view, { detail }), note: prepared.role === 'reviewer' ? 'review complete' : 'workflow complete' });
234
277
  });
235
278
 
236
279
  server.registerTool('dsh_worker_config', {
@@ -333,6 +376,21 @@ async function buildConfigReport() {
333
376
  providerCatalogChecked,
334
377
  providerCatalogBody,
335
378
  });
379
+ const roleProfiles = loadRoleProfiles();
380
+ const workspaceReadiness = await assessWorkspaceReadiness({ cwd: process.cwd() });
381
+ const extensionContract = buildExtensionContract({
382
+ config: {
383
+ ...globalConfig,
384
+ subagents_enabled: sessionConfig.enabled !== false && globalConfig.subagents_enabled !== false,
385
+ worker_state: globalConfig.worker_state,
386
+ review_state: globalConfig.review_state,
387
+ escalate_on_failure: sessionConfig.escalate_on_failure ?? legacy.escalate_on_failure,
388
+ },
389
+ readinessMatrix,
390
+ workspace: workspaceReadiness,
391
+ profiles: roleProfiles,
392
+ runtime: getHubRuntimeIdentity(),
393
+ });
336
394
  return {
337
395
  enabled: sessionConfig.enabled,
338
396
  default_tier: sessionConfig.default_tier ?? legacy.default_tier,
@@ -371,6 +429,8 @@ async function buildConfigReport() {
371
429
  runtime_controls: runtimeControls,
372
430
  activation_boundaries: activationBoundaries,
373
431
  readiness_matrix: readinessMatrix,
432
+ role_profiles: roleProfiles,
433
+ extension_contract: extensionContract,
374
434
  hub_reachable: hubCompatibility.reachable,
375
435
  hub_compatible: hubCompatibility.compatible,
376
436
  hub_compatibility: hubCompatibility,
@@ -387,39 +447,18 @@ server.registerTool('dsh_spawn_worker', {
387
447
  legacy_tier: tierSchema.describe('Legacy model-class hint (flash | pro) — only influences the model class backing a worker role, never the role gate'),
388
448
  effort: effortSchema,
389
449
  cwd: z.string().optional(),
450
+ job_id: clientJobIdSchema,
451
+ workspace: workspaceSchema,
452
+ constraints: constraintsSchema,
453
+ profile: profileSchema,
454
+ workspace_id: workspaceIdSchema,
455
+ context_refs: contextRefsSchema,
390
456
  },
391
- }, async ({ task, role, tier, legacy_tier, effort, cwd }) => {
457
+ }, async ({ task, role, tier, legacy_tier, effort, cwd, profile, workspace_id, context_refs, job_id, workspace, constraints }) => {
392
458
  if (!sessionConfig.enabled) return dispatchDisabled();
393
- const globalConfig = currentGlobalConfig();
394
- const hint = resolveRoleTierHint(role, legacy_tier ?? tier);
395
- if (!hint.ok) return text({ error: hint.error, code: hint.code, note: 'Resolve the conflict on the caller side: pass only role, or only tier.' });
396
- const effRole = hint.role;
397
- let effTier;
398
- let decision;
399
- if (role === undefined) {
400
- decision = chooseDefaultTier(globalConfig, legacy_tier ?? tier, sessionConfig);
401
- if (!decision.ok) return policyRejection(decision);
402
- effTier = decision.tier;
403
- } else {
404
- decision = canDispatchRole(globalConfig, effRole, true, sessionConfig);
405
- if (!decision.ok) return policyRejection(decision);
406
- if (effRole === 'reviewer') effTier = 'pro';
407
- else if (legacy_tier !== undefined) effTier = legacy_tier;
408
- else if (tier !== undefined) effTier = tier;
409
- else { const slot = chooseDefaultTier(globalConfig, undefined, sessionConfig); effTier = slot.ok ? slot.tier : 'flash'; }
410
- }
411
- const workDir = cwd ?? process.cwd();
412
- const e = effort ?? sessionConfig.default_effort;
413
- const spec = {
414
- role: effRole,
415
- delivery: effRole === 'reviewer' ? 'review' : 'coding',
416
- model_class_hint: effTier,
417
- task,
418
- cwd: workDir,
419
- effort: e,
420
- source: ORCHESTRATOR,
421
- };
422
- const wf = workflowRuntime.start(spec);
459
+ const prepared = prepareDispatch({ task, role, tier, legacy_tier, effort, cwd, profile, workspace_id, context_refs, job_id, workspace, constraints });
460
+ if (!prepared.ok) return prepared.response;
461
+ const wf = workflowRuntime.start(prepared.spec);
423
462
  return text({ ...workflowRuntime.get(wf.id), workflow_id: wf.id, note: 'started in background; poll with dsh_worker_status / dsh_worker_result' });
424
463
  });
425
464
 
@@ -437,41 +476,45 @@ server.registerTool('dsh_worker_result', {
437
476
  inputSchema: {
438
477
  job_id: z.string(),
439
478
  wait_seconds: z.number().int().min(0).max(7200).default(0).describe('0 = return current state immediately'),
479
+ after_sequence: z.number().int().min(0).default(0).describe('Return canonical events after this cursor for incremental watch.'),
480
+ detail: detailSchema,
440
481
  },
441
- }, async ({ job_id, wait_seconds }) => {
482
+ }, async ({ job_id, wait_seconds, after_sequence, detail }) => {
442
483
  if (job_id.startsWith('wf-')) {
443
484
  if (wait_seconds > 0) await workflowRuntime.wait(job_id, wait_seconds * 1000);
444
485
  const view = workflowRuntime.get(job_id, { withResult: true });
445
486
  if (!view) return text({ error: `no such workflow: ${job_id}` });
446
- return text(view);
487
+ return text(projectWorkflowView(view, { detail, afterSequence: after_sequence }));
447
488
  }
448
489
  if (job_id.startsWith('hub-')) {
449
490
  const status = await hubStatus();
450
491
  if (!status.compatible) return text({ error: hubCompatibilityMessage(status), code: status.code, hub_compatibility: status });
451
- return text(await hub.get(job_id, wait_seconds).catch((e) => ({ error: e.message })));
492
+ const view = await hub.get(job_id, wait_seconds).catch((e) => ({ error: e.message }));
493
+ return text(projectWorkflowView(view, { detail, afterSequence: after_sequence }));
452
494
  }
453
495
  if (!getJob(job_id)) return text({ error: `no such job: ${job_id} (expected a wf- workflow id)` });
454
496
  const job = await waitJob(job_id, wait_seconds > 0 ? wait_seconds * 1000 : 1);
455
- return text(jobView(job, { withResult: true }));
497
+ return text(projectWorkflowView(jobView(job, { withResult: true }), { detail, afterSequence: after_sequence }));
456
498
  });
457
499
 
458
500
  server.registerTool('dsh_worker_cancel', {
459
501
  title: 'Cancel DSH worker',
460
502
  description: 'Cancel a worker workflow (stops the active attempt, never starts escalation/review, releases its worktree). Accepts workflow ids (wf-...), Hub attempt ids (hub-...) and legacy standalone ids (job-...).',
461
- inputSchema: { job_id: z.string() },
462
- }, async ({ job_id }) => {
503
+ inputSchema: { job_id: z.string(), detail: detailSchema },
504
+ }, async ({ job_id, detail }) => {
463
505
  if (job_id.startsWith('wf-')) {
464
506
  const view = await workflowRuntime.cancel(job_id);
465
507
  if (!view) return text({ error: `no such workflow: ${job_id}` });
466
- return text({ ...view, note: 'cancelled' });
508
+ return text({ ...projectWorkflowView(view, { detail }), note: 'cancelled' });
467
509
  }
468
510
  if (job_id.startsWith('hub-')) {
469
511
  const status = await hubStatus();
470
512
  if (!status.compatible) return text({ error: hubCompatibilityMessage(status), code: status.code, hub_compatibility: status });
471
- return text(await hub.cancel(job_id).catch((e) => ({ error: e.message })));
513
+ const view = await hub.cancel(job_id).catch((e) => ({ error: e.message }));
514
+ return text(projectWorkflowView(view, { detail }));
472
515
  }
473
516
  if (!getJob(job_id)) return text({ error: `no such job: ${job_id} (expected a wf- workflow id)` });
474
- return text(jobView(await cancelJob(job_id), { withResult: true }));
517
+ return text(projectWorkflowView(jobView(await cancelJob(job_id), { withResult: true }), { detail }));
475
518
  });
476
519
 
477
520
  await server.connect(new StdioServerTransport());