@ran-sh/dsh-crew 0.5.1 → 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.
@@ -0,0 +1,316 @@
1
+ // ZCode host integration. This module is deliberately file-only: it never
2
+ // launches ZCode, mutates credentials, or overwrites an unowned MCP server.
3
+ // The generated files make ZCode dispatch through the same dsh-crew MCP server
4
+ // used by Codex and Claude while keeping ZCode's native config precedence.
5
+
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { homedir } from 'node:os';
11
+
12
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
13
+ const HOST = 'zcode';
14
+ const SERVER = 'dsh-crew';
15
+ const POLICY_START = '<!-- DSH CREW MANAGED ZCODE POLICY:START -->';
16
+ const POLICY_END = '<!-- DSH CREW MANAGED ZCODE POLICY:END -->';
17
+ const OWNERSHIP_FILE = ({ home = homedir() } = {}) => join(home, '.config', 'dsh-crew', 'integrations', 'zcode.json');
18
+
19
+ // Keep the allowlist explicit. ZCode rejects wildcard tool permissions, and a
20
+ // future server tool is not silently exposed until a template intentionally
21
+ // opts into it.
22
+ export const ZCODE_MCP_TOOLS = Object.freeze([
23
+ 'dsh_run_worker',
24
+ 'dsh_spawn_worker',
25
+ 'dsh_worker_status',
26
+ 'dsh_worker_result',
27
+ 'dsh_worker_cancel',
28
+ 'dsh_worker_config',
29
+ ]);
30
+
31
+ function readText(file) {
32
+ try { return readFileSync(file, 'utf8'); } catch { return null; }
33
+ }
34
+
35
+ function readJson(file, fallback = null) {
36
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; }
37
+ }
38
+
39
+ function contentHash(text) {
40
+ return createHash('sha256').update(text, 'utf8').digest('hex');
41
+ }
42
+
43
+ function writeJson(file, value) {
44
+ mkdirSync(dirname(file), { recursive: true });
45
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
46
+ }
47
+
48
+ function backup(file) {
49
+ if (!existsSync(file)) return null;
50
+ const path = `${file}.dsh-crew-backup-${Date.now()}`;
51
+ copyFileSync(file, path);
52
+ return path;
53
+ }
54
+
55
+ function normalizePath(value) {
56
+ if (typeof value !== 'string' || !value.trim()) return null;
57
+ try {
58
+ const path = resolve(value);
59
+ return process.platform === 'win32' ? path.toLowerCase() : path;
60
+ } catch { return null; }
61
+ }
62
+
63
+ function serverTarget(server) {
64
+ if (!server || typeof server !== 'object') return null;
65
+ if (server.command !== 'node' || !Array.isArray(server.args) || typeof server.args[0] !== 'string') return null;
66
+ return normalizePath(server.args[0]);
67
+ }
68
+
69
+ function nativeServers(config) {
70
+ const servers = config?.mcp?.servers;
71
+ return servers && typeof servers === 'object' && !Array.isArray(servers) ? servers : {};
72
+ }
73
+
74
+ function sharedServers(config) {
75
+ const servers = config?.mcpServers;
76
+ return servers && typeof servers === 'object' && !Array.isArray(servers) ? servers : {};
77
+ }
78
+
79
+ function configSource({ home = homedir() } = {}) {
80
+ const nativeFile = join(home, '.zcode', 'cli', 'config.json');
81
+ const sharedFile = join(home, '.agents', 'mcp.json');
82
+ const native = readJson(nativeFile, {});
83
+ const shared = readJson(sharedFile, {});
84
+ const nativeMap = nativeServers(native);
85
+ const sharedMap = sharedServers(shared);
86
+ // ZCode shadows .agents/mcp.json whenever its native config has at least
87
+ // one server. If native is empty, the shared file is the least-surprising
88
+ // compatibility path for users who already manage MCP centrally.
89
+ if (Object.keys(nativeMap).length > 0 || Object.keys(sharedMap).length === 0) {
90
+ return { file: nativeFile, kind: 'native', config: native, servers: nativeMap };
91
+ }
92
+ return { file: sharedFile, kind: 'shared', config: shared, servers: sharedMap };
93
+ }
94
+
95
+ export function resolveZCodeMcpTarget({ home = homedir() } = {}) {
96
+ const source = configSource({ home });
97
+ return serverTarget(source.servers[SERVER]);
98
+ }
99
+
100
+ function expectedTarget({ root = ROOT } = {}) {
101
+ return resolve(join(root, 'src', 'server.mjs'));
102
+ }
103
+
104
+ function managedPolicyBlock(root) {
105
+ const template = readText(join(root, 'zcode', 'AGENTS.md'))?.trim();
106
+ return template ? `${POLICY_START}\n${template}\n${POLICY_END}` : null;
107
+ }
108
+
109
+ function installPolicy({ home, root }) {
110
+ const file = join(home, '.zcode', 'AGENTS.md');
111
+ const block = managedPolicyBlock(root);
112
+ if (!block) return { ok: false, code: 'ZCODE_POLICY_TEMPLATE_MISSING' };
113
+ mkdirSync(dirname(file), { recursive: true });
114
+ const current = readText(file) ?? '';
115
+ const escapedStart = POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ const escapedEnd = POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
117
+ const managed = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, 'm');
118
+ const next = managed.test(current)
119
+ ? current.replace(managed, block)
120
+ : `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${block}\n`;
121
+ if (next !== current) { backup(file); writeFileSync(file, next); }
122
+ return { ok: true, file };
123
+ }
124
+
125
+ function removePolicy({ home }) {
126
+ const file = join(home, '.zcode', 'AGENTS.md');
127
+ const current = readText(file);
128
+ if (typeof current !== 'string') return false;
129
+ const start = POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
130
+ const end = POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
+ const managed = new RegExp(`(?:\\r?\\n){0,2}${start}[\\s\\S]*?${end}(?:\\r?\\n)?`, 'm');
132
+ if (!managed.test(current)) return false;
133
+ const next = current.replace(managed, '').trimEnd();
134
+ backup(file);
135
+ if (next.trim()) writeFileSync(file, `${next}\n`);
136
+ else rmSync(file, { force: true });
137
+ return true;
138
+ }
139
+
140
+ function ownership({ home = homedir() } = {}) {
141
+ const data = readJson(OWNERSHIP_FILE({ home }), null);
142
+ return data && typeof data === 'object' && data.host === HOST ? data : null;
143
+ }
144
+
145
+ function writeOwnership({ home, configFile, configKind, target, files = [] }) {
146
+ writeJson(OWNERSHIP_FILE({ home }), {
147
+ schema_version: 1,
148
+ host: HOST,
149
+ server: SERVER,
150
+ config_file: configFile,
151
+ config_kind: configKind,
152
+ target,
153
+ files,
154
+ managed_at: new Date().toISOString(),
155
+ });
156
+ }
157
+
158
+ function templateFiles({ home, root }) {
159
+ return [
160
+ ['agents', 'ds-worker.md'],
161
+ ['agents', 'ds-reviewer.md'],
162
+ ['commands', 'dsh-config.md'],
163
+ ['commands', 'dsh-status.md'],
164
+ ].map(([dir, file]) => ({ source: join(root, 'zcode', dir, file), dest: join(home, '.zcode', dir, file) }));
165
+ }
166
+
167
+ function installTemplates({ home, root, priorFiles = [] }) {
168
+ const actions = [];
169
+ const records = [];
170
+ for (const { source, dest } of templateFiles({ home, root })) {
171
+ if (!existsSync(source)) return { ok: false, code: 'ZCODE_TEMPLATE_MISSING', source };
172
+ mkdirSync(dirname(dest), { recursive: true });
173
+ const before = readText(dest);
174
+ const rendered = readFileSync(source, 'utf8');
175
+ const previous = priorFiles.find((entry) => entry?.path === dest);
176
+ let backupFile = previous?.backup ?? null;
177
+ if (before !== rendered) {
178
+ if (before !== null && !backupFile) backupFile = backup(dest);
179
+ writeFileSync(dest, rendered);
180
+ }
181
+ actions.push(dest);
182
+ records.push({
183
+ path: dest,
184
+ preexisting: previous?.preexisting === true || (previous === undefined && before !== null),
185
+ ...(backupFile ? { backup: backupFile } : {}),
186
+ managed_sha256: contentHash(rendered),
187
+ });
188
+ }
189
+ return { ok: true, actions, records };
190
+ }
191
+
192
+ function updateMcp({ home, root }) {
193
+ const source = configSource({ home });
194
+ const target = expectedTarget({ root });
195
+ const current = source.servers[SERVER];
196
+ const currentTarget = serverTarget(current);
197
+ const owned = ownership({ home });
198
+ if (current && currentTarget !== target) {
199
+ // Only an exact previously-owned entry may be repaired. Any foreign
200
+ // command, even if it is also named dsh-crew, is a hard collision.
201
+ if (!(owned && owned.config_file === source.file && normalizePath(owned.target) === currentTarget)) {
202
+ return { ok: false, code: 'ZCODE_MCP_COLLISION', config_file: source.file };
203
+ }
204
+ }
205
+
206
+ const next = { ...source.config };
207
+ if (source.kind === 'native') next.mcp = { ...(next.mcp ?? {}), servers: { ...source.servers, [SERVER]: { command: 'node', args: [target] } } };
208
+ else next.mcpServers = { ...source.servers, [SERVER]: { command: 'node', args: [target] } };
209
+ const changed = JSON.stringify(next) !== JSON.stringify(source.config);
210
+ if (changed) { backup(source.file); writeJson(source.file, next); }
211
+ return { ok: true, changed, config_file: source.file, config_kind: source.kind, target };
212
+ }
213
+
214
+ export function installZCode({ home = homedir(), root = ROOT } = {}) {
215
+ const templates = templateFiles({ home, root });
216
+ const missing = templates.find(({ source }) => !existsSync(source));
217
+ if (missing) return { ok: false, code: 'ZCODE_TEMPLATE_MISSING', source: missing.source };
218
+ const priorFiles = ownership({ home })?.files ?? [];
219
+ // Check for collisions before writing any other integration surface so a
220
+ // failed install is transaction-like and leaves user files untouched.
221
+ const mcp = updateMcp({ home, root });
222
+ if (!mcp.ok) return mcp;
223
+ const policy = installPolicy({ home, root });
224
+ if (!policy.ok) return policy;
225
+ const installed = installTemplates({ home, root, priorFiles });
226
+ if (!installed.ok) return installed;
227
+ writeOwnership({
228
+ home,
229
+ configFile: mcp.config_file,
230
+ configKind: mcp.config_kind,
231
+ target: mcp.target,
232
+ files: installed.records,
233
+ });
234
+ return { ok: true, ...mcp, policy_file: policy.file, files: installed.actions };
235
+ }
236
+
237
+ function zcodeComponents({ home = homedir(), root = ROOT } = {}) {
238
+ const expected = normalizePath(expectedTarget({ root }));
239
+ const source = configSource({ home });
240
+ const configured = serverTarget(source.servers[SERVER]);
241
+ const owned = ownership({ home });
242
+ const components = {
243
+ mcp: configured === expected,
244
+ policy: typeof readText(join(home, '.zcode', 'AGENTS.md')) === 'string' && readText(join(home, '.zcode', 'AGENTS.md')).includes(POLICY_START),
245
+ worker_agent: existsSync(join(home, '.zcode', 'agents', 'ds-worker.md')),
246
+ reviewer_agent: existsSync(join(home, '.zcode', 'agents', 'ds-reviewer.md')),
247
+ config_prompt: existsSync(join(home, '.zcode', 'commands', 'dsh-config.md')),
248
+ status_prompt: existsSync(join(home, '.zcode', 'commands', 'dsh-status.md')),
249
+ ownership: !!owned && owned.config_file === source.file && normalizePath(owned.target) === expected,
250
+ };
251
+ return { components, source, expected, configured, owned };
252
+ }
253
+
254
+ export function zcodeStatus({ home = homedir(), root = ROOT } = {}) {
255
+ const { components, source, expected, configured } = zcodeComponents({ home, root });
256
+ const missing = Object.entries(components).filter(([, value]) => !value).map(([key]) => key);
257
+ const installed = Object.values(components).some(Boolean);
258
+ return {
259
+ installed,
260
+ ready: missing.length === 0,
261
+ components,
262
+ missing,
263
+ config_file: source.file,
264
+ config_kind: source.kind,
265
+ target: configured ?? expected,
266
+ };
267
+ }
268
+
269
+ export function uninstallZCode({ home = homedir() } = {}) {
270
+ const actions = [];
271
+ const owned = ownership({ home });
272
+ if (owned) {
273
+ const file = owned.config_file;
274
+ const config = readJson(file, null);
275
+ if (config && typeof config === 'object') {
276
+ const kind = owned.config_kind === 'shared' ? 'shared' : 'native';
277
+ const servers = kind === 'shared' ? sharedServers(config) : nativeServers(config);
278
+ const current = servers[SERVER];
279
+ if (normalizePath(owned.target) === serverTarget(current)) {
280
+ const next = { ...config };
281
+ if (kind === 'shared') {
282
+ next.mcpServers = { ...servers }; delete next.mcpServers[SERVER];
283
+ if (Object.keys(next.mcpServers).length === 0) delete next.mcpServers;
284
+ } else {
285
+ next.mcp = { ...(next.mcp ?? {}), servers: { ...servers } }; delete next.mcp.servers[SERVER];
286
+ if (Object.keys(next.mcp.servers).length === 0) delete next.mcp.servers;
287
+ if (Object.keys(next.mcp).length === 0) delete next.mcp;
288
+ }
289
+ backup(file); writeJson(file, next); actions.push(`mcp: removed ${SERVER} from ${file}`);
290
+ }
291
+ }
292
+ rmSync(OWNERSHIP_FILE({ home }), { force: true });
293
+ }
294
+ if (removePolicy({ home })) actions.push('policy: removed managed ZCode block');
295
+ const managedFiles = Array.isArray(owned?.files) && owned.files.length
296
+ ? owned.files
297
+ : ['agents/ds-worker.md', 'agents/ds-reviewer.md', 'commands/dsh-config.md', 'commands/dsh-status.md']
298
+ .map((rel) => ({ path: join(home, '.zcode', rel) }));
299
+ for (const entry of managedFiles) {
300
+ const file = entry?.path;
301
+ if (!file || !existsSync(file)) continue;
302
+ const current = readText(file);
303
+ const unchanged = !entry.managed_sha256 || (current !== null && contentHash(current) === entry.managed_sha256);
304
+ if (!unchanged) continue;
305
+ if (entry.preexisting && entry.backup && existsSync(entry.backup)) {
306
+ copyFileSync(entry.backup, file);
307
+ actions.push(`restored: ${file}`);
308
+ } else {
309
+ rmSync(file, { force: true });
310
+ actions.push(`removed: ${file}`);
311
+ }
312
+ }
313
+ return { ok: true, actions };
314
+ }
315
+
316
+ export function zcodeOwnershipFile({ home = homedir() } = {}) { return OWNERSHIP_FILE({ home }); }
@@ -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.1';
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([
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,