@ran-sh/dsh-crew 0.3.8 → 0.4.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.
package/src/hub/index.mjs CHANGED
@@ -21,7 +21,14 @@ import { readHarnessModelCatalog } from '../model-catalog.mjs';
21
21
  import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from '../delivery.mjs';
22
22
  import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from '../workspace-audit.mjs';
23
23
  import { buildOutcome, JOB_PHASES } from '../workflow.mjs';
24
- import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
24
+ import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
25
+ import { createCanonicalJobEvent, projectWorkflowView } from '../job-contracts.mjs';
26
+ import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
27
+ import { loadRoleProfiles, resolveRoleProfile, saveRoleProfiles } from '../role-profiles.mjs';
28
+ import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspaceContexts, resolveWorkspaceContext, saveWorkspaceContexts } from '../workspace-context.mjs';
29
+ import { buildExtensionContract } from '../extension-contract.mjs';
30
+ import { cleanupIsolatedWorkspace, createIsolatedWorkspace } from '../workspace-isolation.mjs';
31
+ import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
25
32
 
26
33
  // policy.mjs is pure (no @deepseek-ai imports, no ctx access), so importing it
27
34
  // here is safe for the profile-realm discipline: it never pulls in package
@@ -82,7 +89,35 @@ const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro'
82
89
  // Local copy (the hub must not import jobs.mjs, which pulls the DSH SDK into
83
90
  // the profile realm): a valid dispatch role set.
84
91
  const ROLES = { worker: true, reviewer: true };
85
- const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
92
+ const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
93
+
94
+ export function hubCanonicalEvents(job = {}) {
95
+ if (!job.id) return [];
96
+ const role = job.role === 'reviewer' ? 'reviewer' : 'worker';
97
+ const atStart = job.startedAt ?? null;
98
+ const atEnd = job.endedAt ?? atStart;
99
+ const definitions = [
100
+ ['job.created', atStart, { client_job_id: job.client_job_id ?? null }],
101
+ ['job.started', atStart, {}],
102
+ ['model.selected', atStart, { provider: job.provider ?? null, model: job.model ?? null, source: job.selection_source ?? null }],
103
+ [role === 'reviewer' ? 'review.started' : 'worker.started', atStart, { run_id: job.id }],
104
+ ];
105
+ if (job.status !== 'running') {
106
+ definitions.push([
107
+ role === 'reviewer' ? 'review.completed' : 'worker.completed',
108
+ atEnd,
109
+ role === 'reviewer'
110
+ ? { status: job.status ?? null, verdict: job.review?.verdict ?? null }
111
+ : { status: job.status ?? null, summary: job.outcome?.task_status ?? null },
112
+ ]);
113
+ if (job.status === 'done') definitions.push(['job.completed', atEnd, { result_ref: job.id }]);
114
+ else if (job.status === 'cancelled') definitions.push(['job.cancelled', atEnd, { reason: 'cancelled' }]);
115
+ else definitions.push(['job.failed', atEnd, { error_code: job.error_code ?? null }]);
116
+ }
117
+ return definitions.map(([type, at, data], index) => createCanonicalJobEvent({
118
+ jobId: job.id, type, sequence: index + 1, at, role, attempt: job.attempt ?? 0, data,
119
+ }));
120
+ }
86
121
 
87
122
  // ---------- job registry ----------
88
123
 
