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/roadmap.py ADDED
@@ -0,0 +1,151 @@
1
+ """The EPIC's managed roadmap section: locate, splice, and prove the rest untouched.
2
+
3
+ The EPIC body is the operator's document. AutoForge owns exactly one part
4
+ of it, the section between two marker lines::
5
+
6
+ <!-- ai-controller-roadmap:start -->
7
+ ...
8
+ <!-- ai-controller-roadmap:end -->
9
+
10
+ and nothing else. The UPDATE_EPIC agent never edits the body: it returns
11
+ the section's new content in its ``CONTROL_RESULT`` and the controller
12
+ performs the edit (``gh issue edit --body-file``), reads the body back, and
13
+ requires every byte outside the markers to be identical to what it read
14
+ before writing. That read-back is the only evidence the update happened;
15
+ the counter of merges awaiting a roadmap update is reset after it and
16
+ never before.
17
+
18
+ This module is pure text logic so the splice and its acceptance rules can
19
+ be tested without an engine or a GitHub fake: :func:`split_roadmap` finds
20
+ the section (or proves there is none), :func:`splice_roadmap` builds the
21
+ body that carries a new one, and :func:`outside_roadmap` is the text the
22
+ read-back compares. A body whose markers cannot be read unambiguously (a
23
+ marker repeated, an end before a start, one without the other) raises
24
+ :class:`RoadmapError`: the controller then blocks rather than guess which
25
+ part of the operator's document is its own.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+
32
+ from .errors import AutoForgeError
33
+
34
+ ROADMAP_START_MARKER = "<!-- ai-controller-roadmap:start -->"
35
+ ROADMAP_END_MARKER = "<!-- ai-controller-roadmap:end -->"
36
+
37
+
38
+ class RoadmapError(AutoForgeError):
39
+ """The EPIC body's managed section cannot be located unambiguously."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RoadmapSplit:
44
+ """An EPIC body cut around its managed section.
45
+
46
+ ``body`` is the text as read. ``before`` ends just after the start
47
+ marker (or is the whole body when there is no section), ``section`` is
48
+ the content between the marker lines (``None`` when absent), and
49
+ ``after`` starts at the end marker. ``outside`` is what a read-back after
50
+ the controller's write must find byte-identical.
51
+ """
52
+
53
+ body: str
54
+ before: str
55
+ section: str | None
56
+ after: str
57
+
58
+ @property
59
+ def present(self) -> bool:
60
+ return self.section is not None
61
+
62
+ @property
63
+ def outside(self) -> str:
64
+ """Every byte that is not managed-section content."""
65
+ return self.before + self.after
66
+
67
+
68
+ def _count(body: str, marker: str) -> int:
69
+ return body.count(marker)
70
+
71
+
72
+ def split_roadmap(body: str) -> RoadmapSplit:
73
+ """Locate the managed section of ``body``.
74
+
75
+ Exactly one start marker followed by exactly one end marker is a
76
+ section; neither marker at all is "no section yet" (the splice appends
77
+ one). Anything else is ambiguous and raises :class:`RoadmapError`.
78
+ """
79
+ starts = _count(body, ROADMAP_START_MARKER)
80
+ ends = _count(body, ROADMAP_END_MARKER)
81
+ if starts == 0 and ends == 0:
82
+ return RoadmapSplit(body=body, before=body, section=None, after="")
83
+ if starts != 1 or ends != 1:
84
+ raise RoadmapError(
85
+ f"the EPIC body carries {starts} start and {ends} end roadmap marker(s); the "
86
+ f"managed section is exactly one {ROADMAP_START_MARKER!r} followed by exactly one "
87
+ f"{ROADMAP_END_MARKER!r}"
88
+ )
89
+ start = body.index(ROADMAP_START_MARKER)
90
+ end = body.index(ROADMAP_END_MARKER)
91
+ if end < start:
92
+ raise RoadmapError(
93
+ "the EPIC body's roadmap end marker comes before its start marker; the managed "
94
+ "section cannot be located"
95
+ )
96
+ inner_start = start + len(ROADMAP_START_MARKER)
97
+ inner = body[inner_start:end]
98
+ # The section is written as ``START\n<content>\nEND``; the newline that
99
+ # ends the start marker's line and the one that ends the content's last
100
+ # line are structure, not content. A body edited by hand may lack either.
101
+ content = inner
102
+ if content.startswith("\n"):
103
+ content = content[1:]
104
+ if content.endswith("\n"):
105
+ content = content[:-1]
106
+ return RoadmapSplit(body=body, before=body[:inner_start], section=content, after=body[end:])
107
+
108
+
109
+ def outside_roadmap(body: str) -> str:
110
+ """Every byte of ``body`` that is not managed-section content."""
111
+ return split_roadmap(body).outside
112
+
113
+
114
+ def render_roadmap_block(section: str) -> str:
115
+ """The managed section as it is written into a body."""
116
+ return f"{ROADMAP_START_MARKER}\n{section}\n{ROADMAP_END_MARKER}"
117
+
118
+
119
+ def splice_roadmap(body: str, section: str) -> str:
120
+ """``body`` with its managed section replaced by ``section``, or appended.
121
+
122
+ Replacing keeps every byte outside the marker lines; appending adds the
123
+ block after the existing text, separated by a blank line, and never
124
+ changes a byte of it. ``section`` must not carry a marker itself (the
125
+ result parser refuses one), so the spliced body always splits back into
126
+ exactly this section.
127
+ """
128
+ if ROADMAP_START_MARKER in section or ROADMAP_END_MARKER in section:
129
+ raise RoadmapError("a roadmap section must not contain the roadmap markers")
130
+ split = split_roadmap(body)
131
+ if split.present:
132
+ # ``before`` ends with the start marker; ``after`` starts with the end
133
+ # marker. The content goes between them on lines of its own.
134
+ return f"{split.before}\n{section}\n{split.after}"
135
+ block = render_roadmap_block(section)
136
+ if not body:
137
+ return block + "\n"
138
+ separator = "" if body.endswith("\n\n") else ("\n" if body.endswith("\n") else "\n\n")
139
+ return f"{body}{separator}{block}\n"
140
+
141
+
142
+ __all__ = [
143
+ "ROADMAP_END_MARKER",
144
+ "ROADMAP_START_MARKER",
145
+ "RoadmapError",
146
+ "RoadmapSplit",
147
+ "outside_roadmap",
148
+ "render_roadmap_block",
149
+ "splice_roadmap",
150
+ "split_roadmap",
151
+ ]
@@ -0,0 +1,408 @@
1
+ """The durable contract of a LOCAL run: what defines it, persisted once.
2
+
3
+ Why this module exists
4
+ ----------------------
5
+
6
+ A LOCAL run has no GitHub to be the source of truth. Its meaning -- *which*
7
+ tree is reviewed, *as classified by which rules*, verified by *which*
8
+ commands, bounded by *how many* fix rounds, with state kept *where* -- is
9
+ decided when the run is created, from the configuration and the environment
10
+ of that moment. Every later step of the run (a ``step``, a ``resume``, a
11
+ crash recovery, a ``status``) runs in a new process that loads the state
12
+ file and *also* loads today's configuration and observes today's
13
+ environment. The failure mode this module closes is letting those two
14
+ sources be confused: a step that re-derives a run-defining input from the
15
+ current environment and treats the result as the run's definition has
16
+ silently **rebound** the run. Review rounds 6 and 7 each found one instance
17
+ of that pattern (``local.exclude`` re-read on resume; the state directory
18
+ re-resolved by pathname at bootstrap) and each fix froze one input. That
19
+ cannot converge: every un-enumerated input is the next finding.
20
+
21
+ The closed formulation is a contract:
22
+
23
+ * :class:`WorkspacePolicy` -- everything about the workspace reader that
24
+ shapes a snapshot, in one canonical, unambiguous, digested form.
25
+ * :class:`LocalRunContract` -- every run-defining input, classified
26
+ **IMMUTABLE** (persisted at creation; a later invocation must present the
27
+ same value or the run refuses to continue).
28
+ * :func:`validate_local_run_contract` -- the single gate that compares the
29
+ persisted contract with what the current invocation would define, before
30
+ anything executes.
31
+
32
+ The master invariant: *a resumed run may revalidate its contract, but must
33
+ never silently redefine it.* Concretely, execution reads the run-defining
34
+ values **from the contract** (``contract.validation_commands``, never
35
+ ``config.local.validation_commands``), and the gate is what proves the
36
+ operator's current configuration still agrees with it. Adding a
37
+ run-defining input is one field on :class:`LocalRunContract`; the
38
+ persistence, the comparison and the drift message follow from the
39
+ dataclass, so there is no second list to forget.
40
+
41
+ Classification of the run's inputs
42
+ ----------------------------------
43
+
44
+ IMMUTABLE (in the contract, compared by the gate, drift refuses):
45
+ repository root, state root, workspace policy (exclusion rules, cost
46
+ bounds, snapshot algorithm), validation commands, fix-round budget,
47
+ cumulative step budget, prompt version. Also, outside the contract object but with the same
48
+ semantics: the protocol version (checked on load) and the feature
49
+ specification path (persisted; re-supplied by nothing, so it cannot
50
+ drift).
51
+
52
+ REVALIDATED (re-read from the environment and proven equivalent):
53
+ the feature specification's bytes (SHA-256, VerificationError), the git
54
+ anchor (HEAD and branch, BLOCKED). These are *content* checks of the
55
+ world the contract names; they are performed where the run touches that
56
+ content, and they never write what they read back into the contract.
57
+
58
+ DYNAMIC (observed, never defining):
59
+ the pre-phase workspace fingerprint (a binding for the *next* agent
60
+ phase, persisted so the reviewer's claim can be checked against it -- a
61
+ persisted safety judgment never reads a stale one back), git-directory
62
+ inodes (re-read per snapshot from a location the contract names),
63
+ provider/model/effort/command/extra_args/timeouts (which agent runs, not
64
+ what it is asked to do), the invocation's cwd and PATH, counters. No
65
+ persisted safety judgment depends on any of them, which is the proof
66
+ that they may change.
67
+
68
+ The invocation's cwd earns that classification only because nothing is
69
+ *executed* from it: agents and validation commands are launched from
70
+ the contract's ``repository_root`` (``ControllerEngine._execution_cwd``),
71
+ so the cwd decides where the repository is found and the gate then
72
+ proves it is the recorded one. An argv is a program only relative to a
73
+ directory -- ``["./verify"]`` from ``src/`` is a different verifier --
74
+ so launching from the cwd would have made it a run-defining input the
75
+ contract did not record (round 11, R11-F1).
76
+ """
77
+
78
+ from __future__ import annotations
79
+
80
+ import hashlib
81
+ import json
82
+ import re
83
+ from collections.abc import Sequence
84
+ from dataclasses import dataclass, field, fields
85
+ from typing import TYPE_CHECKING, Any
86
+
87
+ from . import __prompt_version__
88
+ from .errors import StateError, VerificationError
89
+
90
+ if TYPE_CHECKING: # pragma: no cover - typing only
91
+ from .config import AutoForgeConfig
92
+ from .local_workspace import LocalWorkspace
93
+ from .state import StatePaths
94
+
95
+ #: Schema of the persisted contract. Bumped when a field is added, removed or
96
+ #: reinterpreted; an older schema is refused, never migrated (see
97
+ #: :meth:`LocalRunContract.from_dict`). 2 added ``max_total_steps``: a run
98
+ #: created under schema 1 could resume under a larger cumulative step budget
99
+ #: than the one it was created with, without any drift being reported.
100
+ CONTRACT_SCHEMA = 2
101
+
102
+ #: Schema of the persisted workspace policy. ``v1`` (the pre-release form:
103
+ #: one comma-joined line that did not name the snapshot algorithm and could
104
+ #: not tell ``["a,b", "c"]`` from ``["a", "b,c"]``) is refused.
105
+ POLICY_SCHEMA = "v2"
106
+
107
+ _POLICY_FIELDS = ("snapshot_tag", "exclude", "max_entries", "max_bytes")
108
+ _SNAPSHOT_TAG_RE = re.compile(r"^[A-Za-z0-9._-]+$")
109
+
110
+
111
+ def _digest(text: str) -> str:
112
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
113
+
114
+
115
+ def _fmt(value: Any) -> str:
116
+ """Render a contract value for a drift line (JSON, so [] and "" are visible)."""
117
+ if isinstance(value, tuple):
118
+ return json.dumps([list(v) if isinstance(v, tuple) else v for v in value])
119
+ return json.dumps(value)
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class WorkspacePolicy:
124
+ """Everything about the workspace reader that shapes a snapshot.
125
+
126
+ A fingerprint binds the working tree *as this reader classifies it*, so
127
+ the reader's configuration is part of what a review means. Comparing
128
+ fingerprints cannot detect a policy change: a re-snapshot after the
129
+ change computes both sides under the new policy. The policy is therefore
130
+ part of the run's contract, whole: the bounds only ever turn a snapshot
131
+ into a refusal, so carving them out would be sound today -- and would be
132
+ one more enumeration of which knobs happen to be benign, the shape of
133
+ reasoning this design replaced. The snapshot algorithm's tag is in it
134
+ for the same reason: a controller whose walk classifies entries
135
+ differently produces fingerprints that mean something else.
136
+ """
137
+
138
+ exclude: tuple[str, ...]
139
+ max_entries: int
140
+ max_bytes: int
141
+ snapshot_tag: str
142
+
143
+ def __post_init__(self) -> None:
144
+ # The policy is a *set* of rules: one canonical spelling per policy,
145
+ # so equality, the digest and the drift message never depend on the
146
+ # order the operator wrote the list in.
147
+ object.__setattr__(self, "exclude", tuple(sorted(set(self.exclude))))
148
+
149
+ def canonical(self) -> str:
150
+ """The one canonical spelling of this policy; what is digested.
151
+
152
+ A JSON object with sorted keys and no whitespace. Every exclusion
153
+ pattern is its own JSON string, so the encoding is unambiguous for
154
+ any pattern the loader accepts: a comma, a bracket or a quote inside
155
+ a pattern is quoted, never a delimiter. (The pre-release ``v1`` text
156
+ joined the patterns with commas, and ``["a,b", "c"]`` and
157
+ ``["a", "b,c"]`` were one policy under it.)
158
+ """
159
+ return json.dumps(
160
+ {
161
+ "version": POLICY_SCHEMA,
162
+ "snapshot_tag": self.snapshot_tag,
163
+ "exclude": list(self.exclude),
164
+ "max_entries": self.max_entries,
165
+ "max_bytes": self.max_bytes,
166
+ },
167
+ sort_keys=True,
168
+ separators=(",", ":"),
169
+ ensure_ascii=True,
170
+ )
171
+
172
+ def digest(self) -> str:
173
+ return _digest(self.canonical())
174
+
175
+ def to_dict(self) -> dict:
176
+ """The persisted form: the fields, readable as they are, plus the digest."""
177
+ return {
178
+ "version": POLICY_SCHEMA,
179
+ "snapshot_tag": self.snapshot_tag,
180
+ "exclude": list(self.exclude),
181
+ "max_entries": self.max_entries,
182
+ "max_bytes": self.max_bytes,
183
+ "digest": self.digest(),
184
+ }
185
+
186
+ @classmethod
187
+ def from_dict(cls, data: Any) -> WorkspacePolicy:
188
+ """Strictly decode the persisted form; the digest must match the fields.
189
+
190
+ The fields are required to be *already canonical* (sorted, unique,
191
+ loader-normalised patterns): a policy the controller wrote is, and
192
+ one that is not was written by something else.
193
+ """
194
+ expected = {"version", "digest", *_POLICY_FIELDS}
195
+ if not isinstance(data, dict) or set(data) != expected:
196
+ raise StateError(
197
+ "workspace policy must be an object with exactly the fields "
198
+ + ", ".join(sorted(expected))
199
+ )
200
+ if data["version"] != POLICY_SCHEMA:
201
+ raise StateError(
202
+ f"workspace policy version {data['version']!r} is not {POLICY_SCHEMA!r}; "
203
+ "this run was created by a different controller release and is not "
204
+ "migrated -- start a new run"
205
+ )
206
+ tag = data["snapshot_tag"]
207
+ if not isinstance(tag, str) or not _SNAPSHOT_TAG_RE.match(tag):
208
+ raise StateError(f"workspace policy snapshot tag is not usable: {tag!r}")
209
+ exclude = data["exclude"]
210
+ if (
211
+ not isinstance(exclude, list)
212
+ or not all(isinstance(p, str) and p and "\0" not in p for p in exclude)
213
+ or exclude != sorted(set(exclude))
214
+ ):
215
+ raise StateError(f"workspace policy exclusions are not canonical: {exclude!r}")
216
+ bounds = {}
217
+ for name in ("max_entries", "max_bytes"):
218
+ value = data[name]
219
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
220
+ raise StateError(f"workspace policy field {name!r} must be a non-negative integer")
221
+ bounds[name] = value
222
+ if not isinstance(data["digest"], str):
223
+ raise StateError("workspace policy digest must be a string")
224
+ policy = cls(exclude=tuple(exclude), snapshot_tag=tag, **bounds)
225
+ if data["digest"] != policy.digest():
226
+ raise StateError("workspace policy digest does not match its fields")
227
+ return policy
228
+
229
+ def drift(self, current: WorkspacePolicy) -> list[str]:
230
+ """Per-field differences, named by the config setting that moves them."""
231
+ lines: list[str] = []
232
+ labels = {
233
+ "exclude": "local.exclude",
234
+ "max_entries": "local.max_workspace_entries",
235
+ "max_bytes": "local.max_workspace_bytes",
236
+ "snapshot_tag": "workspace snapshot algorithm",
237
+ }
238
+ for f in fields(self):
239
+ mine, theirs = getattr(self, f.name), getattr(current, f.name)
240
+ if mine != theirs:
241
+ lines.append(f"{labels[f.name]}: run: {_fmt(mine)} current: {_fmt(theirs)}")
242
+ return lines
243
+
244
+
245
+ @dataclass(frozen=True)
246
+ class LocalRunContract:
247
+ """The persisted definition of one LOCAL run (every field IMMUTABLE).
248
+
249
+ Each field carries the name the operator knows it by, so a drift line
250
+ reads ``local.exclude: run: [] current: ["src"]``.
251
+ """
252
+
253
+ repository_root: str = field(metadata={"label": "repository root"})
254
+ state_root: str = field(metadata={"label": "state directory"})
255
+ workspace_policy: WorkspacePolicy = field(metadata={"label": "workspace policy"})
256
+ validation_commands: tuple[tuple[str, ...], ...] = field(
257
+ metadata={"label": "local.validation_commands"}
258
+ )
259
+ max_fix_rounds: int = field(metadata={"label": "local.max_fix_rounds"})
260
+ # The run's cumulative step budget. It bounds the run as a whole (every
261
+ # phase entry, across `resume`), so it is as run-defining as the fix
262
+ # budget: enforced from here, never from the configuration of the day.
263
+ max_total_steps: int = field(metadata={"label": "workflow.max_total_steps"})
264
+ prompt_version: str = field(metadata={"label": "prompt_version"})
265
+
266
+ @property
267
+ def max_review_rounds(self) -> int:
268
+ """Review passes the run may complete: the fix rounds plus the first."""
269
+ return self.max_fix_rounds + 1
270
+
271
+ # -- construction ------------------------------------------------------
272
+ @classmethod
273
+ def from_config(
274
+ cls, config: AutoForgeConfig, workspace: LocalWorkspace, paths: StatePaths
275
+ ) -> LocalRunContract:
276
+ """What *this* invocation would define the run as.
277
+
278
+ Used once to create the contract and afterwards only to compare
279
+ against it; a comparison never writes anything back.
280
+ """
281
+ return cls(
282
+ repository_root=str(workspace.root()),
283
+ state_root=paths.canonical_state_dir(),
284
+ workspace_policy=workspace.policy(),
285
+ validation_commands=tuple(
286
+ tuple(str(a) for a in argv) for argv in config.local.validation_commands
287
+ ),
288
+ max_fix_rounds=int(config.local.max_fix_rounds),
289
+ max_total_steps=int(config.workflow.max_total_steps),
290
+ prompt_version=config.prompt_version or __prompt_version__,
291
+ )
292
+
293
+ # -- persistence -------------------------------------------------------
294
+ def to_dict(self) -> dict:
295
+ return {
296
+ "schema": CONTRACT_SCHEMA,
297
+ "repository_root": self.repository_root,
298
+ "state_root": self.state_root,
299
+ "workspace_policy": self.workspace_policy.to_dict(),
300
+ "validation_commands": [list(argv) for argv in self.validation_commands],
301
+ "max_fix_rounds": self.max_fix_rounds,
302
+ "max_total_steps": self.max_total_steps,
303
+ "prompt_version": self.prompt_version,
304
+ }
305
+
306
+ @classmethod
307
+ def from_dict(cls, data: Any) -> LocalRunContract:
308
+ """Strict decode. Missing, extra or mistyped fields are StateError.
309
+
310
+ There is deliberately no ``missing field -> fill from current
311
+ config`` path: that would be the silent rebinding this contract
312
+ exists to make impossible. A contract an older controller wrote is
313
+ refused with a message that says to start a new run.
314
+ """
315
+ if not isinstance(data, dict):
316
+ raise StateError("local run contract must be an object")
317
+ expected = {"schema"} | {f.name for f in fields(cls)}
318
+ missing = sorted(expected - set(data))
319
+ unknown = sorted(set(data) - expected)
320
+ if missing or unknown:
321
+ raise StateError(
322
+ "local run contract has "
323
+ + (f"missing field(s) {missing}" if missing else "")
324
+ + (" and " if missing and unknown else "")
325
+ + (f"unknown field(s) {unknown}" if unknown else "")
326
+ + "; a contract the controller cannot read whole is not reconstructed from "
327
+ "the current configuration -- start a new run"
328
+ )
329
+ if data["schema"] != CONTRACT_SCHEMA:
330
+ raise StateError(
331
+ f"local run contract schema {data['schema']!r} is not {CONTRACT_SCHEMA}; "
332
+ "this run was created by a different controller release and is not "
333
+ "migrated -- start a new run"
334
+ )
335
+ for name in ("repository_root", "state_root", "prompt_version"):
336
+ if not isinstance(data[name], str) or not data[name]:
337
+ raise StateError(f"local run contract field {name!r} must be a non-empty string")
338
+ rounds = data["max_fix_rounds"]
339
+ if not isinstance(rounds, int) or isinstance(rounds, bool) or rounds < 0:
340
+ raise StateError(
341
+ "local run contract field 'max_fix_rounds' must be a non-negative integer"
342
+ )
343
+ steps = data["max_total_steps"]
344
+ if not isinstance(steps, int) or isinstance(steps, bool) or steps < 1:
345
+ # The loader refuses a budget below 1, so a persisted one is
346
+ # something the controller never wrote.
347
+ raise StateError(
348
+ "local run contract field 'max_total_steps' must be a positive integer"
349
+ )
350
+ commands = data["validation_commands"]
351
+ if not isinstance(commands, list) or not all(
352
+ isinstance(argv, list) and argv and all(isinstance(a, str) for a in argv)
353
+ for argv in commands
354
+ ):
355
+ raise StateError(
356
+ "local run contract field 'validation_commands' must be a list of "
357
+ "non-empty argv lists of strings"
358
+ )
359
+ return cls(
360
+ repository_root=data["repository_root"],
361
+ state_root=data["state_root"],
362
+ workspace_policy=WorkspacePolicy.from_dict(data["workspace_policy"]),
363
+ validation_commands=tuple(tuple(argv) for argv in commands),
364
+ max_fix_rounds=rounds,
365
+ max_total_steps=steps,
366
+ prompt_version=data["prompt_version"],
367
+ )
368
+
369
+ # -- comparison --------------------------------------------------------
370
+ def drift(self, current: LocalRunContract) -> list[str]:
371
+ """Every field on which ``current`` disagrees with this contract.
372
+
373
+ Generic over the dataclass: a new field is compared and reported
374
+ without a new branch here.
375
+ """
376
+ lines: list[str] = []
377
+ for f in fields(self):
378
+ mine, theirs = getattr(self, f.name), getattr(current, f.name)
379
+ if isinstance(mine, WorkspacePolicy):
380
+ lines.extend(mine.drift(theirs))
381
+ elif mine != theirs:
382
+ lines.append(f"{f.metadata['label']}: run: {_fmt(mine)} current: {_fmt(theirs)}")
383
+ return lines
384
+
385
+
386
+ def contract_drift_error(lines: Sequence[str]) -> VerificationError:
387
+ return VerificationError(
388
+ "this LOCAL run's contract does not match the current invocation:\n "
389
+ + "\n ".join(lines)
390
+ + "\nA run is defined once, when it is created; resuming it under a different "
391
+ "definition would silently rebind what was reviewed, verified or bounded. Restore "
392
+ "the run's settings (and location) to resume it, or start a new run under the "
393
+ "current ones. Nothing was changed."
394
+ )
395
+
396
+
397
+ def validate_local_run_contract(
398
+ recorded: LocalRunContract, current: LocalRunContract
399
+ ) -> LocalRunContract:
400
+ """The gate: refuse (VerificationError, per-field) unless the two agree.
401
+
402
+ Returns the recorded contract, which is what execution then reads from.
403
+ Nothing is persisted on either outcome.
404
+ """
405
+ lines = recorded.drift(current)
406
+ if lines:
407
+ raise contract_drift_error(lines)
408
+ return recorded