agentcam 0.1.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.
- agentcam/__init__.py +4 -0
- agentcam/cli.py +252 -0
- agentcam/git_state.py +207 -0
- agentcam/models.py +146 -0
- agentcam/paths.py +102 -0
- agentcam/redaction.py +259 -0
- agentcam/report.py +407 -0
- agentcam/runner.py +301 -0
- agentcam/scanner.py +363 -0
- agentcam-0.1.0.dist-info/METADATA +313 -0
- agentcam-0.1.0.dist-info/RECORD +14 -0
- agentcam-0.1.0.dist-info/WHEEL +4 -0
- agentcam-0.1.0.dist-info/entry_points.txt +2 -0
- agentcam-0.1.0.dist-info/licenses/LICENSE +21 -0
agentcam/__init__.py
ADDED
agentcam/cli.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""agentcam command-line entry point.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
- ``agentcam version`` — print version and exit
|
|
5
|
+
- ``agentcam run -- <argv...>`` — wrap a command, record raw + redacted
|
|
6
|
+
logs, generate AGENT_RUN_REPORT.md
|
|
7
|
+
|
|
8
|
+
``run`` is intentionally argv-only; for shell features (pipes, redirects,
|
|
9
|
+
variable expansion) wrap your own shell explicitly, e.g.::
|
|
10
|
+
|
|
11
|
+
agentcam run -- bash -lc "echo hi > out.txt"
|
|
12
|
+
agentcam run -- pwsh -Command "Get-Process | Out-File procs.txt"
|
|
13
|
+
agentcam run -- cmd /c "dir > files.txt"
|
|
14
|
+
|
|
15
|
+
See ``docs/design.md`` (forthcoming) for the rationale.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import platform
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from agentcam import __version__
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="agentcam",
|
|
31
|
+
description=(
|
|
32
|
+
"Local-first CLI wrapper that records what your AI coding agent "
|
|
33
|
+
"changed in your repo."
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
sub = parser.add_subparsers(dest="cmd", required=True, metavar="COMMAND")
|
|
37
|
+
|
|
38
|
+
sub.add_parser("version", help="Print agentcam version and exit.")
|
|
39
|
+
|
|
40
|
+
run = sub.add_parser(
|
|
41
|
+
"run",
|
|
42
|
+
help="Wrap a command and record the agent run.",
|
|
43
|
+
description=(
|
|
44
|
+
"Wraps an argv-style command. Use `bash -lc \"...\"`, "
|
|
45
|
+
"`pwsh -Command \"...\"`, or `cmd /c \"...\"` for shell features "
|
|
46
|
+
"(pipes, redirects, variable expansion)."
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
run.add_argument(
|
|
50
|
+
"--name",
|
|
51
|
+
default=None,
|
|
52
|
+
help="Slug included in the run id (e.g. 'claude-fix-login').",
|
|
53
|
+
)
|
|
54
|
+
run.add_argument(
|
|
55
|
+
"argv",
|
|
56
|
+
nargs=argparse.REMAINDER,
|
|
57
|
+
help="The command to run, after a `--` separator.",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _strip_leading_dashdash(argv: list[str]) -> list[str]:
|
|
64
|
+
"""argparse.REMAINDER keeps a leading `--`; strip it for cleanliness."""
|
|
65
|
+
if argv and argv[0] == "--":
|
|
66
|
+
return argv[1:]
|
|
67
|
+
return argv
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv: list[str] | None = None) -> int:
|
|
71
|
+
parser = build_parser()
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
|
|
74
|
+
if args.cmd == "version":
|
|
75
|
+
print(f"agentcam {__version__}")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
if args.cmd == "run":
|
|
79
|
+
return _run_command(args)
|
|
80
|
+
|
|
81
|
+
parser.error(f"unknown subcommand: {args.cmd}")
|
|
82
|
+
return 2 # unreachable; parser.error exits
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# `agentcam run` orchestrator
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def _run_command(args) -> int:
|
|
90
|
+
# Imports are local so `agentcam version` doesn't pay for them at startup.
|
|
91
|
+
from agentcam.git_state import (
|
|
92
|
+
NotAGitRepoError,
|
|
93
|
+
collect_git_state,
|
|
94
|
+
is_working_tree_dirty,
|
|
95
|
+
resolve_git_dir,
|
|
96
|
+
resolve_git_root,
|
|
97
|
+
)
|
|
98
|
+
from agentcam.models import RunManifest
|
|
99
|
+
from agentcam.paths import RunIdCollisionError, create_run_dir
|
|
100
|
+
from agentcam.redaction import StreamingRedactor, redact_argv
|
|
101
|
+
from agentcam.report import render_report, write_manifest
|
|
102
|
+
from agentcam.runner import CommandNotFoundError, run_wrapped
|
|
103
|
+
from agentcam.scanner import scan_output, scan_paths
|
|
104
|
+
|
|
105
|
+
run_argv = _strip_leading_dashdash(args.argv or [])
|
|
106
|
+
if not run_argv:
|
|
107
|
+
print(
|
|
108
|
+
"agentcam run: no command provided. "
|
|
109
|
+
"Usage: agentcam run -- <command...>",
|
|
110
|
+
file=sys.stderr,
|
|
111
|
+
)
|
|
112
|
+
return 2
|
|
113
|
+
|
|
114
|
+
cwd = Path.cwd()
|
|
115
|
+
|
|
116
|
+
# 1) Confirm we're in a git repo and resolve git dir.
|
|
117
|
+
try:
|
|
118
|
+
git_dir = resolve_git_dir(cwd)
|
|
119
|
+
git_root = resolve_git_root(cwd)
|
|
120
|
+
except NotAGitRepoError:
|
|
121
|
+
print(
|
|
122
|
+
"agentcam: not in a git repository. "
|
|
123
|
+
"Initialize one with 'git init' first.",
|
|
124
|
+
file=sys.stderr,
|
|
125
|
+
)
|
|
126
|
+
return 2
|
|
127
|
+
except Exception as e: # noqa: BLE001
|
|
128
|
+
print(f"agentcam: git error: {e}", file=sys.stderr)
|
|
129
|
+
return 2
|
|
130
|
+
|
|
131
|
+
# 2) Collect pre-run git state.
|
|
132
|
+
try:
|
|
133
|
+
state_before = collect_git_state(cwd, is_after=False)
|
|
134
|
+
except NotAGitRepoError:
|
|
135
|
+
print(
|
|
136
|
+
"agentcam: not in a git repository. "
|
|
137
|
+
"Initialize one with 'git init' first.",
|
|
138
|
+
file=sys.stderr,
|
|
139
|
+
)
|
|
140
|
+
return 2
|
|
141
|
+
pre_run_dirty = is_working_tree_dirty(state_before)
|
|
142
|
+
|
|
143
|
+
# 3) Create the run directory under <git_dir>/agentcam/runs/<run_id>/.
|
|
144
|
+
started_at = datetime.now(timezone.utc).astimezone()
|
|
145
|
+
try:
|
|
146
|
+
run_id, run_paths = create_run_dir(
|
|
147
|
+
git_dir, started_at, name=args.name
|
|
148
|
+
)
|
|
149
|
+
except RunIdCollisionError as e:
|
|
150
|
+
print(f"agentcam: {e}", file=sys.stderr)
|
|
151
|
+
return 2
|
|
152
|
+
|
|
153
|
+
# 4) Run the wrapped subprocess with threads-based tee.
|
|
154
|
+
try:
|
|
155
|
+
run_result = run_wrapped(
|
|
156
|
+
run_argv,
|
|
157
|
+
cwd=cwd,
|
|
158
|
+
stdout_raw_path=Path(run_paths.stdout_raw),
|
|
159
|
+
stderr_raw_path=Path(run_paths.stderr_raw),
|
|
160
|
+
)
|
|
161
|
+
except CommandNotFoundError as e:
|
|
162
|
+
print(str(e), file=sys.stderr)
|
|
163
|
+
return 2
|
|
164
|
+
|
|
165
|
+
ended_at = datetime.now(timezone.utc).astimezone()
|
|
166
|
+
duration = (ended_at - started_at).total_seconds()
|
|
167
|
+
|
|
168
|
+
# 5) Produce redacted logs from the raw logs.
|
|
169
|
+
_redact_log(Path(run_paths.stdout_raw), Path(run_paths.stdout_redacted))
|
|
170
|
+
_redact_log(Path(run_paths.stderr_raw), Path(run_paths.stderr_redacted))
|
|
171
|
+
|
|
172
|
+
# 6) Collect post-run git state (is_after=True triggers diff --check).
|
|
173
|
+
state_after = collect_git_state(cwd, is_after=True)
|
|
174
|
+
|
|
175
|
+
# 7) Scan paths + raw output for risk flags.
|
|
176
|
+
risk_flags = scan_paths(state_after.changed_files)
|
|
177
|
+
risk_flags.extend(_scan_log(Path(run_paths.stdout_raw), "stdout.log"))
|
|
178
|
+
risk_flags.extend(_scan_log(Path(run_paths.stderr_raw), "stderr.log"))
|
|
179
|
+
|
|
180
|
+
# 8) Assemble the manifest.
|
|
181
|
+
manifest = RunManifest(
|
|
182
|
+
schema_version="0.1",
|
|
183
|
+
run_id=run_id.text,
|
|
184
|
+
started_at=started_at,
|
|
185
|
+
ended_at=ended_at,
|
|
186
|
+
duration_seconds=duration,
|
|
187
|
+
cwd=str(cwd),
|
|
188
|
+
git_root=str(git_root),
|
|
189
|
+
git_dir=str(git_dir),
|
|
190
|
+
branch=state_before.branch,
|
|
191
|
+
is_detached_head=state_before.is_detached_head,
|
|
192
|
+
head_before=state_before.head,
|
|
193
|
+
head_after=state_after.head,
|
|
194
|
+
pre_existing_op=(
|
|
195
|
+
state_before.pre_existing_op or state_after.pre_existing_op
|
|
196
|
+
),
|
|
197
|
+
pre_run_dirty=pre_run_dirty,
|
|
198
|
+
command_argv_raw=list(run_argv),
|
|
199
|
+
command_argv_redacted=redact_argv(list(run_argv)),
|
|
200
|
+
exit_detail=run_result.exit_detail,
|
|
201
|
+
shell_used=run_result.shell_used,
|
|
202
|
+
terminal_forward_degraded=run_result.terminal_forward_degraded,
|
|
203
|
+
platform=platform.system().lower(),
|
|
204
|
+
agentcam_version=__version__,
|
|
205
|
+
paths=run_paths,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# 9) Write report + manifest.
|
|
209
|
+
Path(run_paths.report_md).write_text(
|
|
210
|
+
render_report(manifest, state_before, state_after, risk_flags),
|
|
211
|
+
encoding="utf-8",
|
|
212
|
+
)
|
|
213
|
+
write_manifest(manifest, Path(run_paths.manifest_json))
|
|
214
|
+
|
|
215
|
+
# 10) Tell the user where to find the report (stderr so it doesn't pollute
|
|
216
|
+
# programmatic stdout consumers).
|
|
217
|
+
print(
|
|
218
|
+
f"\nagentcam: run report at {run_paths.report_md}",
|
|
219
|
+
file=sys.stderr,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# 11) Return the wrapper exit code (0 if subprocess succeeded, else 1).
|
|
223
|
+
return run_result.exit_detail.wrapper_exit
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _redact_log(raw_path: Path, redacted_path: Path) -> None:
|
|
227
|
+
"""Stream raw_path through StreamingRedactor into redacted_path."""
|
|
228
|
+
from agentcam.redaction import StreamingRedactor
|
|
229
|
+
|
|
230
|
+
with raw_path.open("rb") as in_fp, redacted_path.open("wb") as out_fp:
|
|
231
|
+
r = StreamingRedactor(out_fp)
|
|
232
|
+
while True:
|
|
233
|
+
chunk = in_fp.read(4096)
|
|
234
|
+
if not chunk:
|
|
235
|
+
break
|
|
236
|
+
r.feed(chunk)
|
|
237
|
+
r.close()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _scan_log(raw_path: Path, label: str):
|
|
241
|
+
"""Scan a raw log for output-pattern risk flags."""
|
|
242
|
+
from agentcam.scanner import scan_output
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
text = raw_path.read_bytes().decode("utf-8", errors="replace")
|
|
246
|
+
except OSError:
|
|
247
|
+
text = ""
|
|
248
|
+
return scan_output(text, stream_label=label)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
sys.exit(main())
|
agentcam/git_state.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Git state collection (before and after the wrapped command).
|
|
2
|
+
|
|
3
|
+
Uses ``git status --porcelain=v1 -z`` as the primary source of truth, with
|
|
4
|
+
``git diff [--cached] --stat / --name-status / --check`` for display in the
|
|
5
|
+
report. See plan section 4.
|
|
6
|
+
|
|
7
|
+
``git_dir`` is resolved via ``git rev-parse --git-dir`` so worktree and
|
|
8
|
+
submodule gitlink cases (where ``<repo>/.git`` is a file, not a directory)
|
|
9
|
+
work correctly. See plan section 1.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from agentcam.models import ChangedFile, ChangeStatus, GitState
|
|
17
|
+
|
|
18
|
+
# Order matters: the first matching marker wins. ``rebase-merge`` and
|
|
19
|
+
# ``rebase-apply`` are checked before ``REVERT_HEAD`` etc.
|
|
20
|
+
_PRE_EXISTING_OP_MARKERS: tuple[tuple[str, str], ...] = (
|
|
21
|
+
("MERGE_HEAD", "merge"),
|
|
22
|
+
("rebase-merge", "rebase"),
|
|
23
|
+
("rebase-apply", "rebase"),
|
|
24
|
+
("CHERRY_PICK_HEAD", "cherry-pick"),
|
|
25
|
+
("REVERT_HEAD", "revert"),
|
|
26
|
+
("BISECT_LOG", "bisect"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotAGitRepoError(RuntimeError):
|
|
31
|
+
"""Raised when the cwd is not inside a git repository."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_git_repo(cwd: Path) -> bool:
|
|
35
|
+
return _git(cwd, "rev-parse", "--git-dir", check=False).returncode == 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_git_dir(cwd: Path) -> Path:
|
|
39
|
+
"""Absolute path of the real git directory.
|
|
40
|
+
|
|
41
|
+
``git rev-parse --git-dir`` resolves worktree / submodule gitlink files
|
|
42
|
+
for us, so we never have to read or parse ``<repo>/.git`` ourselves.
|
|
43
|
+
"""
|
|
44
|
+
text = _git_text(cwd, "rev-parse", "--git-dir")
|
|
45
|
+
p = Path(text)
|
|
46
|
+
if not p.is_absolute():
|
|
47
|
+
p = (cwd / p).resolve()
|
|
48
|
+
return p
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_git_root(cwd: Path) -> Path:
|
|
52
|
+
"""Absolute path of the working tree root."""
|
|
53
|
+
return Path(_git_text(cwd, "rev-parse", "--show-toplevel"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def detect_pre_existing_op(git_dir: Path) -> str | None:
|
|
57
|
+
"""Return operation name (merge / rebase / cherry-pick / etc.) or None."""
|
|
58
|
+
for filename, op in _PRE_EXISTING_OP_MARKERS:
|
|
59
|
+
if (git_dir / filename).exists():
|
|
60
|
+
return op
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def collect_git_state(cwd: Path, *, is_after: bool = False) -> GitState:
|
|
65
|
+
"""Snapshot git state. ``is_after=True`` also runs ``git diff --check``."""
|
|
66
|
+
if not is_git_repo(cwd):
|
|
67
|
+
raise NotAGitRepoError(
|
|
68
|
+
"Not in a git repository. Initialize one with 'git init' first."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
git_dir = resolve_git_dir(cwd)
|
|
72
|
+
|
|
73
|
+
head = _safe_head(cwd)
|
|
74
|
+
branch_raw = _git_text(cwd, "branch", "--show-current")
|
|
75
|
+
branch = branch_raw or None
|
|
76
|
+
is_detached = head is not None and not branch
|
|
77
|
+
|
|
78
|
+
porcelain_raw = _git(cwd, "status", "--porcelain=v1", "-z").stdout
|
|
79
|
+
diff_stat = _git_text(cwd, "diff", "--stat", check=False)
|
|
80
|
+
diff_stat_cached = _git_text(cwd, "diff", "--cached", "--stat", check=False)
|
|
81
|
+
diff_name_status = _git_text(cwd, "diff", "--name-status", check=False)
|
|
82
|
+
diff_name_status_cached = _git_text(
|
|
83
|
+
cwd, "diff", "--cached", "--name-status", check=False
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
diff_check = ""
|
|
87
|
+
diff_check_cached = ""
|
|
88
|
+
if is_after:
|
|
89
|
+
diff_check = _git_text(cwd, "diff", "--check", check=False)
|
|
90
|
+
diff_check_cached = _git_text(
|
|
91
|
+
cwd, "diff", "--cached", "--check", check=False
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
pre_existing_op = detect_pre_existing_op(git_dir)
|
|
95
|
+
changed_files = parse_porcelain_v1z(porcelain_raw)
|
|
96
|
+
|
|
97
|
+
return GitState(
|
|
98
|
+
head=head,
|
|
99
|
+
branch=branch,
|
|
100
|
+
is_detached_head=is_detached,
|
|
101
|
+
porcelain_raw=porcelain_raw,
|
|
102
|
+
diff_stat=diff_stat,
|
|
103
|
+
diff_stat_cached=diff_stat_cached,
|
|
104
|
+
diff_name_status=diff_name_status,
|
|
105
|
+
diff_name_status_cached=diff_name_status_cached,
|
|
106
|
+
diff_check=diff_check,
|
|
107
|
+
diff_check_cached=diff_check_cached,
|
|
108
|
+
pre_existing_op=pre_existing_op,
|
|
109
|
+
changed_files=changed_files,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_working_tree_dirty(state: GitState) -> bool:
|
|
114
|
+
"""True if there are any staged, unstaged, or untracked changes."""
|
|
115
|
+
return bool(state.changed_files)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Porcelain v1 -z parser
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def parse_porcelain_v1z(data: bytes) -> list[ChangedFile]:
|
|
123
|
+
"""Parse ``git status --porcelain=v1 -z`` output.
|
|
124
|
+
|
|
125
|
+
Each entry is ``XY<space><path>\\x00``. R/C (rename / copy) entries take
|
|
126
|
+
two NUL-separated fields: ``XY<space><new>\\x00<old>\\x00``.
|
|
127
|
+
"""
|
|
128
|
+
if not data:
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
tokens = data.split(b"\x00")
|
|
132
|
+
results: list[ChangedFile] = []
|
|
133
|
+
i = 0
|
|
134
|
+
while i < len(tokens):
|
|
135
|
+
tok = tokens[i]
|
|
136
|
+
if not tok:
|
|
137
|
+
i += 1
|
|
138
|
+
continue
|
|
139
|
+
if len(tok) < 3:
|
|
140
|
+
# Malformed entry; skip defensively rather than crash.
|
|
141
|
+
i += 1
|
|
142
|
+
continue
|
|
143
|
+
x = chr(tok[0])
|
|
144
|
+
y = chr(tok[1])
|
|
145
|
+
# tok[2] is the separator (typically a space). Path bytes start at 3.
|
|
146
|
+
path = tok[3:].decode("utf-8", errors="replace")
|
|
147
|
+
rename_from: str | None = None
|
|
148
|
+
if x in ("R", "C") or y in ("R", "C"):
|
|
149
|
+
i += 1
|
|
150
|
+
if i < len(tokens):
|
|
151
|
+
rename_from = tokens[i].decode("utf-8", errors="replace")
|
|
152
|
+
status = _classify_status(x, y)
|
|
153
|
+
results.append(
|
|
154
|
+
ChangedFile(path=path, status=status, rename_from=rename_from)
|
|
155
|
+
)
|
|
156
|
+
i += 1
|
|
157
|
+
return results
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _classify_status(x: str, y: str) -> ChangeStatus:
|
|
161
|
+
xy = x + y
|
|
162
|
+
if xy == "??":
|
|
163
|
+
return "untracked"
|
|
164
|
+
if x == "U" or y == "U" or xy in ("AA", "DD"):
|
|
165
|
+
return "unmerged"
|
|
166
|
+
if x in ("R", "C") or y in ("R", "C"):
|
|
167
|
+
return "renamed"
|
|
168
|
+
if x != " " and x not in ("?", "!"):
|
|
169
|
+
if x == "D":
|
|
170
|
+
return "staged_deleted"
|
|
171
|
+
return "staged"
|
|
172
|
+
if y == "M":
|
|
173
|
+
return "unstaged_modified"
|
|
174
|
+
if y == "D":
|
|
175
|
+
return "unstaged_deleted"
|
|
176
|
+
# Defensive fallback.
|
|
177
|
+
return "unstaged_modified"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Low-level git helpers
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def _git(
|
|
185
|
+
cwd: Path,
|
|
186
|
+
*args: str,
|
|
187
|
+
check: bool = True,
|
|
188
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
189
|
+
return subprocess.run(
|
|
190
|
+
["git", *args],
|
|
191
|
+
cwd=cwd,
|
|
192
|
+
capture_output=True,
|
|
193
|
+
check=check,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _git_text(cwd: Path, *args: str, check: bool = True) -> str:
|
|
198
|
+
res = _git(cwd, *args, check=check)
|
|
199
|
+
return res.stdout.decode("utf-8", errors="replace").rstrip("\n")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _safe_head(cwd: Path) -> str | None:
|
|
203
|
+
"""Return HEAD SHA, or None if HEAD does not resolve (empty repo)."""
|
|
204
|
+
res = _git(cwd, "rev-parse", "HEAD", check=False)
|
|
205
|
+
if res.returncode != 0:
|
|
206
|
+
return None
|
|
207
|
+
return res.stdout.decode("utf-8", errors="replace").strip() or None
|
agentcam/models.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Data structures used across agentcam modules.
|
|
2
|
+
|
|
3
|
+
Plain dataclasses (no Pydantic) to keep dependencies to the standard library
|
|
4
|
+
only. JSON serialization is done by ``report.py`` and the manifest writer, not
|
|
5
|
+
by these classes.
|
|
6
|
+
|
|
7
|
+
See ``docs/design.md`` (forthcoming) for the schema design rationale.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
# Two-level risk taxonomy (v0.1). LOW was dropped — see design.md decision 8.
|
|
16
|
+
RiskLevel = Literal["HIGH", "MEDIUM"]
|
|
17
|
+
|
|
18
|
+
ChangeStatus = Literal[
|
|
19
|
+
"staged",
|
|
20
|
+
"staged_deleted",
|
|
21
|
+
"unstaged_modified",
|
|
22
|
+
"unstaged_deleted",
|
|
23
|
+
"untracked",
|
|
24
|
+
"renamed",
|
|
25
|
+
"unmerged",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Source of the exit interpretation in manifest.exit_detail.
|
|
29
|
+
InterpretationSource = Literal[
|
|
30
|
+
"known_table",
|
|
31
|
+
"signal",
|
|
32
|
+
"user_defined",
|
|
33
|
+
"unknown",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class RunId:
|
|
39
|
+
"""Identifier for an agentcam run.
|
|
40
|
+
|
|
41
|
+
Format: ``YYYYMMDD-HHMMSS-<ms>-<slug>[-<hex>]``
|
|
42
|
+
where ``<hex>`` is a 4-char collision-avoidance suffix added on retry.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
text: str
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
return self.text
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class RunPaths:
|
|
53
|
+
"""Filesystem layout for a single agentcam run.
|
|
54
|
+
|
|
55
|
+
All paths live under ``<git_dir>/agentcam/runs/<run_id>/``. ``git_dir`` is
|
|
56
|
+
the *real* git dir as resolved by ``git rev-parse --git-dir`` (handles
|
|
57
|
+
worktrees and submodule gitlinks correctly).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
run_dir: str
|
|
61
|
+
manifest_json: str
|
|
62
|
+
report_md: str
|
|
63
|
+
stdout_raw: str
|
|
64
|
+
stderr_raw: str
|
|
65
|
+
stdout_redacted: str
|
|
66
|
+
stderr_redacted: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ChangedFile:
|
|
71
|
+
"""A file modified between pre-run and post-run git state."""
|
|
72
|
+
|
|
73
|
+
path: str
|
|
74
|
+
status: ChangeStatus
|
|
75
|
+
rename_from: str | None = None
|
|
76
|
+
secret_like_name: bool = False # True if filename matches secret-like pattern
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class RiskFlag:
|
|
81
|
+
"""A single risk observation. evidence must not contain raw secrets."""
|
|
82
|
+
|
|
83
|
+
level: RiskLevel
|
|
84
|
+
rule: str
|
|
85
|
+
evidence: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class GitState:
|
|
90
|
+
"""Snapshot of git state (before or after the wrapped command)."""
|
|
91
|
+
|
|
92
|
+
head: str | None
|
|
93
|
+
branch: str | None
|
|
94
|
+
is_detached_head: bool
|
|
95
|
+
porcelain_raw: bytes
|
|
96
|
+
diff_stat: str
|
|
97
|
+
diff_stat_cached: str
|
|
98
|
+
diff_name_status: str
|
|
99
|
+
diff_name_status_cached: str
|
|
100
|
+
diff_check: str = ""
|
|
101
|
+
diff_check_cached: str = ""
|
|
102
|
+
pre_existing_op: str | None = None # 'merge' | 'rebase' | 'cherry-pick' | ...
|
|
103
|
+
changed_files: list[ChangedFile] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class ExitDetail:
|
|
108
|
+
"""Exit status detail, written to manifest and Exit Code Detail section.
|
|
109
|
+
|
|
110
|
+
See plan section 9 (Exit code pass-through).
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
wrapper_exit: int # 0 or 1
|
|
114
|
+
raw_returncode: int
|
|
115
|
+
raw_returncode_hex: str | None
|
|
116
|
+
platform: str
|
|
117
|
+
interpretation: str
|
|
118
|
+
interpretation_source: InterpretationSource
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class RunManifest:
|
|
123
|
+
"""Top-level run manifest, serialized to ``manifest.json``."""
|
|
124
|
+
|
|
125
|
+
schema_version: str
|
|
126
|
+
run_id: str
|
|
127
|
+
started_at: datetime
|
|
128
|
+
ended_at: datetime | None
|
|
129
|
+
duration_seconds: float | None
|
|
130
|
+
cwd: str
|
|
131
|
+
git_root: str
|
|
132
|
+
git_dir: str
|
|
133
|
+
branch: str | None
|
|
134
|
+
is_detached_head: bool
|
|
135
|
+
head_before: str | None
|
|
136
|
+
head_after: str | None
|
|
137
|
+
pre_existing_op: str | None
|
|
138
|
+
pre_run_dirty: bool
|
|
139
|
+
command_argv_raw: list[str]
|
|
140
|
+
command_argv_redacted: list[str]
|
|
141
|
+
exit_detail: ExitDetail | None
|
|
142
|
+
shell_used: bool
|
|
143
|
+
terminal_forward_degraded: bool
|
|
144
|
+
platform: str
|
|
145
|
+
agentcam_version: str
|
|
146
|
+
paths: RunPaths
|