@ran-sh/dsh-crew 2.0.2 → 2.0.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
@@ -85,13 +85,18 @@ Per-job precedence is request `constraints` > Profile > session defaults.
85
85
  `auto`, `existing`, or `none`. Workspace preflight reports `READY`, `CONFLICT`,
86
86
  `READ_ONLY`, or `UNAVAILABLE` before dispatch.
87
87
 
88
- `constraints.allow_no_changes: true` is reserved for explicitly read-only
89
- search, inspection, or analysis work. It does not blindly convert a partial
90
- Delivery Report into success: Crew requires an isolated candidate with zero
91
- changed files, a complete Delivery Report, at least one passing evidence check,
92
- and no failed check. If the Worker reports no changes but the workspace contains
93
- edits—or reports changes while the workspace is empty—the workflow fails closed
94
- with workspace-evidence mismatch.
88
+ `constraints.allow_no_changes: true` is for tasks explicitly expected to leave
89
+ no net workspace changes, including read-only search/inspection and bounded
90
+ temporary smoke work. It does not blindly convert a partial Delivery Report
91
+ into success: Crew requires a complete Delivery Report, at least one passing
92
+ evidence check, no failed check, and machine-verifiable zero net workspace
93
+ changes. For Hub jobs, that evidence comes from a clean Git baseline, or from a
94
+ non-Git workspace whose initial tree contains only directories; the latter audit
95
+ compares paths and entry types without opening file contents. Non-Git trees that
96
+ already contain files, links, or special entries remain unverifiable and fail
97
+ closed. If the Worker reports no changes but the workspace contains edits—or
98
+ reports changes while the workspace is empty—the workflow fails closed with
99
+ workspace-evidence mismatch.
95
100
 
96
101
  The CLI is a JSON projection of the same local HTTP surface:
97
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
package/src/hub/index.mjs CHANGED
@@ -502,10 +502,9 @@ export function hubCanonicalEvents(job = {}) {
502
502
 
503
503
  export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChanges = false, isolation = 'shared', role = 'worker' } = {}) {
504
504
  void isolation;
505
- const changes = workspaceDiff?.changes ?? {};
506
- const hasChanges = ['modified', 'deleted', 'renamed', 'untracked']
507
- .some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
508
- const evidenceAvailable = workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true;
505
+ const hasChanges = workspaceHasChanges(workspaceDiff);
506
+ const evidenceAvailable = (workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true)
507
+ || (workspaceDiff?.kind === 'filesystem-empty' && typeof workspaceDiff.unchanged === 'boolean');
509
508
  return applyWorkspaceEvidence(outcome, {
510
509
  evidenceAvailable,
511
510
  hasChanges,
@@ -521,6 +520,19 @@ export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChang
521
520
  });
522
521
  }
523
522
 
