@ran-sh/dsh-crew 0.5.2 → 0.5.3

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.
@@ -7,10 +7,11 @@ model: haiku
7
7
  You are a thin dispatcher for the DSH worker role. You NEVER do the task yourself.
8
8
 
9
9
  1. Take the task you were given and pass it VERBATIM (plus any file paths / context you were given) to the `dsh_run_worker` tool with:
10
- - `role`: `"worker"`
11
- - `effort`: omit it entirely (the session/global default applies) unless the task explicitly names an effort level
12
- - `cwd`: the current project directory
13
- 2. Wait for the tool to return.
10
+ - `role`: `"worker"`
11
+ - `effort`: omit it entirely (the session/global default applies) unless the task explicitly names an effort level
12
+ - `cwd`: the current project directory
13
+ - For an explicitly read-only search or analysis task that must make zero file changes, pass `constraints: { allow_no_changes: true }`. Otherwise omit this constraint.
14
+ 2. Wait for the tool to return.
14
15
  3. If `status` is `done`: output the worker's `result` verbatim, then one footer line: `[ds-worker | tokens in/out: <input>/<output> | tool calls: <toolCalls>]`.
15
16
  4. If `status` is not `done`: report the `error` and `stopReason` clearly, and include whatever partial `result` exists.
16
17
 
