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.
Files changed (50) hide show
  1. {regent_cli-0.4.0 → regent_cli-0.6.0}/PKG-INFO +12 -2
  2. {regent_cli-0.4.0 → regent_cli-0.6.0}/README.md +11 -1
  3. {regent_cli-0.4.0 → regent_cli-0.6.0}/pyproject.toml +1 -1
  4. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/__init__.py +1 -1
  5. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/activity_cli.py +101 -0
  6. regent_cli-0.6.0/src/regent/conduction/__init__.py +7 -0
  7. regent_cli-0.6.0/src/regent/conduction/confine.py +79 -0
  8. regent_cli-0.6.0/src/regent/conduction/consult.py +98 -0
  9. regent_cli-0.6.0/src/regent/conduction/evidence.py +101 -0
  10. regent_cli-0.6.0/src/regent/conduction/gate.py +71 -0
  11. regent_cli-0.6.0/src/regent/conduction/hookscript.py +181 -0
  12. regent_cli-0.6.0/src/regent/conduction/process.py +45 -0
  13. regent_cli-0.6.0/src/regent/conduction/turn.py +389 -0
  14. regent_cli-0.6.0/src/regent/conduction/turnlog.py +216 -0
  15. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/initcmd.py +5 -2
  16. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/templates/MANIFEST.json +3 -1
  17. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/templates/skills/regent/SKILL.md +23 -7
  18. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/PKG-INFO +12 -2
  19. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/SOURCES.txt +15 -1
  20. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_activity_cli.py +2 -2
  21. regent_cli-0.6.0/tests/test_confine.py +229 -0
  22. regent_cli-0.6.0/tests/test_consult.py +180 -0
  23. regent_cli-0.6.0/tests/test_gate.py +108 -0
  24. regent_cli-0.6.0/tests/test_hookscript.py +222 -0
  25. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_skills_v1.py +8 -2
  26. regent_cli-0.6.0/tests/test_turn.py +308 -0
  27. {regent_cli-0.4.0 → regent_cli-0.6.0}/LICENSE +0 -0
  28. {regent_cli-0.4.0 → regent_cli-0.6.0}/setup.cfg +0 -0
  29. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/activity.py +0 -0
  30. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/cli.py +0 -0
  31. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/doctor.py +0 -0
  32. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/protocol/__init__.py +0 -0
  33. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/protocol/audit.py +0 -0
  34. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/protocol/control.py +0 -0
  35. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/protocol/lock.py +0 -0
  36. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/protocol/stop.py +0 -0
  37. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent/templates/skills/regent-stop/SKILL.md +0 -0
  38. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/dependency_links.txt +0 -0
  39. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/entry_points.txt +0 -0
  40. {regent_cli-0.4.0 → regent_cli-0.6.0}/src/regent_cli.egg-info/top_level.txt +0 -0
  41. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_activity.py +0 -0
  42. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_choreography.py +0 -0
  43. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_control.py +0 -0
  44. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_doctor.py +0 -0
  45. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_init_upgrade.py +0 -0
  46. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_initcmd.py +0 -0
  47. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_lock.py +0 -0
  48. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_protocol_facade.py +0 -0
  49. {regent_cli-0.4.0 → regent_cli-0.6.0}/tests/test_step06_fixes.py +0 -0
  50. {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.4.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.4.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"
@@ -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.6.0"
@@ -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,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,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)}