@ran-sh/dsh-crew 0.5.2 → 0.5.4

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/src/server.mjs CHANGED
@@ -26,8 +26,9 @@ import { buildReviewTask } from './information-flow.mjs';
26
26
  import { projectWorkflowView } from './job-contracts.mjs';
27
27
  import { loadRoleProfiles, resolveRoleProfile } from './role-profiles.mjs';
28
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';
29
+ import { buildExtensionContract } from './extension-contract.mjs';
30
+ import { assessWorkspaceReadiness } from './workspace-readiness.mjs';
31
+ import { detectOrchestrator } from './orchestrator.mjs';
31
32
 
32
33
  const server = new McpServer({ name: 'dsh-crew', version: RUNTIME_VERSION });
33
34
 
@@ -44,10 +45,11 @@ const workspaceSchema = z.object({
44
45
  branch: z.string().refine(isSafeBranchName, 'invalid git branch name').optional(),
45
46
  worktree: z.enum(['auto', 'existing', 'none']).optional(),
46
47
  }).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.');
48
+ const constraintsSchema = z.object({
49
+ timeout_seconds: z.number().int().positive().max(7200).optional(),
50
+ allow_fallback: z.boolean().optional(),
51
+ allow_no_changes: z.boolean().optional().describe('Explicitly allow a verified read-only or analysis job to succeed with zero workspace changes.'),
52
+ }).optional().describe('Per-job constraints override profile and session defaults.');
51
53
 
52
54
  // Session-level configuration. This MCP server process lives exactly as long
53
55
  // as one Claude Code / Codex session, so plain memory IS session scope.
@@ -123,19 +125,7 @@ function text(obj) {
123
125
  return { content: [{ type: 'text', text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2) }] };
124
126
  }
125
127
 
126
- function detectOrchestrator() {
127
- if (process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT) return 'claude-code';
128
- try {
129
- const { execSync } = require('node:child_process');
130
- const comm = execSync(`ps -o comm= -p ${process.ppid}`, { encoding: 'utf8' }).trim().toLowerCase();
131
- if (comm.includes('claude')) return 'claude-code';
132
- if (comm.includes('codex')) return 'codex';
133
- return comm.split('/').pop() || 'unknown';
134
- } catch { return 'unknown'; }
135
- }
136
- import { createRequire } from 'node:module';
137
- const require = createRequire(import.meta.url);
138
- const ORCHESTRATOR = detectOrchestrator();
128
+ const ORCHESTRATOR = detectOrchestrator();
139
129
 
