regent-cli 0.4.0__tar.gz → 0.6.0__tar.gz
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.
- {regent_cli-0.4.0 → regent_cli-0.6.0}/PKG-INFO +12 -2
- {regent_cli-0.4.0 → regent_cli-0.6.0}/README.md +11 -1
- {regent_cli-0.4.0 → regent_cli-0.6.0}/pyproject.toml +1 -1
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/__init__.py +1 -1
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/activity_cli.py +101 -0
- regent_cli-0.6.0/src/regent/conduction/__init__.py +7 -0
- regent_cli-0.6.0/src/regent/conduction/confine.py +79 -0
- regent_cli-0.6.0/src/regent/conduction/consult.py +98 -0
- regent_cli-0.6.0/src/regent/conduction/evidence.py +101 -0
- regent_cli-0.6.0/src/regent/conduction/gate.py +71 -0
- regent_cli-0.6.0/src/regent/conduction/hookscript.py +181 -0
- regent_cli-0.6.0/src/regent/conduction/process.py +45 -0
- regent_cli-0.6.0/src/regent/conduction/turn.py +389 -0
- regent_cli-0.6.0/src/regent/conduction/turnlog.py +216 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/initcmd.py +5 -2
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/templates/MANIFEST.json +3 -1
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/templates/skills/regent/SKILL.md +23 -7
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/PKG-INFO +12 -2
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/SOURCES.txt +15 -1
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_activity_cli.py +2 -2
- regent_cli-0.6.0/tests/test_confine.py +229 -0
- regent_cli-0.6.0/tests/test_consult.py +180 -0
- regent_cli-0.6.0/tests/test_gate.py +108 -0
- regent_cli-0.6.0/tests/test_hookscript.py +222 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_skills_v1.py +8 -2
- regent_cli-0.6.0/tests/test_turn.py +308 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/LICENSE +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/setup.cfg +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/activity.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/cli.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/doctor.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/protocol/__init__.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/protocol/audit.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/protocol/control.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/protocol/lock.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/protocol/stop.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/templates/skills/regent-stop/SKILL.md +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/dependency_links.txt +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/entry_points.txt +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/top_level.txt +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_activity.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_choreography.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_control.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_doctor.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_init_upgrade.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_initcmd.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_lock.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_protocol_facade.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_step06_fixes.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_stop.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regent-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Autonomous conduction and mediated adversarial deliberation between AI agents, pluggable into any project.
|
|
5
5
|
Author-email: Flavio Alvim <flavioalvim@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -48,7 +48,17 @@ the JSON subcommands — `regent status` (control + lock + the executable contro
|
|
|
48
48
|
matrix as `workspace.verdict`), `regent activity
|
|
49
49
|
start|resume|suspend|conclude|heartbeat|takeover`, `regent stop request|check`. Hosts
|
|
50
50
|
seeded by older versions upgrade automatically on `regent init` (known-version manifest;
|
|
51
|
-
unknown local edits are preserved as conflicts). The advisor requires the `codex` CLI.
|
|
51
|
+
unknown local edits are preserved as conflicts). The advisor requires the `codex` CLI. Conduction
|
|
52
|
+
phase 1 mechanizes the two most error-prone sub-steps: `regent advisor consult
|
|
53
|
+
--prompt-file … --artifact … --linkage …` (read-only sandboxed consultation with the
|
|
54
|
+
REQ-003 §5 evidence pair generated by the command, fail-closed verdict expectation) and
|
|
55
|
+
`regent gate run --command … --declared-in <plan> --artifact … --linkage …` (verbatim
|
|
56
|
+
provenance check, process-group-killing timeout, integral output preserved). Conduction
|
|
57
|
+
phase 2 adds `regent turn run`: one supervised, CONFINED `claude -p` turn — the agent may
|
|
58
|
+
only write inside a declared envelope (a PreToolUse hook denies the rest), the supervisor
|
|
59
|
+
proves what changed via git (blob shas vs authenticated post events) and commits through a
|
|
60
|
+
private index with a HEAD compare-and-swap. The agent never commits; a violation, a
|
|
61
|
+
tampered log or a red gate never yields a product commit.
|
|
52
62
|
|
|
53
63
|
Development: `PYTHONPATH=src python3 -m unittest discover -s tests`; packaging gate:
|
|
54
64
|
`bash scripts/gate-package.sh`. Canonical skill content lives in `src/regent/templates/`
|
|
@@ -32,7 +32,17 @@ the JSON subcommands — `regent status` (control + lock + the executable contro
|
|
|
32
32
|
matrix as `workspace.verdict`), `regent activity
|
|
33
33
|
start|resume|suspend|conclude|heartbeat|takeover`, `regent stop request|check`. Hosts
|
|
34
34
|
seeded by older versions upgrade automatically on `regent init` (known-version manifest;
|
|
35
|
-
unknown local edits are preserved as conflicts). The advisor requires the `codex` CLI.
|
|
35
|
+
unknown local edits are preserved as conflicts). The advisor requires the `codex` CLI. Conduction
|
|
36
|
+
phase 1 mechanizes the two most error-prone sub-steps: `regent advisor consult
|
|
37
|
+
--prompt-file … --artifact … --linkage …` (read-only sandboxed consultation with the
|
|
38
|
+
REQ-003 §5 evidence pair generated by the command, fail-closed verdict expectation) and
|
|
39
|
+
`regent gate run --command … --declared-in <plan> --artifact … --linkage …` (verbatim
|
|
40
|
+
provenance check, process-group-killing timeout, integral output preserved). Conduction
|
|
41
|
+
phase 2 adds `regent turn run`: one supervised, CONFINED `claude -p` turn — the agent may
|
|
42
|
+
only write inside a declared envelope (a PreToolUse hook denies the rest), the supervisor
|
|
43
|
+
proves what changed via git (blob shas vs authenticated post events) and commits through a
|
|
44
|
+
private index with a HEAD compare-and-swap. The agent never commits; a violation, a
|
|
45
|
+
tampered log or a red gate never yields a product commit.
|
|
36
46
|
|
|
37
47
|
Development: `PYTHONPATH=src python3 -m unittest discover -s tests`; packaging gate:
|
|
38
48
|
`bash scripts/gate-package.sh`. Canonical skill content lives in `src/regent/templates/`
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "regent-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Autonomous conduction and mediated adversarial deliberation between AI agents, pluggable into any project."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -24,6 +24,10 @@ _EXIT_BY_CODE = {
|
|
|
24
24
|
"NOT_SUSPENDED": 2, "ACTIVITY_OPEN": 2,
|
|
25
25
|
"TOKEN_MISMATCH": 3, "LOCK_HELD": 3, "LOCK_SUSPECT": 3, "BUSY": 3,
|
|
26
26
|
"CONFLICT": 3, "UNATTRIBUTABLE": 3, "CORRUPT_CONTROL": 4, "IO": 5,
|
|
27
|
+
"ADVISOR_FAILED": 3, "ADVISOR_UNAVAILABLE": 2, "GATE_RED": 3, "PROVENANCE": 3,
|
|
28
|
+
"TURN_VIOLATION": 3, "TURN_TAMPERED": 3, "TURN_FAILED": 3, "NOT_ACTIVE": 2,
|
|
29
|
+
"STEP_MISMATCH": 2, "ARTIFACT_OUTSIDE_REGENT": 2, "STEP_ALREADY_DONE": 2,
|
|
30
|
+
"WORKTREE_DIRTY": 3,
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
|
|
@@ -91,6 +95,42 @@ def build_parser(sub) -> None:
|
|
|
91
95
|
p.add_argument("--reason", default=None)
|
|
92
96
|
stop_sub.add_parser("check")
|
|
93
97
|
|
|
98
|
+
p_advisor = sub.add_parser("advisor", help="mechanized advisor consultation")
|
|
99
|
+
p_advisor.add_argument("--project", default=None)
|
|
100
|
+
advisor_sub = p_advisor.add_subparsers(dest="advisor_command", required=True)
|
|
101
|
+
p = advisor_sub.add_parser("consult")
|
|
102
|
+
p.add_argument("--prompt-file", required=True, dest="prompt_file")
|
|
103
|
+
p.add_argument("--artifact", required=True)
|
|
104
|
+
p.add_argument("--linkage", required=True)
|
|
105
|
+
p.add_argument("--timeout", type=float, default=600.0)
|
|
106
|
+
p.add_argument("--expect-verdict", default=None, dest="expect_verdict")
|
|
107
|
+
|
|
108
|
+
p_turn = sub.add_parser("turn", help="supervised confined production turn")
|
|
109
|
+
p_turn.add_argument("--project", default=None)
|
|
110
|
+
turn_sub = p_turn.add_subparsers(dest="turn_command", required=True)
|
|
111
|
+
p = turn_sub.add_parser("run")
|
|
112
|
+
p.add_argument("--prompt-file", required=True, dest="prompt_file")
|
|
113
|
+
p.add_argument("--envelope", action="append", required=True)
|
|
114
|
+
p.add_argument("--gate-envelope", action="append", default=[],
|
|
115
|
+
dest="gate_envelope")
|
|
116
|
+
p.add_argument("--gate-command", required=True, dest="gate_command")
|
|
117
|
+
p.add_argument("--declared-in", required=True, dest="declared_in")
|
|
118
|
+
p.add_argument("--step", required=True)
|
|
119
|
+
p.add_argument("--artifact-dir", required=True, dest="artifact_dir")
|
|
120
|
+
p.add_argument("--linkage", required=True)
|
|
121
|
+
p.add_argument("--timeout", type=float, default=900.0)
|
|
122
|
+
p.add_argument("--claude-bin", default="claude", dest="claude_bin")
|
|
123
|
+
|
|
124
|
+
p_gate = sub.add_parser("gate", help="mechanized gate execution")
|
|
125
|
+
p_gate.add_argument("--project", default=None)
|
|
126
|
+
gate_sub = p_gate.add_subparsers(dest="gate_command", required=True)
|
|
127
|
+
p = gate_sub.add_parser("run")
|
|
128
|
+
p.add_argument("--command", required=True, dest="gate_cmd")
|
|
129
|
+
p.add_argument("--declared-in", required=True, dest="declared_in")
|
|
130
|
+
p.add_argument("--artifact", required=True)
|
|
131
|
+
p.add_argument("--linkage", required=True)
|
|
132
|
+
p.add_argument("--timeout", type=float, default=1800.0)
|
|
133
|
+
|
|
94
134
|
p_control = sub.add_parser("control",
|
|
95
135
|
help="control.json attributability helpers")
|
|
96
136
|
p_control.add_argument("--project", default=None)
|
|
@@ -120,6 +160,67 @@ def run(args, out=None) -> int:
|
|
|
120
160
|
if args.command == "control":
|
|
121
161
|
return _run_control_explain(service, root, out,
|
|
122
162
|
since_version=args.since_version)
|
|
163
|
+
if args.command == "advisor":
|
|
164
|
+
from .conduction.consult import AdvisorUnavailable, run_consult
|
|
165
|
+
from .conduction.evidence import EvidenceConflict
|
|
166
|
+
try:
|
|
167
|
+
result = run_consult(root, prompt_file=Path(args.prompt_file),
|
|
168
|
+
artifact=Path(args.artifact),
|
|
169
|
+
linkage=args.linkage, timeout=args.timeout,
|
|
170
|
+
expect_verdict=args.expect_verdict)
|
|
171
|
+
except AdvisorUnavailable as exc:
|
|
172
|
+
return _fail("ADVISOR_UNAVAILABLE", {"reason": str(exc)}, out)
|
|
173
|
+
except EvidenceConflict as exc:
|
|
174
|
+
return _fail("CONFLICT", {"paths": exc.paths}, out)
|
|
175
|
+
if not result["ok"]:
|
|
176
|
+
return _fail("ADVISOR_FAILED",
|
|
177
|
+
{"outcome": result["outcome"],
|
|
178
|
+
"exit_code": result["exit_code"],
|
|
179
|
+
"verdict": result["verdict"],
|
|
180
|
+
"artifact": result["artifact"]}, out)
|
|
181
|
+
return _emit(result, 0, out)
|
|
182
|
+
if args.command == "turn":
|
|
183
|
+
from .conduction.turn import TurnError, run_turn
|
|
184
|
+
from .conduction.gate import ProvenanceError
|
|
185
|
+
from .conduction.evidence import EvidenceConflict
|
|
186
|
+
try:
|
|
187
|
+
result = run_turn(
|
|
188
|
+
root, prompt_file=Path(args.prompt_file),
|
|
189
|
+
envelope=args.envelope, gate_envelope=args.gate_envelope,
|
|
190
|
+
gate_command=args.gate_command,
|
|
191
|
+
declared_in=Path(args.declared_in), step=args.step,
|
|
192
|
+
artifact_dir=Path(args.artifact_dir), linkage=args.linkage,
|
|
193
|
+
timeout=args.timeout, claude_bin=args.claude_bin)
|
|
194
|
+
except TurnError as exc:
|
|
195
|
+
return _fail(exc.code, exc.detail, out)
|
|
196
|
+
except ProvenanceError as exc:
|
|
197
|
+
return _fail("PROVENANCE", {"reason": str(exc)}, out)
|
|
198
|
+
except EvidenceConflict as exc:
|
|
199
|
+
return _fail("CONFLICT", {"paths": exc.paths}, out)
|
|
200
|
+
if not result["ok"]:
|
|
201
|
+
code = ({"TURN_VIOLATION": "TURN_VIOLATION",
|
|
202
|
+
"TURN_TAMPERED": "TURN_TAMPERED",
|
|
203
|
+
"GATE_RED": "GATE_RED"}.get(result["outcome"], "TURN_FAILED"))
|
|
204
|
+
return _fail(code, {"outcome": result["outcome"],
|
|
205
|
+
"artifact": result["artifact"]}, out)
|
|
206
|
+
return _emit(result, 0, out)
|
|
207
|
+
if args.command == "gate":
|
|
208
|
+
from .conduction.gate import ProvenanceError, run_gate
|
|
209
|
+
from .conduction.evidence import EvidenceConflict
|
|
210
|
+
try:
|
|
211
|
+
result = run_gate(root, command=args.gate_cmd,
|
|
212
|
+
declared_in=Path(args.declared_in),
|
|
213
|
+
artifact=Path(args.artifact),
|
|
214
|
+
linkage=args.linkage, timeout=args.timeout)
|
|
215
|
+
except ProvenanceError as exc:
|
|
216
|
+
return _fail("PROVENANCE", {"reason": str(exc)}, out)
|
|
217
|
+
except EvidenceConflict as exc:
|
|
218
|
+
return _fail("CONFLICT", {"paths": exc.paths}, out)
|
|
219
|
+
if not result["ok"]:
|
|
220
|
+
return _fail("GATE_RED", {"outcome": result["outcome"],
|
|
221
|
+
"exit_code": result["exit_code"],
|
|
222
|
+
"artifact": result["artifact"]}, out)
|
|
223
|
+
return _emit(result, 0, out)
|
|
123
224
|
return _fail("USAGE", f"unknown command {args.command!r}", out)
|
|
124
225
|
except ActivityError as exc:
|
|
125
226
|
return _fail(exc.code, exc.detail, out)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Confined-turn composition (PLAN-004 STEP-02).
|
|
2
|
+
|
|
3
|
+
Builds an immutable per-turn PRIVATE dir (outside the repo) with a generated
|
|
4
|
+
settings.json that wires the Pre/PostToolUse hooks to a private copy of
|
|
5
|
+
hookscript.py, and returns the `claude -p` argv + minimal environment. The
|
|
6
|
+
launch inherits NO config (`--setting-sources ""`), restricts tools, and forces
|
|
7
|
+
acceptEdits (the confinement IS the hook's deny; dontAsk would deny even
|
|
8
|
+
in-envelope writes — empirical IMP-003 lesson).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
import shutil
|
|
17
|
+
import tempfile
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
ALLOWED_TOOLS = "Read,Write,Edit,MultiEdit"
|
|
22
|
+
ENV_ALLOWLIST = ("PATH", "HOME", "LANG", "LC_ALL")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ConfinedTurn:
|
|
27
|
+
private_dir: Path
|
|
28
|
+
settings_path: Path
|
|
29
|
+
event_log: Path
|
|
30
|
+
secret: str
|
|
31
|
+
envelope: list[str]
|
|
32
|
+
|
|
33
|
+
def cleanup(self) -> None:
|
|
34
|
+
shutil.rmtree(self.private_dir, ignore_errors=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compose(*, envelope: list[str], claude_bin: str = "claude") -> ConfinedTurn:
|
|
38
|
+
private_dir = Path(tempfile.mkdtemp(prefix="regent-turn-"))
|
|
39
|
+
hook_copy = private_dir / "hookscript.py"
|
|
40
|
+
shutil.copy(Path(__file__).with_name("hookscript.py"), hook_copy)
|
|
41
|
+
event_log = private_dir / "events.log"
|
|
42
|
+
secret = secrets.token_hex(32)
|
|
43
|
+
envelope = [str(Path(p).resolve()) for p in envelope]
|
|
44
|
+
|
|
45
|
+
hook_cmd = f"{_python()} {hook_copy}"
|
|
46
|
+
settings = {
|
|
47
|
+
"hooks": {
|
|
48
|
+
"PreToolUse": [{"hooks": [{"type": "command", "command": hook_cmd}]}],
|
|
49
|
+
"PostToolUse": [{"hooks": [{"type": "command", "command": hook_cmd}]}],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
settings_path = private_dir / "settings.json"
|
|
53
|
+
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
|
|
54
|
+
# Freeze the composition (read-only) so nothing mutates it mid-turn.
|
|
55
|
+
for path in (hook_copy, settings_path):
|
|
56
|
+
path.chmod(0o400)
|
|
57
|
+
return ConfinedTurn(private_dir, settings_path, event_log, secret, envelope)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def launch_argv(turn: ConfinedTurn, *, prompt: str,
|
|
61
|
+
claude_bin: str = "claude") -> list[str]:
|
|
62
|
+
return [claude_bin, "-p", prompt,
|
|
63
|
+
"--setting-sources", "", # inherit NOTHING
|
|
64
|
+
"--settings", str(turn.settings_path),
|
|
65
|
+
"--tools", ALLOWED_TOOLS,
|
|
66
|
+
"--permission-mode", "acceptEdits"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def launch_env(turn: ConfinedTurn) -> dict[str, str]:
|
|
70
|
+
env = {k: os.environ[k] for k in ENV_ALLOWLIST if k in os.environ}
|
|
71
|
+
env["REGENT_TURN_SECRET"] = turn.secret
|
|
72
|
+
env["REGENT_ENVELOPE"] = json.dumps(turn.envelope)
|
|
73
|
+
env["REGENT_EVENT_LOG"] = str(turn.event_log)
|
|
74
|
+
return env
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _python() -> str:
|
|
78
|
+
import sys
|
|
79
|
+
return sys.executable or "python3"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""`regent advisor consult` — mechanized advisor consultation (PLAN-003).
|
|
2
|
+
|
|
3
|
+
Ports the IMP-003-proven CodexConsultAdapter: read-only sandboxed `codex exec`
|
|
4
|
+
with the REQ-003 §5 evidence tuple generated BY THE COMMAND, not by executor
|
|
5
|
+
discipline. Sandbox flags are mandatory and not configurable. Every terminal
|
|
6
|
+
outcome (SUCCESS/TIMEOUT/FAILURE) leaves the evidence pair complete.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .evidence import EvidenceSet, header
|
|
17
|
+
from .process import SubprocessRunner
|
|
18
|
+
|
|
19
|
+
DEFAULT_VERDICT_RE = (r"^(CONCORDA|DISCORDA.*|APROVADO( COM RESSALVAS)?|"
|
|
20
|
+
r"REPROVADO|QUITADAS|PENDENTES.*)$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AdvisorUnavailable(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_consult(root: Path, *, prompt_file: Path, artifact: Path, linkage: str,
|
|
28
|
+
timeout: float = 600.0, expect_verdict: str | None = None,
|
|
29
|
+
runner=None, codex_bin: str = "codex") -> dict:
|
|
30
|
+
root = Path(root)
|
|
31
|
+
prompt_bytes = Path(prompt_file).read_bytes() # byte-faithful (no newline
|
|
32
|
+
# translation — read_text would normalize CRLF)
|
|
33
|
+
prompt_text = prompt_bytes.decode("utf-8", errors="replace")
|
|
34
|
+
evidence = EvidenceSet(Path(artifact),
|
|
35
|
+
{"prompt": Path(str(artifact) + "-PROMPT.md")})
|
|
36
|
+
evidence.precheck()
|
|
37
|
+
|
|
38
|
+
if runner is None:
|
|
39
|
+
if shutil.which(codex_bin) is None:
|
|
40
|
+
raise AdvisorUnavailable(f"'{codex_bin}' not found on PATH")
|
|
41
|
+
runner = SubprocessRunner()
|
|
42
|
+
|
|
43
|
+
# ONE cleanup guard around the WHOLE operation (sibling publish, temp
|
|
44
|
+
# file, execution and the main publish): any exception — OSError,
|
|
45
|
+
# EvidenceConflict from a no-clobber race on the main artifact, anything —
|
|
46
|
+
# removes the already-published siblings. Only a completed write_main
|
|
47
|
+
# (terminal outcome) leaves the pair behind.
|
|
48
|
+
try:
|
|
49
|
+
evidence.write_sibling("prompt", prompt_bytes) # byte-identical copy
|
|
50
|
+
fd, msg_file = tempfile.mkstemp(prefix="regent-consult-", suffix=".txt")
|
|
51
|
+
os.close(fd)
|
|
52
|
+
try:
|
|
53
|
+
argv = [codex_bin, "--ask-for-approval", "never",
|
|
54
|
+
"--sandbox", "read-only", # MANDATORY, not configurable
|
|
55
|
+
"exec", "--cd", str(root), "-o", msg_file, prompt_text]
|
|
56
|
+
result = runner.run(argv, cwd=str(root), timeout=timeout)
|
|
57
|
+
try:
|
|
58
|
+
last_message = Path(msg_file).read_text(encoding="utf-8",
|
|
59
|
+
errors="replace")
|
|
60
|
+
except OSError:
|
|
61
|
+
last_message = ""
|
|
62
|
+
finally:
|
|
63
|
+
try:
|
|
64
|
+
os.unlink(msg_file)
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
if result.timed_out:
|
|
69
|
+
outcome = "TIMEOUT"
|
|
70
|
+
elif result.exit_code != 0:
|
|
71
|
+
outcome = "FAILURE"
|
|
72
|
+
else:
|
|
73
|
+
outcome = "SUCCESS"
|
|
74
|
+
|
|
75
|
+
body = last_message if last_message.strip() else result.output
|
|
76
|
+
pattern = (expect_verdict if expect_verdict is not None
|
|
77
|
+
else DEFAULT_VERDICT_RE)
|
|
78
|
+
verdict = _extract_verdict(body, pattern)
|
|
79
|
+
evidence.write_main(header(outcome, result.exit_code, linkage,
|
|
80
|
+
verdict=verdict), body)
|
|
81
|
+
except BaseException:
|
|
82
|
+
evidence.cleanup_orphans()
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
verdict_ok = verdict is not None if expect_verdict is not None else True
|
|
86
|
+
return {"ok": outcome == "SUCCESS" and verdict_ok, "outcome": outcome,
|
|
87
|
+
"verdict": verdict, "exit_code": result.exit_code,
|
|
88
|
+
"artifact": str(evidence.main),
|
|
89
|
+
"prompt_copy": str(evidence.siblings["prompt"])}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extract_verdict(body: str, pattern: str) -> str | None:
|
|
93
|
+
import re
|
|
94
|
+
for line in reversed(body.splitlines()):
|
|
95
|
+
line = line.strip()
|
|
96
|
+
if line:
|
|
97
|
+
return line if re.fullmatch(pattern, line) else None
|
|
98
|
+
return None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Atomic evidence sets for conduction artifacts (PLAN-003 contracts).
|
|
2
|
+
|
|
3
|
+
An evidence SET (main artifact + siblings such as the prompt copy or the
|
|
4
|
+
FULL.log) has ONE atomic contract: every path is pre-checked BEFORE anything
|
|
5
|
+
is written (any pre-existing member = conflict — evidence is never
|
|
6
|
+
overwritten); siblings are committed first (tmp+replace each), the main
|
|
7
|
+
artifact last; every terminal outcome leaves the set complete; a non-terminal
|
|
8
|
+
failure removes the orphan siblings written so far.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..protocol.audit import utcnow
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EvidenceConflict(Exception):
|
|
21
|
+
def __init__(self, paths: list[str]) -> None:
|
|
22
|
+
super().__init__(f"evidence already exists: {paths}")
|
|
23
|
+
self.paths = paths
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def atomic_write(path: Path, content: "str | bytes") -> None:
|
|
27
|
+
"""Atomic NO-CLOBBER publish: os.link fails with EEXIST if the target
|
|
28
|
+
appeared meanwhile (closes the precheck→publish TOCTOU — evidence is never
|
|
29
|
+
overwritten, not even by a concurrent writer)."""
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
tmp = path.with_name(f".{path.name}.tmp-{uuid.uuid4().hex}")
|
|
32
|
+
payload = content.encode("utf-8") if isinstance(content, str) else content
|
|
33
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644)
|
|
34
|
+
try:
|
|
35
|
+
with os.fdopen(fd, "wb") as handle:
|
|
36
|
+
handle.write(payload)
|
|
37
|
+
os.link(tmp, path) # atomic no-clobber (EEXIST if raced)
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
import errno as _errno
|
|
40
|
+
if exc.errno == _errno.EEXIST:
|
|
41
|
+
try:
|
|
42
|
+
tmp.unlink()
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
raise EvidenceConflict([str(path)]) from None
|
|
46
|
+
try:
|
|
47
|
+
tmp.unlink()
|
|
48
|
+
except OSError:
|
|
49
|
+
pass
|
|
50
|
+
raise
|
|
51
|
+
tmp.unlink()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EvidenceSet:
|
|
55
|
+
def __init__(self, main: Path, siblings: dict[str, Path]) -> None:
|
|
56
|
+
self.main = Path(main)
|
|
57
|
+
self.siblings = {k: Path(v) for k, v in siblings.items()}
|
|
58
|
+
self._written: list[Path] = []
|
|
59
|
+
|
|
60
|
+
def precheck(self) -> None:
|
|
61
|
+
existing = [str(p) for p in (self.main, *self.siblings.values())
|
|
62
|
+
if p.exists() or p.is_symlink()]
|
|
63
|
+
if existing:
|
|
64
|
+
raise EvidenceConflict(existing)
|
|
65
|
+
|
|
66
|
+
def write_sibling(self, key: str, content: "str | bytes") -> Path:
|
|
67
|
+
path = self.siblings[key]
|
|
68
|
+
try:
|
|
69
|
+
atomic_write(path, content)
|
|
70
|
+
except (OSError, EvidenceConflict):
|
|
71
|
+
self.cleanup_orphans()
|
|
72
|
+
raise
|
|
73
|
+
self._written.append(path)
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
def write_main(self, header: dict, body: str) -> Path:
|
|
77
|
+
lines = ["---"]
|
|
78
|
+
for key, value in header.items():
|
|
79
|
+
lines.append(f"{key}: {value if value is not None else 'null'}")
|
|
80
|
+
lines.append("---")
|
|
81
|
+
content = "\n".join(lines) + "\n\n" + body
|
|
82
|
+
try:
|
|
83
|
+
atomic_write(self.main, content)
|
|
84
|
+
except (OSError, EvidenceConflict):
|
|
85
|
+
self.cleanup_orphans()
|
|
86
|
+
raise
|
|
87
|
+
return self.main
|
|
88
|
+
|
|
89
|
+
def cleanup_orphans(self) -> None:
|
|
90
|
+
for path in self._written:
|
|
91
|
+
try:
|
|
92
|
+
path.unlink()
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def header(outcome: str, exit_code: int | None, linkage: str, **extra) -> dict:
|
|
98
|
+
base = {"outcome": outcome, "exit_code": exit_code,
|
|
99
|
+
"timestamp": utcnow(), "linkage": linkage}
|
|
100
|
+
base.update(extra)
|
|
101
|
+
return base
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""`regent gate run` — mechanized gate execution (PLAN-003).
|
|
2
|
+
|
|
3
|
+
The gate command is NEVER invented by regent: it must appear VERBATIM in the
|
|
4
|
+
plan artifact referenced by --declared-in (provenance is checked, not
|
|
5
|
+
trusted). Full output is always preserved: <=200 KiB inline in the artifact;
|
|
6
|
+
above that, header+tail with DECLARED truncation plus the integral output in
|
|
7
|
+
<artifact>-FULL.log, all under one atomic evidence-set contract.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .evidence import EvidenceSet, header
|
|
15
|
+
from .process import SubprocessRunner
|
|
16
|
+
|
|
17
|
+
TAIL_LIMIT = 200 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProvenanceError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_gate(root: Path, *, command: str, declared_in: Path, artifact: Path,
|
|
25
|
+
linkage: str, timeout: float = 1800.0, runner=None) -> dict:
|
|
26
|
+
root = Path(root)
|
|
27
|
+
if not command.strip():
|
|
28
|
+
raise ProvenanceError("empty gate command is never declared")
|
|
29
|
+
declared_text = Path(declared_in).read_text(encoding="utf-8",
|
|
30
|
+
errors="replace")
|
|
31
|
+
if command not in declared_text:
|
|
32
|
+
raise ProvenanceError(
|
|
33
|
+
f"gate command not declared verbatim in {declared_in}")
|
|
34
|
+
|
|
35
|
+
evidence = EvidenceSet(Path(artifact),
|
|
36
|
+
{"full": Path(str(artifact) + "-FULL.log")})
|
|
37
|
+
evidence.precheck()
|
|
38
|
+
if runner is None:
|
|
39
|
+
runner = SubprocessRunner()
|
|
40
|
+
|
|
41
|
+
# ONE cleanup guard around execution + both publishes (see consult.py).
|
|
42
|
+
try:
|
|
43
|
+
result = runner.run(["bash", "-c", command], cwd=str(root),
|
|
44
|
+
timeout=timeout)
|
|
45
|
+
raw = result.output_bytes
|
|
46
|
+
output_bytes = len(raw)
|
|
47
|
+
truncated = output_bytes > TAIL_LIMIT
|
|
48
|
+
|
|
49
|
+
if result.timed_out:
|
|
50
|
+
outcome = "TIMEOUT"
|
|
51
|
+
elif result.exit_code == 0:
|
|
52
|
+
outcome = "GREEN"
|
|
53
|
+
else:
|
|
54
|
+
outcome = "RED"
|
|
55
|
+
|
|
56
|
+
if truncated:
|
|
57
|
+
evidence.write_sibling("full", raw) # integral RAW BYTES first
|
|
58
|
+
tail = raw[-TAIL_LIMIT:].decode("utf-8", errors="replace")
|
|
59
|
+
body = (f"[truncated: full output is {output_bytes} bytes — see "
|
|
60
|
+
f"{evidence.siblings['full'].name}; tail follows]\n" + tail)
|
|
61
|
+
else:
|
|
62
|
+
body = raw.decode("utf-8", errors="replace")
|
|
63
|
+
evidence.write_main(header(outcome, result.exit_code, linkage,
|
|
64
|
+
command=command, output_bytes=output_bytes,
|
|
65
|
+
truncated=truncated), body)
|
|
66
|
+
except BaseException:
|
|
67
|
+
evidence.cleanup_orphans()
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
return {"ok": outcome == "GREEN", "outcome": outcome,
|
|
71
|
+
"exit_code": result.exit_code, "artifact": str(evidence.main)}
|