agentflow-proof 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentflow/git.py ADDED
@@ -0,0 +1,89 @@
1
+ """Git helpers used by drift auditing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional, Tuple
8
+
9
+
10
+ def run_git(root: Path, args: List[str]) -> Tuple[int, str, str]:
11
+ proc = subprocess.run(
12
+ ["git", "-C", str(root), *args],
13
+ check=False,
14
+ stdout=subprocess.PIPE,
15
+ stderr=subprocess.PIPE,
16
+ text=True,
17
+ )
18
+ return proc.returncode, proc.stdout.rstrip(), proc.stderr.rstrip()
19
+
20
+
21
+ def is_git_repo(root: Path) -> bool:
22
+ code, _, _ = run_git(root, ["rev-parse", "--is-inside-work-tree"])
23
+ return code == 0
24
+
25
+
26
+ def changed_files(root: Path) -> List[str]:
27
+ code, stdout, stderr = run_git(root, ["status", "--porcelain=v1", "-uall"])
28
+ if code != 0:
29
+ raise RuntimeError(stderr or "git status failed")
30
+
31
+ files = []
32
+ for line in stdout.splitlines():
33
+ if not line:
34
+ continue
35
+ path = line[3:].strip()
36
+ if " -> " in path:
37
+ path = path.split(" -> ", 1)[1]
38
+ files.append(path)
39
+ return sorted(set(files))
40
+
41
+
42
+ def changed_file_records(root: Path) -> List[Dict[str, str]]:
43
+ code, stdout, stderr = run_git(root, ["status", "--porcelain=v1", "-uall"])
44
+ if code != 0:
45
+ raise RuntimeError(stderr or "git status failed")
46
+
47
+ records: List[Dict[str, str]] = []
48
+ for line in stdout.splitlines():
49
+ if not line:
50
+ continue
51
+ status = line[:2]
52
+ raw_path = line[3:].strip()
53
+ previous_path = ""
54
+ path = raw_path
55
+ change_kind = "modified"
56
+ if " -> " in raw_path:
57
+ previous_path, path = raw_path.split(" -> ", 1)
58
+ change_kind = "renamed"
59
+ elif status == "??" or "A" in status:
60
+ change_kind = "added"
61
+ elif "D" in status:
62
+ change_kind = "deleted"
63
+ records.append(
64
+ {
65
+ "path": path,
66
+ "previous_path": previous_path,
67
+ "status": status,
68
+ "change_kind": change_kind,
69
+ }
70
+ )
71
+ return sorted(records, key=lambda item: item["path"])
72
+
73
+
74
+ def git_blob_for_head(root: Path, path: str) -> Optional[str]:
75
+ code, stdout, _ = run_git(root, ["rev-parse", f"HEAD:{path}"])
76
+ if code != 0:
77
+ return None
78
+ return stdout.strip()
79
+
80
+
81
+ def current_branch(root: Path) -> Optional[str]:
82
+ """Return the current branch name, or None outside a repo / on detached HEAD."""
83
+ code, stdout, _ = run_git(root, ["rev-parse", "--abbrev-ref", "HEAD"])
84
+ if code != 0:
85
+ return None
86
+ branch = stdout.strip()
87
+ if not branch or branch == "HEAD":
88
+ return None
89
+ return branch
agentflow/handoff.py ADDED
@@ -0,0 +1,112 @@
1
+ """Provider-neutral handoff rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict, List, Union
7
+
8
+ from .contracts import PROVIDER_NEUTRAL_DENYLIST
9
+
10
+
11
+ def _step(plan: Dict[str, Any], step_id: str) -> Dict[str, Any]:
12
+ for item in plan.get("steps", []):
13
+ if isinstance(item, dict) and item.get("id") == step_id:
14
+ return item
15
+ raise ValueError(f"unknown step id: {step_id}")
16
+
17
+
18
+ def lint_handoff_text(text: str) -> List[Dict[str, str]]:
19
+ lower = text.lower()
20
+ findings: List[Dict[str, str]] = []
21
+ for token in PROVIDER_NEUTRAL_DENYLIST:
22
+ if token in lower:
23
+ findings.append(
24
+ {
25
+ "severity": "error",
26
+ "message": f"provider-specific token is not allowed in handoff: {token}",
27
+ }
28
+ )
29
+ return findings
30
+
31
+
32
+ def _json_handoff(plan: Dict[str, Any], step_id: str) -> Dict[str, Any]:
33
+ step = _step(plan, step_id)
34
+ return {
35
+ "contract": "agentflow_shell_handoff",
36
+ "objective": plan.get("objective", ""),
37
+ "step": step,
38
+ "allowed_files": step.get("files", []),
39
+ "blocked_files": plan.get("blocked_files", []),
40
+ "non_goals": plan.get("non_goals", []),
41
+ "invariants": plan.get("invariants", []),
42
+ "expected_diff": step.get("expected_diff", []),
43
+ "validation": step.get("validation", []),
44
+ "commands": {
45
+ "claim": f"agentflow claim-step {step_id} --agent $AGENT_ID",
46
+ "run": f"agentflow run --step {step_id} -- <command>",
47
+ "record_file_change": f"agentflow record-file-change --step {step_id} --path <path>",
48
+ "verify": f"agentflow verify-step {step_id}",
49
+ "complete": f"agentflow complete-step {step_id}",
50
+ "block": f"agentflow block-step {step_id} --reason <reason>",
51
+ "fail": f"agentflow fail-step {step_id} --reason <reason>",
52
+ },
53
+ "exit_code_contract": {
54
+ "0": "no error-severity findings",
55
+ "1": "one or more error-severity findings",
56
+ "2": "command usage error",
57
+ },
58
+ }
59
+
60
+
61
+ def _markdown_handoff(payload: Dict[str, Any]) -> str:
62
+ step = payload["step"]
63
+ lines = [
64
+ "# Agentflow Work Packet",
65
+ "",
66
+ "## Objective",
67
+ "",
68
+ payload["objective"],
69
+ "",
70
+ "## Step",
71
+ "",
72
+ f"{step['id']}: {step['action']}",
73
+ "",
74
+ "## Allowed Files",
75
+ "",
76
+ *[f"- {item}" for item in payload["allowed_files"]],
77
+ "",
78
+ "## Blocked Files",
79
+ "",
80
+ *[f"- {item}" for item in payload["blocked_files"]],
81
+ "",
82
+ "## Expected Diff",
83
+ "",
84
+ *[f"- {item}" for item in payload["expected_diff"]],
85
+ "",
86
+ "## Validation",
87
+ "",
88
+ *[f"- {item}" for item in payload["validation"]],
89
+ "",
90
+ "## Commands",
91
+ "",
92
+ *[f"- `{name}`: `{command}`" for name, command in payload["commands"].items()],
93
+ "",
94
+ ]
95
+ return "\n".join(lines)
96
+
97
+
98
+ def export_handoff(plan: Dict[str, Any], step_id: str, output_format: str) -> Union[Dict[str, Any], str]:
99
+ payload = _json_handoff(plan, step_id)
100
+ if output_format == "json":
101
+ text = json.dumps(payload, sort_keys=True)
102
+ findings = lint_handoff_text(text)
103
+ if findings:
104
+ raise ValueError(findings[0]["message"])
105
+ return payload
106
+ if output_format == "markdown":
107
+ markdown = _markdown_handoff(payload)
108
+ findings = lint_handoff_text(markdown)
109
+ if findings:
110
+ raise ValueError(findings[0]["message"])
111
+ return markdown
112
+ raise ValueError("format must be json or markdown")
agentflow/hunks.py ADDED
@@ -0,0 +1,245 @@
1
+ """Hunk-level attribution: shared diff parsing, identity, and coverage.
2
+
3
+ This module is the only place that shells ``git diff`` and computes hunk
4
+ identity hashes. It imports only stdlib plus ``artifacts`` and ``contracts`` to
5
+ stay free of import cycles (``receipts``/``validation``/``execution_coverage``
6
+ all import this module). It deliberately does not use ``git.run_git`` because
7
+ that helper strips stdout, and hunk patches / HEAD blobs need exact output.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import re
14
+ import subprocess
15
+ from pathlib import Path
16
+ from tempfile import NamedTemporaryFile
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ from .artifacts import read_jsonl, try_read_json
20
+ from .contracts import DIFF_COMMAND_VERSION, EXECUTION_ARTIFACT_PATHS, HUNK_ATTRIBUTION_POLICIES
21
+
22
+ _HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
23
+ # Number of lines before each hunk to include in the context anchor window.
24
+ _CONTEXT_ANCHOR_LINES = 3
25
+
26
+
27
+ def _git_text(root: Path, args: List[str]) -> Tuple[int, str, str]:
28
+ proc = subprocess.run(
29
+ ["git", "-C", str(root), *args],
30
+ check=False,
31
+ stdout=subprocess.PIPE,
32
+ stderr=subprocess.PIPE,
33
+ text=True,
34
+ encoding="utf-8",
35
+ errors="surrogateescape",
36
+ )
37
+ return proc.returncode, proc.stdout, proc.stderr
38
+
39
+
40
+ def _git_bytes(root: Path, args: List[str]) -> Tuple[int, bytes, bytes]:
41
+ proc = subprocess.run(
42
+ ["git", "-C", str(root), *args],
43
+ check=False,
44
+ stdout=subprocess.PIPE,
45
+ stderr=subprocess.PIPE,
46
+ )
47
+ return proc.returncode, proc.stdout, proc.stderr
48
+
49
+
50
+ def _base_flags(no_index: bool) -> List[str]:
51
+ flags = [
52
+ "-c",
53
+ "core.autocrlf=false",
54
+ "diff",
55
+ "--no-color",
56
+ "--no-ext-diff",
57
+ "--no-textconv",
58
+ "--unified=0",
59
+ "--diff-algorithm=myers",
60
+ ]
61
+ flags.append("--no-index" if no_index else "--no-renames")
62
+ return flags
63
+
64
+
65
+ def hunk_identity(posix_path: str, change_kind: str, changed_lines: List[str]) -> str:
66
+ payload = "\n".join([DIFF_COMMAND_VERSION, posix_path, change_kind, *changed_lines])
67
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
68
+
69
+
70
+ def _normalize_path(path: str) -> str:
71
+ return path.replace("\\", "/")
72
+
73
+
74
+ def _head_lines(root: Path, path: str) -> Optional[List[str]]:
75
+ code, stdout, _ = _git_text(root, ["show", f"HEAD:{path}"])
76
+ if code != 0:
77
+ return None
78
+ # CRLF→LF normalization is deliberate: ensures cross-platform identity stability.
79
+ return stdout.replace("\r\n", "\n").split("\n")
80
+
81
+
82
+ def _context_sha256(head_lines: Optional[List[str]], old_start: int) -> Tuple[Optional[str], str]:
83
+ if not head_lines or old_start <= 0:
84
+ return None, "none"
85
+ start = max(0, old_start - 1 - _CONTEXT_ANCHOR_LINES)
86
+ anchors = head_lines[start : old_start - 1]
87
+ if not anchors:
88
+ return None, "none"
89
+ digest = hashlib.sha256("\n".join(anchors).encode("utf-8")).hexdigest()
90
+ return digest, "head-baseline"
91
+
92
+
93
+ def _parse_patch(text: str, posix_path: str, change_kind: str, head_lines: Optional[List[str]]) -> List[Dict[str, Any]]:
94
+ hunks: List[Dict[str, Any]] = []
95
+ # CRLF→LF normalization is deliberate: ensures cross-platform identity stability.
96
+ lines = text.replace("\r\n", "\n").split("\n")
97
+ index = 0
98
+ while index < len(lines):
99
+ match = _HUNK_HEADER.match(lines[index])
100
+ if not match:
101
+ index += 1
102
+ continue
103
+ old_start = int(match.group(1))
104
+ old_count = int(match.group(2) or "1")
105
+ new_start = int(match.group(3))
106
+ new_count = int(match.group(4) or "1")
107
+ index += 1
108
+ body: List[str] = []
109
+ # Safe under --unified=0: no context lines are emitted, so every
110
+ # added/removed content line carries a '+'/'-' prefix; a literal "@@ "
111
+ # sequence in file content cannot appear without a prefix and cannot be
112
+ # misread as a hunk header.
113
+ while index < len(lines) and not lines[index].startswith("@@ "):
114
+ line = lines[index]
115
+ if line.startswith(("+", "-")):
116
+ body.append(line)
117
+ index += 1
118
+ context_hash, context_source = _context_sha256(head_lines, old_start)
119
+ hunks.append(
120
+ {
121
+ "hash": hunk_identity(posix_path, change_kind, body),
122
+ "old_start": old_start,
123
+ "old_count": old_count,
124
+ "new_start": new_start,
125
+ "new_count": new_count,
126
+ "context_sha256": context_hash,
127
+ "context_source": context_source,
128
+ }
129
+ )
130
+ return hunks
131
+
132
+
133
+ def _is_binary(text: str) -> bool:
134
+ return "Binary files " in text or "GIT binary patch" in text
135
+
136
+
137
+ def compute_hunks(root: Path, record: Dict[str, Any]) -> Tuple[str, List[Dict[str, Any]]]:
138
+ """Return ``(attribution, hunks)`` for one changed-file record.
139
+
140
+ ``attribution`` is ``"hunked"`` for a parsed text diff or
141
+ ``"whole_file_fallback"`` for binary / unparseable / unreconstructable
142
+ inputs. The ``"disabled"`` state is set by the caller, never here.
143
+ """
144
+ path = record["path"]
145
+ posix_path = _normalize_path(path)
146
+ change_kind = record.get("change_kind", "modified")
147
+ head_lines = _head_lines(root, record.get("previous_path") or path)
148
+
149
+ if change_kind == "added":
150
+ code, stdout, _ = _git_text(root, [*_base_flags(no_index=True), "--", "/dev/null", path])
151
+ if code > 1:
152
+ return "whole_file_fallback", []
153
+ if _is_binary(stdout):
154
+ return "whole_file_fallback", []
155
+ return "hunked", _parse_patch(stdout, posix_path, change_kind, None)
156
+
157
+ if change_kind == "renamed":
158
+ previous_path = record.get("previous_path") or ""
159
+ if not previous_path:
160
+ return "whole_file_fallback", []
161
+ code, blob, _ = _git_bytes(root, ["show", f"HEAD:{previous_path}"])
162
+ if code != 0:
163
+ return "whole_file_fallback", []
164
+ with NamedTemporaryFile("wb", suffix=".afhunk", delete=True) as handle:
165
+ handle.write(blob)
166
+ handle.flush()
167
+ code, stdout, _ = _git_text(
168
+ root, [*_base_flags(no_index=True), "--", handle.name, path]
169
+ )
170
+ if code > 1 or _is_binary(stdout):
171
+ return "whole_file_fallback", []
172
+ return "hunked", _parse_patch(stdout, posix_path, change_kind, head_lines)
173
+
174
+ # modified / deleted: working tree vs HEAD
175
+ code, stdout, _ = _git_text(root, [*_base_flags(no_index=False), "HEAD", "--", path])
176
+ if code != 0:
177
+ return "whole_file_fallback", []
178
+ if _is_binary(stdout):
179
+ return "whole_file_fallback", []
180
+ return "hunked", _parse_patch(stdout, posix_path, change_kind, head_lines)
181
+
182
+
183
+ def effective_hunk_policy(root: Path) -> str:
184
+ contract_path = root / EXECUTION_ARTIFACT_PATHS["execution-contract"]
185
+ if not contract_path.exists():
186
+ return "off"
187
+ # A malformed contract must not crash the proof/CI gate; degrade to the
188
+ # contract-present default ("enforce") rather than raising.
189
+ contract, _ = try_read_json(contract_path)
190
+ if not isinstance(contract, dict):
191
+ return "enforce"
192
+ policy = contract.get("proof_policy", {})
193
+ value = policy.get("hunk_attribution", "enforce") if isinstance(policy, dict) else "enforce"
194
+ return value if value in HUNK_ATTRIBUTION_POLICIES else "enforce"
195
+
196
+
197
+ def _receipts_by_path(root: Path) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, List[Dict[str, Any]]]]:
198
+ latest: Dict[str, Dict[str, Any]] = {}
199
+ grouped: Dict[str, List[Dict[str, Any]]] = {}
200
+ for receipt in read_jsonl(root / EXECUTION_ARTIFACT_PATHS["file-receipts"]):
201
+ path = receipt.get("path")
202
+ if not isinstance(path, str):
203
+ continue
204
+ latest[path] = receipt # append-only ledger: last write wins
205
+ grouped.setdefault(path, []).append(receipt)
206
+ return latest, grouped
207
+
208
+
209
+ def unmapped_hunks(root: Path, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
210
+ latest, grouped = _receipts_by_path(root)
211
+ out: List[Dict[str, Any]] = []
212
+ for record in records:
213
+ path = record.get("path")
214
+ latest_receipt = latest.get(path)
215
+ if latest_receipt is None:
216
+ continue # missing-receipt policy handles this upstream
217
+ path_receipts = grouped.get(path, [])
218
+ if "hunks" not in latest_receipt:
219
+ continue # latest legacy 0.3.0 receipt -> whole-file coverage
220
+ if latest_receipt.get("hunk_attribution") != "hunked":
221
+ continue # whole_file_fallback / disabled
222
+ recorded = {
223
+ hunk["hash"]
224
+ for receipt in path_receipts
225
+ if receipt.get("hunk_attribution") == "hunked"
226
+ for hunk in receipt.get("hunks", [])
227
+ if isinstance(hunk.get("hash"), str)
228
+ }
229
+ attribution, current = compute_hunks(root, record)
230
+ if attribution != "hunked":
231
+ continue # became binary/unparseable -> after_sha256 governs
232
+ for hunk in current:
233
+ if hunk["hash"] not in recorded:
234
+ out.append(
235
+ {
236
+ "path": path,
237
+ "hash": hunk["hash"],
238
+ "old_start": hunk["old_start"],
239
+ "old_count": hunk["old_count"],
240
+ "new_start": hunk["new_start"],
241
+ "new_count": hunk["new_count"],
242
+ "reason": "no_matching_hunk",
243
+ }
244
+ )
245
+ return out
agentflow/locks.py ADDED
@@ -0,0 +1,71 @@
1
+ """Cross-platform advisory file locking for serializing ledger writes.
2
+
3
+ Standard library only, to preserve Agentflow's no-runtime-dependency invariant.
4
+ Uses ``fcntl`` on POSIX and ``msvcrt`` on Windows. If neither is available (no
5
+ known platform), the lock degrades to a no-op, which preserves prior behavior.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Iterator
14
+
15
+ # Poll interval for the Windows retry loop (POSIX flock blocks in the kernel and
16
+ # needs no polling).
17
+ _MSVCRT_RETRY_SECONDS = 0.05
18
+
19
+ try: # POSIX
20
+ import fcntl
21
+
22
+ _HAVE_FCNTL = True
23
+ except ImportError: # pragma: no cover - non-POSIX
24
+ _HAVE_FCNTL = False
25
+
26
+ try: # Windows
27
+ import msvcrt
28
+
29
+ _HAVE_MSVCRT = True
30
+ except ImportError: # pragma: no cover - non-Windows
31
+ _HAVE_MSVCRT = False
32
+
33
+
34
+ @contextlib.contextmanager
35
+ def file_lock(lock_path: Path) -> Iterator[None]:
36
+ """Hold an exclusive advisory lock on ``lock_path`` for the block's duration.
37
+
38
+ The lock file is created if needed and never truncated; only its kernel lock
39
+ state is used. The lock is released and the descriptor closed on exit, even if
40
+ the body raises. ``fcntl`` locks are released by the kernel if the process
41
+ dies, so a crash cannot leave a stale lock behind.
42
+ """
43
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
44
+ handle = open(lock_path, "a+")
45
+ acquired = False
46
+ try:
47
+ if _HAVE_FCNTL:
48
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
49
+ acquired = True
50
+ elif _HAVE_MSVCRT: # pragma: no cover - Windows only
51
+ # LK_LOCK gives up with OSError after ~10 retries, so a long-held
52
+ # lock would make concurrent writers fail. Poll a non-blocking lock
53
+ # until acquired to match POSIX flock's indefinite blocking.
54
+ handle.seek(0)
55
+ while True:
56
+ try:
57
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
58
+ acquired = True
59
+ break
60
+ except OSError:
61
+ time.sleep(_MSVCRT_RETRY_SECONDS)
62
+ yield
63
+ finally:
64
+ try:
65
+ if acquired and _HAVE_FCNTL:
66
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
67
+ elif acquired and _HAVE_MSVCRT: # pragma: no cover - Windows only
68
+ handle.seek(0)
69
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
70
+ finally:
71
+ handle.close()