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.
Files changed (43) hide show
  1. {regent_cli-0.4.0 → regent_cli-0.5.0}/PKG-INFO +7 -2
  2. {regent_cli-0.4.0 → regent_cli-0.5.0}/README.md +6 -1
  3. {regent_cli-0.4.0 → regent_cli-0.5.0}/pyproject.toml +1 -1
  4. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/__init__.py +1 -1
  5. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/activity_cli.py +57 -0
  6. regent_cli-0.5.0/src/regent/conduction/__init__.py +7 -0
  7. regent_cli-0.5.0/src/regent/conduction/consult.py +98 -0
  8. regent_cli-0.5.0/src/regent/conduction/evidence.py +101 -0
  9. regent_cli-0.5.0/src/regent/conduction/gate.py +71 -0
  10. regent_cli-0.5.0/src/regent/conduction/process.py +44 -0
  11. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/templates/MANIFEST.json +2 -1
  12. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/templates/skills/regent/SKILL.md +14 -6
  13. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent_cli.egg-info/PKG-INFO +7 -2
  14. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent_cli.egg-info/SOURCES.txt +7 -0
  15. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_activity_cli.py +2 -2
  16. regent_cli-0.5.0/tests/test_consult.py +180 -0
  17. regent_cli-0.5.0/tests/test_gate.py +108 -0
  18. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_skills_v1.py +7 -2
  19. {regent_cli-0.4.0 → regent_cli-0.5.0}/LICENSE +0 -0
  20. {regent_cli-0.4.0 → regent_cli-0.5.0}/setup.cfg +0 -0
  21. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/activity.py +0 -0
  22. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/cli.py +0 -0
  23. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/doctor.py +0 -0
  24. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/initcmd.py +0 -0
  25. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/protocol/__init__.py +0 -0
  26. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/protocol/audit.py +0 -0
  27. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/protocol/control.py +0 -0
  28. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/protocol/lock.py +0 -0
  29. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/protocol/stop.py +0 -0
  30. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent/templates/skills/regent-stop/SKILL.md +0 -0
  31. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent_cli.egg-info/dependency_links.txt +0 -0
  32. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent_cli.egg-info/entry_points.txt +0 -0
  33. {regent_cli-0.4.0 → regent_cli-0.5.0}/src/regent_cli.egg-info/top_level.txt +0 -0
  34. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_activity.py +0 -0
  35. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_choreography.py +0 -0
  36. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_control.py +0 -0
  37. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_doctor.py +0 -0
  38. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_init_upgrade.py +0 -0
  39. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_initcmd.py +0 -0
  40. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_lock.py +0 -0
  41. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_protocol_facade.py +0 -0
  42. {regent_cli-0.4.0 → regent_cli-0.5.0}/tests/test_step06_fixes.py +0 -0
  43. {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.4.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.4.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"
@@ -1,3 +1,3 @@
1
1
  """regent — autonomous conduction and mediated adversarial deliberation between AI agents."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -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,7 @@
1
+ """regent.conduction — conduction phase 1 (PLAN-003): mechanized advisor
2
+ consultations and gate runs with automated, fail-closed evidence."""
3
+
4
+ from .consult import run_consult
5
+ from .gate import run_gate
6
+
7
+ __all__ = ["run_consult", "run_gate"]
@@ -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 for real →
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
- `codex --ask-for-approval never --sandbox read-only exec --cd <repo> -o <tmpfile>
83
- "<prompt>"` full prompt persisted in `<ARTIFACT>-PROMPT.md`; response artifact opens
84
- with the structured header (`outcome`, `exit_code`, `timestamp`, `linkage`). Fail-closed:
85
- non-SUCCESS never advances; retry = a NEW recorded consultation.
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.4.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 test_cli_version_reports_040(self):
166
+ def test_cli_version_reports_050(self):
167
167
  import regent
168
- self.assertEqual(regent.__version__, "0.4.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