@cat-factory/executor-harness 1.58.0 → 1.62.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.
@@ -0,0 +1,155 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The TEXT BOUNDARY for agent-authored text the harness writes onto a VCS host.
3
+ //
4
+ // A pull-request description is NOT an inert string sink. The host parses it: `#123` becomes
5
+ // an issue link, `@name` notifies a real person, a closing keyword in front of an issue
6
+ // reference CLOSES that issue when the PR merges, and an unbalanced code fence swallows
7
+ // everything rendered after it — including the fenced JSON block the engine later appends as
8
+ // the verification report's machine-readable contract.
9
+ //
10
+ // The agent's reviewer briefing (`pr-description.ts`) is model-authored prose that lands
11
+ // verbatim on that surface, so it crosses this boundary first. "This closes #42" is idiomatic
12
+ // for a briefing to emit and must not close issue 42; "@alice owns the rounding rule" is
13
+ // idiomatic and must not page whoever holds that handle.
14
+ //
15
+ // This is a deliberate COPY of `hostMarkdown` in `@cat-factory/kernel`
16
+ // (`src/shared/host-markdown.logic.ts`), for the same reason `isSafeTestPath` is copied: the
17
+ // container image is built from `src/` plus typescript alone (the Dockerfile cannot resolve a
18
+ // `workspace:*` dependency), so the harness carries no runtime dependency on any package here.
19
+ // `test/host-markdown.conformity.test.ts` pins the two implementations to byte-identical
20
+ // output over a shared corpus, so the copy cannot drift — change one, change the other.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * The host's closing keywords. A PR body carrying one of these in front of an issue reference
25
+ * closes that issue on merge — a side effect the harness must never trigger on the agent's
26
+ * behalf. Same list on GitHub and GitLab.
27
+ */
28
+ const CLOSING_KEYWORDS =
29
+ 'close[sd]?|closing|fix|fixe[sd]|fixing|resolve[sd]?|resolving|implement(?:s|ed)?|implementing'
30
+
31
+ /** An issue/MR URL on either host, in the form a closing keyword can reference. */
32
+ const ISSUE_URL = String.raw`https?://\S+?/(?:issues|-/issues|merge_requests|pull)/\d+`
33
+
34
+ /**
35
+ * Every auto-linking trigger, in ONE alternation.
36
+ *
37
+ * Deliberately a single pass rather than chained `.replace()` calls: each escape EMITS a `#`,
38
+ * so a later rule would re-escape the output of an earlier one (`@` → `@` → `@`).
39
+ * One regex means the replacement text is never rescanned.
40
+ */
41
+ const AUTO_LINK_TRIGGERS = new RegExp(
42
+ [
43
+ // A closing keyword in front of an issue/MR URL. The URL form survives the character
44
+ // escapes below (nothing in it is a trigger), so the KEYWORD is what gets defused.
45
+ String.raw`(?<keyword>\b(?:${CLOSING_KEYWORDS}))(?=\s*:?\s+${ISSUE_URL})`,
46
+ // `@name` / `@org/team` — a mention notifies a real account.
47
+ String.raw`(?<at>@(?=[A-Za-z0-9]))`,
48
+ // `#123` and `owner/repo#123` — an issue/PR cross-reference.
49
+ String.raw`(?<hash>#(?=\d))`,
50
+ // `!123` — GitLab's merge-request reference.
51
+ String.raw`(?<bang>!(?=\d))`,
52
+ ].join('|'),
53
+ 'gi',
54
+ )
55
+
56
+ /**
57
+ * Neutralise the host's auto-linking triggers in ONE line of untrusted text, leaving inline
58
+ * code spans alone (the host does not auto-link inside them, so escaping there would only
59
+ * show the reader a literal `&#35;`).
60
+ *
61
+ * The escapes are numeric HTML entities, which render as the original character but are
62
+ * invisible to the reference parser — so the reader sees exactly what the agent wrote while
63
+ * the mention/close side effects are defused.
64
+ */
65
+ function inertLine(line: string): string {
66
+ return mapOutsideCodeSpans(line, (text) =>
67
+ text.replace(AUTO_LINK_TRIGGERS, (match, ...args) => {
68
+ const groups = args[args.length - 1] as Record<string, string | undefined>
69
+ // Entity-escaping the FIRST character is enough to break the parser's match while
70
+ // rendering identically — which matters most for the keyword, whose remaining letters
71
+ // are ordinary prose the reader should still see.
72
+ return `&#${match.charCodeAt(0)};${groups.keyword ? match.slice(1) : ''}`
73
+ }),
74
+ )
75
+ }
76
+
77
+ /**
78
+ * Apply `fn` to the parts of `line` that are NOT inline code spans. Code spans are matched by
79
+ * a backtick run and its matching closer, which is CommonMark's rule and — more to the point
80
+ * — the rule the host renderer applies when deciding where to auto-link.
81
+ */
82
+ function mapOutsideCodeSpans(line: string, fn: (text: string) => string): string {
83
+ const out: string[] = []
84
+ let index = 0
85
+ const span = /(`+)[\s\S]*?\1/g
86
+ let match: RegExpExecArray | null
87
+ while ((match = span.exec(line)) !== null) {
88
+ out.push(fn(line.slice(index, match.index)), match[0])
89
+ index = match.index + match[0].length
90
+ }
91
+ return out.join('') + fn(line.slice(index))
92
+ }
93
+
94
+ /** A line that opens or closes a fenced code block, with the fence it uses. */
95
+ function fenceAt(line: string): { char: string; length: number; info: boolean } | null {
96
+ const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
97
+ if (!match) return null
98
+ const fence = match[1]!
99
+ // A ``` fence's info string may not contain a backtick (CommonMark), which is what stops an
100
+ // inline span from being read as a fence.
101
+ if (fence.startsWith('`') && match[2]!.includes('`')) return null
102
+ return { char: fence[0]!, length: fence.length, info: match[2]!.trim().length > 0 }
103
+ }
104
+
105
+ /**
106
+ * Walk `lines`, tracking fenced-code state, and hand each line to `visit` together with
107
+ * whether it sits INSIDE a fenced block. Returns the fence still open at the end, if any.
108
+ *
109
+ * One shared walker so the three things that care about fences — leaving code untouched,
110
+ * closing what the text left open, and finding the briefing's title heading — can never
111
+ * disagree about where a block starts and ends.
112
+ */
113
+ export function walkFences(
114
+ lines: readonly string[],
115
+ visit: (line: string, insideFence: boolean) => void,
116
+ ): { char: string; length: number } | null {
117
+ let open: { char: string; length: number } | null = null
118
+ for (const line of lines) {
119
+ const fence = fenceAt(line)
120
+ // The fence line itself belongs to the code block, so it is never rewritten.
121
+ visit(line, open !== null || fence !== null)
122
+ if (!fence) continue
123
+ if (!open) open = { char: fence.char, length: fence.length }
124
+ else if (fence.char === open.char && fence.length >= open.length && !fence.info) open = null
125
+ }
126
+ return open
127
+ }
128
+
129
+ /**
130
+ * Render untrusted multi-line markdown safe to send to a host: auto-link triggers defused
131
+ * outside fenced code, and any fence the text leaves open closed again.
132
+ *
133
+ * Unlike kernel's `hostMarkdown.prose` this does NOT cap the length — the caller
134
+ * ({@link import('./pr-description.js')}) applies its own budget with its own visible note
135
+ * BEFORE calling here, so an escape entity can never be sliced in half. With that one
136
+ * difference the output is identical, which the conformity test pins.
137
+ */
138
+ export function inertMarkdown(text: string): string {
139
+ const normalised = text.replace(/\r\n?/g, '\n')
140
+ const rewritten: string[] = []
141
+ const open = walkFences(normalised.split('\n'), (line, insideFence) => {
142
+ rewritten.push(insideFence ? line : inertLine(line))
143
+ })
144
+ const joined = rewritten.join('\n')
145
+ return open ? `${joined}\n${open.char.repeat(open.length)}` : joined
146
+ }
147
+
148
+ /**
149
+ * Render untrusted text INLINE (a pull-request title): newlines folded to spaces because the
150
+ * surrounding line has its own meaning, and auto-link triggers defused. The caller caps the
151
+ * length first, for the same reason as {@link inertMarkdown}.
152
+ */
153
+ export function inertInline(text: string): string {
154
+ return inertLine(text.replace(/\s+/g, ' '))
155
+ }
package/src/job.ts CHANGED
@@ -2,7 +2,16 @@ import type { HarnessCallMetric, PiRunStats } from './pi.js'
2
2
  import type { HarnessKind } from './pi-workspace.js'
3
3
  import type { FailureCause } from './failure.js'
4
4
  import type { EffortReport } from './effort.js'
5
- import type { ValidationChecksSpec, ValidationReport } from './validation-checks.js'
5
+ import {
6
+ parseValidationChecksSpec,
7
+ type ValidationChecksSpec,
8
+ type ValidationReport,
9
+ } from './validation-checks.js'
10
+ import {
11
+ parseReproductionSpec,
12
+ type ReproductionReport,
13
+ type ReproductionSpec,
14
+ } from './reproduction-proof.js'
6
15
 
7
16
  // The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
8
17
  // types with a hand-rolled validator so the image needs no schema dependency.
@@ -175,38 +184,6 @@ function parseValidationSpec(value: unknown): ValidationSpec | undefined {
175
184
  }
176
185
  }
