agentdir-cli 0.7.5__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.
agentdir/review.py ADDED
@@ -0,0 +1,288 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .index import rebuild_index
8
+ from .query import query_messages
9
+ from .sessions import require_current_session
10
+
11
+ EVIDENCE_FAMILIES = ("test", "lint", "typecheck", "build", "doctor", "release", "diagnostic")
12
+
13
+ _FAMILY_KEYWORDS: dict[str, tuple[str, ...]] = {
14
+ "test": (
15
+ "pytest",
16
+ "unittest",
17
+ "test",
18
+ "tests",
19
+ "npm test",
20
+ "pnpm test",
21
+ "yarn test",
22
+ "go test",
23
+ "cargo test",
24
+ "rspec",
25
+ ),
26
+ "lint": ("lint", "eslint", "ruff", "flake8", "prettier", "clippy", "golangci-lint"),
27
+ "typecheck": ("typecheck", "type-check", "tsc", "mypy", "pyright", "sorbet", "staticcheck"),
28
+ "build": ("build", "npm run build", "pnpm build", "yarn build", "cargo build", "go build", "make"),
29
+ "doctor": ("doctor", "health", "diagnose", "diagnostic"),
30
+ "release": ("release", "publish", "pack", "version", "tag", "twine", "npm publish"),
31
+ }
32
+
33
+ _DIAGNOSTIC_EVENTS = {"file.diff"}
34
+
35
+
36
+ def resolve_review_session(root: str | Path, session_id: str | None) -> str:
37
+ if session_id:
38
+ return session_id
39
+ return require_current_session(root).session_id
40
+
41
+
42
+ def ensure_index(root: str | Path) -> None:
43
+ rebuild_index(root)
44
+
45
+
46
+ def summarize_session(root: str | Path, session_id: str | None = None, *, rebuild: bool = True) -> dict[str, Any]:
47
+ resolved = resolve_review_session(root, session_id)
48
+ if rebuild:
49
+ ensure_index(root)
50
+ rows = query_messages(root, session_id=resolved, limit=10_000)
51
+ counts = Counter(row.get("event_type") or "unknown" for row in rows)
52
+ tool_results = [row for row in rows if row.get("event_type") == "tool.result"]
53
+ failed_tools = [row for row in tool_results if row.get("tool_exit_code") not in (None, 0)]
54
+ return {
55
+ "session_id": resolved,
56
+ "events": len(rows),
57
+ "event_counts": dict(sorted(counts.items())),
58
+ "tool_results": len(tool_results),
59
+ "failed_tools": len(failed_tools),
60
+ "first_event": rows[0]["date_header"] if rows else None,
61
+ "last_event": rows[-1]["date_header"] if rows else None,
62
+ }
63
+
64
+
65
+ def format_summary(summary: dict[str, Any]) -> str:
66
+ lines = [
67
+ f"session={summary['session_id']}",
68
+ f"events={summary['events']}",
69
+ f"tool_results={summary['tool_results']}",
70
+ f"failed_tools={summary['failed_tools']}",
71
+ ]
72
+ for event_type, count in summary["event_counts"].items():
73
+ lines.append(f"{event_type}={count}")
74
+ return "\n".join(lines)
75
+
76
+
77
+ def evidence_rows(root: str | Path, session_id: str | None = None, *, rebuild: bool = True) -> list[dict[str, Any]]:
78
+ resolved = resolve_review_session(root, session_id)
79
+ if rebuild:
80
+ ensure_index(root)
81
+ rows = query_messages(root, session_id=resolved, limit=10_000)
82
+ wanted = {"tool.call", "tool.result", "file.diff"}
83
+ evidence = [
84
+ row
85
+ for row in rows
86
+ if row.get("event_type") in wanted or str(row.get("event_type") or "").startswith("git.hook.")
87
+ ]
88
+ for row in evidence:
89
+ row["family"] = classify_evidence(row)
90
+ row["failed"] = evidence_failed(row)
91
+ return evidence
92
+
93
+
94
+ def classify_evidence(row: dict[str, Any]) -> str:
95
+ event_type = str(row.get("event_type") or "")
96
+ if event_type in _DIAGNOSTIC_EVENTS or event_type.startswith("git.hook."):
97
+ return "diagnostic"
98
+ primary = " ".join(str(row.get(key) or "") for key in ("tool", "subject")).lower()
99
+ for family in EVIDENCE_FAMILIES:
100
+ if family == "diagnostic":
101
+ continue
102
+ if any(keyword in primary for keyword in _FAMILY_KEYWORDS.get(family, ())):
103
+ return family
104
+ haystack = _evidence_search_text(row)
105
+ for family in EVIDENCE_FAMILIES:
106
+ if family == "diagnostic":
107
+ continue
108
+ if any(keyword in haystack for keyword in _FAMILY_KEYWORDS.get(family, ())):
109
+ return family
110
+ return "diagnostic"
111
+
112
+
113
+ def evidence_failed(row: dict[str, Any]) -> bool:
114
+ return row.get("event_type") == "tool.result" and row.get("tool_exit_code") not in (None, 0)
115
+
116
+
117
+ def evidence_ref(row: dict[str, Any]) -> dict[str, Any]:
118
+ return {
119
+ "event_type": row.get("event_type"),
120
+ "family": row.get("family") or classify_evidence(row),
121
+ "tool": row.get("tool"),
122
+ "exit_code": row.get("tool_exit_code"),
123
+ "subject": row.get("subject"),
124
+ "date": row.get("date_header") or row.get("date_utc") or row.get("indexed_at"),
125
+ "path": row.get("file_path"),
126
+ "failed": row.get("failed") if "failed" in row else evidence_failed(row),
127
+ }
128
+
129
+
130
+ def filter_evidence(
131
+ rows: list[dict[str, Any]],
132
+ *,
133
+ family: str | None = None,
134
+ failed: bool = False,
135
+ ) -> list[dict[str, Any]]:
136
+ if family and family not in EVIDENCE_FAMILIES:
137
+ raise ValueError(f"unknown evidence family: {family}")
138
+ filtered: list[dict[str, Any]] = []
139
+ for row in rows:
140
+ row_family = row.get("family") or classify_evidence(row)
141
+ row_failed = row.get("failed") if "failed" in row else evidence_failed(row)
142
+ if family and row_family != family:
143
+ continue
144
+ if failed and not row_failed:
145
+ continue
146
+ copied = dict(row)
147
+ copied["family"] = row_family
148
+ copied["failed"] = row_failed
149
+ filtered.append(copied)
150
+ return filtered
151
+
152
+
153
+ def evidence_brief(rows: list[dict[str, Any]], *, family: str | None = None, failed: bool = False) -> dict[str, Any]:
154
+ filtered = filter_evidence(rows, family=family, failed=failed)
155
+ grouped: dict[str, list[dict[str, Any]]] = {name: [] for name in EVIDENCE_FAMILIES}
156
+ for row in filtered:
157
+ grouped[str(row.get("family") or classify_evidence(row))].append(row)
158
+ failed_rows = [row for row in filtered if row.get("failed") or evidence_failed(row)]
159
+ families: list[dict[str, Any]] = []
160
+ latest_by_family: dict[str, dict[str, Any]] = {}
161
+ for name in EVIDENCE_FAMILIES:
162
+ items = grouped[name]
163
+ if not items:
164
+ continue
165
+ latest = evidence_ref(items[-1])
166
+ latest_by_family[name] = latest
167
+ families.append(
168
+ {
169
+ "family": name,
170
+ "total": len(items),
171
+ "failed": sum(1 for row in items if row.get("failed") or evidence_failed(row)),
172
+ "latest": latest,
173
+ }
174
+ )
175
+ return {
176
+ "families": families,
177
+ "latest_by_family": latest_by_family,
178
+ "failed_evidence": [evidence_ref(row) for row in failed_rows],
179
+ "counts": {
180
+ "total": len(filtered),
181
+ "failed": len(failed_rows),
182
+ "families": len(families),
183
+ },
184
+ }
185
+
186
+
187
+ def format_evidence_brief(brief: dict[str, Any]) -> str:
188
+ lines: list[str] = []
189
+ failed = brief.get("failed_evidence") or []
190
+ if failed:
191
+ lines.append("Failed evidence:")
192
+ for item in failed:
193
+ lines.append(_format_evidence_ref(item))
194
+ families = brief.get("families") or []
195
+ if families:
196
+ if lines:
197
+ lines.append("")
198
+ lines.append("Latest evidence by family:")
199
+ for family in families:
200
+ lines.append(_format_evidence_ref(family["latest"], prefix=f"{family['family']}: "))
201
+ if not lines:
202
+ return "No evidence captured."
203
+ return "\n".join(lines)
204
+
205
+
206
+ def timeline_rows(
207
+ root: str | Path,
208
+ session_id: str | None = None,
209
+ *,
210
+ limit: int = 100,
211
+ rebuild: bool = True,
212
+ ) -> list[dict[str, Any]]:
213
+ resolved = resolve_review_session(root, session_id)
214
+ if rebuild:
215
+ ensure_index(root)
216
+ rows = query_messages(root, session_id=resolved, limit=limit)
217
+ return [timeline_ref(row) for row in rows]
218
+
219
+
220
+ def timeline_ref(row: dict[str, Any]) -> dict[str, Any]:
221
+ event_type = row.get("event_type")
222
+ item = {
223
+ "date": row.get("date_header") or row.get("date_utc") or row.get("indexed_at"),
224
+ "event_type": event_type,
225
+ "subject": row.get("subject"),
226
+ "tool": row.get("tool"),
227
+ "exit_code": row.get("tool_exit_code"),
228
+ "actor": row.get("from_actor"),
229
+ "path": row.get("file_path"),
230
+ }
231
+ if event_type in {"tool.call", "tool.result", "file.diff"} or str(event_type or "").startswith("git.hook."):
232
+ item["family"] = classify_evidence(row)
233
+ item["failed"] = evidence_failed(row)
234
+ return item
235
+
236
+
237
+ def format_timeline(rows: list[dict[str, Any]]) -> str:
238
+ lines: list[str] = []
239
+ for row in rows:
240
+ date = row.get("date") or "unknown-date"
241
+ event_type = row.get("event_type") or "unknown"
242
+ subject = row.get("subject") or ""
243
+ tool = row.get("tool") or ""
244
+ exit_code = row.get("exit_code")
245
+ family = row.get("family") or ""
246
+ detail_parts = []
247
+ if tool:
248
+ detail_parts.append(str(tool))
249
+ if family:
250
+ detail_parts.append(f"family={family}")
251
+ if exit_code is not None:
252
+ detail_parts.append(f"exit={exit_code}")
253
+ detail = " ".join(detail_parts)
254
+ lines.append(f"{date} {event_type} {detail} {subject}".rstrip())
255
+ return "\n".join(lines)
256
+
257
+
258
+ def format_evidence(rows: list[dict[str, Any]]) -> str:
259
+ lines: list[str] = []
260
+ for row in rows:
261
+ date = row.get("date_header") or row.get("indexed_at") or "unknown-date"
262
+ event_type = row.get("event_type") or "unknown"
263
+ tool = row.get("tool") or ""
264
+ exit_code = row.get("tool_exit_code")
265
+ subject = row.get("subject") or ""
266
+ file_path = row.get("file_path") or ""
267
+ family = row.get("family") or classify_evidence(row)
268
+ detail = f" exit={exit_code}" if exit_code is not None else ""
269
+ family_detail = f" family={family}" if family else ""
270
+ lines.append(f"{date} {event_type} {tool}{detail}{family_detail} {subject} [{file_path}]")
271
+ return "\n".join(lines)
272
+
273
+
274
+ def _evidence_search_text(row: dict[str, Any]) -> str:
275
+ return " ".join(
276
+ str(row.get(key) or "")
277
+ for key in ("tool", "subject", "body_text", "event_type", "message_id")
278
+ ).lower()
279
+
280
+
281
+ def _format_evidence_ref(item: dict[str, Any], *, prefix: str = "- ") -> str:
282
+ tool = item.get("tool") or ""
283
+ exit_code = item.get("exit_code")
284
+ subject = item.get("subject") or ""
285
+ date = item.get("date") or "unknown-date"
286
+ exit_text = f" exit={exit_code}" if exit_code is not None else ""
287
+ failed = " failed=true" if item.get("failed") else ""
288
+ return f"{prefix}{date} {item.get('event_type') or 'unknown'} {tool}{exit_text}{failed} {subject}".rstrip()
agentdir/secrets.py ADDED
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import asdict, dataclass
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .envelope import envelope_bytes, parse_envelope
10
+ from .index import rebuild_index
11
+ from .mailbox import fsync_directory, iter_records
12
+ from .redaction import redact_text, secret_labels
13
+ from .store import is_mailbox, require_root
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SecretFinding:
18
+ path: str
19
+ mailbox: str
20
+ message_id: str | None
21
+ event_type: str | None
22
+ subject: str | None
23
+ labels: tuple[str, ...]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SecretRedaction:
28
+ path: str
29
+ labels: tuple[str, ...]
30
+ replacements: int
31
+ applied: bool
32
+
33
+
34
+ def scan_secret_records(root: str | Path) -> list[SecretFinding]:
35
+ paths = require_root(root)
36
+ findings: list[SecretFinding] = []
37
+ for mailbox in _all_mailboxes(paths.root):
38
+ for record in iter_records(mailbox):
39
+ parsed = parse_envelope(record.path)
40
+ labels = secret_labels(parsed.body_text)
41
+ if not labels:
42
+ continue
43
+ findings.append(
44
+ SecretFinding(
45
+ path=str(record.path.relative_to(paths.root)),
46
+ mailbox=str(mailbox.relative_to(paths.root)),
47
+ message_id=parsed.message_id,
48
+ event_type=parsed.message.get("X-AgentDir-Event-Type"),
49
+ subject=parsed.message.get("Subject"),
50
+ labels=labels,
51
+ )
52
+ )
53
+ return findings
54
+
55
+
56
+ def redact_secret_records(root: str | Path, *, apply: bool = False) -> dict[str, Any]:
57
+ paths = require_root(root)
58
+ redactions: list[SecretRedaction] = []
59
+ for mailbox in _all_mailboxes(paths.root):
60
+ for record in iter_records(mailbox):
61
+ parsed = parse_envelope(record.path)
62
+ result = redact_text(parsed.body_text)
63
+ if result.replacements == 0:
64
+ continue
65
+ if apply:
66
+ _rewrite_body(record.path, parsed.message, result.text, result.labels, result.replacements)
67
+ redactions.append(
68
+ SecretRedaction(
69
+ path=str(record.path.relative_to(paths.root)),
70
+ labels=result.labels,
71
+ replacements=result.replacements,
72
+ applied=apply,
73
+ )
74
+ )
75
+ if apply and redactions:
76
+ rebuild_index(paths.root)
77
+ return {
78
+ "applied": apply,
79
+ "count": len(redactions),
80
+ "redactions": [asdict(item) for item in redactions],
81
+ "index_rebuilt": bool(apply and redactions),
82
+ }
83
+
84
+
85
+ def format_secret_findings(findings: list[SecretFinding]) -> str:
86
+ if not findings:
87
+ return "No secret-like envelope bodies found."
88
+ lines = [f"Found {len(findings)} secret-like envelope body/bodies:"]
89
+ for finding in findings:
90
+ labels = ",".join(finding.labels)
91
+ event = finding.event_type or "unknown"
92
+ lines.append(f"- {finding.path} [{event}] labels={labels}")
93
+ lines.append("No body text was printed.")
94
+ return "\n".join(lines)
95
+
96
+
97
+ def format_secret_redaction(result: dict[str, Any]) -> str:
98
+ count = int(result["count"])
99
+ if count == 0:
100
+ return "No secret-like envelope bodies found."
101
+ action = "Redacted" if result["applied"] else "Would redact"
102
+ lines = [f"{action} {count} secret-like envelope body/bodies:"]
103
+ for item in result["redactions"]:
104
+ labels = ",".join(item["labels"])
105
+ lines.append(f"- {item['path']} labels={labels} replacements={item['replacements']}")
106
+ if result["applied"]:
107
+ lines.append("Rebuilt the derived SQLite index after redaction.")
108
+ else:
109
+ lines.append("Dry run only. Re-run with --apply to rewrite bodies and rebuild the index.")
110
+ lines.append("No body text was printed.")
111
+ return "\n".join(lines)
112
+
113
+
114
+ def _all_mailboxes(root: Path) -> list[Path]:
115
+ return sorted(path for path in root.glob("**/Maildir") if is_mailbox(path))
116
+
117
+
118
+ def _rewrite_body(
119
+ path: Path,
120
+ message: Any,
121
+ body: str,
122
+ labels: tuple[str, ...],
123
+ replacements: int,
124
+ ) -> None:
125
+ message.set_content(body, subtype="plain", charset="utf-8")
126
+ _replace_header(message, "X-AgentDir-Secret-Redacted", "true")
127
+ _replace_header(message, "X-AgentDir-Secret-Redacted-At", datetime.now(UTC).isoformat())
128
+ _replace_header(message, "X-AgentDir-Secret-Redaction-Labels", ",".join(labels))
129
+ _replace_header(message, "X-AgentDir-Secret-Redaction-Replacements", str(replacements))
130
+ _atomic_replace(path, envelope_bytes(message))
131
+
132
+
133
+ def _replace_header(message: Any, name: str, value: str) -> None:
134
+ while name in message:
135
+ del message[name]
136
+ message[name] = value
137
+
138
+
139
+ def _atomic_replace(path: Path, data: bytes) -> None:
140
+ temp = path.with_name(f".{path.name}.agentdir-redact-tmp")
141
+ if temp.exists():
142
+ temp.unlink()
143
+ mode = path.stat().st_mode
144
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
145
+ try:
146
+ with os.fdopen(fd, "wb") as handle:
147
+ handle.write(data)
148
+ handle.flush()
149
+ os.fsync(handle.fileno())
150
+ os.chmod(temp, mode)
151
+ os.replace(temp, path)
152
+ fsync_directory(path.parent)
153
+ except Exception:
154
+ try:
155
+ temp.unlink()
156
+ except FileNotFoundError:
157
+ pass
158
+ raise
agentdir/sessions.py ADDED
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from dataclasses import asdict, dataclass
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from .events import emit_event
10
+ from .git import git_head, workspace_name
11
+ from .store import AgentDirError, init_root, paths_for, validate_id
12
+
13
+
14
+ @dataclass
15
+ class SessionState:
16
+ session_id: str
17
+ title: str
18
+ actor: str
19
+ workspace: str
20
+ git_head: str | None
21
+ started_at: str
22
+ status: str = "active"
23
+ ended_at: str | None = None
24
+
25
+
26
+ def now_iso() -> str:
27
+ return datetime.now(UTC).isoformat()
28
+
29
+
30
+ def safe_session_id(value: str) -> str:
31
+ safe = "".join(ch if ch.isalnum() or ch in "._@+-" else "-" for ch in value.strip())
32
+ safe = safe.strip(".-") or "agent-session"
33
+ return validate_id(safe[:128], "session id")
34
+
35
+
36
+ def default_session_id(cwd: str | Path | None = None) -> str:
37
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
38
+ suffix = f"{time.time_ns() % 1_000_000_000:09d}"
39
+ return safe_session_id(f"repo-{workspace_name(cwd)}-{stamp}-{suffix}")
40
+
41
+
42
+ def current_session_path(root: str | Path) -> Path:
43
+ return paths_for(root).state / "current-session.json"
44
+
45
+
46
+ def last_session_path(root: str | Path) -> Path:
47
+ return paths_for(root).state / "last-session.json"
48
+
49
+
50
+ def write_session_state(root: str | Path, state: SessionState) -> None:
51
+ paths = init_root(root)
52
+ paths.state.mkdir(parents=True, exist_ok=True)
53
+ current_session_path(root).write_text(json.dumps(asdict(state), indent=2) + "\n", encoding="utf-8")
54
+
55
+
56
+ def read_current_session(root: str | Path) -> SessionState | None:
57
+ path = current_session_path(root)
58
+ if not path.is_file():
59
+ return None
60
+ payload = json.loads(path.read_text(encoding="utf-8"))
61
+ return SessionState(**payload)
62
+
63
+
64
+ def require_current_session(root: str | Path) -> SessionState:
65
+ state = read_current_session(root)
66
+ if state is None or state.status != "active":
67
+ raise AgentDirError("No active AgentDir session; run: agentdir session ensure")
68
+ return state
69
+
70
+
71
+ def ensure_session(
72
+ root: str | Path,
73
+ session_id: str | None = None,
74
+ *,
75
+ title: str | None = None,
76
+ actor: str = "agent",
77
+ emit_started: bool = True,
78
+ ) -> SessionState:
79
+ if session_id:
80
+ current = read_current_session(root)
81
+ if current and current.session_id == session_id and current.status == "active":
82
+ return current
83
+ return start_session(root, session_id=session_id, title=title, actor=actor, emit_started=emit_started)
84
+ current = read_current_session(root)
85
+ if current and current.status == "active":
86
+ return current
87
+ return start_session(root, title=title or "AgentDir auto session", actor=actor, emit_started=emit_started)
88
+
89
+
90
+ def start_session(
91
+ root: str | Path,
92
+ *,
93
+ session_id: str | None = None,
94
+ title: str | None = None,
95
+ actor: str = "agent",
96
+ note: str | None = None,
97
+ cwd: str | Path | None = None,
98
+ emit_started: bool = True,
99
+ ) -> SessionState:
100
+ resolved_id = safe_session_id(session_id) if session_id else default_session_id(cwd)
101
+ state = SessionState(
102
+ session_id=resolved_id,
103
+ title=title or resolved_id,
104
+ actor=actor,
105
+ workspace=workspace_name(cwd),
106
+ git_head=git_head(cwd),
107
+ started_at=now_iso(),
108
+ )
109
+ write_session_state(root, state)
110
+ if emit_started:
111
+ body = "\n".join(
112
+ [
113
+ f"session_id={state.session_id}",
114
+ f"title={state.title}",
115
+ f"actor={state.actor}",
116
+ f"workspace={state.workspace}",
117
+ f"git_head={state.git_head or ''}",
118
+ f"started_at={state.started_at}",
119
+ "",
120
+ note or "AgentDir session started.",
121
+ ]
122
+ )
123
+ emit_event(
124
+ root,
125
+ session_id=state.session_id,
126
+ event_type="session.started",
127
+ body=body,
128
+ subject=state.title,
129
+ from_actor=actor,
130
+ workspace=state.workspace,
131
+ git_head=state.git_head,
132
+ )
133
+ return state
134
+
135
+
136
+ def end_session(
137
+ root: str | Path,
138
+ *,
139
+ status: str = "completed",
140
+ summary: str | None = None,
141
+ actor: str = "agent",
142
+ ) -> SessionState:
143
+ state = require_current_session(root)
144
+ state.status = status
145
+ state.ended_at = now_iso()
146
+ body = "\n".join(
147
+ [
148
+ f"session_id={state.session_id}",
149
+ f"status={state.status}",
150
+ f"ended_at={state.ended_at}",
151
+ "",
152
+ summary or "AgentDir session ended.",
153
+ ]
154
+ )
155
+ emit_event(
156
+ root,
157
+ session_id=state.session_id,
158
+ event_type="session.ended",
159
+ body=body,
160
+ subject=f"session {status}",
161
+ from_actor=actor,
162
+ workspace=state.workspace,
163
+ git_head=git_head(),
164
+ )
165
+ paths = init_root(root)
166
+ last_session_path(root).write_text(json.dumps(asdict(state), indent=2) + "\n", encoding="utf-8")
167
+ current = current_session_path(root)
168
+ if current.exists():
169
+ current.unlink()
170
+ paths.state.mkdir(parents=True, exist_ok=True)
171
+ return state