@ran-sh/dsh-crew 0.3.8 → 0.4.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.
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,38 @@ 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: [],
298
+ delivery_complete: false, delivery_missing: [], delivery_metadata: null,
299
+ outcome: null,
300
+ lastAssistantText: null,
247
301
  };
248
302
  // Read-only pre-run snapshot (async, never blocks dispatch): the audit
249
303
  // only needs the before-state by the time the worker finishes. Non-repos
250
304
  // 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' }));
305
+ job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
252
306
  this.jobs.set(id, job);
253
307
 
254
308
  const presets = this.ctx.get('agentPresets');
@@ -272,9 +326,9 @@ export class WorkerRegistry { constructor(ctx) {
272
326
  job.tokens.output += u.outputTokens ?? 0;
273
327
  job.tokens.reasoning += u.reasoningTokens ?? 0;
274
328
  }
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);
329
+ const text = (event.data?.message?.content ?? [])
330
+ .filter((c) => c?.type === 'text').map((c) => c.text).join('');
331
+ if (text) job.lastAssistantText = text;
278
332
  break;
279
333
  }
280
334
  case 'turn/end':
@@ -288,7 +342,7 @@ export class WorkerRegistry { constructor(ctx) {
288
342
  const run = async () => {
289
343
  const handle = await this.ctx.agents.create({
290
344
  sessionId,
291
- meta: { cwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
345
+ meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
292
346
  agentOptions: { provider: selection.provider, model: selection.model },
293
347
  setup: async (agentCtx) => {
294
348
  installModelSelection(agentCtx, { current: selection, assembled: undefined });
@@ -307,27 +361,42 @@ export class WorkerRegistry { constructor(ctx) {
307
361
  // job cwd never inherits a parent directory's workspace).
308
362
  const registry = this.ctx.get('workspaceRegistry');
309
363
  if (registry !== undefined) {
310
- const ws = (await registry.resolveByPath(cwd)) ?? (await registry.create(cwd));
364
+ const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
311
365
  await ws.attachSession(sessionId);
312
366
  }
313
367
  } catch (err) {
314
- this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${cwd}: ${err?.message ?? err}`);
368
+ this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
315
369
  }
316
370
  await handle.agent.whenIdle();
317
371
  handle.agent.followup(userMessage(job.prompt));
318
372
  await handle.agent.whenIdle();
319
- job.result = job.texts.at(-1) ?? '';
320
- job.status = job.stopReason === 'completed' ? 'done' : 'failed';
373
+ job.result = job.lastAssistantText ?? '';
374
+ if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
321
375
  if (job.status === 'failed' && !job.error) job.error = `turn ended: ${job.stopReason ?? 'unknown'}`;
322
376
  await this.ctx.sessions.flush(handle.agent.session);
323
377
  };
324
378
 
325
- job.promise = run()
379
+ const timeoutMs = Number.isInteger(timeout_seconds) && timeout_seconds > 0 ? timeout_seconds * 1000 : null;
380
+ if (timeoutMs) {
381
+ job.timeoutHandle = setTimeout(async () => {
382
+ if (job.status !== 'running') return;
383
+ job.status = 'failed';
384
+ job.error = `job timed out after ${timeout_seconds}s`;
385
+ job.error_code = 'JOB_TIMEOUT';
386
+ job.endedAt = new Date().toISOString();
387
+ this.publish();
388
+ for (const waiter of job.waiters.splice(0)) waiter();
389
+ try { await job.handle?.dispose(); } catch {}
390
+ }, timeoutMs);
391
+ job.timeoutHandle.unref?.();
392
+ }
393
+ job.promise = run()
326
394
  .catch((err) => {
327
395
  if (job.status === 'running') job.status = 'failed';
328
396
  job.error = job.error ?? (err?.message ?? String(err));
329
397
  })
330
- .finally(async () => {
398
+ .finally(async () => {
399
+ if (job.timeoutHandle) clearTimeout(job.timeoutHandle);
331
400
  job.endedAt = new Date().toISOString();
332
401
  job.currentTool = null;
333
402
  // Delivery completeness is separate from execution status: a job can
@@ -337,7 +406,21 @@ export class WorkerRegistry { constructor(ctx) {
337
406
  const parsed = parseDeliveryReport(job.result ?? '');
338
407
  job.delivery_complete = parsed.complete;
339
408
  job.delivery_missing = parsed.missing;
340
- job.delivery_metadata = formatDeliveryMetadata(parsed);
409
+ job.delivery_metadata = formatDeliveryMetadata(parsed);
410
+ if (job.role === 'reviewer') {
411
+ const verdictText = String(parsed.sections?.Verdict ?? '').trim().toLowerCase();
412
+ const verdict = /^(approve|approved|pass)\b/.test(verdictText)
413
+ ? 'approve'
414
+ : /request.*chang|chang.*request|reject|needs changes/i.test(verdictText)
415
+ ? 'request_changes'
416
+ : 'inconclusive';
417
+ const lines = (value) => String(value ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 40);
418
+ job.review = {
419
+ verdict, status: job.status, delivery_complete: parsed.complete === true,
420
+ findings: lines(parsed.sections?.['Review Findings']),
421
+ evidence: lines(parsed.sections?.Evidence), risks: lines(parsed.sections?.Risks),
422
+ };
423
+ }
341
424
  // Canonical structured outcome (shared workflow layer) + terminal phase.
342
425
  job.outcome = buildOutcome({
343
426
  result: job.result ?? '',
@@ -348,9 +431,23 @@ export class WorkerRegistry { constructor(ctx) {
348
431
  });
349
432
  job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
350
433
  // 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;
434
+ job.workspaceDiff = job.baseline.kind === 'git'
435
+ ? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
436
+ : job.baseline;
437
+ if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
438
+ const changes = job.workspaceDiff.changes ?? {};
439
+ const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
440
+ if (mutated) {
441
+ job.review.mutated_candidate = true;
442
+ job.review.invalidated = true;
443
+ job.review.verdict = 'request_changes';
444
+ }
445
+ }
446
+ if (job.isolatedWorkspace) {
447
+ const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
448
+ job.workspace_retained = cleanup.ok !== true;
449
+ job.cleanup_warning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
450
+ }
354
451
  this.publish();
355
452
  for (const w of job.waiters.splice(0)) w();
356
453
  });
@@ -400,11 +497,23 @@ function isLoopbackAddress(address) {
400
497
  if (!n.startsWith('::ffff:')) return false;
401
498
  return isIpv4Loopback(n.slice(7));
402
499
  }
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
- }
500
+ export function isLoopbackRequest(req) {
501
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
502
+ let host;
503
+ try { host = new URL(`http://${String(req.headers.host ?? '')}`).hostname.toLowerCase(); } catch { return false; }
504
+ if (!(host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || isIpv4Loopback(host))) return false;
505
+ const fetchSite = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase();
506
+ if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false;
507
+ const origin = req.headers?.origin;
508
+ if (origin !== undefined) {
509
+ if (typeof origin !== 'string') return false;
510
+ let parsed;
511
+ try { parsed = new URL(origin); } catch { return false; }
512
+ const originHost = parsed.hostname.toLowerCase();
513
+ if (!(originHost === 'localhost' || originHost === '127.0.0.1' || originHost === '::1' || originHost === '[::1]' || isIpv4Loopback(originHost))) return false;
514
+ }
515
+ return true;
516
+ }
408
517
  function sendJson(res, status, value, headers = {}) {
409
518
  const body = JSON.stringify(value);
410
519
  res.writeHead(status, {
@@ -446,22 +555,92 @@ async function readBody(req, limit = 64 * 1024) {
446
555
  * Returns { ok: true, payload } (payload.tier = effective tier) or
447
556
  * { ok: false, code, error }.
448
557
  */
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
- }
558
+ export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependencies = {}) {
559
+ const config = normalizeGlobalConfig(getConfig());
560
+ const raw = payload ?? {};
561
+ const advanced = raw.profile !== undefined || raw.workspace_id !== undefined || raw.workspace !== undefined
562
+ || raw.constraints !== undefined || raw.context_refs !== undefined || raw.job_id !== undefined || raw.objective !== undefined;
563
+ let normalized = { ...raw };
564
+ if (advanced) {
565
+ if (raw.workspace !== undefined && (!raw.workspace || typeof raw.workspace !== 'object' || Array.isArray(raw.workspace))) {
566
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace must be an object' };
567
+ }
568
+ if (raw.constraints !== undefined && (!raw.constraints || typeof raw.constraints !== 'object' || Array.isArray(raw.constraints))) {
569
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'constraints must be an object' };
570
+ }
571
+ if (raw.workspace?.repo_root !== undefined && typeof raw.workspace.repo_root !== 'string') {
572
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace repo_root must be a string' };
573
+ }
574
+ if (raw.workspace?.worktree !== undefined && !['auto', 'existing', 'none'].includes(raw.workspace.worktree)) {
575
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid worktree policy' };
576
+ }
577
+ if (raw.workspace?.branch !== undefined && !isSafeBranchName(raw.workspace.branch)) {
578
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid workspace branch' };
579
+ }
580
+ if (raw.constraints?.timeout_seconds !== undefined && (!Number.isInteger(raw.constraints.timeout_seconds) || raw.constraints.timeout_seconds < 1 || raw.constraints.timeout_seconds > 7200)) {
581
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'invalid timeout_seconds' };
582
+ }
583
+ if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
584
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
585
+ }
586
+ const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
587
+ const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
588
+ if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
589
+ if (!workspaceRegistry.ok) return { ok: false, code: 'WORKSPACE_CONTEXT_FILE_INVALID', error: 'workspace registry is invalid' };
590
+ const profileRole = raw.profile ? profileRegistry.profiles?.[raw.profile]?.role : undefined;
591
+ const requestedRole = raw.role ?? profileRole ?? 'worker';
592
+ const resolvedProfile = resolveRoleProfile(profileRegistry, raw.profile, requestedRole);
593
+ if (!resolvedProfile.ok) return { ok: false, code: resolvedProfile.code, error: resolvedProfile.code };
594
+ const knownContext = raw.workspace_id ? workspaceRegistry.contexts?.[raw.workspace_id] : null;
595
+ const cwd = raw.workspace?.repo_root ?? raw.cwd ?? knownContext?.repo_root;
596
+ if (!cwd) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', error: 'workspace repo_root or cwd is required' };
597
+ const workspace = resolveWorkspaceContext(workspaceRegistry, { workspace_id: raw.workspace_id, cwd });
598
+ if (!workspace.ok) return { ok: false, code: workspace.code, error: workspace.code };
599
+ const withRefs = addContextReferences(workspace.context, raw.context_refs, { cwd });
600
+ if (!withRefs.ok) return { ok: false, code: withRefs.code, error: withRefs.code };
601
+ const profile = resolvedProfile.profile;
602
+ const worktree = raw.workspace?.worktree;
603
+ const requestedIsolation = worktree === 'auto' ? 'worktree'
604
+ : worktree === 'existing' || worktree === 'none' ? 'shared'
605
+ : profile.isolation;
606
+ const objective = raw.objective ?? raw.task;
607
+ if (typeof objective !== 'string' || objective.trim() === '') return { ok: false, code: 'JOB_OBJECTIVE_REQUIRED', error: 'task or objective is required' };
608
+ if (raw.job_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(raw.job_id)) {
609
+ return { ok: false, code: 'JOB_ID_INVALID', error: 'invalid client job id' };
610
+ }
611
+ const contextTask = buildWorkspaceTask(objective, withRefs.context);
612
+ normalized = {
613
+ ...raw,
614
+ task: profile.review_strictness === 'strict' && requestedRole === 'reviewer'
615
+ ? `${contextTask}\n\nSTRICT REVIEW: fail closed on missing direct code or test evidence.`
616
+ : contextTask,
617
+ cwd,
618
+ role: requestedRole,
619
+ client_job_id: raw.job_id ?? null,
620
+ requested_isolation: requestedIsolation,
621
+ workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
622
+ timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
623
+ allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
624
+ routing: profile.routing,
625
+ review_strictness: profile.review_strictness,
626
+ profile_id: resolvedProfile.profile_id,
627
+ workspace_context: withRefs.context,
628
+ };
629
+ for (const key of ['objective', 'job_id', 'profile', 'workspace_id', 'workspace', 'constraints', 'context_refs']) delete normalized[key];
630
+ }
631
+ // v0.2 role-based dispatch: reviewer / worker are gated by their role state,
632
+ // and the tier slot is derived from the role (reviewer always → pro).
633
+ if (normalized.role === 'worker' || normalized.role === 'reviewer') {
634
+ const hint = resolveRoleTierHint(normalized.role, normalized.tier);
635
+ if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
636
+ const decision = canDispatchRole(config, normalized.role, true, {});
637
+ if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
638
+ return { ok: true, payload: { ...normalized, role: normalized.role, tier: hint.tier } };
639
+ }
640
+ const decision = chooseDefaultTier(config, normalized.tier, {});
641
+ if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
642
+ return { ok: true, payload: { ...normalized, tier: decision.tier } };
643
+ }
465
644
 
466
645
  // ---------- plugin entry ----------
467
646
 
@@ -535,12 +714,29 @@ export async function apply(ctx) {
535
714
  const foreign = readMergedStatus({ excludeWriter: hub.shard.writer });
536
715
  return sendJson(res, 200, { ok: true, jobs: [...own, ...foreign] });
537
716
  }
538
- if (req.method === 'GET' && parts.length === 1) {
717
+ if (req.method === 'GET' && parts.length === 1) {
539
718
  const wait = Number(url.searchParams.get('wait') ?? 0);
540
719
  const job = await hub.wait(parts[0], Math.min(wait, 600) * 1000);
541
720
  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
- }
721
+ return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
722
+ }
723
+ if (req.method === 'GET' && parts.length === 2 && parts[1] === 'events') {
724
+ const job = hub.jobs.get(parts[0]);
725
+ if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
726
+ const after = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
727
+ const events = hubCanonicalEvents(job).filter((event) => event.sequence > after);
728
+ return sendJson(res, 200, {
729
+ ok: true, job_id: job.id, events,
730
+ event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
731
+ });
732
+ }
733
+ if (req.method === 'GET' && parts.length === 2 && parts[1] === 'contract') {
734
+ const job = hub.jobs.get(parts[0]);
735
+ if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
736
+ const detail = url.searchParams.get('detail') === 'full' ? 'full' : 'compact';
737
+ const afterSequence = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
738
+ return sendJson(res, 200, { ok: true, job: projectWorkflowView(hub.view(job, true), { detail, afterSequence }) });
739
+ }
544
740
  if (req.method === 'POST' && parts.length === 0) {
545
741
  const payload = await readBody(req);
546
742
  // Same policy resolver as the MCP server (src/server.mjs), with the
@@ -564,10 +760,95 @@ export async function apply(ctx) {
564
760
  if (code) body.code = code;
565
761
  return sendJson(res, 400, body);
566
762
  }
567
- },
568
- }));
569
-
570
- disposers.push(webServer.register({
763
+ },
764
+ }));
765
+
766
+ disposers.push(webServer.register({
767
+ kind: 'exact', path: `${ROUTE_BASE}/profiles`,
768
+ handler: async (req, res) => {
769
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
770
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadRoleProfiles() });
771
+ if (req.method === 'POST') {
772
+ try {
773
+ const saved = saveRoleProfiles(await readBody(req));
774
+ return sendJson(res, saved.ok ? 200 : 400, { ...saved });
775
+ } catch {
776
+ return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'PROFILE_FILE_INVALID' });
777
+ }
778
+ }
779
+ return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
780
+ },
781
+ }));
782
+
783
+ disposers.push(webServer.register({
784
+ kind: 'exact', path: `${ROUTE_BASE}/workspaces`,
785
+ handler: async (req, res) => {
786
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
787
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadWorkspaceContexts() });
788
+ if (req.method === 'POST') {
789
+ try {
790
+ const saved = saveWorkspaceContexts(await readBody(req));
791
+ return sendJson(res, saved.ok ? 200 : 400, { ...saved });
792
+ } catch {
793
+ return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'WORKSPACE_CONTEXT_FILE_INVALID' });
794
+ }
795
+ }
796
+ return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
797
+ },
798
+ }));
799
+
800
+ disposers.push(webServer.register({
801
+ kind: 'exact', path: `${ROUTE_BASE}/extension`,
802
+ handler: async (req, res) => {
803
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
804
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
805
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
806
+ let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
807
+ if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
808
+ catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
809
+ } else {
810
+ try {
811
+ await readHarnessModelCatalog({
812
+ llm: ctx.llm ?? ctx.get('llm'),
813
+ getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
814
+ });
815
+ catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
816
+ } catch {
817
+ catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
818
+ }
819
+ }
820
+ const profiles = loadRoleProfiles();
821
+ const requestUrl = new URL(req.url, 'http://localhost');
822
+ const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
823
+ const workspaceRegistry = loadWorkspaceContexts();
824
+ const requestedContext = requestedWorkspaceId ? workspaceRegistry.contexts?.[requestedWorkspaceId] : null;
825
+ const workspaceReadiness = requestedWorkspaceId && !requestedContext
826
+ ? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
827
+ : await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
828
+ const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
829
+ const modelExecution = liveJobs.some((job) => job?.role === 'worker' && job?.status === 'done')
830
+ ? { id: 'model_execution', status: 'PASS', reason_code: 'REAL_EXECUTION_PASSED' }
831
+ : { id: 'model_execution', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' };
832
+ const reviewerExecution = liveJobs.some((job) => job?.role === 'reviewer' && job?.status === 'done')
833
+ ? { id: 'reviewer_pipeline', status: 'PASS', reason_code: 'REAL_REVIEW_PASSED' }
834
+ : { id: 'reviewer_pipeline', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' };
835
+ const contract = buildExtensionContract({
836
+ config,
837
+ readinessMatrix: { rows: [
838
+ { id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
839
+ catalogStatus,
840
+ modelExecution,
841
+ reviewerExecution,
842
+ ] },
843
+ workspace: workspaceReadiness,
844
+ profiles,
845
+ runtime: getHubRuntimeIdentity(),
846
+ });
847
+ return sendJson(res, 200, { ok: true, extension: contract });
848
+ },
849
+ }));
850
+
851
+ disposers.push(webServer.register({
571
852
  kind: 'exact', path: `${ROUTE_BASE}/config`,
572
853
  handler: async (req, res) => {
573
854
  if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
@@ -0,0 +1,67 @@
1
+ // Bounded context builders for agent-to-agent hand-offs.
2
+ //
3
+ // Workers still receive the user's complete delegated task. Once a worker has
4
+ // run, downstream agents receive only an objective plus structured evidence
5
+ // and artifact references. They inspect the isolated workspace directly when
6
+ // more detail is required; raw prose and whole patches are never re-embedded.
7
+
8
+ function clip(value, limit) {
9
+ const text = String(value ?? '').trim();
10
+ if (text.length <= limit) return text;
11
+ return `${text.slice(0, limit)}\n[truncated: ${text.length - limit} characters omitted]`;
12
+ }
13
+
14
+ function list(values, { count = 40, itemLimit = 300 } = {}) {
15
+ if (!Array.isArray(values) || values.length === 0) return ['(none)'];
16
+ const selected = values.slice(0, count).map((value) => `- ${clip(value, itemLimit)}`);
17
+ if (values.length > count) selected.push(`- [truncated: ${values.length - count} additional items omitted]`);
18
+ return selected;
19
+ }
20
+
21
+ function tests(values) {
22
+ if (!Array.isArray(values) || values.length === 0) return ['(none reported)'];
23
+ return list(values.map((entry) => [entry?.status, entry?.command, entry?.summary].filter(Boolean).join(' — ')), {
24
+ count: 30,
25
+ itemLimit: 500,
26
+ });
27
+ }
28
+
29
+ /** Build the automatic-review context capsule. */
30
+ export function buildReviewTask(task, view = {}, { strictness = 'standard' } = {}) {
31
+ const outcome = view?.outcome ?? {};
32
+ const candidate = view?.candidate ?? {};
33
+ const changedFiles = Array.isArray(candidate.changed_files) ? candidate.changed_files : [];
34
+ const parts = [
35
+ 'You are the automatic reviewer of a completed worker implementation.',
36
+ 'REVIEW ONLY: inspect the candidate and report findings. Do not modify files.',
37
+ ...(strictness === 'strict' ? [
38
+ 'STRICT REVIEW: fail closed. Approve only when direct code and test evidence supports every material claim; treat missing evidence as needs changes.',
39
+ ] : []),
40
+ '',
41
+ 'Objective:',
42
+ clip(task, 4000),
43
+ '',
44
+ 'Worker outcome:',
45
+ `task_status=${outcome.task_status ?? 'unknown'} tests_status=${outcome.tests_status ?? 'unknown'} delivery=${outcome.delivery?.complete ? 'complete' : 'incomplete'}`,
46
+ '',
47
+ 'Reported changes:',
48
+ ...list(outcome.changes),
49
+ '',
50
+ 'Reported tests:',
51
+ ...tests(outcome.tests),
52
+ '',
53
+ 'Reported risks:',
54
+ ...list(outcome.risks),
55
+ '',
56
+ 'Candidate artifact:',
57
+ `base_revision=${candidate.base_revision ?? 'unknown'}`,
58
+ `fingerprint=${candidate.fingerprint ?? 'unknown'}`,
59
+ 'changed_files:',
60
+ ...list(changedFiles, { count: 80, itemLimit: 500 }),
61
+ '',
62
+ 'Inspect the candidate directly in the current isolated workspace. Use git diff against the base revision and open only the files needed for review. The worker\'s raw prose and full patch are intentionally not embedded in this hand-off.',
63
+ '',
64
+ 'Report: 1) whether the implementation satisfies the objective, 2) concrete bugs/style/security risks, 3) suggested fixes. End with ## Review Findings / ## Evidence / ## Risks / ## Verdict (approved | needs changes | rejected).',
65
+ ];
66
+ return parts.join('\n');
67
+ }