@@ -98,7 +133,7 @@ export class WorkerRegistry { constructor(ctx) {
98
133
 
99
134
  view(job, withResult = false) {
100
135
  const v = {
101
- id: job.id, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
136
+ id: job.id, client_job_id: job.client_job_id ?? null, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
102
137
  tier: job.tier, provider: job.provider, model: job.model,
103
138
  selection_source: job.selection_source,
104
139
  selection_trace: job.selection_trace ?? null,
@@ -107,19 +142,27 @@ export class WorkerRegistry { constructor(ctx) {
107
142
  cwd: job.cwd, turn: job.turn, step: job.step, currentTool: job.currentTool,
108
143
  phase: job.phase ?? null,
109
144
  toolCalls: job.toolCalls, tokens: job.tokens, mode: 'hub',
110
- startedAt: job.startedAt, endedAt: job.endedAt,
111
- delivery_complete: !!job.delivery_complete,
112
- workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind === 'git',
145
+ startedAt: job.startedAt, endedAt: job.endedAt,
146
+ isolation: job.isolation ?? 'shared', workspace_branch: job.workspace_branch ?? null,
147
+ delivery_complete: !!job.delivery_complete,
148
+ workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind === 'git',
149
+ workspace_retained: job.workspace_retained === true,
150
+ cleanup_warning: job.cleanup_warning ?? null,
151
+ profile_id: job.profile_id ?? null,
152
+ workspace_context: job.workspace_context ?? null,
153
+ event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
113
154
  };
114
155
  if (withResult) {
115
156
  v.result = job.result; v.error = job.error; v.stopReason = job.stopReason;
116
157
  v.reasonDetail = job.reasonDetail;
117
158
  v.delivery = job.delivery_metadata ?? null;
118
- v.delivery_missing = job.delivery_missing ?? [];
119
- v.outcome = job.outcome ?? null;
159
+ v.delivery_missing = job.delivery_missing ?? [];
160
+ v.outcome = job.outcome ?? null;
161
+ v.review = job.review ?? null;
120
162
  v.workspace_diff = job.workspaceDiff ?? null;
121
- v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
122
- }
163
+ v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
164
+ v.canonical_events = hubCanonicalEvents(job);
165
+ }
123
166
  return v;
124
167
  }
125
168
 
@@ -135,7 +178,7 @@ export class WorkerRegistry { constructor(ctx) {
135
178
  * `role` (worker | reviewer) records who does the work; `tier` remains the
136
179
  * legacy model-class slot. Reviewer-role jobs always use the pro slot.
137
180
  */
138
- async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding' }) {
181
+ async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding', client_job_id, requested_isolation, workspace_branch, timeout_seconds, profile_id, workspace_context }) {
139
182
  // role is only honored when the caller explicitly names it; a legacy
140
183
  // tier-only spawn (role === undefined) keeps the exact v0.1 resolution.
141
184
  const hasRole = role === 'worker' || role === 'reviewer';
@@ -228,27 +271,44 @@ export class WorkerRegistry { constructor(ctx) {
228
271
  const jobRole = hasRole ? role : (delivery === 'review' || role === 'reviewer' ? 'reviewer' : 'worker');
229
272
  const workerPrompt = appendDeliveryInstructions(task, { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
230
273
 
231
- const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
232
- const sessionId = `session-${randomUUID()}`;
233
- const job = {
234
- id, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
274
+ const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
275
+ let executionCwd = cwd;
276
+ let isolatedWorkspace = null;
277
+ if ((requested_isolation === 'worktree' && jobRole === 'worker') || requested_isolation === 'readonly') {
278
+ const created = await createIsolatedWorkspace({ cwd, jobId: id, baseRevision: workspace_branch });
279
+ if (!created.ok) throw Object.assign(new Error(created.error ?? created.reason), { code: created.reason });
280
+ executionCwd = created.worktreePath;
281
+ isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
282
+ }
283
+ const sessionId = `session-${randomUUID()}`;
284
+ const job = {
285
+ id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
235
286
  selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
236
287
  effort, reasoning_effort: selection.reasoningEffort,
237
- task, source, cwd,
288
+ task, source, cwd: executionCwd, requested_cwd: cwd,
289
+ isolation: isolatedWorkspace ? 'worktree' : 'shared',
290
+ workspace_branch: workspace_branch ?? null, isolatedWorkspace,
291
+ profile_id: profile_id ?? null, workspace_context: workspace_context ?? null,
238
292
  prompt: workerPrompt, delivery: delivery === 'review' ? 'review' : 'coding',
239
293
  phase: JOB_PHASES.RUNNING,
240
294
  status: 'running', turn: 0, step: 0, currentTool: null, toolCalls: 0,
241
295
  tokens: { input: 0, output: 0, reasoning: 0 },
242
296
  startedAt: new Date().toISOString(), endedAt: null,
243
297
  result: null, error: null, stopReason: null, handle: null, waiters: [],
244
- delivery_complete: false, delivery_missing: [], delivery_metadata: null,
245
- outcome: null,
246
- texts: [],
247
- };
298
+ delivery_complete: false, delivery_missing: [], delivery_metadata: null,
299
+ outcome: null,
300
+ lastAssistantText: null, handle_dispose_promise: null, disposeHandle: null,
301
+ };
302
+ const disposeJobHandle = async () => {
303
+ if (!job.handle) return;
304
+ job.handle_dispose_promise ??= Promise.resolve().then(() => job.handle.dispose());
305
+ await job.handle_dispose_promise;
306
+ };
307
+ job.disposeHandle = disposeJobHandle;
248
308
  // Read-only pre-run snapshot (async, never blocks dispatch): the audit
249
309
  // only needs the before-state by the time the worker finishes. Non-repos
250
310
  // degrade to { kind:'no-git' } instead of failing the job.
251
- job.baseline = await captureWorkspaceBaseline({ cwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
311
+ job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
252
312
  this.jobs.set(id, job);
253
313
 
254
314
  const presets = this.ctx.get('agentPresets');
@@ -272,9 +332,9 @@ export class WorkerRegistry { constructor(ctx) {
272
332
  job.tokens.output += u.outputTokens ?? 0;
273
333
  job.tokens.reasoning += u.reasoningTokens ?? 0;
274
334
  }
275
- const text = (event.data?.message?.content ?? [])
276
- .filter((c) => c?.type === 'text').map((c) => c.text).join('');
277
- if (text) job.texts.push(text);
335
+ const text = (event.data?.message?.content ?? [])
336
+ .filter((c) => c?.type === 'text').map((c) => c.text).join('');
337
+ if (text) job.lastAssistantText = text;
278
338
  break;
279
339
  }
280
340
  case 'turn/end':
@@ -288,7 +348,7 @@ export class WorkerRegistry { constructor(ctx) {
288
348
  const run = async () => {
289
349
  const handle = await this.ctx.agents.create({
290
350
  sessionId,
291
- meta: { cwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
351
+ meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
292
352
  agentOptions: { provider: selection.provider, model: selection.model },
293
353
  setup: async (agentCtx) => {
294
354
  installModelSelection(agentCtx, { current: selection, assembled: undefined });
@@ -307,29 +367,51 @@ export class WorkerRegistry { constructor(ctx) {
307
367
  // job cwd never inherits a parent directory's workspace).
308
368
  const registry = this.ctx.get('workspaceRegistry');
309
369
  if (registry !== undefined) {
310
- const ws = (await registry.resolveByPath(cwd)) ?? (await registry.create(cwd));
370
+ const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
311
371
  await ws.attachSession(sessionId);
312
372
  }
313
373
  } catch (err) {
314
- this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${cwd}: ${err?.message ?? err}`);
374
+ this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
315
375
  }
316
376
  await handle.agent.whenIdle();
317
377
  handle.agent.followup(userMessage(job.prompt));
318
378
  await handle.agent.whenIdle();
319
- job.result = job.texts.at(-1) ?? '';
320
- job.status = job.stopReason === 'completed' ? 'done' : 'failed';
379
+ job.result = job.lastAssistantText ?? '';
380
+ if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
321
381
  if (job.status === 'failed' && !job.error) job.error = `turn ended: ${job.stopReason ?? 'unknown'}`;
322
382
  await this.ctx.sessions.flush(handle.agent.session);
323
383
  };
324
384
 
325
- job.promise = run()
385
+ const timeoutMs = Number.isInteger(timeout_seconds) && timeout_seconds > 0 ? timeout_seconds * 1000 : null;
386
+ if (timeoutMs) {
387
+ job.timeoutHandle = setTimeout(async () => {
388
+ if (job.status !== 'running') return;
389
+ job.status = 'failed';
390
+ job.error = `job timed out after ${timeout_seconds}s`;
391
+ job.error_code = 'JOB_TIMEOUT';
392
+ job.endedAt = new Date().toISOString();
393
+ this.publish();
394
+ for (const waiter of job.waiters.splice(0)) waiter();
395
+ try { await disposeJobHandle(); } catch (error) {
396
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
397
+ this.publish();
398
+ }
399
+ }, timeoutMs);
400
+ job.timeoutHandle.unref?.();
401
+ }
402
+ job.promise = run()
326
403
  .catch((err) => {
327
404
  if (job.status === 'running') job.status = 'failed';
328
405
  job.error = job.error ?? (err?.message ?? String(err));
329
- })
330
- .finally(async () => {
331
- job.endedAt = new Date().toISOString();
332
- job.currentTool = null;
406
+ })
407
+ .finally(async () => {
408
+ if (job.timeoutHandle) clearTimeout(job.timeoutHandle);
409
+ job.endedAt = new Date().toISOString();
410
+ job.currentTool = null;
411
+ let handleCleanupWarning = job.cleanup_warning ?? null;
412
+ try { await disposeJobHandle(); } catch (error) {
413
+ handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
414
+ }
333
415
  // Delivery completeness is separate from execution status: a job can
334
416
  // be done yet fail to report Diff/Tests/Risks (or Review sections for
335
417
  // an automatic review). Parse whatever final message the worker
@@ -337,7 +419,21 @@ export class WorkerRegistry { constructor(ctx) {
337
419
  const parsed = parseDeliveryReport(job.result ?? '');
338
420
  job.delivery_complete = parsed.complete;
339
421
  job.delivery_missing = parsed.missing;
340
- job.delivery_metadata = formatDeliveryMetadata(parsed);
422
+ job.delivery_metadata = formatDeliveryMetadata(parsed);
423
+ if (job.role === 'reviewer') {
424
+ const verdictText = String(parsed.sections?.Verdict ?? '').trim().toLowerCase();
425
+ const verdict = /^(approve|approved|pass)\b/.test(verdictText)
426
+ ? 'approve'
427
+ : /request.*chang|chang.*request|reject|needs changes/i.test(verdictText)
428
+ ? 'request_changes'
429
+ : 'inconclusive';
430
+ const lines = (value) => String(value ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 40);
431
+ job.review = {
432
+ verdict, status: job.status, delivery_complete: parsed.complete === true,
433
+ findings: lines(parsed.sections?.['Review Findings']),
434
+ evidence: lines(parsed.sections?.Evidence), risks: lines(parsed.sections?.Risks),
435
+ };
436
+ }
341
437
  // Canonical structured outcome (shared workflow layer) + terminal phase.
342
438
  job.outcome = buildOutcome({
343
439
  result: job.result ?? '',
@@ -348,9 +444,26 @@ export class WorkerRegistry { constructor(ctx) {
348
444
  });
349
445
  job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
350
446
  // Read-only after-snapshot of the workspace: bounded, redacted patch.
351
- job.workspaceDiff = job.baseline.kind === 'git'
352
- ? await captureWorkspaceDiff({ cwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
353
- : job.baseline;
447
+ job.workspaceDiff = job.baseline.kind === 'git'
448
+ ? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
449
+ : job.baseline;
450
+ if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
451
+ const changes = job.workspaceDiff.changes ?? {};
452
+ const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
453
+ if (mutated) {
454
+ job.review.mutated_candidate = true;
455
+ job.review.invalidated = true;
456
+ job.review.verdict = 'request_changes';
457
+ }
458
+ }
459
+ if (job.isolatedWorkspace) {
460
+ const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
461
+ job.workspace_retained = cleanup.ok !== true;
462
+ const workspaceCleanupWarning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
463
+ job.cleanup_warning = [handleCleanupWarning, workspaceCleanupWarning].filter(Boolean).join('; ') || null;
464
+ } else {
465
+ job.cleanup_warning = handleCleanupWarning;
466
+ }
354
467
  this.publish();
355
468
  for (const w of job.waiters.splice(0)) w();
356
469
  });
@@ -370,24 +483,30 @@ export class WorkerRegistry { constructor(ctx) {
370
483
  return job;
371
484
  }
372
485
 
373
- async cancel(id) {
486
+ async cancel(id) {
374
487
  const job = this.jobs.get(id);
375
488
  if (!job) return undefined;
376
489
  if (job.status === 'running') {
377
490
  job.status = 'cancelled';
378
491
  job.phase = JOB_PHASES.CANCELLED;
379
492
  job.error = 'cancelled by request';
380
- try { await job.handle?.dispose(); } catch {}
493
+ try { await job.disposeHandle?.(); } catch (error) {
494
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
495
+ }
381
496
  this.publish();
382
497
  }
383
498
  return job;
384
499
  }
385
500
 
386
- async dispose() {
387
- for (const job of this.jobs.values()) {
388
- if (job.status === 'running') { try { await job.handle?.dispose(); } catch {} }
389
- }
390
- }
501
+ async dispose() {
502
+ for (const job of this.jobs.values()) {
503
+ if (job.status === 'running') {
504
+ try { await job.disposeHandle?.(); } catch (error) {
505
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
506
+ }
507
+ }
508
+ }
509
+ }
391
510
  }
392
511
 
393
512
  // ---------- loopback route helpers (pattern from dsh-noema) ----------
@@ -400,11 +519,23 @@ function isLoopbackAddress(address) {
400
519
  if (!n.startsWith('::ffff:')) return false;
401
520
  return isIpv4Loopback(n.slice(7));
402
521
  }
403
- function isLoopbackRequest(req) {
404
- if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
405
- const host = (req.headers.host ?? '').split(':')[0].toLowerCase();
406
- return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || isIpv4Loopback(host);
407
- }
522
+ export function isLoopbackRequest(req) {
523
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
524
+ let host;
525
+ try { host = new URL(`http://${String(req.headers.host ?? '')}`).hostname.toLowerCase(); } catch { return false; }
526
+ if (!(host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || isIpv4Loopback(host))) return false;
527
+ const fetchSite = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase();
528
+ if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false;
529
+ const origin = req.headers?.origin;
530
+ if (origin !== undefined) {
531
+ if (typeof origin !== 'string') return false;
532
+ let parsed;
533
+ try { parsed = new URL(origin); } catch { return false; }
534
+ const originHost = parsed.hostname.toLowerCase();
535
+ if (!(originHost === 'localhost' || originHost === '127.0.0.1' || originHost === '::1' || originHost === '[::1]' || isIpv4Loopback(originHost))) return false;
536
+ }
537
+ return true;
538
+ }
408
539
  function sendJson(res, status, value, headers = {}) {
409
540
  const body = JSON.stringify(value);
410
541
  res.writeHead(status, {
@@ -446,22 +577,92 @@ async function readBody(req, limit = 64 * 1024) {
446
577
  * Returns { ok: true, payload } (payload.tier = effective tier) or
447
578
  * { ok: false, code, error }.
448
579
  */
449
- export function resolveHubSpawnPayload(payload, getConfig = () => ({})) {
450
- const config = normalizeGlobalConfig(getConfig());
451
- const raw = payload ?? {};
452
- // v0.2 role-based dispatch: reviewer / worker are gated by their role state,
453
- // and the tier slot is derived from the role (reviewer always pro).
454
- if (raw.role === 'worker' || raw.role === 'reviewer') {
455
- const hint = resolveRoleTierHint(raw.role, raw.tier);
456
- if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
457
- const decision = canDispatchRole(config, raw.role, true, {});
458
- if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
459
- return { ok: true, payload: { ...raw, role: raw.role, tier: hint.tier } };
460
- }
461
- const decision = chooseDefaultTier(config, raw.tier, {});
462
- if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
463
- return { ok: true, payload: { ...raw, tier: decision.tier } };
464
- }
580
+ export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependencies = {}) {
581
+ const config = normalizeGlobalConfig(getConfig());
582
+ const raw = payload ?? {};
583
+ const advanced = raw.profile !== undefined || raw.workspace_id !== undefined || raw.workspace !== undefined
584
+ || raw.constraints !== undefined || raw.context_refs !== undefined || raw.job_id !== undefined || raw.objective !== undefined;
585
+ let normalized = { ...raw };
586
+ if (advanced) {
587
+ if (raw.workspace !== undefined && (!raw.workspace || typeof raw.workspace !== 'object' || Array.isArray(raw.workspace))) {
588
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace must be an object' };
589
+ }
590
+ if (raw.constraints !== undefined && (!raw.constraints || typeof raw.constraints !== 'object' || Array.isArray(raw.constraints))) {
591
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'constraints must be an object' };
592
+ }
593
+ if (raw.workspace?.repo_root !== undefined && typeof raw.workspace.repo_root !== 'string') {
594
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace repo_root must be a string' };
595
+ }
596
+ if (raw.workspace?.worktree !== undefined && !['auto', 'existing', 'none'].includes(raw.workspace.worktree)) {
597
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid worktree policy' };
598
+ }
599
+ if (raw.workspace?.branch !== undefined && !isSafeBranchName(raw.workspace.branch)) {
600
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid workspace branch' };
601
+ }
602
+ if (raw.constraints?.timeout_seconds !== undefined && (!Number.isInteger(raw.constraints.timeout_seconds) || raw.constraints.timeout_seconds < 1 || raw.constraints.timeout_seconds > 7200)) {
603
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'invalid timeout_seconds' };
604
+ }
605
+ if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
606
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
607
+ }
608
+ const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
609
+ const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
610
+ if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
611
+ if (!workspaceRegistry.ok) return { ok: false, code: 'WORKSPACE_CONTEXT_FILE_INVALID', error: 'workspace registry is invalid' };
612
+ const profileRole = raw.profile ? profileRegistry.profiles?.[raw.profile]?.role : undefined;
613
+ const requestedRole = raw.role ?? profileRole ?? 'worker';
614
+ const resolvedProfile = resolveRoleProfile(profileRegistry, raw.profile, requestedRole);
615
+ if (!resolvedProfile.ok) return { ok: false, code: resolvedProfile.code, error: resolvedProfile.code };
616
+ const knownContext = raw.workspace_id ? workspaceRegistry.contexts?.[raw.workspace_id] : null;
617
+ const cwd = raw.workspace?.repo_root ?? raw.cwd ?? knownContext?.repo_root;
618
+ if (!cwd) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', error: 'workspace repo_root or cwd is required' };
619
+ const workspace = resolveWorkspaceContext(workspaceRegistry, { workspace_id: raw.workspace_id, cwd });
620
+ if (!workspace.ok) return { ok: false, code: workspace.code, error: workspace.code };
621
+ const withRefs = addContextReferences(workspace.context, raw.context_refs, { cwd });
622
+ if (!withRefs.ok) return { ok: false, code: withRefs.code, error: withRefs.code };
623
+ const profile = resolvedProfile.profile;
624
+ const worktree = raw.workspace?.worktree;
625
+ const requestedIsolation = worktree === 'auto' ? 'worktree'
626
+ : worktree === 'existing' || worktree === 'none' ? 'shared'
627
+ : profile.isolation;
628
+ const objective = raw.objective ?? raw.task;
629
+ if (typeof objective !== 'string' || objective.trim() === '') return { ok: false, code: 'JOB_OBJECTIVE_REQUIRED', error: 'task or objective is required' };
630
+ if (raw.job_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(raw.job_id)) {
631
+ return { ok: false, code: 'JOB_ID_INVALID', error: 'invalid client job id' };
632
+ }
633
+ const contextTask = buildWorkspaceTask(objective, withRefs.context);
634
+ normalized = {
635
+ ...raw,
636
+ task: profile.review_strictness === 'strict' && requestedRole === 'reviewer'
637
+ ? `${contextTask}\n\nSTRICT REVIEW: fail closed on missing direct code or test evidence.`
638
+ : contextTask,
639
+ cwd,
640
+ role: requestedRole,
641
+ client_job_id: raw.job_id ?? null,
642
+ requested_isolation: requestedIsolation,
643
+ workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
644
+ timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
645
+ allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
646
+ routing: profile.routing,
647
+ review_strictness: profile.review_strictness,
648
+ profile_id: resolvedProfile.profile_id,
649
+ workspace_context: withRefs.context,
650
+ };
651
+ for (const key of ['objective', 'job_id', 'profile', 'workspace_id', 'workspace', 'constraints', 'context_refs']) delete normalized[key];
652
+ }
653
+ // v0.2 role-based dispatch: reviewer / worker are gated by their role state,
654
+ // and the tier slot is derived from the role (reviewer always → pro).
655
+ if (normalized.role === 'worker' || normalized.role === 'reviewer') {
656
+ const hint = resolveRoleTierHint(normalized.role, normalized.tier);
657
+ if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
658
+ const decision = canDispatchRole(config, normalized.role, true, {});
659
+ if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
660
+ return { ok: true, payload: { ...normalized, role: normalized.role, tier: hint.tier } };
661
+ }
662
+ const decision = chooseDefaultTier(config, normalized.tier, {});
663
+ if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
664
+ return { ok: true, payload: { ...normalized, tier: decision.tier } };
665
+ }
465
666
 
466
667
  // ---------- plugin entry ----------
467
668
 
@@ -535,12 +736,29 @@ export async function apply(ctx) {
535
736
  const foreign = readMergedStatus({ excludeWriter: hub.shard.writer });
536
737
  return sendJson(res, 200, { ok: true, jobs: [...own, ...foreign] });
537
738
  }
538
- if (req.method === 'GET' && parts.length === 1) {
739
+ if (req.method === 'GET' && parts.length === 1) {
539
740
  const wait = Number(url.searchParams.get('wait') ?? 0);
540
741
  const job = await hub.wait(parts[0], Math.min(wait, 600) * 1000);
541
742
  if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
542
- return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
543
- }
743
+ return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
744
+ }
745
+ if (req.method === 'GET' && parts.length === 2 && parts[1] === 'events') {
746
+ const job = hub.jobs.get(parts[0]);
747
+ if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
748
+ const after = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
749
+ const events = hubCanonicalEvents(job).filter((event) => event.sequence > after);
750
+ return sendJson(res, 200, {
751
+ ok: true, job_id: job.id, events,
752
+ event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
753
+ });
754
+ }
755
+ if (req.method === 'GET' && parts.length === 2 && parts[1] === 'contract') {
756
+ const job = hub.jobs.get(parts[0]);
757
+ if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
758
+ const detail = url.searchParams.get('detail') === 'full' ? 'full' : 'compact';
759
+ const afterSequence = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
760
+ return sendJson(res, 200, { ok: true, job: projectWorkflowView(hub.view(job, true), { detail, afterSequence }) });
761
+ }
544
762
  if (req.method === 'POST' && parts.length === 0) {
545
763
  const payload = await readBody(req);
546
764
  // Same policy resolver as the MCP server (src/server.mjs), with the
@@ -564,10 +782,95 @@ export async function apply(ctx) {
564
782
  if (code) body.code = code;
565
783
  return sendJson(res, 400, body);
566
784
  }
567
- },
568
- }));
569
-
570
- disposers.push(webServer.register({
785
+ },
786
+ }));
787
+
788
+ disposers.push(webServer.register({
789
+ kind: 'exact', path: `${ROUTE_BASE}/profiles`,
790
+ handler: async (req, res) => {
791
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
792
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadRoleProfiles() });
793
+ if (req.method === 'POST') {
794
+ try {
795
+ const saved = saveRoleProfiles(await readBody(req));
796
+ return sendJson(res, saved.ok ? 200 : 400, { ...saved });
797
+ } catch {
798
+ return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'PROFILE_FILE_INVALID' });
799
+ }
800
+ }
801
+ return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
802
+ },
803
+ }));
804
+
805
+ disposers.push(webServer.register({
806
+ kind: 'exact', path: `${ROUTE_BASE}/workspaces`,
807
+ handler: async (req, res) => {
808
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
809
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadWorkspaceContexts() });
810
+ if (req.method === 'POST') {
811
+ try {
812
+ const saved = saveWorkspaceContexts(await readBody(req));
813
+ return sendJson(res, saved.ok ? 200 : 400, { ...saved });
814
+ } catch {
815
+ return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'WORKSPACE_CONTEXT_FILE_INVALID' });
816
+ }
817
+ }
818
+ return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
819
+ },
820
+ }));
821
+
822
+ disposers.push(webServer.register({
823
+ kind: 'exact', path: `${ROUTE_BASE}/extension`,
824
+ handler: async (req, res) => {
825
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
826
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
827
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
828
+ let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
829
+ if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
830
+ catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
831
+ } else {
832
+ try {
833
+ await readHarnessModelCatalog({
834
+ llm: ctx.llm ?? ctx.get('llm'),
835
+ getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
836
+ });
837
+ catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
838
+ } catch {
839
+ catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
840
+ }
841
+ }
842
+ const profiles = loadRoleProfiles();
843
+ const requestUrl = new URL(req.url, 'http://localhost');
844
+ const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
845
+ const workspaceRegistry = loadWorkspaceContexts();
846
+ const requestedContext = requestedWorkspaceId ? workspaceRegistry.contexts?.[requestedWorkspaceId] : null;
847
+ const workspaceReadiness = requestedWorkspaceId && !requestedContext
848
+ ? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
849
+ : await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
850
+ const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
851
+ const modelExecution = liveJobs.some((job) => job?.role === 'worker' && job?.status === 'done')
852
+ ? { id: 'model_execution', status: 'PASS', reason_code: 'REAL_EXECUTION_PASSED' }
853
+ : { id: 'model_execution', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' };
854
+ const reviewerExecution = liveJobs.some((job) => job?.role === 'reviewer' && job?.status === 'done')
855
+ ? { id: 'reviewer_pipeline', status: 'PASS', reason_code: 'REAL_REVIEW_PASSED' }
856
+ : { id: 'reviewer_pipeline', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' };
857
+ const contract = buildExtensionContract({
858
+ config,
859
+ readinessMatrix: { rows: [
860
+ { id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
861
+ catalogStatus,
862
+ modelExecution,
863
+ reviewerExecution,
864
+ ] },
865
+ workspace: workspaceReadiness,
866
+ profiles,
867
+ runtime: getHubRuntimeIdentity(),
868
+ });
869
+ return sendJson(res, 200, { ok: true, extension: contract });
870
+ },
871
+ }));
872
+
873
+ disposers.push(webServer.register({
571
874
  kind: 'exact', path: `${ROUTE_BASE}/config`,
572
875
  handler: async (req, res) => {
573
876
  if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });