mandrel 2.65.0 → 2.67.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.
Files changed (82) hide show
  1. package/.agents/agents/acceptance-critic.md +7 -7
  2. package/.agents/agents/auditor.md +17 -18
  3. package/.agents/agents/plan-critic.md +5 -5
  4. package/.agents/agents/story-worker.md +5 -5
  5. package/.agents/docs/agentrc-reference.json +2 -1
  6. package/.agents/docs/configuration.md +2 -1
  7. package/.agents/docs/execution-reference.md +27 -5
  8. package/.agents/docs/workflows.md +4 -2
  9. package/.agents/instructions.md +12 -13
  10. package/.agents/rules/ci-remediation.md +3 -3
  11. package/.agents/rules/gherkin-standards.md +3 -2
  12. package/.agents/rules/git-conventions-reference.md +17 -8
  13. package/.agents/rules/git-conventions.md +10 -8
  14. package/.agents/rules/testing-standards.md +8 -7
  15. package/.agents/runtime-deps.json +1 -1
  16. package/.agents/schemas/agentrc.schema.json +6 -1
  17. package/.agents/scripts/boot-sweep.js +97 -9
  18. package/.agents/scripts/bootstrap.js +94 -89
  19. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  20. package/.agents/scripts/clean-temp.js +54 -0
  21. package/.agents/scripts/clean-worktrees.js +593 -0
  22. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  23. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  24. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
  25. package/.agents/scripts/lib/clean-temp.js +440 -0
  26. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  27. package/.agents/scripts/lib/cli-args.js +26 -0
  28. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
  30. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  31. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  32. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  34. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  35. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  36. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  37. package/.agents/scripts/lib/observability/source-classifier.js +3 -1
  38. package/.agents/scripts/lib/orchestration/code-review.js +22 -0
  39. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  40. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  41. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  42. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
  43. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  48. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
  49. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  50. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  51. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  52. package/.agents/scripts/lib/temp-removal.js +110 -0
  53. package/.agents/scripts/lib/temp-retention.js +122 -73
  54. package/.agents/scripts/lib/transpile.js +28 -3
  55. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  56. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  57. package/.agents/scripts/single-story-close.js +10 -2
  58. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  59. package/.agents/scripts/single-story-init.js +120 -17
  60. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  61. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  62. package/.agents/workflows/audit-architecture.md +5 -4
  63. package/.agents/workflows/audit-documentation.md +5 -5
  64. package/.agents/workflows/audit-performance.md +10 -10
  65. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  66. package/.agents/workflows/clean-temp.md +67 -0
  67. package/.agents/workflows/clean-worktrees.md +63 -0
  68. package/.agents/workflows/git-deliver.md +1 -1
  69. package/.agents/workflows/helpers/acceptance-self-eval.md +11 -10
  70. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  71. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  72. package/.agents/workflows/helpers/deliver-reference.md +3 -1
  73. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  74. package/.agents/workflows/helpers/deliver-story.md +6 -1
  75. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  76. package/.agents/workflows/mandrel-deliver.md +1 -1
  77. package/.agents/workflows/mandrel-plan.md +6 -5
  78. package/docs/CHANGELOG.md +39 -0
  79. package/lib/cli/guarded-sync.js +87 -0
  80. package/lib/cli/sync-agents.js +9 -92
  81. package/lib/cli/sync-commands.js +9 -101
  82. package/package.json +2 -2
@@ -1,11 +1,17 @@
1
1
  /**
2
- * plan-runner/worktree-sweep.js — plan-boot sweep that force-removes
2
+ * plan-runner/worktree-sweep.js — boot sweep that removes
3
3
  * `.worktrees/story-<id>/` entries whose Story is closed or `agent::done`,
4
- * after draining the pending-cleanup manifest.
4
+ * after draining the pending-cleanup manifest. Called by `runBootSweep`
5
+ * (`boot-sweep.js`) under the shared merged-branch sweep lock.
5
6
  *
6
- * `--force` is deliberate: a done Story's branch is already merged, so any
7
- * residue (dirty artifacts, an interrupted rebase, a Windows lock) is noise;
8
- * the `WorktreeManager.reap` safety rails are for the active close path.
7
+ * Removal goes through `removeWorktreeWithRecovery` — the same seam close
8
+ * uses: Windows lock retries, an `fs.rm` fallback that discards residue a
9
+ * done Story's merged branch no longer needs, and the pending-cleanup
10
+ * hand-off when that exhausts. Branches are never deleted here; the
11
+ * merged-branch sweep owns them.
12
+ *
13
+ * Two trees are never touched: an open Story's, and the one the running
14
+ * process was loaded from or is working in (`findRunningCodeInside`).
9
15
  */