177
186
 
178
- /**
179
- * Parse the optional PRE-PR VALIDATION CHECKS spec (see
180
- * docs/initiatives/pre-pr-validation.md): the service's ordered `{ label, command }` pairs and
181
- * the repair-round budget. Every entry needs a non-empty command; entries without one are
182
- * dropped, and a spec that ends up with no usable check returns `undefined` — so a malformed
183
- * body degrades to the exact pre-feature behaviour (no loop, PR opens as before) rather than
184
- * failing an otherwise-good coding run. `maxAttempts` is clamped to a sane range so a bad body
185
- * can't make a container loop forever.
186
- */
187
- function parseValidationChecksSpec(value: unknown): ValidationChecksSpec | undefined {
188
- if (typeof value !== 'object' || value === null) return undefined
189
- const o = value as Record<string, unknown>
190
- if (!Array.isArray(o.checks)) return undefined
191
- const checks: { label: string; command: string }[] = []
192
- for (const raw of o.checks) {
193
- if (typeof raw !== 'object' || raw === null) continue
194
- const c = raw as Record<string, unknown>
195
- if (typeof c.command !== 'string' || c.command.trim() === '') continue
196
- const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command
197
- checks.push({ label, command: c.command })
198
- }
199
- if (checks.length === 0) return undefined
200
- const parsed = posInt(o.maxAttempts)
201
- return {
202
- checks,
203
- maxAttempts: Math.min(
204
- parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS,
205
- VALIDATION_MAX_ATTEMPTS_CEILING,
206
- ),
207
- }
208
- }
209
-
210
187
  /**
211
188
  * Parse the shared per-job auth fields, validating per harness: a subscription
212
189
  * harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
@@ -872,21 +849,18 @@ export interface AgentJob extends HarnessAuthFields {
872
849
  * job DATA, not the agent kind. See {@link ValidationChecksSpec}.
873
850
  */
