@ran-sh/dsh-crew 2.1.8 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/docs/job-contracts.md +14 -1
  3. package/docs/readiness-matrix.md +20 -1
  4. package/lib/client.js +9 -8
  5. package/package.json +1 -1
  6. package/scripts/setup.mjs +6 -27
  7. package/scripts/verify-npm-install.mjs +2 -2
  8. package/skills/dsh-crew/SKILL.md +24 -15
  9. package/src/adaptive-routing.mjs +2 -2
  10. package/src/ci-evidence.mjs +1 -1
  11. package/src/client/history-panel.tsx +6 -5
  12. package/src/client/quick-panel.tsx +0 -1
  13. package/src/credential-lifecycle.mjs +0 -3
  14. package/src/credential-purge-state.mjs +1 -5
  15. package/src/credential-reference.mjs +0 -8
  16. package/src/crew-workspaces.mjs +136 -0
  17. package/src/delivery.mjs +2 -2
  18. package/src/dsh-cli-runtime.mjs +18 -22
  19. package/src/extension-contract.mjs +1 -1
  20. package/src/failure-classification.mjs +0 -10
  21. package/src/history/cleanup-plan.mjs +15 -5
  22. package/src/hub/entry.mjs +1 -1
  23. package/src/hub/index.mjs +45 -19
  24. package/src/i18n.mjs +0 -2
  25. package/src/install/crew-skill.mjs +1 -1
  26. package/src/install/install-legacy.mjs +3 -3
  27. package/src/install/npx-lifecycle.mjs +60 -112
  28. package/src/install/official-web.mjs +1 -1
  29. package/src/install/windows-startup.mjs +5 -5
  30. package/src/install/windows-supervisor-adapter.mjs +3 -2
  31. package/src/install/windows-supervisor-lifecycle.mjs +2 -2
  32. package/src/install/zcode.mjs +0 -2
  33. package/src/job-contracts.mjs +1 -1
  34. package/src/job-identity.mjs +13 -6
  35. package/src/jobs.mjs +3 -4
  36. package/src/mcp-runtime.mjs +17 -12
  37. package/src/model-routing.mjs +3 -4
  38. package/src/model-schedule.mjs +0 -1
  39. package/src/policy-legacy.mjs +10 -12
  40. package/src/provider-inventory.mjs +1 -1
  41. package/src/provider-layer-migration.mjs +0 -2
  42. package/src/readiness-matrix.mjs +1 -1
  43. package/src/release-in-use.mjs +1 -1
  44. package/src/role-profiles.mjs +2 -2
  45. package/src/runtime-controls.mjs +1 -1
  46. package/src/runtime-identity.mjs +1 -1
  47. package/src/status-shard.mjs +1 -1
  48. package/src/supervisor/restart-request.mjs +4 -4
  49. package/src/workflow-runtime.mjs +1 -2
  50. package/src/workspace-context.mjs +2 -2
  51. package/src/workspace-isolation.mjs +71 -4
  52. package/src/workspace-lock.mjs +134 -0
  53. package/scripts/policy-probe.mjs +0 -101
  54. package/scripts/verify-crew-ui-polish.mjs +0 -145
  55. package/scripts/verify-history-ui.mjs +0 -98
  56. package/scripts/verify-installer-fix.mjs +0 -26
@@ -5,6 +5,7 @@ import { existsSync, readFileSync } from 'node:fs';
5
5
  import { join, win32 } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
7
 
8
+ import { RELEASE_COHORT_FILENAME } from '../dsh-cohort.mjs';
8
9
  import {
9
10
  readMaintenanceSession,
10
11
  readSupervisorHeartbeat,
@@ -47,7 +48,7 @@ export function hashSupervisorHelper(file) {
47
48
  try { return createHash('sha256').update(readFileSync(file)).digest('hex'); } catch { return null; }
48
49
  }
49
50
 
50
- export function canonicalWindowsPowerShellPath({
51
+ function canonicalWindowsPowerShellPath({
51
52
  environment = process.env,
52
53
  exists = existsSync,
53
54
  } = {}) {
@@ -344,7 +345,7 @@ function prepareWindowsSupervisorConvergence({ home, root, platform }) {
344
345
  let expectedDshVersion = text(manifest?.dependencies?.['@deepseek-ai/dsh'] ?? manifest?.peerDependencies?.['@deepseek-ai/dsh']);
345
346
  if (!EXACT_VERSION_RE.test(expectedDshVersion ?? '')) {
346
347
  try {
347
- const cohort = JSON.parse(readFileSync(join(root, 'release-cohort.json'), 'utf8'));
348
+ const cohort = JSON.parse(readFileSync(join(root, RELEASE_COHORT_FILENAME), 'utf8'));
348
349
  expectedDshVersion = text(cohort?.dsh_version);
349
350
  } catch { expectedDshVersion = null; }
350
351
  }
@@ -10,8 +10,8 @@ import {
10
10
  import { dirname, join } from 'node:path';
11
11
  import { compareProcessToken, processStartToken } from '../process-identity.mjs';
12
12
 
13
- export const WINDOWS_SUPERVISOR_HANDOFF_SCHEMA = 1;
14
- export const WINDOWS_SUPERVISOR_HANDOFF_PHASES = Object.freeze([
13
+ const WINDOWS_SUPERVISOR_HANDOFF_SCHEMA = 1;
14
+ const WINDOWS_SUPERVISOR_HANDOFF_PHASES = Object.freeze([
15
15
  'planned',
16
16
  'backend-stopped',
17
17
  'old-watcher-stopped',
@@ -395,5 +395,3 @@ export function uninstallZCode({ home = homedir(), env = process.env } = {}) {
395
395
  }
396
396
  return { ok: true, actions };
397
397
  }
398
-
399
- export function zcodeOwnershipFile({ home = homedir() } = {}) { return OWNERSHIP_FILE({ home }); }
@@ -5,7 +5,7 @@
5
5
  // internal details remain available only through an explicit detail=full
6
6
  // request at the MCP boundary.
7
7
 
8
- export const JOB_CONTRACT_SCHEMA_VERSION = 1;
8
+ const JOB_CONTRACT_SCHEMA_VERSION = 1;
9
9
 
10
10
  export const JOB_EVENT_TYPES = Object.freeze([
11
11
  'job.created',
@@ -7,13 +7,20 @@
7
7
  // of the first message the agent receives — so they only agree if they are built
8
8
  // from the same string. This module is that string.
9
9
  //
10
- // The shape is `Crew_<YYYYMMDD>_<HHMMSS>_<purpose>`, matching the worktree naming
11
- // rule; `src/workspace-isolation.mjs` allocates worktrees in the same shape, and a
12
- // test asserts that every name produced here satisfies its ownership grammar, so
13
- // the two cannot drift apart silently.
10
+ // The shape is `Crew_<YYYYMMDD>_<HHMMSS>_<purpose>`, matching the disposable
11
+ // worktree naming rule; `src/workspace-isolation.mjs` allocates those in the same
12
+ // shape, and a test asserts that every name produced here satisfies its ownership
13
+ // grammar, so the two cannot drift apart silently. A job that runs in a
14
+ // caller-supplied Crew worktree is still named after that directory.
15
+ //
16
+ // A job running in one of the stable per-role workspaces is the exception, and
17
+ // the agreement above deliberately does not apply to it: `dsh-crew-worker` is
18
+ // shared by every job that uses it, so naming jobs after their workspace would
19
+ // give them all one title. There the workspace groups the sessions and this name
20
+ // tells them apart.
14
21
 
15
- export const JOB_NAME_PREFIX = 'Crew_';
16
- export const JOB_PURPOSE_MAX = 32;
22
+ const JOB_NAME_PREFIX = 'Crew_';
23
+ const JOB_PURPOSE_MAX = 32;
17
24
 
18
25
  export const JOB_NAME_RE = /^Crew_\d{8}_\d{6}_[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*(?:-\d+)?$/;
19
26
 
package/src/jobs.mjs CHANGED
@@ -14,8 +14,8 @@ import { createShardWriter } from './status-shard.mjs';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { dirname, join, resolve, basename } from 'node:path';
16
16
  import { homedir } from 'node:os';
17
- import { appendDeliveryInstructions, prependJobIdentity, parseDeliveryReport, formatDeliveryMetadata } from './delivery.mjs';
18
- import { jobDisplayName } from './job-identity.mjs';
17
+ import { appendDeliveryInstructions, prependJobIdentity, parseDeliveryReport, formatDeliveryMetadata } from './delivery.mjs';
18
+ import { jobDisplayName } from './job-identity.mjs';
19
19
  import { isCrewWorktreeName } from './workspace-isolation.mjs';
20
20
  import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from './workspace-audit.mjs';
21
21
  import { buildOutcome, JOB_PHASES } from './workflow.mjs';
@@ -28,7 +28,6 @@ import { readGlobalConfig } from './install/install.mjs';
28
28
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
29
29
  const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
30
30
  const CREW_DSH_HOME = join(homedir(), '.config', 'dsh-crew', 'harness');
31
- const STATUS_FILE = join(CONFIG_DIR, 'status.json');
32
31
  const CORDIS = join(ROOT, 'worker.cordis.yml');
33
32
 
34
33
  // Read the operator's schedule once per dispatch. An unreadable config must not
@@ -38,7 +37,7 @@ function readModelSchedule() {
38
37
  try { return normalizeModelSchedule(readGlobalConfig().model_schedule); } catch { return normalizeModelSchedule(undefined); }
39
38
  }
40
39
 
41
- export const TIERS = {
40
+ const TIERS = {
42
41
  flash: { model: 'deepseek-v4-flash', label: 'V4 Flash' },
43
42
  pro: { model: 'deepseek-v4-pro', label: 'V4 Pro' },
44
43
  };
@@ -11,7 +11,6 @@ import { createWorkflowRuntime } from './workflow-runtime.mjs';
11
11
  import { CONFIG_SCHEMA_VERSION, normalizeGlobalConfig } from './policy.mjs';
12
12
  import { buildDirectSelectionTrace, enrichSelectionTrace } from './model-routing.mjs';
13
13
  import {
14
- createIsolatedWorkspace,
15
14
  cleanupIsolatedWorkspace,
16
15
  inspectRepository,
17
16
  captureCandidate as captureIsolationCandidate,
@@ -19,6 +18,7 @@ import {
19
18
  import { startJob, waitJob, jobView, cancelJob } from './jobs.mjs';
20
19
  import { hub } from './hub-client.mjs';
21
20
  import { crewHarnessSessionsDir } from './install/crew-paths.mjs';
21
+ import { ensureCrewWorkspace, releaseCrewWorkspace } from './crew-workspaces.mjs';
22
22
 
23
23
  const SESSION_CONFIG_KEYS = [
24
24
  'default_tier', 'default_effort', 'mode', 'default_timeout_seconds',
@@ -301,25 +301,27 @@ export function buildMcpWorkflowRuntime(deps) {
301
301
  if (!repo.ok) {
302
302
  return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error: `${job.role ?? 'worker'} needs an isolated git worktree: ${repo.error ?? repo.reason}` };
303
303
  }
304
- // The worktree name carries what the job was for, so an operator reading the
305
- // directory list can tell a worker tree from a reviewer tree without opening
306
- // anything. Role is the honest answer; it is what the job actually is.
307
- const created = await createIsolatedWorkspace({
304
+ // The job runs in this project's stable worker/reviewer workspace rather
305
+ // than a worktree of its own, so the sessions the Harness groups by cwd
306
+ // accumulate in two entries instead of one per job. The workspace is reset
307
+ // to the job's base revision on the way in, so the candidate diff is still
308
+ // this job's work alone, and it is held for the job's duration so two jobs
309
+ // cannot share it.
310
+ const workspace = await ensureCrewWorkspace({
308
311
  cwd: job.requested_cwd,
309
- jobId: job.id,
310
- purpose: job.role ?? 'job',
312
+ role: job.role ?? 'worker',
311
313
  baseRevision: job.workspace_branch ?? repo.baseRevision,
312
314
  });
313
- if (!created.ok) {
314
- return { ok: false, reason: created.reason ?? 'WORKTREE_CREATE_FAILED', error: `worktree create failed: ${created.error ?? ''}` };
315
+ if (!workspace.ok) {
316
+ return { ok: false, reason: workspace.reason ?? 'WORKTREE_CREATE_FAILED', error: `workspace unavailable: ${workspace.error ?? ''}` };
315
317
  }
316
318
  return {
317
319
  ok: true,
318
- execution_cwd: created.worktreePath,
319
- base_revision: created.baseRevision,
320
+ execution_cwd: workspace.worktreePath,
321
+ base_revision: workspace.baseRevision,
320
322
  isolation: 'worktree',
321
323
  primary_workspace_dirty: repo.dirty === true,
322
- handle: { worktreePath: created.worktreePath, repoRoot: created.repoRoot },
324
+ handle: { worktreePath: workspace.worktreePath, repoRoot: workspace.repoRoot, stable: true, release: workspace.release },
323
325
  };
324
326
  };
325
327
 
@@ -327,6 +329,9 @@ export function buildMcpWorkflowRuntime(deps) {
327
329
 
328
330
  const releaseWorkspace = async (handle) => {
329
331
  if (!handle) return { ok: true };
332
+ // A stable workspace is not disposed of — it is the directory the next job
333
+ // and every session it produced belong to. Only the lock is dropped.
334
+ if (handle.stable === true) return { ok: releaseCrewWorkspace(handle) };
330
335
  try {
331
336
  const r = await cleanupIsolatedWorkspace({ worktreePath: handle.worktreePath, repoRoot: handle.repoRoot });
332
337
  return { ok: r.ok, error: r.ok ? undefined : r.error };
@@ -7,7 +7,7 @@
7
7
  import { rankAdaptiveCandidates } from './adaptive-routing.mjs';
8
8
  import { scheduleAdmission } from './model-schedule.mjs';
9
9
 
10
- export const DEFAULT_TIER_MODEL_PREFERENCES = Object.freeze({
10
+ const DEFAULT_TIER_MODEL_PREFERENCES = Object.freeze({
11
11
  flash: 'deepseek-v4-flash',
12
12
  pro: 'deepseek-v4-pro',
13
13
  });
@@ -20,9 +20,8 @@ export const DEFAULT_ROLE_MODEL_PREFERENCES = Object.freeze({
20
20
  reviewer: 'deepseek-v4-pro',
21
21
  });
22
22
 
23
- export const MODEL_FALLBACKS = ['harness-default'];
24
23
  export const NO_WORKER_MODEL_AVAILABLE = 'NO_WORKER_MODEL_AVAILABLE';
25
- export const MODEL_SELECTION_TRACE_VERSION = 1;
24
+ const MODEL_SELECTION_TRACE_VERSION = 1;
26
25
  export const MODEL_SELECTION_REASON_CODES = Object.freeze({
27
26
  PROVIDER_UNAVAILABLE: 'PROVIDER_UNAVAILABLE',
28
27
  PREFERRED_MODEL_UNAVAILABLE: 'PREFERRED_MODEL_UNAVAILABLE',
@@ -61,7 +60,7 @@ const BLOCKED_MODEL_CODES = Object.freeze({
61
60
  [MODEL_SELECTION_REASON_CODES.PEAK_RESTRICTED]: 'MODEL_BLOCKED_PEAK',
62
61
  });
63
62
 
64
- export function normalizeModelRef(raw) {
63
+ function normalizeModelRef(raw) {
65
64
  if (!raw || typeof raw !== 'object') return null;
66
65
  const provider = typeof raw.provider === 'string' ? raw.provider.trim() : '';
67
66
  const model = typeof raw.model === 'string' ? raw.model.trim() : '';
@@ -27,7 +27,6 @@ export const DEFAULT_PEAK_WINDOWS = Object.freeze([
27
27
 
28
28
  export const DEFAULT_TIMEZONE_OFFSET_MINUTES = 8 * 60;
29
29
  export const DEFAULT_PEAK_WEEKDAYS = Object.freeze([1, 2, 3, 4, 5]);
30
- export const MINUTES_PER_DAY = 24 * 60;
31
30
  // A fixed offset is bounded by the real-world range of UTC offsets; anything
32
31
  // wider is a typo, not a timezone.
33
32
  const MAX_OFFSET_MINUTES = 14 * 60;
@@ -21,8 +21,8 @@ import { normalizeModelPriority } from './model-routing.mjs';
21
21
 
22
22
  export const TIER_STATES = ['disabled', 'manual', 'auto'];
23
23
  export const COLLABORATION_MODES = ['flash-only', 'pro-only', 'balanced', 'review-pipeline', 'custom'];
24
- export const MAIN_AGENT_MODES = ['direct-allowed', 'coordinator-first', 'dispatcher-only'];
25
- export const ROLE_IDS = [
24
+ const MAIN_AGENT_MODES = ['direct-allowed', 'coordinator-first', 'dispatcher-only'];
25
+ const ROLE_IDS = [
26
26
  'implementation',
27
27
  'simple_fix',
28
28
  'tests',
@@ -52,7 +52,7 @@ export const POLICY_ERROR_CODES = {
52
52
  ROLE_TIER_CONFLICT: 'ROLE_TIER_CONFLICT',
53
53
  };
54
54
 
55
- export const POLICY_ERROR_MESSAGES = {
55
+ const POLICY_ERROR_MESSAGES = {
56
56
  [POLICY_ERROR_CODES.SUBAGENTS_DISABLED]:
57
57
  'DSH Crew worker dispatch is disabled.',
58
58
  [POLICY_ERROR_CODES.TIER_DISABLED]:
@@ -89,18 +89,16 @@ export const POLICY_ERROR_MESSAGES = {
89
89
  // Everything new here reads a canonical config shape produced by
90
90
  // migrateLegacyConfig; the legacy collab/tier functions stay for compatibility.
91
91
 
92
- export const DISPATCH_ROLES = ['worker', 'reviewer'];
93
- export const ROLE_STATES = TIER_STATES; // disabled | manual | auto
94
92
  export const ROLE_MODEL_STRATEGIES = ['economy', 'balanced', 'quality', 'strong'];
95
- export const DEFAULT_WORKER_STRATEGY = 'balanced';
96
- export const DEFAULT_REVIEWER_STRATEGY = 'strong';
93
+ const DEFAULT_WORKER_STRATEGY = 'balanced';
94
+ const DEFAULT_REVIEWER_STRATEGY = 'strong';
97
95
  export const DEFAULT_MAX_PARALLEL = 3;
98
96
 
99
- export function normalizeRoleState(raw) {
97
+ function normalizeRoleState(raw) {
100
98
  return normalizeState(raw);
101
99
  }
102
100
 
103
- export function normalizeIsolation(raw) {
101
+ function normalizeIsolation(raw) {
104
102
  return raw === 'worktree' || raw === 'shared' ? raw : 'worktree';
105
103
  }
106
104
 
@@ -117,7 +115,7 @@ function normalizeMaxParallel(raw) {
117
115
  * escalation_priority_configured, fallback, escalation: { enabled, max_attempts } }.
118
116
  * Pure; unknown fields drop, empty priority falls back to the role default.
119
117
  */
120
- export function normalizeModelPolicy(raw = {}) {
118
+ function normalizeModelPolicy(raw = {}) {
121
119
  return {
122
120
  role: raw.role === 'reviewer' ? 'reviewer' : 'worker',
123
121
  strategy: ROLE_MODEL_STRATEGIES.includes(raw.strategy)
@@ -444,7 +442,7 @@ export function resolveHubWorkerProvider({ worker_provider_mode, getCurrentSelec
444
442
  }
445
443
 
446
444
  /** Structured policy error; `code` is machine-readable, `message` user-facing. */
447
- export function policyError(code, extra = {}) {
445
+ function policyError(code, extra = {}) {
448
446
  const base = POLICY_ERROR_MESSAGES[code] ?? code;
449
447
  return Object.assign(new Error(base.replace('{tier}', extra.tier ?? 'pro')), { policyCode: code });
450
448
  }
@@ -677,7 +675,7 @@ export function shouldRunProReview(config, session = {}) {
677
675
  }
678
676
 
679
677
  /** Roles for a tier (host guidance for who does what, not a hard classifier). */
680
- export function getTierRoles(config, tier) {
678
+ function getTierRoles(config, tier) {
681
679
  const roles = tier === 'flash' ? config.flash_roles : config.pro_roles;
682
680
  return normalizeRoles(roles, tier === 'flash' ? DEFAULT_FLASH_ROLES : DEFAULT_PRO_ROLES);
683
681
  }
@@ -9,7 +9,7 @@ import { classifyCredentialReference } from './credential-reference.mjs';
9
9
  const LIFECYCLE_ORIGINS = new Set(['builtin', 'profile-managed', 'dynamic', 'unknown']);
10
10
  const OWNERSHIPS = new Set(['harness', 'crew-managed-profile', 'user-managed-profile', 'dynamic-user', 'unknown']);
11
11
  const MUTABLE_AUTHORITY_KINDS = new Set(['crew-profile', 'harness-settings']);
12
- export const IMMUTABLE_HARNESS_PROVIDER_IDS = Object.freeze(['deepseek-official']);
12
+ const IMMUTABLE_HARNESS_PROVIDER_IDS = Object.freeze(['deepseek-official']);
13
13
 
14
14
  function text(value) {
15
15
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -120,8 +120,6 @@ export function hasProviderLayerMigration(plan) {
120
120
  return Array.isArray(plan?.providers) && plan.providers.length > 0;
121
121
  }
122
122
 
123
- const MIGRATION_STATES = Object.freeze(['PLANNED', 'APPLIED', 'RESTART_PENDING', 'VERIFYING', 'VERIFIED', 'FAILED']);
124
-
125
123
  function migrationCode(error, fallback = 'PROVIDER_MIGRATION_FAILED') {
126
124
  const code = text(error?.code);
127
125
  return code && /^[A-Z][A-Z0-9_]{1,63}$/u.test(code) ? code : fallback;
@@ -5,7 +5,7 @@
5
5
  // (Hub handshake / catalog read) are intentionally separate from execution
6
6
  // verification (real worker, reviewer, cancellation, timeout, etc.).
7
7
 
8
- export const READINESS_STATUSES = Object.freeze(['PASS', 'FAIL', 'BLOCKED', 'SKIP', 'NOT_RUN']);
8
+ const READINESS_STATUSES = Object.freeze(['PASS', 'FAIL', 'BLOCKED', 'SKIP', 'NOT_RUN']);
9
9
 
10
10
  export const READINESS_REASON_CODES = Object.freeze({
11
11
  LIVE_CHECK_PASSED: 'LIVE_CHECK_PASSED',
@@ -22,7 +22,7 @@ import { homedir } from 'node:os';
22
22
  import { dirname, join, resolve } from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
 
25
- export const IN_USE_DIRNAME = 'in-use';
25
+ const IN_USE_DIRNAME = 'in-use';
26
26
 
27
27
  // A claim file is `<pid>.json` or `<pid>-<nonce>.json`. The PID alone is not
28
28
  // enough to identify a claim: two mounts inside one process would publish to the
@@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
6
6
  import { dirname, join } from 'node:path';
7
7
  import { homedir } from 'node:os';
8
8
 
9
- export const ROLE_PROFILE_SCHEMA_VERSION = 1;
9
+ const ROLE_PROFILE_SCHEMA_VERSION = 1;
10
10
  export const DEFAULT_ROLE_PROFILES = Object.freeze({
11
11
  'worker-default': Object.freeze({
12
12
  role: 'worker', routing: 'auto', isolation: 'worktree', fallback: true,
@@ -45,7 +45,7 @@ function normalizeProfile(id, raw) {
45
45
  };
46
46
  }
47
47
 
48
- export function roleProfilesFile({ home = homedir() } = {}) {
48
+ function roleProfilesFile({ home = homedir() } = {}) {
49
49
  return join(home, '.config', 'dsh-crew', 'profiles.json');
50
50
  }
51
51
 
@@ -22,7 +22,7 @@ const R = ACTIVATION_BOUNDARY.RESTART_REQUIRED;
22
22
  * running MCP process. `session: null` means the setting is not a session
23
23
  * override surface.
24
24
  */
25
- export const RUNTIME_SETTING_ACTIVATION = Object.freeze({
25
+ const RUNTIME_SETTING_ACTIVATION = Object.freeze({
26
26
  max_parallel: Object.freeze({ global: L, session: null, note: 'Admission limit refreshes before new workflow starts and when slots release; lowering never cancels active work.' }),
27
27
 
28
28
  subagents_enabled: Object.freeze({ global: N, session: N }),
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '2.1.8';
39
+ export const RUNTIME_VERSION = '2.2.1';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -9,7 +9,7 @@ import { homedir } from 'node:os';
9
9
 
10
10
  const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
11
11
  const SHARD_DIR = join(CONFIG_DIR, 'status.d');
12
- export const SHARD_FRESH_MS = 30 * 60 * 1000;
12
+ const SHARD_FRESH_MS = 30 * 60 * 1000;
13
13
 
14
14
  export function createShardWriter(kind) {
15
15
  const writer = `${kind}-${process.pid}`;
@@ -18,18 +18,18 @@ import { randomUUID } from 'node:crypto';
18
18
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
 
21
- export const RESTART_REQUEST_SCHEMA = 1;
22
- export const RESTART_REQUEST_TTL_MS = 60_000;
21
+ const RESTART_REQUEST_SCHEMA = 1;
22
+ const RESTART_REQUEST_TTL_MS = 60_000;
23
23
 
24
24
  export function supervisorStateRoot(appRoot) {
25
25
  return join(appRoot, 'supervisor');
26
26
  }
27
27
 
28
- export function restartRequestsDir(appRoot) {
28
+ function restartRequestsDir(appRoot) {
29
29
  return join(supervisorStateRoot(appRoot), 'restart-requests');
30
30
  }
31
31
 
32
- export function restartResultsDir(appRoot) {
32
+ function restartResultsDir(appRoot) {
33
33
  return join(supervisorStateRoot(appRoot), 'restart-results');
34
34
  }
35
35
 
@@ -31,7 +31,6 @@ export const WORKFLOW_ERROR_CODES = {
31
31
  REVIEWER_MUTATED_CANDIDATE: 'REVIEWER_MUTATED_CANDIDATE',
32
32
  };
33
33
 
34
- const TERMINAL = new Set([JOB_PHASES.COMPLETED, JOB_PHASES.FAILED, JOB_PHASES.CANCELLED, JOB_PHASES.INTERRUPTED]);
35
34
 
36
35
  function lines(value) {
37
36
  if (typeof value !== 'string' || value.trim() === '') return [];
@@ -817,4 +816,4 @@ export function createWorkflowRuntime(adapters, {
817
816
  return { start, wait, get, list, cancel, runtimeState, setMaxParallel, refreshRuntimeControls };
818
817
  }
819
818
 
820
- export { JOB_PHASES, TERMINAL };
819
+ export { JOB_PHASES };
@@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
6
6
  import { homedir } from 'node:os';
7
7
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
8
8
 
9
- export const WORKSPACE_CONTEXT_SCHEMA_VERSION = 1;
9
+ const WORKSPACE_CONTEXT_SCHEMA_VERSION = 1;
10
10
  const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
11
11
 
12
12
  export function isSafeBranchName(value) {
@@ -51,7 +51,7 @@ function normalizeContext(id, raw) {
51
51
  };
52
52
  }
53
53
 
54
- export function workspaceContextsFile({ home = homedir() } = {}) {
54
+ function workspaceContextsFile({ home = homedir() } = {}) {
55
55
  return join(home, '.config', 'dsh-crew', 'workspaces.json');
56
56
  }
57
57
 
@@ -28,9 +28,9 @@ export const GIT_ERROR = 'GIT_ERROR';
28
28
  /** A valid repository whose HEAD does not resolve because nothing is committed. */
29
29
  export const REPOSITORY_HAS_NO_COMMITS = 'REPOSITORY_HAS_NO_COMMITS';
30
30
  export const WORKTREE_LOCKED = 'WORKTREE_LOCKED';
31
- export const WORKTREE_RESERVE_FAILED = 'WORKTREE_RESERVE_FAILED';
31
+ const WORKTREE_RESERVE_FAILED = 'WORKTREE_RESERVE_FAILED';
32
32
  export const CANDIDATE_CAPTURE_FAILED = 'CANDIDATE_CAPTURE_FAILED';
33
- export const MAX_PARALLEL_CAP = 16;
33
+ const MAX_PARALLEL_CAP = 16;
34
34
  export const DEFAULT_MAX_PARALLEL = 3;
35
35
  // Worktree names read `Crew_YYYYMMDD_HHMMSS_<purpose>` so an operator can tell
36
36
  // from the directory alone when a job ran and what it was for. Worktrees from
@@ -642,8 +642,75 @@ async function ownershipDrift(run, worktreePath, expect) {
642
642
  * the registration is gone and no directory remains — success is never claimed
643
643
  * while a worktree stays registered or on disk.
644
644
  */
645
- export const WORKTREE_CLEANUP_RETRIES = 3;
646
- export const WORKTREE_CLEANUP_BACKOFF_MS = 150;
645
+ const WORKTREE_CLEANUP_RETRIES = 3;
646
+ const WORKTREE_CLEANUP_BACKOFF_MS = 150;
647
+
648
+ /**
649
+ * Is `worktreePath` a worktree Crew created, for this repository?
650
+ *
651
+ * The same identity check the automatic prune uses, exposed for the stable
652
+ * per-role workspaces: they are created once and then adopted by every later
653
+ * job, and "adopt" has to mean the same thing it means to the pruner — shape,
654
+ * name, path, repository and incarnation all matching a record Crew wrote.
655
+ * Anything less would let a directory that merely has the right name be reused
656
+ * as if Crew had made it.
657
+ */
658
+ export async function crewOwnsWorktree({ worktreePath, repoRoot, git } = {}) {
659
+ const run = git ?? defaultRunner;
660
+ const repo = await commonDirOf(run, repoRoot);
661
+ if (!repo) return false;
662
+ const incarnation = await incarnationOf(run, worktreePath);
663
+ return Boolean(readOwnership({ worktreePath, repo, incarnation }));
664
+ }
665
+
666
+ /**
667
+ * Create a worktree of `repoRoot` at an exact path, and record ownership.
668
+ *
669
+ * `createIsolatedWorkspace` reserves a name it invents; the stable per-role
670
+ * workspaces already have their path, so this is the same creation without the
671
+ * reservation step. Ownership is recorded only after git succeeded, so a failed
672
+ * creation never leaves a claim on a tree that does not exist.
673
+ */
674
+ export async function createWorktreeAt({ dir, repoRoot, revision, purpose, at, git } = {}) {
675
+ const run = git ?? defaultRunner;
676
+ const res = await runGit(run, ['worktree', 'add', '--detach', dir, revision], { cwd: repoRoot });
677
+ if (!res.ok) {
678
+ const released = await cleanupIsolatedWorkspace({ worktreePath: dir, repoRoot, git: run, reservation: true });
679
+ return {
680
+ ok: false,
681
+ reason: res.reason,
682
+ error: res.error,
683
+ ...(released.ok ? {} : { cleanupBlocked: true, cleanupError: released.error }),
684
+ };
685
+ }
686
+ const headRes = await runGit(run, ['rev-parse', 'HEAD'], { cwd: dir });
687
+ const created = headRes.ok ? headRes.stdout.trim() : '';
688
+ const commonDir = await commonDirOf(run, repoRoot);
689
+ const incarnation = commonDir ? await claimIncarnation(run, dir) : null;
690
+ const owned = Boolean(commonDir && incarnation && created)
691
+ && recordOwnership({ worktreePath: dir, repo: commonDir, incarnation, head: created, purpose, at });
692
+ return { ok: true, worktreePath: dir, repoRoot, owned };
693
+ }
694
+
695
+ /**
696
+ * Return a stable workspace to a known revision before a job starts.
697
+ *
698
+ * The workspace outlives the job, so without this a job would inherit whatever
699
+ * the previous one left — and the candidate diff, which is taken against a base
700
+ * revision, would then contain someone else's work. `reset --hard` discards
701
+ * tracked modifications; `clean -fd` removes untracked files but deliberately
702
+ * not ignored ones, so a workspace's `node_modules` survives. A reset that fails
703
+ * is reported rather than ignored: starting a job on an unknown tree is what the
704
+ * reset exists to prevent.
705
+ */
706
+ export async function resetWorktreeTo({ worktreePath, revision, git } = {}) {
707
+ const run = git ?? defaultRunner;
708
+ const reset = await runGit(run, ['reset', '--hard', revision], { cwd: worktreePath });
709
+ if (!reset.ok) return { ok: false, reason: reset.reason, error: reset.error };
710
+ const clean = await runGit(run, ['clean', '-fd'], { cwd: worktreePath });
711
+ if (!clean.ok) return { ok: false, reason: clean.reason, error: clean.error };
712
+ return { ok: true };
713
+ }
647
714
 
648
715
  export async function cleanupIsolatedWorkspace({
649
716
  worktreePath,