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
autoforge/claims.py ADDED
@@ -0,0 +1,726 @@
1
+ """Durable GitHub claims: the one identity contract for controller-adoptable writes.
2
+
3
+ An agent's GitHub write (an implementation PR, a review comment, an EPIC
4
+ progress comment, a follow-up issue, an acceptance comment, an operator
5
+ comment) is something a later controller entry
6
+ must be able to *find again* and something the post-agent read-back must be
7
+ able to *hold the agent to*. Both need the same answer to the same question:
8
+ "which object is this phase's write?". This module owns that answer.
9
+
10
+ The identity of such a write is a **marker**: an HTML comment
11
+ ``<!-- <name>: <json object> -->`` that the controller renders into the
12
+ agent's prompt and the agent copies verbatim into the object. Each marker
13
+ kind has exactly one typed payload (:class:`ImplementationClaim`,
14
+ :class:`ReviewClaim`, :class:`ProgressClaim`, :class:`FollowUpClaim`,
15
+ :class:`AcceptanceClaim`, :class:`OperatorCommentClaim`), one
16
+ exact-schema decoder, one renderer, and one ``key``: the value two claims
17
+ are compared by. Nothing outside this module parses a marker.
18
+
19
+ The contract every consumer gets from :func:`collect`:
20
+
21
+ - **Every marker is classified, none is skipped.** A marker of the kind
22
+ whose payload is not exactly the documented shape is a *defect* of the
23
+ object carrying it, not "no marker". A defect anywhere in a collection
24
+ makes every question about that collection inconclusive
25
+ (:class:`ClaimConflictError`), because "no object claims this key" cannot
26
+ be proven while an object carries a claim that could not be read.
27
+ - **One object, one compatible identity.** A PR carries at most one
28
+ implementation marker, a comment at most one review, progress,
29
+ acceptance or operator-comment marker,
30
+ an issue any number of follow-up markers with distinct keys. An object
31
+ that publishes more is defective, whatever the extra marker names: a
32
+ provenance that names two things proves neither.
33
+ - **Cardinality is explicit.** :meth:`Claimants.at_most_one` (an entry:
34
+ nothing yet, or the one write to adopt) and :meth:`Claimants.exactly_one`
35
+ (a read-back: the write the agent claims must exist and be the only one).
36
+ There is no "not more than one".
37
+ - **Identity is GitHub's.** URLs inside payloads are parsed with the typed
38
+ parsers and compared by :attr:`GitHubRef.identity` (owner and repository
39
+ case-insensitive, plus number), never as strings. They are bounded by
40
+ ``MAX_URL_CHARS`` before they are parsed, exactly as ``CONTROL_RESULT``
41
+ URLs are: a marker is untrusted text, and the defect it produces is
42
+ persisted as a block reason, so an oversized value is refused by its
43
+ length and never quoted.
44
+
45
+ ``replan_txn`` keeps its own transaction marker: that one is bound to a
46
+ controller-generated transaction id and a PR-number watermark, and its
47
+ acceptance rules (source PR, pre-existing PRs) are a different protocol.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import json
53
+ import re
54
+ from collections.abc import Callable, Hashable, Iterable, Sequence
55
+ from dataclasses import dataclass
56
+ from typing import Generic, Protocol, TypeVar
57
+
58
+ from .errors import ClaimConflictError, ConfigurationError
59
+ from .result_parser import (
60
+ CONTROLLER_MARKER_OPEN_RE,
61
+ FINDING_ID_RE,
62
+ MAX_FINDING_ID_CHARS,
63
+ MAX_URL_CHARS,
64
+ )
65
+ from .validation import GitHubIssueRef, GitHubPullRequestRef, parse_issue_url, parse_pr_url
66
+
67
+ _FULL_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
68
+ # A git refname component is at most 255 bytes and a branch name a few of
69
+ # them; the bound only keeps an oversized marker value out of a block reason.
70
+ MAX_BASE_REF_CHARS = 512
71
+ _BASE_REF_FORBIDDEN_RE = re.compile(r"[\s\x00-\x1f\x7f]")
72
+
73
+
74
+ class Marked(Protocol):
75
+ """A GitHub object a marker can live in: it has a URL and a body."""
76
+
77
+ @property
78
+ def url(self) -> str: ...
79
+
80
+ @property
81
+ def body(self) -> str: ...
82
+
83
+
84
+ C = TypeVar("C", bound="Claim")
85
+ O = TypeVar("O", bound=Marked) # noqa: E741 - the object a claim is found in
86
+
87
+
88
+ class Claim(Protocol):
89
+ @property
90
+ def key(self) -> Hashable: ...
91
+
92
+
93
+ # -- payloads ------------------------------------------------------------------
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ImplementationClaim:
98
+ """``ai-implementation``: the PR body names the issue it implements."""
99
+
100
+ issue: GitHubIssueRef
101
+
102
+ @property
103
+ def key(self) -> Hashable:
104
+ return self.issue.identity
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class ReviewClaim:
109
+ """``ai-review-result``: a review comment names the revision its round decided on.
110
+
111
+ The revision is the PR diff: the reviewed HEAD against the base branch
112
+ the PR targeted when the round was bound, from the merge base the two
113
+ had at that moment. All three are part of the key, so a comment posted
114
+ while the PR targeted another base, or while the base branch was
115
+ another history under the same name (#96), is not this round's,
116
+ exactly as one posted at another HEAD is not, and is never adopted by
117
+ a later entry as a review of the diff the PR shows now.
118
+
119
+ ``reviewed_base_ref`` is ``None`` for a marker written before the base
120
+ was part of the schema, and ``reviewed_merge_base_sha`` is ``None`` for
121
+ one written before the merge base was. Such a comment reviewed a diff
122
+ nobody recorded, so it matches no bound key and is never adopted; it
123
+ is not a defect either, because a mid-flight PR carries one from every
124
+ earlier round and a defect anywhere would block every later entry on
125
+ that PR.
126
+ """
127
+
128
+ round: int
129
+ reviewed_head_sha: str # lowercased
130
+ needs_fix_round: bool
131
+ finding_ids: tuple[str, ...] | None = None
132
+ reviewed_base_ref: str | None = None
133
+ reviewed_merge_base_sha: str | None = None # lowercased
134
+
135
+ @property
136
+ def key(self) -> Hashable:
137
+ return (
138
+ self.round,
139
+ self.reviewed_head_sha,
140
+ self.reviewed_base_ref,
141
+ self.reviewed_merge_base_sha,
142
+ )
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class ProgressClaim:
147
+ """``ai-epic-progress``: an EPIC comment names the finished issue and merged PR."""
148
+
149
+ issue: GitHubIssueRef
150
+ pr: GitHubPullRequestRef
151
+
152
+ @property
153
+ def key(self) -> Hashable:
154
+ return (self.issue.identity, self.pr.identity)
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class AcceptanceClaim:
159
+ """``ai-acceptance``: a comment on the run's issue names the finding it is evidence for.
160
+
161
+ The finding is one round's finding of one PR, so the key is the issue
162
+ the comment is on, the PR, the round and the finding id: a comment
163
+ posted for another round, another PR or another issue is never this
164
+ finding's evidence (#211).
165
+ """
166
+
167
+ issue: GitHubIssueRef
168
+ pr: GitHubPullRequestRef
169
+ round: int
170
+ finding_id: str
171
+
172
+ @property
173
+ def key(self) -> Hashable:
174
+ return (self.issue.identity, self.pr.identity, self.round, self.finding_id)
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class OperatorCommentClaim:
179
+ """``ai-operator-comment``: a comment on another issue names the finding it is evidence for.
180
+
181
+ The operator, never the controller, posts it (K10, #243). The key is the
182
+ run's issue, the PR, the round, the finding id and the ``target`` issue
183
+ the comment is meant for, so two rounds, two PRs or two targets of one
184
+ finding never share a key. The issue the comment is *on* is not part of
185
+ the key: a payload posted on the wrong issue still carries its key, and
186
+ the controller reads it there as a misplaced payload.
187
+ """
188
+
189
+ issue: GitHubIssueRef
190
+ pr: GitHubPullRequestRef
191
+ round: int
192
+ finding_id: str
193
+ target: GitHubIssueRef
194
+
195
+ @property
196
+ def key(self) -> Hashable:
197
+ return (
198
+ self.issue.identity,
199
+ self.pr.identity,
200
+ self.round,
201
+ self.finding_id,
202
+ self.target.identity,
203
+ )
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class FollowUpClaim:
208
+ """``ai-follow-up``: a follow-up issue names the PR and finding it defers."""
209
+
210
+ pr: GitHubPullRequestRef
211
+ finding_id: str
212
+
213
+ @property
214
+ def key(self) -> Hashable:
215
+ return (self.pr.identity, self.finding_id)
216
+
217
+
218
+ # -- exact-schema decoding ---------------------------------------------------------
219
+
220
+
221
+ def _exact_keys(payload: dict, required: tuple[str, ...], optional: tuple[str, ...] = ()) -> None:
222
+ keys = set(payload)
223
+ missing = sorted(set(required) - keys)
224
+ unknown = sorted(keys - set(required) - set(optional))
225
+ if missing:
226
+ raise ValueError(f"missing key(s) {', '.join(missing)}")
227
+ if unknown:
228
+ raise ValueError(f"unknown key(s) {', '.join(unknown)}")
229
+
230
+
231
+ def _url(payload: dict, key: str, what: str) -> str:
232
+ """The URL string under ``key``, bounded before any parser can quote it.
233
+
234
+ The ``MAX_URL_CHARS`` rule of ``result_parser``: the typed parsers quote
235
+ the value in their error, and a marker's error reaches a block reason
236
+ and the run log, so an oversized value is refused by its length alone
237
+ and never quoted. A marker is GitHub-authored text like a
238
+ ``CONTROL_RESULT`` is agent-authored text; both cross the same boundary.
239
+ """
240
+ value = payload[key]
241
+ if not isinstance(value, str):
242
+ raise ValueError(f"{key} must be a GitHub {what} URL string")
243
+ if len(value) > MAX_URL_CHARS:
244
+ raise ValueError(
245
+ f"{key} is {len(value)} characters; a GitHub {what} URL is at most {MAX_URL_CHARS}"
246
+ )
247
+ return value
248
+
249
+
250
+ def _issue_ref(payload: dict, key: str) -> GitHubIssueRef:
251
+ value = _url(payload, key, "issue")
252
+ try:
253
+ return parse_issue_url(value)
254
+ except ConfigurationError as exc:
255
+ raise ValueError(f"{key} is not a GitHub issue URL ({exc})") from exc
256
+
257
+
258
+ def _pr_ref(payload: dict, key: str) -> GitHubPullRequestRef:
259
+ value = _url(payload, key, "pull request")
260
+ try:
261
+ return parse_pr_url(value)
262
+ except ConfigurationError as exc:
263
+ raise ValueError(f"{key} is not a GitHub pull request URL ({exc})") from exc
264
+
265
+
266
+ def _base_ref(value: object) -> str:
267
+ """The base branch a review marker names, as GitHub reports it.
268
+
269
+ Compared for equality with the ``baseRefName`` the controller bound
270
+ right before the round, never interpreted, so the only shape rule is
271
+ the one git imposes on every refname (non-empty, no whitespace, no
272
+ control character) plus a bound before the value can be quoted. A
273
+ comment delimiter inside the name is not refused here: git allows it,
274
+ and :func:`marker_json` keeps it out of the rendered marker text.
275
+ """
276
+ if not isinstance(value, str):
277
+ raise ValueError("reviewed_base_ref must be a branch name string")
278
+ if not value or len(value) > MAX_BASE_REF_CHARS:
279
+ raise ValueError(
280
+ f"reviewed_base_ref is {len(value)} characters; a branch name is 1 to "
281
+ f"{MAX_BASE_REF_CHARS}"
282
+ )
283
+ if _BASE_REF_FORBIDDEN_RE.search(value):
284
+ raise ValueError(
285
+ f"reviewed_base_ref {value!r} is not a branch name (whitespace or control character)"
286
+ )
287
+ return value
288
+
289
+
290
+ def _finding_id(value: object, what: str, round_: int | None = None) -> str:
291
+ """The finding id rule of ``result_parser``, applied to marker data.
292
+
293
+ Length is checked before shape so the reason never quotes an unbounded
294
+ string; the id is quoted with ``repr`` so control characters cannot
295
+ reach a block reason or a prompt unescaped.
296
+ """
297
+ if not isinstance(value, str):
298
+ raise ValueError(f"{what} must be a string")
299
+ if len(value) > MAX_FINDING_ID_CHARS:
300
+ raise ValueError(f"{what} is longer than {MAX_FINDING_ID_CHARS} characters")
301
+ match = FINDING_ID_RE.match(value)
302
+ if match is None:
303
+ raise ValueError(f"{what} {value!r} is not a finding id of the form R<round>-F<n>")
304
+ if round_ is not None and int(match.group("round")) != round_:
305
+ raise ValueError(f"{what} {value!r} does not belong to round {round_}")
306
+ return value
307
+
308
+
309
+ def _decode_implementation(payload: dict) -> ImplementationClaim:
310
+ _exact_keys(payload, ("issue",))
311
+ return ImplementationClaim(issue=_issue_ref(payload, "issue"))
312
+
313
+
314
+ def _decode_review(payload: dict) -> ReviewClaim:
315
+ _exact_keys(
316
+ payload,
317
+ ("round", "reviewed_head_sha", "needs_fix_round"),
318
+ ("finding_ids", "reviewed_base_ref", "reviewed_merge_base_sha"),
319
+ )
320
+ round_ = payload["round"]
321
+ if not isinstance(round_, int) or isinstance(round_, bool) or round_ < 1:
322
+ raise ValueError(f"round must be a JSON integer >= 1, got {round_!r}")
323
+ sha = payload["reviewed_head_sha"]
324
+ if not isinstance(sha, str) or not _FULL_SHA_RE.match(sha):
325
+ raise ValueError(f"reviewed_head_sha must be a 40-character git SHA, got {sha!r}")
326
+ needs_fix = payload["needs_fix_round"]
327
+ if not isinstance(needs_fix, bool):
328
+ raise ValueError(f"needs_fix_round must be a JSON boolean, got {needs_fix!r}")
329
+ finding_ids: tuple[str, ...] | None = None
330
+ if "finding_ids" in payload:
331
+ raw = payload["finding_ids"]
332
+ if not isinstance(raw, list):
333
+ raise ValueError("finding_ids must be a JSON array")
334
+ ids = [_finding_id(v, "finding_ids entry", round_) for v in raw]
335
+ if len(set(ids)) != len(ids):
336
+ raise ValueError("finding_ids repeats an id")
337
+ finding_ids = tuple(ids)
338
+ base_ref: str | None = None
339
+ if "reviewed_base_ref" in payload:
340
+ base_ref = _base_ref(payload["reviewed_base_ref"])
341
+ merge_base: str | None = None
342
+ if "reviewed_merge_base_sha" in payload:
343
+ raw_base = payload["reviewed_merge_base_sha"]
344
+ if not isinstance(raw_base, str) or not _FULL_SHA_RE.match(raw_base):
345
+ raise ValueError(
346
+ f"reviewed_merge_base_sha must be a 40-character git SHA, got {raw_base!r}"
347
+ )
348
+ merge_base = raw_base.lower()
349
+ return ReviewClaim(
350
+ round=round_,
351
+ reviewed_head_sha=sha.lower(),
352
+ needs_fix_round=needs_fix,
353
+ finding_ids=finding_ids,
354
+ reviewed_base_ref=base_ref,
355
+ reviewed_merge_base_sha=merge_base,
356
+ )
357
+
358
+
359
+ def _decode_progress(payload: dict) -> ProgressClaim:
360
+ _exact_keys(payload, ("issue", "pr"))
361
+ return ProgressClaim(issue=_issue_ref(payload, "issue"), pr=_pr_ref(payload, "pr"))
362
+
363
+
364
+ def _decode_acceptance(payload: dict) -> AcceptanceClaim:
365
+ _exact_keys(payload, ("finding_id", "issue", "pr", "round"))
366
+ round_ = payload["round"]
367
+ if not isinstance(round_, int) or isinstance(round_, bool) or round_ < 1:
368
+ raise ValueError(f"round must be a JSON integer >= 1, got {round_!r}")
369
+ return AcceptanceClaim(
370
+ issue=_issue_ref(payload, "issue"),
371
+ pr=_pr_ref(payload, "pr"),
372
+ round=round_,
373
+ finding_id=_finding_id(payload["finding_id"], "finding_id", round_),
374
+ )
375
+
376
+
377
+ def _decode_operator_comment(payload: dict) -> OperatorCommentClaim:
378
+ _exact_keys(payload, ("finding_id", "issue", "pr", "round", "target"))
379
+ round_ = payload["round"]
380
+ if not isinstance(round_, int) or isinstance(round_, bool) or round_ < 1:
381
+ raise ValueError(f"round must be a JSON integer >= 1, got {round_!r}")
382
+ return OperatorCommentClaim(
383
+ issue=_issue_ref(payload, "issue"),
384
+ pr=_pr_ref(payload, "pr"),
385
+ round=round_,
386
+ finding_id=_finding_id(payload["finding_id"], "finding_id", round_),
387
+ target=_issue_ref(payload, "target"),
388
+ )
389
+
390
+
391
+ def _decode_follow_up(payload: dict) -> FollowUpClaim:
392
+ _exact_keys(payload, ("finding_id", "pr"))
393
+ return FollowUpClaim(
394
+ pr=_pr_ref(payload, "pr"), finding_id=_finding_id(payload["finding_id"], "finding_id")
395
+ )
396
+
397
+
398
+ # -- kinds ----------------------------------------------------------------------------
399
+
400
+ # The name prefix ``CONTROLLER_MARKER_OPEN_RE`` ends with; a kind's pattern
401
+ # is that opening followed by the rest of its name.
402
+ _NAME_PREFIX = "ai-"
403
+
404
+
405
+ @dataclass(frozen=True)
406
+ class MarkerKind(Generic[C]):
407
+ """One marker kind: its name, its exact-schema decoder, and its object rule."""
408
+
409
+ name: str
410
+ decode: Callable[[dict], C]
411
+ several_per_object: bool # distinct keys may share one object (follow-ups)
412
+
413
+ def __post_init__(self) -> None:
414
+ # Every kind opens as ``CONTROLLER_MARKER_OPEN_RE``: that shared
415
+ # opening is what the result parser refuses in agent text that the
416
+ # controller writes into an issue body verbatim, so a kind outside it
417
+ # would be scanned but not refused.
418
+ if not self.name.startswith(_NAME_PREFIX):
419
+ raise ConfigurationError(f"marker kind {self.name!r} must start with {_NAME_PREFIX!r}")
420
+
421
+ @property
422
+ def pattern(self) -> re.Pattern[str]:
423
+ # Every *complete* comment bearing the name, whatever the payload
424
+ # looks like, so a broken payload is classified rather than passed
425
+ # over. The payload may not contain a comment delimiter: an
426
+ # unterminated marker is not a complete comment and must not swallow
427
+ # the body up to a later ``-->`` and hide a valid marker in the match.
428
+ # The opening is ``CONTROLLER_MARKER_OPEN_RE`` itself (not a copy of
429
+ # it) so the scanner and the result parser's refusal cannot drift.
430
+ #
431
+ # Cost is part of the contract: this pattern runs over every open
432
+ # issue body and every PR body the controller lists, which is text
433
+ # anyone can edit. It is linear in the body: the whitespace runs are
434
+ # possessive (``*+``, no backtracking into them) and no whitespace
435
+ # quantifier touches the lazy payload, so an unterminated marker
436
+ # followed by a long run of blanks is one failed pass, not a
437
+ # quadratic-or-worse search for a split between payload and blanks.
438
+ # :func:`scan` strips the payload instead.
439
+ rest = re.escape(self.name.removeprefix(_NAME_PREFIX))
440
+ return re.compile(
441
+ rf"{CONTROLLER_MARKER_OPEN_RE.pattern}{rest}\s*+:"
442
+ rf"(?P<payload>(?:(?!-->|<!--)[\s\S])*?)-->"
443
+ )
444
+
445
+ def render(self, payload: dict) -> str:
446
+ """The exact marker text for ``payload``; it round-trips through ``decode``."""
447
+ self.decode(payload) # a renderer that emits an undecodable marker is a bug
448
+ return f"<!-- {self.name}: {marker_json(payload)} -->"
449
+
450
+
451
+ def marker_json(value: object) -> str:
452
+ """``value`` as JSON text that can live inside an HTML comment.
453
+
454
+ A marker payload is JSON between ``<!--`` and ``-->``, and :func:`scan`
455
+ ends the payload at the first comment delimiter it meets, as an HTML
456
+ parser does. A payload string that contains one (``x-->y`` and ``a<!--b``
457
+ are valid git branch names, so a ``reviewed_base_ref`` can) would
458
+ truncate the marker it is part of and leave the scanner an unterminated
459
+ JSON string: the object then carries a *defect*, and no review of that
460
+ PR can ever be read back. ``<`` and ``>`` are therefore emitted as the
461
+ JSON escapes ``\\u003c`` and ``\\u003e``, which every JSON decoder
462
+ reads back to the same characters; both delimiters (and the ``--!>``
463
+ an HTML parser also treats as a close) contain one of them, so no
464
+ payload rendered here can end its own comment. The replacement is
465
+ textual and safe: ``json.dumps`` escapes non-ASCII by default, so the
466
+ two characters occur only as themselves, and only inside a string
467
+ literal, since neither is JSON structure.
468
+
469
+ This is the one JSON encoder for marker text: every renderer uses it.
470
+ """
471
+ return json.dumps(value, sort_keys=True).replace("<", "\\u003c").replace(">", "\\u003e")
472
+
473
+
474
+ IMPLEMENTATION: MarkerKind[ImplementationClaim] = MarkerKind(
475
+ "ai-implementation", _decode_implementation, several_per_object=False
476
+ )
477
+ REVIEW: MarkerKind[ReviewClaim] = MarkerKind("ai-review-result", _decode_review, False)
478
+ PROGRESS: MarkerKind[ProgressClaim] = MarkerKind("ai-epic-progress", _decode_progress, False)
479
+ FOLLOW_UP: MarkerKind[FollowUpClaim] = MarkerKind("ai-follow-up", _decode_follow_up, True)
480
+ ACCEPTANCE: MarkerKind[AcceptanceClaim] = MarkerKind("ai-acceptance", _decode_acceptance, False)
481
+ OPERATOR_COMMENT: MarkerKind[OperatorCommentClaim] = MarkerKind(
482
+ "ai-operator-comment", _decode_operator_comment, False
483
+ )
484
+
485
+
486
+ def render_implementation_marker(issue_url: str) -> str:
487
+ """The exact ``ai-implementation`` marker the issue's implementation PR carries."""
488
+ return IMPLEMENTATION.render({"issue": parse_issue_url(issue_url).canonical})
489
+
490
+
491
+ def render_progress_marker(issue_url: str, pr_url: str) -> str:
492
+ """The exact ``ai-epic-progress`` marker of one UPDATE_EPIC entry (issue, PR)."""
493
+ return PROGRESS.render(
494
+ {"issue": parse_issue_url(issue_url).canonical, "pr": parse_pr_url(pr_url).canonical}
495
+ )
496
+
497
+
498
+ def render_review_marker(
499
+ round_: int,
500
+ reviewed_head_sha: str,
501
+ reviewed_base_ref: str,
502
+ reviewed_merge_base_sha: str,
503
+ needs_fix_round: bool,
504
+ finding_ids: Sequence[str],
505
+ ) -> str:
506
+ """The exact ``ai-review-result`` marker of one round's review comment."""
507
+ return REVIEW.render(
508
+ {
509
+ "finding_ids": list(finding_ids),
510
+ "needs_fix_round": needs_fix_round,
511
+ "reviewed_base_ref": reviewed_base_ref,
512
+ "reviewed_head_sha": reviewed_head_sha.lower(),
513
+ "reviewed_merge_base_sha": reviewed_merge_base_sha.lower(),
514
+ "round": round_,
515
+ }
516
+ )
517
+
518
+
519
+ def render_acceptance_marker(issue_url: str, pr_url: str, round_: int, finding_id: str) -> str:
520
+ """The exact ``ai-acceptance`` marker of one acceptance comment (issue, PR, round, finding)."""
521
+ return ACCEPTANCE.render(
522
+ {
523
+ "finding_id": finding_id,
524
+ "issue": parse_issue_url(issue_url).canonical,
525
+ "pr": parse_pr_url(pr_url).canonical,
526
+ "round": round_,
527
+ }
528
+ )
529
+
530
+
531
+ def render_operator_comment_marker(
532
+ issue_url: str, pr_url: str, round_: int, finding_id: str, target_url: str
533
+ ) -> str:
534
+ """The exact ``ai-operator-comment`` marker of one operator comment (K10, #243)."""
535
+ return OPERATOR_COMMENT.render(
536
+ {
537
+ "finding_id": finding_id,
538
+ "issue": parse_issue_url(issue_url).canonical,
539
+ "pr": parse_pr_url(pr_url).canonical,
540
+ "round": round_,
541
+ "target": parse_issue_url(target_url).canonical,
542
+ }
543
+ )
544
+
545
+
546
+ def render_follow_up_marker(pr_url: str, finding_id: str) -> str:
547
+ """The exact ``ai-follow-up`` marker of one deferral (PR, finding id)."""
548
+ return FOLLOW_UP.render({"finding_id": finding_id, "pr": parse_pr_url(pr_url).canonical})
549
+
550
+
551
+ # -- scanning one object ------------------------------------------------------------
552
+
553
+
554
+ @dataclass(frozen=True)
555
+ class Scan(Generic[C]):
556
+ """Every marker of one kind in one body: the usable claims and the defects."""
557
+
558
+ claims: tuple[C, ...]
559
+ defects: tuple[str, ...] # human-readable reasons; non-empty means "defective object"
560
+
561
+
562
+ def scan(kind: MarkerKind[C], body: str) -> Scan[C]:
563
+ """Classify every complete ``kind`` marker in ``body``.
564
+
565
+ A defect is any complete marker whose payload is not exactly the kind's
566
+ schema (unparsable text, a JSON scalar or array, a missing, unknown or
567
+ mistyped key), a second marker on an object that may carry one, or a
568
+ repeated key on an object that may carry several. Defects are reported,
569
+ never skipped: an object that tried to carry a claim and failed is
570
+ evidence of a botched write, not of an unrelated object.
571
+ """
572
+ claims: list[C] = []
573
+ defects: list[str] = []
574
+ for match in kind.pattern.finditer(body or ""):
575
+ raw = match.group("payload").strip()
576
+ try:
577
+ payload = json.loads(raw)
578
+ except ValueError as exc:
579
+ defects.append(f"{kind.name} marker payload is not valid JSON ({exc})")
580
+ continue
581
+ if not isinstance(payload, dict):
582
+ defects.append(f"{kind.name} marker payload must be a JSON object")
583
+ continue
584
+ try:
585
+ claims.append(kind.decode(payload))
586
+ except ValueError as exc:
587
+ defects.append(f"{kind.name} marker payload is invalid ({exc})")
588
+ if not kind.several_per_object and len(claims) > 1:
589
+ defects.append(
590
+ f"carries {len(claims)} {kind.name} markers; an object publishes exactly one "
591
+ "and a provenance that names two things proves neither"
592
+ )
593
+ elif kind.several_per_object:
594
+ keys = [c.key for c in claims]
595
+ if len(set(keys)) != len(keys):
596
+ defects.append(f"carries the same {kind.name} marker more than once")
597
+ return Scan(tuple(claims), tuple(defects))
598
+
599
+
600
+ # -- collections and cardinality -------------------------------------------------------
601
+
602
+
603
+ @dataclass(frozen=True)
604
+ class Holder(Generic[O, C]):
605
+ """One object and the one claim of the kind it makes for the key asked about."""
606
+
607
+ obj: O
608
+ claim: C
609
+
610
+
611
+ @dataclass(frozen=True)
612
+ class Claimants(Generic[O, C]):
613
+ """The objects of a collection claiming one key, with explicit cardinality.
614
+
615
+ ``defects`` are the defective objects of the *whole* collection, not only
616
+ of the holders: while any object's marker of this kind could not be
617
+ read, "nothing claims this key" is not provable.
618
+ """
619
+
620
+ kind: MarkerKind[C]
621
+ what: str # "issue #12", "round 2 at HEAD ab12...", for messages
622
+ holders: tuple[Holder[O, C], ...]
623
+ defects: tuple[str, ...]
624
+ noun: str # "open PR", "comment", "open issue"
625
+
626
+ def _refuse_defects(self) -> None:
627
+ if self.defects:
628
+ raise ClaimConflictError(
629
+ f"cannot establish which {self.noun} carries the {self.kind.name} marker for "
630
+ f"{self.what}: {'; '.join(self.defects)}"
631
+ )
632
+
633
+ def _refuse_many(self) -> None:
634
+ if len(self.holders) > 1:
635
+ urls = ", ".join(sorted(h.obj.url for h in self.holders))
636
+ raise ClaimConflictError(
637
+ f"{len(self.holders)} {self.noun}s carry the {self.kind.name} marker for "
638
+ f"{self.what} ({urls})"
639
+ )
640
+
641
+ def at_most_one(self) -> Holder[O, C] | None:
642
+ """An entry's question: nothing yet (launch), or the one write to adopt."""
643
+ self._refuse_defects()
644
+ self._refuse_many()
645
+ return self.holders[0] if self.holders else None
646
+
647
+ def exactly_one(self) -> Holder[O, C]:
648
+ """A read-back's question: the write exists and is the only one."""
649
+ self._refuse_defects()
650
+ self._refuse_many()
651
+ if not self.holders:
652
+ raise ClaimConflictError(
653
+ f"no {self.noun} carries the {self.kind.name} marker for {self.what}"
654
+ )
655
+ return self.holders[0]
656
+
657
+
658
+ @dataclass(frozen=True)
659
+ class Collection(Generic[O, C]):
660
+ """Every claim of one kind across a collection of objects, scanned once."""
661
+
662
+ kind: MarkerKind[C]
663
+ noun: str
664
+ holders: tuple[Holder[O, C], ...]
665
+ defects: tuple[str, ...]
666
+
667
+ def claimants(self, key: Hashable, what: str) -> Claimants[O, C]:
668
+ mine = tuple(h for h in self.holders if h.claim.key == key)
669
+ return Claimants(self.kind, what, mine, self.defects, self.noun)
670
+
671
+ def grouped(
672
+ self, select: Callable[[C], bool], what: str
673
+ ) -> dict[Hashable, tuple[Holder[O, C], ...]]:
674
+ """Holders whose claim ``select`` accepts, per key; inconclusive on defects."""
675
+ if self.defects:
676
+ raise ClaimConflictError(
677
+ f"cannot establish which {self.noun}s carry {self.kind.name} markers for "
678
+ f"{what}: {'; '.join(self.defects)}"
679
+ )
680
+ out: dict[Hashable, list[Holder[O, C]]] = {}
681
+ for h in self.holders:
682
+ if select(h.claim):
683
+ out.setdefault(h.claim.key, []).append(h)
684
+ return {k: tuple(v) for k, v in out.items()}
685
+
686
+
687
+ def collect(kind: MarkerKind[C], objects: Iterable[O], noun: str) -> Collection[O, C]:
688
+ """Scan every object once; the result answers every claimant question on it."""
689
+ holders: list[Holder[O, C]] = []
690
+ defects: list[str] = []
691
+ for obj in objects:
692
+ result = scan(kind, obj.body)
693
+ for reason in result.defects:
694
+ defects.append(f"{noun} {obj.url}: {reason}")
695
+ holders.extend(Holder(obj, claim) for claim in result.claims)
696
+ return Collection(kind, noun, tuple(holders), tuple(defects))
697
+
698
+
699
+ __all__ = [
700
+ "ACCEPTANCE",
701
+ "FOLLOW_UP",
702
+ "IMPLEMENTATION",
703
+ "MAX_BASE_REF_CHARS",
704
+ "OPERATOR_COMMENT",
705
+ "PROGRESS",
706
+ "REVIEW",
707
+ "AcceptanceClaim",
708
+ "Claimants",
709
+ "Collection",
710
+ "FollowUpClaim",
711
+ "Holder",
712
+ "ImplementationClaim",
713
+ "MarkerKind",
714
+ "OperatorCommentClaim",
715
+ "ProgressClaim",
716
+ "ReviewClaim",
717
+ "Scan",
718
+ "collect",
719
+ "render_acceptance_marker",
720
+ "render_follow_up_marker",
721
+ "render_implementation_marker",
722
+ "render_operator_comment_marker",
723
+ "render_progress_marker",
724
+ "render_review_marker",
725
+ "scan",
726
+ ]