874
851
  validationChecks?: ValidationChecksSpec
852
+ /**
853
+ * Coding mode: the run's BUGFIX REPRODUCTION PROOF — the declared reproduction command, the
854
+ * test file(s) that constitute it, and an optional setup command. When set, the harness runs
855
+ * that command against the pre-fix tree AND the tree the PR will open from, and reports both
856
+ * exit codes: only red-then-green is proof. Present only on a dispatch that opens a PR and
857
+ * whose run carries a reproduction declaration; absent ⇒ the run behaves exactly as before.
858
+ * Deliberately keyed off job DATA, not the agent kind. See
859
+ * `docs/initiatives/bugfix-reproduction-proof.md`.
860
+ */
861
+ reproduction?: ReproductionSpec
875
862
  }
876
863
 
877
- /**
878
- * The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
879
- * default it applies when the body omits one.
880
- *
881
- * DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
882
- * in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
883
- * cannot import them. Keep the two in step: the API validates writes against the contracts
884
- * values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
885
- * was allowed to save, with nothing to flag the mismatch.
886
- */
887
- export const VALIDATION_MAX_ATTEMPTS_CEILING = 10
888
- export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3
889
-
890
864
  /** Per-job, per-knob progress-guard overrides (see {@link AgentJob.guardLimits}). */