140
130
  function policyRejection(decision) {
141
131
  return text({
@@ -232,9 +222,10 @@ function prepareDispatch({ task, role, tier, legacy_tier, effort, cwd, timeout_s
232
222
  profile_id: resolvedProfile.profile_id,
233
223
  requested_isolation: requestedIsolation,
234
224
  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,
225
+ timeout_seconds: effectiveTimeout,
226
+ allow_fallback: constraints?.allow_fallback ?? profileValue.fallback,
227
+ allow_no_changes: constraints?.allow_no_changes === true,
228
+ routing: profileValue.routing,
238
229
  review_strictness: profileValue.review_strictness,
239
230
  workspace_context: withRefs.context,
240
231
  },
@@ -333,10 +324,12 @@ async function buildConfigReport() {
333
324
  const hubCompatibility = await hubStatus({ force: true });
334
325
  let effectiveWorkerProvider = null;
335
326
  let effectiveWorkerSelection = { flash: null, pro: null };
336
- let providerResolutionError;
337
- let providerCatalogChecked = false;
338
- let providerCatalogBody = null;
339
- const workerProviderMode = globalConfig.worker_provider_mode ?? 'deepseek-official';
327
+ let providerResolutionError;
328
+ let providerCatalogChecked = false;
329
+ let providerCatalogBody = null;
330
+ let hubJobsChecked = false;
331
+ let hubJobsBody = null;
332
+ const workerProviderMode = globalConfig.worker_provider_mode ?? 'deepseek-official';
340
333
  if (workerProviderMode === 'deepseek-official') {
341
334
  effectiveWorkerSelection = {
342
335
  flash: { provider: 'deepseek-official', model: 'deepseek-v4-flash', source: 'legacy-strict' },
@@ -366,16 +359,27 @@ async function buildConfigReport() {
366
359
  providerCatalogChecked = true;
367
360
  providerResolutionError = err?.message ?? String(err);
368
361
  }
369
- } else if (hubCompatibility.reachable) {
370
- providerResolutionError = hubCompatibilityMessage(hubCompatibility);
371
- }
372
- effectiveWorkerProvider = effectiveWorkerSelection.flash?.provider ?? null;
373
- const readinessMatrix = buildConfigReadinessMatrix({
362
+ } else if (hubCompatibility.reachable) {
363
+ providerResolutionError = hubCompatibilityMessage(hubCompatibility);
364
+ }
365
+ if (hubCompatibility.compatible) {
366
+ try {
367
+ hubJobsChecked = true;
368
+ const jobsRes = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/jobs`, { signal: AbortSignal.timeout(800) });
369
+ hubJobsBody = await jobsRes.json();
370
+ } catch {
371
+ hubJobsChecked = true;
372
+ }
373
+ }
374
+ effectiveWorkerProvider = effectiveWorkerSelection.flash?.provider ?? null;
375
+ const readinessMatrix = buildConfigReadinessMatrix({
374
376
  hubCompatibility,
375
377
  workerProviderMode,
376
- providerCatalogChecked,
377
- providerCatalogBody,
378
- });
378
+ providerCatalogChecked,
379
+ providerCatalogBody,
380
+ hubJobsChecked,
381
+ hubJobsBody,
382
+ });
379
383
  const roleProfiles = loadRoleProfiles();
380
384
  const workspaceReadiness = await assessWorkspaceReadiness({ cwd: process.cwd() });
381
385
  const extensionContract = buildExtensionContract({
@@ -15,7 +15,7 @@
15
15
  // appear here.
16
16
 
17
17
  import { resolveModelPolicy, shouldAutoReview, getRoleState } from './policy.mjs';
18
- import { buildOutcome, decideNextStep, JOB_PHASES, canTransition } from './workflow.mjs';
18
+ import { applyWorkspaceEvidence, buildOutcome, decideNextStep, JOB_PHASES, canTransition } from './workflow.mjs';
19
19
  import { parseDeliveryReport } from './delivery.mjs';
20
20
  import { classifyFailure } from './failure-classification.mjs';
21
21
  import { createCanonicalJobEvent } from './job-contracts.mjs';
@@ -70,17 +70,6 @@ function mutationDetected(before, after) {
70
70
  return after.fingerprint != null && before.fingerprint !== after.fingerprint;
71
71
  }
72
72
 
73
- function deliveryClaimsChanges(outcome) {
74
- return Array.isArray(outcome?.changes) && outcome.changes.length > 0;
75
- }
76
-
77
- function workspaceEvidenceOK(outcome, candidate) {
78
- if (!candidate) return true;
79
- const hasChanges = Array.isArray(candidate.changed_files) && candidate.changed_files.length > 0;
80
- if (outcome?.execution_status !== 'completed') return true;
81
- return !deliveryClaimsChanges(outcome) || hasChanges;
82
- }
83
-
84
73
  function sumUsage(attempts) {
85
74
  const tokens = { input: 0, output: 0, reasoning: 0 };
86
75
  for (const a of attempts) {
@@ -181,9 +170,10 @@ export function createWorkflowRuntime(adapters, {
181
170
  requested_isolation: spec.requested_isolation ?? null,
182
171
  workspace_branch: spec.workspace_branch ?? null,
183
172
  timeout_seconds: spec.timeout_seconds ?? null,
184
- profile_id: spec.profile_id ?? null,
185
- allow_fallback: spec.allow_fallback !== false,
186
- routing: spec.routing ?? 'auto',
173
+ profile_id: spec.profile_id ?? null,
174
+ allow_fallback: spec.allow_fallback !== false,
175
+ allow_no_changes: spec.allow_no_changes === true,
176
+ routing: spec.routing ?? 'auto',
187
177
  review_strictness: spec.review_strictness ?? null,
188
178
  workspace_context: spec.workspace_context ? { ...spec.workspace_context } : null,
189
179
  effort: spec.effort ?? 'max',
@@ -385,14 +375,17 @@ export function createWorkflowRuntime(adapters, {
385
375
  failJob(job, Object.assign(new Error(ar.error ?? 'infrastructure failure'), { code: WORKFLOW_ERROR_CODES.ATTEMPT_INFRA_FAILURE }));
386
376
  return;
387
377
  }
388
- const outcome = ar.outcome ?? buildOutcome({
378
+ let outcome = ar.outcome ?? buildOutcome({
389
379
  result: ar.result ?? '',
390
380
  stopReason: ar.stopReason,
391
381
  executionStatus: ar.status === 'done' ? 'completed' : 'failed',
392
382
  });
393
383
  transition(job, JOB_PHASES.VERIFYING, `attempt ${attempt} complete`);
394
384
 
395
- // Capture the latest candidate after every attempt. Capture failure is
385
+ let workspaceEvidenceAvailable = false;
386
+ let workspaceHasChanges = false;
387
+
388
+ // Capture the latest candidate after every attempt. Capture failure is
396
389
  // not allowed to delete the only recoverable state: retain the worktree
397
390
  // and preserve the worker business result, while surfacing the warning.
398
391
  if (alloc?.ok && alloc.isolation === 'worktree') {
@@ -402,17 +395,23 @@ export function createWorkflowRuntime(adapters, {
402
395
  job.retain_workspace = true;
403
396
  job.candidate = null;
404
397
  job.events.push({ at: clock(), phase: job.phase, type: 'candidate/failed', attempt, message: 'candidate capture failed; worktree retained for recovery' });
405
- } else {
406
- job.candidate = candidate;
407
- attemptView.candidate_fingerprint = candidate.fingerprint ?? null;
408
- outcome.workspace_evidence_ok = workspaceEvidenceOK(outcome, candidate);
409
- if (candidate.complete === false || candidate.replayable === false) {
398
+ } else {
399
+ job.candidate = candidate;
400
+ attemptView.candidate_fingerprint = candidate.fingerprint ?? null;
401
+ workspaceEvidenceAvailable = true;
402
+ workspaceHasChanges = Array.isArray(candidate.changed_files) && candidate.changed_files.length > 0;
403
+ if (candidate.complete === false || candidate.replayable === false) {
410
404
  job.retain_workspace = true;
411
405
  job.events.push({ at: clock(), phase: job.phase, type: 'candidate/incomplete', attempt, message: 'candidate is not fully replayable; worktree retained' });
412
406
  }
413
- }
414
- }
415
- job.outcome = outcome;
407
+ }
408
+ }
409
+ outcome = applyWorkspaceEvidence(outcome, {
410
+ evidenceAvailable: workspaceEvidenceAvailable,
411
+ hasChanges: workspaceHasChanges,
412
+ allowNoChanges: job.allow_no_changes,
413
+ });
414
+ job.outcome = outcome;
416
415
 
417
416
  const reviewRequested = !isReviewJob && shouldAutoReview(config);
418
417
  const reviewerAuto = getRoleState(config, 'reviewer') !== 'disabled';
@@ -580,8 +579,9 @@ export function createWorkflowRuntime(adapters, {
580
579
  current_model: job.attempts[job.attempts.length - 1]?.model ?? null,
581
580
  model_class_hint: job.model_class_hint,
582
581
  source: job.source,
583
- profile_id: job.profile_id,
584
- routing: job.routing,
582
+ profile_id: job.profile_id,
583
+ allow_no_changes: job.allow_no_changes,
584
+ routing: job.routing,
585
585
  workspace_context: job.workspace_context ? { ...job.workspace_context } : null,
586
586
  workspace_branch: job.workspace_branch,
587
587
  timeout_seconds: job.timeout_seconds,
package/src/workflow.mjs CHANGED
@@ -58,10 +58,68 @@ export function canTransition(from, to) {
58
58
  return allowed.includes(to);
59
59
  }
60
60
 
61
- function splitSection(value) {
62
- if (typeof value !== 'string' || value.trim() === '') return [];
63
- return value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
64
- }
61
+ function splitSection(value) {
62
+ if (typeof value !== 'string' || value.trim() === '') return [];
63
+ return value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
64
+ }
65
+
66
+ const NO_CHANGE_SENTINELS = new Set([
67
+ 'no files changed',
68
+ 'no file changed',
69
+ 'no changes',
70
+ '无文件变更',
71
+ '没有文件变更',
72
+ '未更改任何文件',
73
+ '无变更',
74
+ ]);
75
+
76
+ function deliveryClaimsChanges(outcome) {
77
+ return Array.isArray(outcome?.changes) && outcome.changes.length > 0;
78
+ }
79
+
80
+ export function applyWorkspaceEvidence(outcome, {
81
+ evidenceAvailable = false,
82
+ hasChanges = false,
83
+ allowNoChanges = false,
84
+ requireNoChangeAuthorization = true,
85
+ } = {}) {
86
+ const next = { ...outcome };
87
+ const claimsChanges = deliveryClaimsChanges(next);
88
+ if (evidenceAvailable && next.execution_status === 'completed') {
89
+ next.workspace_evidence_ok = claimsChanges === hasChanges;
90
+ }
91
+ const tests = Array.isArray(next.tests) ? next.tests : [];
92
+ const verifiedNoChange = requireNoChangeAuthorization === true
93
+ && evidenceAvailable === true
94
+ && allowNoChanges === true
95
+ && hasChanges === false
96
+ && claimsChanges === false
97
+ && next.execution_status === 'completed'
98
+ && next.workspace_evidence_ok === true
99
+ && next.delivery?.complete === true
100
+ && tests.some((test) => test.status === 'PASS')
101
+ && !tests.some((test) => test.status === 'FAIL');
102
+ if (verifiedNoChange) {
103
+ next.task_status = 'success';
104
+ next.no_change_verified = true;
105
+ } else if (requireNoChangeAuthorization === true && claimsChanges === false && next.task_status === 'success') {
106
+ next.task_status = 'partial';
107
+ delete next.no_change_verified;
108
+ }
109
+ return next;
110
+ }
111
+
112
+ function parseChanges(section) {
113
+ return splitSection(section).filter((line) => {
114
+ const normalized = line
115
+ .replace(/^(?:[-*+]\s+)+/, '')
116
+ .replace(/[`"'“”‘’]/g, '')
117
+ .replace(/[.!。!]+$/g, '')
118
+ .trim()
119
+ .toLowerCase();
120
+ return !NO_CHANGE_SENTINELS.has(normalized);
121
+ });
122
+ }
65
123
 
66
124
  function parseTests(section) {
67
125
  return splitSection(section).map((line) => {
@@ -106,7 +164,7 @@ export function buildOutcome({ result = '', deliveryMeta, executionStatus, stopR
106
164
  }),
107
165
  confidence: null,
108
166
  needs_escalation: false,
109
- changes: splitSection(parsed.sections.Diff),
167
+ changes: parseChanges(parsed.sections.Diff),
110
168
  tests,
111
169
  tests_status: testsStatus ?? null,
112
170
  risks: splitSection(parsed.sections.Risks),
@@ -2,54 +2,56 @@
2
2
  setlocal EnableExtensions
3
3
  title DSH Crew Launcher
4
4
 
5
- set "CREW_HOME=%USERPROFILE%\.config\dsh-crew\harness"
6
- set "OFFICIAL_HOME=%USERPROFILE%\.dsh"
7
- set "DSH_CLI=%CREW_HOME%\runtime\node_modules\.bin\dsh.cmd"
8
- set "CREW_URL=http://127.0.0.1:3210"
9
- set "UI_URL=http://127.0.0.1:3080"
5
+ set "LAUNCH_REQUEST=%*"
6
+ set "LAUNCH_MODE=open"
7
+ set "LAUNCH_DIR=%~dp0"
8
+
9
+ if "%~1"=="" goto :run
10
+ if /i "%~1"=="--background" (
11
+ set "LAUNCH_MODE=background"
12
+ shift
13
+ goto :validate
14
+ )
15
+ if /i "%~1"=="--open" (
16
+ set "LAUNCH_MODE=open"
17
+ shift
18
+ goto :validate
19
+ )
20
+ if /i "%~1"=="--watch" (
21
+ set "LAUNCH_MODE=watch"
22
+ shift
23
+ goto :validate
24
+ )
25
+ if /i "%~1"=="--help" goto :help
26
+ goto :invalid_argument
27
+
28
+ :validate
29
+ if not "%~1"=="" goto :invalid_argument
30
+
31
+ :run
32
+ set "LAUNCH_HELPER=%LAUNCH_DIR%start-dsh-crew.ps1"
10
33
  set "LAUNCH_LOG=%TEMP%\dsh-crew-launcher.log"
11
-
12
- if not exist "%DSH_CLI%" goto :not_installed
13
- if not exist "%CREW_HOME%\profiles\dsh-crew\package.json" goto :not_installed
14
- if not exist "%OFFICIAL_HOME%\profiles\web\package.json" goto :official_missing
15
-
16
- call :ensure_service 3210 dsh-crew "%CREW_HOME%" "%CREW_URL%"
17
- if errorlevel 1 goto :failed
18
- call :ensure_service 3080 web "%OFFICIAL_HOME%" "%UI_URL%"
19
- if errorlevel 1 goto :failed
34
+ if not exist "%LAUNCH_HELPER%" (
35
+ >>"%LAUNCH_LOG%" echo [%date% %time%] ERROR Managed launcher helper is missing: %LAUNCH_HELPER%
36
+ echo ERROR: DSH Crew launcher helper is missing.
37
+ echo Repair it with: dsh-crew update
38
+ if /i "%LAUNCH_MODE%"=="open" pause
39
+ exit /b 1
40
+ )
41
+
42
+ powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%LAUNCH_HELPER%" -Mode "%LAUNCH_MODE%"
43
+ set "LAUNCH_EXIT=%ERRORLEVEL%"
44
+ if not "%LAUNCH_EXIT%"=="0" if /i "%LAUNCH_MODE%"=="open" pause
45
+ exit /b %LAUNCH_EXIT%
46
+
47
+ :invalid_argument
48
+ echo ERROR: Unsupported launcher arguments: %LAUNCH_REQUEST%
49
+ echo Use --open, --background, or --watch.
50
+ exit /b 64
51
+
52
+ :help
53
+ echo Usage: %~nx0 [--open ^| --background ^| --watch]
54
+ echo --open Start both services and open http://127.0.0.1:3080/.
55
+ echo --background Start both services silently without opening a browser.
56
+ echo --watch Keep both services healthy and restart them after an exit.
20
57
  exit /b 0
21
-
22
- :ensure_service
23
- set "LAUNCH_PORT=%~1"
24
- set "LAUNCH_PROFILE=%~2"
25
- set "LAUNCH_HOME=%~3"
26
- set "LAUNCH_URL=%~4"
27
-
28
- call :health_check "%LAUNCH_URL%"
29
- if not errorlevel 1 exit /b 0
30
-
31
- powershell.exe -NoLogo -NoProfile -NonInteractive -Command "$listener=Get-NetTCPConnection -State Listen -LocalPort $env:LAUNCH_PORT -ErrorAction SilentlyContinue; if ($listener) { exit 0 } else { exit 1 }" >nul 2>&1
32
- if not errorlevel 1 exit /b 1
33
-
34
- powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -Command "try { $env:DSH_HOME=$env:LAUNCH_HOME; Start-Process -FilePath $env:DSH_CLI -ArgumentList @('--profile',$env:LAUNCH_PROFILE,'--host','127.0.0.1','--port',$env:LAUNCH_PORT,'--no-open') -WindowStyle Hidden -ErrorAction Stop } catch { ('['+(Get-Date -Format s)+'] Failed to start '+$env:LAUNCH_PROFILE+': '+$_.Exception.Message) | Add-Content -LiteralPath $env:LAUNCH_LOG; exit 1 }" >nul 2>&1
35
- if errorlevel 1 exit /b 1
36
-
37
- powershell.exe -NoLogo -NoProfile -NonInteractive -Command "$lastError=$null; $deadline=(Get-Date).AddSeconds(90); do { try { $r=Invoke-RestMethod -Uri ($env:LAUNCH_URL+'/_dsh/dsh-crew/extension') -TimeoutSec 2; if ($r.ok -eq $true -and $r.extension.runtime.runtime_version) { exit 0 } } catch { $lastError=$_.Exception.Message }; Start-Sleep -Milliseconds 500 } while ((Get-Date) -lt $deadline); if (-not $lastError) { $lastError='No healthy response before the startup deadline.' }; ('['+(Get-Date -Format s)+'] '+$env:LAUNCH_PROFILE+' health check failed: '+$lastError) | Add-Content -LiteralPath $env:LAUNCH_LOG; exit 1" >nul 2>&1
38
- exit /b %ERRORLEVEL%
39
-
40
- :health_check
41
- set "HEALTH_URL=%~1"
42
- powershell.exe -NoLogo -NoProfile -NonInteractive -Command "try { $r=Invoke-RestMethod -Uri ($env:HEALTH_URL+'/_dsh/dsh-crew/extension') -TimeoutSec 2; if ($r.ok -eq $true -and $r.extension.runtime.runtime_version) { exit 0 } } catch {}; exit 1" >nul 2>&1
43
- exit /b %ERRORLEVEL%
44
-
45
- :not_installed
46
- echo [%date% %time%] DSH Crew is not installed completely.>>"%LAUNCH_LOG%"
47
- exit /b 1
48
-
49
- :official_missing
50
- echo [%date% %time%] Official DeepSeek Harness web profile was not found.>>"%LAUNCH_LOG%"
51
- exit /b 1
52
-
53
- :failed
54
- echo [%date% %time%] DSH Crew startup failed.>>"%LAUNCH_LOG%"
55
- exit /b 1