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/config.py ADDED
@@ -0,0 +1,2178 @@
1
+ """Controller configuration layer.
2
+
3
+ Model selection is controller configuration — never hard-coded shell
4
+ commands in the engine. Users edit a config file (YAML/TOML/JSON) to change
5
+ the real CLI command / model identifiers; logical profile names stay stable.
6
+
7
+ Logical execution profiles (routing semantics are fixed in ``profiles.py``)::
8
+
9
+ analyze_execute Claude Code / Fable / high
10
+ fix Claude Code / Fable / high
11
+ review_round_1 OpenCode / GPT 5.6 Luna / high
12
+ review_round_2_5 OpenCode / GPT 5.6 Terra / high
13
+ review_round_6_plus OpenCode / GPT 5.6 Sol / medium
14
+ update_epic (future milestone; gated)
15
+
16
+ MERGE has no agent profile: the controller itself runs ``gh pr merge``
17
+ (see ``merge:`` below and ``GitHubClient.merge_pr``), behind the merge gate.
18
+
19
+ The *real* model identifiers below were checked against the locally
20
+ installed CLIs (``claude --help``, ``opencode models``); change them in the
21
+ config file, never by editing routing code.
22
+
23
+ Supported config file formats:
24
+ .toml — stdlib tomllib (always available)
25
+ .json — stdlib json (always available)
26
+ .yaml/.yml — PyYAML if installed, else a minimal built-in subset parser
27
+ sufficient for the documented example file (nested maps with
28
+ 2-space indent, lists with "- ", scalars, quoted strings).
29
+
30
+ The two YAML backends must never read the same file differently: what the
31
+ subset parser accepts it resolves exactly as PyYAML's YAML 1.1 implicit
32
+ resolvers do, and what it cannot resolve that way it refuses outright rather
33
+ than keeping as the string it looks like. See the note above ``_scalar``.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import fnmatch
39
+ import hashlib
40
+ import json
41
+ import os
42
+ import re
43
+ from collections.abc import Iterator
44
+ from dataclasses import asdict, dataclass, field, replace
45
+ from pathlib import Path
46
+
47
+ from . import __prompt_version__
48
+ from .child_env import refuse_forbidden_env
49
+ from .errors import ConfigurationError, SandboxPolicyError, ValidationDefinitionError
50
+ from .evidence import SCOPE, check_definitions, check_scope
51
+ from .executor import MAX_DEADLINE_SECONDS, is_env_pattern
52
+ from .local_workspace import (
53
+ DEFAULT_MAX_BYTES,
54
+ DEFAULT_MAX_ENTRIES,
55
+ normalize_exclude_pattern,
56
+ )
57
+ from .sandbox import check_declared_path
58
+
59
+ CONFIG_VERSION = 1
60
+
61
+ # An agent is killed once it has written nothing for this long (#193): above
62
+ # the longest silent stretch a working agent has, which is usually a tool
63
+ # call that prints nothing until it returns (a long test run).
64
+ DEFAULT_IDLE_TIMEOUT_SECONDS = 900
65
+ # The wall-clock limit of each repository-defined command the controller
66
+ # runs itself (pre-merge verification, LOCAL validation).
67
+ DEFAULT_COMMAND_TIMEOUT_SECONDS = 1800
68
+
69
+ KNOWN_PROVIDERS = ("claude", "opencode", "pi", "scripted", "claude-sdk", "opencode-sdk")
70
+
71
+
72
+ # Where a REMOTE run keeps its state, relative to the invocation directory.
73
+ # A LOCAL run defaults elsewhere -- see
74
+ # :meth:`autoforge.engine.ControllerEngine.bind_local_state_dir` -- because its
75
+ # fingerprint covers the whole working tree and runtime state must not be in it.
76
+ DEFAULT_STATE_DIR = ".autoforge"
77
+
78
+
79
+ @dataclass
80
+ class ProfileConfig:
81
+ """One logical execution profile -> concrete provider/model/effort.
82
+
83
+ ``options`` carries provider-specific knobs the adapter understands
84
+ (e.g. ``permission_mode`` for Claude Code, ``auto_approve`` for
85
+ OpenCode, ``require_oauth`` for Pi, ``interaction_policy`` and its limits
86
+ for the SDK providers); each real adapter names the keys it accepts and
87
+ rejects any other. ``extra_args`` are appended verbatim before the prompt
88
+ (Pi and the SDK providers accept none: docs/sdk-policy.md).
89
+ """
90
+
91
+ name: str
92
+ # "claude" | "opencode" | "pi" | "scripted" | "claude-sdk" | "opencode-sdk"
93
+ provider: str
94
+ model: str = ""
95
+ effort: str = "high"
96
+ command: str = ""
97
+ extra_args: list[str] = field(default_factory=list)
98
+ # The agent's limits (#193); ``None`` falls back to
99
+ # ``execution.default_idle_timeout_seconds`` /
100
+ # ``execution.default_max_runtime_seconds``. Resolved, together with what
101
+ # the provider can report, by :meth:`AutoForgeConfig.agent_limits`.
102
+ idle_timeout_seconds: int | None = None
103
+ max_runtime_seconds: int | None = None
104
+ options: dict[str, str] = field(default_factory=dict)
105
+
106
+ def build_command(self, prompt: str) -> list[str]:
107
+ """Render the real CLI argv via the provider adapter (see providers.py)."""
108
+ from .providers import provider_for # local import: providers depends on config
109
+
110
+ return provider_for(self).build_command_for(self, prompt)
111
+
112
+
113
+ # The keys a profile mapping under `profiles:` may contain. `options` is
114
+ # itself a mapping of provider-specific knobs; its *contents* are the
115
+ # provider adapter's to validate (each real adapter declares the keys it
116
+ # accepts, ``AgentProvider.option_keys``), its presence is checked here.
117
+ PROFILE_KEYS = (
118
+ "provider",
119
+ "model",
120
+ "effort",
121
+ "command",
122
+ "extra_args",
123
+ "idle_timeout_seconds",
124
+ "max_runtime_seconds",
125
+ "options",
126
+ )
127
+
128
+ # A profile key that used to be read, and where it went: rejected with that
129
+ # message rather than reinterpreted (see ``REMOVED_EXECUTION_KEYS``).
130
+ REMOVED_PROFILE_KEYS = {
131
+ "timeout_seconds": (
132
+ "'timeout_seconds' is no longer supported: an agent is now killed only after "
133
+ "making no progress (no output) for 'idle_timeout_seconds' (default: "
134
+ "execution.default_idle_timeout_seconds, {idle}s), with an optional wall-clock "
135
+ "ceiling in 'max_runtime_seconds'. Replace the key with one or both"
136
+ ),
137
+ }
138
+
139
+ EXECUTION_KEYS = (
140
+ "default_idle_timeout_seconds",
141
+ "default_max_runtime_seconds",
142
+ "command_timeout_seconds",
143
+ "max_correction_attempts",
144
+ "env_allowlist",
145
+ "env_allowlist_extra",
146
+ "worktree_dir",
147
+ "loop_detection",
148
+ )
149
+
150
+ # What the loop detector (#194, ``autoforge.loop_detect``) does once an agent
151
+ # that keeps producing output repeats itself with nothing new: ``kill`` ends
152
+ # the invocation like a timeout, ``warn`` only says so in the progress output
153
+ # and ``execution.json``, ``off`` says nothing. Every mode records the
154
+ # calibration figures in ``provider_summary``. The default is ``kill`` (#199):
155
+ # real runs stayed below half of every default threshold (the margins are in
156
+ # docs/configuration.md, "How the defaults were set").
157
+ LOOP_DETECTION_MODES = ("kill", "warn", "off")
158
+ # The providers and phases no real run had recorded the calibration figures
159
+ # for when #199 made ``kill`` the default. Until they have, an invocation on
160
+ # one of them only warns unless ``mode`` is set (``mode: kill`` kills there
161
+ # too); see ``LoopDetectionConfig.for_invocation``.
162
+ LOOP_DETECTION_UNCALIBRATED_PROVIDERS = ("pi",)
163
+ LOOP_DETECTION_UNCALIBRATED_PHASES = ("UPDATE_EPIC",)
164
+ LOOP_DETECTION_KEYS = (
165
+ "mode",
166
+ "max_cycle_period",
167
+ "max_cycle_repeats",
168
+ "novelty_window_seconds",
169
+ "max_line_repeats",
170
+ )
171
+ # Upper bounds that keep the detector's per-action work and memory small: a
172
+ # cycle longer than this is not one an operator would call a loop, and a
173
+ # repeat count past the cap is no threshold at all.
174
+ MAX_LOOP_CYCLE_PERIOD = 16
175
+ MAX_LOOP_REPEATS = 100_000
176
+
177
+ # The environment an agent, a pre-merge verification command and a LOCAL
178
+ # validation command start from: these variables of the operator's
179
+ # environment and nothing else (see ``autoforge.executor.select_environment``).
180
+ # What a coding agent needs to find its tools, its home configuration, its
181
+ # locale and its temporary directory; what ``git`` needs to identify the
182
+ # author; and what an HTTPS client needs behind a corporate proxy or a
183
+ # private CA. No GitHub or git credential route is here, and none can be
184
+ # added (``autoforge.child_env.FORBIDDEN_ENV_NAMES``, #165): gh's tokens and
185
+ # configuration location, the SSH agent socket, git's ssh and askpass
186
+ # programs and its environment configuration are refused at load, and the
187
+ # controller sets the gh and git configuration a child sees itself, and its
188
+ # ``SHELL`` (a shell that reads no startup file of the operator's,
189
+ # ``autoforge.child_env.child_shell``). The API keys
190
+ # a provider's own CLI reads are contributed by the provider adapter
191
+ # (``AgentProvider.environment_names``), not listed here, so an OpenCode
192
+ # launch never carries an Anthropic key it has no use for. A trailing ``*``
193
+ # is a prefix. Anything else the operator's shell holds -- a cloud
194
+ # credential, a database password, another project's token -- is not
195
+ # inherited; ``env_allowlist_extra`` adds a name without restating this list.
196
+ DEFAULT_ENV_ALLOWLIST: tuple[str, ...] = (
197
+ "PATH",
198
+ "HOME",
199
+ "USER",
200
+ "LOGNAME",
201
+ "TERM",
202
+ "TMPDIR",
203
+ "TZ",
204
+ "LANG",
205
+ "LANGUAGE",
206
+ "LC_*",
207
+ "XDG_CONFIG_HOME",
208
+ "XDG_DATA_HOME",
209
+ "XDG_CACHE_HOME",
210
+ "XDG_STATE_HOME",
211
+ "GIT_AUTHOR_NAME",
212
+ "GIT_AUTHOR_EMAIL",
213
+ "GIT_COMMITTER_NAME",
214
+ "GIT_COMMITTER_EMAIL",
215
+ "HTTP_PROXY",
216
+ "HTTPS_PROXY",
217
+ "NO_PROXY",
218
+ "http_proxy",
219
+ "https_proxy",
220
+ "no_proxy",
221
+ "SSL_CERT_FILE",
222
+ "SSL_CERT_DIR",
223
+ "NODE_EXTRA_CA_CERTS",
224
+ "REQUESTS_CA_BUNDLE",
225
+ "CURL_CA_BUNDLE",
226
+ )
227
+
228
+
229
+ @dataclass
230
+ class LoopDetectionConfig:
231
+ """``execution.loop_detection`` (#194): the mode and the thresholds.
232
+
233
+ A cycle of up to ``max_cycle_period`` tool calls (same name, input and
234
+ result) repeated ``max_cycle_repeats`` times in a row; no tool call the
235
+ invocation had not seen for ``novelty_window_seconds`` while at least
236
+ ``max_cycle_period * max_cycle_repeats`` of them were replayed; a line of
237
+ unstructured output, or a cycle of up to ``max_cycle_period`` lines,
238
+ repeated ``max_line_repeats`` times in a row. See ``autoforge.loop_detect``.
239
+
240
+ ``mode`` ``None`` is a config that does not set it: ``kill``, except on
241
+ an uncalibrated provider or phase, which only warns (#199). A launch runs
242
+ under :meth:`for_invocation`, which always names the mode.
243
+ """
244
+
245
+ mode: str | None = None
246
+ max_cycle_period: int = 4
247
+ max_cycle_repeats: int = 8
248
+ novelty_window_seconds: int = 1800
249
+ max_line_repeats: int = 200
250
+
251
+ def for_invocation(self, provider: str, phase: str) -> LoopDetectionConfig:
252
+ """This config with the mode one invocation of ``provider`` in
253
+ ``phase`` runs under; a mode the config sets applies everywhere."""
254
+ if self.mode is not None:
255
+ return self
256
+ uncalibrated = (
257
+ provider in LOOP_DETECTION_UNCALIBRATED_PROVIDERS
258
+ or phase in LOOP_DETECTION_UNCALIBRATED_PHASES
259
+ )
260
+ return replace(self, mode="warn" if uncalibrated else "kill")
261
+
262
+ def describe(self) -> str:
263
+ """The mode and thresholds, for the dry-run plan and ``doctor``."""
264
+ mode = self.mode or (
265
+ "kill (warn on provider "
266
+ + ", ".join(LOOP_DETECTION_UNCALIBRATED_PROVIDERS)
267
+ + " and in "
268
+ + ", ".join(LOOP_DETECTION_UNCALIBRATED_PHASES)
269
+ + ": not calibrated yet)"
270
+ )
271
+ return (
272
+ f"loop detection {mode} (cycles of up to {self.max_cycle_period} actions "
273
+ f"x{self.max_cycle_repeats}, no new action for {self.novelty_window_seconds}s, "
274
+ f"repeated lines x{self.max_line_repeats})"
275
+ )
276
+
277
+ def as_dict(self) -> dict[str, object]:
278
+ """The same, for ``request.json`` and ``execution.json``."""
279
+ return {
280
+ "mode": self.mode,
281
+ "max_cycle_period": self.max_cycle_period,
282
+ "max_cycle_repeats": self.max_cycle_repeats,
283
+ "novelty_window_seconds": self.novelty_window_seconds,
284
+ "max_line_repeats": self.max_line_repeats,
285
+ }
286
+
287
+
288
+ @dataclass
289
+ class ExecutionConfig:
290
+ # The limits of a profile that sets none of its own (#193): an agent is
291
+ # killed once it has written nothing for ``default_idle_timeout_seconds``,
292
+ # and, when ``default_max_runtime_seconds`` is set, once it has run that
293
+ # long whatever it writes. ``None``: no wall-clock ceiling.
294
+ default_idle_timeout_seconds: int = DEFAULT_IDLE_TIMEOUT_SECONDS
295
+ default_max_runtime_seconds: int | None = None
296
+ # The wall-clock limit of a pre-merge verification command and of a
297
+ # LOCAL validation command; agents never use it.
298
+ command_timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS
299
+ # How many times a *malformed CONTROL_RESULT* (exit 0) triggers a
300
+ # correction prompt before the step fails. 0 disables correction.
301
+ max_correction_attempts: int = 1
302
+ # The environment agents and repository-defined commands start from
303
+ # (``env_allowlist`` replaces the default list; ``env_allowlist_extra``
304
+ # adds to whichever list is in force). See ``DEFAULT_ENV_ALLOWLIST``.
305
+ env_allowlist: list[str] = field(default_factory=lambda: list(DEFAULT_ENV_ALLOWLIST))
306
+ env_allowlist_extra: list[str] = field(default_factory=list)
307
+ # Where a REMOTE run's per-issue agent worktrees are created. Empty (the
308
+ # default): ``<git common dir>/autoforge/worktrees``, outside every
309
+ # working tree of the checkout; otherwise a directory, relative to the
310
+ # controller's working directory, that holds one worktree per issue.
311
+ worktree_dir: str = ""
312
+ # What an agent that keeps producing output but loops runs into (#194).
313
+ loop_detection: LoopDetectionConfig = field(default_factory=LoopDetectionConfig)
314
+
315
+ def environment_names(self) -> tuple[str, ...]:
316
+ """The allow-list in force: the base list plus the additions, deduplicated."""
317
+ return tuple(dict.fromkeys([*self.env_allowlist, *self.env_allowlist_extra]))
318
+
319
+
320
+ # Paths whose contents define what the hosted checks actually run. See
321
+ # ``SafetyConfig.protected_merge_paths``.
322
+ DEFAULT_PROTECTED_MERGE_PATHS = (".github/workflows/",)
323
+
324
+
325
+ # The keys `safety:` may contain. Anything else is a hard error: a typo such
326
+ # as `allow_merges: true` must not fail closed *silently*, because the operator
327
+ # then believes the gate is in the state they wrote, not the state it is in.
328
+ # Every other section holds itself to the same rule (see `_section`): most of
329
+ # them carry loop bounds or merge behaviour, where a misspelled key keeps a
330
+ # looser built-in bound than the operator wrote.
331
+ SAFETY_KEYS = (
332
+ "acceptance_actions",
333
+ "allow_merge",
334
+ "protected_merge_paths",
335
+ "required_checks",
336
+ "verify_check_definition",
337
+ )
338
+
339
+ # `safety.required_checks` when no config file sets it: the aggregate check
340
+ # name of this repository's own `.github/workflows/ci.yml`.
341
+ DEFAULT_REQUIRED_CHECKS = ("ci",)
342
+
343
+ # `safety.allow_merge` when no config file sets it.
344
+ DEFAULT_ALLOW_MERGE_SOURCE = "built-in default"
345
+
346
+ # `safety.acceptance_actions` (#211): whether a REMOTE fixer may resolve a
347
+ # finding by asking the controller to publish an acceptance comment on the
348
+ # run's own issue, and who authorizes that one write. `off` refuses the
349
+ # resolution; `approve` (the default) publishes it only after the operator
350
+ # approves its action id (`autoforge unblock --approve-action`); `auto`
351
+ # publishes it during the FIX step with no human.
352
+ ACCEPTANCE_ACTIONS_OFF = "off"
353
+ ACCEPTANCE_ACTIONS_APPROVE = "approve"
354
+ ACCEPTANCE_ACTIONS_AUTO = "auto"
355
+ ACCEPTANCE_ACTION_POLICIES = (
356
+ ACCEPTANCE_ACTIONS_OFF,
357
+ ACCEPTANCE_ACTIONS_APPROVE,
358
+ ACCEPTANCE_ACTIONS_AUTO,
359
+ )
360
+
361
+
362
+ @dataclass
363
+ class SafetyConfig:
364
+ # Controller invariant: no real merge unless this is true AND the CLI
365
+ # passes --allow-merge. Default off for this milestone. This is the ONLY
366
+ # key that opens the gate: the historical `execution.allow_merge` is
367
+ # rejected on load rather than read, so two keys can never disagree about
368
+ # the most dangerous setting in the file.
369
+ allow_merge: bool = False
370
+ # Where `allow_merge` came from (`<config file>: safety.allow_merge`, or
371
+ # the built-in default) so `doctor` can show the effective gate state and
372
+ # the operator can find the line that set it.
373
+ allow_merge_source: str = DEFAULT_ALLOW_MERGE_SOURCE
374
+ # Paths a PR may not change and still be merged unattended. The hosted
375
+ # checks the merge gate trusts ("every check on the PR succeeded") are
376
+ # defined by the workflow files *in the PR itself*: GitHub runs the PR's
377
+ # version of `.github/workflows/` and reports the result under the same
378
+ # check name, so a PR that edits them also edits the meaning of its own
379
+ # green result. The controller cannot verify that from the outside, so
380
+ # it refuses to merge such a PR unattended and leaves it to a human.
381
+ # Entries ending in "/" are directory prefixes; anything else is an exact
382
+ # path or an fnmatch pattern (where "*" also spans "/"). An empty list
383
+ # disables the gate.
384
+ protected_merge_paths: list[str] = field(
385
+ default_factory=lambda: list(DEFAULT_PROTECTED_MERGE_PATHS)
386
+ )
387
+ # Status-check contexts the default branch is expected to *require*
388
+ # (repository ruleset or classic branch protection). The merge gate
389
+ # trusts "every check on the PR passed" and refuses a rollup with no
390
+ # check at all, or no successful one (#289), but which checks had to
391
+ # exist is a branch rule. `autoforge doctor` reads the effective branch
392
+ # rules and fails when one of these contexts is not required, or, with
393
+ # `allow_merge` true, when it cannot verify the rules (#289). An empty
394
+ # list only requires that *some* status check is required.
395
+ required_checks: list[str] = field(default_factory=lambda: list(DEFAULT_REQUIRED_CHECKS))
396
+ # Whether READY_FOR_MERGE / MERGE also verify *where* each required check
397
+ # came from. A green `ci` proves that the workflow defining it ran to
398
+ # completion on the PR; it does not prove that the workflow was the one
399
+ # the base branch defines. With this on, the controller resolves each
400
+ # `required_checks` context to the GitHub Actions run that produced it,
401
+ # requires that run to be at the reviewed HEAD, and requires its job and
402
+ # step structure (job names, step names in order) to equal that of the
403
+ # base branch's own most recent run of the same workflow at the base
404
+ # branch's current tip. A redefined, extended or trimmed workflow is a
405
+ # difference and BLOCKS the merge; so does a required context that is not
406
+ # an Actions check run, appears more than once, or has no base-branch run
407
+ # to compare against. This is a check on the *definition* that produced
408
+ # the green result, complementary to `protected_merge_paths` (which reads
409
+ # the PR's file list instead of GitHub's run record); neither proves what
410
+ # the tests in the PR assert (see `merge.verification_commands`).
411
+ verify_check_definition: bool = True
412
+ # Who may authorize the one external action a fixer can request, an
413
+ # acceptance comment on the run's own issue (#211): nobody (`off`), the
414
+ # operator per action id (`approve`) or this setting (`auto`). The
415
+ # value is recorded into the action when the FIX plan is saved, so a
416
+ # later change never widens an authorization already persisted.
417
+ acceptance_actions: str = ACCEPTANCE_ACTIONS_APPROVE
418
+
419
+ def protects(self, path: str) -> bool:
420
+ """Whether ``path`` (a POSIX repo-relative path) is protected."""
421
+ for pattern in self.protected_merge_paths:
422
+ if pattern.endswith("/"):
423
+ if path == pattern[:-1] or path.startswith(pattern):
424
+ return True
425
+ elif path == pattern or fnmatch.fnmatchcase(path, pattern):
426
+ return True
427
+ return False
428
+
429
+
430
+ GITHUB_KEYS = ("command", "timeout_seconds")
431
+
432
+
433
+ @dataclass
434
+ class GitHubConfig:
435
+ command: str = "gh"
436
+ timeout_seconds: int = 120
437
+
438
+
439
+ MERGE_METHODS = ("squash", "merge", "rebase")
440
+
441
+ MERGE_KEYS = ("method", "delete_branch", "max_verification_attempts", "verification_commands")
442
+
443
+
444
+ @dataclass
445
+ class MergeConfig:
446
+ """How the *controller* merges once the gate is open (never an agent)."""
447
+
448
+ method: str = "squash" # squash | merge | rebase -> gh pr merge --<method>
449
+ # After a verified merge, delete the PR's head branch on GitHub only (a
450
+ # scoped REST call, never `gh pr merge --delete-branch`, #283).
451
+ delete_branch: bool = False
452
+ # Inconclusive GitHub data (mergeability UNKNOWN, checks still running,
453
+ # post-merge re-read failed) is re-checked on `resume` at most this many
454
+ # times per phase; then the run is BLOCKED instead of retrying forever.
455
+ max_verification_attempts: int = 5
456
+ # Controller-owned verification of the reviewed HEAD *before* the merge,
457
+ # on the operator's machine. The hosted check runs the PR's own code, so
458
+ # a PR that weakens what its tests assert produces a genuine green check
459
+ # without touching any protected path; nothing GitHub reports can tell
460
+ # that apart from a real pass. These argv arrays (never shell strings)
461
+ # are the controller's own evidence: once every GitHub-side check has
462
+ # passed, the exact reviewed commit is exported -- its committed bytes,
463
+ # proven against the commit's tree (`materialize.py`), into a private
464
+ # temporary directory, never the operator's checkout, never a worktree
465
+ # or branch -- and each command runs there in order, under
466
+ # `execution.command_timeout_seconds`. Any non-zero exit or timeout
467
+ # BLOCKS the merge for a human. A pass is
468
+ # persisted against the HEAD and the command list, so MERGE does not
469
+ # repeat it for the same commit. An empty list disables this
470
+ # verification: the commands are the project's own (`pytest`, `make
471
+ # check`, ...) and there is no build-system detection.
472
+ verification_commands: list[list[str]] = field(default_factory=list)
473
+
474
+
475
+ # The parallel EPIC scheduler's bounds (ADR 0008 D18.3, D18.4): no unbounded
476
+ # mode and no CPU-count default. ``scheduler.json`` (``SchedulerConfig``)
477
+ # enforces the same constants.
478
+ MAX_PARALLEL_BOUNDS = (1, 4)
479
+ POLL_INTERVAL_BOUNDS = (15, 3600)
480
+ STOP_GRACE_BOUNDS = (10, 600)
481
+
482
+ SCHEDULER_KEYS = ("max_parallel", "poll_interval_seconds", "stop_grace_seconds")
483
+
484
+
485
+ @dataclass
486
+ class EpicSchedulerConfig:
487
+ """How ``autoforge epic`` runs one EPIC's issues in parallel (ADR 0008 D18; #182).
488
+
489
+ ``max_parallel`` is the number of issue workers live at once (1-4).
490
+ ``poll_interval_seconds`` is the least time between two EPIC snapshots
491
+ while admission is open. ``stop_grace_seconds`` is how long a stop or a
492
+ first Ctrl-C waits for the workers to return after it interrupted them:
493
+ above the executor's agent teardown plus a state save. Workers never
494
+ merge, whatever ``safety.allow_merge`` says.
495
+ """
496
+
497
+ max_parallel: int = 2
498
+ poll_interval_seconds: int = 60
499
+ stop_grace_seconds: int = 30
500
+
501
+
502
+ VALIDATION_KEYS = ("commands", "scope")
503
+
504
+
505
+ @dataclass
506
+ class ValidationConfig:
507
+ """REMOTE candidate validation (``validation:`` in the config file; ADR 0006 D3.1).
508
+
509
+ ``commands`` are trusted argv arrays (never shell strings), empty by
510
+ default: the controller runs each one, in order, in a byte-exact export
511
+ of one exact candidate commit, under ``execution.command_timeout_seconds``
512
+ and ``execution.env_allowlist``, and records what it observed in a
513
+ ``ValidationRecord`` in the state file. They are read from this
514
+ configuration only, never from the candidate tree, an agent result or
515
+ issue text. At most 16 definitions; an argv holds at most 32 elements,
516
+ each non-blank and at most 1,024 characters, and 4,096 in all (D10.3).
517
+
518
+ ``scope`` is ``candidate-tree``, the only scope there is (D4.2): a
519
+ definition depends on the candidate's bytes alone. Any other value is
520
+ refused.
521
+
522
+ Separate from ``merge.verification_commands``, which keeps running at
523
+ every merge gate pass; the same commands may be configured in both.
524
+ """
525
+
526
+ commands: list[list[str]] = field(default_factory=list)
527
+ scope: str = SCOPE
528
+
529
+
530
+ REPLAN_KEYS = (
531
+ "enabled",
532
+ "soft_threshold",
533
+ "hard_threshold",
534
+ "stagnation_window",
535
+ "max_findings_per_round",
536
+ "max_replans_per_issue",
537
+ )
538
+
539
+
540
+ @dataclass
541
+ class ReplanConfig:
542
+ """Controller policy for abandoning a non-converging implementation."""
543
+
544
+ enabled: bool = True
545
+ # First review round from which the controller may *replace* an
546
+ # implementation instead of continuing to patch it. Every stagnation
547
+ # trigger is gated behind it, including `workflow.stagnation_*`: below
548
+ # this round a stagnant loop still ends in BLOCKED for a human, which is
549
+ # far less destructive than discarding a PR after one ineffective FIX.
550
+ soft_threshold: int = 12
551
+ # Review round at which findings trigger a replan unconditionally.
552
+ hard_threshold: int = 20
553
+ # Window rule, from `soft_threshold`: this many trailing review rounds
554
+ # with findings, each holding at most `max_findings_per_round`, trigger a
555
+ # replan. It deliberately counts rounds of entirely new findings too --
556
+ # recurrence is what `workflow.stagnation_*` detects, and that verdict is
557
+ # already a replan trigger from the same round; this rule exists for the
558
+ # long tail of small, fresh findings that never ends.
559
+ stagnation_window: int = 3
560
+ max_findings_per_round: int = 2
561
+ # Counts only completed REPLAN_REEXECUTE lifecycles; initial implementation
562
+ # is not a replan.
563
+ max_replans_per_issue: int = 2
564
+
565
+
566
+ REVIEW_KEYS = ("replan",)
567
+
568
+
569
+ @dataclass
570
+ class ReviewConfig:
571
+ replan: ReplanConfig = field(default_factory=ReplanConfig)
572
+
573
+
574
+ WORKFLOW_KEYS = (
575
+ "max_review_rounds",
576
+ "stagnation_identical_rounds",
577
+ "stagnation_unchanged_count_rounds",
578
+ "max_total_steps",
579
+ "epic_update_every",
580
+ )
581
+
582
+
583
+ @dataclass
584
+ class WorkflowConfig:
585
+ """Controller-owned bounds on the workflow loop (see ``loop_guard.py``).
586
+
587
+ Every bound is enforced from persisted state, so ``resume`` continues
588
+ the same budget instead of starting a fresh one. Reaching a bound enters
589
+ BLOCKED with an explicit reason; nothing is merged, pushed or retried.
590
+ """
591
+
592
+ # Review rounds a single PR may consume. Round N with findings, where
593
+ # N == max_review_rounds, is BLOCKED instead of starting another FIX
594
+ # (a FIX whose result could never be reviewed is never invoked); a
595
+ # clean round N still reaches READY_FOR_MERGE. Round N+1 never starts.
596
+ max_review_rounds: int = 20
597
+ # Consecutive review rounds with findings whose required resolutions are
598
+ # identical (normalised text) before the loop is declared stagnant.
599
+ # 0 disables this rule (the hard cap above still applies); 1 is rejected
600
+ # because the rule compares rounds against each other.
601
+ stagnation_identical_rounds: int = 2
602
+ # Consecutive review rounds with findings whose finding *count* never
603
+ # changed, while at least one required resolution recurs within those
604
+ # rounds (A/B/A ping-pong), before the loop is declared stagnant. Rounds
605
+ # of entirely new findings are progress and only meet the cap above.
606
+ # 0 disables this rule; 1 is rejected (a one-round window can hold no
607
+ # recurrence, so it would disable the rule while looking enabled).
608
+ stagnation_unchanged_count_rounds: int = 3
609
+ # Cumulative executed steps for the whole run (all issues, all phases,
610
+ # across `resume`). Persisted as ``step_count``; the CLI's ``--max-steps``
611
+ # is only a per-invocation slice of this budget.
612
+ max_total_steps: int = 300
613
+ # Merged PRs per EPIC roadmap update. UPDATE_EPIC still runs after every
614
+ # MERGE (it is where the next issue is chosen), but the controller only
615
+ # asks for, writes and reads back the EPIC's managed roadmap section, and
616
+ # resets ``merged_since_epic_update``, once that many merges have
617
+ # accumulated since the last verified write. 1 updates after every merge.
618
+ epic_update_every: int = 1
619
+
620
+
621
+ LOCAL_KEYS = (
622
+ "feature_dir",
623
+ "validation_commands",
624
+ "max_fix_rounds",
625
+ "exclude",
626
+ "max_workspace_entries",
627
+ "max_workspace_bytes",
628
+ )
629
+
630
+
631
+ @dataclass
632
+ class LocalConfig:
633
+ """LOCAL-mode settings (``local:`` in the config file).
634
+
635
+ ``validation_commands`` are **controller-owned** checks: argv arrays, run
636
+ through :mod:`autoforge.executor` with no shell, after every local
637
+ implementation and fix phase. A non-zero exit means the phase is not
638
+ successfully verified. There is no automatic build-system detection: what
639
+ is not configured here is not run.
640
+
641
+ ``exclude`` is the *only* way to remove anything from the workspace
642
+ snapshot that binds a LOCAL review (the repository's own git directory
643
+ aside, which is identified by inode). A LOCAL run hashes every entry of
644
+ the working tree, ignored files included, because the alternative is
645
+ letting a ``.gitignore`` edit decide which bytes a review covers. That
646
+ completeness has a cost the operator has to pay explicitly: build output,
647
+ virtual environments and caches are rewritten by the very validation
648
+ commands the controller runs, so they must be declared unreviewed rather
649
+ than silently tolerated. Each pattern is matched component-wise against
650
+ repository-relative paths; ``*`` and ``?`` match within one component and
651
+ ``**`` spans any number of them. The patterns are hashed into the
652
+ fingerprint and named to the review agent in its prompt, so "what was not
653
+ reviewed" is part of the review's identity rather than a local detail.
654
+
655
+ ``max_workspace_entries`` / ``max_workspace_bytes`` bound the walk. They
656
+ are refusal thresholds, not sampling thresholds: exceeding one fails the
657
+ run with the largest subtrees named, because a fingerprint that fell back
658
+ to metadata for the rest would accept an equal-sized replacement with a
659
+ restored mtime.
660
+ """
661
+
662
+ # Where ``autoforge local init`` writes feature specifications. Project
663
+ # data, never under the runtime state directory.
664
+ feature_dir: str = "features"
665
+ validation_commands: list[list[str]] = field(default_factory=list)
666
+ # Local review/fix bound: the initial REVIEW, at most this many FIX
667
+ # rounds, then a final REVIEW. 0 disables FIX entirely (one review pass).
668
+ max_fix_rounds: int = 1
669
+ # Declared-unreviewed regions of the working tree (see the class docstring).
670
+ exclude: list[str] = field(default_factory=list)
671
+ max_workspace_entries: int = DEFAULT_MAX_ENTRIES
672
+ max_workspace_bytes: int = DEFAULT_MAX_BYTES
673
+
674
+ @property
675
+ def max_review_rounds(self) -> int:
676
+ """Review passes a local run may complete (fix rounds + the first)."""
677
+ return self.max_fix_rounds + 1
678
+
679
+
680
+ SANDBOX_DISABLED = "disabled"
681
+ SANDBOX_PREFERRED = "preferred"
682
+ SANDBOX_REQUIRED = "required"
683
+ SANDBOX_MODES = (SANDBOX_DISABLED, SANDBOX_PREFERRED, SANDBOX_REQUIRED)
684
+ SANDBOX_NETWORKS = ("host", "isolated")
685
+ SANDBOX_KEYS = ("mode", "validation_network", "premerge_network", "read_only_paths")
686
+
687
+
688
+ @dataclass
689
+ class SandboxConfig:
690
+ """The outer sandbox (``sandbox:`` in the config file; ADR 0005 D12, D9.2, D15.4).
691
+
692
+ ``mode`` is one of:
693
+
694
+ - ``disabled`` (the default): logical isolation only (#165); no launch
695
+ is sandboxed, and ``doctor``, the plan and ``status`` say so;
696
+ - ``required``: every agent launch, LOCAL validation command and
697
+ pre-merge verification command runs inside the outer sandbox, or is
698
+ refused; it never runs without it. A host that cannot enforce is
699
+ refused by the pre-launch gate, before anything is saved or charged;
700
+ - ``preferred``: in a LOCAL run, enforced when the host can, else run
701
+ with logical isolation only, visibly (the plan, ``status``, stderr at
702
+ the launch, every execution record); in a REMOTE run, exactly
703
+ ``required``, since nobody watches an unattended fallback (D12.2).
704
+
705
+ Not part of LOCAL's run contract (D12.3): it decides how a launch is
706
+ contained, not what the run is. A run is pinned to the mode it is created
707
+ under instead (#280, :mod:`autoforge.sandbox_pin`): a later command under
708
+ a stronger mode moves the pin up, and one under a weaker mode is refused
709
+ unless the operator accepts it with ``--accept-config-change``. Under
710
+ ``required`` or ``preferred`` the ``--config`` file must lie outside every
711
+ tree the repository's enforced launches write and every directory a
712
+ provider's launches are given read-write, or every command refuses it
713
+ (:func:`autoforge.engine.config_file_placement_problem`). The candidate
714
+ read and the strict import of an issue's private objects (D6.3-D6.5)
715
+ apply in every mode.
716
+
717
+ ``validation_network`` and ``premerge_network`` are ``host`` (the
718
+ default) or ``isolated``: a validation command (LOCAL, or REMOTE
719
+ ``validation.commands`` in a candidate's export), or a pre-merge
720
+ verification command, then runs with no network at all (no loopback, no
721
+ abstract socket, no download). Agents always share the host network.
722
+
723
+ ``read_only_paths`` are absolute host paths every sandboxed launch sees
724
+ read-only (D15.4): a toolchain, an interpreter or a file a build needs
725
+ outside ``/usr`` and ``/etc``. Refused at load when one names the
726
+ operator's home, an ancestor of it or a forbidden source, or two nest;
727
+ one overlapping a denied root of the checkout is refused before the
728
+ first launch. There are no operator-declared writable paths.
729
+
730
+ The networks and the paths are read only when a launch is enforced.
731
+ """
732
+
733
+ mode: str = SANDBOX_DISABLED
734
+ validation_network: str = "host"
735
+ premerge_network: str = "host"
736
+ read_only_paths: list[str] = field(default_factory=list)
737
+
738
+ @property
739
+ def enabled(self) -> bool:
740
+ """A sandbox is wanted: ``required`` or ``preferred``."""
741
+ return self.mode != SANDBOX_DISABLED
742
+
743
+ def refuses_unenforced(self, local: bool) -> bool:
744
+ """Whether a launch the host cannot enforce is refused rather than run.
745
+
746
+ ``required`` everywhere, ``preferred`` in REMOTE (ADR 0005 D12.2).
747
+ """
748
+ return self.mode == SANDBOX_REQUIRED or (self.mode == SANDBOX_PREFERRED and not local)
749
+
750
+
751
+ # The keys a config file may contain at top level: the scalars read directly
752
+ # below plus one entry per section. There is no `repository` key: the
753
+ # repository comes from the issue URLs on the command line.
754
+ TOP_LEVEL_KEYS = (
755
+ "version",
756
+ "state_dir",
757
+ "prompt_version",
758
+ "execution",
759
+ "safety",
760
+ "github",
761
+ "merge",
762
+ "review",
763
+ "workflow",
764
+ "local",
765
+ "sandbox",
766
+ "validation",
767
+ "scheduler",
768
+ "profiles",
769
+ )
770
+
771
+
772
+ @dataclass(frozen=True)
773
+ class AgentLimits:
774
+ """The limits one agent invocation runs under (#193).
775
+
776
+ ``idle_timeout_seconds``: the agent is killed once it has written
777
+ nothing on stdout or stderr for that long; ``None`` when its CLI writes
778
+ nothing until it exits, so only the ceiling can apply.
779
+ ``max_runtime_seconds``: the wall-clock ceiling from the launch;
780
+ ``None``: unset, so only the executor's one-week backstop applies.
781
+ """
782
+
783
+ idle_timeout_seconds: int | None
784
+ max_runtime_seconds: int | None
785
+
786
+ def describe(self) -> str:
787
+ """Both values, for the dry-run plan, ``doctor`` and the launch line."""
788
+ idle = (
789
+ "off (no output until exit)"
790
+ if self.idle_timeout_seconds is None
791
+ else f"{self.idle_timeout_seconds}s"
792
+ )
793
+ ceiling = "unset" if self.max_runtime_seconds is None else f"{self.max_runtime_seconds}s"
794
+ return f"idle timeout {idle}, max runtime {ceiling}"
795
+
796
+
797
+ @dataclass
798
+ class AutoForgeConfig:
799
+ version: int = CONFIG_VERSION
800
+ state_dir: str = DEFAULT_STATE_DIR
801
+ prompt_version: str = __prompt_version__
802
+ execution: ExecutionConfig = field(default_factory=ExecutionConfig)
803
+ safety: SafetyConfig = field(default_factory=SafetyConfig)
804
+ github: GitHubConfig = field(default_factory=GitHubConfig)
805
+ merge: MergeConfig = field(default_factory=MergeConfig)
806
+ review: ReviewConfig = field(default_factory=ReviewConfig)
807
+ workflow: WorkflowConfig = field(default_factory=WorkflowConfig)
808
+ local: LocalConfig = field(default_factory=LocalConfig)
809
+ sandbox: SandboxConfig = field(default_factory=SandboxConfig)
810
+ validation: ValidationConfig = field(default_factory=ValidationConfig)
811
+ scheduler: EpicSchedulerConfig = field(default_factory=EpicSchedulerConfig)
812
+ profiles: dict[str, ProfileConfig] = field(default_factory=dict)
813
+
814
+ def profile(self, name: str) -> ProfileConfig:
815
+ try:
816
+ return self.profiles[name]
817
+ except KeyError:
818
+ raise ConfigurationError(f"unknown execution profile {name!r}") from None
819
+
820
+ def agent_limits(self, profile: ProfileConfig) -> AgentLimits:
821
+ """The limits ``profile``'s agent runs under, or ConfigurationError.
822
+
823
+ The profile's own values, else the ``execution.default_*`` ones. A
824
+ CLI that writes nothing until it exits (Claude with ``output_format:
825
+ text``) would look frozen for its whole run, so it cannot run under
826
+ an idle limit: it runs under its wall-clock ceiling alone, and a
827
+ ceiling is then required, never an unbounded run. A profile whose
828
+ ``interaction_policy`` is ``prompt`` needs a ``max_wait_seconds``
829
+ below both limits (ADR 0007 D7.3).
830
+ """
831
+ from .providers import provider_for # local import: providers depends on config
832
+
833
+ for key in ("idle_timeout_seconds", "max_runtime_seconds"):
834
+ value = getattr(profile, key)
835
+ if value is not None:
836
+ _check_limit(value, f"profile {profile.name!r}", key)
837
+ idle = (
838
+ profile.idle_timeout_seconds
839
+ if profile.idle_timeout_seconds is not None
840
+ else self.execution.default_idle_timeout_seconds
841
+ )
842
+ ceiling = (
843
+ profile.max_runtime_seconds
844
+ if profile.max_runtime_seconds is not None
845
+ else self.execution.default_max_runtime_seconds
846
+ )
847
+ provider = provider_for(profile)
848
+ settings = provider.interaction_settings(profile)
849
+ if settings is not None and settings.live:
850
+ # A live ask waits for a person: the wait must end before either
851
+ # limit would end the invocation under it (ADR 0007 D7.3).
852
+ wait = settings.max_wait_seconds
853
+ for key, limit in (("idle timeout", idle), ("max_runtime_seconds", ceiling)):
854
+ if limit is not None and wait >= limit:
855
+ raise ConfigurationError(
856
+ f"profile {profile.name!r}: option max_wait_seconds ({wait}) must be "
857
+ f"below its {key} ({limit}s): a wait for an answer that outlasts the "
858
+ "limit would end the invocation under the person answering"
859
+ )
860
+ if provider.reports_activity(profile):
861
+ return AgentLimits(idle_timeout_seconds=idle, max_runtime_seconds=ceiling)
862
+ if ceiling is None:
863
+ raise ConfigurationError(
864
+ f"profile {profile.name!r}: its {profile.provider} CLI writes nothing until it "
865
+ "exits with these options, so it cannot run under an idle timeout and needs a "
866
+ "wall-clock limit: set 'max_runtime_seconds' on the profile (or "
867
+ "'execution.default_max_runtime_seconds'), or use an output format that "
868
+ "reports progress (claude: output_format stream-json)"
869
+ )
870
+ return AgentLimits(idle_timeout_seconds=None, max_runtime_seconds=ceiling)
871
+
872
+ @property
873
+ def merge_allowed_by_config(self) -> bool:
874
+ """The config half of the merge gate: `safety.allow_merge` and nothing else."""
875
+ return bool(self.safety.allow_merge)
876
+
877
+
878
+ def _claude_profile(name: str) -> ProfileConfig:
879
+ return ProfileConfig(
880
+ name=name,
881
+ provider="claude",
882
+ model="fable",
883
+ effort="high",
884
+ command="claude",
885
+ extra_args=[],
886
+ options={"permission_mode": "bypassPermissions", "output_format": "stream-json"},
887
+ )
888
+
889
+
890
+ def _opencode_profile(name: str, model: str, effort: str) -> ProfileConfig:
891
+ return ProfileConfig(
892
+ name=name,
893
+ provider="opencode",
894
+ model=model,
895
+ effort=effort,
896
+ command="opencode",
897
+ extra_args=[],
898
+ options={"output_format": "default", "auto_approve": "false"},
899
+ )
900
+
901
+
902
+ def default_config() -> AutoForgeConfig:
903
+ """Built-in defaults mirroring autoforge.example.yaml."""
904
+ profiles = {
905
+ "analyze_execute": _claude_profile("analyze_execute"),
906
+ "fix": _claude_profile("fix"),
907
+ "review_round_1": _opencode_profile("review_round_1", "openai/gpt-5.6-luna", "high"),
908
+ "review_round_2_5": _opencode_profile("review_round_2_5", "openai/gpt-5.6-terra", "high"),
909
+ "review_round_6_plus": _opencode_profile(
910
+ "review_round_6_plus", "openai/gpt-5.6-sol", "medium"
911
+ ),
912
+ "replan_reexecute": _opencode_profile("replan_reexecute", "openai/gpt-5.6-terra", "high"),
913
+ "update_epic": _opencode_profile("update_epic", "openai/gpt-5.6-sol", "high"),
914
+ }
915
+ return AutoForgeConfig(profiles=profiles)
916
+
917
+
918
+ # -- validation ------------------------------------------------------------
919
+ def validate_profile(profile: ProfileConfig) -> None:
920
+ """Raise ConfigurationError when a profile cannot possibly be executed."""
921
+ if profile.provider not in KNOWN_PROVIDERS:
922
+ raise ConfigurationError(
923
+ f"profile {profile.name!r}: unknown provider {profile.provider!r} "
924
+ f"(expected one of {KNOWN_PROVIDERS})"
925
+ )
926
+ if profile.provider != "scripted" and not profile.model:
927
+ raise ConfigurationError(f"profile {profile.name!r}: 'model' must be set")
928
+ from .providers import provider_for
929
+
930
+ provider_for(profile).validate_profile(profile)
931
+
932
+
933
+ def validate_required_profiles(cfg: AutoForgeConfig, names: list[str]) -> None:
934
+ """Fail early (ConfigurationError) if any required profile is missing/invalid."""
935
+ missing = [n for n in names if n not in cfg.profiles]
936
+ if missing:
937
+ raise ConfigurationError(
938
+ f"required execution profile(s) not configured: {', '.join(missing)} "
939
+ "— add them under 'profiles:' in the AutoForge config"
940
+ )
941
+ for n in names:
942
+ validate_profile(cfg.profiles[n])
943
+ cfg.agent_limits(cfg.profiles[n])
944
+
945
+
946
+ def _check_limit(value: int, where: str, key: str) -> None:
947
+ """A limit in seconds is > 0 and within the executor's backstop."""
948
+ if not 0 < value <= MAX_DEADLINE_SECONDS:
949
+ raise ConfigurationError(
950
+ f"{where}: {key!r} must be > 0 and <= {MAX_DEADLINE_SECONDS} (one week), "
951
+ f"got {value} (a non-positive value would disable the limit entirely)"
952
+ )
953
+
954
+
955
+ # -- file loading ----------------------------------------------------------
956
+ def load_config_file(path: str | Path | None) -> AutoForgeConfig:
957
+ """Load config from file, or defaults when path is None.
958
+
959
+ Raises ConfigurationError on any problem.
960
+ """
961
+ cfg = default_config()
962
+ if path is None:
963
+ return cfg
964
+ p = Path(path)
965
+ if not p.exists():
966
+ raise ConfigurationError(f"config file not found: {p}")
967
+ suffix = p.suffix.lower()
968
+ data: object
969
+ try:
970
+ if suffix == ".toml":
971
+ import tomllib
972
+
973
+ data = tomllib.loads(p.read_text(encoding="utf-8"))
974
+ elif suffix == ".json":
975
+ data = json.loads(
976
+ p.read_text(encoding="utf-8"), object_pairs_hook=_pairs_without_duplicates
977
+ )
978
+ elif suffix in (".yaml", ".yml"):
979
+ data = _load_yaml(p)
980
+ else:
981
+ raise ConfigurationError(
982
+ f"unsupported config extension {suffix!r} (use .yaml/.yml/.toml/.json)"
983
+ )
984
+ except ConfigurationError:
985
+ raise
986
+ except Exception as exc: # parse errors -> ConfigurationError
987
+ raise ConfigurationError(f"cannot parse config {p}: {exc}") from exc
988
+ if not isinstance(data, dict):
989
+ raise ConfigurationError(f"config {p} must contain a mapping at top level")
990
+ return _merge_config(cfg, data, source=str(p))
991
+
992
+
993
+ def config_digest(cfg: AutoForgeConfig) -> str:
994
+ """The SHA-256 of the effective configuration, as canonical JSON (ADR 0008 D9.2 step 4).
995
+
996
+ A scheduler records it in ``scheduler.json`` and each worker requires
997
+ the configuration it loaded to have the same one, so a file edited
998
+ between the scheduler's start and a worker's is refused, never run.
999
+ It covers every field of the effective configuration, the defaults a
1000
+ file left in force included; it is a comparison, never shown.
1001
+ """
1002
+ document = json.dumps(asdict(cfg), sort_keys=True, separators=(",", ":"), default=str)
1003
+ return hashlib.sha256(document.encode("utf-8")).hexdigest()
1004
+
1005
+
1006
+ # Every parser backend must hand `_merge_config` the document as written: a
1007
+ # key the operator wrote and the loader never saw is the silent no-op that
1008
+ # `_reject_unknown_keys` exists to refuse, and a duplicate key is exactly
1009
+ # that -- `json.loads`, PyYAML and the subset parser all keep the last value
1010
+ # and drop the earlier one, typo included, before any validation runs. TOML
1011
+ # (`tomllib`) rejects a duplicate on its own; the other three are told to.
1012
+ def _pairs_without_duplicates(pairs: list[tuple[str, object]]) -> dict[str, object]:
1013
+ """``json.loads`` object hook: a repeated key is a parse error, not a merge."""
1014
+ out: dict[str, object] = {}
1015
+ for key, value in pairs:
1016
+ if key in out:
1017
+ raise ValueError(f"duplicate key {key!r}")
1018
+ out[key] = value
1019
+ return out
1020
+
1021
+
1022
+ def _as_options(raw: object, source: str, name: str) -> dict[str, str]:
1023
+ if raw is None:
1024
+ return {}
1025
+ if not isinstance(raw, dict):
1026
+ raise ConfigurationError(f"{source}: profile {name!r} 'options' must be a mapping")
1027
+ out: dict[str, str] = {}
1028
+ for k, v in raw.items():
1029
+ if isinstance(v, bool):
1030
+ out[str(k)] = "true" if v else "false"
1031
+ else:
1032
+ out[str(k)] = "" if v is None else str(v)
1033
+ return out
1034
+
1035
+
1036
+ def _as_str_list(raw: object, source: str, key: str) -> list[str]:
1037
+ """A list of non-empty strings -- never a bare string silently split.
1038
+
1039
+ An explicit ``null`` is rejected rather than read as ``[]``. The empty
1040
+ list is a deliberate opt-out (for ``safety.protected_merge_paths`` it
1041
+ turns the merge gate off), and a key whose value went missing -- a
1042
+ hand-edited config, a generator emitting nothing, a list the YAML subset
1043
+ parser could not read -- must not be able to disable a safety gate by
1044
+ looking like one.
1045
+ """
1046
+ if raw is None:
1047
+ raise ConfigurationError(
1048
+ f"{source}: {key} is null; write [] to set it empty deliberately, "
1049
+ "or remove the key to keep the default"
1050
+ )
1051
+ if isinstance(raw, str) or not isinstance(raw, list):
1052
+ raise ConfigurationError(f"{source}: {key} must be a list of strings, got {raw!r}")
1053
+ out: list[str] = []
1054
+ for item in raw:
1055
+ if not isinstance(item, str) or not item.strip():
1056
+ raise ConfigurationError(
1057
+ f"{source}: {key} must contain non-empty strings, got {item!r}"
1058
+ )
1059
+ out.append(item.strip())
1060
+ return out
1061
+
1062
+
1063
+ def _as_argv_list(raw: object, source: str, key: str) -> list[list[str]]:
1064
+ """A list of argv arrays -- never a shell string.
1065
+
1066
+ ``[["./gradlew", "test"]]`` is accepted; ``["./gradlew test"]`` is not.
1067
+ A command line is not parsed, split or handed to a shell anywhere in
1068
+ AutoForge, so accepting a string here would create the one place where a
1069
+ quoting bug turns into arbitrary command execution.
1070
+ """
1071
+ if raw is None:
1072
+ raise ConfigurationError(
1073
+ f"{source}: {key} is null; write [] to set it empty deliberately, "
1074
+ "or remove the key to keep the default"
1075
+ )
1076
+ if isinstance(raw, str) or not isinstance(raw, list):
1077
+ raise ConfigurationError(f"{source}: {key} must be a list of argv arrays, got {raw!r}")
1078
+ out: list[list[str]] = []
1079
+ for index, entry in enumerate(raw):
1080
+ if isinstance(entry, str) or not isinstance(entry, list) or not entry:
1081
+ raise ConfigurationError(
1082
+ f"{source}: {key}[{index}] must be a non-empty argv array such as "
1083
+ f'["pytest", "-q"] -- a shell command string is never accepted, got {entry!r}'
1084
+ )
1085
+ argv: list[str] = []
1086
+ for item in entry:
1087
+ if not isinstance(item, str) or not item.strip():
1088
+ raise ConfigurationError(
1089
+ f"{source}: {key}[{index}] must contain non-empty strings, got {item!r}"
1090
+ )
1091
+ # Stored verbatim, unlike `_as_str_list`: an argv element reaches
1092
+ # the process exactly as written, so surrounding whitespace is
1093
+ # part of the argument, not trimmed away. Only a blank element is
1094
+ # refused, since it can never be anything but a slip.
1095
+ argv.append(item)
1096
+ out.append(argv)
1097
+ return out
1098
+
1099
+
1100
+ def _as_definitions(raw: object, source: str, key: str) -> list[list[str]]:
1101
+ """A validation set: argv arrays within the bounds a record can hold (ADR 0006 D10.3).
1102
+
1103
+ ``merge.verification_commands`` is copied into no record and is not
1104
+ bounded by this.
1105
+ """
1106
+ commands = _as_argv_list(raw, source, key)
1107
+ try:
1108
+ check_definitions(commands)
1109
+ except ValidationDefinitionError as exc:
1110
+ raise ConfigurationError(f"{source}: {key}: {exc}") from None
1111
+ return commands
1112
+
1113
+
1114
+ def _as_bool(raw: object, source: str, key: str) -> bool:
1115
+ """Accept only a real boolean — never coerce strings like "false" to True."""
1116
+ if isinstance(raw, bool):
1117
+ return raw
1118
+ raise ConfigurationError(f"{source}: {key!r} must be a boolean (true/false), got {raw!r}")
1119
+
1120
+
1121
+ def _as_int(raw: object, source: str, key: str) -> int:
1122
+ """Accept only a real integer (bool excluded); raise ConfigurationError otherwise."""
1123
+ if isinstance(raw, int) and not isinstance(raw, bool):
1124
+ return raw
1125
+ raise ConfigurationError(f"{source}: {key!r} must be an integer, got {raw!r}")
1126
+
1127
+
1128
+ def _optional_limit(raw: object, source: str, key: str) -> int | None:
1129
+ """A limit in seconds, or ``None`` for ``null``."""
1130
+ if raw is None:
1131
+ return None
1132
+ value = _as_int(raw, source, key)
1133
+ _check_limit(value, source, key)
1134
+ return value
1135
+
1136
+
1137
+ def _as_read_only_paths(raw: object, source: str) -> list[str]:
1138
+ """``sandbox.read_only_paths``: absolute paths every sandboxed launch sees read-only (D15.4).
1139
+
1140
+ Each is checked with the rules that need no launch (C1, C3, C7, D4.3;
1141
+ :func:`autoforge.sandbox.check_declared_path`) against the controller's
1142
+ own home and environment, and no two may be equal or nested (C6). A
1143
+ refusal names the key, the entry and the rule, never a file's contents.
1144
+ """
1145
+ key = "sandbox.read_only_paths"
1146
+ if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
1147
+ raise ConfigurationError(
1148
+ f"{source}: '{key}' must be a list of absolute paths, e.g. ['/opt/toolchain']"
1149
+ )
1150
+ home = os.path.normpath(os.environ.get("HOME") or os.path.expanduser("~"))
1151
+ paths: list[str] = []
1152
+ for index, path in enumerate(raw):
1153
+ label = f"'{key}[{index}]'"
1154
+ try:
1155
+ check_declared_path(path, home, os.environ, label)
1156
+ except SandboxPolicyError as exc:
1157
+ raise ConfigurationError(f"{source}: {exc}") from None
1158
+ for other in paths:
1159
+ if path == other or path.startswith(other + "/") or other.startswith(path + "/"):
1160
+ raise ConfigurationError(
1161
+ f"{source}: {label} {path} overlaps {other}, another entry of '{key}' "
1162
+ "(C6 overlaps): declare the outer one alone"
1163
+ )
1164
+ paths.append(path)
1165
+ return paths
1166
+
1167
+
1168
+ def _reject_unknown_keys(mapping: dict, known: tuple[str, ...], source: str, label: str) -> None:
1169
+ """Fail on any key of ``mapping`` that the loader would not read.
1170
+
1171
+ Every known key is read with ``if key in section``, so a key that is not
1172
+ known is not a wrong value: it is a *silent no-op* that leaves the
1173
+ built-in default in force while ``doctor`` calls the file valid. For a
1174
+ loop bound or a merge setting that means a looser bound than the operator
1175
+ wrote, so the typo is an error, in the same shape for every section.
1176
+ """
1177
+ unknown = sorted(str(k) for k in mapping if k not in known)
1178
+ if unknown:
1179
+ raise ConfigurationError(
1180
+ f"{source}: unknown key(s) under '{label}': {', '.join(unknown)} "
1181
+ f"(known: {', '.join(known)})"
1182
+ )
1183
+
1184
+
1185
+ def _section(
1186
+ data: dict,
1187
+ key: str,
1188
+ source: str,
1189
+ known: tuple[str, ...] | None,
1190
+ label: str | None = None,
1191
+ removed: dict[str, str] | None = None,
1192
+ ) -> dict:
1193
+ """A config section: absent or ``null`` is the built-in default, else a mapping.
1194
+
1195
+ ``null`` is what the YAML reader produces for a section header with every
1196
+ child commented out (``safety:`` alone on its line), and an empty section
1197
+ yields the same fail-closed defaults as an omitted one. Any other
1198
+ non-mapping value (``[]``, ``false``, ``""``, ``0``, a string) is rejected
1199
+ rather than normalised into an omitted section: ``data.get(key, {}) or
1200
+ {}`` would turn ``safety: []`` into a silently closed gate and let ``doctor``
1201
+ call that configuration valid.
1202
+
1203
+ ``known`` is the closed set of keys the section may contain; any other key
1204
+ is rejected (see ``_reject_unknown_keys``). ``None`` is for a section
1205
+ whose keys are *names* rather than a schema (``profiles``), where the
1206
+ check belongs on each named mapping instead. ``removed`` maps a key that
1207
+ used to be read to the message explaining where it went, so a config from
1208
+ an older controller gets that explanation rather than "unknown key".
1209
+ """
1210
+ raw = data.get(key)
1211
+ if raw is None:
1212
+ return {}
1213
+ name = label or key
1214
+ if not isinstance(raw, dict):
1215
+ raise ConfigurationError(f"{source}: '{name}' must be a mapping, got {raw!r}")
1216
+ for old_key, message in (removed or {}).items():
1217
+ if old_key in raw:
1218
+ raise ConfigurationError(f"{source}: {message}")
1219
+ if known is not None:
1220
+ _reject_unknown_keys(raw, known, source, name)
1221
+ return raw
1222
+
1223
+
1224
+ # The merge gate once had a second, deprecated location. It is not read any
1225
+ # more -- not even as `false` -- because a config that sets both can be
1226
+ # "disabled" in one place and still open in the other.
1227
+ REMOVED_EXECUTION_KEYS = {
1228
+ "allow_merge": (
1229
+ "'execution.allow_merge' is no longer supported; the merge gate is "
1230
+ "'safety.allow_merge' only. Remove the key from 'execution' (moving it to "
1231
+ "'safety' if you meant to open the gate)"
1232
+ ),
1233
+ # One wall-clock value used to cover agents and controller-run commands
1234
+ # alike; agents now have an idle limit (#193) and commands keep theirs.
1235
+ "default_timeout_seconds": (
1236
+ "'execution.default_timeout_seconds' is no longer supported. Agents now run "
1237
+ "under 'execution.default_idle_timeout_seconds' (killed only after making no "
1238
+ "progress for that long; default 900) and an optional wall-clock ceiling "
1239
+ "'execution.default_max_runtime_seconds'; pre-merge verification and LOCAL "
1240
+ "validation commands run under 'execution.command_timeout_seconds' (default "
1241
+ "1800). Replace the key with the ones you need"
1242
+ ),
1243
+ }
1244
+
1245
+
1246
+ def _merge_loop_detection(loop: LoopDetectionConfig, exe: dict, source: str) -> None:
1247
+ section = _section(
1248
+ exe, "loop_detection", source, LOOP_DETECTION_KEYS, label="execution.loop_detection"
1249
+ )
1250
+ if "mode" in section:
1251
+ mode = section["mode"]
1252
+ if not isinstance(mode, str) or mode not in LOOP_DETECTION_MODES:
1253
+ raise ConfigurationError(
1254
+ f"{source}: 'execution.loop_detection.mode' must be one of "
1255
+ f"{', '.join(LOOP_DETECTION_MODES)}, got {mode!r}"
1256
+ )
1257
+ loop.mode = mode
1258
+ # A repeat count of 1 is every action, and a period of 0 none: neither is
1259
+ # a loop bound, so both are refused rather than read as "off" (that is
1260
+ # what ``mode: off`` is for).
1261
+ for key, low, high in (
1262
+ ("max_cycle_period", 1, MAX_LOOP_CYCLE_PERIOD),
1263
+ ("max_cycle_repeats", 2, MAX_LOOP_REPEATS),
1264
+ ("max_line_repeats", 2, MAX_LOOP_REPEATS),
1265
+ ("novelty_window_seconds", 1, MAX_DEADLINE_SECONDS),
1266
+ ):
1267
+ if key in section:
1268
+ value = _as_int(section[key], source, f"execution.loop_detection.{key}")
1269
+ if not low <= value <= high:
1270
+ raise ConfigurationError(
1271
+ f"{source}: 'execution.loop_detection.{key}' must be between "
1272
+ f"{low} and {high}, got {value}"
1273
+ )
1274
+ setattr(loop, key, value)
1275
+
1276
+
1277
+ def _merge_config(base: AutoForgeConfig, data: dict, source: str) -> AutoForgeConfig:
1278
+ _reject_unknown_keys(data, TOP_LEVEL_KEYS, source, "the top level")
1279
+ version = data.get("version", CONFIG_VERSION)
1280
+ if version != CONFIG_VERSION:
1281
+ raise ConfigurationError(
1282
+ f"{source}: unsupported config version {version!r} (expected {CONFIG_VERSION})"
1283
+ )
1284
+ if "state_dir" in data:
1285
+ base.state_dir = str(data["state_dir"])
1286
+ if "prompt_version" in data:
1287
+ base.prompt_version = str(data["prompt_version"])
1288
+ exe = _section(data, "execution", source, EXECUTION_KEYS, removed=REMOVED_EXECUTION_KEYS)
1289
+ # The executor treats a non-positive wall-clock timeout as "no timeout at
1290
+ # all", and an unbounded subprocess would hold the repository lock
1291
+ # forever, so every limit is > 0 (and within the executor's backstop).
1292
+ # Only the agents' ceiling may be absent (null).
1293
+ for key in ("default_idle_timeout_seconds", "command_timeout_seconds"):
1294
+ if key in exe:
1295
+ value = _as_int(exe[key], source, f"execution.{key}")
1296
+ _check_limit(value, source, f"execution.{key}")
1297
+ setattr(base.execution, key, value)
1298
+ if "default_max_runtime_seconds" in exe:
1299
+ base.execution.default_max_runtime_seconds = _optional_limit(
1300
+ exe["default_max_runtime_seconds"], source, "execution.default_max_runtime_seconds"
1301
+ )
1302
+ if "max_correction_attempts" in exe:
1303
+ base.execution.max_correction_attempts = _as_int(
1304
+ exe["max_correction_attempts"], source, "execution.max_correction_attempts"
1305
+ )
1306
+ for key in ("env_allowlist", "env_allowlist_extra"):
1307
+ if key in exe:
1308
+ names = _as_str_list(exe[key], source, f"execution.{key}")
1309
+ for name in names:
1310
+ if not is_env_pattern(name):
1311
+ raise ConfigurationError(
1312
+ f"{source}: 'execution.{key}' entry {name!r} is not an environment "
1313
+ "variable name or a name prefix followed by '*'"
1314
+ )
1315
+ refuse_forbidden_env(names, f"{source}: 'execution.{key}'")
1316
+ setattr(base.execution, key, names)
1317
+ if "env_allowlist" in exe and not base.execution.env_allowlist:
1318
+ # An empty environment cannot even find the agent CLI on PATH; the
1319
+ # operator who wants a narrower list writes the narrower list.
1320
+ raise ConfigurationError(
1321
+ f"{source}: 'execution.env_allowlist' must name at least one variable "
1322
+ "(remove the key to keep the default list, or use env_allowlist_extra to add)"
1323
+ )
1324
+ if "worktree_dir" in exe:
1325
+ raw_dir = exe["worktree_dir"]
1326
+ if raw_dir is None or raw_dir == "":
1327
+ base.execution.worktree_dir = ""
1328
+ elif not isinstance(raw_dir, str) or not raw_dir.strip():
1329
+ raise ConfigurationError(
1330
+ f"{source}: 'execution.worktree_dir' must be a path or null, got {raw_dir!r}"
1331
+ )
1332
+ else:
1333
+ base.execution.worktree_dir = raw_dir.strip()
1334
+ _merge_loop_detection(base.execution.loop_detection, exe, source)
1335
+ safety = _section(data, "safety", source, SAFETY_KEYS)
1336
+ if "allow_merge" in safety:
1337
+ base.safety.allow_merge = _as_bool(safety["allow_merge"], source, "safety.allow_merge")
1338
+ base.safety.allow_merge_source = f"{source}: safety.allow_merge"
1339
+ if "protected_merge_paths" in safety:
1340
+ base.safety.protected_merge_paths = _as_str_list(
1341
+ safety["protected_merge_paths"], source, "safety.protected_merge_paths"
1342
+ )
1343
+ if "required_checks" in safety:
1344
+ base.safety.required_checks = _as_str_list(
1345
+ safety["required_checks"], source, "safety.required_checks"
1346
+ )
1347
+ if "verify_check_definition" in safety:
1348
+ base.safety.verify_check_definition = _as_bool(
1349
+ safety["verify_check_definition"], source, "safety.verify_check_definition"
1350
+ )
1351
+ if "acceptance_actions" in safety:
1352
+ policy = safety["acceptance_actions"]
1353
+ # YAML 1.1 (and this parser, for parity) reads a bare `off` as false.
1354
+ # That is the one boolean the key accepts: `true` names no policy.
1355
+ if policy is False:
1356
+ policy = ACCEPTANCE_ACTIONS_OFF
1357
+ if not isinstance(policy, str) or policy not in ACCEPTANCE_ACTION_POLICIES:
1358
+ raise ConfigurationError(
1359
+ f"{source}: 'safety.acceptance_actions' must be one of "
1360
+ f"{ACCEPTANCE_ACTION_POLICIES}, got {policy!r}"
1361
+ )
1362
+ base.safety.acceptance_actions = policy
1363
+ gh = _section(data, "github", source, GITHUB_KEYS)
1364
+ if "command" in gh:
1365
+ base.github.command = str(gh["command"])
1366
+ if "timeout_seconds" in gh:
1367
+ base.github.timeout_seconds = _as_int(
1368
+ gh["timeout_seconds"], source, "github.timeout_seconds"
1369
+ )
1370
+ merge = _section(data, "merge", source, MERGE_KEYS)
1371
+ if "method" in merge:
1372
+ method = merge["method"]
1373
+ if not isinstance(method, str) or method not in MERGE_METHODS:
1374
+ raise ConfigurationError(
1375
+ f"{source}: 'merge.method' must be one of {MERGE_METHODS}, got {method!r}"
1376
+ )
1377
+ base.merge.method = method
1378
+ if "delete_branch" in merge:
1379
+ base.merge.delete_branch = _as_bool(merge["delete_branch"], source, "merge.delete_branch")
1380
+ if "max_verification_attempts" in merge:
1381
+ attempts = _as_int(
1382
+ merge["max_verification_attempts"], source, "merge.max_verification_attempts"
1383
+ )
1384
+ if attempts < 1:
1385
+ raise ConfigurationError(
1386
+ f"{source}: 'merge.max_verification_attempts' must be >= 1, got {attempts}"
1387
+ )
1388
+ base.merge.max_verification_attempts = attempts
1389
+ if "verification_commands" in merge:
1390
+ base.merge.verification_commands = _as_argv_list(
1391
+ merge["verification_commands"], source, "merge.verification_commands"
1392
+ )
1393
+ scheduler = _section(data, "scheduler", source, SCHEDULER_KEYS)
1394
+ for key, (low, high) in (
1395
+ ("max_parallel", MAX_PARALLEL_BOUNDS),
1396
+ ("poll_interval_seconds", POLL_INTERVAL_BOUNDS),
1397
+ ("stop_grace_seconds", STOP_GRACE_BOUNDS),
1398
+ ):
1399
+ if key in scheduler:
1400
+ value = _as_int(scheduler[key], source, f"scheduler.{key}")
1401
+ if not low <= value <= high:
1402
+ raise ConfigurationError(
1403
+ f"{source}: 'scheduler.{key}' must be between {low} and {high}, got "
1404
+ f"{value} (there is no unbounded mode)"
1405
+ )
1406
+ setattr(base.scheduler, key, value)
1407
+ review = _section(data, "review", source, REVIEW_KEYS)
1408
+ replan = _section(review, "replan", source, REPLAN_KEYS, label="review.replan")
1409
+ rp = base.review.replan
1410
+ if "enabled" in replan:
1411
+ rp.enabled = _as_bool(replan["enabled"], source, "review.replan.enabled")
1412
+ for key, minimum in (
1413
+ ("soft_threshold", 1),
1414
+ ("hard_threshold", 1),
1415
+ ("stagnation_window", 1),
1416
+ ("max_findings_per_round", 0),
1417
+ ("max_replans_per_issue", 0),
1418
+ ):
1419
+ if key in replan:
1420
+ value = _as_int(replan[key], source, f"review.replan.{key}")
1421
+ if value < minimum:
1422
+ raise ConfigurationError(
1423
+ f"{source}: 'review.replan.{key}' must be >= {minimum}, got {value}"
1424
+ )
1425
+ setattr(rp, key, value)
1426
+ if rp.hard_threshold < rp.soft_threshold:
1427
+ raise ConfigurationError(
1428
+ f"{source}: 'review.replan.hard_threshold' must be >= 'review.replan.soft_threshold'"
1429
+ )
1430
+ workflow = _section(data, "workflow", source, WORKFLOW_KEYS)
1431
+ for key, minimum in (
1432
+ ("max_review_rounds", 1),
1433
+ ("max_total_steps", 1),
1434
+ ("epic_update_every", 1),
1435
+ ):
1436
+ if key in workflow:
1437
+ value = _as_int(workflow[key], source, f"workflow.{key}")
1438
+ if value < minimum:
1439
+ raise ConfigurationError(
1440
+ f"{source}: 'workflow.{key}' must be >= {minimum}, got {value}"
1441
+ )
1442
+ setattr(base.workflow, key, value)
1443
+ # Both stagnation rules compare consecutive rounds *against each other*, so
1444
+ # a window of 1 has no meaning: it would compare a round with nothing and
1445
+ # silently disable the unchanged-count rule (which needs a recurrence
1446
+ # inside the window) while making the identical-resolutions rule fire on
1447
+ # the first round that has findings at all. Only 0 disables a rule.
1448
+ for key in ("stagnation_identical_rounds", "stagnation_unchanged_count_rounds"):
1449
+ if key in workflow:
1450
+ value = _as_int(workflow[key], source, f"workflow.{key}")
1451
+ if value < 0 or value == 1:
1452
+ raise ConfigurationError(
1453
+ f"{source}: 'workflow.{key}' must be 0 (rule disabled) or >= 2 "
1454
+ f"(the rule compares consecutive review rounds), got {value}"
1455
+ )
1456
+ setattr(base.workflow, key, value)
1457
+ if base.review.replan.hard_threshold > base.workflow.max_review_rounds:
1458
+ raise ConfigurationError(
1459
+ f"{source}: 'review.replan.hard_threshold' must be <= 'workflow.max_review_rounds'"
1460
+ )
1461
+ local = _section(data, "local", source, LOCAL_KEYS)
1462
+ if "feature_dir" in local:
1463
+ feature_dir = str(local["feature_dir"]).strip()
1464
+ if not feature_dir:
1465
+ raise ConfigurationError(f"{source}: 'local.feature_dir' must not be empty")
1466
+ base.local.feature_dir = feature_dir
1467
+ if "validation_commands" in local:
1468
+ base.local.validation_commands = _as_definitions(
1469
+ local["validation_commands"], source, "local.validation_commands"
1470
+ )
1471
+ if "max_fix_rounds" in local:
1472
+ value = _as_int(local["max_fix_rounds"], source, "local.max_fix_rounds")
1473
+ if value < 0:
1474
+ raise ConfigurationError(f"{source}: 'local.max_fix_rounds' must be >= 0, got {value}")
1475
+ base.local.max_fix_rounds = value
1476
+ if "exclude" in local:
1477
+ raw = local["exclude"]
1478
+ if isinstance(raw, str) or not isinstance(raw, list):
1479
+ raise ConfigurationError(
1480
+ f"{source}: 'local.exclude' must be a list of path patterns, e.g. "
1481
+ "['.venv', '**/__pycache__']"
1482
+ )
1483
+ patterns: list[str] = []
1484
+ for item in raw:
1485
+ try:
1486
+ patterns.append(normalize_exclude_pattern(item))
1487
+ except ConfigurationError as exc:
1488
+ raise ConfigurationError(f"{source}: {exc}") from None
1489
+ base.local.exclude = sorted(dict.fromkeys(patterns))
1490
+ for name, attr, minimum in (
1491
+ ("max_workspace_entries", "max_workspace_entries", 1),
1492
+ ("max_workspace_bytes", "max_workspace_bytes", 1),
1493
+ ):
1494
+ if name in local:
1495
+ value = _as_int(local[name], source, f"local.{name}")
1496
+ if value < minimum:
1497
+ raise ConfigurationError(
1498
+ f"{source}: 'local.{name}' must be >= {minimum}, got {value}"
1499
+ )
1500
+ setattr(base.local, attr, value)
1501
+ sandbox = _section(data, "sandbox", source, SANDBOX_KEYS)
1502
+ for key, allowed in (
1503
+ ("mode", SANDBOX_MODES),
1504
+ ("validation_network", SANDBOX_NETWORKS),
1505
+ ("premerge_network", SANDBOX_NETWORKS),
1506
+ ):
1507
+ if key in sandbox:
1508
+ value = sandbox[key]
1509
+ if not isinstance(value, str) or value not in allowed:
1510
+ raise ConfigurationError(
1511
+ f"{source}: 'sandbox.{key}' must be one of {', '.join(allowed)}, got {value!r}"
1512
+ )
1513
+ setattr(base.sandbox, key, value)
1514
+ if "read_only_paths" in sandbox:
1515
+ base.sandbox.read_only_paths = _as_read_only_paths(sandbox["read_only_paths"], source)
1516
+ validation = _section(data, "validation", source, VALIDATION_KEYS)
1517
+ if "commands" in validation:
1518
+ base.validation.commands = _as_definitions(
1519
+ validation["commands"], source, "validation.commands"
1520
+ )
1521
+ if "scope" in validation:
1522
+ try:
1523
+ check_scope(validation["scope"])
1524
+ except ConfigurationError as exc:
1525
+ raise ConfigurationError(f"{source}: {exc}") from None
1526
+ base.validation.scope = SCOPE
1527
+ # Keyed by profile *name*, so the closed key set applies to each profile
1528
+ # mapping rather than to the section.
1529
+ profiles = _section(data, "profiles", source, known=None)
1530
+ for name, p in profiles.items():
1531
+ # YAML keys are not necessarily strings (`1:`, `true:`, `null:`), and
1532
+ # a name is looked up, sorted and printed as one: a non-string would
1533
+ # load here and fail as a `TypeError` in `doctor` instead.
1534
+ if not isinstance(name, str) or not name.strip():
1535
+ raise ConfigurationError(
1536
+ f"{source}: profile names must be non-empty strings, got {name!r}"
1537
+ )
1538
+ if not isinstance(p, dict):
1539
+ raise ConfigurationError(f"{source}: profile {name!r} must be a mapping")
1540
+ for old_key, message in REMOVED_PROFILE_KEYS.items():
1541
+ if old_key in p:
1542
+ raise ConfigurationError(
1543
+ f"{source}: profiles.{name}: "
1544
+ + message.format(idle=base.execution.default_idle_timeout_seconds)
1545
+ )
1546
+ _reject_unknown_keys(p, PROFILE_KEYS, source, f"profiles.{name}")
1547
+ # Typed here, range-checked when the profile is validated: a config
1548
+ # may carry profiles a given run never reaches (``validate_profile``).
1549
+ limits = {
1550
+ key: None if p[key] is None else _as_int(p[key], source, f"profiles.{name}.{key}")
1551
+ for key in ("idle_timeout_seconds", "max_runtime_seconds")
1552
+ if key in p
1553
+ }
1554
+ cur = base.profiles.get(name)
1555
+ if cur is not None and "provider" in p and str(p["provider"]) != cur.provider:
1556
+ # A different provider is a different CLI: nothing the default
1557
+ # profile carried for the old one (its `command`, its options,
1558
+ # its extra arguments) may survive into the new one, or the
1559
+ # override would launch the old binary with the new model.
1560
+ cur = None
1561
+ if cur is not None:
1562
+ if "model" in p:
1563
+ cur.model = str(p["model"])
1564
+ if "effort" in p:
1565
+ cur.effort = str(p["effort"])
1566
+ if "command" in p:
1567
+ cur.command = str(p["command"])
1568
+ if "extra_args" in p:
1569
+ cur.extra_args = [str(a) for a in (p["extra_args"] or [])]
1570
+ for key, limit in limits.items():
1571
+ setattr(cur, key, limit)
1572
+ if "options" in p:
1573
+ cur.options.update(_as_options(p["options"], source, name))
1574
+ else:
1575
+ # A profile the defaults do not have, or one whose provider the
1576
+ # override changes: built from the override alone.
1577
+ base.profiles[name] = ProfileConfig(
1578
+ name=name,
1579
+ provider=str(p.get("provider", "opencode")),
1580
+ model=str(p.get("model", "")),
1581
+ effort=str(p.get("effort", "high")),
1582
+ command=str(p.get("command", "")),
1583
+ extra_args=[str(a) for a in (p.get("extra_args") or [])],
1584
+ idle_timeout_seconds=limits.get("idle_timeout_seconds"),
1585
+ max_runtime_seconds=limits.get("max_runtime_seconds"),
1586
+ options=_as_options(p.get("options"), source, name),
1587
+ )
1588
+ return base
1589
+
1590
+
1591
+ def _load_yaml(path: Path) -> object:
1592
+ """The YAML document as parsed; ``load_config_file`` checks the root type.
1593
+
1594
+ Only an *empty* document (no content, or comments only) reads as ``{}``:
1595
+ that is what the subset parser produces for it, and it means "all
1596
+ defaults" the same way an omitted file does. Any other non-mapping root
1597
+ (``false``, a list, a bare string) is returned as is so the root check
1598
+ rejects it on both backends alike, rather than PyYAML alone reading it as
1599
+ the built-in configuration.
1600
+ """
1601
+ text = path.read_text(encoding="utf-8")
1602
+ try:
1603
+ import yaml # type: ignore
1604
+ except ImportError:
1605
+ return _minimal_yaml_parse(text)
1606
+
1607
+ class UniqueKeySafeLoader(yaml.SafeLoader):
1608
+ """``SafeLoader`` that refuses a key repeated within one mapping.
1609
+
1610
+ PyYAML keeps the last value of a repeated key, so the earlier one --
1611
+ and any typo in it -- would vanish before ``_reject_unknown_keys``
1612
+ runs. Merge keys (``<<``) are left to PyYAML: overriding a merged
1613
+ key is what they are for, not a duplicate.
1614
+ """
1615
+
1616
+ def construct_mapping(self, node: object, deep: bool = False) -> dict:
1617
+ seen: set[object] = set()
1618
+ for key_node, _value_node in node.value: # type: ignore[attr-defined]
1619
+ if key_node.tag == "tag:yaml.org,2002:merge":
1620
+ continue
1621
+ key = self.construct_object(key_node, deep=deep)
1622
+ try:
1623
+ repeated = key in seen
1624
+ except TypeError: # unhashable key: PyYAML reports it below
1625
+ continue
1626
+ if repeated:
1627
+ raise ValueError(f"duplicate key {key!r} (line {key_node.start_mark.line + 1})")
1628
+ seen.add(key)
1629
+ return super().construct_mapping(node, deep=deep)
1630
+
1631
+ data = yaml.load(text, Loader=UniqueKeySafeLoader) # a SafeLoader subclass
1632
+ return {} if data is None else data
1633
+
1634
+
1635
+ def _minimal_yaml_parse(text: str) -> object:
1636
+ """Minimal YAML-subset parser for our config shape.
1637
+
1638
+ Supports: nested maps via 2-space indentation, lists via "- " items,
1639
+ inline scalars (int/float/bool/null/quoted strings), each resolved
1640
+ exactly as PyYAML resolves it. Anything fancier raises ValueError telling
1641
+ the user to install PyYAML rather than reading it as the string it looks
1642
+ like (see the note below); like PyYAML's own errors it reaches the
1643
+ operator as ``cannot parse config <path>: ...``, and like PyYAML the root
1644
+ is returned as parsed for ``load_config_file`` to check.
1645
+ """
1646
+ return _parse_yaml_subset(text)
1647
+
1648
+
1649
+ # -- the YAML subset parser ------------------------------------------------
1650
+ #
1651
+ # Two backends read the same `.yaml` file: PyYAML for an operator who
1652
+ # installed `autoforge-cli[yaml]`, this parser for one who did not. A scalar the
1653
+ # two resolve *differently* is the failure mode the parser below is built
1654
+ # against (#40). The file it feeds decides routing, loop bounds, timeouts and
1655
+ # `safety.allow_merge`, and nothing downstream can tell that `on` was read as
1656
+ # a string here and as `True` there, or `0600` as 600 here and 384 there.
1657
+ #
1658
+ # The rule is therefore: resolve exactly what PyYAML's YAML 1.1 implicit
1659
+ # resolvers resolve, to exactly the same value, and refuse everything else
1660
+ # outright. A refusal reaches the operator as `cannot parse config <path>`;
1661
+ # a quietly different value reaches them as a different controller. Whatever
1662
+ # this parser accepts is proven equal to PyYAML's reading of it, scalar by
1663
+ # scalar and document by document, in `tests/test_config.py`.
1664
+
1665
+ # PyYAML's implicit resolvers (`yaml/resolver.py`), reproduced. Anything a
1666
+ # resolver claims that this parser does not implement -- a timestamp -- is
1667
+ # refused rather than kept as the string it looks like.
1668
+ _YAML_NULL = frozenset({"", "~", "null", "Null", "NULL"})
1669
+ _YAML_TRUE = frozenset({"yes", "Yes", "YES", "true", "True", "TRUE", "on", "On", "ON"})
1670
+ _YAML_FALSE = frozenset({"no", "No", "NO", "false", "False", "FALSE", "off", "Off", "OFF"})
1671
+
1672
+ _YAML_INT_RE = re.compile(
1673
+ r"""^(?:[-+]?0b[0-1_]+
1674
+ |[-+]?0[0-7_]+
1675
+ |[-+]?(?:0|[1-9][0-9_]*)
1676
+ |[-+]?0x[0-9a-fA-F_]+
1677
+ |[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$""",
1678
+ re.X,
1679
+ )
1680
+
1681
+ _YAML_FLOAT_RE = re.compile(
1682
+ r"""^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)?
1683
+ |\.[0-9][0-9_]*(?:[eE][-+][0-9]+)?
1684
+ |[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*
1685
+ |[-+]?\.(?:inf|Inf|INF)
1686
+ |\.(?:nan|NaN|NAN))$""",
1687
+ re.X,
1688
+ )
1689
+
1690
+ _YAML_TIMESTAMP_RE = re.compile(
1691
+ r"""^(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]
1692
+ |[0-9][0-9][0-9][0-9] -[0-9][0-9]? -[0-9][0-9]?
1693
+ (?:[Tt]|[ \t]+)[0-9][0-9]?
1694
+ :[0-9][0-9] :[0-9][0-9] (?:\.[0-9]*)?
1695
+ (?:[ \t]*(?:Z|[-+][0-9][0-9]?(?::[0-9][0-9])?))?)$""",
1696
+ re.X,
1697
+ )
1698
+
1699
+ # A plain scalar never starts with one of these: each introduces YAML this
1700
+ # parser does not implement (anchor, alias, tag, block scalar, flow mapping,
1701
+ # directive, reserved indicator) or is a syntax error under PyYAML. Reading
1702
+ # any of them as the string it looks like is exactly the silent disagreement
1703
+ # to avoid, so they are refused.
1704
+ _UNSUPPORTED_FIRST_CHARS = "&*!|>{}],%@`"
1705
+
1706
+ # YAML's whitespace, line breaks and printable set -- none of which is
1707
+ # Python's. Between tokens PyYAML's scanner skips a space or a tab and nothing
1708
+ # else, so every *other* character `str.strip()` treats as whitespace (U+00A0,
1709
+ # U+1680, U+2000-U+200A, U+202F, U+205F, U+3000) is an ordinary character of
1710
+ # the plain scalar it sits in. Stripping lines, keys, values and items with
1711
+ # `str.strip()` dropped them silently: `safety.allow_merge: <NBSP>true`
1712
+ # resolved to True and opened the merge gate here, while PyYAML read the
1713
+ # string '\xa0true' and the loader refused the file. `str.splitlines()` is the
1714
+ # same mistake one layer down -- it breaks on U+000B, U+000C, U+001C, U+001D
1715
+ # and U+001E, which are not line breaks in YAML but characters PyYAML's
1716
+ # *reader* refuses outright, so `version: 1<U+000C>safety:` was read here as
1717
+ # two lines and opened the gate against a file the other backend cannot load.
1718
+ _YAML_WHITESPACE = " \t"
1719
+
1720
+ # `\n`, `\r\n`, `\r`, U+0085, U+2028 and U+2029 -- `yaml.scanner.scan_line_break`.
1721
+ _YAML_LINE_BREAK_RE = re.compile("\r\n|[\n\r\x85\u2028\u2029]")
1722
+
1723
+ # `yaml.reader.Reader.NON_PRINTABLE`, reproduced.
1724
+ _YAML_NON_PRINTABLE_RE = re.compile(
1725
+ "[^\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\U00010000-\U0010ffff]"
1726
+ )
1727
+
1728
+
1729
+ def _yaml_strip(text: str) -> str:
1730
+ """Strip YAML's whitespace -- a space or a tab -- and nothing else.
1731
+
1732
+ A Unicode space is kept because PyYAML keeps it: it is a character of the
1733
+ scalar, and dropping it made the two backends read the same file
1734
+ differently (see the note above).
1735
+ """
1736
+ return text.strip(_YAML_WHITESPACE)
1737
+
1738
+
1739
+ def _yaml_lines(text: str) -> list[str]:
1740
+ """Split a document on YAML's line breaks, not on Python's wider set."""
1741
+ return _YAML_LINE_BREAK_RE.split(text)
1742
+
1743
+
1744
+ def _unsupported(what: str, text: str) -> ValueError:
1745
+ """The parser's one error shape: what it could not read, and the way out."""
1746
+ return ValueError(
1747
+ f"YAML subset parser: {what}: {text!r} — install PyYAML for full YAML support"
1748
+ )
1749
+
1750
+
1751
+ def _parse_yaml_subset(text: str) -> object:
1752
+ """Small recursive indentation-based parser for the documented subset.
1753
+
1754
+ Raises only ``ValueError``: this parser is one backend among four and
1755
+ knows nothing about the file it is reading, so the path that makes the
1756
+ message actionable is added by ``load_config_file`` for all of them.
1757
+ """
1758
+ lines = _yaml_lines(text)
1759
+ for line in lines:
1760
+ _reject_non_printable(line)
1761
+ if "\t" in line:
1762
+ _reject_tab(line)
1763
+ cleaned: list[tuple[int, str]] = []
1764
+ for line in lines:
1765
+ # Indentation is spaces alone: a tab cannot indent under PyYAML and
1766
+ # the pass above has already refused every bare one, and a Unicode
1767
+ # space is a character of the scalar rather than indentation.
1768
+ indent = len(line) - len(line.lstrip(" "))
1769
+ stripped = _yaml_strip(line)
1770
+ if not stripped or stripped.startswith("#"):
1771
+ continue
1772
+ content = _strip_inline_comment(stripped)
1773
+ if content:
1774
+ cleaned.append((indent, content))
1775
+ for _, content in cleaned:
1776
+ # `---` and `...` open and close a *document*; a file with more than
1777
+ # one is a PyYAML error, and this parser reads a single document, so
1778
+ # it never treats either marker as the content it looks like.
1779
+ if content in ("---", "...") or content[:4] in ("--- ", "... "):
1780
+ raise _unsupported("YAML document markers are not supported at", content)
1781
+ pos = 0
1782
+
1783
+ def is_list_item(content: str) -> bool:
1784
+ return content == "-" or content.startswith("- ")
1785
+
1786
+ def parse_block(min_indent: int) -> object:
1787
+ nonlocal pos
1788
+ # decide dict vs list by first line
1789
+ if pos >= len(cleaned) or cleaned[pos][0] < min_indent:
1790
+ return {}
1791
+ if is_list_item(cleaned[pos][1]):
1792
+ items: list[object] = []
1793
+ # Items of one sequence align exactly. A *deeper* `- x` line is
1794
+ # not a sibling to PyYAML but the continuation of the multi-line
1795
+ # plain scalar above it (`- a` then ` - b` is the one item
1796
+ # "a - b" there), so leave it to the caller, which refuses it.
1797
+ item_indent = cleaned[pos][0]
1798
+ while (
1799
+ pos < len(cleaned)
1800
+ and cleaned[pos][0] == item_indent
1801
+ and is_list_item(cleaned[pos][1])
1802
+ ):
1803
+ ind, content = cleaned[pos]
1804
+ item_text = _yaml_strip(content[1:])
1805
+ pos += 1
1806
+ if item_text == "":
1807
+ # An item with nothing after the dash is a nested block,
1808
+ # or -- with nothing indented under it -- null, which is
1809
+ # what PyYAML builds for it; an empty mapping is not.
1810
+ if pos < len(cleaned) and cleaned[pos][0] > ind:
1811
+ items.append(parse_block(ind + 1))
1812
+ else:
1813
+ items.append(None)
1814
+ elif _split_key(item_text) is not None:
1815
+ # `- key: value` is a one-key *mapping* to PyYAML, and
1816
+ # `- key: value` + an aligned second key is a two-key one.
1817
+ # Resolving the item as a scalar kept it as the string it
1818
+ # looks like, so `required_checks:\n - x: y` installed the
1819
+ # check "x: y" here while PyYAML built {"x": "y"} and the
1820
+ # loader refused the file. This parser implements no
1821
+ # mapping inside a sequence, so it refuses the item.
1822
+ raise _unsupported("a mapping inside a sequence item at", content)
1823
+ else:
1824
+ items.append(_scalar(item_text))
1825
+ return items
1826
+ # Keys are resolved like any other scalar, because PyYAML resolves
1827
+ # them too: `1:` is the integer 1 and `~:` is None there, and the
1828
+ # loader's "profile names must be non-empty strings" check is written
1829
+ # for exactly that. A key kept as the string it looks like would pass
1830
+ # the check on this backend and fail it on the other.
1831
+ mapping: dict[object, object] = {}
1832
+ while pos < len(cleaned) and cleaned[pos][0] >= min_indent:
1833
+ ind, content = cleaned[pos]
1834
+ if ind != min_indent:
1835
+ # Over-indented stray line (mapping keys must align).
1836
+ raise _unsupported("bad indentation at", content)
1837
+ if is_list_item(content):
1838
+ break
1839
+ split = _split_key(content)
1840
+ if split is None:
1841
+ raise _unsupported("cannot parse line", content)
1842
+ raw_key, rest = split
1843
+ if raw_key == "":
1844
+ # `: 1` is an explicit-key mapping to PyYAML, which refuses
1845
+ # it in block context; only a *quoted* empty key is one.
1846
+ raise _unsupported("a mapping key is missing at", content)
1847
+ key = _scalar(raw_key)
1848
+ try:
1849
+ repeated = key in mapping
1850
+ except TypeError:
1851
+ raise _unsupported("unhashable key at", content) from None
1852
+ if repeated:
1853
+ raise ValueError(f"duplicate key {key!r} at: {content!r}")
1854
+ pos += 1
1855
+ if rest == "":
1856
+ # look ahead: deeper indent -> nested block, else None
1857
+ if pos < len(cleaned) and cleaned[pos][0] > ind:
1858
+ mapping[key] = parse_block(cleaned[pos][0])
1859
+ else:
1860
+ mapping[key] = None
1861
+ else:
1862
+ mapping[key] = _scalar(rest)
1863
+ return mapping
1864
+
1865
+ root = parse_block(0)
1866
+ # `parse_block` returns at the first line that does not belong to the
1867
+ # block it is reading: a nested one leaves that line to its caller, which
1868
+ # refuses it, but the top-level call has no caller. So a document that
1869
+ # mixes a mapping and a sequence at the root -- `safety:\n allow_merge:
1870
+ # true\n- x\n` -- used to be returned as the part read before the stray
1871
+ # line, silently dropping content PyYAML rejects outright. Reading *part*
1872
+ # of a malformed file is the one outcome this parser must never produce:
1873
+ # the dropped line could be the one that closes the merge gate.
1874
+ if pos != len(cleaned):
1875
+ raise _unsupported("unexpected content after the document at", cleaned[pos][1])
1876
+ return root
1877
+
1878
+
1879
+ def _scan_line(content: str) -> Iterator[tuple[str, bool]]:
1880
+ r"""Yield ``(char, quoted)`` for the part of a line that is not a comment.
1881
+
1882
+ One scan serves both callers below, because both need PyYAML's two
1883
+ boundary rules and must not disagree about them:
1884
+
1885
+ * A quote *opens* a quoted scalar only where a scalar may **begin** -- at
1886
+ the start of the line, after the ``: `` or ``- `` that introduces a
1887
+ value or a block sequence item, or after a ``,``, ``[`` or ``{`` inside
1888
+ a flow collection. A quote anywhere else is an ordinary character of
1889
+ the plain scalar already being read, which is why ``model: don't # why``
1890
+ keeps its comment and ``model: a 'b # c'`` is the plain scalar ``a 'b``
1891
+ rather than a quoted scalar swallowing the ``#``. Treating *any* space
1892
+ as a scalar start read the first as PyYAML does and the second as a
1893
+ quoted scalar, and let ``model: a 'b\tc'`` past the tab rule below.
1894
+ * A ``#`` starts a comment only after whitespace (or at the start of the
1895
+ line), and everything from there on is comment, so the scan stops.
1896
+ """
1897
+ quote = ""
1898
+ at_start = True # a scalar may begin at the start of a line
1899
+ depth = 0 # open flow collections: `,` separates items only inside one
1900
+ prev = " "
1901
+ for i, ch in enumerate(content):
1902
+ if quote:
1903
+ yield ch, True
1904
+ if ch == quote:
1905
+ quote = ""
1906
+ at_start = False
1907
+ elif ch in "'\"" and at_start:
1908
+ quote = ch
1909
+ yield ch, True
1910
+ elif ch == "#" and (at_start or prev in " \t"):
1911
+ return
1912
+ else:
1913
+ yield ch, False
1914
+ if ch in " \t":
1915
+ pass # whitespace separates tokens; it does not end a scalar
1916
+ elif ch in "[{" and at_start:
1917
+ depth += 1 # a flow collection, whose first item begins next
1918
+ elif ch in "]}" and depth:
1919
+ depth -= 1
1920
+ at_start = False
1921
+ elif ch == "," and depth:
1922
+ at_start = True # the next item of that flow collection
1923
+ elif ch == ":" and content[i + 1 : i + 2] in ("", " "):
1924
+ at_start = True # a mapping value begins after the colon
1925
+ elif ch == "-" and at_start and content[i + 1 : i + 2] == " ":
1926
+ pass # a block sequence indicator: the item begins after it
1927
+ else:
1928
+ at_start = False
1929
+ prev = ch
1930
+
1931
+
1932
+ def _strip_inline_comment(content: str) -> str:
1933
+ """Drop a trailing ``#`` comment, ignoring one inside a quoted scalar."""
1934
+ return "".join(ch for ch, _ in _scan_line(content)).rstrip(_YAML_WHITESPACE)
1935
+
1936
+
1937
+ def _reject_non_printable(line: str) -> None:
1938
+ r"""Refuse the characters ``yaml.reader.Reader`` refuses, as it does.
1939
+
1940
+ PyYAML validates its raw stream before the scanner ever runs, so a
1941
+ non-printable character is an ``unacceptable character`` reader error
1942
+ wherever it sits -- in a comment, inside a quoted scalar, or in the middle
1943
+ of a plain one. Nothing below would have refused one: ``model: ab``
1944
+ was read here as the ordinary plain scalar ``ab``, and the five
1945
+ characters ``str.splitlines()`` breaks on but YAML does not (U+000B,
1946
+ U+000C, U+001C, U+001D, U+001E) turned one line into two, which is how
1947
+ ``version: 1<U+000C>safety:\n allow_merge: true`` opened the merge gate
1948
+ on this backend against a file PyYAML refuses to load.
1949
+
1950
+ This is the reader's rule, which is why it is checked over the raw line
1951
+ and not through ``_scan_line``: unlike a tab, no quoting makes it legal.
1952
+ """
1953
+ found = _YAML_NON_PRINTABLE_RE.search(line)
1954
+ if found is not None:
1955
+ raise _unsupported(f"unacceptable character #x{ord(found.group()):04x} at", line)
1956
+
1957
+
1958
+ def _reject_tab(line: str) -> None:
1959
+ r"""Refuse a tab anywhere PyYAML's scanner refuses one.
1960
+
1961
+ PyYAML skips only *spaces* between tokens and ends a plain scalar at a
1962
+ tab, so a tab that is neither inside a quoted scalar nor inside a comment
1963
+ reaches its scanner as a character that "cannot start any token" -- tab
1964
+ indentation, ``model: foo\tbar`` and even a trailing ``model: foo\t`` are
1965
+ all scanner errors there. The line cleaning above strips and splits on
1966
+ spaces alone, so it would read every one of them as an ordinary plain
1967
+ scalar. A tab inside a quoted scalar or a comment is legal under PyYAML
1968
+ and read identically here, so only the rest is refused.
1969
+
1970
+ Which tab is inside a quoted scalar is ``_scan_line``'s decision, the
1971
+ same one ``_strip_inline_comment`` uses.
1972
+ """
1973
+ for ch, quoted in _scan_line(line):
1974
+ if ch == "\t" and not quoted:
1975
+ raise _unsupported("tab outside a quoted scalar at", line)
1976
+
1977
+
1978
+ def _split_key(content: str) -> tuple[str, str] | None:
1979
+ """Split ``key: value`` at the colon that ends the key, or ``None``.
1980
+
1981
+ In block context a plain ``:`` ends a key only when a space or the end of
1982
+ the line follows it, so ``model: gpt`` is a mapping while ``url:port`` is
1983
+ one plain scalar and ``a:b: 1`` is the key ``a:b``. Splitting on the first
1984
+ colon instead read ``a:b: 1`` as the key ``a`` with the value ``b: 1``,
1985
+ which PyYAML never agreed with. A quoted key is scanned to its closing
1986
+ quote first, so ``"a: b": 1`` is one key too.
1987
+ """
1988
+ i = 0
1989
+ if content[:1] in ("'", '"'):
1990
+ end = content.find(content[0], 1)
1991
+ if end < 0:
1992
+ return None
1993
+ i = end + 1
1994
+ while i < len(content):
1995
+ if content[i] == ":" and (i + 1 == len(content) or content[i + 1] == " "):
1996
+ return _yaml_strip(content[:i]), _yaml_strip(content[i + 1 :])
1997
+ i += 1
1998
+ return None
1999
+
2000
+
2001
+ def _reject_plain_indicators(t: str, flow: bool) -> None:
2002
+ """Refuse the plain scalars PyYAML's scanner refuses in this context.
2003
+
2004
+ PyYAML ends a plain scalar at a ``:`` that a space or the end of the line
2005
+ follows, and reads a leading ``- ``, ``? `` or ``: `` as the block
2006
+ indicator each one is, so it never builds the string these look like:
2007
+ ``model: x: y`` is `mapping values are not allowed here` there, ``model:
2008
+ - x`` is `sequence entries are not allowed here` and ``model: ? x`` is
2009
+ `mapping keys are not allowed here`. ``_split_key`` already encodes the
2010
+ colon half of that rule for keys; this is the same rule for what remains
2011
+ as a value, so the string a plain scalar looks like is never invented
2012
+ where PyYAML refuses the file outright.
2013
+
2014
+ Inside a flow collection PyYAML also ends a plain scalar at ``?``
2015
+ wherever it appears (``[a?b]`` is a parser error there, while the block
2016
+ scalar ``a?b`` is an ordinary string).
2017
+ """
2018
+ if t == "-" or t.startswith("- "):
2019
+ raise _unsupported("sequence entries are not allowed in a plain scalar at", t)
2020
+ if t == "?" or t.startswith("? ") or (flow and "?" in t):
2021
+ raise _unsupported("mapping keys are not allowed in a plain scalar at", t)
2022
+ if t.endswith(":") or ": " in t or (flow and t.startswith(":")):
2023
+ raise _unsupported("mapping values are not allowed in a plain scalar at", t)
2024
+
2025
+
2026
+ def _scalar(text: str, flow: bool = False) -> object:
2027
+ """One YAML scalar, resolved exactly as PyYAML's implicit resolvers do.
2028
+
2029
+ Anything whose PyYAML reading this parser cannot reproduce is refused
2030
+ rather than guessed at (see the note above the resolvers). ``flow`` says
2031
+ whether the scalar is an item of a flow sequence, where PyYAML ends a
2032
+ plain scalar at more characters than in block context.
2033
+ """
2034
+ t = _yaml_strip(text)
2035
+ if t[:1] in ("'", '"'):
2036
+ return _quoted_scalar(t)
2037
+ if t.startswith("["):
2038
+ return _flow_sequence(t)
2039
+ if t[:1] and (t[0] in _UNSUPPORTED_FIRST_CHARS or t in ("=", "<<")):
2040
+ raise _unsupported("unsupported YAML at", t)
2041
+ _reject_plain_indicators(t, flow)
2042
+ if t in _YAML_NULL:
2043
+ return None
2044
+ if t in _YAML_TRUE:
2045
+ return True
2046
+ if t in _YAML_FALSE:
2047
+ return False
2048
+ if _YAML_INT_RE.match(t):
2049
+ return _yaml_int(t)
2050
+ if _YAML_FLOAT_RE.match(t):
2051
+ return _yaml_float(t)
2052
+ if _YAML_TIMESTAMP_RE.match(t):
2053
+ raise _unsupported("YAML timestamps are not supported at", t)
2054
+ return t
2055
+
2056
+
2057
+ def _quoted_scalar(text: str) -> str:
2058
+ """A single- or double-quoted scalar, without decoding any escape.
2059
+
2060
+ ``''`` inside a single-quoted scalar and ``\\n`` inside a double-quoted one
2061
+ mean something to PyYAML that this parser would keep verbatim, so a quoted
2062
+ scalar containing either is refused instead of being read differently.
2063
+ """
2064
+ quote = text[0]
2065
+ if len(text) < 2 or not text.endswith(quote):
2066
+ raise _unsupported("unterminated quoted string at", text)
2067
+ inner = text[1:-1]
2068
+ if quote in inner:
2069
+ raise _unsupported("quoted string with an embedded quote at", text)
2070
+ if quote == '"' and "\\" in inner:
2071
+ raise _unsupported("escape sequence in a double-quoted string at", text)
2072
+ return inner
2073
+
2074
+
2075
+ def _flow_sequence(text: str) -> list[object]:
2076
+ """An inline ``[a, b]`` list; its items are scalars like any other."""
2077
+ if not text.endswith("]"):
2078
+ raise _unsupported("unterminated flow sequence at", text)
2079
+ inner = _yaml_strip(text[1:-1])
2080
+ if not inner:
2081
+ return []
2082
+ return [_scalar(part, flow=True) for part in _split_inline(inner)]
2083
+
2084
+
2085
+ def _yaml_int(text: str) -> int:
2086
+ """``yaml.constructor.SafeConstructor.construct_yaml_int``, reproduced.
2087
+
2088
+ YAML 1.1 has binary, octal (a leading ``0``), hexadecimal and sexagesimal
2089
+ integers, so ``0600`` is 384 and ``1:30`` is 90. Python's ``int()`` reads
2090
+ neither, which is where the two backends used to part ways.
2091
+ """
2092
+ value = text.replace("_", "")
2093
+ sign = -1 if value[0] == "-" else 1
2094
+ if value[0] in "+-":
2095
+ value = value[1:]
2096
+ if value == "0":
2097
+ return 0
2098
+ if value.startswith("0b"):
2099
+ return sign * int(value[2:], 2)
2100
+ if value.startswith("0x"):
2101
+ return sign * int(value[2:], 16)
2102
+ if value[0] == "0":
2103
+ return sign * int(value, 8)
2104
+ if ":" in value:
2105
+ digits = [int(part) for part in value.split(":")]
2106
+ digits.reverse()
2107
+ total, base = 0, 1
2108
+ for digit in digits:
2109
+ total += digit * base
2110
+ base *= 60
2111
+ return sign * total
2112
+ return sign * int(value)
2113
+
2114
+
2115
+ def _yaml_float(text: str) -> float:
2116
+ """``yaml.constructor.SafeConstructor.construct_yaml_float``, reproduced."""
2117
+ value = text.replace("_", "").lower()
2118
+ sign = -1 if value[0] == "-" else 1
2119
+ if value[0] in "+-":
2120
+ value = value[1:]
2121
+ if value == ".inf":
2122
+ return sign * float("inf")
2123
+ if value == ".nan":
2124
+ return float("nan")
2125
+ if ":" in value:
2126
+ digits = [float(part) for part in value.split(":")]
2127
+ digits.reverse()
2128
+ total, base = 0.0, 1
2129
+ for digit in digits:
2130
+ total += digit * base
2131
+ base *= 60
2132
+ return sign * total
2133
+ return sign * float(value)
2134
+
2135
+
2136
+ def _split_inline(inner: str) -> list[str]:
2137
+ """Split a flow sequence's items on the commas outside its quoted scalars.
2138
+
2139
+ A nested flow collection is refused rather than split on the wrong comma:
2140
+ ``[[1, 2]]`` is two malformed items to this split and one list to PyYAML.
2141
+
2142
+ A quote opens a quoted scalar only where an item may begin, which is
2143
+ ``_scan_line``'s rule one level down: ``[a 'b, c']`` is the two plain
2144
+ scalars ``a 'b`` and ``c'`` to PyYAML, not one quoted item, and a quote
2145
+ inside a plain item must not hide the ``]`` after it either.
2146
+ """
2147
+ parts: list[str] = []
2148
+ cur = ""
2149
+ quote = ""
2150
+ at_start = True
2151
+ for ch in inner:
2152
+ if quote:
2153
+ if ch == quote:
2154
+ quote = ""
2155
+ at_start = False
2156
+ cur += ch
2157
+ continue
2158
+ if ch in "'\"" and at_start:
2159
+ quote = ch
2160
+ cur += ch
2161
+ continue
2162
+ if ch in "[]{}":
2163
+ raise _unsupported("nested flow collection at", inner)
2164
+ if ch == ",":
2165
+ parts.append(_yaml_strip(cur))
2166
+ cur = ""
2167
+ at_start = True
2168
+ continue
2169
+ if ch not in " \t":
2170
+ at_start = False
2171
+ cur += ch
2172
+ if quote:
2173
+ raise _unsupported("unterminated quoted string at", inner)
2174
+ if _yaml_strip(cur):
2175
+ parts.append(_yaml_strip(cur))
2176
+ if not all(parts):
2177
+ raise _unsupported("empty item in a flow sequence at", inner)
2178
+ return parts