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,404 @@
1
+ """Loop bounds for the REVIEW <-> FIX cycle (pure, easily tested logic).
2
+
3
+ The engine records one entry per *completed* review round of the current PR
4
+ in ``state.review_history`` (see :func:`review_record`) and asks these
5
+ functions whether the loop must stop:
6
+
7
+ - :func:`round_cap_reason` — the hard cap ``workflow.max_review_rounds``.
8
+ - :func:`stagnation_reason` — consecutive rounds with findings that show no
9
+ progress: identical required resolutions (normalised text) for
10
+ ``workflow.stagnation_identical_rounds`` rounds, or an unchanged finding
11
+ count for ``workflow.stagnation_unchanged_count_rounds`` rounds *while at
12
+ least one required resolution recurs inside that window* (the same demand
13
+ keeps coming back, e.g. an A/B/A ping-pong). Rounds whose findings are all
14
+ new are progress, not stagnation, however many of them there are: a picky
15
+ reviewer that raises one fresh finding per round is bounded by the hard
16
+ cap only. A clean or stale round breaks the streak. Both rules compare
17
+ rounds *against each other*, so a window of 1 has no meaning and the
18
+ configuration rejects it: the settings are 0 (disabled) or >= 2.
19
+ - :func:`step_budget_reason` — the cumulative ``workflow.max_total_steps``
20
+ budget, measured on the persisted ``step_count`` so ``resume`` continues
21
+ the same budget instead of starting a new one.
22
+
23
+ :func:`review_record` keeps that history bounded, so it also records when a
24
+ round's findings had to be dropped or clipped; :func:`truncated_evidence_rounds`
25
+ reports those rounds so the replan policy can refuse to discard a PR whose
26
+ findings the controller can no longer reproduce in full.
27
+
28
+ Every function returns a human-readable reason (non-empty -> BLOCKED) or
29
+ ``""``. Nothing here reads GitHub or invokes an agent.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import re
36
+
37
+ from .errors import StateError
38
+ from .redaction import MAX_GROWTH_FACTOR
39
+ from .result_parser import MAX_FINDING_RESOLUTION_CHARS
40
+
41
+ _WS_RE = re.compile(r"\s+")
42
+ # Per-round bounds on the persisted findings. Both are at or above what the
43
+ # parser lets a REVIEW result carry (``result_parser.MAX_FINDINGS_PER_REVIEW``,
44
+ # ``MAX_FINDING_RESOLUTION_CHARS``), so a round the controller accepted is
45
+ # always retained complete and the clipping below only ever applies to state
46
+ # that did not come through the parser. The count bound is an independent
47
+ # number above the parser's; ``tests/test_result_parser.py`` pins that
48
+ # relation. The resolution bound is *computed* from the parser bound and
49
+ # ``redaction.MAX_GROWTH_FACTOR`` (2000 x 3 = 6000) rather than restated: the
50
+ # engine redacts a finding between the parser and this record, and redaction
51
+ # can lengthen a text (a one-character secret becomes a 14-character marker),
52
+ # so a bound equal to the parser's would clip an accepted resolution and mark
53
+ # the round truncated, which refuses every later replan of the PR (#33).
54
+ # Deriving it means neither input can drift away from it unnoticed.
55
+ MAX_PERSISTED_FINDINGS_PER_ROUND = 100
56
+ MAX_REQUIRED_RESOLUTION_CHARS = MAX_FINDING_RESOLUTION_CHARS * MAX_GROWTH_FACTOR
57
+
58
+ # ``result`` values recorded per review round.
59
+ RESULT_NEEDS_FIX = "needs_fix"
60
+ RESULT_CLEAN = "clean"
61
+ RESULT_STALE = "stale"
62
+ RESULTS = (RESULT_NEEDS_FIX, RESULT_CLEAN, RESULT_STALE)
63
+
64
+ # Per-round bound on the persisted recurrence evidence. A review round may
65
+ # return arbitrarily many findings, and ``review_history`` keeps one entry per
66
+ # round for the whole PR, so an unbounded digest list would let a single
67
+ # verbose reviewer grow the state file without limit. A clipped round is
68
+ # marked ``resolutions_truncated`` and can then no longer prove the *absence*
69
+ # of a recurrence (see :func:`_recurring_resolutions`).
70
+ MAX_PERSISTED_RESOLUTION_DIGESTS = 50
71
+
72
+
73
+ def normalize_resolution(text: object) -> str:
74
+ """Whitespace-collapsed, lower-cased ``required_resolution`` text."""
75
+ return _WS_RE.sub(" ", str(text or "")).strip().lower()
76
+
77
+
78
+ def findings_fingerprint(findings: list[dict]) -> str:
79
+ """Order-independent digest of the findings' normalised required resolutions.
80
+
81
+ Finding IDs (``R1-F1``, ``R2-F1``) change every round and are ignored;
82
+ two rounds fingerprint the same when they request the same resolutions.
83
+ """
84
+ texts = sorted(normalize_resolution(f.get("required_resolution")) for f in findings)
85
+ digest = hashlib.sha256("\n".join(texts).encode("utf-8")).hexdigest()
86
+ return digest[:16]
87
+
88
+
89
+ def resolution_digests(findings: list[dict]) -> list[str]:
90
+ """Sorted per-finding digests of the normalised required resolutions.
91
+
92
+ Unlike :func:`findings_fingerprint` (one digest per round) this keeps one
93
+ digest per finding, so a later round can be checked for *recurring*
94
+ demands without persisting the review text itself.
95
+
96
+ A resolution that normalises to the empty string demands nothing, so it
97
+ is not recurrence evidence and gets no digest: otherwise two rounds that
98
+ each carry a blank ``required_resolution`` would look like the same
99
+ demand coming back.
100
+ """
101
+ digests = {
102
+ hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
103
+ for text in (normalize_resolution(f.get("required_resolution")) for f in findings)
104
+ if text
105
+ }
106
+ return sorted(digests)
107
+
108
+
109
+ def _retained_finding(finding: dict) -> tuple[dict, bool]:
110
+ """The persisted summary of ``finding`` plus whether its text was clipped.
111
+
112
+ Only ``id``, ``classification`` and ``required_resolution`` are kept. A
113
+ finding's ``title``, ``location`` and any other field the reviewer
114
+ supplied are dropped *by design* and that is not truncation: the
115
+ persisted evidence exists so a replan can show the replacement agent
116
+ every demand the reviews made, and the demand is the
117
+ ``required_resolution`` text. So "complete" in
118
+ :func:`round_evidence_is_complete` means *complete resolutions* -- every
119
+ finding retained and none of their resolution texts clipped -- never a
120
+ verbatim copy of the review comment, which stays on GitHub.
121
+ """
122
+ text = str(finding.get("required_resolution", ""))
123
+ clipped = text[:MAX_REQUIRED_RESOLUTION_CHARS]
124
+ return (
125
+ {
126
+ "id": str(finding.get("id", "")),
127
+ "classification": str(finding.get("classification", "")),
128
+ "required_resolution": clipped,
129
+ },
130
+ len(clipped) != len(text),
131
+ )
132
+
133
+
134
+ def round_evidence_is_complete(record: dict) -> bool:
135
+ """True when ``record`` holds a complete copy of its round's findings.
136
+
137
+ "Complete" means complete *resolutions*: every finding of the round is
138
+ retained and no ``required_resolution`` text was clipped. The retained
139
+ summary never carries a finding's ``title`` or ``location`` (see
140
+ :func:`_retained_finding`), and their absence is not truncation.
141
+
142
+ Only rounds that ended with findings carry evidence a replacement must
143
+ consider; clean and stale rounds are trivially complete. The length
144
+ comparison is a second, independent check on the ``evidence_truncated``
145
+ marker (it also covers history written before that marker existed).
146
+ """
147
+ if record.get("result") != RESULT_NEEDS_FIX:
148
+ return True
149
+ if record.get("evidence_truncated"):
150
+ return False
151
+ try:
152
+ expected = int(record.get("finding_count", 0) or 0)
153
+ except (TypeError, ValueError):
154
+ return False
155
+ return len(record.get("findings") or []) == expected
156
+
157
+
158
+ def truncated_evidence_rounds(history: list[dict]) -> list[int]:
159
+ """Rounds whose persisted findings are an incomplete copy of the review.
160
+
161
+ Replacing a PR deletes the controller's only record of its findings, so a
162
+ replan is refused while any of that record is missing: no acknowledgement
163
+ the replacement agent can make covers a finding it was never shown.
164
+ """
165
+ return [
166
+ int(record.get("round", 0) or 0)
167
+ for record in history
168
+ if not round_evidence_is_complete(record)
169
+ ]
170
+
171
+
172
+ def review_record(
173
+ round: int,
174
+ reviewed_head_sha: str,
175
+ result: str,
176
+ findings: list[dict],
177
+ review_comment_url: str = "",
178
+ timestamp: str = "",
179
+ ) -> dict:
180
+ """One ``review_history`` entry (plain dict: it is persisted as JSON).
181
+
182
+ The digest list is clipped to ``MAX_PERSISTED_RESOLUTION_DIGESTS``; a
183
+ clipped entry carries ``resolutions_truncated: True`` so the stagnation
184
+ rule knows its recurrence evidence is incomplete.
185
+ """
186
+ if result not in RESULTS:
187
+ raise ValueError(f"unknown review result {result!r}")
188
+ digests = resolution_digests(findings)
189
+ record = {
190
+ "round": int(round),
191
+ "reviewed_head_sha": reviewed_head_sha,
192
+ "result": result,
193
+ "finding_count": len(findings),
194
+ "fingerprint": findings_fingerprint(findings),
195
+ "resolutions": digests[:MAX_PERSISTED_RESOLUTION_DIGESTS],
196
+ "resolutions_truncated": len(digests) > MAX_PERSISTED_RESOLUTION_DIGESTS,
197
+ }
198
+ # Keep compact, bounded evidence. GitHub remains authoritative for comment
199
+ # bodies and the full finding list if this history is truncated. Whenever
200
+ # anything was dropped or clipped the round is marked explicitly, because a
201
+ # replan discards the PR that carries the untruncated originals — see
202
+ # :func:`truncated_evidence_rounds`.
203
+ if findings:
204
+ retained = [_retained_finding(f) for f in findings[:MAX_PERSISTED_FINDINGS_PER_ROUND]]
205
+ record["findings"] = [item for item, _ in retained]
206
+ if len(findings) > MAX_PERSISTED_FINDINGS_PER_ROUND or any(cut for _, cut in retained):
207
+ record["evidence_truncated"] = True
208
+ if review_comment_url:
209
+ record["review_comment_url"] = review_comment_url
210
+ if timestamp:
211
+ record["timestamp"] = timestamp
212
+ return record
213
+
214
+
215
+ def validate_review_history(history: object) -> None:
216
+ """Raise :class:`StateError` unless ``history`` is a list of valid entries.
217
+
218
+ Persisted ``review_history`` drives terminal decisions (BLOCKED) and, via
219
+ :func:`round_evidence_is_complete`, the destructive close of a replan, so
220
+ a malformed entry must fail loudly instead of being reinterpreted. Only a
221
+ *missing* optional key (``resolutions`` on an entry written before
222
+ per-finding digests existed, ``findings`` on a clean round) is
223
+ compatibility; a present field is validated like any other.
224
+ """
225
+ if not isinstance(history, list):
226
+ raise StateError("'review_history' must be a list")
227
+ for i, entry in enumerate(history):
228
+ where = f"'review_history[{i}]'"
229
+ if not isinstance(entry, dict):
230
+ raise StateError(f"{where} must be an object")
231
+ for key, typ in (("round", int), ("finding_count", int)):
232
+ value = entry.get(key)
233
+ if not isinstance(value, typ) or isinstance(value, bool):
234
+ raise StateError(f"{where}.{key} must be an integer, got {value!r}")
235
+ for key in ("reviewed_head_sha", "fingerprint"):
236
+ if not isinstance(entry.get(key), str):
237
+ raise StateError(f"{where}.{key} must be a string, got {entry.get(key)!r}")
238
+ if entry.get("result") not in RESULTS:
239
+ raise StateError(
240
+ f"{where}.result must be one of {RESULTS}, got {entry.get('result')!r}"
241
+ )
242
+ if "resolutions" in entry:
243
+ digests = entry["resolutions"]
244
+ if not isinstance(digests, list):
245
+ raise StateError(
246
+ f"{where}.resolutions must be a list of digest strings, got {digests!r}"
247
+ )
248
+ for d in digests:
249
+ if not isinstance(d, str) or not d.strip():
250
+ raise StateError(
251
+ f"{where}.resolutions must contain non-empty digest strings, got {d!r}"
252
+ )
253
+ if "resolutions_truncated" in entry and not isinstance(
254
+ entry["resolutions_truncated"], bool
255
+ ):
256
+ raise StateError(
257
+ f"{where}.resolutions_truncated must be a boolean, "
258
+ f"got {entry['resolutions_truncated']!r}"
259
+ )
260
+ if "findings" in entry:
261
+ retained = entry["findings"]
262
+ if not isinstance(retained, list) or not all(isinstance(f, dict) for f in retained):
263
+ raise StateError(f"{where}.findings must be a list of objects, got {retained!r}")
264
+ if "evidence_truncated" in entry and not isinstance(entry["evidence_truncated"], bool):
265
+ raise StateError(
266
+ f"{where}.evidence_truncated must be a boolean, got {entry['evidence_truncated']!r}"
267
+ )
268
+ for key in ("review_comment_url", "timestamp"):
269
+ if key in entry and not isinstance(entry[key], str):
270
+ raise StateError(f"{where}.{key} must be a string, got {entry[key]!r}")
271
+
272
+
273
+ def round_cap_reason(review_round: int, max_review_rounds: int, *, has_findings: bool) -> str:
274
+ """Reason when review round ``review_round`` (just completed) hits the cap.
275
+
276
+ A round *at* the cap that still has findings must not start another FIX
277
+ (its result could never be reviewed); a round *past* the cap must never
278
+ have run at all. A clean round at the cap is fine (``""``).
279
+ """
280
+ if review_round > max_review_rounds:
281
+ return f"review round {review_round} exceeds workflow.max_review_rounds={max_review_rounds}"
282
+ if has_findings and review_round >= max_review_rounds:
283
+ return (
284
+ f"review round {review_round} still has findings and workflow.max_review_rounds="
285
+ f"{max_review_rounds} is reached; no further FIX round is started"
286
+ )
287
+ return ""
288
+
289
+
290
+ def next_round_cap_reason(completed_rounds: int, max_review_rounds: int) -> str:
291
+ """Reason when review round ``completed_rounds + 1`` must not start."""
292
+ if completed_rounds >= max_review_rounds:
293
+ return (
294
+ f"{completed_rounds} review round(s) completed for this PR and "
295
+ f"workflow.max_review_rounds={max_review_rounds} is reached; review round "
296
+ f"{completed_rounds + 1} is not started"
297
+ )
298
+ return ""
299
+
300
+
301
+ def _trailing_needs_fix(history: list[dict], window: int) -> list[dict]:
302
+ """The last ``window`` entries when all of them are needs_fix rounds, else []."""
303
+ if window < 1 or len(history) < window:
304
+ return []
305
+ tail = history[-window:]
306
+ if any(r.get("result") != RESULT_NEEDS_FIX for r in tail):
307
+ return []
308
+ return tail
309
+
310
+
311
+ def _recurring_resolutions(tail: list[dict]) -> tuple[set[str], str]:
312
+ """``(digests requested by more than one round, incomplete-evidence reason)``.
313
+
314
+ The reason is ``""`` when every entry of the window carries its complete
315
+ per-finding digests. It is non-empty when an entry predates the
316
+ ``resolutions`` field (a run persisted by an older controller) or was
317
+ clipped by :data:`MAX_PERSISTED_RESOLUTION_DIGESTS`: such a window can
318
+ still *prove* a recurrence, but it can never prove there was none, so the
319
+ count-only behaviour applies. Malformed entries raise (see
320
+ :func:`validate_review_history`) rather than being read as either.
321
+ """
322
+ validate_review_history(tail)
323
+ seen: set[str] = set()
324
+ recurring: set[str] = set()
325
+ incomplete = ""
326
+ for r in tail:
327
+ if "resolutions" not in r:
328
+ incomplete = incomplete or (
329
+ f"round {r.get('round')} was written by an older controller and has no "
330
+ "per-finding resolution digests, so recurrence cannot be ruled out"
331
+ )
332
+ continue
333
+ if r.get("resolutions_truncated"):
334
+ incomplete = (
335
+ f"round {r.get('round')} persisted only the first "
336
+ f"{MAX_PERSISTED_RESOLUTION_DIGESTS} resolution digest(s) of "
337
+ f"{r.get('finding_count')} finding(s), so recurrence cannot be ruled out"
338
+ )
339
+ digests = set(r["resolutions"])
340
+ recurring |= seen & digests
341
+ seen |= digests
342
+ return recurring, incomplete
343
+
344
+
345
+ def stagnation_reason(
346
+ history: list[dict], identical_rounds: int, unchanged_count_rounds: int
347
+ ) -> str:
348
+ """Reason when the trailing review rounds show no progress; ``""`` otherwise.
349
+
350
+ ``identical_rounds`` / ``unchanged_count_rounds`` of 0 disable the
351
+ respective rule. Only *consecutive* rounds that ended with findings are
352
+ considered: a clean or stale round in between resets both rules.
353
+
354
+ The unchanged-count rule needs, on top of the constant count, at least
355
+ one required resolution that was requested by two different rounds of
356
+ the window: the count alone cannot tell an A/B/A ping-pong from a
357
+ reviewer that raises one genuinely new finding per round (every earlier
358
+ finding was resolved), and the latter is progress bounded by
359
+ ``workflow.max_review_rounds``. A window whose recurrence evidence is
360
+ incomplete — an entry written before the per-finding digests existed, or
361
+ one clipped by :data:`MAX_PERSISTED_RESOLUTION_DIGESTS` — cannot rule a
362
+ recurrence out and keeps the count-only behaviour; the reason says which.
363
+
364
+ Both windows are 0 (disabled) or >= 2: a window of 1 compares a round
365
+ with nothing and is rejected by the configuration loader.
366
+
367
+ Raises :class:`StateError` when ``history`` holds a malformed entry.
368
+ """
369
+ validate_review_history(history)
370
+ tail = _trailing_needs_fix(history, identical_rounds)
371
+ if tail and len({r.get("fingerprint") for r in tail}) == 1:
372
+ rounds = ", ".join(str(r.get("round")) for r in tail)
373
+ return (
374
+ f"review rounds {rounds} requested identical resolutions "
375
+ f"({tail[-1].get('finding_count')} finding(s) each); the FIX rounds between them "
376
+ f"changed nothing the reviewer accepts (workflow.stagnation_identical_rounds="
377
+ f"{identical_rounds})"
378
+ )
379
+ tail = _trailing_needs_fix(history, unchanged_count_rounds)
380
+ if tail and len({r.get("finding_count") for r in tail}) == 1:
381
+ recurring, incomplete = _recurring_resolutions(tail)
382
+ detail = (
383
+ f"{len(recurring)} required resolution(s) recur across them"
384
+ if recurring
385
+ else incomplete
386
+ )
387
+ if detail:
388
+ rounds = ", ".join(str(r.get("round")) for r in tail)
389
+ return (
390
+ f"review rounds {rounds} each ended with {tail[-1].get('finding_count')} "
391
+ f"finding(s); the finding count has not changed and {detail} "
392
+ f"(workflow.stagnation_unchanged_count_rounds={unchanged_count_rounds})"
393
+ )
394
+ return ""
395
+
396
+
397
+ def step_budget_reason(step_count: int, max_total_steps: int) -> str:
398
+ """Reason when the run's cumulative step budget is exhausted."""
399
+ if step_count >= max_total_steps:
400
+ return (
401
+ f"{step_count} step(s) executed in this run and workflow.max_total_steps="
402
+ f"{max_total_steps} is reached; no further step is executed"
403
+ )
404
+ return ""