autoforge-cli 0.1.0a0__py3-none-any.whl

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 (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2234 @@
1
+ """CONTROL_RESULT protocol: extraction + strict per-phase validation.
2
+
3
+ Agent stdout may contain arbitrary logs, but must end with exactly one
4
+ machine-readable block::
5
+
6
+ <<<CONTROL_RESULT>>>
7
+ { ... JSON object ... }
8
+ <<<END_CONTROL_RESULT>>>
9
+
10
+ Rules: find ALL complete blocks, use only the LAST one; JSON must be valid;
11
+ payload must be an object containing at least ``phase`` and ``status``; the
12
+ payload phase must equal the controller's current phase; per-phase required
13
+ fields are enforced with typed dataclass models.
14
+
15
+ The controller never "guesses what the agent meant": a REVIEW result whose
16
+ ``needs_fix_round`` disagrees with its ``findings`` list, or a FIX
17
+ resolution without the evidence its kind requires, is rejected outright. So
18
+ is a REVIEW result larger than the controller is willing to persist and
19
+ render (``MAX_FINDINGS_PER_REVIEW`` and the per-field character bounds,
20
+ finding ids included): it is refused whole, never clipped, so the reviewer
21
+ can re-emit it. The FIX payload is bounded the same way
22
+ (``MAX_RESOLUTIONS_PER_FIX``, ``MAX_FIX_RATIONALE_CHARS``, the shape of
23
+ ``commit_sha`` and the length of any URL): every resolution is persisted
24
+ whole. A finding's one-line fields (``title``, ``location``) may carry no
25
+ control character at all, and its ``required_resolution`` and a FIX
26
+ ``rationale`` only a newline or a tab (#78): the prompt renderer would
27
+ escape or indent anything else, and a value the controller would have to
28
+ rewrite before it can show it is refused, not repaired. The same holds for
29
+ the block as a whole
30
+ (``MAX_CONTROL_RESULT_CHARS``): the accepted payload is persisted whole and
31
+ is what the next phase acts on, so its size is checked before it is decoded.
32
+
33
+ Agent text the controller publishes is held to one content policy (ADR 0004
34
+ D8.2, D8.3, D8.5): no controller marker opener of either prefix, no
35
+ credential-shaped string (refused by the redactor's pattern class, never
36
+ redacted), no closing keyword followed by an issue reference, and no
37
+ ``@``-mention outside code. :func:`published_text_problem` judges one field,
38
+ :func:`published_payload_problem` a whole rendered payload,
39
+ :func:`published_markdown_problem` the Markdown several fields compose and
40
+ :func:`commit_message_problem` a commit message; each names the subject and
41
+ the rule, never the text. An ANALYZE_EXECUTE result carries the PR title
42
+ and body the controller publishes (``pr_title``, ``pr_body``), under that
43
+ policy and bounded (``MAX_PR_TITLE_CHARS``, ``MAX_PR_BODY_CHARS``), plus
44
+ the issue URL and the candidate SHA as cross-checks; it names no PR, branch
45
+ or push target (#161). A REPLAN_REEXECUTE result carries the replacement
46
+ PR's title and body under the same policy and bounds, the candidate SHA and
47
+ the transaction checkpoint's cross-checks, and names no replacement PR,
48
+ branch or push target either (#164). A REMOTE REVIEW result carries the prose sections of
49
+ the review comment the controller renders and posts (``spec``,
50
+ ``standards``, ``assessment``, ``observations``, ``verification``,
51
+ ``summary``), each under that policy and bounded
52
+ (``MAX_REVIEW_SECTION_CHARS``, ``MAX_REVIEW_SUMMARY_CHARS``), and so is
53
+ every finding field the comment shows; ``round`` and ``reviewed_head_sha``
54
+ are cross-checks, and it names no comment (#162). A REMOTE FIX result
55
+ carries one resolution per open finding; a deferral either names an issue
56
+ the controller handed over or carries the title and body of a new follow-up
57
+ issue the controller creates, under that policy and bounded
58
+ (``MAX_FOLLOW_UP_TITLE_CHARS``, ``MAX_FOLLOW_UP_BODY_CHARS``), and
59
+ ``previous_head_sha`` and ``head_sha`` are cross-checks; it names no push
60
+ target and creates nothing (#163). One of its resolutions may instead ask
61
+ the controller to publish an acceptance comment on the run's own issue: a
62
+ closed ``kind`` and a bounded body under the same policy
63
+ (``MAX_ACCEPTANCE_BODY_CHARS``, ``MAX_ACCEPTANCE_ACTIONS_PER_FIX``), never
64
+ a target, URL, method, path or command (#211). An UPDATE_EPIC result is validated
65
+ against the schema of the request that launched the agent
66
+ (:class:`UpdateEpicRequest`, D4.7): a re-request after publication carries
67
+ only what it asks for.
68
+ """
69
+
70
+ from __future__ import annotations
71
+
72
+ import html
73
+ import json
74
+ import re
75
+ from collections import deque
76
+ from collections.abc import Callable, Iterable, Iterator
77
+ from dataclasses import dataclass, field
78
+ from enum import StrEnum
79
+ from urllib.parse import unquote
80
+
81
+ from .errors import ConfigurationError, ControlResultError, ControlResultValidationError
82
+ from .prompts import CONTROL_CHAR_RE, CONTROL_CHARS
83
+ from .redaction import credential_classes
84
+ from .roadmap import ROADMAP_END_MARKER, ROADMAP_START_MARKER
85
+ from .transitions import Phase, WorkflowMode
86
+ from .validation import parse_comment_url, parse_issue_url, parse_pr_url
87
+
88
+ BEGIN = "<<<CONTROL_RESULT>>>"
89
+ END = "<<<END_CONTROL_RESULT>>>"
90
+
91
+ _BLOCK_RE = re.compile(r"<<<CONTROL_RESULT>>>(.*?)<<<END_CONTROL_RESULT>>>", re.DOTALL)
92
+ # A SHA field is the full 40-character object id, as the prompts require and
93
+ # as GitHub reports it: every SHA the controller accepts is compared by
94
+ # equality with one read from GitHub, so an abbreviated SHA could only pass
95
+ # the schema and then fail that comparison with a misleading "mismatch"
96
+ # instead of the schema error it is (#19). The same shape ``autoforge.claims``
97
+ # reads back from a marker.
98
+ _SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
99
+ # The one finding-id rule: the parser applies it to CONTROL_RESULT ids and
100
+ # ``autoforge.claims`` to the ids read back from follow-up markers, so a
101
+ # marker can never carry an id the parser would have refused. ``\Z``, not
102
+ # ``$``: ``$`` also matches before a trailing newline, and ``"R1-F1\n"`` is
103
+ # not a finding id (it would look like one and compare unequal to it).
104
+ FINDING_ID_RE = re.compile(r"^R(?P<round>[1-9][0-9]*)-F(?P<n>[1-9][0-9]*)\Z")
105
+ _FINDING_ID_RE = FINDING_ID_RE
106
+ _FINGERPRINT_RE = re.compile(r"^[0-9a-f]{64}$")
107
+
108
+ ALLOWED_STATUSES = ("success", "failure", "blocked")
109
+ FINDING_CLASSIFICATIONS = ("blocked", "non-blocked", "nit")
110
+ FIX_RESOLUTIONS = (
111
+ "fixed",
112
+ "follow_up_created",
113
+ "no_change_with_rationale",
114
+ "acceptance_action",
115
+ "operator_acceptance",
116
+ )
117
+ # LOCAL mode has no GitHub, so a finding cannot be deferred to a follow-up
118
+ # Issue. "unresolved" is the honest local disposition: the fixer could not
119
+ # resolve it and says why. The controller treats it as a remaining finding.
120
+ LOCAL_FIX_RESOLUTIONS = ("fixed", "no_change_with_rationale", "unresolved")
121
+ MIN_RATIONALE_CHARS = 40
122
+
123
+ # Bounds on the REVIEW payload the controller accepts. Review output is
124
+ # untrusted project data, and every accepted finding is persisted in full in
125
+ # ``state.open_findings`` (a full atomic rewrite of ``state.json``) and
126
+ # rendered verbatim into the next FIX prompt. Without a controller-owned
127
+ # limit a single pathological round decides the size of both. An oversized
128
+ # result is *rejected*, never clipped: the findings are the work the FIX
129
+ # round has to act on, so clipping them would silently drop work, whereas a
130
+ # rejection is correctable (the reviewer re-emits a bounded result through the
131
+ # ordinary correction retry). Each bound is at or below the corresponding
132
+ # persisted-evidence bound in ``loop_guard`` so an accepted round is always
133
+ # retained complete (``tests/test_result_parser.py`` pins that relation).
134
+ MAX_FINDINGS_PER_REVIEW = 50
135
+ MAX_FINDING_RESOLUTION_CHARS = 2000
136
+ MAX_FINDING_TITLE_CHARS = 200
137
+ MAX_FINDING_LOCATION_CHARS = 300
138
+ # A finding id is ``R<round>-F<n>``; its shape does not bound its length (the
139
+ # digit runs are open-ended), and the id is persisted and rendered like every
140
+ # other finding field, so it is bounded explicitly. The bound is checked
141
+ # before the shape, so an oversized id is never echoed into a message. A FIX
142
+ # ``finding_id`` must equal an accepted finding's id, so the same bound
143
+ # applies to it at parse time rather than after the coverage check.
144
+ MAX_FINDING_ID_CHARS = 32
145
+ # Bounds on the FIX payload (#77). Every accepted resolution is persisted
146
+ # whole in ``state.last_fix_resolutions`` (after redaction, which can lengthen
147
+ # it), and in LOCAL mode an ``unresolved`` rationale is echoed into the
148
+ # persisted ``block_reason`` that ``status`` shows. A FIX can never
149
+ # legitimately report more resolutions than the controller accepted findings,
150
+ # so the count bound *is* the REVIEW's, derived rather than restated, and it
151
+ # is checked before any element is parsed. The rationale bound times
152
+ # ``redaction.MAX_GROWTH_FACTOR`` is at or below
153
+ # ``loop_guard.MAX_REQUIRED_RESOLUTION_CHARS`` (``tests/test_result_parser.py``
154
+ # pins that relation), so a persisted rationale is never larger than a
155
+ # persisted resolution. Rejected, never clipped, like a REVIEW field.
156
+ MAX_RESOLUTIONS_PER_FIX = MAX_FINDINGS_PER_REVIEW
157
+ MAX_FIX_RATIONALE_CHARS = 2000
158
+ # Bound on any URL field before the ``validation`` parser sees it: that
159
+ # parser quotes the value in its error, and the error is echoed into the
160
+ # correction prompt and the run log. A real GitHub issue, PR or comment URL
161
+ # is far shorter.
162
+ MAX_URL_CHARS = 512
163
+ # Bound on the whole CONTROL_RESULT block (the raw JSON text between the
164
+ # markers, in characters). The accepted payload is written whole to
165
+ # ``control-result.json`` and as one ``events.jsonl`` line, and its fields
166
+ # drive the next phase, so it is bounded where it is accepted: checked by
167
+ # size alone before ``json.loads``, rejected rather than clipped. The bound
168
+ # sits above the largest REVIEW the field bounds admit even in the worst
169
+ # JSON encoding (every character escaped as ``\uXXXX``, six per character:
170
+ # ``tests/test_result_parser.py`` pins that relation), so the field bounds,
171
+ # not this one, are what a reviewer is held to. Any stdout the parser sees is
172
+ # itself bounded by the executor's capture bound.
173
+ MAX_CONTROL_RESULT_CHARS = 1024 * 1024
174
+ # Bound on the EPIC's managed roadmap section an UPDATE_EPIC result may
175
+ # carry. The controller writes it into the EPIC body itself (between the
176
+ # roadmap markers, nothing else), so the section is the one piece of agent
177
+ # text that reaches a GitHub issue body verbatim; it is bounded where it is
178
+ # accepted and rejected, never clipped, so a truncated roadmap is never
179
+ # published. GitHub bounds an issue body at 65536 characters; the section
180
+ # leaves the operator's own text room.
181
+ MAX_ROADMAP_SECTION_CHARS = 32768
182
+ # Bound on an UPDATE_EPIC result's ``progress`` text. The controller posts it
183
+ # as the EPIC progress comment and appends its own marker, and the whole
184
+ # comment must stay far under GitHub's 65,536-character comment limit.
185
+ # Rejected, never clipped, like every other published field.
186
+ MAX_PROGRESS_CHARS = 16384
187
+ # Bounds on the PR text an ANALYZE_EXECUTE result carries (#161). The
188
+ # controller creates the PR with this title and with the body followed by
189
+ # its own ``Closes #n`` and implementation marker, so the body leaves room
190
+ # for those under GitHub's 65,536-character body limit and the effect
191
+ # payload's bound; the title is the effect's one-line title bound. Rejected,
192
+ # never clipped, like every other published field.
193
+ MAX_PR_TITLE_CHARS = 256
194
+ MAX_PR_BODY_CHARS = 60000
195
+ # Bounds on the prose sections of a REVIEW result (#162). The controller
196
+ # renders the round's review comment from the result: its heading, binding
197
+ # line, findings, needs-fix line and marker are its own, and each prose
198
+ # section is the reviewer's text under its fixed heading. Rejected, never
199
+ # clipped, like every other published field; the rendered comment as a
200
+ # whole is checked against GitHub's comment limit too (ADR 0004 D8.6),
201
+ # because the findings alone may already exceed it.
202
+ MAX_REVIEW_SECTION_CHARS = 6000
203
+ MAX_REVIEW_SUMMARY_CHARS = 2000
204
+ # The prose sections, in the order the comment renders them, each mapped to
205
+ # its bound.
206
+ REVIEW_PROSE_SECTIONS: tuple[tuple[str, int], ...] = (
207
+ ("spec", MAX_REVIEW_SECTION_CHARS),
208
+ ("standards", MAX_REVIEW_SECTION_CHARS),
209
+ ("assessment", MAX_REVIEW_SECTION_CHARS),
210
+ ("observations", MAX_REVIEW_SECTION_CHARS),
211
+ ("verification", MAX_REVIEW_SECTION_CHARS),
212
+ ("summary", MAX_REVIEW_SUMMARY_CHARS),
213
+ )
214
+ # Bounds on a new follow-up issue a FIX result asks for (#163). The
215
+ # controller creates the issue with this title and with the body followed by
216
+ # its own reference to the PR and finding and the finding's follow-up marker,
217
+ # so the body leaves room for those under GitHub's 65,536-character body
218
+ # limit and the effect payload's bound; the title is the effect's one-line
219
+ # title bound. Rejected, never clipped, like every other published field.
220
+ MAX_FOLLOW_UP_TITLE_CHARS = 256
221
+ MAX_FOLLOW_UP_BODY_CHARS = 60000
222
+ # An ``acceptance_action`` resolution (#211): the one kind of external action
223
+ # a fixer may request, a comment on the run's own issue whose text is the
224
+ # evidence a finding asks for. The kind is a closed set and the request
225
+ # names no target: the controller derives the issue from its state (ADR 0004
226
+ # D8.1), so any key that looks like one is refused rather than ignored. The
227
+ # body is the agent's part of the comment the controller renders (its
228
+ # header, the body, its marker), bounded like a follow-up issue body.
229
+ # Rejected, never clipped, like every other published field.
230
+ ACCEPTANCE_ACTION_KINDS = ("issue_comment",)
231
+ ACCEPTANCE_ACTION_KEYS = ("kind", "body")
232
+ ACCEPTANCE_TARGET_KEYS = ("url", "issue", "repository", "target", "method", "path", "command")
233
+ MAX_ACCEPTANCE_BODY_CHARS = 60000
234
+ MAX_ACCEPTANCE_ACTIONS_PER_FIX = 1
235
+ # An ``operator_acceptance`` resolution (#243): 1 to
236
+ # ``MAX_OPERATOR_ACTIONS_PER_FIX`` comments on *other* issues of the run's
237
+ # repository that the operator posts and the controller only verifies (K10).
238
+ # Each action is a closed schema: a kind, a target issue *number* the
239
+ # controller checks against the allowed set it read at FIX entry, and the
240
+ # text. Nothing names a URL, repository or command: those keys are refused
241
+ # by name. The operator often pipes the text unedited under their own
242
+ # identity, so beyond the published-text rules it carries no image, no raw
243
+ # HTML and no link other than one into the run's repository
244
+ # (:func:`operator_comment_link_problem`). Rejected, never clipped.
245
+ OPERATOR_ACTION_KINDS = ("related_issue_comment",)
246
+ OPERATOR_ACTION_KEYS = ("kind", "issue", "body")
247
+ OPERATOR_ACCEPTANCE_KEYS = ("actions",)
248
+ OPERATOR_TARGET_KEYS = (
249
+ "url",
250
+ "repository",
251
+ "owner",
252
+ "repo",
253
+ "target",
254
+ "method",
255
+ "path",
256
+ "command",
257
+ )
258
+ MAX_OPERATOR_COMMENT_CHARS = 16000
259
+ MAX_OPERATOR_ACTIONS_PER_FIX = 4
260
+ # The largest issue number ``engine._ISSUE_REFERENCE_RE`` reads (nine digits).
261
+ MAX_OPERATOR_ISSUE_NUMBER = 999_999_999
262
+ # Bounds on the ``tests`` list: names of what the agent ran, recorded with
263
+ # the result and never published.
264
+ MAX_TESTS_REPORTED = 50
265
+ MAX_TEST_CHARS = 500
266
+
267
+
268
+ class UpdateEpicRequest(StrEnum):
269
+ """Which UPDATE_EPIC result the controller asked for (ADR 0004 D4.7).
270
+
271
+ ``FULL`` is the phase's launch: ``progress`` (required),
272
+ ``roadmap_section`` (optional) and ``next_issue_url`` (a required key,
273
+ ``null`` when the EPIC is complete). The others are re-requests after the
274
+ phase's publication is complete: each asks only for the input a later
275
+ controller step rejected, and a field outside its schema is refused.
276
+
277
+ - ``SELECTION``: ``next_issue_url`` only.
278
+ - ``SELECTION_WITH_ROADMAP``: ``next_issue_url`` and an optional
279
+ ``roadmap_section``.
280
+ - ``ROADMAP``: ``roadmap_section`` only, required and non-blank.
281
+ """
282
+
283
+ FULL = "full"
284
+ SELECTION = "selection"
285
+ SELECTION_WITH_ROADMAP = "selection_with_roadmap"
286
+ ROADMAP = "roadmap"
287
+
288
+
289
+ # The opening every controller marker shares: ``<!--``, any whitespace (none
290
+ # included), ``ai-``. ``autoforge.claims`` builds each marker kind's scanner
291
+ # pattern on this expression, and the roadmap-section refusal below matches
292
+ # it, so what the section may not carry is exactly what the scanner would
293
+ # later read. The whitespace run is possessive as in the scanner (the
294
+ # expression runs over agent-supplied text; see ``MarkerKind.pattern``).
295
+ CONTROLLER_MARKER_OPEN_RE = re.compile(r"<!--\s*+ai-")
296
+ # A roadmap section may not carry a controller marker: the roadmap markers
297
+ # would split the body into more than one managed section, and any other
298
+ # ``<!-- ai-`` marker would plant a durable claim in an open issue that the
299
+ # controller later scans and trusts (``autoforge.claims``). The refusal uses
300
+ # the scanner's own opening, so ``<!--ai-`` and ``<!--\nai-`` are refused too.
301
+ _CONTROLLER_MARKER_PREFIX = "<!-- ai-"
302
+ # The opening of every marker the controller writes or later trusts: its own
303
+ # ``<!-- ai-`` claims (:data:`CONTROLLER_MARKER_OPEN_RE`) and the replan
304
+ # transaction's ``<!-- autoforge-`` markers (``autoforge.replan_txn``).
305
+ # Agent text may carry neither (ADR 0004 D8.2). Case-insensitive, unlike the
306
+ # scanner's own opening: a refusal wider than the scanner fails closed.
307
+ # Possessive like it, for the same reason.
308
+ AGENT_MARKER_OPEN_RE = re.compile(r"<!--\s*+(?:ai|autoforge)-", re.IGNORECASE)
309
+
310
+
311
+ def extract_last_block(stdout: str) -> str:
312
+ """Return the raw JSON text of the last complete CONTROL_RESULT block."""
313
+ matches = _BLOCK_RE.findall(stdout or "")
314
+ if not matches:
315
+ if BEGIN in (stdout or ""):
316
+ raise ControlResultError(
317
+ "found '<<<CONTROL_RESULT>>>' but no closing "
318
+ "'<<<END_CONTROL_RESULT>>>' — incomplete block"
319
+ )
320
+ raise ControlResultError(
321
+ "no CONTROL_RESULT block found in agent stdout "
322
+ "(expected '<<<CONTROL_RESULT>>>{...}<<<END_CONTROL_RESULT>>>')"
323
+ )
324
+ return matches[-1].strip()
325
+
326
+
327
+ def parse_control_result(
328
+ stdout: str,
329
+ expected_phase: Phase,
330
+ mode: WorkflowMode = WorkflowMode.REMOTE,
331
+ *,
332
+ update_epic_request: UpdateEpicRequest = UpdateEpicRequest.FULL,
333
+ ) -> dict:
334
+ """Extract + JSON-parse + validate the CONTROL_RESULT payload.
335
+
336
+ Returns the payload dict. Raises ControlResultError on extraction/JSON
337
+ problems and ControlResultValidationError on schema/phase problems.
338
+ ``mode`` selects the per-phase schema: a LOCAL phase reports semantic
339
+ facts (a summary, a workspace fingerprint, finding resolutions) and never
340
+ a PR URL, a comment URL or a follow-up Issue. ``update_epic_request``
341
+ selects the UPDATE_EPIC schema (:class:`UpdateEpicRequest`).
342
+ """
343
+ raw = extract_last_block(stdout)
344
+ if len(raw) > MAX_CONTROL_RESULT_CHARS:
345
+ raise ControlResultValidationError(
346
+ f"CONTROL_RESULT block is {len(raw)} characters and the controller accepts at "
347
+ f"most {MAX_CONTROL_RESULT_CHARS}. Keep the block to the fields the phase "
348
+ "requires and re-emit it."
349
+ )
350
+ try:
351
+ payload = json.loads(raw)
352
+ except json.JSONDecodeError as exc:
353
+ raise ControlResultError(f"CONTROL_RESULT block is not valid JSON: {exc}") from exc
354
+ if not isinstance(payload, dict):
355
+ raise ControlResultValidationError(
356
+ f"CONTROL_RESULT must be a JSON object, got {type(payload).__name__}"
357
+ )
358
+ for key in ("phase", "status"):
359
+ if key not in payload:
360
+ raise ControlResultValidationError(f"CONTROL_RESULT missing required field {key!r}")
361
+ if payload["phase"] != expected_phase.value:
362
+ raise ControlResultValidationError(
363
+ f"phase mismatch: controller is in {expected_phase.value}, "
364
+ f"but CONTROL_RESULT says {payload['phase']!r}"
365
+ )
366
+ if payload["status"] not in ALLOWED_STATUSES:
367
+ raise ControlResultValidationError(
368
+ f"unknown status {payload['status']!r} (expected one of {ALLOWED_STATUSES})"
369
+ )
370
+ if payload["status"] == "success":
371
+ validate_for_phase(expected_phase, payload, mode, update_epic_request=update_epic_request)
372
+ else:
373
+ msg = payload.get("message")
374
+ if not isinstance(msg, str) or not msg.strip():
375
+ raise ControlResultValidationError(
376
+ f"status {payload['status']!r} requires a non-empty 'message' explaining why"
377
+ )
378
+ return payload
379
+
380
+
381
+ # -- helpers --------------------------------------------------------------
382
+ def _req(payload: dict, key: str, phase: str):
383
+ if key not in payload or payload[key] in (None, ""):
384
+ raise ControlResultValidationError(
385
+ f"CONTROL_RESULT for {phase} missing required field {key!r}"
386
+ )
387
+ return payload[key]
388
+
389
+
390
+ def _req_str(payload: dict, key: str, phase: str) -> str:
391
+ v = _req(payload, key, phase)
392
+ if not isinstance(v, str):
393
+ raise ControlResultValidationError(f"{phase}: field {key!r} must be a string")
394
+ # ``_req`` rejects "" before stripping; a whitespace-only value is just as
395
+ # absent (a blank required_resolution demands nothing) and is rejected
396
+ # with the same message rather than becoming an empty required field.
397
+ stripped = v.strip()
398
+ if not stripped:
399
+ raise ControlResultValidationError(
400
+ f"CONTROL_RESULT for {phase} missing required field {key!r}"
401
+ )
402
+ return stripped
403
+
404
+
405
+ def _opt_str(payload: dict, key: str, phase: str) -> str:
406
+ """An optional free-text field: absent, JSON ``null``, or a string.
407
+
408
+ Never ``str(...)``. Coercion made a number, a list or an object into a
409
+ *successful* result whose text the controller then persisted and rendered
410
+ back into a prompt, so a schema violation arrived as content instead of as
411
+ a rejection. ``null`` is JSON's other spelling of "absent" and is treated
412
+ as such (the same rule :func:`_opt_str_list` already applies); anything
413
+ else is the protocol violation it looks like.
414
+ """
415
+ raw = payload.get(key)
416
+ if raw is None:
417
+ return ""
418
+ if not isinstance(raw, str):
419
+ raise ControlResultValidationError(
420
+ f"{phase}: optional field {key!r} must be a string when present, got "
421
+ f"{type(raw).__name__}"
422
+ )
423
+ return raw.strip()
424
+
425
+
426
+ def _checked_sha(v: str, key: str, phase: str) -> str:
427
+ """``v`` as a lower-cased full git SHA, or a rejection that quotes it
428
+ only when it is no longer than a SHA: an oversized value is reported by
429
+ its length, never echoed into the correction prompt or the run log."""
430
+ if not _SHA_RE.match(v):
431
+ shown = repr(v) if len(v) <= 40 else f"{len(v)} characters"
432
+ raise ControlResultValidationError(
433
+ f"{phase}: field {key!r} must be a full git SHA (exactly 40 hex chars, as "
434
+ f"`git rev-parse HEAD` and `gh pr view --json headRefOid` report it), got {shown}"
435
+ )
436
+ return v.lower()
437
+
438
+
439
+ def _req_sha(payload: dict, key: str, phase: str) -> str:
440
+ return _checked_sha(_req_str(payload, key, phase), key, phase)
441
+
442
+
443
+ def _opt_sha(payload: dict, key: str, phase: str) -> str:
444
+ """An optional SHA: absent or ``null`` is ``""``; a present value must be a SHA."""
445
+ v = _opt_str(payload, key, phase)
446
+ return _checked_sha(v, key, phase) if v else ""
447
+
448
+
449
+ def _checked_url(v: str, key: str, phase: str, kind: str) -> str:
450
+ """``v`` validated as a GitHub ``kind`` URL, bounded first.
451
+
452
+ The ``validation`` parser quotes the value in its error, and that error
453
+ reaches the correction prompt and the run log, so the length is checked
454
+ before the parser sees the text.
455
+ """
456
+ if len(v) > MAX_URL_CHARS:
457
+ raise ControlResultValidationError(
458
+ f"{phase}: field {key!r} is {len(v)} characters; a GitHub {kind} URL is at most "
459
+ f"{MAX_URL_CHARS}. Re-emit the CONTROL_RESULT with the real URL."
460
+ )
461
+ parser = {"issue": parse_issue_url, "pr": parse_pr_url, "comment": parse_comment_url}[kind]
462
+ try:
463
+ parser(v)
464
+ except ConfigurationError as exc:
465
+ raise ControlResultValidationError(
466
+ f"{phase}: field {key!r} must be a GitHub {kind} URL: {exc}"
467
+ ) from exc
468
+ return v
469
+
470
+
471
+ def _req_url(payload: dict, key: str, phase: str, kind: str) -> str:
472
+ return _checked_url(_req_str(payload, key, phase), key, phase, kind)
473
+
474
+
475
+ def _opt_url(payload: dict, key: str, phase: str, kind: str) -> str:
476
+ """An optional URL: absent or ``null`` is ``""``; a present value must parse."""
477
+ v = _opt_str(payload, key, phase)
478
+ return _checked_url(v, key, phase, kind) if v else ""
479
+
480
+
481
+ def _req_bool(payload: dict, key: str, phase: str) -> bool:
482
+ if key not in payload:
483
+ raise ControlResultValidationError(
484
+ f"CONTROL_RESULT for {phase} missing required field {key!r}"
485
+ )
486
+ v = payload[key]
487
+ if not isinstance(v, bool):
488
+ raise ControlResultValidationError(f"{phase}: field {key!r} must be a boolean")
489
+ return v
490
+
491
+
492
+ # -- published-content policy (ADR 0004 D8.2, D8.3, D8.5) ------------------
493
+ # Every rule refuses; none rewrites. Where a rule approximates how GitHub
494
+ # reads Markdown, the approximation errs toward refusing: a refusal costs a
495
+ # correction, a miss publishes. A message names the subject and the rule,
496
+ # and at most an index, never the text.
497
+
498
+ # A closing keyword followed by an issue reference: ``#n``, ``owner/repo#n``,
499
+ # an issue or pull URL, or ``GH-n``. Any case, an optional ``:`` after the
500
+ # keyword. Matched anywhere, code spans included: GitHub's own reading of a
501
+ # commit message has no code spans, and a wider refusal fails closed. Every
502
+ # quantifier is possessive and each candidate starts at a keyword, so a
503
+ # candidate scans only the run that follows its own keyword.
504
+ _REF_NAME = r"[A-Za-z0-9_.-]++"
505
+ _CLOSING_REFERENCE_RE = re.compile(
506
+ r"(?<![A-Za-z0-9_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::\s*+|\s++)(?:"
507
+ rf"https?://(?:www\.)?github\.com/(?P<url_repo>{_REF_NAME}/{_REF_NAME})"
508
+ r"/(?P<url_kind>issues|pull)/(?P<url_number>[0-9]++)"
509
+ rf"|(?P<repo>{_REF_NAME}/{_REF_NAME})#(?P<repo_number>[0-9]++)"
510
+ r"|#(?P<number>[0-9]++)"
511
+ r"|gh-(?P<gh_number>[0-9]++))",
512
+ re.IGNORECASE,
513
+ )
514
+ # An ``@`` that starts a GitHub mention: not after an ASCII word character
515
+ # (so ``a@b.com`` is an address, not a mention; a non-ASCII letter before
516
+ # the ``@`` counts as punctuation, which fails closed), followed by a name
517
+ # character. ``@org/team`` starts the same way.
518
+ _MENTION_RE = re.compile(r"(?<![A-Za-z0-9_])@[A-Za-z0-9]")
519
+ # A URL in UPDATE_EPIC ``progress`` (#160: the agent's progress text carries
520
+ # no URL; the controller's marker names the issue and the PR). A scheme
521
+ # separator ``://``, or a ``www.`` host not inside a word: the two forms
522
+ # GitHub autolinks, the first widened to any scheme so the rule errs toward
523
+ # refusing. Matched anywhere, code included, and in ``progress`` only:
524
+ # ``roadmap_section`` may link the EPIC's PRs.
525
+ _PROGRESS_URL_RE = re.compile(r"://|(?<![A-Za-z0-9])www\.", re.IGNORECASE)
526
+ # Raw HTML (a tag, a comment, an autolink): GitHub reads backticks and fences
527
+ # inside an HTML block or tag as text, so wherever raw HTML sits outside code
528
+ # the code exemption for mentions is not trusted at all.
529
+ _RAW_HTML_RE = re.compile(r"<[A-Za-z/!?]")
530
+ # CommonMark's line endings, and nothing else: ``str.splitlines`` would also
531
+ # break at characters GitHub reads as text, and so find fences GitHub does
532
+ # not.
533
+ _LINE_BREAK_RE = re.compile(r"\r\n|\r|\n")
534
+ # A fence opens at column 0 only. CommonMark allows three spaces of indent,
535
+ # but an indented ``` may sit inside a list item or block quote, where it is
536
+ # not the top-level fence it looks like; at column 0 it is one (or it is in
537
+ # an HTML block, which the raw-HTML rule covers). A backtick fence's info
538
+ # string has no backtick.
539
+ _FENCE_OPEN_RE = re.compile(r"(`{3,}+|~{3,}+)(.*)")
540
+ _FENCE_CLOSE_RE = re.compile(r" {0,3}(`{3,}+|~{3,}+)[ \t]*+\Z")
541
+ _BACKTICK_RUN_RE = re.compile(r"`++")
542
+
543
+
544
+ def _lines(text: str) -> Iterator[tuple[int, str]]:
545
+ """``(offset, line)`` for each line of ``text``, without its line ending."""
546
+ start = 0
547
+ for m in _LINE_BREAK_RE.finditer(text):
548
+ yield start, text[start : m.start()]
549
+ start = m.end()
550
+ yield start, text[start:]
551
+
552
+
553
+ def _line_code_spans(line: str) -> tuple[list[tuple[int, int]], bool, bool]:
554
+ """The inline code spans of one line, by CommonMark's backtick matching.
555
+
556
+ A run of ``n`` backticks opens a span closed by the next run of exactly
557
+ ``n``; a run with no such closer is literal. Returns the spans' content
558
+ ranges, whether every run was paired (``balanced``: nothing can carry
559
+ into the next line) and whether the spans are ``trusted``. They are not
560
+ when an opener is escaped (``\\```), which CommonMark reads differently,
561
+ or a span holds a ``|``, which splits a GFM table cell before code spans
562
+ are parsed. Linear: the next run of each length is found through a
563
+ queue per length, each index dequeued once.
564
+ """
565
+ runs = [(m.start(), m.end()) for m in _BACKTICK_RUN_RE.finditer(line)]
566
+ by_length: dict[int, deque[int]] = {}
567
+ for index, (start, end) in enumerate(runs):
568
+ by_length.setdefault(end - start, deque()).append(index)
569
+ spans: list[tuple[int, int]] = []
570
+ balanced = trusted = True
571
+ k = 0
572
+ while k < len(runs):
573
+ start, end = runs[k]
574
+ if start and line[start - 1] == "\\":
575
+ balanced = trusted = False
576
+ later = by_length[end - start]
577
+ while later and later[0] <= k:
578
+ later.popleft()
579
+ if not later:
580
+ balanced = False
581
+ k += 1
582
+ continue
583
+ closer = later.popleft()
584
+ content_end = runs[closer][0]
585
+ if line.find("|", end, content_end) != -1:
586
+ trusted = False
587
+ spans.append((end, content_end))
588
+ k = closer + 1
589
+ return spans, balanced, trusted
590
+
591
+
592
+ def _code_ranges(text: str) -> list[tuple[int, int]]:
593
+ """Ascending, disjoint ranges of ``text`` that are certainly code.
594
+
595
+ Fenced blocks (``` or ~~~, at least three, closed by the same character
596
+ at least as long; an unclosed fence runs to the end) and inline code
597
+ spans. Spans are paired per line, and a line's spans count only when
598
+ they are trusted and no earlier line of the same paragraph run (lines
599
+ since the last blank line or fence) left a backtick unpaired: such a
600
+ backtick may pair across the line break and shift every span after it.
601
+ A multi-line span is therefore never exempt; it fails closed.
602
+ """
603
+ ranges: list[tuple[int, int]] = []
604
+ fence: tuple[str, int] | None = None
605
+ carried = False
606
+ for start, line in _lines(text):
607
+ if fence is not None:
608
+ m = _FENCE_CLOSE_RE.match(line)
609
+ if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= fence[1]:
610
+ fence = None
611
+ elif line:
612
+ ranges.append((start, start + len(line)))
613
+ continue
614
+ m = _FENCE_OPEN_RE.match(line)
615
+ if m and not (m.group(1)[0] == "`" and "`" in m.group(2)):
616
+ fence = (m.group(1)[0], len(m.group(1)))
617
+ carried = False
618
+ continue
619
+ if not line.strip(" \t"):
620
+ carried = False
621
+ continue
622
+ spans, balanced, trusted = _line_code_spans(line)
623
+ if trusted and not carried:
624
+ ranges.extend((start + s, start + e) for s, e in spans)
625
+ carried = carried or not balanced
626
+ return ranges
627
+
628
+
629
+ def _outside(ranges: list[tuple[int, int]], positions: Iterable[int]) -> Iterator[int]:
630
+ """The ascending ``positions`` that no range of ascending, disjoint ``ranges`` holds."""
631
+ i = 0
632
+ for position in positions:
633
+ while i < len(ranges) and ranges[i][1] <= position:
634
+ i += 1
635
+ if i < len(ranges) and ranges[i][0] <= position:
636
+ continue
637
+ yield position
638
+
639
+
640
+ def _exposed_mention(text: str) -> tuple[int, int | None] | None:
641
+ """The first ``@``-mention of ``text`` GitHub may read as one, if any.
642
+
643
+ ``(index, None)`` for a mention outside every code range; ``(index,
644
+ html)`` for the first mention, even one in code, when raw HTML stands
645
+ outside code at ``html``, where the code exemption is not trusted.
646
+ """
647
+ mentions = [m.start() for m in _MENTION_RE.finditer(text)]
648
+ if not mentions:
649
+ return None
650
+ ranges = _code_ranges(text)
651
+ outside = next(_outside(ranges, mentions), None)
652
+ raw_html = next(_outside(ranges, (m.start() for m in _RAW_HTML_RE.finditer(text))), None)
653
+ if raw_html is not None and outside != mentions[0]:
654
+ return mentions[0], raw_html
655
+ if outside is None:
656
+ return None
657
+ return outside, None
658
+
659
+
660
+ def _mention_problem(subject: str, text: str) -> str | None:
661
+ """The refusal of the first ``@``-mention outside code in ``text``, if any."""
662
+ exposed = _exposed_mention(text)
663
+ if exposed is None:
664
+ return None
665
+ index, raw_html = exposed
666
+ if raw_html is not None:
667
+ return (
668
+ f"{subject!r} contains an @-mention at index {index} and raw HTML (a tag, "
669
+ f"comment or autolink) outside code at index {raw_html}; GitHub may read code "
670
+ "near raw HTML as text, so the code exemption does not apply. Remove the HTML or "
671
+ "the mention and re-emit the CONTROL_RESULT."
672
+ )
673
+ return (
674
+ f"{subject!r} contains an @-mention at index {index} outside a code span or fenced "
675
+ "block; GitHub would notify that user or team. Put such tokens in code spans "
676
+ "(`@name`) and re-emit the CONTROL_RESULT."
677
+ )
678
+
679
+
680
+ def published_text_problem(subject: str, text: str) -> str | None:
681
+ """Why agent text ``subject`` may not be published as is, or ``None``.
682
+
683
+ The rules, in order (ADR 0004 D8.2, D8.3): a controller marker opener of
684
+ either prefix (:data:`AGENT_MARKER_OPEN_RE`); a credential-shaped string,
685
+ named by its redaction pattern class (refused, never redacted); a closing
686
+ keyword followed by an issue reference, anywhere; an ``@``-mention
687
+ outside a code span or fenced block. The answer is one line naming
688
+ ``subject`` and the rule, never quoting ``text``. Roughly linear in the
689
+ length of ``text``.
690
+ """
691
+ m = AGENT_MARKER_OPEN_RE.search(text)
692
+ if m is not None:
693
+ return (
694
+ f"{subject!r} contains a controller marker opener at index {m.start()} ('<!--' "
695
+ "then 'ai-' or 'autoforge-', in any case or spacing); only the controller writes "
696
+ "markers. Remove it and re-emit the CONTROL_RESULT."
697
+ )
698
+ classes = credential_classes(text)
699
+ if classes:
700
+ return (
701
+ f"{subject!r} contains a credential-shaped string (pattern class: "
702
+ f"{', '.join(classes)}); published text is refused, never redacted. Remove it "
703
+ "and re-emit the CONTROL_RESULT."
704
+ )
705
+ m = _CLOSING_REFERENCE_RE.search(text)
706
+ if m is not None:
707
+ return (
708
+ f"{subject!r} contains a closing keyword followed by an issue reference at index "
709
+ f"{m.start()} ('close', 'fix' or 'resolve' in any form, then '#n', 'owner/repo#n' "
710
+ "or an issue URL), even inside code; GitHub closes issues named that way, and the "
711
+ "controller links the run's own issue itself. Reword it and re-emit the "
712
+ "CONTROL_RESULT."
713
+ )
714
+ return _mention_problem(subject, text)
715
+
716
+
717
+ def published_payload_problem(subject: str, payload: str) -> str | None:
718
+ """Why the rendered ``payload`` ``subject`` may not be published, or ``None``.
719
+
720
+ Judges the credential rule alone, on the whole payload (ADR 0004 D8.3):
721
+ fields that pass one by one do not make a payload that passes, because a
722
+ pattern can span a field's end and the text rendered after it (a field
723
+ ending in ``GITHUB_TOKEN=`` takes the next rendered word as its value).
724
+ Names the payload and the pattern classes, never the text.
725
+ """
726
+ classes = credential_classes(payload)
727
+ if not classes:
728
+ return None
729
+ return (
730
+ f"the rendered {subject!r} contains a credential-shaped string (pattern class: "
731
+ f"{', '.join(classes)}), although each field may pass alone: a field's end can join "
732
+ "the text rendered after it. Published text is refused, never redacted; change the "
733
+ "fields so that no credential shape remains and re-emit the CONTROL_RESULT."
734
+ )
735
+
736
+
737
+ def published_markdown_problem(subject: str, markdown: str) -> str | None:
738
+ """Why the Markdown ``subject`` composed of several fields may not be published, or ``None``.
739
+
740
+ Judges the mention rule on the composition (ADR 0004 D8.2): fields that
741
+ pass one by one do not make Markdown that passes, because a field's
742
+ unclosed fence, unpaired backtick or raw HTML can change how GitHub
743
+ reads the text rendered after it (a fence one field leaves open is
744
+ closed by another's, and the mention that followed that fence is no
745
+ longer code). ``markdown`` is everything the controller renders before
746
+ its own marker, the one raw HTML it writes. Names the payload and an
747
+ index, never the text.
748
+ """
749
+ exposed = _exposed_mention(markdown)
750
+ if exposed is None:
751
+ return None
752
+ index, raw_html = exposed
753
+ where = (
754
+ f"and raw HTML (a tag, comment or autolink) outside code at index {raw_html}, near "
755
+ "which GitHub may read code as text"
756
+ if raw_html is not None
757
+ else "outside a code span or fenced block"
758
+ )
759
+ return (
760
+ f"the rendered {subject!r} contains an @-mention at index {index} {where}, although "
761
+ "each field may pass alone: an unclosed fence, an unpaired backtick or raw HTML in one "
762
+ "field changes how GitHub reads the fields rendered after it. Close every code span and "
763
+ "fenced block in the field that opens it, drop the raw HTML or the mention, and "
764
+ "re-emit the CONTROL_RESULT."
765
+ )
766
+
767
+
768
+ def markdown_code_span(text: str) -> str:
769
+ """``text`` as one Markdown code span, whatever backticks it holds.
770
+
771
+ The delimiter is one backtick longer than the longest run inside, and a
772
+ space pads a value that starts or ends with a backtick, or that starts
773
+ and ends with a space (CommonMark strips one such pair), so the span
774
+ shows ``text`` exactly. One-line text only; an empty value is ``""``.
775
+ """
776
+ if not text:
777
+ return ""
778
+ longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
779
+ fence = "`" * (longest + 1)
780
+ pad = " " if text[0] == "`" or text[-1] == "`" or (text[0] == " " and text[-1] == " ") else ""
781
+ return f"{fence}{pad}{text}{pad}{fence}"
782
+
783
+
784
+ def _names_issue(m: re.Match[str], repository: str, issue_number: int) -> bool:
785
+ """Whether closing reference ``m`` names issue ``issue_number`` of ``repository``.
786
+
787
+ Repository names compare case-insensitively, as GitHub treats them; the
788
+ number compares as digits, so a padded ``#07`` is another reference and
789
+ fails closed.
790
+ """
791
+ number = str(issue_number)
792
+ repo = repository.strip().lower()
793
+ if m.group("url_number") is not None:
794
+ return (
795
+ m.group("url_kind").lower() == "issues"
796
+ and m.group("url_repo").lower() == repo
797
+ and m.group("url_number") == number
798
+ )
799
+ if m.group("repo_number") is not None:
800
+ return m.group("repo").lower() == repo and m.group("repo_number") == number
801
+ return (m.group("number") or m.group("gh_number")) == number
802
+
803
+
804
+ def commit_message_problem(message: str, *, repository: str, issue_number: int) -> str | None:
805
+ """Why commit ``message`` may not be published, or ``None`` (ADR 0004 D8.5).
806
+
807
+ Refuses a credential-shaped string, and a closing keyword that names any
808
+ issue other than the run's own: ``#<issue_number>``,
809
+ ``<repository>#<issue_number>`` (``owner/repo``, any case) or that
810
+ issue's URL. GitHub closes every issue named that way once the commit
811
+ reaches the default branch. Never quotes the message.
812
+ """
813
+ classes = credential_classes(message)
814
+ if classes:
815
+ return (
816
+ f"commit message contains a credential-shaped string (pattern class: "
817
+ f"{', '.join(classes)}); published text is refused, never redacted. Rewrite the "
818
+ "commit without it."
819
+ )
820
+ for m in _CLOSING_REFERENCE_RE.finditer(message):
821
+ if not _names_issue(m, repository, issue_number):
822
+ return (
823
+ "commit message names an issue other than this run's own "
824
+ f"#{issue_number} with a closing keyword at index {m.start()}; GitHub would "
825
+ "close that issue when the commit reaches the default branch. Reword the "
826
+ f"commit message so that a closing keyword names #{issue_number} only."
827
+ )
828
+ return None
829
+
830
+
831
+ # -- typed per-phase models -----------------------------------------------
832
+ @dataclass
833
+ class AnalyzeExecuteResult:
834
+ """What an implementation agent reports; the controller publishes it (#161).
835
+
836
+ ``issue_url`` and ``head_sha`` are cross-checks against the run's issue
837
+ and the worktree's ``HEAD``, which the controller reads itself; neither
838
+ is a target. ``pr_title`` and ``pr_body`` are the agent's text for the PR
839
+ the controller creates: bounded, held to the published-content policy,
840
+ and never carrying a marker (the controller appends ``Closes #n`` and
841
+ the marker itself). ``tests`` names what the agent ran.
842
+ """
843
+
844
+ issue_url: str
845
+ head_sha: str
846
+ pr_title: str
847
+ pr_body: str
848
+ tests: list[str] = field(default_factory=list)
849
+
850
+ @classmethod
851
+ def from_payload(cls, p: dict) -> AnalyzeExecuteResult:
852
+ ph = "ANALYZE_EXECUTE"
853
+ issue_url = _req_url(p, "issue_url", ph, "issue")
854
+ head_sha = _req_sha(p, "head_sha", ph)
855
+ title = validate_pr_title(_req_str(p, "pr_title", ph))
856
+ body = validate_pr_body(_req_str(p, "pr_body", ph))
857
+ tests = _opt_str_list(p, "tests", ph)
858
+ if len(tests) > MAX_TESTS_REPORTED:
859
+ raise ControlResultValidationError(
860
+ f"{ph}: field 'tests' lists {len(tests)} entries; the controller accepts at "
861
+ f"most {MAX_TESTS_REPORTED}. Summarise them and re-emit the CONTROL_RESULT."
862
+ )
863
+ for test in tests:
864
+ _one_line(_bounded(test, ph, "result", "tests", MAX_TEST_CHARS), ph, "result", "tests")
865
+ return cls(
866
+ issue_url=issue_url,
867
+ head_sha=head_sha,
868
+ pr_title=title,
869
+ pr_body=body,
870
+ tests=tests,
871
+ )
872
+
873
+
874
+ def _published_field(text: str, key: str, phase: str) -> str:
875
+ """Refuse ``phase``'s field ``key`` under the published-content policy."""
876
+ problem = published_text_problem(key, text)
877
+ if problem is not None:
878
+ raise ControlResultValidationError(f"{phase}: field {problem}")
879
+ return text
880
+
881
+
882
+ def _pr_text(
883
+ text: str, key: str, limit: int, lines: Callable[[str, str, str, str], str], ph: str
884
+ ) -> str:
885
+ if not isinstance(text, str):
886
+ raise ControlResultValidationError(f"{ph}: field {key!r} must be a string")
887
+ if not text.strip():
888
+ raise ControlResultValidationError(
889
+ f"CONTROL_RESULT for {ph} missing required field {key!r}"
890
+ )
891
+ text = _bounded(text, ph, "result", key, limit)
892
+ return _published_field(lines(text, ph, "result", key), key, ph)
893
+
894
+
895
+ def validate_pr_title(text: str, *, phase: str = "ANALYZE_EXECUTE") -> str:
896
+ """``text`` as a ``pr_title`` of ``phase``, or a rejection.
897
+
898
+ Non-blank, at most :data:`MAX_PR_TITLE_CHARS`, one line of printable
899
+ text, and publishable (:func:`published_text_problem`): the controller
900
+ creates the PR with it, the implementation PR of ANALYZE_EXECUTE (#161)
901
+ or the replacement PR of REPLAN_REEXECUTE (#164). The checks
902
+ :meth:`AnalyzeExecuteResult.from_payload` and
903
+ :meth:`ReplanReexecuteResult.from_payload` apply, with the same messages,
904
+ so a persisted K2 or K7 title can be re-validated under the parser's
905
+ rules.
906
+ """
907
+ return _pr_text(text, "pr_title", MAX_PR_TITLE_CHARS, _one_line, phase)
908
+
909
+
910
+ def validate_pr_body(text: str, *, phase: str = "ANALYZE_EXECUTE") -> str:
911
+ """``text`` as a ``pr_body`` of ``phase``, or a rejection.
912
+
913
+ Non-blank, at most :data:`MAX_PR_BODY_CHARS`, multi-line text with no
914
+ other control character, and publishable (:func:`published_text_problem`):
915
+ the agent's part of the PR body, before the controller's ``Closes #n``
916
+ and markers. The checks :meth:`AnalyzeExecuteResult.from_payload` and
917
+ :meth:`ReplanReexecuteResult.from_payload` apply, with the same messages,
918
+ so the agent's part of a persisted K2 or K7 body can be re-validated
919
+ under the parser's rules.
920
+ """
921
+ return _pr_text(text, "pr_body", MAX_PR_BODY_CHARS, _multi_line, phase)
922
+
923
+
924
+ # Control characters a *multi-line* text field may still carry: a newline
925
+ # structures a resolution and a tab is ordinary indentation; every other
926
+ # member of the class the prompt renderer escapes is refused (#78).
927
+ _MULTI_LINE_CONTROL_RE = re.compile(rf"(?![\n\t])[{CONTROL_CHARS}]")
928
+
929
+
930
+ def _one_line(text: str, phase: str, subject: str, key: str) -> str:
931
+ """Reject a one-line text field of ``subject`` carrying a control character.
932
+
933
+ The class is ``prompts.CONTROL_CHAR_RE``, the one ``escape_inline`` would
934
+ otherwise escape when the field is rendered on one line of a prompt: a
935
+ value the controller would have to rewrite before it can show it is not
936
+ accepted. The message names the code point and its index, never the
937
+ text. Length is checked first (``_bounded``), so the index is into a
938
+ value of accepted size.
939
+ """
940
+ m = CONTROL_CHAR_RE.search(text)
941
+ if m is not None:
942
+ raise ControlResultValidationError(
943
+ f"{phase}: {subject} field {key!r} contains a control character "
944
+ f"(U+{ord(m.group(0)):04X} at index {m.start()}); keep it to one line of "
945
+ "printable text and re-emit the CONTROL_RESULT."
946
+ )
947
+ return text
948
+
949
+
950
+ def _multi_line(text: str, phase: str, subject: str, key: str) -> str:
951
+ """Reject a multi-line text field of ``subject`` carrying a control
952
+ character other than a newline or a tab. Same message discipline as
953
+ :func:`_one_line`."""
954
+ m = _MULTI_LINE_CONTROL_RE.search(text)
955
+ if m is not None:
956
+ raise ControlResultValidationError(
957
+ f"{phase}: {subject} field {key!r} contains a control character "
958
+ f"(U+{ord(m.group(0)):04X} at index {m.start()}); only newlines and tabs are "
959
+ f"accepted inside {key!r}. Remove it and re-emit the CONTROL_RESULT."
960
+ )
961
+ return text
962
+
963
+
964
+ def _bounded(text: str, phase: str, subject: str, key: str, limit: int) -> str:
965
+ """Reject a text field of ``subject`` longer than ``limit`` characters.
966
+
967
+ The message reports the size, never the text: the oversized value is the
968
+ thing being refused, and the error is echoed into the correction prompt
969
+ and the run log.
970
+ """
971
+ if len(text) > limit:
972
+ raise ControlResultValidationError(
973
+ f"{phase}: {subject} field {key!r} is {len(text)} characters; the controller "
974
+ f"accepts at most {limit}. Shorten it and re-emit the CONTROL_RESULT."
975
+ )
976
+ return text
977
+
978
+
979
+ def _raw_resolutions(p: dict) -> list:
980
+ """The ``resolutions`` list of a FIX result, count-checked before any
981
+ element is parsed (both modes)."""
982
+ raw = p.get("resolutions")
983
+ if not isinstance(raw, list):
984
+ raise ControlResultValidationError("'resolutions' must be a list (one per finding)")
985
+ if len(raw) > MAX_RESOLUTIONS_PER_FIX:
986
+ raise ControlResultValidationError(
987
+ f"FIX: {len(raw)} resolutions reported; the controller accepts at most "
988
+ f"{MAX_RESOLUTIONS_PER_FIX}, one per open finding. Report exactly one resolution "
989
+ "per finding id listed in the prompt and re-emit the CONTROL_RESULT."
990
+ )
991
+ return raw
992
+
993
+
994
+ def _finding_id(payload: dict, key: str, phase: str, round: int | None = None) -> str:
995
+ """The bounded, well-formed finding id at ``payload[key]``.
996
+
997
+ Length is checked first: the shape and round checks quote the id in their
998
+ messages, and those messages reach the correction prompt and the run log.
999
+ With ``round`` given (REVIEW), the id must belong to that round.
1000
+ """
1001
+ fid = _req_str(payload, key, phase)
1002
+ if len(fid) > MAX_FINDING_ID_CHARS:
1003
+ raise ControlResultValidationError(
1004
+ f"{phase}: field {key!r} is {len(fid)} characters; a finding id is R<round>-F<n> "
1005
+ f"and the controller accepts at most {MAX_FINDING_ID_CHARS}. Re-emit the "
1006
+ "CONTROL_RESULT with well-formed ids."
1007
+ )
1008
+ m = _FINDING_ID_RE.match(fid)
1009
+ if not m:
1010
+ if round is None:
1011
+ raise ControlResultValidationError(f"{phase}: invalid {key} {fid!r}")
1012
+ raise ControlResultValidationError(
1013
+ f"{phase}: finding id {fid!r} must look like R<round>-F<n> (e.g. R{round}-F1)"
1014
+ )
1015
+ if round is not None and int(m.group("round")) != round:
1016
+ raise ControlResultValidationError(
1017
+ f"{phase}: finding id {fid!r} does not belong to review round {round}"
1018
+ )
1019
+ return fid
1020
+
1021
+
1022
+ def _parse_findings(payload: dict, round: int, needs_fix: bool) -> list[Finding]:
1023
+ """The bounded, validated findings list of a REVIEW result (any mode).
1024
+
1025
+ The count is checked before any element is parsed, so an oversized list is
1026
+ refused without the controller doing per-element work on it, and the
1027
+ review invariant (``needs_fix_round == (findings > 0)``) is enforced here
1028
+ so REMOTE and LOCAL reviews cannot drift apart.
1029
+ """
1030
+ raw_findings = payload.get("findings")
1031
+ if not isinstance(raw_findings, list):
1032
+ raise ControlResultValidationError("'findings' must be a list (empty when clean)")
1033
+ if len(raw_findings) > MAX_FINDINGS_PER_REVIEW:
1034
+ raise ControlResultValidationError(
1035
+ f"REVIEW: {len(raw_findings)} findings reported; the controller accepts at most "
1036
+ f"{MAX_FINDINGS_PER_REVIEW} per review round. Keep the actionable findings, move "
1037
+ "non-actionable remarks to observations, and re-emit the CONTROL_RESULT."
1038
+ )
1039
+ findings = [Finding.from_payload(f, round, i) for i, f in enumerate(raw_findings)]
1040
+ ids = [f.id for f in findings]
1041
+ if len(set(ids)) != len(ids):
1042
+ raise ControlResultValidationError(f"duplicate finding ids: {ids}")
1043
+ # Controller invariant (§14): the boolean must agree with the list.
1044
+ if needs_fix != (len(findings) > 0):
1045
+ raise ControlResultValidationError(
1046
+ f"invalid REVIEW result: needs_fix_round={needs_fix} but "
1047
+ f"{len(findings)} finding(s) reported — the two must agree "
1048
+ "(any finding, even a nit, requires a fix round; observations are not findings)"
1049
+ )
1050
+ return findings
1051
+
1052
+
1053
+ @dataclass
1054
+ class Finding:
1055
+ id: str
1056
+ classification: str
1057
+ required_resolution: str
1058
+ title: str = ""
1059
+ location: str = ""
1060
+
1061
+ @classmethod
1062
+ def from_payload(cls, raw: object, round: int, index: int) -> Finding:
1063
+ ph = "REVIEW"
1064
+ if not isinstance(raw, dict):
1065
+ raise ControlResultValidationError(
1066
+ f"{ph}: findings[{index}] must be an object with id/classification/"
1067
+ "required_resolution"
1068
+ )
1069
+ fid = _finding_id(raw, "id", ph, round)
1070
+ subject = f"finding {fid}"
1071
+ cls_ = _req_str(raw, "classification", ph)
1072
+ if cls_ not in FINDING_CLASSIFICATIONS:
1073
+ raise ControlResultValidationError(
1074
+ f"{ph}: finding {fid} classification must be one of "
1075
+ f"{FINDING_CLASSIFICATIONS}, got {cls_!r}"
1076
+ )
1077
+ return cls(
1078
+ id=fid,
1079
+ classification=cls_,
1080
+ required_resolution=_multi_line(
1081
+ _bounded(
1082
+ _req_str(raw, "required_resolution", ph),
1083
+ ph,
1084
+ subject,
1085
+ "required_resolution",
1086
+ MAX_FINDING_RESOLUTION_CHARS,
1087
+ ),
1088
+ ph,
1089
+ subject,
1090
+ "required_resolution",
1091
+ ),
1092
+ title=_one_line(
1093
+ _bounded(_opt_str(raw, "title", ph), ph, subject, "title", MAX_FINDING_TITLE_CHARS),
1094
+ ph,
1095
+ subject,
1096
+ "title",
1097
+ ),
1098
+ location=_one_line(
1099
+ _bounded(
1100
+ _opt_str(raw, "location", ph),
1101
+ ph,
1102
+ subject,
1103
+ "location",
1104
+ MAX_FINDING_LOCATION_CHARS,
1105
+ ),
1106
+ ph,
1107
+ subject,
1108
+ "location",
1109
+ ),
1110
+ )
1111
+
1112
+ def to_dict(self) -> dict:
1113
+ return {
1114
+ "id": self.id,
1115
+ "classification": self.classification,
1116
+ "required_resolution": self.required_resolution,
1117
+ "title": self.title,
1118
+ "location": self.location,
1119
+ }
1120
+
1121
+
1122
+ @dataclass
1123
+ class ReviewResult:
1124
+ """What a REMOTE reviewer reports; the controller publishes it (#162).
1125
+
1126
+ ``round`` and ``reviewed_head_sha`` are cross-checks against the round
1127
+ and the HEAD the controller bound; neither is a target, and the result
1128
+ names no comment. ``needs_fix_round`` and ``findings`` are the verdict.
1129
+ ``sections`` holds the prose of :data:`REVIEW_PROSE_SECTIONS`, in that
1130
+ order. Every field the controller renders into the review comment (the
1131
+ prose, and each finding's ``title``, ``location`` and
1132
+ ``required_resolution``) is held to the published-content policy; the
1133
+ findings' shape and bounds are LOCAL's too, unchanged.
1134
+ """
1135
+
1136
+ round: int
1137
+ reviewed_head_sha: str
1138
+ needs_fix_round: bool
1139
+ findings: list[Finding] = field(default_factory=list)
1140
+ sections: dict[str, str] = field(default_factory=dict)
1141
+
1142
+ @classmethod
1143
+ def from_payload(cls, p: dict) -> ReviewResult:
1144
+ ph = "REVIEW"
1145
+ rnd = _req(p, "round", ph)
1146
+ if not isinstance(rnd, int) or isinstance(rnd, bool) or rnd < 1:
1147
+ raise ControlResultValidationError("'round' must be an int >= 1")
1148
+ needs_fix = _req_bool(p, "needs_fix_round", ph)
1149
+ findings = _parse_findings(p, rnd, needs_fix)
1150
+ for finding in findings:
1151
+ check_published_finding(finding)
1152
+ sections = {
1153
+ key: validate_review_section(key, p.get(key)) for key, _ in REVIEW_PROSE_SECTIONS
1154
+ }
1155
+ return cls(
1156
+ round=rnd,
1157
+ reviewed_head_sha=_req_sha(p, "reviewed_head_sha", ph),
1158
+ needs_fix_round=needs_fix,
1159
+ findings=findings,
1160
+ sections=sections,
1161
+ )
1162
+
1163
+
1164
+ def check_published_finding(finding: Finding) -> None:
1165
+ """Refuse ``finding`` unless the round's review comment may show it (#162).
1166
+
1167
+ Each field is judged as the comment renders it: the location inside a
1168
+ code span, the others as Markdown text. A persisted REVIEW plan's
1169
+ findings are held to the same rule when they are loaded (ADR 0004 D4.6).
1170
+ """
1171
+ _review_published(finding.title, f"{finding.id}.title")
1172
+ _review_published(markdown_code_span(finding.location), f"{finding.id}.location")
1173
+ if credential_classes(finding.location):
1174
+ # The span's backticks must not hide a shape the bare value has: the
1175
+ # findings are persisted as reported, in plain state.
1176
+ _review_published(finding.location, f"{finding.id}.location")
1177
+ _review_published(finding.required_resolution, f"{finding.id}.required_resolution")
1178
+
1179
+
1180
+ def validate_review_section(key: str, text: object) -> str:
1181
+ """``text`` as REVIEW prose section ``key``, or a rejection (#162).
1182
+
1183
+ Non-blank, within the section's bound (:data:`REVIEW_PROSE_SECTIONS`),
1184
+ multi-line text with no other control character, and publishable
1185
+ (:func:`published_text_problem`): the controller renders it into the
1186
+ round's review comment under its fixed heading. The checks
1187
+ :meth:`ReviewResult.from_payload` applies, with the same messages, so a
1188
+ persisted section can be re-validated under the parser's rules.
1189
+ """
1190
+ ph = "REVIEW"
1191
+ limit = dict(REVIEW_PROSE_SECTIONS)[key]
1192
+ stripped = _req_str({key: text}, key, ph)
1193
+ stripped = _multi_line(_bounded(stripped, ph, "result", key, limit), ph, "result", key)
1194
+ return _review_published(stripped, key)
1195
+
1196
+
1197
+ def _review_published(text: str, subject: str) -> str:
1198
+ """Refuse REVIEW text ``subject`` under the published-content policy."""
1199
+ problem = published_text_problem(subject, text)
1200
+ if problem is not None:
1201
+ raise ControlResultValidationError(f"REVIEW: field {problem}")
1202
+ return text
1203
+
1204
+
1205
+ def _fix_text(text: object, subject: str, key: str, limit: int, one_line: bool) -> str:
1206
+ """A FIX follow-up text the controller publishes: non-blank, bounded, publishable."""
1207
+ ph = "FIX"
1208
+ if not isinstance(text, str):
1209
+ raise ControlResultValidationError(f"{ph}: {subject} field {key!r} must be a string")
1210
+ stripped = text.strip()
1211
+ if not stripped:
1212
+ raise ControlResultValidationError(f"{ph}: {subject} is missing required field {key!r}")
1213
+ stripped = _bounded(stripped, ph, subject, key, limit)
1214
+ stripped = (_one_line if one_line else _multi_line)(stripped, ph, subject, key)
1215
+ problem = published_text_problem(f"{subject} {key}", stripped)
1216
+ if problem is not None:
1217
+ raise ControlResultValidationError(f"{ph}: field {problem}")
1218
+ return stripped
1219
+
1220
+
1221
+ def validate_follow_up_title(text: object, subject: str = "follow_up_issue") -> str:
1222
+ """``text`` as the title of a new follow-up issue (#163), or a rejection.
1223
+
1224
+ Non-blank, at most :data:`MAX_FOLLOW_UP_TITLE_CHARS`, one line of
1225
+ printable text, and publishable (:func:`published_text_problem`): the
1226
+ controller creates the issue with it. The checks
1227
+ :meth:`FindingResolution.from_payload` applies, so a persisted K5 title
1228
+ can be re-validated under the parser's rules.
1229
+ """
1230
+ return _fix_text(text, subject, "title", MAX_FOLLOW_UP_TITLE_CHARS, one_line=True)
1231
+
1232
+
1233
+ def validate_follow_up_body(text: object, subject: str = "follow_up_issue") -> str:
1234
+ """``text`` as the agent's part of a new follow-up issue's body (#163), or a rejection.
1235
+
1236
+ Non-blank, at most :data:`MAX_FOLLOW_UP_BODY_CHARS`, multi-line text
1237
+ with no other control character, and publishable: the controller
1238
+ follows it with its own reference to the PR and finding and the
1239
+ finding's marker. The checks :meth:`FindingResolution.from_payload`
1240
+ applies, so the agent's part of a persisted K5 body can be re-validated.
1241
+ """
1242
+ return _fix_text(text, subject, "body", MAX_FOLLOW_UP_BODY_CHARS, one_line=False)
1243
+
1244
+
1245
+ def validate_acceptance_body(text: object, subject: str = "acceptance_action") -> str:
1246
+ """``text`` as the agent's part of an acceptance comment (#211), or a rejection.
1247
+
1248
+ Non-blank, at most :data:`MAX_ACCEPTANCE_BODY_CHARS`, multi-line text
1249
+ with no other control character, and publishable: the controller puts
1250
+ its own header before it and the finding's ``ai-acceptance`` marker
1251
+ after it. The checks :meth:`FindingResolution.from_payload` applies, so
1252
+ the agent's part of a persisted K9 body can be re-validated.
1253
+ """
1254
+ return _fix_text(text, subject, "body", MAX_ACCEPTANCE_BODY_CHARS, one_line=False)
1255
+
1256
+
1257
+ def _acceptance_body(raw: object, fid: str) -> str:
1258
+ """The body of ``fid``'s ``acceptance_action`` object, or a rejection.
1259
+
1260
+ The object is a closed schema: ``kind`` (one of
1261
+ :data:`ACCEPTANCE_ACTION_KINDS`) and ``body``. A key that would name a
1262
+ target is refused by name, any other unknown key as unknown; nothing is
1263
+ ignored, because an ignored ``url`` would read as a target the
1264
+ controller honoured.
1265
+ """
1266
+ ph = "FIX"
1267
+ if not isinstance(raw, dict):
1268
+ raise ControlResultValidationError(
1269
+ f"{ph}: {fid} field 'acceptance_action' must be an object with kind and body"
1270
+ )
1271
+ targets = sorted(k for k in raw if k in ACCEPTANCE_TARGET_KEYS)
1272
+ if targets:
1273
+ raise ControlResultValidationError(
1274
+ f"{ph}: {fid} acceptance_action carries {', '.join(repr(k) for k in targets)}; an "
1275
+ "acceptance action names no target, URL, method, path or command: the controller "
1276
+ "comments on the run's own issue. Remove the field and re-emit the CONTROL_RESULT."
1277
+ )
1278
+ unknown = sorted(str(k) for k in raw if k not in ACCEPTANCE_ACTION_KEYS)
1279
+ if unknown:
1280
+ raise ControlResultValidationError(
1281
+ f"{ph}: {fid} acceptance_action has unknown field(s) "
1282
+ f"{', '.join(repr(k[:MAX_FINDING_ID_CHARS]) for k in unknown)}; it carries "
1283
+ "exactly 'kind' and 'body'"
1284
+ )
1285
+ kind = raw.get("kind")
1286
+ if kind not in ACCEPTANCE_ACTION_KINDS:
1287
+ shown = repr(kind[:MAX_FINDING_ID_CHARS]) if isinstance(kind, str) else type(kind).__name__
1288
+ raise ControlResultValidationError(
1289
+ f"{ph}: {fid} acceptance_action kind {shown} is not supported; the one supported "
1290
+ f"kind is {ACCEPTANCE_ACTION_KINDS[0]!r} (a comment on the run's own issue)"
1291
+ )
1292
+ return validate_acceptance_body(raw.get("body"), f"acceptance_action of {fid}")
1293
+
1294
+
1295
+ # A link or bare URL in operator text (#243): a scheme separator, an
1296
+ # extended ``www.`` autolink, a Markdown inline link destination or a link
1297
+ # reference definition. Matched anywhere, code included, like
1298
+ # :data:`_PROGRESS_URL_RE`: the operator copies the text whole. A
1299
+ # definition is read wherever ``]:`` occurs, not only at a line start:
1300
+ # CommonMark honours one inside a block quote or list item, with an escaped
1301
+ # bracket or a line break in its label, and with its destination on the next
1302
+ # line. A ``]:`` that starts no definition is refused too unless what follows
1303
+ # links into the repository; rephrasing it costs the fixer one correction.
1304
+ _OPERATOR_URL_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*+://[^\s<>\"'`)\]]*+")
1305
+ _OPERATOR_WWW_RE = re.compile(r"(?<![A-Za-z0-9.])www\.", re.IGNORECASE)
1306
+ _OPERATOR_LINK_DEST_RE = re.compile(r"\]\(\s*+<?([^\s)>]*+)")
1307
+ _OPERATOR_LINK_DEF_RE = re.compile(r"\]:[ \t]*+(?:\r?\n[ \t]*+)?(<?)([^\s>]*+)")
1308
+ _OPERATOR_IMAGE_RE = re.compile(r"!\[")
1309
+ # An e-mail address GitHub autolinks as a ``mailto:`` link.
1310
+ _OPERATOR_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]@[A-Za-z0-9-]++\.[A-Za-z]")
1311
+
1312
+
1313
+ def _repository_url_prefix(repository: str) -> re.Pattern[str]:
1314
+ """The one link form operator text may carry: ``https://github.com/<owner>/<repo>``.
1315
+
1316
+ Owner and repository compare case-insensitively, as GitHub does; the
1317
+ prefix must end the URL or be followed by ``/``, ``?`` or ``#``, so a
1318
+ longer name (``repo-evil``) or another host never matches.
1319
+ """
1320
+ return re.compile(rf"https://github\.com/{re.escape(repository)}(?:[/?#]|\Z)", re.IGNORECASE)
1321
+
1322
+
1323
+ def operator_comment_link_problem(subject: str, text: str, repository: str) -> str | None:
1324
+ """Why operator text ``subject`` links outside ``repository``, or ``None`` (#243).
1325
+
1326
+ Every link or bare URL must be ``https://github.com/<repository>/...``;
1327
+ a relative link, another scheme, another host or repository, a ``www.``
1328
+ or e-mail autolink and a path that climbs out with ``..`` or a backslash
1329
+ are refused, as written and once entity and percent escapes are decoded
1330
+ (CommonMark decodes an entity in a destination, and a browser resolves
1331
+ ``%2e%2e`` as ``..``). Names the subject, the index and the rule, never
1332
+ the text.
1333
+ """
1334
+ allowed = _repository_url_prefix(repository)
1335
+
1336
+ def bad(url: str) -> bool:
1337
+ return any(
1338
+ allowed.match(form) is None
1339
+ or "\\" in form
1340
+ or re.search(r"/\.{1,2}(?:[/?#]|\Z)", form) is not None
1341
+ for form in (url, unquote(html.unescape(url)))
1342
+ )
1343
+
1344
+ found: list[int] = []
1345
+ for m in _OPERATOR_URL_RE.finditer(text):
1346
+ if bad(m.group(0)):
1347
+ found.append(m.start())
1348
+ found.extend(m.start() for m in _OPERATOR_WWW_RE.finditer(text))
1349
+ found.extend(m.start() for m in _OPERATOR_EMAIL_RE.finditer(text))
1350
+ for m in _OPERATOR_LINK_DEST_RE.finditer(text):
1351
+ if bad(m.group(1)):
1352
+ found.append(m.start(1))
1353
+ for m in _OPERATOR_LINK_DEF_RE.finditer(text):
1354
+ if (m.group(1) or m.group(2)) and bad(m.group(2)):
1355
+ found.append(m.start(2))
1356
+ if not found:
1357
+ return None
1358
+ return (
1359
+ f"{subject!r} contains a link or URL at index {min(found)} that is not "
1360
+ f"'https://github.com/{repository}/...'; the operator posts this text under their own "
1361
+ "identity, so it links only into the run's repository (no relative link, other host, "
1362
+ "other repository, 'www.' form or e-mail address). Remove it and re-emit the "
1363
+ "CONTROL_RESULT."
1364
+ )
1365
+
1366
+
1367
+ def operator_markup_problem(subject: str, text: str) -> str | None:
1368
+ """Why operator text ``subject`` carries an image or raw HTML, or ``None`` (#243).
1369
+
1370
+ Both are judged outside code spans and fenced blocks, where GitHub
1371
+ renders them. Names the subject and an index, never the text.
1372
+ """
1373
+ ranges = _code_ranges(text)
1374
+ image = next(_outside(ranges, (m.start() for m in _OPERATOR_IMAGE_RE.finditer(text))), None)
1375
+ if image is not None:
1376
+ return (
1377
+ f"{subject!r} contains a Markdown image at index {image} outside code; the operator "
1378
+ "posts this text under their own identity, so it embeds no image. Remove it and "
1379
+ "re-emit the CONTROL_RESULT."
1380
+ )
1381
+ html = next(_outside(ranges, (m.start() for m in _RAW_HTML_RE.finditer(text))), None)
1382
+ if html is not None:
1383
+ return (
1384
+ f"{subject!r} contains raw HTML (a tag, comment or autolink) at index {html} outside "
1385
+ "code; the operator posts this text under their own identity, so it carries no raw "
1386
+ "HTML. Remove it (or put it in a code span) and re-emit the CONTROL_RESULT."
1387
+ )
1388
+ return None
1389
+
1390
+
1391
+ def validate_operator_comment_body(
1392
+ text: object, subject: str = "operator_acceptance", repository: str = ""
1393
+ ) -> str:
1394
+ """``text`` as the agent's part of an operator comment (K10, #243), or a rejection.
1395
+
1396
+ Non-blank, at most :data:`MAX_OPERATOR_COMMENT_CHARS`, multi-line text
1397
+ with no other control character, publishable
1398
+ (:func:`published_text_problem`), and with no image or raw HTML
1399
+ (:func:`operator_markup_problem`). With ``repository``
1400
+ (``owner/name``) the link rule applies too
1401
+ (:func:`operator_comment_link_problem`): the parser does not know the
1402
+ run's repository, so the controller applies it when it checks the
1403
+ result and when it reloads a persisted record.
1404
+ """
1405
+ body = _fix_text(text, subject, "body", MAX_OPERATOR_COMMENT_CHARS, one_line=False)
1406
+ problem = operator_markup_problem(f"{subject} body", body)
1407
+ if problem is None and repository:
1408
+ problem = operator_comment_link_problem(f"{subject} body", body, repository)
1409
+ if problem is not None:
1410
+ raise ControlResultValidationError(f"FIX: field {problem}")
1411
+ return body
1412
+
1413
+
1414
+ @dataclass(frozen=True)
1415
+ class OperatorAction:
1416
+ """One ``related_issue_comment`` an ``operator_acceptance`` resolution requests (#243).
1417
+
1418
+ ``issue`` is a number of the run's repository the controller checks
1419
+ against the allowed set it read at FIX entry; ``body`` is the agent's
1420
+ text the controller renders between its header and marker.
1421
+ """
1422
+
1423
+ issue: int
1424
+ body: str
1425
+
1426
+
1427
+ def _operator_issue_number(value: object, fid: str, index: int) -> int:
1428
+ """The target issue number of ``fid``'s action ``index``, or a rejection."""
1429
+ if isinstance(value, bool) or not isinstance(value, int):
1430
+ shown = "a string" if isinstance(value, str) else type(value).__name__
1431
+ raise ControlResultValidationError(
1432
+ f"FIX: {fid} operator_acceptance actions[{index}] field 'issue' is {shown}; it is "
1433
+ "a JSON integer, the issue number alone (never a URL, '#n' or 'owner/repo#n'). Fix "
1434
+ "it and re-emit the CONTROL_RESULT."
1435
+ )
1436
+ if not 1 <= value <= MAX_OPERATOR_ISSUE_NUMBER:
1437
+ raise ControlResultValidationError(
1438
+ f"FIX: {fid} operator_acceptance actions[{index}] field 'issue' must be between 1 "
1439
+ f"and {MAX_OPERATOR_ISSUE_NUMBER}"
1440
+ )
1441
+ return value
1442
+
1443
+
1444
+ def _operator_actions(raw: object, fid: str) -> tuple[OperatorAction, ...]:
1445
+ """The actions of ``fid``'s ``operator_acceptance`` object, or a rejection.
1446
+
1447
+ A closed schema at both levels: the object carries ``actions`` only, and
1448
+ each action exactly ``kind``, ``issue`` and ``body``. A key that would
1449
+ name a target is refused by name, any other unknown key as unknown;
1450
+ nothing is ignored, because an ignored ``url`` would read as a target
1451
+ the controller honoured.
1452
+ """
1453
+ ph = "FIX"
1454
+ if not isinstance(raw, dict):
1455
+ raise ControlResultValidationError(
1456
+ f"{ph}: {fid} field 'operator_acceptance' must be an object with 'actions'"
1457
+ )
1458
+
1459
+ def refuse_keys(obj: dict, allowed: tuple[str, ...], what: str) -> None:
1460
+ targets = sorted(str(k) for k in obj if k in OPERATOR_TARGET_KEYS)
1461
+ if targets:
1462
+ raise ControlResultValidationError(
1463
+ f"{ph}: {fid} {what} carries {', '.join(repr(k) for k in targets)}; an operator "
1464
+ "action names no URL, repository, owner, target, method, path or command: it "
1465
+ "names an issue number of the run's repository. Remove the field and re-emit "
1466
+ "the CONTROL_RESULT."
1467
+ )
1468
+ unknown = sorted(str(k) for k in obj if k not in allowed)
1469
+ if unknown:
1470
+ raise ControlResultValidationError(
1471
+ f"{ph}: {fid} {what} has unknown field(s) "
1472
+ f"{', '.join(repr(k[:MAX_FINDING_ID_CHARS]) for k in unknown)}; it carries "
1473
+ f"exactly {', '.join(repr(k) for k in allowed)}"
1474
+ )
1475
+
1476
+ refuse_keys(raw, OPERATOR_ACCEPTANCE_KEYS, "operator_acceptance")
1477
+ actions = raw.get("actions")
1478
+ if not isinstance(actions, list) or not actions:
1479
+ raise ControlResultValidationError(
1480
+ f"{ph}: {fid} operator_acceptance must carry a non-empty 'actions' array"
1481
+ )
1482
+ if len(actions) > MAX_OPERATOR_ACTIONS_PER_FIX:
1483
+ raise ControlResultValidationError(
1484
+ f"{ph}: {fid} operator_acceptance carries {len(actions)} actions; the controller "
1485
+ f"accepts at most {MAX_OPERATOR_ACTIONS_PER_FIX} per FIX result. Re-emit the "
1486
+ "CONTROL_RESULT with fewer."
1487
+ )
1488
+ out: list[OperatorAction] = []
1489
+ for index, action in enumerate(actions):
1490
+ what = f"operator_acceptance actions[{index}]"
1491
+ if not isinstance(action, dict):
1492
+ raise ControlResultValidationError(
1493
+ f"{ph}: {fid} {what} must be an object with kind, issue and body"
1494
+ )
1495
+ refuse_keys(action, OPERATOR_ACTION_KEYS, what)
1496
+ kind = action.get("kind")
1497
+ if kind not in OPERATOR_ACTION_KINDS:
1498
+ shown = (
1499
+ repr(kind[:MAX_FINDING_ID_CHARS]) if isinstance(kind, str) else type(kind).__name__
1500
+ )
1501
+ raise ControlResultValidationError(
1502
+ f"{ph}: {fid} {what} kind {shown} is not supported; the one supported kind is "
1503
+ f"{OPERATOR_ACTION_KINDS[0]!r} (a comment the operator posts on another issue "
1504
+ "of the run's repository)"
1505
+ )
1506
+ issue = _operator_issue_number(action.get("issue"), fid, index)
1507
+ if any(a.issue == issue for a in out):
1508
+ raise ControlResultValidationError(
1509
+ f"{ph}: {fid} operator_acceptance names issue #{issue} twice; one finding "
1510
+ "requests at most one comment per issue"
1511
+ )
1512
+ body = validate_operator_comment_body(
1513
+ action.get("body"), f"operator_acceptance of {fid} for #{issue}"
1514
+ )
1515
+ out.append(OperatorAction(issue=issue, body=body))
1516
+ return tuple(out)
1517
+
1518
+
1519
+ @dataclass
1520
+ class FindingResolution:
1521
+ """One open finding's resolution, as a REMOTE fixer reports it (#163).
1522
+
1523
+ A ``follow_up_created`` resolution either names an issue the controller
1524
+ handed over (``follow_up_issue_url``) or carries the title and body of a
1525
+ new follow-up issue the controller creates (``follow_up_title``,
1526
+ ``follow_up_body``; the payload's ``follow_up_issue`` object), never
1527
+ both. An ``acceptance_action`` resolution carries the body of the
1528
+ comment the controller publishes on the run's issue
1529
+ (``acceptance_body``; the payload's ``acceptance_action`` object, #211)
1530
+ and the rationale a ``no_change_with_rationale`` needs. An
1531
+ ``operator_acceptance`` resolution carries the comments the operator
1532
+ posts on other issues (``operator_actions``; the payload's
1533
+ ``operator_acceptance`` object, #243) and a rationale too. ``commit_sha``
1534
+ is allowed on ``fixed`` only. ``to_dict`` keeps the persisted shape of
1535
+ ``state.last_fix_resolutions``.
1536
+ """
1537
+
1538
+ finding_id: str
1539
+ resolution: str
1540
+ rationale: str = ""
1541
+ follow_up_issue_url: str = ""
1542
+ commit_sha: str = ""
1543
+ follow_up_title: str = ""
1544
+ follow_up_body: str = ""
1545
+ acceptance_body: str = ""
1546
+ operator_actions: tuple[OperatorAction, ...] = ()
1547
+
1548
+ @property
1549
+ def new_follow_up(self) -> bool:
1550
+ """A follow-up issue the controller creates for this finding."""
1551
+ return bool(self.follow_up_title)
1552
+
1553
+ @property
1554
+ def acceptance_action(self) -> bool:
1555
+ """An acceptance comment the controller publishes for this finding (#211)."""
1556
+ return self.resolution == "acceptance_action"
1557
+
1558
+ @property
1559
+ def operator_acceptance(self) -> bool:
1560
+ """Comments the operator posts on other issues for this finding (#243)."""
1561
+ return self.resolution == "operator_acceptance"
1562
+
1563
+ @classmethod
1564
+ def from_payload(cls, raw: object, index: int) -> FindingResolution:
1565
+ ph = "FIX"
1566
+ if not isinstance(raw, dict):
1567
+ raise ControlResultValidationError(
1568
+ f"{ph}: resolutions[{index}] must be an object with finding_id/resolution"
1569
+ )
1570
+ fid = _finding_id(raw, "finding_id", ph)
1571
+ res = _req_str(raw, "resolution", ph)
1572
+ if res not in FIX_RESOLUTIONS:
1573
+ raise ControlResultValidationError(
1574
+ f"{ph}: resolution for {fid} must be one of {FIX_RESOLUTIONS}, got {res!r}"
1575
+ )
1576
+ subject = f"resolution for {fid}"
1577
+ rationale = _multi_line(
1578
+ _bounded(
1579
+ _opt_str(raw, "rationale", ph), ph, subject, "rationale", MAX_FIX_RATIONALE_CHARS
1580
+ ),
1581
+ ph,
1582
+ subject,
1583
+ "rationale",
1584
+ )
1585
+ follow_up = _opt_url(raw, "follow_up_issue_url", ph, "issue")
1586
+ new_issue = raw.get("follow_up_issue")
1587
+ commit_sha = _opt_sha(raw, "commit_sha", ph)
1588
+ action = raw.get("acceptance_action")
1589
+ operator = raw.get("operator_acceptance")
1590
+ if res in ("no_change_with_rationale", "acceptance_action", "operator_acceptance"):
1591
+ if len(rationale) < MIN_RATIONALE_CHARS:
1592
+ raise ControlResultValidationError(
1593
+ f"{ph}: {fid} uses {res} but the rationale is missing "
1594
+ f"or too short (>= {MIN_RATIONALE_CHARS} chars of actual reasoning required)"
1595
+ )
1596
+ if res != "acceptance_action" and action is not None:
1597
+ raise ControlResultValidationError(
1598
+ f"{ph}: {fid} carries an acceptance_action but resolution is {res!r}"
1599
+ )
1600
+ if res != "operator_acceptance" and operator is not None:
1601
+ raise ControlResultValidationError(
1602
+ f"{ph}: {fid} carries an operator_acceptance but resolution is {res!r}"
1603
+ )
1604
+ if res != "follow_up_created":
1605
+ if follow_up or new_issue is not None:
1606
+ raise ControlResultValidationError(
1607
+ f"{ph}: {fid} carries a follow-up issue but resolution is {res!r}"
1608
+ )
1609
+ elif bool(follow_up) == (new_issue is not None):
1610
+ raise ControlResultValidationError(
1611
+ f"{ph}: {fid} uses follow_up_created and must carry exactly one of "
1612
+ "'follow_up_issue_url' (an issue the prompt lists) and 'follow_up_issue' "
1613
+ "(the title and body of a new issue the controller creates)"
1614
+ )
1615
+ if commit_sha and res != "fixed":
1616
+ raise ControlResultValidationError(
1617
+ f"{ph}: {fid} carries commit_sha but resolution is {res!r}; only a fixed "
1618
+ "finding names the commit that fixed it"
1619
+ )
1620
+ title = body = ""
1621
+ if new_issue is not None:
1622
+ if not isinstance(new_issue, dict):
1623
+ raise ControlResultValidationError(
1624
+ f"{ph}: {fid} field 'follow_up_issue' must be an object with title and body"
1625
+ )
1626
+ what = f"follow_up_issue of {fid}"
1627
+ title = validate_follow_up_title(new_issue.get("title"), what)
1628
+ body = validate_follow_up_body(new_issue.get("body"), what)
1629
+ acceptance = ""
1630
+ if res == "acceptance_action":
1631
+ if action is None:
1632
+ raise ControlResultValidationError(
1633
+ f"{ph}: {fid} uses acceptance_action and must carry an 'acceptance_action' "
1634
+ "object with kind and body"
1635
+ )
1636
+ acceptance = _acceptance_body(action, fid)
1637
+ operator_actions: tuple[OperatorAction, ...] = ()
1638
+ if res == "operator_acceptance":
1639
+ if operator is None:
1640
+ raise ControlResultValidationError(
1641
+ f"{ph}: {fid} uses operator_acceptance and must carry an "
1642
+ "'operator_acceptance' object with 'actions'"
1643
+ )
1644
+ operator_actions = _operator_actions(operator, fid)
1645
+ return cls(
1646
+ finding_id=fid,
1647
+ resolution=res,
1648
+ rationale=rationale,
1649
+ follow_up_issue_url=follow_up,
1650
+ commit_sha=commit_sha,
1651
+ follow_up_title=title,
1652
+ follow_up_body=body,
1653
+ acceptance_body=acceptance,
1654
+ operator_actions=operator_actions,
1655
+ )
1656
+
1657
+ def to_dict(self) -> dict:
1658
+ return {
1659
+ "finding_id": self.finding_id,
1660
+ "resolution": self.resolution,
1661
+ "rationale": self.rationale,
1662
+ "follow_up_issue_url": self.follow_up_issue_url,
1663
+ "commit_sha": self.commit_sha,
1664
+ }
1665
+
1666
+
1667
+ @dataclass
1668
+ class FixResult:
1669
+ """What a REMOTE fixer reports; the controller publishes it (#163).
1670
+
1671
+ ``previous_head_sha`` and ``head_sha`` are cross-checks against the
1672
+ reviewed HEAD and the worktree's ``HEAD``, which the controller reads
1673
+ itself; neither is a target. ``resolutions`` holds one resolution per
1674
+ open finding, ``tests`` names what the agent ran.
1675
+ """
1676
+
1677
+ previous_head_sha: str
1678
+ head_sha: str
1679
+ resolutions: list[FindingResolution] = field(default_factory=list)
1680
+ tests: list[str] = field(default_factory=list)
1681
+
1682
+ @classmethod
1683
+ def from_payload(cls, p: dict) -> FixResult:
1684
+ ph = "FIX"
1685
+ previous = _req_sha(p, "previous_head_sha", ph)
1686
+ head = _req_sha(p, "head_sha", ph)
1687
+ resolutions = [
1688
+ FindingResolution.from_payload(r, i) for i, r in enumerate(_raw_resolutions(p))
1689
+ ]
1690
+ ids = [r.finding_id for r in resolutions]
1691
+ if len(set(ids)) != len(ids):
1692
+ raise ControlResultValidationError(f"duplicate resolution finding_ids: {ids}")
1693
+ actions = [r.finding_id for r in resolutions if r.acceptance_action]
1694
+ if len(actions) > MAX_ACCEPTANCE_ACTIONS_PER_FIX:
1695
+ raise ControlResultValidationError(
1696
+ f"{ph}: {len(actions)} resolutions use acceptance_action "
1697
+ f"({', '.join(actions)}); the controller accepts at most "
1698
+ f"{MAX_ACCEPTANCE_ACTIONS_PER_FIX} per FIX result. Resolve the others another "
1699
+ "way and re-emit the CONTROL_RESULT."
1700
+ )
1701
+ operators = [r for r in resolutions if r.operator_acceptance]
1702
+ if actions and operators:
1703
+ raise ControlResultValidationError(
1704
+ f"{ph}: the result mixes acceptance_action ({', '.join(actions)}) and "
1705
+ f"operator_acceptance ({', '.join(r.finding_id for r in operators)}); one FIX "
1706
+ "result carries one or the other, never both. Resolve one of them another way "
1707
+ "and re-emit the CONTROL_RESULT."
1708
+ )
1709
+ requested = sum(len(r.operator_actions) for r in operators)
1710
+ if requested > MAX_OPERATOR_ACTIONS_PER_FIX:
1711
+ raise ControlResultValidationError(
1712
+ f"{ph}: the operator_acceptance resolutions request {requested} actions; the "
1713
+ f"controller accepts at most {MAX_OPERATOR_ACTIONS_PER_FIX} per FIX result. "
1714
+ "Re-emit the CONTROL_RESULT with fewer."
1715
+ )
1716
+ tests = _opt_str_list(p, "tests", ph)
1717
+ if len(tests) > MAX_TESTS_REPORTED:
1718
+ raise ControlResultValidationError(
1719
+ f"{ph}: field 'tests' lists {len(tests)} entries; the controller accepts at "
1720
+ f"most {MAX_TESTS_REPORTED}. Summarise them and re-emit the CONTROL_RESULT."
1721
+ )
1722
+ for test in tests:
1723
+ _one_line(_bounded(test, ph, "result", "tests", MAX_TEST_CHARS), ph, "result", "tests")
1724
+ return cls(previous_head_sha=previous, head_sha=head, resolutions=resolutions, tests=tests)
1725
+
1726
+
1727
+ @dataclass
1728
+ class ReplanReexecuteResult:
1729
+ """What a replan agent reports; the controller publishes it (#164).
1730
+
1731
+ ``issue_url``, ``previous_pr_url``, ``previous_branch``,
1732
+ ``previous_head_sha`` and ``execution_attempt`` are cross-checks against
1733
+ the transaction's checkpoint, and ``head_sha`` against the worktree's
1734
+ ``HEAD``, which the controller reads itself; none of them is a target.
1735
+ The counts and ``verification.tests_passed`` become the transaction
1736
+ marker the controller renders. ``pr_title`` and ``pr_body`` are the
1737
+ agent's text for the replacement PR the controller creates, under the
1738
+ same bounds and content policy as ANALYZE_EXECUTE's: the controller
1739
+ appends ``Closes #n``, the implementation marker and the transaction
1740
+ marker itself. The result names no replacement PR, branch or push target.
1741
+ """
1742
+
1743
+ issue_url: str
1744
+ previous_pr_url: str
1745
+ previous_branch: str
1746
+ previous_head_sha: str
1747
+ head_sha: str
1748
+ execution_attempt: int
1749
+ historical_findings_considered: int
1750
+ unique_failure_constraints: int
1751
+ fresh_review_round: int
1752
+ tests_run: list[str]
1753
+ tests_passed: bool
1754
+ pr_title: str
1755
+ pr_body: str
1756
+
1757
+ @classmethod
1758
+ def from_payload(cls, p: dict) -> ReplanReexecuteResult:
1759
+ ph = "REPLAN_REEXECUTE"
1760
+ previous_pr = _req_url(p, "previous_pr_url", ph, "pr")
1761
+ previous_branch = _req_str(p, "previous_branch", ph)
1762
+ ints: dict[str, int] = {}
1763
+ for key in (
1764
+ "execution_attempt",
1765
+ "historical_findings_considered",
1766
+ "unique_failure_constraints",
1767
+ "fresh_review_round",
1768
+ ):
1769
+ value = _req(p, key, ph)
1770
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
1771
+ raise ControlResultValidationError(f"{ph}: field {key!r} must be an integer >= 0")
1772
+ ints[key] = value
1773
+ if ints["execution_attempt"] < 1:
1774
+ raise ControlResultValidationError("REPLAN_REEXECUTE: execution_attempt must be >= 1")
1775
+ if ints["fresh_review_round"] != 1:
1776
+ raise ControlResultValidationError("REPLAN_REEXECUTE: fresh_review_round must equal 1")
1777
+ if ints["unique_failure_constraints"] > ints["historical_findings_considered"]:
1778
+ raise ControlResultValidationError(
1779
+ "REPLAN_REEXECUTE: unique_failure_constraints cannot exceed "
1780
+ "historical_findings_considered"
1781
+ )
1782
+ if _req_str(p, "previous_pr_disposition", ph) != "superseded":
1783
+ raise ControlResultValidationError(
1784
+ "REPLAN_REEXECUTE: previous_pr_disposition must be 'superseded'"
1785
+ )
1786
+ verification = _req(p, "verification", ph)
1787
+ if not isinstance(verification, dict):
1788
+ raise ControlResultValidationError("REPLAN_REEXECUTE: verification must be an object")
1789
+ tests_run = verification.get("tests_run")
1790
+ tests_passed = verification.get("tests_passed")
1791
+ if not isinstance(tests_run, list) or not all(isinstance(test, str) for test in tests_run):
1792
+ raise ControlResultValidationError(
1793
+ "REPLAN_REEXECUTE: verification.tests_run must be a list"
1794
+ )
1795
+ if not isinstance(tests_passed, bool):
1796
+ raise ControlResultValidationError(
1797
+ "REPLAN_REEXECUTE: verification.tests_passed must be a boolean"
1798
+ )
1799
+ return cls(
1800
+ issue_url=_req_url(p, "issue_url", ph, "issue"),
1801
+ previous_pr_url=previous_pr,
1802
+ previous_branch=previous_branch,
1803
+ previous_head_sha=_req_sha(p, "previous_head_sha", ph),
1804
+ head_sha=_req_sha(p, "head_sha", ph),
1805
+ execution_attempt=ints["execution_attempt"],
1806
+ historical_findings_considered=ints["historical_findings_considered"],
1807
+ unique_failure_constraints=ints["unique_failure_constraints"],
1808
+ fresh_review_round=1,
1809
+ tests_run=tests_run,
1810
+ tests_passed=tests_passed,
1811
+ pr_title=validate_pr_title(_req_str(p, "pr_title", ph), phase=ph),
1812
+ pr_body=validate_pr_body(_req_str(p, "pr_body", ph), phase=ph),
1813
+ )
1814
+
1815
+
1816
+ # Phases that never produce a CONTROL_RESULT: INITIALIZING and
1817
+ # READY_FOR_MERGE are deterministic, and MERGE is executed by the controller
1818
+ # itself (gh pr merge), never by an agent.
1819
+ NON_AGENT_PHASES = frozenset(
1820
+ {
1821
+ Phase.INITIALIZING,
1822
+ Phase.READY_FOR_MERGE,
1823
+ Phase.MERGE,
1824
+ Phase.DONE,
1825
+ Phase.BLOCKED,
1826
+ Phase.FAILED,
1827
+ }
1828
+ )
1829
+
1830
+
1831
+ # The keys every re-request accepts besides the fields it asks for (D4.7).
1832
+ _RE_REQUEST_ENVELOPE = ("phase", "status", "message")
1833
+ _RE_REQUEST_FIELDS: dict[UpdateEpicRequest, tuple[str, ...]] = {
1834
+ UpdateEpicRequest.SELECTION: ("next_issue_url",),
1835
+ UpdateEpicRequest.SELECTION_WITH_ROADMAP: ("next_issue_url", "roadmap_section"),
1836
+ UpdateEpicRequest.ROADMAP: ("roadmap_section",),
1837
+ }
1838
+ # The UPDATE_EPIC field names a refusal may quote: they are the controller's
1839
+ # own. Any other key is agent text and is only counted.
1840
+ _UPDATE_EPIC_FIELDS = ("progress", "roadmap_section", "next_issue_url")
1841
+
1842
+
1843
+ def validate_progress_text(text: str) -> str:
1844
+ """``text`` as an UPDATE_EPIC ``progress`` field, or a rejection.
1845
+
1846
+ Non-blank, at most :data:`MAX_PROGRESS_CHARS`, multi-line text with no
1847
+ other control character, publishable (:func:`published_text_problem`),
1848
+ and with no URL (:data:`_PROGRESS_URL_RE`): the controller posts it as
1849
+ the EPIC progress comment. The checks
1850
+ :meth:`UpdateEpicResult.from_payload` applies, with the same messages,
1851
+ so a persisted value can be re-validated under the parser's rules.
1852
+ """
1853
+ if not isinstance(text, str):
1854
+ raise ControlResultValidationError("UPDATE_EPIC: field 'progress' must be a string")
1855
+ if not text.strip():
1856
+ raise ControlResultValidationError(
1857
+ "CONTROL_RESULT for UPDATE_EPIC missing required field 'progress'"
1858
+ )
1859
+ text = _bounded(text, "UPDATE_EPIC", "result", "progress", MAX_PROGRESS_CHARS)
1860
+ text = _multi_line(text, "UPDATE_EPIC", "result", "progress")
1861
+ text = _published_field(text, "progress", "UPDATE_EPIC")
1862
+ m = _PROGRESS_URL_RE.search(text)
1863
+ if m is not None:
1864
+ raise ControlResultValidationError(
1865
+ f"UPDATE_EPIC: field 'progress' contains a URL at index {m.start()} ('://' or "
1866
+ "'www.'), even inside code; the progress comment carries no URL, and the "
1867
+ "controller's marker names the issue and PR. Refer to them as '#n' instead, and "
1868
+ "re-emit the CONTROL_RESULT."
1869
+ )
1870
+ return text
1871
+
1872
+
1873
+ def validate_roadmap_section(text: str) -> str:
1874
+ """``text`` as a present UPDATE_EPIC ``roadmap_section``, or a rejection.
1875
+
1876
+ Non-blank, at most :data:`MAX_ROADMAP_SECTION_CHARS`, multi-line text,
1877
+ no ``<!-- ai-`` marker (the controller writes the roadmap markers around
1878
+ the section itself), and publishable (:func:`published_text_problem`).
1879
+ The checks :meth:`UpdateEpicResult.from_payload` applies, with the same
1880
+ messages.
1881
+ """
1882
+ if not isinstance(text, str):
1883
+ raise ControlResultValidationError(
1884
+ "UPDATE_EPIC: optional field 'roadmap_section' must be a string when present, "
1885
+ f"got {type(text).__name__}"
1886
+ )
1887
+ if not text.strip():
1888
+ raise ControlResultValidationError(
1889
+ "CONTROL_RESULT for UPDATE_EPIC missing required field 'roadmap_section'"
1890
+ )
1891
+ text = _bounded(text, "UPDATE_EPIC", "result", "roadmap_section", MAX_ROADMAP_SECTION_CHARS)
1892
+ text = _multi_line(text, "UPDATE_EPIC", "result", "roadmap_section")
1893
+ if CONTROLLER_MARKER_OPEN_RE.search(text):
1894
+ raise ControlResultValidationError(
1895
+ "UPDATE_EPIC: field 'roadmap_section' must not contain a controller "
1896
+ f"marker ({_CONTROLLER_MARKER_PREFIX}...): the controller writes "
1897
+ f"{ROADMAP_START_MARKER!r} and {ROADMAP_END_MARKER!r} around the section "
1898
+ "itself. Return only the section's content and re-emit the CONTROL_RESULT."
1899
+ )
1900
+ return _published_field(text, "roadmap_section", "UPDATE_EPIC")
1901
+
1902
+
1903
+ def validate_next_issue_url(url: str) -> str:
1904
+ """``url`` as a present UPDATE_EPIC ``next_issue_url``, or a rejection.
1905
+
1906
+ Shape and length at parse time (the ``next_issue_url`` half of #15).
1907
+ Which issue the URL names is the engine's to verify (repository, EPIC,
1908
+ finished issue, exists, OPEN); whether the string is an issue URL at all
1909
+ is a malformed result, corrected like any other rather than spent as a
1910
+ selection, and an oversized value is never quoted into
1911
+ ``next_issue_rejections``, the re-selection prompt or the run log.
1912
+ """
1913
+ if not isinstance(url, str):
1914
+ raise ControlResultValidationError("'next_issue_url' must be a string or null")
1915
+ return _checked_url(url, "next_issue_url", "UPDATE_EPIC", "issue")
1916
+
1917
+
1918
+ def _refuse_outside_re_request(p: dict, request: UpdateEpicRequest) -> None:
1919
+ """Refuse a re-request result carrying a key outside its schema (D4.7)."""
1920
+ asked = _RE_REQUEST_FIELDS[request]
1921
+ allowed = (*_RE_REQUEST_ENVELOPE, *asked)
1922
+ extra = [key for key in p if key not in allowed]
1923
+ if not extra:
1924
+ return
1925
+ named = [key for key in _UPDATE_EPIC_FIELDS if key in extra]
1926
+ others = len(extra) - len(named)
1927
+ carried = [repr(key) for key in named]
1928
+ if others:
1929
+ carried.append(f"{others} other key{'s' if others > 1 else ''}")
1930
+ accepted = ", ".join(repr(key) for key in allowed)
1931
+ asked_for = " and ".join(repr(key) for key in asked)
1932
+ raise ControlResultValidationError(
1933
+ f"UPDATE_EPIC: this re-request accepts only {accepted}, and the result also "
1934
+ f"carries {', '.join(carried)}. The published progress comment is already done and "
1935
+ f"this re-request asks only for {asked_for}. Remove the other keys and re-emit the "
1936
+ "CONTROL_RESULT."
1937
+ )
1938
+
1939
+
1940
+ def _next_issue_url(p: dict) -> str | None:
1941
+ """The ``next_issue_url`` key, required; ``null`` or ``""`` is ``None``."""
1942
+ if "next_issue_url" not in p:
1943
+ raise ControlResultValidationError(
1944
+ "CONTROL_RESULT for UPDATE_EPIC missing required field "
1945
+ "'next_issue_url' (use null when the epic is complete)"
1946
+ )
1947
+ nxt = p["next_issue_url"]
1948
+ if nxt is None or nxt == "":
1949
+ return None
1950
+ return validate_next_issue_url(nxt)
1951
+
1952
+
1953
+ @dataclass
1954
+ class UpdateEpicResult:
1955
+ next_issue_url: str | None
1956
+ # The new content of the EPIC's managed roadmap section, or ``None`` when
1957
+ # the agent returned none (absent, ``null`` or blank). Whether one is
1958
+ # required is the engine's decision (``workflow.epic_update_every``);
1959
+ # the parser only bounds and shapes it. It is the *content between* the
1960
+ # markers: the markers themselves are written by the controller.
1961
+ roadmap_section: str | None = None
1962
+ # The EPIC progress comment's text: required in a ``FULL`` result,
1963
+ # ``None`` in a re-request, which never carries progress (D4.7).
1964
+ progress: str | None = None
1965
+
1966
+ @classmethod
1967
+ def from_payload(
1968
+ cls, p: dict, request: UpdateEpicRequest = UpdateEpicRequest.FULL
1969
+ ) -> UpdateEpicResult:
1970
+ """Validate ``p`` against the schema of ``request``.
1971
+
1972
+ ``FULL`` ignores unknown keys, as every phase schema does; a
1973
+ re-request refuses any key outside its own schema, a ``null`` one
1974
+ included.
1975
+ """
1976
+ request = UpdateEpicRequest(request)
1977
+ if request != UpdateEpicRequest.FULL:
1978
+ _refuse_outside_re_request(p, request)
1979
+ section: str | None = _opt_str(p, "roadmap_section", "UPDATE_EPIC") or None
1980
+ if section is not None:
1981
+ section = validate_roadmap_section(section)
1982
+ elif request == UpdateEpicRequest.ROADMAP:
1983
+ raise ControlResultValidationError(
1984
+ "CONTROL_RESULT for UPDATE_EPIC missing required field 'roadmap_section' "
1985
+ "(this re-request asks only for the roadmap section)"
1986
+ )
1987
+ nxt = None if request == UpdateEpicRequest.ROADMAP else _next_issue_url(p)
1988
+ progress = None
1989
+ if request == UpdateEpicRequest.FULL:
1990
+ progress = validate_progress_text(_req_str(p, "progress", "UPDATE_EPIC"))
1991
+ return cls(next_issue_url=nxt, roadmap_section=section, progress=progress)
1992
+
1993
+
1994
+ # -- LOCAL-mode typed models ----------------------------------------------
1995
+ # A local phase has no PR, no comment and no follow-up Issue, so it gets its
1996
+ # own small shapes instead of remote fields stuffed with empty strings or
1997
+ # fabricated URLs. Everything the controller can observe itself (HEAD, the
1998
+ # changed-file list, the fingerprint) is observed, not reported; the agent
1999
+ # reports only what it alone knows, plus the one or two claims worth
2000
+ # cross-checking against the controller's own observation.
2001
+
2002
+
2003
+ def _opt_str_list(payload: dict, key: str, phase: str) -> list[str]:
2004
+ raw = payload.get(key, [])
2005
+ if raw is None:
2006
+ return []
2007
+ if not isinstance(raw, list) or not all(isinstance(x, str) for x in raw):
2008
+ raise ControlResultValidationError(f"{phase}: field {key!r} must be a list of strings")
2009
+ return [x.strip() for x in raw if x.strip()]
2010
+
2011
+
2012
+ def _req_fingerprint(payload: dict, key: str, phase: str) -> str:
2013
+ v = _req_str(payload, key, phase)
2014
+ if not _FINGERPRINT_RE.match(v.lower()):
2015
+ raise ControlResultValidationError(
2016
+ f"{phase}: field {key!r} must be the 64-character workspace fingerprint the "
2017
+ f"controller provided, got {v!r}"
2018
+ )
2019
+ return v.lower()
2020
+
2021
+
2022
+ @dataclass
2023
+ class LocalAnalyzeExecuteResult:
2024
+ """What an implementation agent alone knows about a local run."""
2025
+
2026
+ summary: str
2027
+ changed_workspace: bool
2028
+ tests_attempted: list[str] = field(default_factory=list)
2029
+
2030
+ @classmethod
2031
+ def from_payload(cls, p: dict) -> LocalAnalyzeExecuteResult:
2032
+ ph = "ANALYZE_EXECUTE"
2033
+ return cls(
2034
+ summary=_req_str(p, "summary", ph),
2035
+ # Cross-checked against the controller's own before/after
2036
+ # fingerprint: a claim of "I changed nothing" over a modified
2037
+ # tree (or the reverse) is a rejected result, not a nuance.
2038
+ changed_workspace=_req_bool(p, "changed_workspace", ph),
2039
+ tests_attempted=_opt_str_list(p, "tests_attempted", ph),
2040
+ )
2041
+
2042
+
2043
+ @dataclass
2044
+ class LocalReviewResult:
2045
+ round: int
2046
+ reviewed_workspace_fingerprint: str
2047
+ needs_fix_round: bool
2048
+ findings: list[Finding] = field(default_factory=list)
2049
+ observations: list[str] = field(default_factory=list)
2050
+
2051
+ @classmethod
2052
+ def from_payload(cls, p: dict) -> LocalReviewResult:
2053
+ ph = "REVIEW"
2054
+ rnd = _req(p, "round", ph)
2055
+ if not isinstance(rnd, int) or isinstance(rnd, bool) or rnd < 1:
2056
+ raise ControlResultValidationError("'round' must be an int >= 1")
2057
+ needs_fix = _req_bool(p, "needs_fix_round", ph)
2058
+ findings = _parse_findings(p, rnd, needs_fix)
2059
+ return cls(
2060
+ round=rnd,
2061
+ # Binds the review to exactly the workspace the controller
2062
+ # fingerprinted before invoking the reviewer — the local
2063
+ # analogue of binding a remote review to the PR HEAD SHA.
2064
+ reviewed_workspace_fingerprint=_req_fingerprint(
2065
+ p, "reviewed_workspace_fingerprint", ph
2066
+ ),
2067
+ needs_fix_round=needs_fix,
2068
+ findings=findings,
2069
+ observations=_opt_str_list(p, "observations", ph),
2070
+ )
2071
+
2072
+
2073
+ @dataclass
2074
+ class LocalFindingResolution:
2075
+ finding_id: str
2076
+ resolution: str
2077
+ rationale: str = ""
2078
+
2079
+ @classmethod
2080
+ def from_payload(cls, raw: object, index: int) -> LocalFindingResolution:
2081
+ ph = "FIX"
2082
+ if not isinstance(raw, dict):
2083
+ raise ControlResultValidationError(
2084
+ f"{ph}: resolutions[{index}] must be an object with finding_id/resolution"
2085
+ )
2086
+ fid = _finding_id(raw, "finding_id", ph)
2087
+ res = _req_str(raw, "resolution", ph)
2088
+ if res not in LOCAL_FIX_RESOLUTIONS:
2089
+ raise ControlResultValidationError(
2090
+ f"{ph}: resolution for {fid} must be one of {LOCAL_FIX_RESOLUTIONS}, got {res!r}"
2091
+ + (
2092
+ " — local runs have no GitHub, so a finding cannot be deferred to a "
2093
+ "follow-up Issue; use 'unresolved' with a rationale instead"
2094
+ if res == "follow_up_created"
2095
+ else " — LOCAL runs perform no external writes; resolve as `unresolved` "
2096
+ "with a rationale instead"
2097
+ if res == "acceptance_action"
2098
+ else " — LOCAL runs have no GitHub and no operator actions; resolve as "
2099
+ "`unresolved` with a rationale instead"
2100
+ if res == "operator_acceptance"
2101
+ else ""
2102
+ )
2103
+ )
2104
+ subject = f"resolution for {fid}"
2105
+ rationale = _multi_line(
2106
+ _bounded(
2107
+ _opt_str(raw, "rationale", ph), ph, subject, "rationale", MAX_FIX_RATIONALE_CHARS
2108
+ ),
2109
+ ph,
2110
+ subject,
2111
+ "rationale",
2112
+ )
2113
+ # Both non-fix dispositions are only acceptable with real reasoning:
2114
+ # "won't fix" and "couldn't fix" are decisions a human has to judge.
2115
+ if res in ("no_change_with_rationale", "unresolved") and len(rationale) < (
2116
+ MIN_RATIONALE_CHARS
2117
+ ):
2118
+ raise ControlResultValidationError(
2119
+ f"{ph}: {fid} uses {res} but the rationale is missing or too short "
2120
+ f"(>= {MIN_RATIONALE_CHARS} chars of actual reasoning required)"
2121
+ )
2122
+ if "follow_up_issue_url" in raw:
2123
+ raise ControlResultValidationError(
2124
+ f"{ph}: {fid} carries 'follow_up_issue_url'; local runs never create "
2125
+ "GitHub follow-up issues"
2126
+ )
2127
+ return cls(finding_id=fid, resolution=res, rationale=rationale)
2128
+
2129
+ def to_dict(self) -> dict:
2130
+ return {
2131
+ "finding_id": self.finding_id,
2132
+ "resolution": self.resolution,
2133
+ "rationale": self.rationale,
2134
+ }
2135
+
2136
+ @property
2137
+ def is_resolved(self) -> bool:
2138
+ return self.resolution in ("fixed", "no_change_with_rationale")
2139
+
2140
+
2141
+ @dataclass
2142
+ class LocalFixResult:
2143
+ resolutions: list[LocalFindingResolution] = field(default_factory=list)
2144
+ changed_workspace: bool = False
2145
+
2146
+ @classmethod
2147
+ def from_payload(cls, p: dict) -> LocalFixResult:
2148
+ ph = "FIX"
2149
+ resolutions = [
2150
+ LocalFindingResolution.from_payload(r, i) for i, r in enumerate(_raw_resolutions(p))
2151
+ ]
2152
+ ids = [r.finding_id for r in resolutions]
2153
+ if len(set(ids)) != len(ids):
2154
+ raise ControlResultValidationError(f"duplicate resolution finding_ids: {ids}")
2155
+ # `from_payload` only runs for status "success" (see
2156
+ # `parse_control_result`), so a non-empty run-level blocker here is a
2157
+ # result that contradicts itself: the protocol already carries one,
2158
+ # as status "blocked" plus a message, and the engine routes that to
2159
+ # BLOCKED. Accepting it alongside "success" gave the field nowhere to
2160
+ # go -- it was parsed and dropped, so a FIX could report a blocker,
2161
+ # pass validation, be reviewed clean and reach DONE with the blocker
2162
+ # never seen by anyone. Rejecting it puts the claim back on the one
2163
+ # channel the controller acts on rather than quietly keeping both.
2164
+ blocker = p.get("blocked_reason")
2165
+ if isinstance(blocker, str) and blocker.strip():
2166
+ raise ControlResultValidationError(
2167
+ f"{ph}: status 'success' carries a non-empty 'blocked_reason' "
2168
+ f"({blocker.strip()[:200]!r}); a run-level blocker and a successful fix are "
2169
+ "not both true. Report the obstacle as status 'blocked' with a 'message', or "
2170
+ "report the finding it concerns with resolution 'unresolved' and a rationale"
2171
+ )
2172
+ if blocker is not None and not isinstance(blocker, str):
2173
+ raise ControlResultValidationError(f"{ph}: field 'blocked_reason' must be a string")
2174
+ return cls(
2175
+ resolutions=resolutions,
2176
+ changed_workspace=_req_bool(p, "changed_workspace", ph),
2177
+ )
2178
+
2179
+
2180
+ # Phases a LOCAL run can never reach; they belong to the GitHub lifecycle.
2181
+ LOCAL_UNSUPPORTED_PHASES = frozenset(
2182
+ {Phase.REPLAN_REEXECUTE, Phase.READY_FOR_MERGE, Phase.MERGE, Phase.UPDATE_EPIC}
2183
+ )
2184
+
2185
+
2186
+ def _validate_local_phase(phase: Phase, payload: dict) -> None:
2187
+ if phase == Phase.ANALYZE_EXECUTE:
2188
+ LocalAnalyzeExecuteResult.from_payload(payload)
2189
+ elif phase == Phase.REVIEW:
2190
+ LocalReviewResult.from_payload(payload)
2191
+ elif phase == Phase.FIX:
2192
+ LocalFixResult.from_payload(payload)
2193
+ elif phase in LOCAL_UNSUPPORTED_PHASES:
2194
+ raise ControlResultValidationError(
2195
+ f"phase {phase.value} belongs to the REMOTE (GitHub) workflow and is never "
2196
+ "executed by a local run"
2197
+ )
2198
+ else:
2199
+ raise ControlResultValidationError(
2200
+ f"phase {phase.value} is executed by the controller and never accepts an "
2201
+ "agent CONTROL_RESULT"
2202
+ )
2203
+
2204
+
2205
+ def validate_for_phase(
2206
+ phase: Phase,
2207
+ payload: dict,
2208
+ mode: WorkflowMode = WorkflowMode.REMOTE,
2209
+ *,
2210
+ update_epic_request: UpdateEpicRequest = UpdateEpicRequest.FULL,
2211
+ ) -> None:
2212
+ """Enforce the per-phase required-fields schema (raises on violation).
2213
+
2214
+ ``update_epic_request`` selects the UPDATE_EPIC schema; other phases
2215
+ ignore it.
2216
+ """
2217
+ if mode == WorkflowMode.LOCAL:
2218
+ _validate_local_phase(phase, payload)
2219
+ return
2220
+ if phase == Phase.ANALYZE_EXECUTE:
2221
+ AnalyzeExecuteResult.from_payload(payload)
2222
+ elif phase == Phase.REVIEW:
2223
+ ReviewResult.from_payload(payload)
2224
+ elif phase == Phase.FIX:
2225
+ FixResult.from_payload(payload)
2226
+ elif phase == Phase.REPLAN_REEXECUTE:
2227
+ ReplanReexecuteResult.from_payload(payload)
2228
+ elif phase == Phase.UPDATE_EPIC:
2229
+ UpdateEpicResult.from_payload(payload, update_epic_request)
2230
+ elif phase in NON_AGENT_PHASES:
2231
+ raise ControlResultValidationError(
2232
+ f"phase {phase.value} is executed by the controller and never accepts an "
2233
+ "agent CONTROL_RESULT"
2234
+ )