@@ -8,7 +8,7 @@ model = "gpt-5.4-mini"
8
8
  developer_instructions = """
9
9
  You are a thin dispatcher. You NEVER do the task yourself.
10
10
 
11
- 1. Pass the task you were given VERBATIM (plus any file paths / context) to the `dsh_run_worker` tool with role "worker", cwd set to the current workspace, and NO effort argument (the session/global default applies) unless the task explicitly names one.
11
+ 1. Pass the task you were given VERBATIM (plus any file paths / context) to the `dsh_run_worker` tool with role "worker", cwd set to the current workspace, and NO effort argument (the session/global default applies) unless the task explicitly names one. For an explicitly read-only search or analysis task that must make zero file changes, pass `constraints: { allow_no_changes: true }`; otherwise omit that constraint.
12
12
  2. Wait for the tool to return.
13
13
  3. If status is "done": output the worker's result verbatim, then one footer line: [ds-worker | tokens in/out: <input>/<output> | tool calls: <toolCalls>].
14
14
  4. Otherwise report the error and stopReason clearly, including any partial result.
@@ -80,10 +80,18 @@ long jobs without replaying the entire event history.
80
80
  The isolated Hub also exposes `/extension`, `/profiles`, `/workspaces`,
81
81
  `/jobs/:id/contract`, and `/jobs/:id/events`. All remain loopback-only.
82
82
 
83
- Per-job precedence is request `constraints` > Profile > session defaults.
84
- `workspace.branch` pins the isolated base revision; `workspace.worktree` accepts
85
- `auto`, `existing`, or `none`. Workspace preflight reports `READY`, `CONFLICT`,
86
- `READ_ONLY`, or `UNAVAILABLE` before dispatch.
83
+ Per-job precedence is request `constraints` > Profile > session defaults.
84
+ `workspace.branch` pins the isolated base revision; `workspace.worktree` accepts
85
+ `auto`, `existing`, or `none`. Workspace preflight reports `READY`, `CONFLICT`,
86
+ `READ_ONLY`, or `UNAVAILABLE` before dispatch.
87
+
88
+ `constraints.allow_no_changes: true` is reserved for explicitly read-only
89
+ search, inspection, or analysis work. It does not blindly convert a partial
90
+ Delivery Report into success: Crew requires an isolated candidate with zero
91
+ changed files, a complete Delivery Report, at least one passing evidence check,
92
+ and no failed check. If the Worker reports no changes but the workspace contains
93
+ edits—or reports changes while the workspace is empty—the workflow fails closed
94
+ with workspace-evidence mismatch.
87
95
 
88
96
  The CLI is a JSON projection of the same local HTTP surface:
89
97
 
@@ -24,7 +24,10 @@ The matrix separates three kinds of evidence:
24
24
  2. `ci` — platform validation such as Linux deterministic, Windows regressions, and future macOS smoke.
25
25
  3. `real-execution` — provider/model and workflow behavior that requires a genuine DSH execution.
26
26
 
27
- Only live checks are populated automatically by the current runtime. CI and real-execution rows remain `NOT_RUN` until a trusted higher layer supplies explicit evidence.
27
+ Live checks are populated automatically. The config report also consumes the
28
+ compatible Hub's bounded job registry, so a completed Worker or Reviewer can
29
+ promote the corresponding generic real-execution row. Other CI and execution
30
+ rows remain `NOT_RUN` until a trusted higher layer supplies explicit evidence.
28
31
 
29
32
  ## Target rows
30
33
 
@@ -32,7 +35,8 @@ Only live checks are populated automatically by the current runtime. CI and real
32
35
  - `windows_regressions`
33
36
  - `macos_smoke`
34
37
  - `hub_compatibility`
35
- - `provider_catalog`
38
+ - `provider_catalog`
39
+ - `model_execution`
36
40
  - `deepseek_flash`
37
41
  - `deepseek_pro`
38
42
  - `opencode_go_mimo_qwen`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
package/scripts/setup.mjs CHANGED
@@ -23,20 +23,57 @@ import {
23
23
 
24
24
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
25
25
 
26
- function commandExists(name) {
27
- const r = spawnSync(/^win/.test(process.platform) ? 'where' : 'which', [name], {
26
+ function commandExists(name) {
27
+ const r = spawnSync(/^win/.test(process.platform) ? 'where' : 'which', [name], {
28
28
  encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
29
29
  });
30
- return r.status === 0;
31
- }
32
-
33
- function run(cmd, args, opts = {}) {
34
- const r = spawnSync(cmd, args, {
35
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: opts.shell === true, cwd: opts.cwd ?? ROOT,
36
- env: opts.env ?? process.env,
37
- });
38
- return { ok: r.status === 0, status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
39
- }
30
+ return r.status === 0;
31
+ }
32
+
33
+ const PACKAGE_MANAGER_COMMAND = /^(?:npm|npx|pnpm|yarn|yarnpkg|bun|bunx)$/i;
34
+
35
+ export function spawnCommand(command, platform = process.platform) {
36
+ if (platform === 'win32' && PACKAGE_MANAGER_COMMAND.test(command)) {
37
+ return `${command}.cmd`;
38
+ }
39
+ return command;
40
+ }
41
+
42
+ export function spawnNeedsShell(command, platform = process.platform) {
43
+ return platform === 'win32' && PACKAGE_MANAGER_COMMAND.test(command);
44
+ }
45
+
46
+ export function spawnInvocation(command, args = [], platform = process.platform, environment = process.env) {
47
+ const executable = spawnCommand(command, platform);
48
+ if (!spawnNeedsShell(command, platform)) return { command: executable, args };
49
+ const unsafe = args.find((arg) => !/^[A-Za-z0-9@._:+/=\-]+$/.test(String(arg)));
50
+ if (unsafe !== undefined) throw new Error(`unsafe package-manager argument: ${unsafe}`);
51
+ return {
52
+ command: environment.ComSpec || 'cmd.exe',
53
+ args: ['/d', '/s', '/c', [executable, ...args].join(' ')],
54
+ };
55
+ }
56
+
57
+ function run(cmd, args, opts = {}) {
58
+ let invocation;
59
+ try {
60
+ invocation = spawnInvocation(cmd, args);
61
+ } catch (error) {
62
+ return { ok: false, status: -1, stdout: '', stderr: String(error?.message ?? error), error };
63
+ }
64
+ const r = spawnSync(invocation.command, invocation.args, {
65
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: opts.shell === true, cwd: opts.cwd ?? ROOT,
66
+ env: opts.env ?? process.env,
67
+ });
68
+ const processError = r.error ? `${r.error.name}: ${r.error.message}` : '';
69
+ return {
70
+ ok: r.status === 0,
71
+ status: r.status ?? -1,
72
+ stdout: r.stdout ?? '',
73
+ stderr: r.stderr || processError,
74
+ error: r.error ?? null,
75
+ };
76
+ }
40
77
 
41
78
  /**
42
79
  * Detect the DSH CLI. `allowDownload` is intentionally opt-in: installation
@@ -106,18 +143,18 @@ export async function setupInstall({
106
143
  installer = realInstaller,
107
144
  depRunner = null,
108
145
  } = {}) {
109
- log('DSH Crew installer');
110
- if (!checkRoot(root)) return { ok: false, error: `not a dsh-crew checkout: ${root}` };
111
-
112
- if (depRunner) {
146
+ log('DSH Crew installer');
147
+ if (!checkRoot(root)) return { ok: false, error: `not a dsh-crew checkout: ${root}` };
148
+ const pnpmOk = commandExists('pnpm');
149
+
150
+ if (depRunner) {
113
151
  const r = depRunner(root);
114
152
  if (!r.ok) { mark(log, false, r.text); return { ok: false, error: r.text }; }
115
153
  mark(log, true, r.text);
116
- } else if (!dryRun) {
117
- const pnpmOk = commandExists('pnpm');
118
- const cmd = pnpmOk ? 'pnpm' : 'npx';
154
+ } else if (!dryRun) {
155
+ const cmd = pnpmOk ? 'pnpm' : 'npx';
119
156
  const baseArgs = pnpmOk ? [] : ['-y', 'pnpm'];
120
- const d = run(cmd, [...baseArgs, 'install', '--frozen-lockfile']);
157
+ const d = run(cmd, [...baseArgs, 'install', '--frozen-lockfile'], { cwd: root });
121
158
  if (!d.ok) {
122
159
  if (depsPresent(root)) mark(log, true, `dependencies (already present; install skipped — ${(d.stderr || d.stdout || '').trim().split('\n')[0]})`);
123
160
  else {
@@ -126,9 +163,11 @@ export async function setupInstall({
126
163
  }
127
164
  } else mark(log, true, 'dependencies');
128
165
  } else mark(log, true, 'dependencies (dry-run)');
129
-
130
- if (!dryRun) {
131
- const b = run('npx --no-install tsdown src/client/index.tsx --format cjs --platform browser --target es2022 --tsconfig tsconfig.client.json --deps.never-bundle react --deps.never-bundle react/jsx-runtime --deps.never-bundle react-dom --deps.never-bundle react-dom/client --out-dir .client-build --clean --logLevel warn && node scripts/build-client.mjs', [], { shell: true });
166
+
167
+ if (!dryRun) {
168
+ const b = pnpmOk
169
+ ? run('pnpm', ['run', 'build:client'], { cwd: root })
170
+ : run('npx', ['-y', 'pnpm', 'run', 'build:client'], { cwd: root });
132
171
  if (!b.ok) {
133
172
  log(`✗ client build failed:\n${(b.stderr || b.stdout || '').slice(0, 600)}`);
134
173
  return { ok: false, error: 'client build failed' };
@@ -1,31 +1,71 @@
1
1
  import { buildReadinessMatrix, READINESS_REASON_CODES } from './readiness-matrix.mjs';
2
2
 
3
- function warningCodes(catalogBody) {
3
+ function warningCodes(catalogBody) {
4
4
  const hints = Array.isArray(catalogBody?.health?.hints) ? catalogBody.health.hints : [];
5
5
  return [...new Set(hints
6
6
  .filter((hint) => hint?.level === 'warning' && typeof hint?.code === 'string' && hint.code.trim())
7
7
  .map((hint) => hint.code.trim()))];
8
- }
8
+ }
9
+
10
+ function verifiedHubJob(job) {
11
+ return job?.status === 'done'
12
+ && job?.task_status === 'success'
13
+ && job?.delivery_complete === true
14
+ && job?.workspace_evidence_ok === true;
15
+ }
16
+
17
+ export function buildHubExecutionRows(hubJobs = []) {
18
+ const jobs = Array.isArray(hubJobs) ? hubJobs : [];
19
+ const workerPassed = jobs.some((job) => job?.role === 'worker' && verifiedHubJob(job));
20
+ const reviewerPassed = jobs.some((job) => job?.role === 'reviewer'
21
+ && verifiedHubJob(job)
22
+ && job?.review_verdict === 'approve');
23
+ return [
24
+ workerPassed
25
+ ? { id: 'model_execution', status: 'PASS', reason_code: 'REAL_EXECUTION_PASSED', evidence_source: 'hub-jobs' }
26
+ : { id: 'model_execution', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE', evidence_source: 'none' },
27
+ reviewerPassed
28
+ ? { id: 'reviewer_pipeline', status: 'PASS', reason_code: 'REAL_REVIEW_PASSED', evidence_source: 'hub-jobs' }
29
+ : { id: 'reviewer_pipeline', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE', evidence_source: 'none' },
30
+ ];
31
+ }
9
32
 
10
33
  /**
11
34
  * Enrich the conservative runtime matrix with evidence the config report has
12
35
  * already collected. This function performs no I/O and never reads provider
13
36
  * configuration, credentials, quotas, pricing, or hidden catalog expectations.
14
37
  */
15
- export function buildConfigReadinessMatrix({
16
- platform = process.platform,
17
- hubCompatibility = null,
18
- workerProviderMode = null,
19
- providerCatalogChecked = false,
20
- providerCatalogBody = null,
21
- } = {}) {
38
+ export function buildConfigReadinessMatrix({
39
+ platform = process.platform,
40
+ hubCompatibility = null,
41
+ workerProviderMode = null,
42
+ providerCatalogChecked = false,
43
+ providerCatalogBody = null,
44
+ hubJobsChecked = false,
45
+ hubJobsBody = null,
46
+ } = {}) {
22
47
  const warnings = warningCodes(providerCatalogBody);
23
48
  const catalogResponseOk = !!providerCatalogBody
24
49
  && typeof providerCatalogBody === 'object'
25
50
  && providerCatalogBody.ok !== false;
26
51
  const catalogOk = providerCatalogChecked && catalogResponseOk && warnings.length === 0;
27
52
 
28
- const evidence = {};
53
+ const evidence = {};
54
+ const hubJobs = hubCompatibility?.compatible === true
55
+ && hubJobsChecked
56
+ && hubJobsBody?.ok !== false
57
+ && Array.isArray(hubJobsBody?.jobs)
58
+ ? hubJobsBody.jobs
59
+ : [];
60
+ for (const row of buildHubExecutionRows(hubJobs)) {
61
+ if (row.status === 'PASS') {
62
+ evidence[row.id] = {
63
+ status: row.status,
64
+ reason_code: row.reason_code,
65
+ evidence_source: row.evidence_source,
66
+ };
67
+ }
68
+ }
29
69
  if (
30
70
  workerProviderMode !== 'deepseek-official'
31
71
  && hubCompatibility?.compatible === true
package/src/hub/index.mjs CHANGED
@@ -20,7 +20,7 @@ import { buildDirectSelectionTrace, resolveWorkerModel, resolveModel } from '../
20
20
  import { readHarnessModelCatalog } from '../model-catalog.mjs';
21
21
  import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from '../delivery.mjs';
22
22
  import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from '../workspace-audit.mjs';
23
- import { buildOutcome, JOB_PHASES } from '../workflow.mjs';
23
+ import { applyWorkspaceEvidence, buildOutcome, JOB_PHASES } from '../workflow.mjs';
24
24
  import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
25
25
  import { createCanonicalJobEvent, projectWorkflowView } from '../job-contracts.mjs';
26
26
  import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
@@ -29,6 +29,7 @@ import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspa
29
29
  import { buildExtensionContract } from '../extension-contract.mjs';
30
30
  import { cleanupIsolatedWorkspace, createIsolatedWorkspace } from '../workspace-isolation.mjs';
31
31
  import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
32
+ import { buildHubExecutionRows } from '../config-readiness.mjs';
32
33
 
33
34
  // policy.mjs is pure (no @deepseek-ai imports, no ctx access), so importing it
34
35
  // here is safe for the profile-realm discipline: it never pulls in package
@@ -119,7 +120,20 @@ export function hubCanonicalEvents(job = {}) {
119
120
  }));
120
121
  }
121
122
 
122
- // ---------- job registry ----------
123
+ // ---------- job registry ----------
124
+
125
+ export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChanges = false, isolation = 'shared', role = 'worker' } = {}) {
126
+ const changes = workspaceDiff?.changes ?? {};
127
+ const hasChanges = ['modified', 'deleted', 'renamed', 'untracked']
128
+ .some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
129
+ const evidenceAvailable = workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true;
130
+ return applyWorkspaceEvidence(outcome, {
131
+ evidenceAvailable,
132
+ hasChanges,
133
+ allowNoChanges: allowNoChanges === true && isolation === 'worktree',
134
+ requireNoChangeAuthorization: role === 'worker',
135
+ });
136
+ }
123
137
 
124
138
  // Exported for the unit tests (test/hub-windows.test.mjs); instantiation
125
139
  // needs only a duck-typed ctx, so spawn()'s path guard is testable without a
@@ -145,6 +159,10 @@ export class WorkerRegistry { constructor(ctx) {
145
159
  startedAt: job.startedAt, endedAt: job.endedAt,
146
160
  isolation: job.isolation ?? 'shared', workspace_branch: job.workspace_branch ?? null,
147
161
  delivery_complete: !!job.delivery_complete,
162
+ allow_no_changes: job.allow_no_changes === true,
163
+ task_status: job.outcome?.task_status ?? null,
164
+ workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
165
+ review_verdict: job.review?.verdict ?? null,
148
166
  workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind === 'git',
149
167
  workspace_retained: job.workspace_retained === true,
150
168
  cleanup_warning: job.cleanup_warning ?? null,
@@ -182,7 +200,7 @@ export class WorkerRegistry { constructor(ctx) {
182
200
  * `role` (worker | reviewer) records who does the work; `tier` remains the
183
201
  * legacy model-class slot. Reviewer-role jobs always use the pro slot.
184
202
  */
185
- async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding', client_job_id, requested_isolation, workspace_branch, timeout_seconds, profile_id, workspace_context }) {
203
+ async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding', client_job_id, requested_isolation, workspace_branch, timeout_seconds, profile_id, workspace_context, allow_no_changes }) {
186
204
  // role is only honored when the caller explicitly names it; a legacy
187
205
  // tier-only spawn (role === undefined) keeps the exact v0.1 resolution.
188
206
  const hasRole = role === 'worker' || role === 'reviewer';
@@ -291,6 +309,7 @@ export class WorkerRegistry { constructor(ctx) {
291
309
  effort, reasoning_effort: selection.reasoningEffort,
292
310
  task, source, cwd: executionCwd, requested_cwd: cwd,
293
311
  isolation: isolatedWorkspace ? 'worktree' : 'shared',
312
+ allow_no_changes: allow_no_changes === true,
294
313
  workspace_branch: workspace_branch ?? null, isolatedWorkspace,
295
314
  profile_id: profile_id ?? null, workspace_context: workspace_context ?? null,
296
315
  prompt: workerPrompt, delivery: delivery === 'review' ? 'review' : 'coding',
@@ -451,6 +470,13 @@ export class WorkerRegistry { constructor(ctx) {
451
470
  job.workspaceDiff = job.baseline.kind === 'git'
452
471
  ? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
453
472
  : job.baseline;
473
+ job.outcome = applyHubWorkspaceEvidence({
474
+ outcome: job.outcome,
475
+ workspaceDiff: job.workspaceDiff,
476
+ allowNoChanges: job.allow_no_changes,
477
+ isolation: job.isolation,
478
+ role: job.role,
479
+ });
454
480
  if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
455
481
  const changes = job.workspaceDiff.changes ?? {};
456
482
  const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
@@ -609,6 +635,9 @@ export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependen
609
635
  if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
610
636
  return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
611
637
  }
638
+ if (raw.constraints?.allow_no_changes !== undefined && typeof raw.constraints.allow_no_changes !== 'boolean') {
639
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_no_changes must be boolean' };
640
+ }
612
641
  const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
613
642
  const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
614
643
  if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
@@ -647,6 +676,7 @@ export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependen
647
676
  workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
648
677
  timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
649
678
  allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
679
+ allow_no_changes: raw.constraints?.allow_no_changes === true,
650
680
  routing: profile.routing,
651
681
  review_strictness: profile.review_strictness,
652
682
  profile_id: resolvedProfile.profile_id,
@@ -852,12 +882,7 @@ export async function apply(ctx) {
852
882
  ? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
853
883
  : await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
854
884
  const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
855
- const modelExecution = liveJobs.some((job) => job?.role === 'worker' && job?.status === 'done')
856
- ? { id: 'model_execution', status: 'PASS', reason_code: 'REAL_EXECUTION_PASSED' }
857
- : { id: 'model_execution', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' };
858
- const reviewerExecution = liveJobs.some((job) => job?.role === 'reviewer' && job?.status === 'done')
859
- ? { id: 'reviewer_pipeline', status: 'PASS', reason_code: 'REAL_REVIEW_PASSED' }
860
- : { id: 'reviewer_pipeline', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' };
885
+ const [modelExecution, reviewerExecution] = buildHubExecutionRows(liveJobs);
861
886
  const contract = buildExtensionContract({
862
887
  config,
863
888
  readinessMatrix: { rows: [
@@ -11,6 +11,7 @@ import { homedir } from 'node:os';
11
11
 
12
12
  export const WINDOWS_STARTUP_FILENAME = 'DSH Crew.vbs';
13
13
  export const WINDOWS_LAUNCHER_FILENAME = 'start-dsh-crew.cmd';
14
+ export const WINDOWS_HELPER_FILENAME = 'start-dsh-crew.ps1';
14
15
 
15
16
  function defaultStartupDir({ home, env }) {
16
17
  if (home === homedir() && env.APPDATA) {
@@ -21,9 +22,11 @@ function defaultStartupDir({ home, env }) {
21
22
 
22
23
  function paths({ home, startupDir, env }) {
23
24
  const launcherFile = join(home, '.config', 'dsh-crew', 'launchers', WINDOWS_LAUNCHER_FILENAME);
25
+ const helperFile = join(home, '.config', 'dsh-crew', 'launchers', WINDOWS_HELPER_FILENAME);
24
26
  const resolvedStartupDir = startupDir ?? defaultStartupDir({ home, env });
25
27
  return {
26
28
  launcherFile,
29
+ helperFile,
27
30
  startupFile: join(resolvedStartupDir, WINDOWS_STARTUP_FILENAME),
28
31
  };
29
32
  }
@@ -41,12 +44,22 @@ export function windowsStartupStatus({
41
44
  } = {}) {
42
45
  if (platform !== 'win32') return { supported: false, installed: false, ready: false };
43
46
  const resolved = paths({ home, startupDir, env });
44
- const installed = existsSync(resolved.startupFile) || existsSync(resolved.launcherFile);
45
- let ready = existsSync(resolved.startupFile) && existsSync(resolved.launcherFile);
47
+ const installed = existsSync(resolved.startupFile)
48
+ || existsSync(resolved.launcherFile)
49
+ || existsSync(resolved.helperFile);
50
+ let ready = existsSync(resolved.startupFile)
51
+ && existsSync(resolved.launcherFile)
52
+ && existsSync(resolved.helperFile);
46
53
  if (ready) {
47
54
  try {
48
- const text = readFileSync(resolved.startupFile, 'utf16le').replace(/^\uFEFF/, '');
49
- ready = text.includes(resolved.launcherFile);
55
+ const startup = readFileSync(resolved.startupFile, 'utf16le').replace(/^\uFEFF/, '');
56
+ const launcher = readFileSync(resolved.launcherFile, 'utf8');
57
+ const helper = readFileSync(resolved.helperFile, 'utf8');
58
+ ready = startup.includes(resolved.launcherFile)
59
+ && startup.includes('--watch')
60
+ && launcher.includes(WINDOWS_HELPER_FILENAME)
61
+ && helper.includes('DSH Crew managed Windows launcher')
62
+ && helper.includes('DSHCrewServiceSupervisor');
50
63
  } catch { ready = false; }
51
64
  }
52
65
  return { supported: true, installed, ready, ...resolved };
@@ -62,19 +75,23 @@ export function installWindowsStartup({
62
75
  if (platform !== 'win32') return { ok: true, supported: false, changed: false };
63
76
  if (!root) return { ok: false, supported: true, code: 'STARTUP_SOURCE_REQUIRED' };
64
77
  const sourceLauncher = join(root, 'windows', WINDOWS_LAUNCHER_FILENAME);
78
+ const sourceHelper = join(root, 'windows', WINDOWS_HELPER_FILENAME);
65
79
  const sourceVbs = join(root, 'windows', 'start-dsh-crew.vbs');
66
- if (!existsSync(sourceLauncher) || !existsSync(sourceVbs)) {
80
+ if (!existsSync(sourceLauncher) || !existsSync(sourceHelper) || !existsSync(sourceVbs)) {
67
81
  return { ok: false, supported: true, code: 'STARTUP_ASSET_MISSING' };
68
82
  }
69
83
  const resolved = paths({ home, startupDir, env });
70
84
  mkdirSync(dirname(resolved.launcherFile), { recursive: true });
71
85
  mkdirSync(dirname(resolved.startupFile), { recursive: true });
72
86
  const beforeLauncher = existsSync(resolved.launcherFile) ? readFileSync(resolved.launcherFile) : null;
87
+ const beforeHelper = existsSync(resolved.helperFile) ? readFileSync(resolved.helperFile) : null;
73
88
  const beforeStartup = existsSync(resolved.startupFile) ? readFileSync(resolved.startupFile) : null;
74
89
  copyFileSync(sourceLauncher, resolved.launcherFile);
90
+ copyFileSync(sourceHelper, resolved.helperFile);
75
91
  const rendered = renderVbs(readFileSync(sourceVbs, 'utf8'), resolved.launcherFile);
76
92
  writeFileSync(resolved.startupFile, `\uFEFF${rendered}`, 'utf16le');
77
93
  const changed = !beforeLauncher?.equals(readFileSync(resolved.launcherFile))
94
+ || !beforeHelper?.equals(readFileSync(resolved.helperFile))
78
95
  || !beforeStartup?.equals(readFileSync(resolved.startupFile));
79
96
  return { ok: true, supported: true, changed, ...resolved };
80
97
  }
@@ -88,7 +105,7 @@ export function uninstallWindowsStartup({
88
105
  if (platform !== 'win32') return { ok: true, supported: false, removed: false };
89
106
  const resolved = paths({ home, startupDir, env });
90
107
  let removed = false;
91
- for (const file of [resolved.startupFile, resolved.launcherFile]) {
108
+ for (const file of [resolved.startupFile, resolved.launcherFile, resolved.helperFile]) {
92
109
  if (existsSync(file)) {
93
110
  rmSync(file, { force: true });
94
111
  removed = true;
@@ -0,0 +1,53 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ function explicitSource(value) {
4
+ if (typeof value !== 'string') return null;
5
+ const normalized = value.trim().toLowerCase();
6
+ return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(normalized) ? normalized : null;
7
+ }
8
+
9
+ function classifyParent(value) {
10
+ const firstLine = String(value ?? '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
11
+ if (!firstLine) return 'unknown';
12
+ const command = firstLine.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
13
+ if (command.includes('claude')) return 'claude-code';
14
+ if (command.includes('codex')) return 'codex';
15
+ if (command.includes('zcode')) return 'zcode';
16
+ return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(command) ? command : 'unknown';
17
+ }
18
+
19
+ function readParentProcess({ platform, pid }) {
20
+ const options = {
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'ignore'],
23
+ timeout: 2_000,
24
+ windowsHide: true,
25
+ };
26
+ if (platform === 'win32') {
27
+ return execFileSync('powershell.exe', [
28
+ '-NoLogo',
29
+ '-NoProfile',
30
+ '-NonInteractive',
31
+ '-Command',
32
+ `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").Name`,
33
+ ], options);
34
+ }
35
+ return execFileSync('ps', ['-o', 'comm=', '-p', String(pid)], options);
36
+ }
37
+
38
+ export function detectOrchestrator({
39
+ env = process.env,
40
+ platform = process.platform,
41
+ parentPid = process.ppid,
42
+ readParent = readParentProcess,
43
+ } = {}) {
44
+ const explicit = explicitSource(env.DSH_ORCHESTRATOR);
45
+ if (explicit) return explicit;
46
+ if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return 'claude-code';
47
+ if (!Number.isInteger(parentPid) || parentPid < 1) return 'unknown';
48
+ try {
49
+ return classifyParent(readParent({ platform, pid: parentPid }));
50
+ } catch {
51
+ return 'unknown';
52
+ }
53
+ }
@@ -26,9 +26,10 @@ const TARGET_ROWS = Object.freeze([
26
26
  ['linux_deterministic', 'ci'],
27
27
  ['windows_regressions', 'ci'],
28
28
  ['macos_smoke', 'ci'],
29
- ['hub_compatibility', 'live-runtime'],
30
- ['provider_catalog', 'live-runtime'],
31
- ['deepseek_flash', 'real-execution'],
29
+ ['hub_compatibility', 'live-runtime'],
30
+ ['provider_catalog', 'live-runtime'],
31
+ ['model_execution', 'real-execution'],
32
+ ['deepseek_flash', 'real-execution'],
32
33
  ['deepseek_pro', 'real-execution'],
33
34
  ['opencode_go_mimo_qwen', 'real-execution'],
34
35
  ['reviewer_pipeline', 'real-execution'],
@@ -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.5.2';
11
+ export const RUNTIME_VERSION = '0.5.3';
12
12
  export const HUB_PROTOCOL_VERSION = 1;
13
13
 
14
14
  export const HUB_CAPABILITIES = Object.freeze([