@ran-sh/dsh-crew 0.5.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +20 -7
  2. package/README.zh.md +18 -7
  3. package/docs/installation.md +1 -1
  4. package/docs/ui-surfaces.md +65 -46
  5. package/lib/client.js +565 -28
  6. package/official-web-bridge/lib/client.js +565 -28
  7. package/package.json +1 -1
  8. package/src/client/collapsible-sections.mjs +3 -2
  9. package/src/client/host-readiness.mjs +16 -3
  10. package/src/client/index.tsx +331 -54
  11. package/src/config-readiness.mjs +66 -5
  12. package/src/credential-lifecycle.mjs +73 -0
  13. package/src/credential-purge-adapters.mjs +127 -0
  14. package/src/credential-purge-state.mjs +59 -0
  15. package/src/credential-reference-inventory.mjs +78 -0
  16. package/src/dsh-cli-runtime.mjs +36 -9
  17. package/src/extension-contract.mjs +16 -10
  18. package/src/hub/index.mjs +1004 -438
  19. package/src/hub-client.mjs +25 -5
  20. package/src/hub-compatibility.mjs +23 -7
  21. package/src/install/install.mjs +4 -4
  22. package/src/install/npx-lifecycle.mjs +495 -134
  23. package/src/job-contracts.mjs +27 -6
  24. package/src/jobs.mjs +2 -4
  25. package/src/local-request-guard.mjs +60 -0
  26. package/src/mcp-runtime.mjs +7 -5
  27. package/src/model-routing.mjs +141 -49
  28. package/src/multimodal.mjs +0 -0
  29. package/src/official-web-bridge.mjs +248 -95
  30. package/src/policy.mjs +62 -31
  31. package/src/provider-config-scrub.mjs +71 -0
  32. package/src/provider-delete-adapters.mjs +451 -0
  33. package/src/provider-desired-state.mjs +37 -0
  34. package/src/provider-health.mjs +124 -0
  35. package/src/provider-inventory.mjs +132 -0
  36. package/src/provider-lifecycle-state.mjs +95 -0
  37. package/src/provider-lifecycle.mjs +214 -0
  38. package/src/provider-profile-store.mjs +171 -0
  39. package/src/readiness-matrix.mjs +11 -5
  40. package/src/removable-waiter.mjs +29 -0
  41. package/src/runtime-identity.mjs +66 -21
  42. package/src/runtime-readiness-snapshot.mjs +87 -0
  43. package/src/server.mjs +64 -21
  44. package/src/status-shard.mjs +13 -2
  45. package/src/workflow-runtime.mjs +44 -10
  46. package/windows/start-dsh-crew.cmd +3 -3
  47. package/windows/start-dsh-crew.ps1 +49 -5
@@ -5,10 +5,20 @@
5
5
  // - HUB_PROTOCOL_VERSION changes only when Hub <-> MCP wire semantics become
6
6
  // incompatible.
7
7
  //
8
- // Keep this module pure and dependency-free so Hub, MCP and tests all use the
9
- // exact same compatibility rules.
8
+ // Keep this module dependency-light so Hub, MCP and tests all use the exact
9
+ // same compatibility rules.
10
+
11
+ import { randomUUID } from 'node:crypto';
12
+
13
+ // Production Crew execution is intentionally bound to the isolated 3210 Hub.
14
+ // These fields are public provenance only; no credential or session secret is
15
+ // included in the identity contract.
16
+ export const PRODUCTION_EXECUTION_PLANE = 'hub-3210';
17
+ export const PRODUCTION_PROFILE = 'dsh-crew';
18
+ export const PRODUCTION_LISTEN_PORT = 3210;
19
+ const RUNTIME_ID = randomUUID();
10
20
 
11
- export const RUNTIME_VERSION = '0.5.6';
21
+ export const RUNTIME_VERSION = '1.0.0';
12
22
  export const HUB_PROTOCOL_VERSION = 1;
13
23
 
14
24
  export const HUB_CAPABILITIES = Object.freeze([
@@ -23,10 +33,17 @@ export const HUB_CAPABILITIES = Object.freeze([
23
33
  'config',
24
34
  'canonical-events',
25
35
  'evidence',
26
- 'profiles',
27
- 'workspace-context',
28
- 'extension-contract',
29
- ]);
36
+ 'profiles',
37
+ 'workspace-context',
38
+ 'extension-contract',
39
+ 'provider-inventory',
40
+ 'provider-lifecycle-v1',
41
+ 'provider-health-v1',
42
+ 'provider-probe-stream-v1',
43
+ 'credential-reference-inventory-v1',
44
+ 'credential-purge-v1',
45
+ 'runtime-provenance-v1',
46
+ ]);
30
47
 
31
48
  // Capabilities the current MCP workflow depends on for full Hub execution.
32
49
  // Additional capabilities may be advertised without breaking compatibility.