523
+ function workspaceHasChanges(workspaceDiff) {
524
+ if (workspaceDiff?.kind === 'filesystem-empty') return workspaceDiff.unchanged !== true;
525
+ const changes = workspaceDiff?.changes ?? {};
526
+ return ['modified', 'deleted', 'renamed', 'untracked']
527
+ .some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
528
+ }
529
+
530
+ export function invalidateReviewForWorkspaceMutation({ role, review, workspaceDiff } = {}) {
531
+ if (role !== 'reviewer' || !review || !['git', 'filesystem-empty'].includes(workspaceDiff?.kind)) return review;
532
+ if (!workspaceHasChanges(workspaceDiff)) return review;
533
+ return { ...review, mutated_candidate: true, invalidated: true, verdict: 'request_changes' };
534
+ }
535
+
524
536
  /**
525
537
  * Build the explicit provider callability probe used by the 3210 lifecycle
526
538
  * route. It performs one token-bounded Harness stream through the same llm
@@ -602,7 +614,7 @@ export class WorkerRegistry { constructor(ctx) {
602
614
  task_status: job.outcome?.task_status ?? null,
603
615
  workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
604
616
  review_verdict: job.review?.verdict ?? null,
605
- workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind === 'git',
617
+ workspace_diff_available: !!job.workspaceDiff && ['git', 'filesystem-empty'].includes(job.workspaceDiff.kind),
606
618
  workspace_retained: job.workspace_retained === true,
607
619
  cleanup_warning: job.cleanup_warning ?? null,
608
620
  profile_id: job.profile_id ?? null,
@@ -935,7 +947,8 @@ export class WorkerRegistry { constructor(ctx) {
935
947
  // Read-only pre-run snapshot (async, never blocks dispatch): the audit
936
948
  // only needs the before-state by the time the worker finishes. Non-repos
937
949
  // degrade to { kind:'no-git' } instead of failing the job.
938
- job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
950
+ job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd, allowEmptyNonGitDirectory: true })
951
+ .catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
939
952
  try {
940
953
  this.assertProviderNotMutating(selection.provider, providerMutationEpoch);
941
954
  this.acquireProviderLease(selection.provider);
@@ -1120,7 +1133,7 @@ export class WorkerRegistry { constructor(ctx) {
1120
1133
  });
1121
1134
  job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
1122
1135
  // Read-only after-snapshot of the workspace: bounded, redacted patch.
1123
- job.workspaceDiff = job.baseline.kind === 'git'
1136
+ job.workspaceDiff = ['git', 'filesystem-empty'].includes(job.baseline.kind)
1124
1137
  ? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
1125
1138
  : job.baseline;
1126
1139
  job.outcome = applyHubWorkspaceEvidence({
@@ -1139,15 +1152,11 @@ export class WorkerRegistry { constructor(ctx) {
1139
1152
  error: { code: job.error_code, message: job.error },
1140
1153
  });
1141
1154
  }
1142
- if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
1143
- const changes = job.workspaceDiff.changes ?? {};
1144
- const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
1145
- if (mutated) {
1146
- job.review.mutated_candidate = true;
1147
- job.review.invalidated = true;
1148
- job.review.verdict = 'request_changes';
1149
- }
1150
- }
1155
+ job.review = invalidateReviewForWorkspaceMutation({
1156
+ role: job.role,
1157
+ review: job.review,
1158
+ workspaceDiff: job.workspaceDiff,
1159
+ });
1151
1160
  if (job.isolatedWorkspace) {
1152
1161
  const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
1153
1162
  job.workspace_retained = cleanup.ok !== true;
@@ -283,8 +283,10 @@ export function buildMcpWorkflowRuntime(deps) {
283
283
  const config = getConfig();
284
284
  const isolation = config.execution?.isolation ?? 'worktree';
285
285
  // Explicit shared mode uses the requested workspace. Readonly profiles,
286
- // including the default Reviewer, use a disposable worktree below so an
287
- // accidental edit can be detected and never pollutes the primary tree.
286
+ // including the default Reviewer, take the disposable worktree below, so an
287
+ // accidental edit cannot reach the primary tree at all. A reviewer that was
288
+ // explicitly given a shared workspace still gets fingerprinted around its
289
+ // attempt, so an edit there is detected even though it is not prevented.
288
290
  if (job.requested_isolation === 'shared' || isolation === 'shared') {
289
291
  return { ok: true, execution_cwd: job.requested_cwd, isolation: 'shared', base_revision: null, primary_workspace_dirty: false, handle: null };
290
292
  }
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '2.0.2';
39
+ export const RUNTIME_VERSION = '2.0.4';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -289,7 +289,10 @@ export function createWorkflowRuntime(adapters, {
289
289
 
290
290
  async function releaseWorkspace(job) {
291
291
  if (job.retain_workspace) {
292
- job.workspace_retained = true;
292
+ // Retention means an isolated workspace was deliberately kept for
293
+ // recovery. A shared workspace is the caller's own and was never ours to
294
+ // keep, so an evidence failure there retains nothing.
295
+ if (job.workspaceHandle) job.workspace_retained = true;
293
296
  return;
294
297
  }
295
298
  if (!job.workspaceHandle) return;
@@ -342,7 +345,7 @@ export function createWorkflowRuntime(adapters, {
342
345
 
343
346
  if (isReviewJob) {
344
347
  transition(job, JOB_PHASES.REVIEWING, 'explicit reviewer');
345
- const before = alloc?.ok && alloc.isolation === 'worktree' ? await safeCapture(adapters, job.execution_cwd, job.base_revision) : null;
348
+ const before = alloc?.ok ? await safeCapture(adapters, job.execution_cwd, job.base_revision) : null;
346
349
  // Explicit reviewers inspect the caller's stated task/workspace
347
350
  // directly. The bounded worker-candidate capsule belongs only to the
348
351
  // automatic post-worker review path; constructing it with no worker
@@ -553,8 +556,13 @@ export function createWorkflowRuntime(adapters, {
553
556
  }
554
557
 
555
558
  async function runReviewerAttempt(job, task, config, beforeCandidate, cwd = job.execution_cwd, baseRevision = job.base_revision) {
559
+ // Reviewing is the one role whose entire contract is "changed nothing", so
560
+ // its evidence is not exempted by isolation. In a worktree the pre-review
561
+ // candidate comes from the allocator or the worker and its absence is a real
562
+ // failure; a shared workspace has no such candidate, so fingerprint the tree
563
+ // the reviewer is about to touch — otherwise the reviewer is the one actor
564
+ // whose edits nothing observes.
556
565
  const requireFingerprint = (candidate) => {
557
- if (job.isolation !== 'worktree') return;
558
566
  if (candidate?.ok === true && typeof candidate.fingerprint === 'string' && candidate.fingerprint.trim()) return;
559
567
  job.candidate_capture_failed = true;
560
568
  job.retain_workspace = true;
@@ -562,7 +570,9 @@ export function createWorkflowRuntime(adapters, {
562
570
  code: 'REVIEW_EVIDENCE_UNAVAILABLE',
563
571
  });
564
572
  };
565
- requireFingerprint(beforeCandidate);
573
+ const before = beforeCandidate
574
+ ?? (job.isolation === 'worktree' ? null : await safeCapture(adapters, cwd, baseRevision));
575
+ requireFingerprint(before);
566
576
  const policy = resolveModelPolicy(config, 'reviewer', { attempt: 0 });
567
577
  const attemptId = attemptIdFor(adapters, job.id, 'review');
568
578
  job.current_attempt_id = attemptId;
@@ -594,9 +604,9 @@ export function createWorkflowRuntime(adapters, {
594
604
  fallback_reason: attemptView.selection_trace?.fallback_reason ?? null,
595
605
  }, 0);
596
606
  if (ar.status === 'failed' && attemptView.error_code) job.error_code = attemptView.error_code;
597
- const afterCandidate = job.isolation === 'worktree' ? await safeCapture(adapters, cwd, baseRevision) : null;
607
+ const afterCandidate = await safeCapture(adapters, cwd, baseRevision);
598
608
  requireFingerprint(afterCandidate);
599
- const review = normalizeReview({ attemptResult: ar, beforeCandidate, afterCandidate });
609
+ const review = normalizeReview({ attemptResult: ar, beforeCandidate: before, afterCandidate });
600
610
  recordCanonical(job, 'review.completed', {
601
611
  attempt_id: attemptView.id,
602
612
  status: review.status,
@@ -5,15 +5,17 @@
5
5
  //
6
6
  // Discipline: strictly read-only. Only git porcelain reads are issued — never
7
7
  // reset / stash / clean / checkout, and nothing here writes to disk. A
8
- // non-git directory degrades to { kind: 'no-git' } instead of failing, and a
9
- // pre-dirty workspace is flagged (dirtyBaseline) rather than hidden.
8
+ // non-git directory degrades to { kind: 'no-git' } by default, and a pre-dirty
9
+ // workspace is flagged (dirtyBaseline) rather than hidden. Hub callers may
10
+ // opt into a bounded directory-only snapshot for initially empty non-Git trees.
10
11
  //
11
12
  // The default `git` runner is replaceable with an injected runner in tests:
12
13
  // it receives (argsArray, { cwd }) and resolves { code, stdout, stderr }.
13
14
 
14
15
  import { execFile } from 'node:child_process';
15
16
  import { existsSync } from 'node:fs';
16
- import { win32 } from 'node:path';
17
+ import { lstat, opendir, realpath } from 'node:fs/promises';
18
+ import { isAbsolute, relative, resolve, sep, win32 } from 'node:path';
17
19
  import { promisify } from 'node:util';
18
20
 
19
21
  const execFileAsync = promisify(execFile);
@@ -22,7 +24,10 @@ export const DIFF_LIMIT = 64 * 1024;
22
24
  export const NOT_A_GIT_REPOSITORY = 'NOT_A_GIT_REPOSITORY';
23
25
  export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
24
26
  export const GIT_TIMEOUT = 'GIT_TIMEOUT';
27
+ export const GIT_ERROR = 'GIT_ERROR';
25
28
  export const GIT_TIMEOUT_MS = 8000;
29
+ const EMPTY_TREE_ENTRY_LIMIT = 20_000;
30
+ const EMPTY_TREE_DEPTH_LIMIT = 64;
26
31
 
27
32
  const SENSITIVE_SUFFIXES = ['.pem', '.key'];
28
33
  const SENSITIVE_PREFIXES = ['credentials', 'secret'];
@@ -85,15 +90,103 @@ async function runGit(runner, args, opts) {
85
90
  if (/not a git repository/i.test(stderr)) {
86
91
  return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: stderr.trim() };
87
92
  }
93
+ if (Number.isInteger(r.code) && r.code !== 0) {
94
+ return { ok: false, reason: GIT_ERROR, error: 'git audit failed' };
95
+ }
88
96
  return { ok: true, code: r.code ?? 0, stdout: r.stdout ?? '', stderr };
89
97
  } catch (err) {
90
98
  const msg = err?.message ?? String(err);
99
+ const diagnostic = `${err?.stderr ?? ''}\n${msg}`;
100
+ if (/not a git repository/i.test(diagnostic)) return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: 'not a git repository' };
91
101
  if (err?.code === 'ETIMEDOUT' || err?.killed === true || /timed out|timeout/i.test(msg)) return { ok: false, reason: GIT_TIMEOUT, error: 'git audit timed out' };
92
102
  if (/ENOENT|spawn git/i.test(msg)) return { ok: false, reason: GIT_NOT_FOUND, error: msg };
93
- return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: msg };
103
+ return { ok: false, reason: GIT_ERROR, error: 'git audit failed' };
94
104
  }
95
105
  }
96
106
 
107
+ function samePath(left, right) {
108
+ const normalizedLeft = resolve(left).replace(/[\\/]+$/, '');
109
+ const normalizedRight = resolve(right).replace(/[\\/]+$/, '');
110
+ return process.platform === 'win32'
111
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
112
+ : normalizedLeft === normalizedRight;
113
+ }
114
+
115
+ function pathIsWithin(root, candidate) {
116
+ const rel = relative(root, candidate);
117
+ const comparable = process.platform === 'win32' ? rel.toLowerCase() : rel;
118
+ return comparable === '' || (comparable !== '..' && !comparable.startsWith(`..${sep}`) && !isAbsolute(rel));
119
+ }
120
+
121
+ /**
122
+ * Capture names and entry kinds only; never open file contents. This fallback
123
+ * is eligible only when the pre-run tree has no files or links, so it can prove
124
+ * that a temporary file/directory was removed without reading existing data.
125
+ */
126
+ async function captureDirectoryEntries(cwd) {
127
+ let root;
128
+ try {
129
+ const requested = resolve(cwd);
130
+ const requestedStat = await lstat(requested);
131
+ if (!requestedStat.isDirectory() || requestedStat.isSymbolicLink()) {
132
+ return { ok: false, reason: 'WORKSPACE_ROOT_UNSAFE' };
133
+ }
134
+ root = await realpath(requested);
135
+ } catch {
136
+ return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
137
+ }
138
+
139
+ const pending = [{ absolute: root, depth: 0 }];
140
+ const entries = [];
141
+ let regularFileCount = 0;
142
+ let linkOrSpecialCount = 0;
143
+ try {
144
+ while (pending.length > 0) {
145
+ const current = pending.pop();
146
+ if (current.depth > EMPTY_TREE_DEPTH_LIMIT) return { ok: false, reason: 'WORKSPACE_SCAN_LIMIT' };
147
+ const beforeStat = await lstat(current.absolute);
148
+ if (!beforeStat.isDirectory() || beforeStat.isSymbolicLink()) return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
149
+ const canonical = await realpath(current.absolute);
150
+ if (!pathIsWithin(root, canonical)) return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
151
+ const children = await opendir(canonical);
152
+ for await (const child of children) {
153
+ if (entries.length >= EMPTY_TREE_ENTRY_LIMIT) return { ok: false, reason: 'WORKSPACE_SCAN_LIMIT' };
154
+ const absolute = resolve(canonical, child.name);
155
+ const entryPath = relative(root, absolute).split(sep).join('/');
156
+ const stat = await lstat(absolute);
157
+ let type;
158
+ if (stat.isSymbolicLink()) {
159
+ type = 'link';
160
+ linkOrSpecialCount += 1;
161
+ } else if (stat.isDirectory()) {
162
+ type = 'directory';
163
+ pending.push({ absolute, depth: current.depth + 1 });
164
+ } else if (stat.isFile()) {
165
+ type = 'file';
166
+ regularFileCount += 1;
167
+ } else {
168
+ type = 'special';
169
+ linkOrSpecialCount += 1;
170
+ }
171
+ entries.push({ path: entryPath, type });
172
+ }
173
+ const afterStat = await lstat(current.absolute);
174
+ const afterCanonical = await realpath(current.absolute);
175
+ if (!afterStat.isDirectory() || afterStat.isSymbolicLink()
176
+ || !samePath(canonical, afterCanonical)
177
+ || beforeStat.mtimeMs !== afterStat.mtimeMs
178
+ || beforeStat.ctimeMs !== afterStat.ctimeMs) {
179
+ return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
180
+ }
181
+ }
182
+ } catch {
183
+ return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
184
+ }
185
+
186
+ entries.sort((left, right) => left.path.localeCompare(right.path));
187
+ return { ok: true, entries, regularFileCount, linkOrSpecialCount };
188
+ }
189
+
97
190
  function splitFirstTab(line) {
98
191
  const i = line.indexOf('\t');
99
192
  return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
@@ -136,10 +229,11 @@ export function parseChanges(nameStatus, statusPorcelain) {
136
229
 
137
230
  /**
138
231
  * Read-only pre-run snapshot of a workspace. Returns a git snapshot
139
- * { kind:'git', status, nameStatus, stat, dirty, changes } or a degraded
140
- * { kind:'no-git', reason, error } for non-repos / missing git. Never throws.
232
+ * { kind:'git', status, nameStatus, stat, dirty, changes }, an opted-in
233
+ * { kind:'filesystem-empty', entries } for an empty non-Git tree, or a degraded
234
+ * { kind:'no-git', reason, error } for other non-repos / missing Git. Never throws.
141
235
  */
142
- export async function captureWorkspaceBaseline({ cwd, git } = {}) {
236
+ export async function captureWorkspaceBaseline({ cwd, git, allowEmptyNonGitDirectory = false } = {}) {
143
237
  const runner = git ?? defaultRunner;
144
238
  if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
145
239
  const [status, nameStatus, stat] = await Promise.all([
@@ -147,8 +241,19 @@ export async function captureWorkspaceBaseline({ cwd, git } = {}) {
147
241
  runGit(runner, ['diff', '--name-status'], { cwd }),
148
242
  runGit(runner, ['diff', '--stat'], { cwd }),
149
243
  ]);
150
- for (const r of [status, nameStatus, stat]) {
151
- if (!r.ok) return { kind: 'no-git', reason: r.reason, error: r.error };
244
+ const results = [status, nameStatus, stat];
245
+ const failures = results.filter((result) => !result.ok);
246
+ if (failures.length > 0) {
247
+ const allExplicitlyNonGit = failures.length === results.length
248
+ && failures.every((result) => result.reason === NOT_A_GIT_REPOSITORY);
249
+ if (allExplicitlyNonGit && allowEmptyNonGitDirectory === true) {
250
+ const tree = await captureDirectoryEntries(cwd);
251
+ if (tree.ok && tree.regularFileCount === 0 && tree.linkOrSpecialCount === 0) {
252
+ return { kind: 'filesystem-empty', entries: tree.entries };
253
+ }
254
+ }
255
+ const failed = failures.find((result) => result.reason !== NOT_A_GIT_REPOSITORY) ?? failures[0];
256
+ return { kind: 'no-git', reason: failed.reason, error: failed.error };
152
257
  }
153
258
  const changes = parseChanges(nameStatus.stdout, status.stdout);
154
259
  return {
@@ -203,6 +308,15 @@ async function buildPatch(runner, { cwd, changes, limit }) {
203
308
  export async function captureWorkspaceDiff({ cwd, baseline, git, limit = DIFF_LIMIT } = {}) {
204
309
  const runner = git ?? defaultRunner;
205
310
  if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
311
+ if (baseline?.kind === 'filesystem-empty') {
312
+ const tree = await captureDirectoryEntries(cwd);
313
+ if (!tree.ok) return { kind: 'no-git', reason: tree.reason, error: 'filesystem audit unavailable' };
314
+ return {
315
+ kind: 'filesystem-empty',
316
+ dirtyBaseline: false,
317
+ unchanged: JSON.stringify(tree.entries) === JSON.stringify(baseline.entries),
318
+ };
319
+ }
206
320
  if (!baseline || baseline.kind !== 'git') {
207
321
  return { kind: 'no-git', reason: baseline?.reason ?? NOT_A_GIT_REPOSITORY, error: baseline?.error };
208
322
  }