agent-loop-tool 0.1.0__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.
@@ -0,0 +1,314 @@
1
+ """The checkpoint-gated developer/reviewer loop."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from . import audit, git_ops, safety
9
+ from .providers.base import Provider
10
+ from .state import PlanState, load_state
11
+
12
+
13
+ class LoopHalted(RuntimeError):
14
+ """Raised when a checkpoint cannot be approved within max_review_attempts."""
15
+
16
+
17
+ def _log_event(event: str, **fields: Any) -> None:
18
+ """Print a stable, machine-readable event captured by the loop log tee."""
19
+ print(
20
+ f"[agent-loop] {event} "
21
+ f"{json.dumps({'event': event, **fields}, sort_keys=True, default=str)}",
22
+ flush=True,
23
+ )
24
+
25
+
26
+ def _commit_audit_tail(message: str, log_dir: Path) -> None:
27
+ """Commit the run's closing state, flushing the log tail into git.
28
+
29
+ Called on every exit path, including failures, so it must never replace the
30
+ exception that is already propagating: a commit that cannot be made is
31
+ reported and swallowed.
32
+ """
33
+ try:
34
+ git_ops.commit_checkpoint_changes(message, log_dir=log_dir)
35
+ except Exception as exc: # noqa: BLE001 - audit commit is best-effort
36
+ _log_event(
37
+ "AUDIT_COMMIT_FAILED",
38
+ error=str(exc),
39
+ error_type=type(exc).__name__,
40
+ message=message,
41
+ )
42
+
43
+
44
+ def _run_agent(
45
+ *,
46
+ provider: Provider,
47
+ role: str,
48
+ checkpoint: str,
49
+ prompt: str,
50
+ timeout: int,
51
+ run_kind: str,
52
+ audit_level: str,
53
+ ) -> int:
54
+ """Run an agent while emitting start, prompt, and completion trace events."""
55
+ common = {
56
+ "checkpoint": checkpoint,
57
+ "provider": provider.name,
58
+ "role": role,
59
+ "run_kind": run_kind,
60
+ }
61
+ _log_event("AGENT_TRACE", action="start", timeout_seconds=timeout, **common)
62
+ _log_event(
63
+ "AGENT_TRACE",
64
+ action="prompt",
65
+ prompt=audit.prepare_text(prompt, audit_level),
66
+ **common,
67
+ )
68
+ try:
69
+ return_code = provider.run(prompt, timeout=timeout, audit_level=audit_level)
70
+ except Exception as exc:
71
+ _log_event(
72
+ "AGENT_TRACE",
73
+ action="finish",
74
+ error=str(exc),
75
+ error_type=type(exc).__name__,
76
+ outcome="error",
77
+ **common,
78
+ )
79
+ raise
80
+
81
+ _log_event(
82
+ "AGENT_TRACE",
83
+ action="finish",
84
+ outcome="completed",
85
+ return_code=return_code,
86
+ **common,
87
+ )
88
+ return return_code
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Prompt templates — project-agnostic; commands come from state.project.
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def _criteria_block(checkpoint: dict) -> str:
97
+ return "\n".join(f"- {c}" for c in checkpoint["exit_criteria"])
98
+
99
+
100
+ def _verify_clause(state: PlanState, cid: str) -> str:
101
+ if not state.verify_in_review:
102
+ return ""
103
+ parts: list[str] = []
104
+ for label, field_name in (
105
+ ("Run the build", "build_cmd"),
106
+ ("Run the tests", "test_cmd"),
107
+ ("Run the linter", "lint_cmd"),
108
+ ):
109
+ cmd = state.command_for(cid, field_name)
110
+ if cmd:
111
+ parts.append(f"- {label}: `{cmd}`")
112
+ if not parts:
113
+ return ""
114
+ return (
115
+ "\n\nIndependently verify the work by running these project commands:\n"
116
+ + "\n".join(parts)
117
+ )
118
+
119
+
120
+ def developer_prompt(state: PlanState, cid: str) -> str:
121
+ cp = state.get(cid)
122
+ return f"""You are executing the agreed plan in {state.plan_file}.
123
+ Work ONLY on checkpoint '{cid}' ({cp['name']}). Do not start any later phase.
124
+
125
+ Scope:
126
+ {cp['scope']}
127
+
128
+ This phase is complete only when ALL of these exit criteria are objectively met:
129
+ {_criteria_block(cp)}
130
+
131
+ When finished, in {state.path.name} set checkpoint '{cid}' status to 'built' and
132
+ write a concise summary (files touched, tests added, how each exit criterion is
133
+ met) into its 'review_notes' field.
134
+ """
135
+
136
+
137
+ def reviewer_prompt(state: PlanState, cid: str) -> str:
138
+ cp = state.get(cid)
139
+ verify = _verify_clause(state, cid)
140
+ return f"""Review ONLY checkpoint '{cid}' ({cp['name']}) of the plan in {state.plan_file}.
141
+ Read {state.path.name} for the developer's 'review_notes', then independently
142
+ verify the work in the repo.{verify}
143
+
144
+ Scope for this checkpoint:
145
+ {cp['scope']}
146
+
147
+ Approve ONLY if every one of these exit criteria is genuinely met:
148
+ {_criteria_block(cp)}
149
+
150
+ If all pass: set checkpoint '{cid}' status to 'approved' in {state.path.name}.
151
+ If a criterion genuinely fails: set status to 'built' and write SPECIFIC,
152
+ actionable, criterion-referenced fixes into 'review_notes'. Do not nitpick style
153
+ or request scope beyond this phase.
154
+
155
+ Do not merge to main/master. All final merging must wait for explicit human approval.
156
+ """
157
+
158
+
159
+ def revision_prompt(state: PlanState, cid: str, review_notes: str) -> str:
160
+ return f"""The reviewer did NOT approve checkpoint '{cid}' of the plan.
161
+ Apply ONLY these requested fixes — do not add scope or pull work from later phases:
162
+
163
+ {review_notes}
164
+
165
+ When done, in {state.path.name} set checkpoint '{cid}' status back to 'built' and
166
+ update 'review_notes' describing exactly what you changed.
167
+
168
+ Do not merge to main/master. All final merging must wait for explicit human approval.
169
+ """
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Main loop
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ def run_loop(
178
+ *,
179
+ state_path: Path,
180
+ developer: Provider,
181
+ reviewer: Provider,
182
+ max_review_attempts: int = 3,
183
+ timeout: int = 1800,
184
+ log_dir: Optional[Path] = None,
185
+ audit_level: str = audit.DEFAULT_AUDIT_LEVEL,
186
+ ) -> None:
187
+ audit.validate(audit_level)
188
+ state = load_state(state_path)
189
+ log_dir = log_dir or safety.default_log_dir()
190
+ git_ops.ensure_branch(state.branch)
191
+ git_ops.require_clean_worktree(log_dir=log_dir)
192
+
193
+ try:
194
+ for cid in state.ids():
195
+ cp = state.get(cid)
196
+ if cp["status"] == "approved":
197
+ print(f">> {cid} already approved — skip", flush=True)
198
+ _log_event(
199
+ "APPROVAL_COMMENT",
200
+ approval_status="approved",
201
+ checkpoint=cid,
202
+ comment=audit.prepare_text(cp["review_notes"], audit_level),
203
+ source="existing_checkpoint",
204
+ )
205
+ continue
206
+
207
+ print(
208
+ f"\n============================================================"
209
+ f"\n CHECKPOINT {cid} — {cp['name']}"
210
+ f"\n============================================================",
211
+ flush=True,
212
+ )
213
+
214
+ if cp["status"] != "built":
215
+ _run_agent(
216
+ provider=developer,
217
+ role="developer",
218
+ checkpoint=cid,
219
+ prompt=developer_prompt(state, cid),
220
+ timeout=timeout,
221
+ run_kind="build",
222
+ audit_level=audit_level,
223
+ )
224
+ git_ops.commit_checkpoint_changes(f"{cid}: built", log_dir=log_dir)
225
+ state = load_state(state_path) # reload after agent mutation
226
+ else:
227
+ print(f">> {cid} already built — resuming at review gate", flush=True)
228
+
229
+ while True:
230
+ cp = state.get(cid)
231
+ attempts = cp["attempts"] + 1
232
+ state.set_field(cid, "attempts", attempts)
233
+ state.save()
234
+ print(
235
+ f"--- {cid} review attempt {attempts}/{max_review_attempts} ---",
236
+ flush=True,
237
+ )
238
+
239
+ _run_agent(
240
+ provider=reviewer,
241
+ role="reviewer",
242
+ checkpoint=cid,
243
+ prompt=reviewer_prompt(state, cid),
244
+ timeout=timeout,
245
+ run_kind="review",
246
+ audit_level=audit_level,
247
+ )
248
+ state = load_state(state_path)
249
+ reviewed_checkpoint = state.get(cid)
250
+ status = reviewed_checkpoint["status"]
251
+ notes = reviewed_checkpoint["review_notes"]
252
+ _log_event(
253
+ "REVIEW_COMMENT",
254
+ checkpoint=cid,
255
+ comment=audit.prepare_text(notes, audit_level),
256
+ review_attempt=attempts,
257
+ status=status,
258
+ )
259
+
260
+ if status == "approved":
261
+ print(f">> {cid} APPROVED", flush=True)
262
+ _log_event(
263
+ "APPROVAL_COMMENT",
264
+ approval_status="approved",
265
+ checkpoint=cid,
266
+ comment=audit.prepare_text(notes, audit_level),
267
+ review_attempt=attempts,
268
+ source="reviewer",
269
+ )
270
+ git_ops.commit_checkpoint_changes(
271
+ f"{cid}: approved", log_dir=log_dir
272
+ )
273
+ break
274
+
275
+ if attempts >= max_review_attempts:
276
+ raise LoopHalted(
277
+ f"{cid} not approved after {attempts} attempts — halting for human review."
278
+ )
279
+
280
+ _run_agent(
281
+ provider=developer,
282
+ role="developer",
283
+ checkpoint=cid,
284
+ prompt=revision_prompt(state, cid, notes),
285
+ timeout=timeout,
286
+ run_kind="revision",
287
+ audit_level=audit_level,
288
+ )
289
+ git_ops.commit_checkpoint_changes(
290
+ f"{cid}: revision {attempts}", log_dir=log_dir
291
+ )
292
+ state = load_state(state_path)
293
+ except LoopHalted as exc:
294
+ _log_event("RUN_OUTCOME", outcome="halted", reason=str(exc))
295
+ _commit_audit_tail("audit: halted", log_dir)
296
+ raise
297
+ except BaseException as exc:
298
+ _log_event(
299
+ "RUN_OUTCOME",
300
+ outcome="failed",
301
+ error=str(exc),
302
+ error_type=type(exc).__name__,
303
+ )
304
+ _commit_audit_tail("audit: run failed", log_dir)
305
+ raise
306
+
307
+ print(f"\n### All checkpoints approved on branch {state.branch}.", flush=True)
308
+ print(
309
+ "### No merge was performed. Review the branch and merge to main/master "
310
+ "only after explicit human approval.",
311
+ flush=True,
312
+ )
313
+ _log_event("RUN_OUTCOME", outcome="completed", branch=state.branch)
314
+ _commit_audit_tail("audit: completed run", log_dir)
@@ -0,0 +1,357 @@
1
+ """Shared foundations for building `plan_checkpoints.json` payloads.
2
+
3
+ Provides the canonical generated-state shape (a payload that satisfies the
4
+ same schema `load_state` enforces) plus reusable in-memory validation, so
5
+ that plan-generation code (see the `init` subcommand, added in a later
6
+ phase) can validate a provider-produced payload before writing anything to
7
+ disk.
8
+
9
+ Also provides the `init`-only plan-generation path: running a provider in
10
+ captured (non-streaming) mode with a strict JSON-only prompt, then parsing
11
+ and normalizing whatever it returns into checkpoints in the canonical shape
12
+ above. The developer/reviewer loop is untouched by any of this.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import tempfile
20
+ import unicodedata
21
+ from pathlib import Path
22
+ from typing import Any, Optional
23
+
24
+ from .providers.base import Provider
25
+ from .state import (
26
+ DuplicateCheckpointError,
27
+ InvalidPlanState,
28
+ ProtectedBranchError,
29
+ validate_payload,
30
+ )
31
+
32
+ __all__ = [
33
+ "DEFAULT_PLAN_FILE",
34
+ "DuplicateCheckpointError",
35
+ "InvalidPlanState",
36
+ "PlanOutputParseError",
37
+ "ProtectedBranchError",
38
+ "ProviderExecutionError",
39
+ "StateFileExistsError",
40
+ "build_generated_payload",
41
+ "build_init_prompt",
42
+ "generate_plan_json",
43
+ "new_checkpoint",
44
+ "normalize_checkpoints",
45
+ "parse_plan_json",
46
+ "run_provider_captured",
47
+ "slugify",
48
+ "validate_generated_payload",
49
+ "write_generated_payload",
50
+ ]
51
+
52
+ #: `plan_file` recorded for a plain-English feature description, matching the
53
+ #: convention documented in the README's schema example.
54
+ DEFAULT_PLAN_FILE = "docs/implementation_plan.md"
55
+
56
+
57
+ class ProviderExecutionError(RuntimeError):
58
+ """Raised when the planning provider exits non-zero or times out."""
59
+
60
+
61
+ class PlanOutputParseError(ValueError):
62
+ """Raised when provider output is not raw JSON or a single JSON code fence."""
63
+
64
+
65
+ class StateFileExistsError(RuntimeError):
66
+ """Raised when the target state file exists and overwriting was not requested."""
67
+
68
+
69
+ def new_checkpoint(cid: str, name: str, scope: str, exit_criteria: list[str]) -> dict:
70
+ """Build a single checkpoint in its canonical freshly-generated shape.
71
+
72
+ Always starts `pending`, with zero attempts and no review notes,
73
+ regardless of what a provider returned for those fields.
74
+ """
75
+ return {
76
+ "id": cid,
77
+ "name": name,
78
+ "status": "pending",
79
+ "scope": scope,
80
+ "exit_criteria": list(exit_criteria),
81
+ "attempts": 0,
82
+ "review_notes": "",
83
+ }
84
+
85
+
86
+ def build_generated_payload(
87
+ plan_file: str,
88
+ branch: str,
89
+ checkpoints: list[dict],
90
+ project: Optional[dict] = None,
91
+ models: Optional[dict] = None,
92
+ ) -> dict[str, Any]:
93
+ """Assemble a top-level payload in the canonical generated-state shape."""
94
+ payload: dict[str, Any] = {
95
+ "plan_file": plan_file,
96
+ "branch": branch,
97
+ "checkpoints": checkpoints,
98
+ }
99
+ if project:
100
+ payload["project"] = project
101
+ if models:
102
+ payload["models"] = models
103
+ return payload
104
+
105
+
106
+ def validate_generated_payload(payload: dict) -> None:
107
+ """Validate an in-memory generated payload before it is written to disk.
108
+
109
+ Raises `InvalidPlanState` (or the more specific `ProtectedBranchError` /
110
+ `DuplicateCheckpointError` subclasses) on failure; raises nothing on
111
+ success.
112
+ """
113
+ validate_payload(payload)
114
+
115
+
116
+ _SLUG_COLLAPSE_RE = re.compile(r"[^a-z0-9]+")
117
+
118
+
119
+ def slugify(text: str, max_len: int = 40) -> str:
120
+ """Turn arbitrary text into a short, branch-name-safe slug.
121
+
122
+ Transliterates accented characters to their ASCII base (so "café"
123
+ becomes "cafe" rather than "caf"), lowercases, collapses any run of
124
+ remaining non-alphanumeric characters into a single hyphen, and trims to
125
+ `max_len`. Falls back to "feature" if nothing alphanumeric survives.
126
+ """
127
+ # NFKD splits "é" into "e" + combining accent; dropping non-ASCII then
128
+ # keeps the base letter. Must run before truncation so `max_len` counts
129
+ # the characters that actually reach the branch name.
130
+ decomposed = unicodedata.normalize("NFKD", text)
131
+ ascii_text = decomposed.encode("ascii", "ignore").decode("ascii")
132
+ slug = _SLUG_COLLAPSE_RE.sub("-", ascii_text.strip().lower()).strip("-")
133
+ slug = slug[:max_len].strip("-")
134
+ return slug or "feature"
135
+
136
+
137
+ def _publish_exclusive(tmp_name: str, path: Path) -> None:
138
+ """Publish `tmp_name` as `path`, refusing to replace an existing file.
139
+
140
+ Both routes below create the final name and fail if it is already taken in
141
+ one atomic step, with no exists()-then-write gap — so a state file that
142
+ another process creates between the CLI's up-front existence check and
143
+ this write is never clobbered, on any filesystem.
144
+ """
145
+ try:
146
+ os.link(tmp_name, path)
147
+ return
148
+ except FileExistsError as e:
149
+ raise StateFileExistsError(f"{path} already exists") from e
150
+ except OSError:
151
+ pass # no hardlink support on this filesystem; claim the name instead
152
+
153
+ # Filesystems without hardlink support (exFAT, some network mounts) still
154
+ # honour O_CREAT|O_EXCL, which claims the name atomically and does not
155
+ # follow symlinks. Claiming it up front means the os.replace below can only
156
+ # ever overwrite the empty placeholder this process owns — never a state
157
+ # file a concurrent init wrote. A plain exists()-then-replace would not:
158
+ # it reopens the very race the exclusive publish exists to close.
159
+ try:
160
+ os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
161
+ except FileExistsError as e:
162
+ raise StateFileExistsError(f"{path} already exists") from e
163
+
164
+ try:
165
+ os.replace(tmp_name, path)
166
+ except OSError:
167
+ # Drop the placeholder so a failed publish does not leave a zero-byte
168
+ # state file behind, blocking the next run until --force.
169
+ try:
170
+ os.unlink(path)
171
+ except OSError:
172
+ pass
173
+ raise
174
+
175
+
176
+ def write_generated_payload(path: Path, payload: dict, *, force: bool = False) -> None:
177
+ """Write `payload` as formatted JSON to `path` atomically.
178
+
179
+ Writes to a temp file in the same directory first, flushes it to disk,
180
+ then publishes it under `path`, so a crash or interrupt never leaves
181
+ `path` truncated or holding partial JSON. Without `force`, an existing
182
+ `path` is left untouched and `StateFileExistsError` is raised. Callers
183
+ must validate `payload` before calling this.
184
+ """
185
+ path = Path(path)
186
+ path.parent.mkdir(parents=True, exist_ok=True)
187
+ fd, tmp_name = tempfile.mkstemp(
188
+ dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
189
+ )
190
+ try:
191
+ with os.fdopen(fd, "w") as f:
192
+ json.dump(payload, f, indent=2)
193
+ f.write("\n")
194
+ f.flush()
195
+ # Durable on disk before it is reachable under `path`.
196
+ os.fsync(f.fileno())
197
+
198
+ if force:
199
+ os.replace(tmp_name, path)
200
+ tmp_name = None # consumed by the rename
201
+ else:
202
+ _publish_exclusive(tmp_name, path)
203
+ finally:
204
+ # After a successful link the payload lives under both names; drop the
205
+ # temp one. Also cleans up after any failure above.
206
+ if tmp_name is not None:
207
+ try:
208
+ os.unlink(tmp_name)
209
+ except OSError:
210
+ pass
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Captured provider execution (init only — the run/review loop keeps using
215
+ # Provider.run's streaming behavior, unchanged).
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def run_provider_captured(provider: Provider, prompt: str, timeout: int) -> str:
220
+ """Run `provider` non-interactively and return its captured stdout.
221
+
222
+ Raises `ProviderExecutionError` on a timeout or a non-zero exit rather
223
+ than handing the caller a partial or garbage result.
224
+ """
225
+ result = provider.run_captured(prompt, timeout=timeout)
226
+ if result.timed_out:
227
+ raise ProviderExecutionError(
228
+ f"{provider.name} timed out after {timeout}s while generating a plan"
229
+ )
230
+ if result.returncode != 0:
231
+ detail = result.stderr.strip()
232
+ suffix = f": {detail}" if detail else ""
233
+ raise ProviderExecutionError(
234
+ f"{provider.name} exited with code {result.returncode} while "
235
+ f"generating a plan{suffix}"
236
+ )
237
+ return result.stdout
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Prompt: project-agnostic, ordered, independently reviewable checkpoints,
242
+ # JSON-only output.
243
+ # ---------------------------------------------------------------------------
244
+
245
+ INIT_PROMPT_TEMPLATE = """You are generating an implementation plan for a checkpoint-gated developer/reviewer coding loop. Convert the feature description below into an ordered list of independently reviewable checkpoints.
246
+
247
+ Feature description:
248
+ {feature_text}
249
+
250
+ Rules:
251
+ - Break the feature into 2 to 6 sequential checkpoints, ordered so each one builds on the ones before it and can be developed and reviewed in isolation.
252
+ - Each checkpoint must be project-agnostic: do not assume a specific language, framework, build tool, or test command unless the feature description names one.
253
+ - Each checkpoint needs a short unique id (e.g. "phase0", "phase1", ...), a short name, a "scope" describing exactly what to build, and an "exit_criteria" list of objective, independently verifiable conditions. Do not use vague criteria like "code is clean" or "works well".
254
+ - Do not include "status", "attempts", or "review_notes" fields; they are assigned automatically.
255
+ - Output ONLY a single JSON object and nothing else: no prose, no explanation, no markdown headings, before or after it. You may wrap the JSON in a single ```json code fence, or output raw JSON with no fence at all — never mix prose with either form.
256
+
257
+ Output JSON shape exactly:
258
+ {{
259
+ "checkpoints": [
260
+ {{"id": "phase0", "name": "...", "scope": "...", "exit_criteria": ["...", "..."]}}
261
+ ]
262
+ }}
263
+ """
264
+
265
+
266
+ def build_init_prompt(feature_text: str) -> str:
267
+ """Build the strict, JSON-only planning prompt for `feature_text`."""
268
+ return INIT_PROMPT_TEMPLATE.format(feature_text=feature_text)
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Parsing: raw JSON or a single Markdown-fenced JSON block, nothing else.
273
+ # ---------------------------------------------------------------------------
274
+
275
+ _FENCE_RE = re.compile(r"\A```(?:json)?\s*\n(?P<body>.*?)\n```\s*\Z", re.DOTALL)
276
+
277
+
278
+ def parse_plan_json(raw_output: str) -> Any:
279
+ """Parse `raw_output` as raw JSON or as a single ```json fenced block.
280
+
281
+ Rejects empty output, output that mixes prose with a fence (or has more
282
+ than one fence), and invalid JSON — always with a `PlanOutputParseError`
283
+ describing why.
284
+ """
285
+ text = raw_output.strip()
286
+ if not text:
287
+ raise PlanOutputParseError("Provider produced no output")
288
+
289
+ fence_count = text.count("```")
290
+ if fence_count == 0:
291
+ candidate = text
292
+ elif fence_count == 2:
293
+ fence_match = _FENCE_RE.match(text)
294
+ if not fence_match:
295
+ raise PlanOutputParseError(
296
+ "Provider output mixes prose with a code fence; expected raw "
297
+ "JSON or a single JSON code fence with nothing else outside it"
298
+ )
299
+ candidate = fence_match.group("body")
300
+ else:
301
+ raise PlanOutputParseError(
302
+ "Provider output contains more than one code fence; expected raw "
303
+ "JSON or a single JSON code fence"
304
+ )
305
+
306
+ try:
307
+ return json.loads(candidate)
308
+ except json.JSONDecodeError as e:
309
+ raise PlanOutputParseError(f"Provider output is not valid JSON: {e}") from e
310
+
311
+
312
+ REQUIRED_CHECKPOINT_INPUT_FIELDS = ("id", "name", "scope", "exit_criteria")
313
+
314
+
315
+ def normalize_checkpoints(data: Any) -> list[dict]:
316
+ """Extract and normalize checkpoints from parsed provider JSON.
317
+
318
+ Accepts either `{"checkpoints": [...]}` or a bare `[...]` list. Each
319
+ checkpoint is rebuilt with `new_checkpoint` so status/attempts/review_notes
320
+ always start canonical, regardless of what the provider returned.
321
+ """
322
+ checkpoints = data.get("checkpoints") if isinstance(data, dict) else data
323
+
324
+ if not isinstance(checkpoints, list) or not checkpoints:
325
+ raise PlanOutputParseError(
326
+ "Provider output must contain a non-empty 'checkpoints' list"
327
+ )
328
+
329
+ normalized: list[dict] = []
330
+ for idx, cp in enumerate(checkpoints):
331
+ if not isinstance(cp, dict):
332
+ raise PlanOutputParseError(f"checkpoint #{idx} must be a JSON object")
333
+ missing = [f for f in REQUIRED_CHECKPOINT_INPUT_FIELDS if f not in cp]
334
+ if missing:
335
+ raise PlanOutputParseError(
336
+ f"checkpoint #{idx} is missing required field(s): {', '.join(missing)}"
337
+ )
338
+ if not isinstance(cp["exit_criteria"], list):
339
+ raise PlanOutputParseError(
340
+ f"checkpoint #{idx} ('{cp.get('id')}'): 'exit_criteria' must be a list"
341
+ )
342
+ normalized.append(
343
+ new_checkpoint(cp["id"], cp["name"], cp["scope"], cp["exit_criteria"])
344
+ )
345
+ return normalized
346
+
347
+
348
+ def generate_plan_json(provider: Provider, feature_text: str, timeout: int) -> Any:
349
+ """Run the init prompt through `provider` and parse its captured output.
350
+
351
+ Composes `run_provider_captured` with `parse_plan_json`. Callers combine
352
+ the result with `normalize_checkpoints` and `build_generated_payload` to
353
+ get a payload ready for `validate_generated_payload`.
354
+ """
355
+ prompt = build_init_prompt(feature_text)
356
+ stdout = run_provider_captured(provider, prompt, timeout)
357
+ return parse_plan_json(stdout)
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from .base import Provider, ProviderError
6
+
7
+ PROVIDERS: dict[str, type[Provider]] = {}
8
+
9
+
10
+ def register(cls: type[Provider]) -> type[Provider]:
11
+ PROVIDERS[cls.name] = cls
12
+ return cls
13
+
14
+
15
+ def get_provider(name: str, model: Optional[str] = None) -> Provider:
16
+ if name not in PROVIDERS:
17
+ known = ", ".join(sorted(PROVIDERS)) or "<none>"
18
+ raise ProviderError(f"Unknown provider '{name}'. Known: {known}")
19
+ return PROVIDERS[name](model)
20
+
21
+
22
+ def known_provider_names() -> list[str]:
23
+ return sorted(PROVIDERS)
24
+
25
+
26
+ # Importing the concrete modules triggers @register side effects.
27
+ from . import antigravity as _antigravity # noqa: E402, F401
28
+ from . import claude as _claude # noqa: E402, F401
29
+ from . import codex as _codex # noqa: E402, F401
30
+ from . import grok as _grok # noqa: E402, F401