@@ -43,9 +60,12 @@ export const HUB_COMPATIBILITY_CODES = Object.freeze({
43
60
  UNREACHABLE: 'HUB_UNREACHABLE',
44
61
  HTTP_ERROR: 'HUB_HTTP_ERROR',
45
62
  SERVICE_MISMATCH: 'HUB_SERVICE_MISMATCH',
46
- PROTOCOL_MISSING: 'HUB_PROTOCOL_MISSING',
47
- PROTOCOL_MISMATCH: 'HUB_PROTOCOL_MISMATCH',
48
- CAPABILITY_MISSING: 'HUB_CAPABILITY_MISSING',
63
+ PROTOCOL_MISSING: 'HUB_PROTOCOL_MISSING',
64
+ PROTOCOL_MISMATCH: 'HUB_PROTOCOL_MISMATCH',
65
+ CAPABILITY_MISSING: 'HUB_CAPABILITY_MISSING',
66
+ PROVENANCE_MISSING: 'HUB_PROVENANCE_MISSING',
67
+ EXECUTION_PLANE_MISMATCH: 'HUB_EXECUTION_PLANE_MISMATCH',
68
+ LISTEN_PORT_MISMATCH: 'HUB_LISTEN_PORT_MISMATCH',
49
69
  });
50
70
 
51
71
  function normalizedCapabilities(value) {
@@ -59,8 +79,12 @@ export function getHubRuntimeIdentity() {
59
79
  surface: 'native-crew-harness',
60
80
  ui_role: 'runtime',
61
81
  runtime_version: RUNTIME_VERSION,
62
- protocol_version: HUB_PROTOCOL_VERSION,
63
- capabilities: [...HUB_CAPABILITIES],
82
+ protocol_version: HUB_PROTOCOL_VERSION,
83
+ execution_plane: PRODUCTION_EXECUTION_PLANE,
84
+ profile: PRODUCTION_PROFILE,
85
+ listen_port: PRODUCTION_LISTEN_PORT,
86
+ runtime_id: RUNTIME_ID,
87
+ capabilities: [...HUB_CAPABILITIES],
64
88
  };
65
89
  }
66
90
 