891
865
  export interface GuardLimitsSpec {
892
866
  maxToolCallsWithoutEdit?: number
@@ -938,6 +912,14 @@ export interface AgentResult {
938
912
  * Absent when the job carried no {@link AgentJob.validationChecks}.
939
913
  */
940
914
  validationReport?: ValidationReport
915
+ /**
916
+ * The BUGFIX REPRODUCTION PROOF: the declared reproduction command's verdict across the pre-fix
917
+ * tree and the final tree, computed by the harness from exit codes. Present on every outcome of
918
+ * a job that carried {@link AgentJob.reproduction} — a verdict is evidence, not a gate, so an
919
+ * `inconclusive` one accompanies the opened PR exactly like a `reproduced` one does. Absent
920
+ * when the job carried no reproduction declaration.
921
+ */
922
+ reproductionReport?: ReproductionReport
941
923
  /**
942
924
  * Preview mode: the in-container URL the built app is served at (e.g. `http://localhost:4173`).
943
925
  * This is NOT host-reachable on its own — the container runtime publishes the serve port to an
@@ -1345,6 +1327,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1345
1327
  guardLimits: parseGuardLimits(o.guardLimits),
1346
1328
  validation: parseValidationSpec(o.validation),
1347
1329
  validationChecks: parseValidationChecksSpec(o.validationChecks),
1330
+ reproduction: parseReproductionSpec(o.reproduction),
1348
1331
  reviewPrNumber: posInt(o.reviewPrNumber),
1349
1332
  })
1350
1333
  assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
@@ -1383,6 +1366,7 @@ interface ParsedAgentJobParts {
1383
1366
  guardLimits: ReturnType<typeof parseGuardLimits>
1384
1367
  validation: ReturnType<typeof parseValidationSpec>
1385
1368
  validationChecks: ReturnType<typeof parseValidationChecksSpec>
1369
+ reproduction: ReturnType<typeof parseReproductionSpec>
1386
1370
  reviewPrNumber: number | undefined
1387
1371
  }
1388
1372
 
@@ -1436,6 +1420,7 @@ function assembleAgentJob(
1436
1420
  guardLimits,
1437
1421
  validation,
1438
1422
  validationChecks,
1423
+ reproduction,
1439
1424
  reviewPrNumber,
1440
1425
  } = parts
1441
1426
  const repo = (o.repo ?? {}) as Record<string, unknown>
@@ -1465,6 +1450,7 @@ function assembleAgentJob(
1465
1450
  ...(guardLimits ? { guardLimits } : {}),
1466
1451
  ...(validation ? { validation } : {}),
1467
1452
  ...(validationChecks ? { validationChecks } : {}),
1453
+ ...(reproduction ? { reproduction } : {}),
1468
1454
  }
1469
1455
  }
1470
1456
 
@@ -0,0 +1,171 @@
1
+ import { readFile, rm } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { inertInline, inertMarkdown, walkFences } from './host-markdown.js'
4
+ import { redactSecrets } from './redact.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // The agent-authored pull-request description side channel. A coding agent whose
8
+ // dispatch opens a PR is asked (via the backend-composed system prompt) to end its
9
+ // run by writing a reviewer briefing — the problem, the decisions made, what to
10
+ // look out for — to a sentinel file at the root of the checkout the PR belongs to.
11
+ // The harness reads it after the agent settles, removes it (so it never lands in a
12
+ // commit), and uses it as the PR body in place of the generic dispatch-time text
13
+ // the job body carries. Absent or unusable ⇒ the dispatch-time fallback, unchanged.
14
+ //
15
+ // The briefing is MODEL-AUTHORED text landing verbatim on a host-parsed surface, so
16
+ // it crosses `host-markdown.ts` (auto-link triggers defused, open code fences closed)
17
+ // on the way out — see that module for why a PR body is not an inert string sink.
18
+ //
19
+ // The filename is kept in sync with `PR_DESCRIPTION_FILE` in `@cat-factory/agents`
20
+ // (the executor-harness has no dependency on that package), exactly like the
21
+ // effort-report and follow-ups sentinels.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** The sentinel file the agent writes its PR description to (relative to the checkout root). */
25
+ export const PR_DESCRIPTION_FILE = '.cat-pr-description.md'
26
+
27
+ /**
28
+ * Ceiling on the agent-authored body.
29
+ *
30
+ * The engine appends its verification report to the SAME body later, and that section carries
31
+ * its own 50,000-character ceiling (`MAX_SECTION_CHARS` in kernel's `hostMarkdown`). GitHub
32
+ * rejects a body over 65,536 with a 422, and the report publisher swallows its own failures —
33
+ * so a briefing budget that does not leave the report room would surface as a report that
34
+ * silently never publishes. 15,000 + 50,000 stays under the limit with room to join them.
35
+ */
36
+ const MAX_PR_BODY_CHARS = 15_000
37
+
38
+ /** Ceiling on an agent-supplied title (GitHub truncates around 256; a title should be short). */
39
+ const MAX_PR_TITLE_CHARS = 160
40
+
41
+ /** Opens the engine-managed region of a PR body (kept in sync with `kernel/domain/pr-report.ts`). */
42
+ export const PR_REPORT_MARKER_START = '<!-- cat-factory:verification-report:start -->'
43
+ /** Closes the engine-managed region of a PR body. */
44
+ export const PR_REPORT_MARKER_END = '<!-- cat-factory:verification-report:end -->'
45
+
46
+ /**
47
+ * A marker inside the agent-authored briefing would make the engine's splice treat part of the
48
+ * briefing as its own managed region and rewrite it, so any occurrence is stripped up front.
49
+ * Deliberately laxer than the exact constants above (whitespace-tolerant), so a near-miss the
50
+ * splice itself would not match cannot survive here either.
51
+ */
52
+ const MANAGED_SECTION_MARKER = /<!--\s*cat-factory:verification-report:(?:start|end)\s*-->/g
53
+
54
+ /** An agent-authored PR description: an optional title plus the briefing body. */
55
+ export interface AgentPrDescription {
56
+ title?: string
57
+ body?: string
58
+ }
59
+
60
+ /**
61
+ * Read + parse + REMOVE the agent's PR-description sentinel from `dir`. Lenient: returns
62
+ * undefined when the file is absent (the agent wrote none) or carries nothing usable. Never
63
+ * throws — a bad description must never fail an otherwise-good run; the caller falls back to
64
+ * the dispatch-time text.
65
+ *
66
+ * A SINGLE `# <title>` heading on the first line sets the PR title; everything after it is the
67
+ * body (see {@link splitTitle} for why a LONE heading is required). The whole text is
68
+ * secret-scrubbed, an over-budget body is truncated WITH a visible note (a silent cut would
69
+ * read as the complete briefing), and both halves are made inert for the host.
70
+ *
71
+ * On scrubbing: `redactSecrets`'s credential-assignment rule is deliberately eager, so a
72
+ * briefing sentence like "the token: handling changed" loses its next word. That is the right
73
+ * trade for a surface this public — the rule is shared with every other redaction path, and
74
+ * narrowing it so prose reads better would weaken all of them.
75
+ */
76
+ export async function readPrDescription(dir: string): Promise<AgentPrDescription | undefined> {
77
+ const path = join(dir, PR_DESCRIPTION_FILE)
78
+ let raw: string
79
+ try {
80
+ raw = await readFile(path, 'utf8')
81
+ } catch {
82
+ return undefined // no description written — the fallback body applies
83
+ }
84
+ // Remove it so it never lands in a commit (defence in depth; the checkout also excludes it).
85
+ await rm(path, { force: true }).catch(() => {})
86
+ const text = redactSecrets(raw).replace(MANAGED_SECTION_MARKER, '').trim()
87
+ if (!text) return undefined
88
+
89
+ const split = splitTitle(text)
90
+ // Cap BEFORE the escapes on both halves, so a numeric entity can never be sliced in half.
91
+ const title = split.title ? inertInline(capTitle(split.title)) : undefined
92
+ const body = split.body ? inertMarkdown(capBody(split.body)) : undefined
93
+ if (!title && !body) return undefined
94
+ return { ...(title ? { title } : {}), ...(body ? { body } : {}) }
95
+ }
96
+
97
+ /**
98
+ * Split a leading `# <title>` heading off the briefing.
99
+ *
100
+ * The heading becomes the title ONLY when it is the single level-1 heading in the whole file,
101
+ * which is exactly what the prompt asks for ("a single `# <title>` heading line"). An agent
102
+ * that instead uses `#` for its section headings — `# Problem`, `# Decisions`, entirely
103
+ * idiomatic for the briefing the prompt describes — would otherwise have its first section
104
+ * silently become the pull request's title, replacing `<block> (<pipeline>)` with the word
105
+ * "Problem". Headings inside fenced code are not headings and are skipped, or a briefing
106
+ * quoting a shell snippet (`# rebuild the image`) would lose its title to the snippet.
107
+ */
108
+ function splitTitle(text: string): { title?: string; body: string } {
109
+ const lines = text.split('\n')
110
+ const headings: number[] = []
111
+ let index = 0
112
+ walkFences(lines, (line, insideFence) => {
113
+ if (!insideFence && /^#\s+\S/.test(line)) headings.push(index)
114
+ index += 1
115
+ })
116
+ if (headings.length !== 1 || headings[0] !== 0) return { body: text }
117
+ const title = lines[0]!.replace(/^#\s+/, '').trim()
118
+ if (!title) return { body: text }
119
+ return { title, body: lines.slice(1).join('\n').trim() }
120
+ }
121
+
122
+ /** Cut an over-long title at a word boundary when one is near, marking the cut. */
123
+ function capTitle(value: string): string {
124
+ const collapsed = value.trim()
125
+ if (collapsed.length <= MAX_PR_TITLE_CHARS) return collapsed
126
+ const head = collapsed.slice(0, MAX_PR_TITLE_CHARS - 1)
127
+ const space = head.lastIndexOf(' ')
128
+ const kept = space > MAX_PR_TITLE_CHARS * 0.6 ? head.slice(0, space) : head
129
+ return `${kept.trimEnd()}…`
130
+ }
131
+
132
+ /** Cut an over-budget body, marking the cut so it is never read as the whole briefing. */
133
+ function capBody(value: string): string {
134
+ if (value.length <= MAX_PR_BODY_CHARS) return value
135
+ return (
136
+ value.slice(0, MAX_PR_BODY_CHARS).trimEnd() +
137
+ '\n\n_Truncated by the platform: the description exceeded the size budget._'
138
+ )
139
+ }
140
+
141
+ /**
142
+ * Fold an agent-authored description over the dispatch-time fallback the job body carries.
143
+ * Field-wise: the agent's title/body each win when present, so a body-only briefing keeps the
144
+ * backend-composed title and vice versa.
145
+ */
146
+ export function applyPrDescription(
147
+ fallback: { title: string; body: string },
148
+ agent: AgentPrDescription | undefined,
149
+ ): { title: string; body: string } {
150
+ if (!agent) return fallback
151
+ return { title: agent.title ?? fallback.title, body: agent.body ?? fallback.body }
152
+ }
153
+
154
+ /**
155
+ * The body to PATCH onto an ALREADY-OPEN pull request when a resumed run produced a fresh
156
+ * briefing: the new description followed by whatever the engine's managed verification-report
157
+ * region currently holds.
158
+ *
159
+ * Carrying the region across is what makes the refresh safe. The engine re-publishes the report
160
+ * on every step settlement, so dropping it here would usually self-heal — but "usually" is not
161
+ * a property to rest the one artefact a reviewer reads on, and a run that settles no further
162
+ * step (the work is already merged, the run failed after its push) would never restore it.
163
+ */
164
+ export function preserveManagedSection(currentBody: string | undefined, nextBody: string): string {
165
+ const existing = currentBody ?? ''
166
+ const start = existing.indexOf(PR_REPORT_MARKER_START)
167
+ const end = existing.indexOf(PR_REPORT_MARKER_END)
168
+ if (start === -1 || end <= start) return nextBody
169
+ const region = existing.slice(start, end + PR_REPORT_MARKER_END.length)
170
+ return `${nextBody.trim()}\n\n${region}\n`
171
+ }