10
16
 
11
17
  import path from 'node:path';
@@ -13,8 +19,13 @@ import * as defaultGit from '../../git-utils.js';
13
19
  import { NOOP_LOGGER } from '../../Logger.js';
14
20
  import { AGENT_LABELS } from '../../label-constants.js';
15
21
  import { concurrentMap } from '../../util/concurrent-map.js';
16
- import { parseWorktreePorcelain } from '../../worktree/inspector.js';
22
+ import { canonicalPath } from '../../worktree/canonical-path.js';
23
+ import { parseWorktreePorcelain, samePath } from '../../worktree/inspector.js';
17
24
  import { forceDrainPendingCleanup } from '../../worktree/lifecycle/force-drain.js';
25
+ import {
26
+ findRunningCodeInside,
27
+ removeWorktreeWithRecovery,
28
+ } from '../../worktree/lifecycle/reap.js';
18
29
 
19
30
  const TICKET_READ_CONCURRENCY = 8;
20
31
 
@@ -27,33 +38,32 @@ function isStoryDone(ticket) {
27
38
  return labels.includes(DONE_LABEL);
28
39
  }
29
40
 
30
- function storyIdFromPath(wtPath) {
31
- const parts = wtPath.replace(/\\/g, '/').split('/');
32
- const last = parts[parts.length - 1] ?? '';
33
- return defaultGit.parseStoryBranch(last);
41
+ /**
42
+ * The Story id of a `<worktreeRoot>/story-<id>` entry, else `null` — a
43
+ * `story-<id>` directory anywhere else (e.g. `.claude/worktrees/`) is not
44
+ * this sweep's to judge.
45
+ *
46
+ * @param {string} wtPath
47
+ * @param {string} worktreeRoot
48
+ * @param {string} platform
49
+ * @returns {number|null}
50
+ */
51
+ function storyIdFromPath(wtPath, worktreeRoot, platform) {
52
+ const parent = path.dirname(canonicalPath(wtPath));
53
+ if (!samePath(parent, canonicalPath(worktreeRoot), platform)) return null;
54
+ return defaultGit.parseStoryBranch(path.basename(path.resolve(wtPath)));
34
55
  }
35
56
 
36
57
  /**
37
- * Never touches a worktree whose Story is still open.
58
+ * Validate and normalise the options bag.
38
59
  *
39
60
  * @param {object} opts
40
- * @param {object} opts.provider Only `getTicket(id)` is required.
41
- * @param {string} opts.repoRoot Absolute path to the main checkout.
42
- * @param {object} [opts.git]
43
- * @param {object} [opts.logger]
44
- * @returns {Promise<{
45
- * reaped: Array<{ storyId: number, path: string }>,
46
- * skipped: Array<{ storyId: number|null, path: string, reason: string }>,
47
- * }>}
61
+ * @returns {object}
48
62
  */
49
- export async function sweepStaleStoryWorktrees(opts = {}) {
63
+ function resolveSweepOptions(opts) {
50
64
  const ctx = opts.ctx;
51
65
  const provider = opts.provider ?? ctx?.provider;
52
66
  const repoRoot = opts.repoRoot ?? ctx?.cwd;
53
- const git = opts.git ?? defaultGit;
54
- const logger = opts.logger ?? ctx?.logger ?? NOOP_LOGGER;
55
- const fsRm = opts.fsRm;
56
- const worktreeRoot = opts.worktreeRoot;
57
67
  if (!provider || typeof provider.getTicket !== 'function') {
58
68
  throw new Error(
59
69
  'sweepStaleStoryWorktrees: provider with getTicket(id) is required',
@@ -62,107 +72,149 @@ export async function sweepStaleStoryWorktrees(opts = {}) {
62
72
  if (!repoRoot || typeof repoRoot !== 'string') {
63
73
  throw new Error('sweepStaleStoryWorktrees: repoRoot is required');
64
74
  }
75
+ const platform = opts.platform ?? process.platform;
76
+ return {
77
+ provider,
78
+ repoRoot,
79
+ platform,
80
+ git: opts.git ?? defaultGit,
81
+ logger: opts.logger ?? ctx?.logger ?? NOOP_LOGGER,
82
+ fsRm: opts.fsRm,
83
+ sleepFn: opts.sleepFn,
84
+ worktreeRoot: path.resolve(
85
+ opts.worktreeRoot ?? path.join(repoRoot, '.worktrees'),
86
+ ),
87
+ guardPaths: [process.cwd(), ...(opts.runningPaths ?? [])],
88
+ };
89
+ }
65
90
 
66
- const resolvedWorktreeRoot =
67
- worktreeRoot ?? path.join(repoRoot, '.worktrees');
91
+ /**
92
+ * Read every Story worktree's ticket; each mapper captures its own error so
93
+ * one provider hiccup cannot abort the sweep via concurrentMap's
94
+ * first-rejection-wins policy.
95
+ */
96
+ function readStoryTickets(entries, o) {
97
+ return concurrentMap(
98
+ entries,
99
+ async (entry) => {
100
+ const wtPath = entry.path;
101
+ const storyId = wtPath
102
+ ? storyIdFromPath(wtPath, o.worktreeRoot, o.platform)
103
+ : null;
104
+ if (storyId === null) return { kind: 'non-story' };
105
+ try {
106
+ const ticket = await o.provider.getTicket(storyId);
107
+ return { kind: 'ok', wtPath, storyId, ticket };
108
+ } catch (err) {
109
+ return { kind: 'provider-error', wtPath, storyId, error: err };
110
+ }
111
+ },
112
+ { concurrency: TICKET_READ_CONCURRENCY },
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Why a read entry must be kept, or `null` when it may be removed.
118
+ *
119
+ * @returns {string|null}
120
+ */
121
+ function keepReason(r, o) {
122
+ if (r.kind === 'provider-error') {
123
+ o.logger.warn(
124
+ `worktree-sweep: provider.getTicket(#${r.storyId}) failed: ${r.error.message}`,
125
+ );
126
+ return `provider-error: ${r.error.message}`;
127
+ }
128
+ if (!isStoryDone(r.ticket)) return 'story-open';
129
+ const selfPath = findRunningCodeInside(o, r.wtPath, o.guardPaths);
130
+ if (selfPath) {
131
+ o.logger.warn(
132
+ `worktree-sweep: skipped storyId=${r.storyId} path=${r.wtPath} — the running process uses it (${selfPath})`,
133
+ );
134
+ return 'running-from-target-tree';
135
+ }
136
+ return null;
137
+ }
138
+
139
+ async function removeOne(r, o) {
140
+ const ctx = {
141
+ repoRoot: o.repoRoot,
142
+ git: o.git,
143
+ logger: o.logger,
144
+ platform: o.platform,
145
+ worktreeRoot: o.worktreeRoot,
146
+ listCache: { list: null, ts: 0 },
147
+ fsRm: o.fsRm,
148
+ };
149
+ const res = await removeWorktreeWithRecovery(ctx, r.wtPath, {
150
+ storyId: r.storyId,
151
+ ...(o.sleepFn ? { sleepFn: o.sleepFn, retryDelay: 0 } : {}),
152
+ });
153
+ return res.removed
154
+ ? null
155
+ : `remove-failed: ${res.reason ?? 'worktree-remove-failed'}`;
156
+ }
157
+
158
+ /**
159
+ * Never touches a worktree whose Story is still open, nor the one the
160
+ * running process uses.
161
+ *
162
+ * @param {object} opts
163
+ * @param {object} opts.provider Only `getTicket(id)` is required.
164
+ * @param {string} opts.repoRoot Absolute path to the main checkout.
165
+ * @param {object} [opts.git]
166
+ * @param {object} [opts.logger]
167
+ * @param {string[]} [opts.runningPaths] Extra paths the running process owns.
168
+ * @returns {Promise<{
169
+ * reaped: Array<{ storyId: number, path: string }>,
170
+ * skipped: Array<{ storyId: number|null, path: string, reason: string }>,
171
+ * }>}
172
+ */
173
+ export async function sweepStaleStoryWorktrees(opts = {}) {
174
+ const o = resolveSweepOptions(opts);
68
175
 
69
176
  // Drain the pending-cleanup manifest first so entries whose Windows locks
70
177
  // have released self-heal instead of accumulating.
71
178
  const drainResult = await forceDrainPendingCleanup({
72
- repoRoot,
73
- worktreeRoot: resolvedWorktreeRoot,
74
- git,
75
- fsRm,
76
- logger,
179
+ repoRoot: o.repoRoot,
180
+ worktreeRoot: o.worktreeRoot,
181
+ git: o.git,
182
+ fsRm: o.fsRm,
183
+ logger: o.logger,
77
184
  });
78
185
 
79
186
  const reaped = [];
80
187
  const skipped = [];
81
188
 
82
- const listRes = git.gitSpawn(repoRoot, 'worktree', 'list', '--porcelain');
189
+ const listRes = o.git.gitSpawn(o.repoRoot, 'worktree', 'list', '--porcelain');
83
190
  if (listRes.status !== 0) {
84
- logger.warn(
191
+ o.logger.warn(
85
192
  `worktree-sweep: git worktree list failed: ${listRes.stderr || listRes.stdout || 'unknown'}`,
86
193
  );
87
194
  return { reaped, skipped };
88
195
  }
89
196
 
90
- const entries = parseWorktreePorcelain(listRes.stdout || '');
91
-
92
- // Each mapper captures its own error so one provider hiccup cannot abort
93
- // the sweep via concurrentMap's first-rejection-wins policy.
94
- const reads = await concurrentMap(
95
- entries,
96
- async (entry) => {
97
- const wtPath = entry.path;
98
- if (!wtPath) return { kind: 'no-path' };
99
- const storyId = storyIdFromPath(wtPath);
100
- if (storyId === null) return { kind: 'non-story' };
101
- try {
102
- const ticket = await provider.getTicket(storyId);
103
- return { kind: 'ok', wtPath, storyId, ticket };
104
- } catch (err) {
105
- return { kind: 'provider-error', wtPath, storyId, error: err };
106
- }
107
- },
108
- { concurrency: TICKET_READ_CONCURRENCY },
197
+ const reads = await readStoryTickets(
198
+ parseWorktreePorcelain(listRes.stdout || ''),
199
+ o,
109
200
  );
110
201
 
111
202
  // Removes stay sequential: they mutate .git/worktrees/, and racing git's
112
203
  // locking on Windows can leave a partial admin dir the next remove trips on.
113
204
  for (const r of reads) {
114
- if (r.kind === 'no-path' || r.kind === 'non-story') continue;
115
- if (r.kind === 'provider-error') {
116
- skipped.push({
117
- storyId: r.storyId,
118
- path: r.wtPath,
119
- reason: `provider-error: ${r.error.message}`,
120
- });
121
- logger.warn(
122
- `worktree-sweep: provider.getTicket(#${r.storyId}) failed: ${r.error.message}`,
123
- );
124
- continue;
125
- }
126
-
127
- if (!isStoryDone(r.ticket)) {
128
- skipped.push({
129
- storyId: r.storyId,
130
- path: r.wtPath,
131
- reason: 'story-open',
132
- });
133
- continue;
134
- }
135
-
136
- const res = git.gitSpawn(
137
- repoRoot,
138
- 'worktree',
139
- 'remove',
140
- '--force',
141
- r.wtPath,
142
- );
143
- if (res.status !== 0) {
144
- const reason = (
145
- res.stderr ||
146
- res.stdout ||
147
- 'worktree-remove-failed'
148
- ).trim();
149
- skipped.push({
150
- storyId: r.storyId,
151
- path: r.wtPath,
152
- reason: `remove-failed: ${reason}`,
153
- });
154
- logger.warn(
155
- `worktree-sweep: failed to reap storyId=${r.storyId} path=${r.wtPath}: ${reason}`,
156
- );
205
+ if (r.kind === 'non-story') continue;
206
+ const reason = keepReason(r, o) ?? (await removeOne(r, o));
207
+ if (reason) {
208
+ skipped.push({ storyId: r.storyId, path: r.wtPath, reason });
157
209
  continue;
158
210
  }
159
211
  reaped.push({ storyId: r.storyId, path: r.wtPath });
160
- logger.info(
212
+ o.logger.info(
161
213
  `worktree-sweep: reaped stale worktree storyId=${r.storyId} path=${r.wtPath}`,
162
214
  );
163
215
  }
164
216
 
165
- git.gitSpawn(repoRoot, 'worktree', 'prune');
217
+ o.git.gitSpawn(o.repoRoot, 'worktree', 'prune');
166
218
 
167
219
  return {
168
220
  reaped,
@@ -195,6 +195,16 @@ export function buildProviderChain(entries, ctx) {
195
195
  return { inline, prompts };
196
196
  }
197
197
 
198
+ /**
199
+ * @param {Record<string, number>} tally
200
+ * @param {string} name
201
+ * @param {Finding[]} findings
202
+ */
203
+ function recordCritical(tally, name, findings) {
204
+ const critical = findings.filter((f) => f?.severity === 'critical').length;
205
+ if (critical > 0) tally[name] = critical;
206
+ }
207
+
198
208
  /**
199
209
  * Inline findings merge in declaration order; prompt entries render via
200
210
  * `getPromptMessages`.
@@ -203,11 +213,14 @@ export function buildProviderChain(entries, ctx) {
203
213
  * @param {{ logger?: { info?: Function, warn?: Function } }} [opts]
204
214
  * @returns {ReviewProvider & {
205
215
  * getPromptMessages: (input: ReviewInput) => Promise<string[]>,
216
+ * getCriticalByProvider: () => Record<string, number>,
206
217
  * chain: ProviderChain,
207
218
  * }}
208
219
  */
209
220
  export function createChainProvider(chain, opts = {}) {
210
221
  const logger = opts.logger;
222
+ /** Critical findings per entry from the last `runReview`, for attribution. */
223
+ let criticalByProvider = {};
211
224
 
212
225
  return {
213
226
  chain,
@@ -218,6 +231,7 @@ export function createChainProvider(chain, opts = {}) {
218
231
  async runReview(input) {
219
232
  /** @type {Finding[]} */
220
233
  const merged = [];
234
+ criticalByProvider = {};
221
235
  const ctx = {
222
236
  scope: input?.scope,
223
237
  labels: /** @type {ReadonlyArray<string>} */ (
@@ -238,9 +252,18 @@ export function createChainProvider(chain, opts = {}) {
238
252
  );
239
253
  }
240
254
  for (const f of findings) merged.push(f);
255
+ recordCritical(criticalByProvider, entry.name, findings);
241
256
  }
242
257
  return merged;
243
258
  },
259
+ /**
260
+ * Which entries raised the last run's critical findings, and how many.
261
+ *
262
+ * @returns {Record<string, number>}
263
+ */
264
+ getCriticalByProvider() {
265
+ return { ...criticalByProvider };
266
+ },
244
267
  /**
245
268
  * Called after `runReview`.
246
269
  *
@@ -10,6 +10,7 @@ import { selectAudits } from '../audit-suite/index.js';
10
10
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
11
11
  import { gitSpawn } from '../git-utils.js';
12
12
  import { Logger } from '../Logger.js';
13
+ import { gatherRunTelemetry } from '../observability/close-telemetry.js';
13
14
  import { isEpicTicket } from './epic-container.js';
14
15
  import { composeRoutedProposals } from './retro-proposals.js';
15
16
  import {
@@ -657,6 +658,8 @@ async function executeFollowUpRollup({
657
658
  graduateFn,
658
659
  });
659
660
  const categories = summarizeSignalCategories(signals);
661
+ // Step result only, never the roll-up comment: telemetry stays local.
662
+ const telemetry = await gatherRunTelemetry(stories, config);
660
663
  const proposalCount = proposals.framework.length + proposals.consumer.length;
661
664
  const outcome = assessRollupOutcome({
662
665
  signalCount: signals.length,
@@ -687,6 +690,7 @@ async function executeFollowUpRollup({
687
690
  categories,
688
691
  outcome,
689
692
  frictionWindow,
693
+ telemetry,
690
694
  });
691
695
  }
692
696
 
@@ -743,6 +747,7 @@ function buildRollupStepResult({
743
747
  categories,
744
748
  outcome,
745
749
  frictionWindow,
750
+ telemetry = null,
746
751
  }) {
747
752
  return {
748
753
  kind: 'follow-up-rollup',
@@ -766,6 +771,7 @@ function buildRollupStepResult({
766
771
  fingerprint: item.fingerprint ?? null,
767
772
  })),
768
773
  emptyRollupSuspect: signals.length === 0 && storyCount > 1,
774
+ telemetry,
769
775
  };
770
776
  }
771
777
 
@@ -134,6 +134,7 @@ async function postStoryReviewCrossRef({
134
134
  * halted: boolean,
135
135
  * skipped?: boolean,
136
136
  * severity?: { critical: number, high: number, medium: number, suggestion: number },
137
+ * criticalByProvider?: Record<string, number>,
137
138
  * posted?: boolean,
138
139
  * postedCommentId?: number|null,
139
140
  * degraded?: boolean,
@@ -214,6 +215,7 @@ export async function runStoryScopeReview({
214
215
  return {
215
216
  halted: !!result.halted,
216
217
  severity: sev,
218
+ criticalByProvider: result.criticalByProvider,
217
219
  posted: result.posted,
218
220
  postedCommentId: result.postedCommentId ?? null,
219
221
  ...degradationEnvelope(result.degradations),