regent-cli 0.4.0__tar.gz → 0.5.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.5.0}/PKG-INFO +7 -2
- {regent_cli-0.4.0 → regent_cli-0.5.0}/README.md +6 -1
- {regent_cli-0.4.0 → regent_cli-0.5.0}/pyproject.toml +1 -1
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/__init__.py +1 -1
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/activity_cli.py +57 -0
- regent_cli-0.5.0/src/regent/conduction/__init__.py +7 -0
- regent_cli-0.5.0/src/regent/conduction/consult.py +98 -0
- regent_cli-0.5.0/src/regent/conduction/evidence.py +101 -0
- regent_cli-0.5.0/src/regent/conduction/gate.py +71 -0
- regent_cli-0.5.0/src/regent/conduction/process.py +44 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/templates/MANIFEST.json +2 -1
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/templates/skills/regent/SKILL.md +14 -6
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent_cli.egg-info/PKG-INFO +7 -2
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent_cli.egg-info/SOURCES.txt +7 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_activity_cli.py +2 -2
- regent_cli-0.5.0/tests/test_consult.py +180 -0
- regent_cli-0.5.0/tests/test_gate.py +108 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_skills_v1.py +7 -2
- {regent_cli-0.4.0 → regent_cli-0.5.0}/LICENSE +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/setup.cfg +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/activity.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/cli.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/doctor.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/initcmd.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/protocol/__init__.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/protocol/audit.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/protocol/control.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/protocol/lock.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/protocol/stop.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/templates/skills/regent-stop/SKILL.md +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent_cli.egg-info/dependency_links.txt +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent_cli.egg-info/entry_points.txt +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent_cli.egg-info/top_level.txt +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_activity.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_choreography.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_control.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_doctor.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_init_upgrade.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_initcmd.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_lock.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_protocol_facade.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_step06_fixes.py +0 -0
- {regent_cli-0.4.0 → regent_cli-0.5.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.5.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,12 @@ 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).
|
|
52
57
|
|
|
53
58
|
Development: `PYTHONPATH=src python3 -m unittest discover -s tests`; packaging gate:
|
|
54
59
|
`bash scripts/gate-package.sh`. Canonical skill content lives in `src/regent/templates/`
|
|
@@ -32,7 +32,12 @@ 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).
|
|
36
41
|
|
|
37
42
|
Development: `PYTHONPATH=src python3 -m unittest discover -s tests`; packaging gate:
|
|
38
43
|
`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.5.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,7 @@ _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,
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
|
|
@@ -91,6 +92,26 @@ def build_parser(sub) -> None:
|
|
|
91
92
|
p.add_argument("--reason", default=None)
|
|
92
93
|
stop_sub.add_parser("check")
|
|
93
94
|
|
|
95
|
+
p_advisor = sub.add_parser("advisor", help="mechanized advisor consultation")
|
|
96
|
+
p_advisor.add_argument("--project", default=None)
|
|
97
|
+
advisor_sub = p_advisor.add_subparsers(dest="advisor_command", required=True)
|
|
98
|
+
p = advisor_sub.add_parser("consult")
|
|
99
|
+
p.add_argument("--prompt-file", required=True, dest="prompt_file")
|
|
100
|
+
p.add_argument("--artifact", required=True)
|
|
101
|
+
p.add_argument("--linkage", required=True)
|
|
102
|
+
p.add_argument("--timeout", type=float, default=600.0)
|
|
103
|
+
p.add_argument("--expect-verdict", default=None, dest="expect_verdict")
|
|
104
|
+
|
|
105
|
+
p_gate = sub.add_parser("gate", help="mechanized gate execution")
|
|
106
|
+
p_gate.add_argument("--project", default=None)
|
|
107
|
+
gate_sub = p_gate.add_subparsers(dest="gate_command", required=True)
|
|
108
|
+
p = gate_sub.add_parser("run")
|
|
109
|
+
p.add_argument("--command", required=True, dest="gate_cmd")
|
|
110
|
+
p.add_argument("--declared-in", required=True, dest="declared_in")
|
|
111
|
+
p.add_argument("--artifact", required=True)
|
|
112
|
+
p.add_argument("--linkage", required=True)
|
|
113
|
+
p.add_argument("--timeout", type=float, default=1800.0)
|
|
114
|
+
|
|
94
115
|
p_control = sub.add_parser("control",
|
|
95
116
|
help="control.json attributability helpers")
|
|
96
117
|
p_control.add_argument("--project", default=None)
|
|
@@ -120,6 +141,42 @@ def run(args, out=None) -> int:
|
|
|
120
141
|
if args.command == "control":
|
|
121
142
|
return _run_control_explain(service, root, out,
|
|
122
143
|
since_version=args.since_version)
|
|
144
|
+
if args.command == "advisor":
|
|
145
|
+
from .conduction.consult import AdvisorUnavailable, run_consult
|
|
146
|
+
from .conduction.evidence import EvidenceConflict
|
|
147
|
+
try:
|
|
148
|
+
result = run_consult(root, prompt_file=Path(args.prompt_file),
|
|
149
|
+
artifact=Path(args.artifact),
|
|
150
|
+
linkage=args.linkage, timeout=args.timeout,
|
|
151
|
+
expect_verdict=args.expect_verdict)
|
|
152
|
+
except AdvisorUnavailable as exc:
|
|
153
|
+
return _fail("ADVISOR_UNAVAILABLE", {"reason": str(exc)}, out)
|
|
154
|
+
except EvidenceConflict as exc:
|
|
155
|
+
return _fail("CONFLICT", {"paths": exc.paths}, out)
|
|
156
|
+
if not result["ok"]:
|
|
157
|
+
return _fail("ADVISOR_FAILED",
|
|
158
|
+
{"outcome": result["outcome"],
|
|
159
|
+
"exit_code": result["exit_code"],
|
|
160
|
+
"verdict": result["verdict"],
|
|
161
|
+
"artifact": result["artifact"]}, out)
|
|
162
|
+
return _emit(result, 0, out)
|
|
163
|
+
if args.command == "gate":
|
|
164
|
+
from .conduction.gate import ProvenanceError, run_gate
|
|
165
|
+
from .conduction.evidence import EvidenceConflict
|
|
166
|
+
try:
|
|
167
|
+
result = run_gate(root, command=args.gate_cmd,
|
|
168
|
+
declared_in=Path(args.declared_in),
|
|
169
|
+
artifact=Path(args.artifact),
|
|
170
|
+
linkage=args.linkage, timeout=args.timeout)
|
|
171
|
+
except ProvenanceError as exc:
|
|
172
|
+
return _fail("PROVENANCE", {"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("GATE_RED", {"outcome": result["outcome"],
|
|
177
|
+
"exit_code": result["exit_code"],
|
|
178
|
+
"artifact": result["artifact"]}, out)
|
|
179
|
+
return _emit(result, 0, out)
|
|
123
180
|
return _fail("USAGE", f"unknown command {args.command!r}", out)
|
|
124
181
|
except ActivityError as exc:
|
|
125
182
|
return _fail(exc.code, exc.detail, out)
|
|
@@ -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)}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Injectable process runner for the conduction commands (PLAN-003).
|
|
2
|
+
|
|
3
|
+
The real runner launches the child in a NEW SESSION (its own process group);
|
|
4
|
+
on timeout the WHOLE group is SIGKILLed before the TIMEOUT outcome is
|
|
5
|
+
reported — no child (not even ones spawned by bash) survives the result.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RunResult:
|
|
18
|
+
exit_code: int | None
|
|
19
|
+
output_bytes: bytes
|
|
20
|
+
timed_out: bool
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def output(self) -> str:
|
|
24
|
+
return self.output_bytes.decode("utf-8", errors="replace")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SubprocessRunner:
|
|
28
|
+
def run(self, argv: list[str], *, cwd: str, timeout: float) -> RunResult:
|
|
29
|
+
proc = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.DEVNULL,
|
|
30
|
+
stdout=subprocess.PIPE,
|
|
31
|
+
stderr=subprocess.STDOUT,
|
|
32
|
+
start_new_session=True) # bytes mode + headless: NEVER
|
|
33
|
+
# inherit the parent's stdin — a child waiting for EOF on an
|
|
34
|
+
# inherited pipe hangs forever (caught live by the dogfooded review)
|
|
35
|
+
try:
|
|
36
|
+
output, _ = proc.communicate(timeout=timeout)
|
|
37
|
+
return RunResult(proc.returncode, output or b"", False)
|
|
38
|
+
except subprocess.TimeoutExpired:
|
|
39
|
+
try:
|
|
40
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
41
|
+
except (ProcessLookupError, PermissionError):
|
|
42
|
+
pass
|
|
43
|
+
output, _ = proc.communicate()
|
|
44
|
+
return RunResult(None, output or b"", True)
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"3121ea845545750928ca81fe656fd6a9941cbdc728e224e3aca476b0faa912cb",
|
|
13
13
|
"e6fe09a2931fc4d468dccd981803be5e07a98e1dce1dca6d868291d61eaf7270",
|
|
14
14
|
"6552c88b1ec1b7a286de73067dd16f7c9f333aaae3486398647d89b28b83fe44",
|
|
15
|
-
"4f09f84e5de5ab80ca54e36895e08dea9f0a30506b23d237a20d912633706c9d"
|
|
15
|
+
"4f09f84e5de5ab80ca54e36895e08dea9f0a30506b23d237a20d912633706c9d",
|
|
16
|
+
"340f8b028fdcac4542c32890a13c24fa006637afdea6c015b090de68b8b3980f"
|
|
16
17
|
]
|
|
17
18
|
}
|
|
@@ -72,17 +72,25 @@ executable gate) → advisor review (+1 rebuttal cycle) → `APPROVAL.md`
|
|
|
72
72
|
staged into the commit that closes the current boundary; record `control.version` from
|
|
73
73
|
`regent status` at step start, and BEFORE staging them run `regent control explain --since-version <o valor registrado>`:
|
|
74
74
|
exit 0 = explained operational churn, `UNATTRIBUTABLE` = the step commit MUST fail) →
|
|
75
|
-
implement → run the gate
|
|
75
|
+
implement → run the gate MECHANIZED:
|
|
76
|
+
`regent gate run --command "<gate from the plan>" --declared-in <PLAN.md path>
|
|
77
|
+
--artifact build/GATE-NN[-tryM].md --linkage PLAN-NNN/STEP-NN` (the command must appear
|
|
78
|
+
verbatim in the plan — `PROVENANCE` otherwise; `GATE_RED`/`TIMEOUT` never proceed and
|
|
79
|
+
never commit; a retry uses a NEW artifact name) →
|
|
76
80
|
`build/STEP-NN.md` → deliberate commit with trailer `Regent-Step: PLAN-NNN/STEP-NN` →
|
|
77
81
|
final advisor review over `BASE-SHA..HEAD` → `build/CONCLUSION.md` (status + actor) →
|
|
78
82
|
`regent activity conclude --status <conclusion status>`.
|
|
79
83
|
|
|
80
|
-
## 4. Advisor consultations (shared contract — REQ-003 §5)
|
|
84
|
+
## 4. Advisor consultations (shared contract — REQ-003 §5, mechanized)
|
|
81
85
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
Write the full prompt to a file, then:
|
|
87
|
+
`regent advisor consult --prompt-file <path> --artifact <ARTIFACT>.md --linkage
|
|
88
|
+
<ROUND-NNN|PLAN-NNN[/STEP-NN]> [--expect-verdict "<regex>"]`
|
|
89
|
+
The command persists the whole evidence pair (structured header + byte-identical prompt
|
|
90
|
+
copy) on EVERY outcome, enforces the read-only sandbox, and is fail-closed: exit ≠0
|
|
91
|
+
(`ADVISOR_FAILED`, `ADVISOR_UNAVAILABLE`) never advances the activity; retry = a NEW
|
|
92
|
+
consultation with a NEW artifact name (evidence is never overwritten — `CONFLICT`).
|
|
93
|
+
Never invoke the codex CLI directly.
|
|
86
94
|
|
|
87
95
|
## 5. Error codes you will see (exact)
|
|
88
96
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regent-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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,12 @@ 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).
|
|
52
57
|
|
|
53
58
|
Development: `PYTHONPATH=src python3 -m unittest discover -s tests`; packaging gate:
|
|
54
59
|
`bash scripts/gate-package.sh`. Canonical skill content lives in `src/regent/templates/`
|
|
@@ -7,6 +7,11 @@ src/regent/activity_cli.py
|
|
|
7
7
|
src/regent/cli.py
|
|
8
8
|
src/regent/doctor.py
|
|
9
9
|
src/regent/initcmd.py
|
|
10
|
+
src/regent/conduction/__init__.py
|
|
11
|
+
src/regent/conduction/consult.py
|
|
12
|
+
src/regent/conduction/evidence.py
|
|
13
|
+
src/regent/conduction/gate.py
|
|
14
|
+
src/regent/conduction/process.py
|
|
10
15
|
src/regent/protocol/__init__.py
|
|
11
16
|
src/regent/protocol/audit.py
|
|
12
17
|
src/regent/protocol/control.py
|
|
@@ -23,8 +28,10 @@ src/regent_cli.egg-info/top_level.txt
|
|
|
23
28
|
tests/test_activity.py
|
|
24
29
|
tests/test_activity_cli.py
|
|
25
30
|
tests/test_choreography.py
|
|
31
|
+
tests/test_consult.py
|
|
26
32
|
tests/test_control.py
|
|
27
33
|
tests/test_doctor.py
|
|
34
|
+
tests/test_gate.py
|
|
28
35
|
tests/test_init_upgrade.py
|
|
29
36
|
tests/test_initcmd.py
|
|
30
37
|
tests/test_lock.py
|
|
@@ -163,9 +163,9 @@ class ActivityCliTest(unittest.TestCase):
|
|
|
163
163
|
|
|
164
164
|
|
|
165
165
|
class VersionTest(unittest.TestCase):
|
|
166
|
-
def
|
|
166
|
+
def test_cli_version_reports_050(self):
|
|
167
167
|
import regent
|
|
168
|
-
self.assertEqual(regent.__version__, "0.
|
|
168
|
+
self.assertEqual(regent.__version__, "0.5.0")
|
|
169
169
|
|
|
170
170
|
|
|
171
171
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Directed tests for regent advisor consult (PLAN-003 STEP-01)."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from contextlib import redirect_stdout
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from regent.cli import main
|
|
13
|
+
from regent.conduction.consult import AdvisorUnavailable, run_consult
|
|
14
|
+
from regent.conduction.evidence import EvidenceConflict
|
|
15
|
+
from regent.conduction.process import RunResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FakeRunner:
|
|
19
|
+
def __init__(self, exit_code=0, timed_out=False, message="opinião...\nCONCORDA\n",
|
|
20
|
+
raw_output="raw log"):
|
|
21
|
+
self.exit_code, self.timed_out = exit_code, timed_out
|
|
22
|
+
self.message, self.raw_output = message, raw_output
|
|
23
|
+
self.argv = None
|
|
24
|
+
|
|
25
|
+
def run(self, argv, *, cwd, timeout):
|
|
26
|
+
self.argv = argv
|
|
27
|
+
msg_file = argv[argv.index("-o") + 1]
|
|
28
|
+
if not self.timed_out and self.message is not None:
|
|
29
|
+
Path(msg_file).write_text(self.message, encoding="utf-8")
|
|
30
|
+
return RunResult(None if self.timed_out else self.exit_code,
|
|
31
|
+
self.raw_output.encode("utf-8"), self.timed_out)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConsultTest(unittest.TestCase):
|
|
35
|
+
def setUp(self):
|
|
36
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
37
|
+
self.root = Path(self._tmp.name)
|
|
38
|
+
self.addCleanup(self._tmp.cleanup)
|
|
39
|
+
(self.root / ".regent").mkdir()
|
|
40
|
+
self.prompt = self.root / "PROMPT-src.md"
|
|
41
|
+
self.prompt.write_text("Avalie o plano.\nTERMINE com CONCORDA ou DISCORDA.",
|
|
42
|
+
encoding="utf-8")
|
|
43
|
+
self.artifact = self.root / ".regent" / "plans" / "P" / "ADVISOR-1.md"
|
|
44
|
+
|
|
45
|
+
def _consult(self, runner, **kwargs):
|
|
46
|
+
return run_consult(self.root, prompt_file=self.prompt,
|
|
47
|
+
artifact=self.artifact, linkage="PLAN-003",
|
|
48
|
+
runner=runner, **kwargs)
|
|
49
|
+
|
|
50
|
+
def test_consult_success_persists_tuple_and_verdict(self):
|
|
51
|
+
result = self._consult(FakeRunner())
|
|
52
|
+
self.assertTrue(result["ok"])
|
|
53
|
+
self.assertEqual(result["verdict"], "CONCORDA")
|
|
54
|
+
text = self.artifact.read_text(encoding="utf-8")
|
|
55
|
+
self.assertIn("outcome: SUCCESS", text)
|
|
56
|
+
self.assertIn("exit_code: 0", text)
|
|
57
|
+
self.assertIn("linkage: PLAN-003", text)
|
|
58
|
+
self.assertIn("verdict: CONCORDA", text)
|
|
59
|
+
self.assertIn("opinião...", text)
|
|
60
|
+
|
|
61
|
+
def test_consult_prompt_copy_byte_identical(self):
|
|
62
|
+
self._consult(FakeRunner())
|
|
63
|
+
copy = Path(str(self.artifact) + "-PROMPT.md")
|
|
64
|
+
self.assertEqual(copy.read_bytes(), self.prompt.read_bytes())
|
|
65
|
+
|
|
66
|
+
def test_consult_sandbox_flags_forced(self):
|
|
67
|
+
runner = FakeRunner()
|
|
68
|
+
self._consult(runner)
|
|
69
|
+
argv = runner.argv
|
|
70
|
+
self.assertIn("--sandbox", argv)
|
|
71
|
+
self.assertEqual(argv[argv.index("--sandbox") + 1], "read-only")
|
|
72
|
+
self.assertIn("--ask-for-approval", argv)
|
|
73
|
+
self.assertEqual(argv[argv.index("--ask-for-approval") + 1], "never")
|
|
74
|
+
|
|
75
|
+
def test_consult_timeout_records_and_fails_closed(self):
|
|
76
|
+
result = self._consult(FakeRunner(timed_out=True, message=None))
|
|
77
|
+
self.assertFalse(result["ok"])
|
|
78
|
+
self.assertEqual(result["outcome"], "TIMEOUT")
|
|
79
|
+
text = self.artifact.read_text(encoding="utf-8")
|
|
80
|
+
self.assertIn("outcome: TIMEOUT", text)
|
|
81
|
+
self.assertIn("exit_code: null", text)
|
|
82
|
+
|
|
83
|
+
def test_consult_failure_records_exit_code(self):
|
|
84
|
+
result = self._consult(FakeRunner(exit_code=7, message=None,
|
|
85
|
+
raw_output="boom"))
|
|
86
|
+
self.assertFalse(result["ok"])
|
|
87
|
+
self.assertEqual(result["outcome"], "FAILURE")
|
|
88
|
+
self.assertIn("exit_code: 7", self.artifact.read_text(encoding="utf-8"))
|
|
89
|
+
self.assertIn("boom", self.artifact.read_text(encoding="utf-8"))
|
|
90
|
+
|
|
91
|
+
def test_consult_expect_verdict_fail_closed(self):
|
|
92
|
+
result = self._consult(FakeRunner(message="análise sem veredicto final\n"),
|
|
93
|
+
expect_verdict=r"^APROVADO$")
|
|
94
|
+
self.assertFalse(result["ok"]) # SUCCESS outcome, but demanded verdict absent
|
|
95
|
+
self.assertEqual(result["outcome"], "SUCCESS")
|
|
96
|
+
self.assertIsNone(result["verdict"])
|
|
97
|
+
|
|
98
|
+
def test_consult_default_regex_null_verdict_is_informational(self):
|
|
99
|
+
result = self._consult(FakeRunner(message="sem veredicto\n"))
|
|
100
|
+
self.assertTrue(result["ok"]) # no explicit expectation: null allowed
|
|
101
|
+
self.assertIsNone(result["verdict"])
|
|
102
|
+
|
|
103
|
+
def test_consult_pair_conflict_either_file(self):
|
|
104
|
+
copy = Path(str(self.artifact) + "-PROMPT.md")
|
|
105
|
+
copy.parent.mkdir(parents=True)
|
|
106
|
+
copy.write_text("pre-existing", encoding="utf-8")
|
|
107
|
+
with self.assertRaises(EvidenceConflict):
|
|
108
|
+
self._consult(FakeRunner())
|
|
109
|
+
self.assertFalse(self.artifact.exists()) # nothing else was written
|
|
110
|
+
|
|
111
|
+
def test_consult_refuses_existing_artifact(self):
|
|
112
|
+
self.artifact.parent.mkdir(parents=True)
|
|
113
|
+
self.artifact.write_text("old evidence", encoding="utf-8")
|
|
114
|
+
with self.assertRaises(EvidenceConflict):
|
|
115
|
+
self._consult(FakeRunner())
|
|
116
|
+
self.assertEqual(self.artifact.read_text(encoding="utf-8"), "old evidence")
|
|
117
|
+
|
|
118
|
+
def test_consult_terminal_outcome_always_completes_pair(self):
|
|
119
|
+
for runner in (FakeRunner(), FakeRunner(timed_out=True, message=None),
|
|
120
|
+
FakeRunner(exit_code=3, message=None)):
|
|
121
|
+
with self.subTest(runner=runner):
|
|
122
|
+
self._consult(runner)
|
|
123
|
+
self.assertTrue(self.artifact.exists())
|
|
124
|
+
self.assertTrue(Path(str(self.artifact) + "-PROMPT.md").exists())
|
|
125
|
+
self.artifact.unlink()
|
|
126
|
+
Path(str(self.artifact) + "-PROMPT.md").unlink()
|
|
127
|
+
|
|
128
|
+
def test_consult_missing_codex_is_capability_error(self):
|
|
129
|
+
with self.assertRaises(AdvisorUnavailable):
|
|
130
|
+
run_consult(self.root, prompt_file=self.prompt,
|
|
131
|
+
artifact=self.artifact, linkage="X",
|
|
132
|
+
codex_bin="definitely-not-a-real-binary-xyz")
|
|
133
|
+
|
|
134
|
+
def test_conflict_race_on_main_cleans_prompt_orphan(self):
|
|
135
|
+
artifact = self.artifact
|
|
136
|
+
|
|
137
|
+
class RacingRunner(FakeRunner):
|
|
138
|
+
def run(inner, argv, *, cwd, timeout):
|
|
139
|
+
artifact.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
artifact.write_text("raced-in evidence", encoding="utf-8")
|
|
141
|
+
return super().run(argv, cwd=cwd, timeout=timeout)
|
|
142
|
+
|
|
143
|
+
with self.assertRaises(EvidenceConflict):
|
|
144
|
+
self._consult(RacingRunner())
|
|
145
|
+
self.assertFalse(Path(str(artifact) + "-PROMPT.md").exists()) # cleaned
|
|
146
|
+
self.assertEqual(artifact.read_text(encoding="utf-8"),
|
|
147
|
+
"raced-in evidence") # the racer's evidence untouched
|
|
148
|
+
|
|
149
|
+
def test_runner_never_inherits_stdin(self):
|
|
150
|
+
from regent.conduction.process import SubprocessRunner
|
|
151
|
+
result = SubprocessRunner().run(
|
|
152
|
+
["bash", "-c", "cat; echo done"], cwd=str(self.root), timeout=5)
|
|
153
|
+
self.assertFalse(result.timed_out) # cat gets EOF immediately (DEVNULL)
|
|
154
|
+
self.assertIn("done", result.output)
|
|
155
|
+
|
|
156
|
+
def test_consult_cli_with_fake_codex_on_path(self):
|
|
157
|
+
bindir = self.root / "bin"
|
|
158
|
+
bindir.mkdir()
|
|
159
|
+
fake = bindir / "codex"
|
|
160
|
+
fake.write_text('#!/usr/bin/env bash\n'
|
|
161
|
+
'while [ "$1" != "-o" ]; do shift; done\n'
|
|
162
|
+
'printf "parecer\\nCONCORDA\\n" > "$2"\n', encoding="utf-8")
|
|
163
|
+
fake.chmod(fake.stat().st_mode | stat.S_IEXEC)
|
|
164
|
+
old_path = os.environ["PATH"]
|
|
165
|
+
os.environ["PATH"] = f"{bindir}:{old_path}"
|
|
166
|
+
self.addCleanup(os.environ.__setitem__, "PATH", old_path)
|
|
167
|
+
out = io.StringIO()
|
|
168
|
+
with redirect_stdout(out):
|
|
169
|
+
code = main(["advisor", "--project", str(self.root), "consult",
|
|
170
|
+
"--prompt-file", str(self.prompt),
|
|
171
|
+
"--artifact", str(self.artifact),
|
|
172
|
+
"--linkage", "PLAN-003",
|
|
173
|
+
"--expect-verdict", "^CONCORDA$"])
|
|
174
|
+
payload = json.loads(out.getvalue())
|
|
175
|
+
self.assertEqual(code, 0, payload)
|
|
176
|
+
self.assertEqual(payload["verdict"], "CONCORDA")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
unittest.main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Directed tests for regent gate run (PLAN-003 STEP-02)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from regent.conduction.evidence import EvidenceConflict
|
|
11
|
+
from regent.conduction.gate import ProvenanceError, run_gate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GateTest(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
17
|
+
self.root = Path(self._tmp.name)
|
|
18
|
+
self.addCleanup(self._tmp.cleanup)
|
|
19
|
+
self.plan = self.root / "PLAN.md"
|
|
20
|
+
self.artifact = self.root / "evidence" / "GATE-01.md"
|
|
21
|
+
|
|
22
|
+
def _declare(self, command: str):
|
|
23
|
+
self.plan.write_text(f"## Etapa\n- **Gate:** `{command}`\n", encoding="utf-8")
|
|
24
|
+
return command
|
|
25
|
+
|
|
26
|
+
def _run(self, command, **kwargs):
|
|
27
|
+
return run_gate(self.root, command=command, declared_in=self.plan,
|
|
28
|
+
artifact=self.artifact, linkage="PLAN-003/STEP-02",
|
|
29
|
+
**kwargs)
|
|
30
|
+
|
|
31
|
+
def test_gate_green_persists_and_exits_zero(self):
|
|
32
|
+
result = self._run(self._declare("echo gate-ok"))
|
|
33
|
+
self.assertTrue(result["ok"])
|
|
34
|
+
self.assertEqual(result["outcome"], "GREEN")
|
|
35
|
+
text = self.artifact.read_text(encoding="utf-8")
|
|
36
|
+
self.assertIn("outcome: GREEN", text)
|
|
37
|
+
self.assertIn("command: echo gate-ok", text)
|
|
38
|
+
self.assertIn("gate-ok", text)
|
|
39
|
+
self.assertIn("truncated: False", text)
|
|
40
|
+
|
|
41
|
+
def test_gate_red_fails_closed_with_artifact(self):
|
|
42
|
+
result = self._run(self._declare("echo failing; exit 5"))
|
|
43
|
+
self.assertFalse(result["ok"])
|
|
44
|
+
self.assertEqual(result["outcome"], "RED")
|
|
45
|
+
self.assertEqual(result["exit_code"], 5)
|
|
46
|
+
self.assertIn("outcome: RED", self.artifact.read_text(encoding="utf-8"))
|
|
47
|
+
|
|
48
|
+
def test_gate_provenance_required(self):
|
|
49
|
+
self.plan.write_text("no gates declared here", encoding="utf-8")
|
|
50
|
+
with self.assertRaises(ProvenanceError):
|
|
51
|
+
self._run("echo undeclared")
|
|
52
|
+
self.assertFalse(self.artifact.exists())
|
|
53
|
+
|
|
54
|
+
def test_gate_timeout_recorded(self):
|
|
55
|
+
result = self._run(self._declare("sleep 30"), timeout=0.5)
|
|
56
|
+
self.assertFalse(result["ok"])
|
|
57
|
+
self.assertEqual(result["outcome"], "TIMEOUT")
|
|
58
|
+
self.assertIn("exit_code: null", self.artifact.read_text(encoding="utf-8"))
|
|
59
|
+
|
|
60
|
+
def test_gate_timeout_kills_process_group(self):
|
|
61
|
+
pidfile = self.root / "child.pid"
|
|
62
|
+
command = self._declare(f"sleep 60 & echo $! > {pidfile}; wait")
|
|
63
|
+
result = self._run(command, timeout=0.5)
|
|
64
|
+
self.assertEqual(result["outcome"], "TIMEOUT")
|
|
65
|
+
time.sleep(0.2)
|
|
66
|
+
child_pid = int(pidfile.read_text().strip())
|
|
67
|
+
with self.assertRaises(ProcessLookupError): # the CHILD died with the group
|
|
68
|
+
import os
|
|
69
|
+
os.kill(child_pid, 0)
|
|
70
|
+
|
|
71
|
+
def test_gate_refuses_existing_artifact(self):
|
|
72
|
+
self._declare("echo x")
|
|
73
|
+
self.artifact.parent.mkdir(parents=True)
|
|
74
|
+
self.artifact.write_text("old evidence", encoding="utf-8")
|
|
75
|
+
with self.assertRaises(EvidenceConflict):
|
|
76
|
+
self._run("echo x")
|
|
77
|
+
self.assertEqual(self.artifact.read_text(encoding="utf-8"), "old evidence")
|
|
78
|
+
|
|
79
|
+
def test_full_log_sidecar_over_200k(self):
|
|
80
|
+
command = self._declare("yes A | head -c 300000")
|
|
81
|
+
result = self._run(command)
|
|
82
|
+
self.assertEqual(result["outcome"], "GREEN")
|
|
83
|
+
full = Path(str(self.artifact) + "-FULL.log")
|
|
84
|
+
self.assertTrue(full.exists())
|
|
85
|
+
self.assertGreaterEqual(full.stat().st_size, 300000)
|
|
86
|
+
text = self.artifact.read_text(encoding="utf-8")
|
|
87
|
+
self.assertIn("truncated: True", text)
|
|
88
|
+
self.assertIn("output_bytes: 300000", text)
|
|
89
|
+
self.assertIn("[truncated:", text)
|
|
90
|
+
|
|
91
|
+
def test_gate_output_tail_truncation_declared(self):
|
|
92
|
+
command = self._declare("seq 1 100000")
|
|
93
|
+
self._run(command)
|
|
94
|
+
text = self.artifact.read_text(encoding="utf-8")
|
|
95
|
+
self.assertIn("100000", text) # the tail keeps the END of the output
|
|
96
|
+
|
|
97
|
+
def test_gate_full_sidecar_conflict(self):
|
|
98
|
+
self._declare("echo x")
|
|
99
|
+
full = Path(str(self.artifact) + "-FULL.log")
|
|
100
|
+
full.parent.mkdir(parents=True)
|
|
101
|
+
full.write_text("pre-existing evidence", encoding="utf-8")
|
|
102
|
+
with self.assertRaises(EvidenceConflict):
|
|
103
|
+
self._run("echo x")
|
|
104
|
+
self.assertFalse(self.artifact.exists())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
unittest.main()
|
|
@@ -15,7 +15,7 @@ KNOWN_SUBCOMMANDS = {
|
|
|
15
15
|
"regent activity start", "regent activity resume", "regent activity suspend",
|
|
16
16
|
"regent activity conclude", "regent activity heartbeat",
|
|
17
17
|
"regent activity takeover", "regent stop request", "regent stop check",
|
|
18
|
-
"regent control explain",
|
|
18
|
+
"regent control explain", "regent advisor consult", "regent gate run",
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
|
|
@@ -30,7 +30,7 @@ class SkillAntiDriftTest(unittest.TestCase):
|
|
|
30
30
|
# Only COMMAND citations count: `regent ...` inside backtick spans.
|
|
31
31
|
for name, text in _templates().items():
|
|
32
32
|
for span in re.findall(r"`([^`]*)`", text.replace("\n", " ")):
|
|
33
|
-
match = re.match(r"regent (?:(activity|stop|control) )?([a-z-]+)", span)
|
|
33
|
+
match = re.match(r"regent (?:(activity|stop|control|advisor|gate) )?([a-z-]+)", span)
|
|
34
34
|
if not match:
|
|
35
35
|
continue
|
|
36
36
|
group, word = match.group(1), match.group(2)
|
|
@@ -38,6 +38,11 @@ class SkillAntiDriftTest(unittest.TestCase):
|
|
|
38
38
|
self.assertIn(command, KNOWN_SUBCOMMANDS,
|
|
39
39
|
f"{name}: unknown command cited: {command!r}")
|
|
40
40
|
|
|
41
|
+
def test_skill_no_longer_prescribes_raw_codex(self):
|
|
42
|
+
for name, text in _templates().items():
|
|
43
|
+
self.assertNotIn("codex --ask-for-approval", text,
|
|
44
|
+
f"{name}: raw codex invocation still prescribed")
|
|
45
|
+
|
|
41
46
|
def test_skill_templates_error_codes_exist(self):
|
|
42
47
|
allowed = set(_EXIT_BY_CODE) | set(WORKSPACE_VERDICTS) | {
|
|
43
48
|
"SUCCESS", "DISCORDA"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|