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,374 @@
1
+ """The configuration a REMOTE run was recorded under (#219).
2
+
3
+ Why this module exists
4
+ ----------------------
5
+
6
+ The CLI has no configuration discovery. A REMOTE run started with
7
+ ``--config`` and resumed without it runs on the built-in defaults: another
8
+ model and effort, other limits, and an agent worktree under
9
+ ``<git common dir>/autoforge/worktrees`` instead of the configured
10
+ ``execution.worktree_dir`` -- a different agent in a different, empty
11
+ worktree. A LOCAL run has its contract for what defines it
12
+ (:mod:`autoforge.run_contract`); this is the REMOTE counterpart for what
13
+ decides *who* works on the run and *where*.
14
+
15
+ A REMOTE run records a :class:`RecordedConfiguration` when it is created
16
+ (``state.config_fingerprint``), and ``resume``, ``step`` and ``unblock``
17
+ compare the configuration they would run on with it before any phase
18
+ executes (:meth:`autoforge.engine.ControllerEngine.check_run_configuration`).
19
+ A difference refuses, naming each changed key, unless the operator opts in
20
+ with ``--accept-config-change``; the change is then written to the run log
21
+ and the new configuration replaces the recorded one.
22
+
23
+ What it holds
24
+ -------------
25
+
26
+ For every profile a REMOTE run can route to (:data:`FINGERPRINT_PROFILES`):
27
+ the provider, the model, the effort and the limits the agent runs under
28
+ (the profile's own ``idle_timeout_seconds`` / ``max_runtime_seconds``, else
29
+ the ``execution.default_*`` value); and the resolved agent worktree root.
30
+ Beside them, the absolute path of the ``--config`` file it was recorded from
31
+ (``""`` for the built-in defaults), so a refusal can say how to restore it.
32
+
33
+ Nothing else is recorded, and nothing that could be a secret: a profile's
34
+ ``command``, ``extra_args`` and ``options`` and the environment are left out
35
+ (an argument or an option is where a key would go), so the routing fields
36
+ are stored and printed as they are, and every text built from them crosses
37
+ the redaction boundary at the CLI and the run log anyway.
38
+
39
+ Which keys must match
40
+ ---------------------
41
+
42
+ Every key recorded here. Provider, model, effort and the worktree root
43
+ decide which agent works and in which worktree; the limits are recorded
44
+ because a ``resume`` without ``--config`` changes them just as silently.
45
+ Raising a limit after a timeout is a legitimate reason to resume on a
46
+ changed configuration, and that is what the opt-in is for: it is one flag,
47
+ and the change is recorded.
48
+
49
+ Everything outside the fingerprint may change between steps without it:
50
+ loop bounds and the step budget, correction attempts, the merge gate and
51
+ its verification commands, the environment allow-list and the GitHub
52
+ client. They bound or verify what an agent does without choosing the agent
53
+ or its worktree, and each is read at the step that uses it.
54
+
55
+ ``sandbox.mode`` is not in the fingerprint either, but it may not weaken
56
+ between steps (#280, ADR 0005 D15.3 as amended): the run is pinned to the
57
+ mode it was created under (:mod:`autoforge.sandbox_pin`), in LOCAL and
58
+ REMOTE runs alike, and the same comparison
59
+ (:meth:`~autoforge.engine.ControllerEngine.check_run_configuration`) refuses
60
+ a weaker one beside any change here, with the same opt-in. It is kept out
61
+ of this record because the record refuses any drift and any schema but its
62
+ own, while the pin refuses only a weaker mode and must load in a run
63
+ created before it.
64
+
65
+ A state file written before this field existed has none: it loads, is not
66
+ checked, and the first ``resume``, ``step`` or ``unblock`` records the
67
+ configuration it runs on (source ``upgraded``), saved with that command's
68
+ next save.
69
+
70
+ What can be recorded
71
+ --------------------
72
+
73
+ What the record's parser (:meth:`RecordedConfiguration.from_dict`) reads
74
+ back, and nothing else: it runs on every load of the state file, so a
75
+ record it refuses would make the run unloadable. The configuration bounds
76
+ less than the parser does (a model of any length; an ``update_epic``
77
+ profile, which no command validates before the run reaches it, with an
78
+ empty provider or a zero limit), so the parser is also run on what is
79
+ about to be recorded, and a value it refuses is a :class:`ConfigurationError`
80
+ before anything is written. ``run`` refuses a routing at config validation
81
+ (:func:`fingerprint_routing`; its dry run and ``doctor`` too) and a path
82
+ when it records the run (:meth:`RecordedConfiguration.persisted`), and
83
+ ``resume``, ``step`` and ``unblock`` refuse to record or accept either.
84
+ """
85
+
86
+ from __future__ import annotations
87
+
88
+ import json
89
+ import os
90
+ from collections.abc import Callable
91
+ from dataclasses import dataclass
92
+ from pathlib import Path
93
+ from typing import TYPE_CHECKING, Any
94
+
95
+ from .errors import ConfigurationError, StateError
96
+ from .profiles import REQUIRED_PROFILES
97
+
98
+ if TYPE_CHECKING: # pragma: no cover - typing only
99
+ from .config import AutoForgeConfig, ProfileConfig
100
+
101
+ #: Schema of the persisted record. Bumped when a recorded key is added,
102
+ #: removed or reinterpreted; another schema is refused on load, never
103
+ #: migrated (a recorded configuration rebuilt from today's is no record).
104
+ FINGERPRINT_SCHEMA = 1
105
+
106
+ #: Every profile a REMOTE run can route to: the ones a run requires, and
107
+ #: ``update_epic``, which the run reaches after a merge. A profile the
108
+ #: configuration does not define is recorded as ``None``.
109
+ FINGERPRINT_PROFILES: tuple[str, ...] = (*REQUIRED_PROFILES, "update_epic")
110
+
111
+ #: How the record came to be: ``created`` by ``run``; ``upgraded`` by the
112
+ #: first command that found a state file written before #219 without one;
113
+ #: ``accepted`` by an operator's ``--accept-config-change``.
114
+ SOURCE_CREATED = "created"
115
+ SOURCE_UPGRADED = "upgraded"
116
+ SOURCE_ACCEPTED = "accepted"
117
+ SOURCES = (SOURCE_CREATED, SOURCE_UPGRADED, SOURCE_ACCEPTED)
118
+
119
+ #: What a refusal and the run log call the worktree root.
120
+ WORKTREE_ROOT_KEY = "agent worktree root (execution.worktree_dir)"
121
+
122
+ _RECORD_KEYS = ("schema", "profiles", "worktree_root", "config_file", "source", "recorded_at")
123
+ _PROFILE_KEYS = ("provider", "model", "effort", "idle_timeout_seconds", "max_runtime_seconds")
124
+ # Bounds on what a hand-edited file can make the controller carry and print.
125
+ _MAX_NAME_CHARS = 512
126
+ _MAX_PATH_CHARS = 4096
127
+ _MAX_TIMESTAMP_CHARS = 64
128
+
129
+
130
+ def _text(value: Any, where: str, *, bound: int, empty: bool = False) -> str:
131
+ if not isinstance(value, str) or len(value) > bound or (not empty and not value):
132
+ shape = "a string" if empty else "a non-empty string"
133
+ raise StateError(f"{where} must be {shape} of at most {bound} characters")
134
+ return value
135
+
136
+
137
+ def _limit(value: Any, where: str, *, optional: bool) -> int | None:
138
+ if value is None and optional:
139
+ return None
140
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
141
+ shape = "a positive integer or null" if optional else "a positive integer"
142
+ raise StateError(f"{where} must be {shape}")
143
+ return value
144
+
145
+
146
+ def _path(value: Any, where: str, *, empty: bool = False) -> str:
147
+ text = _text(value, where, bound=_MAX_PATH_CHARS, empty=empty)
148
+ if text and not os.path.isabs(text):
149
+ raise StateError(f"{where} must be an absolute path")
150
+ return text
151
+
152
+
153
+ def _keys(data: Any, keys: tuple[str, ...], where: str) -> dict:
154
+ if not isinstance(data, dict) or sorted(data) != sorted(keys):
155
+ raise StateError(f"{where} must hold exactly {', '.join(keys)}")
156
+ return data
157
+
158
+
159
+ def _recordable(parse: Callable[[], object]) -> None:
160
+ """Refuse (ConfigurationError) what is about to be recorded unless ``parse`` reads it back.
161
+
162
+ ``parse`` is the record's own parser run on it, the one definition of
163
+ what a state file may hold here; its refusal names the key, never the
164
+ value.
165
+ """
166
+ try:
167
+ parse()
168
+ except StateError as exc:
169
+ raise ConfigurationError(
170
+ f"{exc}: a REMOTE run records it in its state file, which could not be loaded "
171
+ "again otherwise (#219)"
172
+ ) from None
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class ProfileRouting:
177
+ """What one profile decides about the agent it launches."""
178
+
179
+ provider: str
180
+ model: str
181
+ effort: str
182
+ idle_timeout_seconds: int
183
+ max_runtime_seconds: int | None
184
+
185
+ @classmethod
186
+ def from_config(cls, cfg: AutoForgeConfig, profile: ProfileConfig) -> ProfileRouting:
187
+ """The profile's routing, its limits resolved against the ``execution`` defaults.
188
+
189
+ Resolved here rather than through ``AutoForgeConfig.agent_limits``,
190
+ which also refuses a provider that cannot report activity: recording
191
+ is not the place to refuse a profile, and the configured values are
192
+ what an operator changes. The one refusal is a routing its record
193
+ could not be read back with (ConfigurationError, see "What can be
194
+ recorded" above).
195
+ """
196
+ idle = profile.idle_timeout_seconds
197
+ ceiling = profile.max_runtime_seconds
198
+ routing = cls(
199
+ provider=profile.provider,
200
+ model=profile.model,
201
+ effort=profile.effort,
202
+ idle_timeout_seconds=(
203
+ idle if idle is not None else cfg.execution.default_idle_timeout_seconds
204
+ ),
205
+ max_runtime_seconds=(
206
+ ceiling if ceiling is not None else cfg.execution.default_max_runtime_seconds
207
+ ),
208
+ )
209
+ _recordable(lambda: cls.from_dict(routing.to_dict(), f"profiles.{profile.name}"))
210
+ return routing
211
+
212
+ def to_dict(self) -> dict:
213
+ return {key: getattr(self, key) for key in _PROFILE_KEYS}
214
+
215
+ @classmethod
216
+ def from_dict(cls, data: Any, where: str) -> ProfileRouting:
217
+ data = _keys(data, _PROFILE_KEYS, where)
218
+ return cls(
219
+ provider=_text(data["provider"], f"{where}.provider", bound=_MAX_NAME_CHARS),
220
+ model=_text(data["model"], f"{where}.model", bound=_MAX_NAME_CHARS, empty=True),
221
+ effort=_text(data["effort"], f"{where}.effort", bound=_MAX_NAME_CHARS, empty=True),
222
+ idle_timeout_seconds=_limit( # type: ignore[arg-type]
223
+ data["idle_timeout_seconds"], f"{where}.idle_timeout_seconds", optional=False
224
+ ),
225
+ max_runtime_seconds=_limit(
226
+ data["max_runtime_seconds"], f"{where}.max_runtime_seconds", optional=True
227
+ ),
228
+ )
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class ConfigChange:
233
+ """One recorded key whose value this invocation would change."""
234
+
235
+ key: str
236
+ recorded: str
237
+ current: str
238
+
239
+ def line(self) -> str:
240
+ return f"{self.key}: {self.recorded} -> {self.current}"
241
+
242
+ def to_dict(self) -> dict:
243
+ return {"key": self.key, "recorded": self.recorded, "current": self.current}
244
+
245
+
246
+ def fingerprint_routing(cfg: AutoForgeConfig) -> dict[str, ProfileRouting | None]:
247
+ """The routing of every profile in :data:`FINGERPRINT_PROFILES`, ``None`` when not configured.
248
+
249
+ ConfigurationError when one could not be recorded. A REMOTE run's
250
+ ``validate_config`` calls it, so ``run`` refuses such a configuration
251
+ before the run is created, its dry run included.
252
+ """
253
+ return {
254
+ name: ProfileRouting.from_config(cfg, cfg.profiles[name]) if name in cfg.profiles else None
255
+ for name in FINGERPRINT_PROFILES
256
+ }
257
+
258
+
259
+ def _shown(value: Any) -> str:
260
+ """A recorded value as a refusal prints it (JSON, so "" and null are visible)."""
261
+ return json.dumps(value)
262
+
263
+
264
+ def _shown_profile(routing: ProfileRouting | None) -> str:
265
+ return "(not configured)" if routing is None else _shown(routing.to_dict())
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class ConfigFingerprint:
270
+ """The compared part: per-profile routing and the agent worktree root."""
271
+
272
+ profiles: dict[str, ProfileRouting | None]
273
+ worktree_root: str
274
+
275
+ @classmethod
276
+ def from_config(cls, cfg: AutoForgeConfig, worktree_root: str | Path) -> ConfigFingerprint:
277
+ """ConfigurationError when a routing or the root could not be recorded."""
278
+ root = str(worktree_root)
279
+ profiles = fingerprint_routing(cfg)
280
+ _recordable(lambda: _path(root, WORKTREE_ROOT_KEY))
281
+ return cls(profiles=profiles, worktree_root=root)
282
+
283
+ def changes(self, current: ConfigFingerprint) -> list[ConfigChange]:
284
+ """Each key ``current`` would change, in a stable order; empty when none."""
285
+ out: list[ConfigChange] = []
286
+ for name in FINGERPRINT_PROFILES:
287
+ was, now = self.profiles.get(name), current.profiles.get(name)
288
+ if was == now:
289
+ continue
290
+ if was is None or now is None:
291
+ out.append(
292
+ ConfigChange(f"profiles.{name}", _shown_profile(was), _shown_profile(now))
293
+ )
294
+ continue
295
+ for key in _PROFILE_KEYS:
296
+ before, after = getattr(was, key), getattr(now, key)
297
+ if before != after:
298
+ out.append(
299
+ ConfigChange(f"profiles.{name}.{key}", _shown(before), _shown(after))
300
+ )
301
+ if self.worktree_root != current.worktree_root:
302
+ out.append(
303
+ ConfigChange(
304
+ WORKTREE_ROOT_KEY, _shown(self.worktree_root), _shown(current.worktree_root)
305
+ )
306
+ )
307
+ return out
308
+
309
+
310
+ @dataclass(frozen=True)
311
+ class RecordedConfiguration:
312
+ """The fingerprint as persisted, with where and how it was recorded."""
313
+
314
+ fingerprint: ConfigFingerprint
315
+ # The absolute path of the ``--config`` file it was recorded from; ""
316
+ # for the built-in defaults. Not compared: a moved file with the same
317
+ # content is the same configuration.
318
+ config_file: str
319
+ source: str
320
+ recorded_at: str
321
+
322
+ def to_dict(self) -> dict:
323
+ return {
324
+ "schema": FINGERPRINT_SCHEMA,
325
+ "profiles": {
326
+ name: (None if routing is None else routing.to_dict())
327
+ for name, routing in self.fingerprint.profiles.items()
328
+ },
329
+ "worktree_root": self.fingerprint.worktree_root,
330
+ "config_file": self.config_file,
331
+ "source": self.source,
332
+ "recorded_at": self.recorded_at,
333
+ }
334
+
335
+ def persisted(self) -> dict:
336
+ """:meth:`to_dict`, for the state file: ConfigurationError unless :meth:`from_dict`
337
+ reads it back, so no record that would make the run unloadable is written."""
338
+ data = self.to_dict()
339
+ _recordable(lambda: RecordedConfiguration.from_dict(data))
340
+ return data
341
+
342
+ @classmethod
343
+ def from_dict(cls, data: Any) -> RecordedConfiguration:
344
+ """Parse a persisted record strictly; StateError on any other shape."""
345
+ if isinstance(data, dict) and data.get("schema") != FINGERPRINT_SCHEMA:
346
+ raise StateError(
347
+ f"schema {data.get('schema')!r:.40} is not {FINGERPRINT_SCHEMA}, the one this "
348
+ "controller writes; a recorded configuration is never migrated"
349
+ )
350
+ data = _keys(data, _RECORD_KEYS, "the recorded configuration")
351
+ raw_profiles = _keys(data["profiles"], FINGERPRINT_PROFILES, "profiles")
352
+ profiles = {
353
+ name: (
354
+ None
355
+ if raw_profiles[name] is None
356
+ else ProfileRouting.from_dict(raw_profiles[name], f"profiles.{name}")
357
+ )
358
+ for name in FINGERPRINT_PROFILES
359
+ }
360
+ source = data["source"]
361
+ if source not in SOURCES:
362
+ raise StateError(f"source must be one of {', '.join(SOURCES)}")
363
+ return cls(
364
+ fingerprint=ConfigFingerprint(
365
+ profiles=profiles, worktree_root=_path(data["worktree_root"], "worktree_root")
366
+ ),
367
+ config_file=_path(data["config_file"], "config_file", empty=True),
368
+ source=source,
369
+ recorded_at=_text(data["recorded_at"], "recorded_at", bound=_MAX_TIMESTAMP_CHARS),
370
+ )
371
+
372
+ def origin(self) -> str:
373
+ """Where it was recorded from, for a message: the file, or the built-in defaults."""
374
+ return f"--config {self.config_file}" if self.config_file else "the built-in defaults"