@ran-sh/dsh-crew 0.5.4 → 0.5.5

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.
@@ -8,7 +8,7 @@
8
8
  // Keep this module pure and dependency-free so Hub, MCP and tests all use the
9
9
  // exact same compatibility rules.
10
10
 
11
- export const RUNTIME_VERSION = '0.5.4';
11
+ export const RUNTIME_VERSION = '0.5.5';
12
12
  export const HUB_PROTOCOL_VERSION = 1;
13
13
 
14
14
  export const HUB_CAPABILITIES = Object.freeze([
package/src/server.mjs CHANGED
@@ -26,9 +26,9 @@ import { buildReviewTask } from './information-flow.mjs';
26
26
  import { projectWorkflowView } from './job-contracts.mjs';
27
27
  import { loadRoleProfiles, resolveRoleProfile } from './role-profiles.mjs';
28
28
  import { loadWorkspaceContexts, resolveWorkspaceContext, buildWorkspaceTask, addContextReferences, isSafeBranchName } from './workspace-context.mjs';
29
- import { buildExtensionContract } from './extension-contract.mjs';
30
- import { assessWorkspaceReadiness } from './workspace-readiness.mjs';
31
- import { detectOrchestrator } from './orchestrator.mjs';
29
+ import { buildExtensionContract } from './extension-contract.mjs';
30
+ import { assessWorkspaceReadiness } from './workspace-readiness.mjs';
31
+ import { detectOrchestrator } from './orchestrator.mjs';
32
32
 
33
33
  const server = new McpServer({ name: 'dsh-crew', version: RUNTIME_VERSION });
34
34
 
@@ -45,11 +45,11 @@ const workspaceSchema = z.object({
45
45
  branch: z.string().refine(isSafeBranchName, 'invalid git branch name').optional(),
46
46
  worktree: z.enum(['auto', 'existing', 'none']).optional(),
47
47
  }).optional().describe('Per-job workspace overrides. auto isolates coding Workers; existing/none use the supplied workspace.');
48
- const constraintsSchema = z.object({
49
- timeout_seconds: z.number().int().positive().max(7200).optional(),
50
- allow_fallback: z.boolean().optional(),
51
- allow_no_changes: z.boolean().optional().describe('Explicitly allow a verified read-only or analysis job to succeed with zero workspace changes.'),
52
- }).optional().describe('Per-job constraints override profile and session defaults.');
48
+ const constraintsSchema = z.object({
49
+ timeout_seconds: z.number().int().positive().max(7200).optional(),
50
+ allow_fallback: z.boolean().optional(),
51
+ allow_no_changes: z.boolean().optional().describe('Explicitly allow a verified read-only or analysis job to succeed with zero workspace changes.'),
52
+ }).optional().describe('Per-job constraints override profile and session defaults.');
53
53
 
54
54
  // Session-level configuration. This MCP server process lives exactly as long
55
55
  // as one Claude Code / Codex session, so plain memory IS session scope.
@@ -125,7 +125,7 @@ function text(obj) {
125
125
  return { content: [{ type: 'text', text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2) }] };
126
126
  }
127
127
 
128
- const ORCHESTRATOR = detectOrchestrator();
128
+ const ORCHESTRATOR = detectOrchestrator();
129
129
 
130
130
  function policyRejection(decision) {
131
131
  return text({
@@ -222,10 +222,10 @@ function prepareDispatch({ task, role, tier, legacy_tier, effort, cwd, timeout_s
222
222
  profile_id: resolvedProfile.profile_id,
223
223
  requested_isolation: requestedIsolation,
224
224
  workspace_branch: workspaceOverride?.branch ?? withRefs.context?.default_branch ?? null,
225
- timeout_seconds: effectiveTimeout,
226
- allow_fallback: constraints?.allow_fallback ?? profileValue.fallback,
227
- allow_no_changes: constraints?.allow_no_changes === true,
228
- routing: profileValue.routing,
225
+ timeout_seconds: effectiveTimeout,
226
+ allow_fallback: constraints?.allow_fallback ?? profileValue.fallback,
227
+ allow_no_changes: constraints?.allow_no_changes === true,
228
+ routing: profileValue.routing,
229
229
  review_strictness: profileValue.review_strictness,
230
230
  workspace_context: withRefs.context,
231
231
  },
@@ -324,12 +324,12 @@ async function buildConfigReport() {
324
324
  const hubCompatibility = await hubStatus({ force: true });
325
325
  let effectiveWorkerProvider = null;
326
326
  let effectiveWorkerSelection = { flash: null, pro: null };
327
- let providerResolutionError;
328
- let providerCatalogChecked = false;
329
- let providerCatalogBody = null;
330
- let hubJobsChecked = false;
331
- let hubJobsBody = null;
332
- const workerProviderMode = globalConfig.worker_provider_mode ?? 'deepseek-official';
327
+ let providerResolutionError;
328
+ let providerCatalogChecked = false;
329
+ let providerCatalogBody = null;
330
+ let hubJobsChecked = false;
331
+ let hubJobsBody = null;
332
+ const workerProviderMode = globalConfig.worker_provider_mode ?? 'deepseek-official';
333
333
  if (workerProviderMode === 'deepseek-official') {
334
334
  effectiveWorkerSelection = {
335
335
  flash: { provider: 'deepseek-official', model: 'deepseek-v4-flash', source: 'legacy-strict' },
@@ -338,7 +338,7 @@ async function buildConfigReport() {
338
338
  } else if (hubCompatibility.compatible) {
339
339
  try {
340
340
  providerCatalogChecked = true;
341
- const res = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/models`);
341
+ const res = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/models`, { signal: AbortSignal.timeout(800) });
342
342
  const body = await res.json();
343
343
  providerCatalogBody = body;
344
344
  if (!body?.ok) providerResolutionError = body?.error ?? 'Unable to read Harness model catalog.';
@@ -359,27 +359,27 @@ async function buildConfigReport() {
359
359
  providerCatalogChecked = true;
360
360
  providerResolutionError = err?.message ?? String(err);
361
361
  }
362
- } else if (hubCompatibility.reachable) {
363
- providerResolutionError = hubCompatibilityMessage(hubCompatibility);
364
- }
365
- if (hubCompatibility.compatible) {
366
- try {
367
- hubJobsChecked = true;
368
- const jobsRes = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/jobs`, { signal: AbortSignal.timeout(800) });
369
- hubJobsBody = await jobsRes.json();
370
- } catch {
371
- hubJobsChecked = true;
372
- }
373
- }
374
- effectiveWorkerProvider = effectiveWorkerSelection.flash?.provider ?? null;
375
- const readinessMatrix = buildConfigReadinessMatrix({
362
+ } else if (hubCompatibility.reachable) {
363
+ providerResolutionError = hubCompatibilityMessage(hubCompatibility);
364
+ }
365
+ if (hubCompatibility.compatible) {
366
+ try {
367
+ hubJobsChecked = true;
368
+ const jobsRes = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/jobs`, { signal: AbortSignal.timeout(800) });
369
+ hubJobsBody = await jobsRes.json();
370
+ } catch {
371
+ hubJobsChecked = true;
372
+ }
373
+ }
374
+ effectiveWorkerProvider = effectiveWorkerSelection.flash?.provider ?? null;
375
+ const readinessMatrix = buildConfigReadinessMatrix({
376
376
  hubCompatibility,
377
377
  workerProviderMode,
378
- providerCatalogChecked,
379
- providerCatalogBody,
380
- hubJobsChecked,
381
- hubJobsBody,
382
- });
378
+ providerCatalogChecked,
379
+ providerCatalogBody,
380
+ hubJobsChecked,
381
+ hubJobsBody,
382
+ });
383
383
  const roleProfiles = loadRoleProfiles();
384
384
  const workspaceReadiness = await assessWorkspaceReadiness({ cwd: process.cwd() });
385
385
  const extensionContract = buildExtensionContract({
@@ -15,7 +15,7 @@
15
15
  // appear here.
16
16
 
17
17
  import { resolveModelPolicy, shouldAutoReview, getRoleState } from './policy.mjs';
18
- import { applyWorkspaceEvidence, buildOutcome, decideNextStep, JOB_PHASES, canTransition } from './workflow.mjs';
18
+ import { applyWorkspaceEvidence, buildOutcome, decideNextStep, JOB_PHASES, canTransition } from './workflow.mjs';
19
19
  import { parseDeliveryReport } from './delivery.mjs';
20
20
  import { classifyFailure } from './failure-classification.mjs';
21
21
  import { createCanonicalJobEvent } from './job-contracts.mjs';
@@ -170,10 +170,10 @@ export function createWorkflowRuntime(adapters, {
170
170
  requested_isolation: spec.requested_isolation ?? null,
171
171
  workspace_branch: spec.workspace_branch ?? null,
172
172
  timeout_seconds: spec.timeout_seconds ?? null,
173
- profile_id: spec.profile_id ?? null,
174
- allow_fallback: spec.allow_fallback !== false,
175
- allow_no_changes: spec.allow_no_changes === true,
176
- routing: spec.routing ?? 'auto',
173
+ profile_id: spec.profile_id ?? null,
174
+ allow_fallback: spec.allow_fallback !== false,
175
+ allow_no_changes: spec.allow_no_changes === true,
176
+ routing: spec.routing ?? 'auto',
177
177
  review_strictness: spec.review_strictness ?? null,
178
178
  workspace_context: spec.workspace_context ? { ...spec.workspace_context } : null,
179
179
  effort: spec.effort ?? 'max',
@@ -202,6 +202,8 @@ export function createWorkflowRuntime(adapters, {
202
202
  startedAt: now,
203
203
  endedAt: null,
204
204
  workspaceHandle: null,
205
+ workspaceReleasePromise: null,
206
+ cancelPromise: null,
205
207
  waiters: [],
206
208
  };
207
209
  recordCanonical(job, 'job.created', { client_job_id: job.client_job_id }, null, now);
@@ -269,19 +271,26 @@ export function createWorkflowRuntime(adapters, {
269
271
  return;
270
272
  }
271
273
  if (!job.workspaceHandle) return;
272
- try {
273
- const r = await adapters.releaseWorkspace(job.workspaceHandle);
274
- if (!r || r.ok !== true) {
275
- // A failed cleanup leaves the isolated workspace in place; say so
276
- // truthfully instead of claiming a clean release.
277
- job.cleanup_warning = r?.error ?? 'worktree cleanup failed';
274
+ if (job.workspaceReleasePromise) return job.workspaceReleasePromise;
275
+ const handle = job.workspaceHandle;
276
+ job.workspaceReleasePromise = (async () => {
277
+ try {
278
+ const r = await adapters.releaseWorkspace(handle);
279
+ if (!r || r.ok !== true) {
280
+ // A failed cleanup leaves the isolated workspace in place; say so
281
+ // truthfully instead of claiming a clean release.
282
+ job.cleanup_warning = r?.error ?? 'worktree cleanup failed';
283
+ job.workspace_retained = true;
284
+ }
285
+ } catch (err) {
286
+ job.cleanup_warning = err?.message ?? String(err);
278
287
  job.workspace_retained = true;
288
+ } finally {
289
+ job.workspaceHandle = null;
290
+ job.workspaceReleasePromise = null;
279
291
  }
280
- } catch (err) {
281
- job.cleanup_warning = err?.message ?? String(err);
282
- job.workspace_retained = true;
283
- }
284
- job.workspaceHandle = null;
292
+ })();
293
+ return job.workspaceReleasePromise;
285
294
  }
286
295
 
287
296
  async function runWorkflow(job) {
@@ -313,6 +322,7 @@ export function createWorkflowRuntime(adapters, {
313
322
  const before = alloc?.ok && alloc.isolation === 'worktree' ? await safeCapture(adapters, job.execution_cwd, job.base_revision) : null;
314
323
  const reviewTask = adapters.buildReviewTask(job.original_task, null, { strictness: job.review_strictness ?? 'standard' });
315
324
  const review = await runReviewerAttempt(job, reviewTask, config, before);
325
+ if (job.cancelling) { await cancelWorkflow(job); return; }
316
326
  job.review = review;
317
327
  if (job.review) transition(job, JOB_PHASES.READY, 'review complete');
318
328
  finalize(job);
@@ -322,7 +332,7 @@ export function createWorkflowRuntime(adapters, {
322
332
  let attempt = 0;
323
333
  let escalationReason = null;
324
334
  for (;;) {
325
- if (job.cancelling) { cancelWorkflow(job); return; }
335
+ if (job.cancelling) { await cancelWorkflow(job); return; }
326
336
  if (attempt > 0) transition(job, JOB_PHASES.RUNNING, `escalated attempt ${attempt}`);
327
337
  const resolvedPolicy = resolveModelPolicy(config, 'worker', { attempt });
328
338
  const fallbackPolicy = job.allow_fallback === false
@@ -356,7 +366,7 @@ export function createWorkflowRuntime(adapters, {
356
366
  },
357
367
  });
358
368
  job.current_attempt_id = null;
359
- if (job.cancelling) { cancelWorkflow(job); return; }
369
+ if (job.cancelling) { await cancelWorkflow(job); return; }
360
370
  const attemptView = attemptRecord(ar, attempt);
361
371
  job.attempts.push(attemptView);
362
372
  recordCanonical(job, 'model.selected', {
@@ -375,17 +385,17 @@ export function createWorkflowRuntime(adapters, {
375
385
  failJob(job, Object.assign(new Error(ar.error ?? 'infrastructure failure'), { code: WORKFLOW_ERROR_CODES.ATTEMPT_INFRA_FAILURE }));
376
386
  return;
377
387
  }
378
- let outcome = ar.outcome ?? buildOutcome({
388
+ let outcome = ar.outcome ?? buildOutcome({
379
389
  result: ar.result ?? '',
380
390
  stopReason: ar.stopReason,
381
391
  executionStatus: ar.status === 'done' ? 'completed' : 'failed',
382
392
  });
383
393
  transition(job, JOB_PHASES.VERIFYING, `attempt ${attempt} complete`);
384
394
 
385
- let workspaceEvidenceAvailable = false;
386
- let workspaceHasChanges = false;
387
-
388
- // Capture the latest candidate after every attempt. Capture failure is
395
+ let workspaceEvidenceAvailable = false;
396
+ let workspaceHasChanges = false;
397
+
398
+ // Capture the latest candidate after every attempt. Capture failure is
389
399
  // not allowed to delete the only recoverable state: retain the worktree
390
400
  // and preserve the worker business result, while surfacing the warning.
391
401
  if (alloc?.ok && alloc.isolation === 'worktree') {
@@ -395,23 +405,23 @@ export function createWorkflowRuntime(adapters, {
395
405
  job.retain_workspace = true;
396
406
  job.candidate = null;
397
407
  job.events.push({ at: clock(), phase: job.phase, type: 'candidate/failed', attempt, message: 'candidate capture failed; worktree retained for recovery' });
398
- } else {
399
- job.candidate = candidate;
400
- attemptView.candidate_fingerprint = candidate.fingerprint ?? null;
401
- workspaceEvidenceAvailable = true;
402
- workspaceHasChanges = Array.isArray(candidate.changed_files) && candidate.changed_files.length > 0;
403
- if (candidate.complete === false || candidate.replayable === false) {
408
+ } else {
409
+ job.candidate = candidate;
410
+ attemptView.candidate_fingerprint = candidate.fingerprint ?? null;
411
+ workspaceEvidenceAvailable = true;
412
+ workspaceHasChanges = Array.isArray(candidate.changed_files) && candidate.changed_files.length > 0;
413
+ if (candidate.complete === false || candidate.replayable === false) {
404
414
  job.retain_workspace = true;
405
415
  job.events.push({ at: clock(), phase: job.phase, type: 'candidate/incomplete', attempt, message: 'candidate is not fully replayable; worktree retained' });
406
416
  }
407
- }
408
- }
409
- outcome = applyWorkspaceEvidence(outcome, {
410
- evidenceAvailable: workspaceEvidenceAvailable,
411
- hasChanges: workspaceHasChanges,
412
- allowNoChanges: job.allow_no_changes,
413
- });
414
- job.outcome = outcome;
417
+ }
418
+ }
419
+ outcome = applyWorkspaceEvidence(outcome, {
420
+ evidenceAvailable: workspaceEvidenceAvailable,
421
+ hasChanges: workspaceHasChanges,
422
+ allowNoChanges: job.allow_no_changes,
423
+ });
424
+ job.outcome = outcome;
415
425
 
416
426
  const reviewRequested = !isReviewJob && shouldAutoReview(config);
417
427
  const reviewerAuto = getRoleState(config, 'reviewer') !== 'disabled';
@@ -419,7 +429,7 @@ export function createWorkflowRuntime(adapters, {
419
429
  job.decision = { step: decision.step, phase: decision.phase, reason: decision.reason };
420
430
  job.events.push({ at: clock(), phase: job.phase, type: 'attempt/complete', attempt, decision: decision.step });
421
431
 
422
- if (job.cancelling) { cancelWorkflow(job); return; }
432
+ if (job.cancelling) { await cancelWorkflow(job); return; }
423
433
  if (decision.step === 'fail') {
424
434
  const finalAttemptCode = ar.status === 'failed' ? attemptView.error_code : null;
425
435
  failJob(job, Object.assign(new Error(`workflow failed: ${decision.reason}`), { code: finalAttemptCode }));
@@ -441,6 +451,7 @@ export function createWorkflowRuntime(adapters, {
441
451
  const before = job.candidate;
442
452
  const reviewTask = adapters.buildReviewTask(job.original_task, { outcome, candidate: job.candidate ?? null }, { strictness: job.review_strictness ?? 'standard' });
443
453
  const review = await runReviewerAttempt(job, reviewTask, config, before, job.execution_cwd, job.base_revision);
454
+ if (job.cancelling) { await cancelWorkflow(job); return; }
444
455
  job.review = review;
445
456
  transition(job, JOB_PHASES.READY, decision.reason);
446
457
  finalize(job);
@@ -451,7 +462,7 @@ export function createWorkflowRuntime(adapters, {
451
462
  return;
452
463
  }
453
464
  } catch (err) {
454
- if (job.cancelling) { cancelWorkflow(job); return; }
465
+ if (job.cancelling) { await cancelWorkflow(job); return; }
455
466
  failJob(job, err);
456
467
  } finally {
457
468
  await releaseWorkspace(job);
@@ -546,17 +557,24 @@ export function createWorkflowRuntime(adapters, {
546
557
  }
547
558
 
548
559
  function cancelWorkflow(job) {
560
+ if (job.cancelPromise) return job.cancelPromise;
549
561
  if (job.status !== 'running') return;
550
- job.cancelling = true;
551
- job.events.push({ at: clock(), phase: job.phase, type: 'cancel' });
552
- const activeAttempt = job.current_attempt_id;
553
- if (activeAttempt) adapters.cancelAttempt?.(activeAttempt).catch(() => {});
554
- job.phase = JOB_PHASES.CANCELLED;
555
- job.status = 'cancelled';
556
- recordCanonical(job, 'job.cancelled', { active_attempt_id: activeAttempt ?? null });
557
- job.endedAt = clock();
558
- job.current_attempt_id = null;
559
- for (const w of job.waiters.splice(0)) w();
562
+ job.cancelPromise = (async () => {
563
+ job.cancelling = true;
564
+ job.events.push({ at: clock(), phase: job.phase, type: 'cancel' });
565
+ const activeAttempt = job.current_attempt_id;
566
+ if (activeAttempt) {
567
+ try { await adapters.cancelAttempt?.(activeAttempt); } catch {}
568
+ }
569
+ await releaseWorkspace(job);
570
+ job.phase = JOB_PHASES.CANCELLED;
571
+ job.status = 'cancelled';
572
+ recordCanonical(job, 'job.cancelled', { active_attempt_id: activeAttempt ?? null });
573
+ job.endedAt = clock();
574
+ job.current_attempt_id = null;
575
+ for (const w of job.waiters.splice(0)) w();
576
+ })();
577
+ return job.cancelPromise;
560
578
  }
561
579
 
562
580
  function workflowView(job, { withResult = false } = {}) {
@@ -579,9 +597,9 @@ export function createWorkflowRuntime(adapters, {
579
597
  current_model: job.attempts[job.attempts.length - 1]?.model ?? null,
580
598
  model_class_hint: job.model_class_hint,
581
599
  source: job.source,
582
- profile_id: job.profile_id,
583
- allow_no_changes: job.allow_no_changes,
584
- routing: job.routing,
600
+ profile_id: job.profile_id,
601
+ allow_no_changes: job.allow_no_changes,
602
+ routing: job.routing,
585
603
  workspace_context: job.workspace_context ? { ...job.workspace_context } : null,
586
604
  workspace_branch: job.workspace_branch,
587
605
  timeout_seconds: job.timeout_seconds,
@@ -660,9 +678,9 @@ export function createWorkflowRuntime(adapters, {
660
678
  async function cancel(id) {
661
679
  const job = jobs.get(id);
662
680
  if (!job) return undefined;
663
- cancelWorkflow(job);
664
681
  const qi = queue.indexOf(job);
665
682
  if (qi !== -1) queue.splice(qi, 1);
683
+ await cancelWorkflow(job);
666
684
  return workflowView(job);
667
685
  }
668
686
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { execFile } from 'node:child_process';
14
14
  import { existsSync, rmSync } from 'node:fs';
15
- import { readFile } from 'node:fs/promises';
15
+ import { readFile, lstat } from 'node:fs/promises';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { join, resolve, basename } from 'node:path';
18
18
  import { randomBytes, createHash } from 'node:crypto';
@@ -106,6 +106,21 @@ function splitFirstTab(line) {
106
106
  return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
107
107
  }
108
108
 
109
+ async function firstLinkComponent(cwd, relPath) {
110
+ let current = resolve(cwd);
111
+ const segments = String(relPath ?? '').replace(/\\/g, '/').split('/').filter(Boolean);
112
+ for (let i = 0; i < segments.length; i += 1) {
113
+ current = join(current, segments[i]);
114
+ try {
115
+ const st = await lstat(current);
116
+ if (st.isSymbolicLink()) return segments.slice(0, i + 1).join('/');
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
109
124
  async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limit }) {
110
125
  const tracked = [];
111
126
  const sensitive = new Set();
@@ -125,6 +140,8 @@ async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limi
125
140
  let out = '';
126
141
  const redacted = [];
127
142
  const incompleteReasons = [];
143
+ const untrackedFiles = [];
144
+ const seenLinks = new Set();
128
145
  if (tracked.length > 0) {
129
146
  const r = await runGit(run, ['diff', '--binary', base, '--', ...tracked], { cwd });
130
147
  if (!r.ok) return { failed: true, reason: r.reason, error: r.error };
@@ -137,6 +154,20 @@ async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limi
137
154
  if (redacted.length > 0) incompleteReasons.push('sensitive_content_redacted');
138
155
 
139
156
  for (const p of untracked) {
157
+ // Walk every path component with lstat so a symlink/junction anywhere in
158
+ // the path is never followed. Out-of-worktree targets are treated as
159
+ // non-replayable; only the link boundary is exposed.
160
+ const link = await firstLinkComponent(cwd, p);
161
+ if (link) {
162
+ if (!seenLinks.has(link)) {
163
+ seenLinks.add(link);
164
+ untrackedFiles.push(link);
165
+ out += `[UNTRACKED SYMLINK: ${link}]\n`;
166
+ incompleteReasons.push(`untracked_symlink:${link}`);
167
+ }
168
+ continue;
169
+ }
170
+ untrackedFiles.push(p);
140
171
  if (isSensitivePath(p)) {
141
172
  redacted.push(p);
142
173
  out += `[REDACTED SENSITIVE FILE: ${p} (untracked)]\n`;
@@ -154,13 +185,16 @@ async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limi
154
185
  if (!next.complete) incompleteReasons.push(next.reason ?? `untracked_unreplayable:${p}`);
155
186
  }
156
187
 
188
+ // Fingerprint identity must represent the complete pre-truncation workspace
189
+ // diff, not the truncated patch bytes that are actually retained.
190
+ const workspaceDigest = createHash('sha256').update(out).digest('hex');
157
191
  const truncated = Buffer.byteLength(out, 'utf8') > limit;
158
192
  if (truncated) {
159
193
  out = Buffer.from(out, 'utf8').subarray(0, limit).toString('utf8');
160
194
  incompleteReasons.push('patch_truncated');
161
195
  }
162
196
  const complete = incompleteReasons.length === 0;
163
- return { failed: false, patch: out, truncated, redacted, complete, incompleteReasons };
197
+ return { failed: false, patch: out, truncated, redacted, complete, incompleteReasons, untrackedFiles, workspaceDigest };
164
198
  }
165
199
 
166
200
  async function safeNewFilePatch(relPath, absPath) {
@@ -180,8 +214,8 @@ async function safeNewFilePatch(relPath, absPath) {
180
214
  }
181
215
  }
182
216
 
183
- function candidateFingerprint({ base, nameStatus, patch }) {
184
- return createHash('sha256').update(`${base}\n${nameStatus ?? ''}\n${patch ?? ''}`).digest('hex');
217
+ function candidateFingerprint({ base, nameStatus, workspaceDigest }) {
218
+ return createHash('sha256').update(`${base}\n${nameStatus ?? ''}\n${workspaceDigest ?? ''}`).digest('hex');
185
219
  }
186
220
 
187
221
  export async function captureCandidate({ worktreePath, baseRevision, git, limit = DIFF_LIMIT } = {}) {
@@ -204,6 +238,8 @@ export async function captureCandidate({ worktreePath, baseRevision, git, limit
204
238
  const built = await buildCandidatePatch(run, { cwd: worktreePath, base, nameStatus: nameStatus.stdout, untracked, limit });
205
239
  if (built.failed) return { ok: false, reason: built.reason ?? CANDIDATE_CAPTURE_FAILED, error: built.error };
206
240
 
241
+ const untrackedFiles = built.untrackedFiles ?? untracked;
242
+
207
243
  const nameStatusOut = nameStatus.stdout;
208
244
  return {
209
245
  ok: true,
@@ -211,17 +247,17 @@ export async function captureCandidate({ worktreePath, baseRevision, git, limit
211
247
  base_revision: base,
212
248
  committed_head: head.ok ? head.stdout.trim() : null,
213
249
  worktree_path: worktreePath,
214
- changed_files: [...new Set([...trackedIn(nameStatusOut, untracked), ...built.redacted, ...untracked])],
250
+ changed_files: [...new Set([...trackedIn(nameStatusOut, untrackedFiles), ...built.redacted, ...untrackedFiles])],
215
251
  name_status: nameStatusOut,
216
252
  diff_stat: statRel.stdout,
217
253
  patch: built.patch,
218
254
  patch_truncated: built.truncated,
219
255
  sensitive_paths_redacted: built.redacted,
220
- untracked_files: untracked,
256
+ untracked_files: untrackedFiles,
221
257
  complete: built.complete,
222
258
  replayable: built.complete,
223
259
  incomplete_reasons: built.incompleteReasons,
224
- fingerprint: candidateFingerprint({ base, nameStatus: nameStatusOut, patch: built.patch }),
260
+ fingerprint: candidateFingerprint({ base, nameStatus: nameStatusOut, workspaceDigest: built.workspaceDigest }),
225
261
  candidate_commit: null,
226
262
  };
227
263
  }