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.
- agent_loop/__init__.py +1 -0
- agent_loop/audit.py +128 -0
- agent_loop/cli.py +354 -0
- agent_loop/git_ops.py +127 -0
- agent_loop/orchestrator.py +314 -0
- agent_loop/plan_init.py +357 -0
- agent_loop/providers/__init__.py +30 -0
- agent_loop/providers/antigravity.py +19 -0
- agent_loop/providers/base.py +147 -0
- agent_loop/providers/claude.py +19 -0
- agent_loop/providers/codex.py +21 -0
- agent_loop/providers/grok.py +18 -0
- agent_loop/safety.py +105 -0
- agent_loop/state.py +247 -0
- agent_loop_tool-0.1.0.dist-info/METADATA +241 -0
- agent_loop_tool-0.1.0.dist-info/RECORD +20 -0
- agent_loop_tool-0.1.0.dist-info/WHEEL +5 -0
- agent_loop_tool-0.1.0.dist-info/entry_points.txt +2 -0
- agent_loop_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_loop_tool-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .base import Provider
|
|
4
|
+
from . import register
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register
|
|
8
|
+
class AntigravityProvider(Provider):
|
|
9
|
+
name = "antigravity"
|
|
10
|
+
binary = "agy"
|
|
11
|
+
auto_mode_env = "ALLOW_AUTO_MODE_ANTIGRAVITY"
|
|
12
|
+
|
|
13
|
+
def build_argv(self, prompt: str) -> list[str]:
|
|
14
|
+
argv = [self.binary, "-p", prompt]
|
|
15
|
+
if self.auto_mode_enabled:
|
|
16
|
+
argv.append("--dangerously-skip-permissions")
|
|
17
|
+
argv.extend(self._model_argv())
|
|
18
|
+
return argv
|
|
19
|
+
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import threading
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .. import audit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProviderError(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
#: Grace period for the output pump to drain after the child exits.
|
|
19
|
+
_PUMP_JOIN_SECONDS = 5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CapturedResult:
|
|
24
|
+
"""Result of a non-streaming, output-capturing provider invocation.
|
|
25
|
+
|
|
26
|
+
Produced only by `Provider.run_captured`, which is used by `agent-loop
|
|
27
|
+
init` to read a provider's full response. The developer/reviewer loop
|
|
28
|
+
keeps using `Provider.run`, which streams straight to the terminal.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
returncode: int
|
|
32
|
+
stdout: str
|
|
33
|
+
stderr: str
|
|
34
|
+
timed_out: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Provider(ABC):
|
|
38
|
+
name: str
|
|
39
|
+
binary: str
|
|
40
|
+
auto_mode_env: str
|
|
41
|
+
#: Flag this CLI uses to pin a model; overridable per provider.
|
|
42
|
+
model_flag: str = "--model"
|
|
43
|
+
|
|
44
|
+
def __init__(self, model: Optional[str] = None) -> None:
|
|
45
|
+
#: When None, the underlying CLI resolves its own default model.
|
|
46
|
+
self.model = model or None
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def build_argv(self, prompt: str) -> list[str]:
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def _model_argv(self) -> list[str]:
|
|
53
|
+
"""Argv fragment that pins the model, or empty to defer to the CLI."""
|
|
54
|
+
return [self.model_flag, self.model] if self.model else []
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def auto_mode_enabled(self) -> bool:
|
|
58
|
+
return os.environ.get(self.auto_mode_env, "0") == "1"
|
|
59
|
+
|
|
60
|
+
def preflight(self) -> None:
|
|
61
|
+
if shutil.which(self.binary) is None:
|
|
62
|
+
raise ProviderError(
|
|
63
|
+
f"Missing CLI for provider '{self.name}': '{self.binary}' not on PATH."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def run(self, prompt: str, timeout: int, audit_level: str = "full") -> int:
|
|
67
|
+
"""Stream the agent's output to the terminal *and* the run log.
|
|
68
|
+
|
|
69
|
+
The child cannot simply inherit our stdout: `safety.tee_stdout_to`
|
|
70
|
+
rebinds `sys.stdout` at the Python level, while a subprocess writes to
|
|
71
|
+
the inherited file descriptor 1 directly. Anything the agent printed
|
|
72
|
+
would reach the terminal and bypass the log entirely — which is most of
|
|
73
|
+
what an audit trail is for. So its output is piped back here and
|
|
74
|
+
re-emitted through `print`, which does go through the tee.
|
|
75
|
+
|
|
76
|
+
A pump thread forwards lines as they arrive so long runs stay live
|
|
77
|
+
rather than surfacing only once the agent exits. `audit_level` (see
|
|
78
|
+
`agent_loop.audit`) controls what that pump actually does with each
|
|
79
|
+
line: pass it through untouched (``full``), scrub known secret shapes
|
|
80
|
+
first (``redacted``), or drop it entirely (``off``) so nothing the
|
|
81
|
+
agent printed reaches the committed log.
|
|
82
|
+
"""
|
|
83
|
+
audit.validate(audit_level)
|
|
84
|
+
argv = self.build_argv(prompt)
|
|
85
|
+
proc = subprocess.Popen(
|
|
86
|
+
argv,
|
|
87
|
+
stdout=subprocess.PIPE,
|
|
88
|
+
stderr=subprocess.STDOUT,
|
|
89
|
+
text=True,
|
|
90
|
+
bufsize=1,
|
|
91
|
+
errors="replace",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
redactor = audit.StreamRedactor() if audit_level == "redacted" else None
|
|
95
|
+
|
|
96
|
+
def _pump() -> None:
|
|
97
|
+
assert proc.stdout is not None
|
|
98
|
+
for line in proc.stdout:
|
|
99
|
+
if audit_level == "off":
|
|
100
|
+
continue
|
|
101
|
+
if redactor is not None:
|
|
102
|
+
line = redactor.feed_line(line)
|
|
103
|
+
if not line:
|
|
104
|
+
continue
|
|
105
|
+
print(line, end="", flush=True)
|
|
106
|
+
|
|
107
|
+
pump = threading.Thread(target=_pump, daemon=True)
|
|
108
|
+
pump.start()
|
|
109
|
+
try:
|
|
110
|
+
proc.wait(timeout=timeout)
|
|
111
|
+
except subprocess.TimeoutExpired:
|
|
112
|
+
proc.kill()
|
|
113
|
+
proc.wait()
|
|
114
|
+
pump.join(timeout=_PUMP_JOIN_SECONDS)
|
|
115
|
+
print(
|
|
116
|
+
f"[agent-loop] {self.name} timed out after {timeout}s",
|
|
117
|
+
flush=True,
|
|
118
|
+
)
|
|
119
|
+
return 124
|
|
120
|
+
|
|
121
|
+
pump.join(timeout=_PUMP_JOIN_SECONDS)
|
|
122
|
+
return proc.returncode
|
|
123
|
+
|
|
124
|
+
def run_captured(self, prompt: str, timeout: int) -> CapturedResult:
|
|
125
|
+
"""Run the provider non-interactively, capturing stdout/stderr.
|
|
126
|
+
|
|
127
|
+
Unlike `run`, nothing is streamed to the terminal — this is for
|
|
128
|
+
callers (namely `agent-loop init`) that need to parse the provider's
|
|
129
|
+
full response rather than watch it work.
|
|
130
|
+
"""
|
|
131
|
+
argv = self.build_argv(prompt)
|
|
132
|
+
try:
|
|
133
|
+
result = subprocess.run(
|
|
134
|
+
argv, timeout=timeout, capture_output=True, text=True
|
|
135
|
+
)
|
|
136
|
+
return CapturedResult(
|
|
137
|
+
returncode=result.returncode,
|
|
138
|
+
stdout=result.stdout,
|
|
139
|
+
stderr=result.stderr,
|
|
140
|
+
)
|
|
141
|
+
except subprocess.TimeoutExpired as e:
|
|
142
|
+
return CapturedResult(
|
|
143
|
+
returncode=124,
|
|
144
|
+
stdout=e.stdout or "",
|
|
145
|
+
stderr=e.stderr or "",
|
|
146
|
+
timed_out=True,
|
|
147
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .base import Provider
|
|
4
|
+
from . import register
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register
|
|
8
|
+
class ClaudeProvider(Provider):
|
|
9
|
+
name = "claude"
|
|
10
|
+
binary = "claude"
|
|
11
|
+
auto_mode_env = "ALLOW_AUTO_MODE_CLAUDE"
|
|
12
|
+
|
|
13
|
+
def build_argv(self, prompt: str) -> list[str]:
|
|
14
|
+
argv = [self.binary, "-p", prompt]
|
|
15
|
+
if self.auto_mode_enabled:
|
|
16
|
+
argv.append("--dangerously-skip-permissions")
|
|
17
|
+
argv.extend(["--output-format", "text"])
|
|
18
|
+
argv.extend(self._model_argv())
|
|
19
|
+
return argv
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .base import Provider
|
|
4
|
+
from . import register
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register
|
|
8
|
+
class CodexProvider(Provider):
|
|
9
|
+
name = "codex"
|
|
10
|
+
binary = "codex"
|
|
11
|
+
auto_mode_env = "ALLOW_AUTO_MODE_CODEX"
|
|
12
|
+
model_flag = "-m"
|
|
13
|
+
|
|
14
|
+
def build_argv(self, prompt: str) -> list[str]:
|
|
15
|
+
flag = (
|
|
16
|
+
"--dangerously-bypass-approvals-and-sandbox"
|
|
17
|
+
if self.auto_mode_enabled
|
|
18
|
+
else "--full-auto"
|
|
19
|
+
)
|
|
20
|
+
# Model must precede the positional prompt so `exec` parses it as a flag.
|
|
21
|
+
return [self.binary, "exec", flag, *self._model_argv(), prompt]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .base import Provider
|
|
4
|
+
from . import register
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register
|
|
8
|
+
class GrokProvider(Provider):
|
|
9
|
+
name = "grok"
|
|
10
|
+
binary = "grok"
|
|
11
|
+
auto_mode_env = "ALLOW_AUTO_MODE_GROK"
|
|
12
|
+
|
|
13
|
+
def build_argv(self, prompt: str) -> list[str]:
|
|
14
|
+
argv = [self.binary, "-p", prompt]
|
|
15
|
+
if self.auto_mode_enabled:
|
|
16
|
+
argv.append("--always-approve")
|
|
17
|
+
argv.extend(self._model_argv())
|
|
18
|
+
return argv
|
agent_loop/safety.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Lockfile, log directory, and auto-mode-flag gating."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .git_ops import GitError, repo_root
|
|
11
|
+
from .providers.base import Provider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LockHeld(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AutoModeGateError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextmanager
|
|
23
|
+
def lockfile(path: Path):
|
|
24
|
+
try:
|
|
25
|
+
path.mkdir(parents=False, exist_ok=False)
|
|
26
|
+
except FileExistsError as e:
|
|
27
|
+
raise LockHeld(
|
|
28
|
+
f"Another loop appears to be running (lock: {path}). "
|
|
29
|
+
"Remove the lock only if you are sure no loop process is active."
|
|
30
|
+
) from e
|
|
31
|
+
try:
|
|
32
|
+
yield
|
|
33
|
+
finally:
|
|
34
|
+
try:
|
|
35
|
+
path.rmdir()
|
|
36
|
+
except OSError:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def open_log_file(log_dir: Path) -> Path:
|
|
41
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
43
|
+
return log_dir / f"loop_{stamp}.log"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def require_auto_mode_gates(providers: list[Provider]) -> None:
|
|
47
|
+
"""Refuse to launch if any assigned provider's write-mode env isn't set.
|
|
48
|
+
|
|
49
|
+
Mirrors the bash script's refusal for prompt-only Claude — every coding-agent
|
|
50
|
+
CLI in v1 expects yolo/auto mode for unattended runs.
|
|
51
|
+
"""
|
|
52
|
+
blocked = [p for p in providers if not p.auto_mode_enabled]
|
|
53
|
+
if not blocked:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
lines = [
|
|
57
|
+
"These providers are assigned to a role that writes files, but their",
|
|
58
|
+
"write-mode env gate is not set:",
|
|
59
|
+
"",
|
|
60
|
+
]
|
|
61
|
+
for p in blocked:
|
|
62
|
+
lines.append(f" {p.name:8s} set {p.auto_mode_env}=1")
|
|
63
|
+
lines.extend(
|
|
64
|
+
[
|
|
65
|
+
"",
|
|
66
|
+
"Unattended runs would otherwise block on a permission prompt.",
|
|
67
|
+
"Re-run with the missing env vars exported.",
|
|
68
|
+
]
|
|
69
|
+
)
|
|
70
|
+
raise AutoModeGateError("\n".join(lines))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tee_stdout_to(log_path: Path) -> None:
|
|
74
|
+
"""Mirror stdout+stderr to a log file in addition to the terminal."""
|
|
75
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
log_fh = open(log_path, "a", buffering=1)
|
|
77
|
+
|
|
78
|
+
class _Tee:
|
|
79
|
+
def __init__(self, *streams):
|
|
80
|
+
self.streams = streams
|
|
81
|
+
|
|
82
|
+
def write(self, data):
|
|
83
|
+
for s in self.streams:
|
|
84
|
+
s.write(data)
|
|
85
|
+
|
|
86
|
+
def flush(self):
|
|
87
|
+
for s in self.streams:
|
|
88
|
+
s.flush()
|
|
89
|
+
|
|
90
|
+
sys.stdout = _Tee(sys.stdout, log_fh)
|
|
91
|
+
sys.stderr = _Tee(sys.stderr, log_fh)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def default_log_dir() -> Path:
|
|
95
|
+
configured = os.environ.get("LOG_DIR")
|
|
96
|
+
if configured:
|
|
97
|
+
return Path(configured)
|
|
98
|
+
try:
|
|
99
|
+
return repo_root() / "logs"
|
|
100
|
+
except GitError:
|
|
101
|
+
return Path("logs")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def default_lock_path() -> Path:
|
|
105
|
+
return Path(os.environ.get("LOCK_DIR", "/tmp/agent_loop.lock"))
|
agent_loop/state.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Load, validate, mutate, and save plan_checkpoints.json."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Iterable, Optional
|
|
8
|
+
|
|
9
|
+
ALLOWED_STATUSES = ("pending", "built", "approved")
|
|
10
|
+
PROTECTED_BRANCHES = ("main", "master")
|
|
11
|
+
REQUIRED_TOP_LEVEL = ("plan_file", "branch", "checkpoints")
|
|
12
|
+
REQUIRED_CHECKPOINT_FIELDS = (
|
|
13
|
+
"id",
|
|
14
|
+
"name",
|
|
15
|
+
"status",
|
|
16
|
+
"scope",
|
|
17
|
+
"exit_criteria",
|
|
18
|
+
"attempts",
|
|
19
|
+
"review_notes",
|
|
20
|
+
)
|
|
21
|
+
COMMAND_FIELDS = ("build_cmd", "test_cmd", "lint_cmd")
|
|
22
|
+
MODEL_ROLES = ("developer", "reviewer")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidPlanState(ValueError):
|
|
26
|
+
"""Raised when plan_checkpoints.json fails schema validation."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProtectedBranchError(InvalidPlanState):
|
|
30
|
+
"""Raised when a payload targets a protected branch (e.g. main, master)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DuplicateCheckpointError(InvalidPlanState):
|
|
34
|
+
"""Raised when a payload contains two checkpoints with the same id."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CheckpointNotFound(KeyError):
|
|
38
|
+
def __init__(self, cid: str):
|
|
39
|
+
super().__init__(cid)
|
|
40
|
+
self.cid = cid
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
return f"Unknown checkpoint: {self.cid}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class PlanState:
|
|
48
|
+
path: Path
|
|
49
|
+
plan_file: str
|
|
50
|
+
branch: str
|
|
51
|
+
checkpoints: list[dict]
|
|
52
|
+
project: dict = field(default_factory=dict)
|
|
53
|
+
models: dict = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Convenience accessors for the project block
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def build_cmd(self) -> Optional[str]:
|
|
61
|
+
return self.project.get("build_cmd")
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def test_cmd(self) -> Optional[str]:
|
|
65
|
+
return self.project.get("test_cmd")
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def lint_cmd(self) -> Optional[str]:
|
|
69
|
+
return self.project.get("lint_cmd")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def verify_in_review(self) -> bool:
|
|
73
|
+
return bool(self.project.get("verify_in_review", False))
|
|
74
|
+
|
|
75
|
+
def command_for(self, cid: str, field_name: str) -> Optional[str]:
|
|
76
|
+
if field_name not in COMMAND_FIELDS:
|
|
77
|
+
raise ValueError(f"Unknown command field: {field_name}")
|
|
78
|
+
cp = self.get(cid)
|
|
79
|
+
return cp.get(field_name) or self.project.get(field_name)
|
|
80
|
+
|
|
81
|
+
def model_for(self, role: str) -> Optional[str]:
|
|
82
|
+
"""Model pinned for a role in the plan, or None to defer to the CLI."""
|
|
83
|
+
if role not in MODEL_ROLES:
|
|
84
|
+
raise ValueError(f"Unknown model role: {role}")
|
|
85
|
+
return self.models.get(role)
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Checkpoint queries / mutations
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def get(self, cid: str) -> dict:
|
|
92
|
+
for cp in self.checkpoints:
|
|
93
|
+
if cp["id"] == cid:
|
|
94
|
+
return cp
|
|
95
|
+
raise CheckpointNotFound(cid)
|
|
96
|
+
|
|
97
|
+
def ids(self) -> list[str]:
|
|
98
|
+
return [cp["id"] for cp in self.checkpoints]
|
|
99
|
+
|
|
100
|
+
def pending_checkpoints(self) -> Iterable[dict]:
|
|
101
|
+
return [cp for cp in self.checkpoints if cp["status"] != "approved"]
|
|
102
|
+
|
|
103
|
+
def set_field(self, cid: str, field_name: str, value: Any) -> None:
|
|
104
|
+
cp = self.get(cid)
|
|
105
|
+
if field_name == "status" and value not in ALLOWED_STATUSES:
|
|
106
|
+
raise InvalidPlanState(
|
|
107
|
+
f"checkpoint {cid}: invalid status {value!r}; allowed: {ALLOWED_STATUSES}"
|
|
108
|
+
)
|
|
109
|
+
cp[field_name] = value
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Persistence
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def save(self) -> None:
|
|
116
|
+
payload: dict[str, Any] = {
|
|
117
|
+
"plan_file": self.plan_file,
|
|
118
|
+
"branch": self.branch,
|
|
119
|
+
}
|
|
120
|
+
if self.project:
|
|
121
|
+
payload["project"] = self.project
|
|
122
|
+
if self.models:
|
|
123
|
+
payload["models"] = self.models
|
|
124
|
+
payload["checkpoints"] = self.checkpoints
|
|
125
|
+
|
|
126
|
+
with self.path.open("w") as f:
|
|
127
|
+
json.dump(payload, f, indent=2)
|
|
128
|
+
f.write("\n")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_state(path: Path | str) -> PlanState:
|
|
132
|
+
path = Path(path)
|
|
133
|
+
if not path.is_file():
|
|
134
|
+
raise InvalidPlanState(f"State file not found: {path}")
|
|
135
|
+
try:
|
|
136
|
+
raw = json.loads(path.read_text())
|
|
137
|
+
except json.JSONDecodeError as e:
|
|
138
|
+
raise InvalidPlanState(f"State file is not valid JSON: {e}") from e
|
|
139
|
+
|
|
140
|
+
validate_payload(raw)
|
|
141
|
+
return PlanState(
|
|
142
|
+
path=path,
|
|
143
|
+
plan_file=raw["plan_file"],
|
|
144
|
+
branch=raw["branch"],
|
|
145
|
+
checkpoints=raw["checkpoints"],
|
|
146
|
+
# `or {}` (not a get-default) so an explicit `null` becomes {} too.
|
|
147
|
+
project=raw.get("project") or {},
|
|
148
|
+
models=raw.get("models") or {},
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def validate_payload(raw: Any) -> None:
|
|
153
|
+
"""Schema-check a plan payload (dict, not yet written to disk).
|
|
154
|
+
|
|
155
|
+
Reused by `load_state` and by plan generation (see `plan_init.py`) so an
|
|
156
|
+
in-memory generated payload can be validated before it is ever written.
|
|
157
|
+
"""
|
|
158
|
+
if not isinstance(raw, dict):
|
|
159
|
+
raise InvalidPlanState("Top-level must be a JSON object")
|
|
160
|
+
|
|
161
|
+
for field_name in REQUIRED_TOP_LEVEL:
|
|
162
|
+
if field_name not in raw:
|
|
163
|
+
raise InvalidPlanState(f"Missing top-level field: {field_name}")
|
|
164
|
+
|
|
165
|
+
branch = raw["branch"]
|
|
166
|
+
if not isinstance(branch, str) or not branch:
|
|
167
|
+
raise InvalidPlanState("'branch' must be a non-empty string")
|
|
168
|
+
if branch in PROTECTED_BRANCHES:
|
|
169
|
+
raise ProtectedBranchError(f"Refusing protected target branch: {branch}")
|
|
170
|
+
|
|
171
|
+
if not isinstance(raw["plan_file"], str) or not raw["plan_file"]:
|
|
172
|
+
raise InvalidPlanState("'plan_file' must be a non-empty string")
|
|
173
|
+
|
|
174
|
+
checkpoints = raw["checkpoints"]
|
|
175
|
+
if not isinstance(checkpoints, list) or not checkpoints:
|
|
176
|
+
raise InvalidPlanState("'checkpoints' must be a non-empty list")
|
|
177
|
+
|
|
178
|
+
seen_ids: set[str] = set()
|
|
179
|
+
for idx, cp in enumerate(checkpoints):
|
|
180
|
+
if not isinstance(cp, dict):
|
|
181
|
+
raise InvalidPlanState(f"checkpoint #{idx} must be a JSON object")
|
|
182
|
+
for f in REQUIRED_CHECKPOINT_FIELDS:
|
|
183
|
+
if f not in cp:
|
|
184
|
+
raise InvalidPlanState(
|
|
185
|
+
f"checkpoint #{idx}: missing required field '{f}'"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Identify the checkpoint by index until 'id' is known to be a usable
|
|
189
|
+
# string; every message below can then name it.
|
|
190
|
+
if not isinstance(cp["id"], str) or not cp["id"]:
|
|
191
|
+
raise InvalidPlanState(f"checkpoint #{idx}: 'id' must be a non-empty string")
|
|
192
|
+
for f in ("name", "scope"):
|
|
193
|
+
if not isinstance(cp[f], str) or not cp[f]:
|
|
194
|
+
raise InvalidPlanState(
|
|
195
|
+
f"checkpoint {cp['id']}: '{f}' must be a non-empty string"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if cp["status"] not in ALLOWED_STATUSES:
|
|
199
|
+
raise InvalidPlanState(
|
|
200
|
+
f"checkpoint {cp['id']}: invalid status {cp['status']!r}; "
|
|
201
|
+
f"allowed: {ALLOWED_STATUSES}"
|
|
202
|
+
)
|
|
203
|
+
# Non-empty: a checkpoint with no criteria gives the review gate
|
|
204
|
+
# nothing objective to check, so it can only ever be waved through.
|
|
205
|
+
if not isinstance(cp["exit_criteria"], list) or not cp["exit_criteria"]:
|
|
206
|
+
raise InvalidPlanState(
|
|
207
|
+
f"checkpoint {cp['id']}: 'exit_criteria' must be a non-empty list"
|
|
208
|
+
)
|
|
209
|
+
for c_idx, criterion in enumerate(cp["exit_criteria"]):
|
|
210
|
+
if not isinstance(criterion, str) or not criterion.strip():
|
|
211
|
+
raise InvalidPlanState(
|
|
212
|
+
f"checkpoint {cp['id']}: 'exit_criteria[{c_idx}]' must be a "
|
|
213
|
+
f"non-empty string"
|
|
214
|
+
)
|
|
215
|
+
# bool is an int subclass, so reject it explicitly. Negatives are
|
|
216
|
+
# rejected too: the loop counts up from 'attempts' toward
|
|
217
|
+
# max_review_attempts, so a negative one buys extra review rounds.
|
|
218
|
+
if (
|
|
219
|
+
not isinstance(cp["attempts"], int)
|
|
220
|
+
or isinstance(cp["attempts"], bool)
|
|
221
|
+
or cp["attempts"] < 0
|
|
222
|
+
):
|
|
223
|
+
raise InvalidPlanState(
|
|
224
|
+
f"checkpoint {cp['id']}: 'attempts' must be a non-negative integer"
|
|
225
|
+
)
|
|
226
|
+
if not isinstance(cp["review_notes"], str):
|
|
227
|
+
raise InvalidPlanState(f"checkpoint {cp['id']}: 'review_notes' must be a string")
|
|
228
|
+
|
|
229
|
+
if cp["id"] in seen_ids:
|
|
230
|
+
raise DuplicateCheckpointError(f"duplicate checkpoint id: {cp['id']}")
|
|
231
|
+
seen_ids.add(cp["id"])
|
|
232
|
+
|
|
233
|
+
project = raw.get("project")
|
|
234
|
+
if project is not None and not isinstance(project, dict):
|
|
235
|
+
raise InvalidPlanState("'project' must be a JSON object when present")
|
|
236
|
+
|
|
237
|
+
models = raw.get("models")
|
|
238
|
+
if models is not None:
|
|
239
|
+
if not isinstance(models, dict):
|
|
240
|
+
raise InvalidPlanState("'models' must be a JSON object when present")
|
|
241
|
+
for role, model in models.items():
|
|
242
|
+
if role not in MODEL_ROLES:
|
|
243
|
+
raise InvalidPlanState(
|
|
244
|
+
f"'models' has unknown role {role!r}; allowed: {MODEL_ROLES}"
|
|
245
|
+
)
|
|
246
|
+
if not isinstance(model, str) or not model:
|
|
247
|
+
raise InvalidPlanState(f"'models.{role}' must be a non-empty string")
|