@ran-sh/dsh-crew 1.10.5 → 1.10.6

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": "1.10.5",
3
+ "version": "1.10.6",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "1.10.5",
3
+ "version": "1.10.6",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { createRequire } from 'node:module';
10
+ import { renameTree } from './install/tree-move.mjs';
10
11
  import {
11
12
  existsSync,
12
13
  lstatSync,
@@ -460,7 +461,7 @@ export async function migrateCrewDshRuntime({
460
461
  let liveMoved = false;
461
462
  try {
462
463
  if (existsSync(liveRoot)) {
463
- rename(liveRoot, prevRoot);
464
+ renameTree(liveRoot, prevRoot, { rename });
464
465
  liveMoved = true;
465
466
  }
466
467
  mkdirSync(liveRoot, { recursive: true });
package/src/hub/index.mjs CHANGED
@@ -1330,6 +1330,19 @@ export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependen
1330
1330
  }
1331
1331
  // v0.2 role-based dispatch: reviewer / worker are gated by their role state,
1332
1332
  // and the tier slot is derived from the role (reviewer always → pro).
1333
+ // A plain payload names no workspace or constraints, but it does name a role,
1334
+ // and the role's profile states how that role runs. Only the advanced shape
1335
+ // resolved it, so the same task submitted through the simple shape ran shared
1336
+ // while the profile said worktree: the profile was silently ignored for the
1337
+ // caller who wrote the least. A payload that names no role keeps its
1338
+ // pass-through shape.
1339
+ if (normalized.requested_isolation === undefined && (normalized.role === 'worker' || normalized.role === 'reviewer')) {
1340
+ const registry = dependencies.profileRegistry ?? loadRoleProfiles();
1341
+ const roleProfile = registry.ok ? resolveRoleProfile(registry, normalized.profile, normalized.role) : { ok: false };
1342
+ if (roleProfile.ok && typeof roleProfile.profile?.isolation === 'string') {
1343
+ normalized = { ...normalized, requested_isolation: roleProfile.profile.isolation };
1344
+ }
1345
+ }
1333
1346
  if (normalized.role === 'worker' || normalized.role === 'reviewer') {
1334
1347
  const hint = resolveRoleTierHint(normalized.role, normalized.tier);
1335
1348
  if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
@@ -0,0 +1,52 @@
1
+ // Where the Crew-owned Harness profile lives, and where the registration link
2
+ // that the host integrations point at is rooted.
3
+ //
4
+ // This is its own module because two layers must agree about it and neither may
5
+ // import the other: the installers write host integrations against the loader
6
+ // link, and the readiness checks decide whether those integrations are correct.
7
+ // When readiness derived its expected paths from the release directory instead,
8
+ // every correctly installed machine read as needing repair — the installer and
9
+ // the check disagreed about what "the installed server" is, and only the
10
+ // installer was right.
11
+
12
+ import { existsSync, realpathSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+
16
+ export const CREW_PROFILE_NAME = 'dsh-crew';
17
+ export const CREW_HOME_REL = join('.config', 'dsh-crew', 'harness');
18
+
19
+ export function crewDshHome({ home = homedir() } = {}) {
20
+ return join(home, CREW_HOME_REL);
21
+ }
22
+
23
+ export function crewProfileDir({ home = homedir() } = {}) {
24
+ return join(crewDshHome({ home }), 'profiles', CREW_PROFILE_NAME);
25
+ }
26
+
27
+ // The profile's `node_modules/<name>` entry for the installed package. The
28
+ // registration points this at the live release, so it is stable across
29
+ // upgrades while the release path underneath it is not.
30
+ export function loaderLinkPath({ home = homedir(), name } = {}) {
31
+ if (typeof name !== 'string' || name.trim() === '') return null;
32
+ return join(crewProfileDir({ home }), 'node_modules', ...name.split('/'));
33
+ }
34
+
35
+ function sameDirectory(left, right) {
36
+ try { return realpathSync(left) === realpathSync(right); } catch { return false; }
37
+ }
38
+
39
+ /**
40
+ * The root the host integrations were installed from.
41
+ *
42
+ * Readiness must judge the integrations against the path they were actually
43
+ * written with. That path is the loader link when it resolves to this release,
44
+ * and the release directory itself otherwise — a machine whose link is missing
45
+ * or points somewhere else is reported against the release, which is exactly
46
+ * the mismatch the operator needs to see.
47
+ */
48
+ export function integrationRoot({ home = homedir(), root, name } = {}) {
49
+ const link = loaderLinkPath({ home, name });
50
+ if (!link || !root || !existsSync(link)) return root;
51
+ return sameDirectory(link, root) ? link : root;
52
+ }
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
10
10
  import { normalizeModelPriority } from '../model-routing.mjs';
11
11
  import { crewSkillFiles, installCrewSkill, readCrewSkill, removeCrewSkill } from './crew-skill.mjs';
12
12
  import { zcodeStatus } from './zcode.mjs';
13
+ import { integrationRoot } from './crew-paths.mjs';
13
14
 
14
15
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
15
16
  const MARKETPLACE_NAME = 'dsh-crew';
@@ -372,6 +373,13 @@ export function writeGlobalConfig(patch) {
372
373
 
373
374
  /** What is currently installed where — drives the settings-page buttons. */
374
375
  export function installStatus({ home = homedir(), root = ROOT, env = process.env } = {}) {
376
+ // Host integrations are installed from the profile's loader link, not from the
377
+ // release directory, so an upgrade re-points one link instead of rewriting four
378
+ // host configurations. Readiness has to judge them against the path they were
379
+ // actually written with: deriving it from the release directory made every
380
+ // correctly installed machine read as needing repair.
381
+ const manifestName = readJson(join(root, 'package.json'), {})?.name ?? null;
382
+ const effectiveRoot = integrationRoot({ home, root, name: manifestName });
375
383
  const settings = readJson(join(home, '.claude', 'settings.json'), {});
376
384
  const enabled = settings.enabledPlugins;
377
385
  const claudeInstalled = !!(enabled && !Array.isArray(enabled) && enabled[PLUGIN_KEY]);
@@ -382,8 +390,8 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
382
390
  const claudeFootprint = claudeInstalled || typeof marketplaceRoot === 'string' || installedPluginRecord !== undefined;
383
391
  const claudeComponents = {
384
392
  enabled: claudeInstalled,
385
- marketplace: normalizedPath(marketplaceRoot) === normalizedPath(root) && claudePluginRootReady(root),
386
- snapshot: claudeSnapshotReady(home, root),
393
+ marketplace: normalizedPath(marketplaceRoot) === normalizedPath(effectiveRoot) && claudePluginRootReady(effectiveRoot),
394
+ snapshot: claudeSnapshotReady(home, effectiveRoot),
387
395
  permissions: claudePermissionsReady(settings),
388
396
  };
389
397
  const claudeMissing = Object.entries(claudeComponents).filter(([, present]) => !present).map(([key]) => key);
@@ -395,11 +403,11 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
395
403
  const workerTarget = codexRoleTarget(workerFile, 'ds-worker');
396
404
  const reviewerTarget = codexRoleTarget(reviewerFile, 'ds-reviewer');
397
405
  const mcpTarget = codexMcpTarget(configText);
398
- const expectedTarget = normalizedPath(join(root, 'src', 'server.mjs'));
399
- const expectedWorkerRole = renderedCodexRole(root, 'ds-worker.toml');
400
- const expectedReviewerRole = renderedCodexRole(root, 'ds-reviewer.toml');
401
- const expectedConfigPrompt = readText(join(root, 'codex', 'prompts', 'dsh-config.md'));
402
- const expectedStatusPrompt = readText(join(root, 'codex', 'prompts', 'dsh-status.md'));
406
+ const expectedTarget = normalizedPath(join(effectiveRoot, 'src', 'server.mjs'));
407
+ const expectedWorkerRole = renderedCodexRole(effectiveRoot, 'ds-worker.toml');
408
+ const expectedReviewerRole = renderedCodexRole(effectiveRoot, 'ds-reviewer.toml');
409
+ const expectedConfigPrompt = readText(join(effectiveRoot, 'codex', 'prompts', 'dsh-config.md'));
410
+ const expectedStatusPrompt = readText(join(effectiveRoot, 'codex', 'prompts', 'dsh-status.md'));
403
411
  const components = {
404
412
  worker_role: expectedWorkerRole !== null && readText(workerFile) === expectedWorkerRole,
405
413
  reviewer_role: expectedReviewerRole !== null && readText(reviewerFile) === expectedReviewerRole,
@@ -422,10 +430,34 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
422
430
  missing: claudeMissing,
423
431
  },
424
432
  codex: { installed: codexInstalled, ready: missing.length === 0, components, missing },
425
- zcode: zcodeStatus({ home, root }),
433
+ zcode: zcodeStatus({ home, root: effectiveRoot }),
426
434
  };
427
435
  }
428
436
 
437
+ function removeLegacyCodexRoles({ agentsDir }) {
438
+ const actions = [];
439
+ for (const f of ['worker.toml', 'reviewer.toml']) {
440
+ const p = join(agentsDir, f);
441
+ if (!existsSync(p)) continue;
442
+ // Only Crew's own abandoned stub is removed. An earlier release wrote these
443
+ // before the roles were renamed to ds-worker/ds-reviewer, and every Codex
444
+ // start since has logged "Ignoring malformed agent role definition" about a
445
+ // file of ours that Codex cannot use: a role without `developer_instructions`
446
+ // is not a role, so anything that *is* a real user role cannot match here.
447
+ const text = readText(p);
448
+ if (typeof text !== 'string') continue;
449
+ const name = f.replace(/\.toml$/, '');
450
+ const stripped = text.replace(/#[^\n]*/g, '').trim();
451
+ if (/developer_instructions/.test(stripped)) continue;
452
+ if (!new RegExp(`^name\\s*=\\s*["']${name}["']$`).test(stripped)) continue;
453
+ const bak = backup(p);
454
+ if (bak) actions.push(`backup: ${bak}`);
455
+ rmSync(p);
456
+ actions.push(`removed obsolete role: ${p}`);
457
+ }
458
+ return actions;
459
+ }
460
+
429
461
  export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
430
462
  const actions = [];
431
463
  // Both the v0.2 roles (ds-worker / ds-reviewer) and the deprecated v0.1
@@ -435,6 +467,7 @@ export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
435
467
  const p = join(codexHomeDir(home, env), 'agents', f);
436
468
  if (existsSync(p)) { backup(p); rmSync(p); actions.push(`removed: ${p} (backup kept)`); }
437
469
  }
470
+ actions.push(...removeLegacyCodexRoles({ agentsDir: join(codexHomeDir(home, env), 'agents') }));
438
471
  for (const f of ['dsh-config.md', 'dsh-status.md']) {
439
472
  const p = join(codexHomeDir(home, env), 'prompts', f);
440
473
  if (existsSync(p)) { rmSync(p); actions.push(`removed: ${p}`); }
@@ -596,6 +629,7 @@ export function installCodex({ home = homedir(), scope, root = ROOT, env = proce
596
629
  writeFileSync(join(promptsDir, f), readFileSync(join(promptsSrc, f), 'utf8'));
597
630
  actions.push(`prompt: ${join(promptsDir, f)}`);
598
631
  }
632
+ actions.push(...removeLegacyCodexRoles({ agentsDir }));
599
633
  if (scope !== 'project') {
600
634
  const act = writeGlobalCodexMcpServer(home, renderedPath, env);
601
635
  actions.push(...act);
@@ -32,16 +32,9 @@ const TIER_STATES = ['disabled', 'manual', 'auto'];
32
32
  // paths must never default to the user's official ~/.dsh or its ``web`` profile.
33
33
  // A fresh Crew Hub config points at the Crew-owned port only; the former
34
34
  // shared-profile default 3080 is treated as a legacy value to migrate away from.
35
- export const CREW_PROFILE_NAME = 'dsh-crew';
36
- export const CREW_HOME_REL = join('.config', 'dsh-crew', 'harness');
37
35
  export const CREW_DEFAULT_HUB_URL = 'http://127.0.0.1:3210';
38
36
  export const CREW_LEGACY_HUB_URL = 'http://127.0.0.1:3080';
39
- export function crewDshHome({ home = homedir() } = {}) {
40
- return join(home, CREW_HOME_REL);
41
- }
42
- export function crewProfileDir({ home = homedir() } = {}) {
43
- return join(crewDshHome({ home }), 'profiles', CREW_PROFILE_NAME);
44
- }
37
+ export { CREW_HOME_REL, CREW_PROFILE_NAME, crewDshHome, crewProfileDir } from './crew-paths.mjs';
45
38
 
46
39
  // The isolated default set: identical to the legacy defaults except the Hub URL,
47
40
  // so fresh Crew installs never point at the official web profile.
@@ -44,6 +44,7 @@ import { samePayloadContent, capturePayloadContent } from './payload-content.mjs
44
44
  import { crewDshHome, crewProfileDir } from './install.mjs';
45
45
  import { releaseClaimsState } from '../release-in-use.mjs';
46
46
  import { compareProcessToken, processStartToken } from '../process-identity.mjs';
47
+ import { renameTree } from './tree-move.mjs';
47
48
  import { checkRuntimeAdvance, normalizeRuntimeState, runtimeStateMayHaveStarted } from './runtime-lifecycle.mjs';
48
49
  import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
49
50
  import {
@@ -857,7 +858,7 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
857
858
  if (parked) {
858
859
  try {
859
860
  rmSync(rt.liveRoot, { recursive: true, force: true });
860
- renameSync(parked, rt.liveRoot);
861
+ renameTree(parked, rt.liveRoot);
861
862
  } catch (error) {
862
863
  return { ok: false, code: 'JOURNAL_COORDINATED_RUNTIME_RESTORE_FAILED', stage: journal.stage, error: `prior runtime restore failed: ${error?.message ?? error}` };
863
864
  }
@@ -868,7 +869,7 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
868
869
  if (retainedDir && existsSync(retainedDir)) {
869
870
  try {
870
871
  rmSync(rt.liveRoot, { recursive: true, force: true });
871
- renameSync(retainedDir, rt.liveRoot);
872
+ renameTree(retainedDir, rt.liveRoot);
872
873
  restored = true;
873
874
  } catch { /* fall through to fail closed */ }
874
875
  }
@@ -2434,14 +2435,14 @@ export async function performCoordinatedCohortUpdate({
2434
2435
  if (existsSync(liveRoot)) {
2435
2436
  // prevPath is unique per attempt and already recorded in the journal.
2436
2437
  try { rmSync(prevPath, { recursive: true, force: true }); } catch {}
2437
- renameSync(liveRoot, prevPath);
2438
+ renameTree(liveRoot, prevPath);
2438
2439
  liveMoved = true;
2439
2440
  }
2440
2441
  mkdirSync(liveRoot, { recursive: true });
2441
2442
  } catch (error) {
2442
2443
  // Park failed: restore live tree before anything else.
2443
2444
  try {
2444
- if (liveMoved && !existsSync(liveRoot) && prevPath && existsSync(prevPath)) renameSync(prevPath, liveRoot);
2445
+ if (liveMoved && !existsSync(liveRoot) && prevPath && existsSync(prevPath)) renameTree(prevPath, liveRoot);
2445
2446
  } catch { /* best effort */ }
2446
2447
  const comp = await compensate();
2447
2448
  return finalizeCompensationFailure({ home, code: 'COORDINATED_RUNTIME_PARK_FAILED', error: String(error?.message ?? error), comp });
@@ -2453,7 +2454,7 @@ export async function performCoordinatedCohortUpdate({
2453
2454
  let comp = { ok: false };
2454
2455
  try {
2455
2456
  rmSync(liveRoot, { recursive: true, force: true });
2456
- if (liveMoved && existsSync(prevPath)) renameSync(prevPath, liveRoot);
2457
+ if (liveMoved && existsSync(prevPath)) renameTree(prevPath, liveRoot);
2457
2458
  comp = await compensate();
2458
2459
  } catch { /* compensate below already reports */ }
2459
2460
  return finalizeCompensationFailure({ home, code: installed.code ?? 'COORDINATED_RUNTIME_INSTALL_FAILED', error: installed.error ?? 'runtime install at live root failed', comp });
@@ -0,0 +1,37 @@
1
+ import { renameSync } from 'node:fs';
2
+
3
+ /**
4
+ * Rename a directory, absorbing the transient refusal Windows gives for a tree
5
+ * that was just in use.
6
+ *
7
+ * Every runtime-tree move in this codebase happens immediately after the process
8
+ * using that tree was stopped: parking the live cohort, restoring a parked one
9
+ * during recovery, rotating a displaced cohort. On Windows the rename can be
10
+ * refused with EPERM/EACCES for a moment afterwards — a handle another process
11
+ * still holds, an indexer, or the previous child's own teardown — and the same
12
+ * call succeeds a few milliseconds later. Treating the first refusal as fatal
13
+ * turns that timing artifact into a failed upgrade, a failed rollback, or worst
14
+ * of all a recovery that cannot finish.
15
+ *
16
+ * Only the transient codes are retried. A missing source or a cross-device move
17
+ * is not going to become possible on the next attempt, so it is raised at once.
18
+ */
19
+ export const TRANSIENT_RENAME_CODES = Object.freeze(['EPERM', 'EACCES', 'EBUSY', 'ENOTEMPTY']);
20
+
21
+ export function renameTree(from, to, { rename = renameSync, delays = [0, 40, 120, 300, 700] } = {}) {
22
+ let lastError = null;
23
+ for (const delay of delays) {
24
+ if (delay > 0) {
25
+ const until = Date.now() + delay;
26
+ while (Date.now() < until) { /* bounded wait: this path is not hot */ }
27
+ }
28
+ try {
29
+ rename(from, to);
30
+ return { ok: true };
31
+ } catch (error) {
32
+ lastError = error;
33
+ if (!TRANSIENT_RENAME_CODES.includes(error?.code)) throw error;
34
+ }
35
+ }
36
+ throw lastError;
37
+ }
@@ -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 = '1.10.5';
39
+ export const RUNTIME_VERSION = '1.10.6';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -181,8 +181,14 @@ export function createWorkflowRuntime(adapters, {
181
181
  phase: JOB_PHASES.CREATED,
182
182
  status: 'running',
183
183
  cancelling: false,
184
+ // Where it will run is not known until the workspace is allocated, and the
185
+ // first snapshot a caller sees is taken before that happens. Claiming
186
+ // `shared` there was a wrong answer for every job that ends up isolated:
187
+ // the caller was told the work happens in their own tree while it was
188
+ // about to happen in a worktree. `null` means not allocated yet;
189
+ // `requested_isolation` carries what was asked for.
184
190
  execution_cwd: spec.cwd,
185
- isolation: 'shared',
191
+ isolation: null,
186
192
  base_revision: null,
187
193
  primary_workspace_dirty: false,
188
194
  attempts: [],
package/src/workflow.mjs CHANGED
@@ -63,17 +63,41 @@ function splitSection(value) {
63
63
  return value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
64
64
  }
65
65
 
66
- const NO_CHANGE_SENTINELS = new Set([
67
- 'no files changed',
68
- 'no file changed',
69
- 'no changes',
70
- '无文件变更',
71
- '没有文件变更',
72
- '未更改任何文件',
73
- '无变更',
74
- ]);
66
+ // A line that asserts the workspace ends unchanged. The report sections are
67
+ // prose, so this has to recognize the assertion inside a sentence rather than
68
+ // only as a whole line: "No net file changes — git status is empty" is the same
69
+ // claim as "no changes", and it is the *better* report.
70
+ const NO_CHANGE_ASSERTION_RE = new RegExp(
71
+ '^(?:'
72
+ + 'no\\s+(?:net\\s+)?(?:files?|changes?)\\b'
73
+ + '|nothing\\s+(?:was\\s+)?(?:changed|modified|added|removed|deleted)\\b'
74
+ + '|none\\s+(?:of\\s+the\\s+)?(?:files?|changes?)\\b'
75
+ + '|workspace\\s+(?:is\\s+|remains\\s+)?(?:unchanged|clean)\\b'
76
+ + '|(?:the\\s+)?(?:final\\s+|net\\s+)?(?:file\\s+)?changes?\\s*[:—-]\\s*(?:none|no\\b|empty)'
77
+ + '|无(?:文件)?变更|没有(?:文件)?变更|未(?:更改|修改)任何文件|工作区(?:保持)?不变'
78
+ + ')',
79
+ );
80
+
81
+ function isNoChangeAssertion(line) {
82
+ const normalized = line
83
+ .replace(/^(?:[-*+]\s+)+/, '')
84
+ .replace(/[`"'“”‘’]/g, '')
85
+ .replace(/[.!。!]+$/, '')
86
+ .trim()
87
+ .toLowerCase();
88
+ if (normalized === '') return false;
89
+ return NO_CHANGE_ASSERTION_RE.test(normalized);
90
+ }
75
91
 
92
+ // Whether the report claims the workspace holds changes. A report that asserts
93
+ // the workspace ends unchanged is not claiming any, whatever else it describes:
94
+ // an authorized task that creates something, verifies it and removes it must
95
+ // describe that transient work, and describing it is not a claim that it is
96
+ // still there. This can only ever turn a mismatch into a match when the
97
+ // workspace really is unchanged — if it did change, `claimsChanges === false`
98
+ // against real changes is still a mismatch, so no actual change is hidden.
76
99
  function deliveryClaimsChanges(outcome) {
100
+ if (outcome?.no_change_declared === true) return false;
77
101
  return Array.isArray(outcome?.changes) && outcome.changes.length > 0;
78
102
  }
79
103
 
@@ -110,15 +134,9 @@ export function applyWorkspaceEvidence(outcome, {
110
134
  }
111
135
 
112
136
  function parseChanges(section) {
113
- return splitSection(section).filter((line) => {
114
- const normalized = line
115
- .replace(/^(?:[-*+]\s+)+/, '')
116
- .replace(/[`"'“”‘’]/g, '')
117
- .replace(/[.!。!]+$/g, '')
118
- .trim()
119
- .toLowerCase();
120
- return !NO_CHANGE_SENTINELS.has(normalized);
121
- });
137
+ // The declaration lines are dropped so the remaining list is what the report
138
+ // says about files; the no-change assertion itself is kept out of it.
139
+ return splitSection(section).filter((line) => !isNoChangeAssertion(line));
122
140
  }
123
141
 
124
142
  // The Tests section is parsed by the delivery contract's own parser. This module
@@ -163,6 +181,11 @@ export function buildOutcome({ result = '', deliveryMeta, executionStatus, stopR
163
181
  confidence: null,
164
182
  needs_escalation: false,
165
183
  changes: parseChanges(parsed.sections.Diff),
184
+ // The report's net claim about the workspace, read from the section as
185
+ // written. It cannot be inferred from `changes`: that list is filtered, so
186
+ // the declaration line is gone from it by the time anyone looks, and the
187
+ // lines that remain describe work the report already said it undid.
188
+ no_change_declared: splitSection(parsed.sections.Diff).some(isNoChangeAssertion),
166
189
  tests,
167
190
  tests_status: testsStatus ?? null,
168
191
  risks: splitSection(parsed.sections.Risks),