@@ -71,30 +95,51 @@ export function getHubRuntimeIdentity() {
71
95
  * `compatible` means the response is from dsh-crew and satisfies the current
72
96
  * protocol + required capability contract.
73
97
  */
74
- export function evaluateHubHandshake(body, { requiredCapabilities = REQUIRED_HUB_CAPABILITIES } = {}) {
98
+ export function evaluateHubHandshake(body, { requiredCapabilities = REQUIRED_HUB_CAPABILITIES, strictProduction = false } = {}) {
75
99
  const capabilities = normalizedCapabilities(body?.capabilities);
76
100
  const base = {
77
101
  reachable: true,
78
102
  compatible: false,
79
103
  service: typeof body?.service === 'string' ? body.service : null,
80
- runtime_version: typeof body?.runtime_version === 'string' ? body.runtime_version : null,
81
- protocol_version: Number.isInteger(body?.protocol_version) ? body.protocol_version : null,
104
+ runtime_version: typeof body?.runtime_version === 'string' ? body.runtime_version : null,
105
+ protocol_version: Number.isInteger(body?.protocol_version) ? body.protocol_version : null,
106
+ execution_plane: typeof body?.execution_plane === 'string' ? body.execution_plane : null,
107
+ profile: typeof body?.profile === 'string' ? body.profile : null,
108
+ listen_port: Number.isInteger(body?.listen_port) ? body.listen_port : null,
109
+ runtime_id: typeof body?.runtime_id === 'string' ? body.runtime_id : null,
82
110
  capabilities,
83
111
  missing_capabilities: [],
84
112
  code: null,
85
113
  };
86
114
 
87
- if (body?.service !== 'dsh-crew-hub') {
88
- return { ...base, code: HUB_COMPATIBILITY_CODES.SERVICE_MISMATCH };
89
- }
115
+ if (body?.service !== 'dsh-crew-hub') {
116
+ return { ...base, code: HUB_COMPATIBILITY_CODES.SERVICE_MISMATCH };
117
+ }
118
+
119
+ // An explicit execution identity that is not the isolated 3210 Crew plane
120
+ // must never be accepted as the production Hub. Missing legacy fields remain
121
+ // compatible with the older protocol contract and are handled below.
122
+ if (body?.execution_plane !== undefined && body.execution_plane !== PRODUCTION_EXECUTION_PLANE) {
123
+ return { ...base, code: HUB_COMPATIBILITY_CODES.EXECUTION_PLANE_MISMATCH };
124
+ }
125
+ if (body?.listen_port !== undefined && body.listen_port !== PRODUCTION_LISTEN_PORT) {
126
+ return { ...base, code: HUB_COMPATIBILITY_CODES.LISTEN_PORT_MISMATCH };
127
+ }
90
128
 
91
129
  if (!Number.isInteger(body?.protocol_version)) {
92
130
  return { ...base, code: HUB_COMPATIBILITY_CODES.PROTOCOL_MISSING };
93
131
  }
94
132
 
95
- if (body.protocol_version !== HUB_PROTOCOL_VERSION) {
96
- return { ...base, code: HUB_COMPATIBILITY_CODES.PROTOCOL_MISMATCH };
97
- }
133
+ if (body.protocol_version !== HUB_PROTOCOL_VERSION) {
134
+ return { ...base, code: HUB_COMPATIBILITY_CODES.PROTOCOL_MISMATCH };
135
+ }
136
+
137
+ if (strictProduction && !(body?.execution_plane === PRODUCTION_EXECUTION_PLANE
138
+ && body?.profile === PRODUCTION_PROFILE
139
+ && Number.isInteger(body?.listen_port) && body.listen_port === PRODUCTION_LISTEN_PORT
140
+ && typeof body?.runtime_id === 'string' && body.runtime_id.trim().length > 0)) {
141
+ return { ...base, code: HUB_COMPATIBILITY_CODES.PROVENANCE_MISSING };
142
+ }
98
143
 
99
144
  const required = normalizedCapabilities(requiredCapabilities);
100
145
  const advertised = new Set(capabilities);
@@ -0,0 +1,87 @@
1
+ // Single, bounded readiness projection shared by the Hub extension surface,
2
+ // MCP diagnostics and the client. It consumes already-collected evidence and
3
+ // never performs I/O or carries raw task/result/error/credential content.
4
+
5
+ const MAX_HEALTH = 128;
6
+
7
+ function text(value) { return typeof value === 'string' && value.trim() ? value.trim() : null; }
8
+
9
+ function projectRuntime(runtime) {
10
+ if (!runtime || typeof runtime !== 'object') return null;
11
+ return {
12
+ execution_plane: text(runtime.execution_plane),
13
+ profile: text(runtime.profile),
14
+ listen_port: Number.isFinite(Number(runtime.listen_port)) ? Number(runtime.listen_port) : null,
15
+ runtime_id: text(runtime.runtime_id),
16
+ };
17
+ }
18
+
19
+ function projectSelection(value) {
20
+ if (!value || typeof value !== 'object') return null;
21
+ const provider = text(value.provider);
22
+ const model = text(value.model);
23
+ if (!provider || !model) return null;
24
+ return { provider, model, ...(text(value.source) ? { source: text(value.source) } : {}) };
25
+ }
26
+
27
+ function projectHealth(entry) {
28
+ if (!entry || typeof entry !== 'object') return null;
29
+ const provider = text(entry.provider);
30
+ const model = text(entry.model);
31
+ const state = text(entry.state);
32
+ if (!provider || !model || !state) return null;
33
+ return {
34
+ provider,
35
+ model,
36
+ state,
37
+ ...(text(entry.reason_code) ? { reason_code: text(entry.reason_code) } : {}),
38
+ ...(Number.isFinite(entry.observed_at) ? { observed_at: entry.observed_at } : {}),
39
+ ...(Number.isFinite(entry.expires_at) ? { expires_at: entry.expires_at } : {}),
40
+ fresh: entry.fresh === true,
41
+ };
42
+ }
43
+
44
+ function matrixRow(matrix, id) {
45
+ return Array.isArray(matrix?.rows) ? matrix.rows.find((row) => row?.id === id) ?? null : null;
46
+ }
47
+
48
+ function projectWorkspace(workspace) {
49
+ if (!workspace || typeof workspace !== 'object') return null;
50
+ return {
51
+ ...(text(workspace.status) ? { status: text(workspace.status) } : {}),
52
+ ...(text(workspace.reason_code) ? { reason_code: text(workspace.reason_code) } : {}),
53
+ };
54
+ }
55
+
56
+ export function buildRuntimeReadinessSnapshot({
57
+ runtime = null,
58
+ readinessMatrix = null,
59
+ selections = {},
60
+ health = [],
61
+ jobs = [],
62
+ workspace = null,
63
+ } = {}) {
64
+ const projectedHealth = (Array.isArray(health) ? health : []).map(projectHealth).filter(Boolean).slice(0, MAX_HEALTH);
65
+ const projectedJobs = Array.isArray(jobs) ? jobs : [];
66
+ const verified = projectedJobs.filter((job) => job?.status === 'done' && job?.task_status === 'success' && job?.delivery_complete === true).length;
67
+ const workerJobs = projectedJobs.filter((job) => job?.role === 'worker').length;
68
+ const reviewerJobs = projectedJobs.filter((job) => job?.role === 'reviewer').length;
69
+ const workerSelection = projectSelection(selections.worker ?? selections.flash);
70
+ const reviewerSelection = projectSelection(selections.reviewer ?? selections.pro);
71
+ return {
72
+ schema_version: 1,
73
+ runtime: projectRuntime(runtime),
74
+ readiness_matrix: readinessMatrix && typeof readinessMatrix === 'object' ? structuredClone(readinessMatrix) : null,
75
+ provider_lifecycle: matrixRow(readinessMatrix, 'provider_lifecycle_consistent'),
76
+ health: projectedHealth,
77
+ worker: { selected: workerSelection, health: projectedHealth.filter((entry) => entry.provider === workerSelection?.provider && entry.model === workerSelection?.model) },
78
+ reviewer: { selected: reviewerSelection, health: projectedHealth.filter((entry) => entry.provider === reviewerSelection?.provider && entry.model === reviewerSelection?.model) },
79
+ historical_evidence: {
80
+ job_count: projectedJobs.length,
81
+ verified_job_count: verified,
82
+ worker_job_count: workerJobs,
83
+ reviewer_job_count: reviewerJobs,
84
+ },
85
+ workspace: projectWorkspace(workspace),
86
+ };
87
+ }
package/src/server.mjs CHANGED
@@ -9,7 +9,8 @@ import { hubCompatibilityMessage, resolveHubExecutionMode } from './hub-compatib
9
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
- import { buildConfigReadinessMatrix } from './config-readiness.mjs';
12
+ import { buildConfigReadinessMatrix } from './config-readiness.mjs';
13
+ import { buildRuntimeReadinessSnapshot } from './runtime-readiness-snapshot.mjs';
13
14
  import { classifyFailure, classifyFailureCode } from './failure-classification.mjs';
14
15
  import {
15
16
  normalizeGlobalConfig,
@@ -56,7 +57,9 @@ const constraintsSchema = z.object({
56
57
  // Initial values come from the global config (~/.config/dsh-crew/config.json,
57
58
  // edited on the DSH settings page); dsh_worker_config overrides per session.
58
59
  // All dispatch decisions (dsh_run_worker AND dsh_spawn_worker, hub AND
59
- // standalone) go through the same policy resolver in src/policy.mjs.
60
+ // production dispatches go through the 3210-only policy resolver in
61
+ // src/policy.mjs; the historical standalone path is retained only for
62
+ // read/migration compatibility and is never selected for execution.
60
63
  import { readGlobalConfig } from './install/install.mjs';
61
64
  const initialGlobalConfig = normalizeGlobalConfig(readGlobalConfig());
62
65
  const legacyDefaults = deriveLegacyConfig(initialGlobalConfig);
@@ -65,7 +68,7 @@ const sessionConfig = {
65
68
  enabled: true,
66
69
  default_tier: legacyDefaults.default_tier,
67
70
  default_effort: legacyDefaults.default_effort,
68
- mode: legacyDefaults.mode, // auto | hub | standalone
71
+ mode: legacyDefaults.mode === 'standalone' ? 'hub' : legacyDefaults.mode, // auto | hub; legacy standalone is migrated to hub
69
72
  default_timeout_seconds: legacyDefaults.default_timeout_seconds,
70
73
  tier_policy: undefined,
71
74
  escalate_on_failure: legacyDefaults.escalate_on_failure,
@@ -83,7 +86,7 @@ function resetSessionConfig() {
83
86
  enabled: true,
84
87
  default_tier: legacyDefaults.default_tier,
85
88
  default_effort: legacyDefaults.default_effort,
86
- mode: legacyDefaults.mode,
89
+ mode: legacyDefaults.mode === 'standalone' ? 'hub' : legacyDefaults.mode,
87
90
  default_timeout_seconds: legacyDefaults.default_timeout_seconds,
88
91
  tier_policy: undefined,
89
92
  escalate_on_failure: legacyDefaults.escalate_on_failure,
@@ -142,9 +145,9 @@ function dispatchDisabled() {
142
145
  });
143
146
  }
144
147
 
145
- async function resolveMode() {
146
- const status = await hubStatus();
147
- const decision = resolveHubExecutionMode(sessionConfig.mode, status);
148
+ async function resolveMode() {
149
+ const status = await hubStatus();
150
+ const decision = resolveHubExecutionMode(sessionConfig.mode, status, { productionOnly: true });
148
151
  if (!decision.ok) {
149
152
  throw Object.assign(new Error(decision.error), {
150
153
  code: decision.code,
@@ -274,7 +277,7 @@ server.registerTool('dsh_worker_config', {
274
277
  enabled: z.boolean().optional().describe('false = refuse all worker dispatch this session'),
275
278
  default_tier: z.enum(['flash', 'pro']).optional(),
276
279
  default_effort: z.enum(['off', 'high', 'max']).optional(),
277
- mode: z.enum(['auto', 'hub', 'standalone']).optional(),
280
+ mode: z.enum(['auto', 'hub']).optional().describe('Execution mode. Production dispatch always uses the isolated 3210 Crew Harness; standalone is retained only as a legacy read/migration value.'),
278
281
  default_timeout_seconds: z.number().int().positive().max(7200).optional(),
279
282
  tier_policy: z.enum(['auto', 'flash-only', 'pro-only']).optional().describe('session hard clamp: flash-only / pro-only pin every dispatch to one tier'),
280
283
  escalate_on_failure: z.boolean().optional().describe('allow an unverified worker attempt to retry through the stronger model policy (applies to run and spawn)'),
@@ -325,9 +328,13 @@ async function buildConfigReport() {
325
328
  let effectiveWorkerProvider = null;
326
329
  let effectiveWorkerSelection = { flash: null, pro: null };
327
330
  let providerResolutionError;
328
- let providerCatalogChecked = false;
329
- let providerCatalogBody = null;
330
- let hubJobsChecked = false;
331
+ let providerCatalogChecked = false;
332
+ let providerCatalogBody = null;
333
+ let providerInventoryChecked = false;
334
+ let providerInventoryBody = null;
335
+ let providerHealthChecked = false;
336
+ let providerHealthBody = null;
337
+ let hubJobsChecked = false;
331
338
  let hubJobsBody = null;
332
339
  const workerProviderMode = globalConfig.worker_provider_mode ?? 'deepseek-official';
333
340
  if (workerProviderMode === 'deepseek-official') {
@@ -362,9 +369,31 @@ async function buildConfigReport() {
362
369
  } else if (hubCompatibility.reachable) {
363
370
  providerResolutionError = hubCompatibilityMessage(hubCompatibility);
364
371
  }
365
- if (hubCompatibility.compatible) {
366
- try {
367
- hubJobsChecked = true;
372
+ if (hubCompatibility.compatible) {
373
+ try {
374
+ providerInventoryChecked = true;
375
+ const inventoryRes = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/providers`, { signal: AbortSignal.timeout(800) });
376
+ providerInventoryBody = inventoryRes.ok
377
+ ? await inventoryRes.json()
378
+ : { ok: false, code: 'PROVIDER_INVENTORY_UNAVAILABLE' };
379
+ } catch {
380
+ providerInventoryChecked = true;
381
+ providerInventoryBody = { ok: false, code: 'PROVIDER_INVENTORY_UNAVAILABLE' };
382
+ }
383
+ }
384
+ if (hubCompatibility.compatible) {
385
+ try {
386
+ providerHealthChecked = true;
387
+ const healthRes = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/provider-health`, { signal: AbortSignal.timeout(800) });
388
+ providerHealthBody = healthRes.ok ? await healthRes.json() : { ok: false, code: 'PROVIDER_HEALTH_UNAVAILABLE' };
389
+ } catch {
390
+ providerHealthChecked = true;
391
+ providerHealthBody = { ok: false, code: 'PROVIDER_HEALTH_UNAVAILABLE' };
392
+ }
393
+ }
394
+ if (hubCompatibility.compatible) {
395
+ try {
396
+ hubJobsChecked = true;
368
397
  const jobsRes = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/jobs`, { signal: AbortSignal.timeout(800) });
369
398
  hubJobsBody = await jobsRes.json();
370
399
  } catch {
@@ -375,14 +404,26 @@ async function buildConfigReport() {
375
404
  const readinessMatrix = buildConfigReadinessMatrix({
376
405
  hubCompatibility,
377
406
  workerProviderMode,
378
- providerCatalogChecked,
379
- providerCatalogBody,
380
- hubJobsChecked,
407
+ providerCatalogChecked,
408
+ providerCatalogBody,
409
+ providerInventoryChecked,
410
+ providerInventoryBody,
411
+ providerHealthChecked,
412
+ providerHealthBody,
413
+ hubJobsChecked,
381
414
  hubJobsBody,
382
415
  });
383
416
  const roleProfiles = loadRoleProfiles();
384
- const workspaceReadiness = await assessWorkspaceReadiness({ cwd: process.cwd() });
385
- const extensionContract = buildExtensionContract({
417
+ const workspaceReadiness = await assessWorkspaceReadiness({ cwd: process.cwd() });
418
+ const readinessSnapshot = buildRuntimeReadinessSnapshot({
419
+ runtime: getHubRuntimeIdentity(),
420
+ readinessMatrix,
421
+ selections: { worker: effectiveWorkerSelection.worker ?? effectiveWorkerSelection.flash, reviewer: effectiveWorkerSelection.reviewer ?? effectiveWorkerSelection.pro },
422
+ health: providerHealthBody?.health,
423
+ jobs: hubJobsBody?.jobs,
424
+ workspace: workspaceReadiness,
425
+ });
426
+ const extensionContract = buildExtensionContract({
386
427
  config: {
387
428
  ...globalConfig,
388
429
  subagents_enabled: sessionConfig.enabled !== false && globalConfig.subagents_enabled !== false,
@@ -390,7 +431,8 @@ async function buildConfigReport() {
390
431
  review_state: globalConfig.review_state,
391
432
  escalate_on_failure: sessionConfig.escalate_on_failure ?? legacy.escalate_on_failure,
392
433
  },
393
- readinessMatrix,
434
+ readinessMatrix,
435
+ readinessSnapshot,
394
436
  workspace: workspaceReadiness,
395
437
  profiles: roleProfiles,
396
438
  runtime: getHubRuntimeIdentity(),
@@ -432,7 +474,8 @@ async function buildConfigReport() {
432
474
  global_defaults: Object.fromEntries(SAFE_GLOBAL_KEYS.map((k) => [k, globalConfig[k]])),
433
475
  runtime_controls: runtimeControls,
434
476
  activation_boundaries: activationBoundaries,
435
- readiness_matrix: readinessMatrix,
477
+ readiness_matrix: readinessMatrix,
478
+ readiness_snapshot: readinessSnapshot,
436
479
  role_profiles: roleProfiles,
437
480
  extension_contract: extensionContract,
438
481
  hub_reachable: hubCompatibility.reachable,
@@ -3,7 +3,7 @@
3
3
  // and readers merge all fresh shards. Kills the last-writer-wins race that a
4
4
  // single shared status.json had with multiple concurrent writers.
5
5
 
6
- import { writeFileSync, mkdirSync, rmSync, readdirSync, readFileSync } from 'node:fs';
6
+ import { writeFileSync, mkdirSync, rmSync, readdirSync, readFileSync, renameSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
 
@@ -21,9 +21,20 @@ export function createShardWriter(kind) {
21
21
  return {
22
22
  writer,
23
23
  publish(jobs) {
24
+ // Same durability pattern as the profile/workspace registries: write a
25
+ // same-directory temp file as 0600 and rename it over the shard, so a
26
+ // reader never observes a partially written shard (parse failures are
27
+ // silently ignored, which would make the writer look vanished).
24
28
  try {
25
29
  mkdirSync(SHARD_DIR, { recursive: true });
26
- writeFileSync(file, JSON.stringify({ updatedAt: new Date().toISOString(), writer, jobs }, null, 2));
30
+ const temp = `${file}.tmp-${Date.now()}`;
31
+ try {
32
+ writeFileSync(temp, JSON.stringify({ updatedAt: new Date().toISOString(), writer, jobs }, null, 2), { encoding: 'utf8', mode: 0o600 });
33
+ renameSync(temp, file);
34
+ } catch (error) {
35
+ try { rmSync(temp, { force: true }); } catch {}
36
+ throw error;
37
+ }
27
38
  } catch {}
28
39
  },
29
40
  dispose: cleanup,
@@ -15,6 +15,7 @@
15
15
  // appear here.
16
16
 
17
17
  import { resolveModelPolicy, shouldAutoReview, getRoleState } from './policy.mjs';
18
+ import { raceWaiters } from './removable-waiter.mjs';
18
19
  import { applyWorkspaceEvidence, buildOutcome, decideNextStep, JOB_PHASES, canTransition } from './workflow.mjs';
19
20
  import { parseDeliveryReport } from './delivery.mjs';
20
21
  import { classifyFailure } from './failure-classification.mjs';
@@ -298,7 +299,8 @@ export function createWorkflowRuntime(adapters, {
298
299
  let config = {};
299
300
 
300
301
  try {
301
- config = adapters.getConfig?.() ?? {};
302
+ config = adapters.getConfig?.() ?? {};
303
+ job.review_gate = ['required', 'optional', 'off'].includes(config?.review?.gate) ? config.review.gate : 'required';
302
304
  const alloc = await adapters.allocateWorkspace?.(job);
303
305
  if (alloc && alloc.ok === false) {
304
306
  failJob(job, Object.assign(new Error(alloc.error ?? alloc.reason), { code: alloc.reason }));
@@ -324,6 +326,19 @@ export function createWorkflowRuntime(adapters, {
324
326
  const review = await runReviewerAttempt(job, reviewTask, config, before);
325
327
  if (job.cancelling) { await cancelWorkflow(job); return; }
326
328
  job.review = review;
329
+ const reviewApproved = review.status === 'done'
330
+ && review.delivery_complete === true
331
+ && review.verdict === 'approve';
332
+ if (!reviewApproved) {
333
+ const code = review.verdict === 'request_changes'
334
+ ? 'REVIEW_CHANGES_REQUESTED'
335
+ : 'REVIEW_INCONCLUSIVE';
336
+ failJob(job, Object.assign(
337
+ new Error(`explicit review blocked acceptance: verdict=${review.verdict}, reviewer status=${review.status}, review delivery_complete=${review.delivery_complete === true}`),
338
+ { code },
339
+ ));
340
+ return;
341
+ }
327
342
  if (job.review) transition(job, JOB_PHASES.READY, 'review complete');
328
343
  finalize(job);
329
344
  return;
@@ -453,6 +468,24 @@ export function createWorkflowRuntime(adapters, {
453
468
  const review = await runReviewerAttempt(job, reviewTask, config, before, job.execution_cwd, job.base_revision);
454
469
  if (job.cancelling) { await cancelWorkflow(job); return; }
455
470
  job.review = review;
471
+ // The review verdict is an acceptance gate, not a footnote: only a
472
+ // reviewer that ran to completion, delivered its own auditable
473
+ // contract, and approved may finalize as COMPLETED. Anything else
474
+ // fails closed through the existing FAILED machinery so the MCP
475
+ // surface reports a failed workflow instead of a false "done".
476
+ const reviewApproved = review.status === 'done'
477
+ && review.delivery_complete === true
478
+ && review.verdict === 'approve';
479
+ if (!reviewApproved && job.review_gate === 'required') {
480
+ const code = review.verdict === 'request_changes'
481
+ ? 'REVIEW_CHANGES_REQUESTED'
482
+ : 'REVIEW_INCONCLUSIVE';
483
+ failJob(job, Object.assign(
484
+ new Error(`automatic review blocked acceptance: verdict=${review.verdict}, reviewer status=${review.status}, review delivery_complete=${review.delivery_complete === true}`),
485
+ { code },
486
+ ));
487
+ return;
488
+ }
456
489
  transition(job, JOB_PHASES.READY, decision.reason);
457
490
  finalize(job);
458
491
  return;
@@ -535,8 +568,9 @@ export function createWorkflowRuntime(adapters, {
535
568
  id: ar.id,
536
569
  role: ar.role ?? 'worker',
537
570
  attempt,
538
- provider: ar.provider ?? null,
539
- model: ar.model ?? null,
571
+ provider: ar.provider ?? null,
572
+ model: ar.model ?? null,
573
+ execution_context: ar.execution_context ?? null,
540
574
  selection_source: ar.selection_source ?? null,
541
575
  selection_trace: ar.selection_trace ?? null,
542
576
  status: ar.status ?? 'failed',
@@ -594,7 +628,8 @@ export function createWorkflowRuntime(adapters, {
594
628
  phase: job.phase,
595
629
  status: job.status,
596
630
  attempt: job.attempts.length,
597
- current_model: job.attempts[job.attempts.length - 1]?.model ?? null,
631
+ current_model: job.attempts[job.attempts.length - 1]?.model ?? null,
632
+ execution_context: job.attempts[job.attempts.length - 1]?.execution_context ?? null,
598
633
  model_class_hint: job.model_class_hint,
599
634
  source: job.source,
600
635
  profile_id: job.profile_id,
@@ -619,12 +654,14 @@ export function createWorkflowRuntime(adapters, {
619
654
  workspace_retained: job.workspace_retained === true,
620
655
  decision: job.decision,
621
656
  candidate_available: !!job.candidate,
622
- review_status: job.review ? job.review.status ?? null : null,
657
+ review_status: job.review ? job.review.status ?? null : null,
658
+ review_gate: job.review_gate ?? 'required',
623
659
  event_cursor: job.canonical_events.at(-1)?.sequence ?? 0,
624
660
  };
625
661
  if (withResult) {
626
662
  v.child_attempts = job.attempts.map((a) => ({
627
- id: a.id, role: a.role, attempt: a.attempt, provider: a.provider, model: a.model,
663
+ id: a.id, role: a.role, attempt: a.attempt, provider: a.provider, model: a.model,
664
+ execution_context: a.execution_context ?? null,
628
665
  selection_source: a.selection_source, selection_trace: a.selection_trace ?? null,
629
666
  status: a.status, stopReason: a.stopReason,
630
667
  outcome_task: a.outcome?.task_status ?? null,
@@ -661,10 +698,7 @@ export function createWorkflowRuntime(adapters, {
661
698
  const job = jobs.get(id);
662
699
  if (!job) return undefined;
663
700
  if (job.status !== 'running') return job;
664
- await Promise.race([
665
- new Promise((res) => job.waiters.push(res)),
666
- timeoutMs > 0 ? new Promise((res) => setTimeout(res, timeoutMs)) : new Promise(() => {}),
667
- ]);
701
+ await raceWaiters(job.waiters, { timeoutMs });
668
702
  return job;
669
703
  }
670
704
 
@@ -51,7 +51,7 @@ exit /b 64
51
51
 
52
52
  :help
53
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.
54
+ echo --open Start the 3080 console and its supervised 3210 backend, then open the console.
55
+ echo --background Start the 3080 console and its supervised 3210 backend silently.
56
+ echo --watch Keep the 3080 console and supervised 3210 backend healthy.
57
57
  exit /b 0
@@ -15,8 +15,8 @@ $logRoot = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
15
15
  $launcherLog = Join-Path $logRoot 'dsh-crew-launcher.log'
16
16
  $startedAt = Get-Date
17
17
  $services = @(
18
- [pscustomobject]@{ Name = 'Crew backend'; Profile = 'dsh-crew'; Home = $crewHome; Port = 3210; Url = 'http://127.0.0.1:3210'; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null },
19
- [pscustomobject]@{ Name = 'Official UI'; Profile = 'web'; Home = $officialHome; Port = 3080; Url = 'http://127.0.0.1:3080'; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null }
18
+ [pscustomobject]@{ Name = 'Official UI'; Profile = 'web'; Home = $officialHome; Port = 3080; Url = 'http://127.0.0.1:3080'; ManagedByBridge = $false; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null },
19
+ [pscustomobject]@{ Name = 'Crew backend'; Profile = 'dsh-crew'; Home = $crewHome; Port = 3210; Url = 'http://127.0.0.1:3210'; ManagedByBridge = $true; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null }
20
20
  )
21
21
 
22
22
  function Write-LaunchLog {
@@ -44,6 +44,18 @@ function Get-HealthState {
44
44
  }
45
45
  }
46
46
 
47
+ function Start-BridgedCrewService {
48
+ try {
49
+ $response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/_dsh/dsh-crew/ping' -TimeoutSec 5
50
+ if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300) {
51
+ return [pscustomobject]@{ Ready = $true; Error = $null }
52
+ }
53
+ return [pscustomobject]@{ Ready = $false; Error = ('3080 bridge returned HTTP {0}.' -f $response.StatusCode) }
54
+ } catch {
55
+ return [pscustomobject]@{ Ready = $false; Error = ('3080 bridge could not start the supervised 3210 backend: {0}' -f $_.Exception.Message) }
56
+ }
57
+ }
58
+
47
59
  function Get-PortState {
48
60
  param([int] $Port)
49
61
  try {
@@ -175,7 +187,7 @@ function Wait-CrewServices {
175
187
  if ($health.Ready) {
176
188
  $service.State = 'ready'
177
189
  $service.ConsecutiveFailures = 0
178
- [void] (Set-TrackedListenerIdentity -Service $service)
190
+ if (-not $service.ManagedByBridge) { [void] (Set-TrackedListenerIdentity -Service $service) }
179
191
  Write-LaunchLog ('{0} ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
180
192
  } elseif ($service.Process -and $service.Process.HasExited) {
181
193
  throw ('{0} exited before becoming ready; PID={1}; exit={2}; last health error: {3}' -f $service.Name, $service.Process.Id, $service.Process.ExitCode, $health.Error)
@@ -184,7 +196,9 @@ function Wait-CrewServices {
184
196
  if (@($services | Where-Object State -eq 'starting').Count -gt 0) { Start-Sleep -Milliseconds 500 }
185
197
  }
186
198
 
187
- $notReady = @($services | Where-Object State -ne 'ready')
199
+ $notReady = @($services | Where-Object {
200
+ $_.State -ne 'ready' -and -not ($_.ManagedByBridge -and $_.State -eq 'pending')
201
+ })
188
202
  if ($notReady.Count -gt 0) {
189
203
  $details = ($notReady | ForEach-Object { '{0}:{1} ({2})' -f $_.Profile, $_.Port, $_.LastError }) -join '; '
190
204
  throw "Startup health deadline exceeded: $details"
@@ -193,21 +207,36 @@ function Wait-CrewServices {
193
207
 
194
208
  function Ensure-CrewServices {
195
209
  param([switch] $QuietHealthy)
210
+ $officialRestarted = $false
196
211
 
197
212
  foreach ($service in $services) {
198
213
  $health = Get-HealthState $service
214
+ if ($service.ManagedByBridge -and $officialRestarted) {
215
+ # A restarted 3080 process must re-establish ownership of 3210 through
216
+ # its own sidecar, even if an old detached listener still answers.
217
+ $service.State = 'pending'
218
+ $service.LastError = 'Waiting for the restarted 3080 bridge to claim 3210.'
219
+ continue
220
+ }
199
221
  if ($health.Ready) {
200
222
  $wasReady = $service.State -eq 'ready'
201
223
  $service.State = 'ready'
202
224
  $service.ConsecutiveFailures = 0
203
225
  $service.LastError = $null
204
- if (-not $service.ListenerPid) { [void] (Set-TrackedListenerIdentity -Service $service) }
226
+ if (-not $service.ManagedByBridge -and -not $service.ListenerPid) { [void] (Set-TrackedListenerIdentity -Service $service) }
205
227
  if (-not $QuietHealthy -or -not $wasReady) {
206
228
  Write-LaunchLog ('{0} already ready on {1}; runtime={2}' -f $service.Name, $service.Port, $health.Version)
207
229
  }
208
230
  continue
209
231
  }
210
232
 
233
+ if ($service.ManagedByBridge) {
234
+ $service.LastError = $health.Error
235
+ $service.ConsecutiveFailures += 1
236
+ $service.State = 'pending'
237
+ continue
238
+ }
239
+
211
240
  $service.LastError = $health.Error
212
241
  $service.ConsecutiveFailures += 1
213
242
  if ($service.Process -and $service.Process.HasExited) {
@@ -242,9 +271,24 @@ function Ensure-CrewServices {
242
271
  Write-LaunchLog ('Supervisor detected {0} unavailable on {1}; restarting it.' -f $service.Name, $service.Port) 'WARN'
243
272
  }
244
273
  Start-CrewService $service
274
+ if (-not $service.ManagedByBridge) { $officialRestarted = $true }
245
275
  }
246
276
 
247
277
  Wait-CrewServices
278
+
279
+ # The 3080 official bridge is the sole owner of the 3210 child. Trigger it
280
+ # only after 3080 is healthy, then wait on the direct 3210 runtime contract.
281
+ foreach ($service in ($services | Where-Object ManagedByBridge -eq $true | Where-Object State -ne 'ready')) {
282
+ $bridge = Start-BridgedCrewService
283
+ if (-not $bridge.Ready) {
284
+ $service.LastError = $bridge.Error
285
+ throw $bridge.Error
286
+ }
287
+ $service.State = 'starting'
288
+ $service.ConsecutiveFailures = 0
289
+ $service.LastError = $null
290
+ }
291
+ if (@($services | Where-Object State -eq 'starting').Count -gt 0) { Wait-CrewServices }
248
292
  }
249
293
 
250
294
  function Start-ServiceSupervisor {