Skip to content

Commit 43c734e

Browse files
feat: intent capture via set_intent MCP tool (v0.1.29)
* fix: prune consumed intent events after finalize, validate intent-type enum * feat(intent): add set_intent MCP tool, intent_type field, report rendering - New set_intent MCP tool appends type=intent events to session.jsonl - AgentdiffMetadata gains intent_type: Option<String> field - report.rs renders [type] intent in Review Context and trace details table - context_json_report includes intent_type in JSON output - AGENTS.md managed block instructs agents to call set_intent before committing - 6 new tests (tools/list, set_intent happy path, empty description rejection) * feat(intent): read intent events in prepare-ledger, persist through finalize - read_intent_events() scans session.jsonl for type=intent events since last commit - Intent priority: agent-stated event (session-id matched) > pending context > none - intent_type propagated through pending_ledger.json to finalize-ledger.py - 2 new tests: write_agent_trace persists intent_type and full structured metadata * fix: pre-landing review fixes for intent capture - Fix byte-boundary panic: slice description by chars not bytes (Rust) - Add server-side intent_type enum validation in set_intent() (Rust) - Skip session-id matching when session_id is 'unknown' to prevent cross-session bleed - Separate read-loop from os.replace in remove_consumed_intents to avoid swallowing rename errors * chore: release v0.1.29 — intent capture via MCP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7055fb4 commit 43c734e

10 files changed

Lines changed: 411 additions & 44 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "agentdiff"
3-
version = "0.1.28"
3+
version = "0.1.29"
44
edition = "2024"
55
rust-version = "1.85"
66
description = "Audit and trace autonomous AI code contributions in git repositories"

‎scripts/finalize-ledger.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,43 @@ def remove_if_exists(path: str) -> None:
6363
pass
6464

6565

66+
def remove_consumed_intents(session_path: str) -> None:
67+
"""Strip type=intent events from session.jsonl after they've been committed.
68+
69+
Intent events are one-shot: set_intent is called just before a commit, so
70+
by the time finalize runs they've been consumed. Leaving them causes stale
71+
intent to bleed into the next commit when two commits share the same second.
72+
"""
73+
if not os.path.exists(session_path):
74+
return
75+
kept = []
76+
try:
77+
with open(session_path, "r", encoding="utf-8") as f:
78+
for raw in f:
79+
line = raw.strip()
80+
if not line:
81+
continue
82+
try:
83+
event = json.loads(line)
84+
if isinstance(event, dict) and event.get("type") == "intent":
85+
continue
86+
except Exception:
87+
pass
88+
kept.append(raw if raw.endswith("\n") else raw + "\n")
89+
except (OSError, IOError):
90+
return
91+
tmp = session_path + ".tmp"
92+
try:
93+
with open(tmp, "w", encoding="utf-8") as f:
94+
f.writelines(kept)
95+
os.replace(tmp, session_path)
96+
except (OSError, IOError):
97+
try:
98+
os.remove(tmp)
99+
except OSError:
100+
pass
101+
102+
66103
def sha_already_recorded(traces_path: str, sha: str) -> bool:
67104
"""Skip finalize if this commit already has a trace recorded locally."""
68105
if not os.path.exists(traces_path):
@@ -158,6 +195,8 @@ def write_agent_trace(repo_root: str, pending: dict, sha: str, ts: str) -> Optio
158195
metadata["session_id"] = str(pending["session_id"])
159196
if pending.get("intent"):
160197
metadata["intent"] = str(pending["intent"])
198+
if pending.get("intent_type"):
199+
metadata["intent_type"] = str(pending["intent_type"])
161200
if isinstance(pending.get("files_read"), list) and pending["files_read"]:
162201
metadata["files_read"] = [str(p) for p in pending["files_read"]]
163202
if git_author:
@@ -228,9 +267,12 @@ def main() -> int:
228267
return 1
229268
ts = ts_res.stdout.strip()
230269

270+
session_path = os.path.join(repo_root, ".git", "agentdiff", "session.jsonl")
231271
result = write_agent_trace(repo_root, pending, sha, ts)
232272
remove_if_exists(pending_ledger_path)
233273
remove_if_exists(pending_context_path)
274+
if result is not None:
275+
remove_consumed_intents(session_path)
234276
return 0 if result is not None else 1
235277

236278

‎scripts/prepare-ledger.py‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,41 @@ def read_events_per_file(
198198
return {fp: ev for fp, (_, ev) in best.items()}
199199

200200

201+
def read_intent_events(
202+
path: str,
203+
min_ts: int,
204+
) -> List[dict]:
205+
"""Read intent events from session.jsonl (type=intent, written by set_intent MCP tool).
206+
207+
Returns all matching intent events sorted by timestamp (most recent last).
208+
"""
209+
if not os.path.exists(path):
210+
return []
211+
results = []
212+
try:
213+
with open(path, "r", encoding="utf-8") as f:
214+
for raw in f:
215+
line = raw.strip()
216+
if not line:
217+
continue
218+
try:
219+
event = json.loads(line)
220+
except Exception:
221+
continue
222+
if not isinstance(event, dict):
223+
continue
224+
if event.get("type") != "intent":
225+
continue
226+
event_ts = parse_event_ts(str(event.get("timestamp") or ""))
227+
if event_ts < min_ts:
228+
continue
229+
results.append(event)
230+
except Exception:
231+
pass
232+
results.sort(key=lambda e: parse_event_ts(str(e.get("timestamp") or "")))
233+
return results
234+
235+
201236
def dominant_event(events_by_file: Dict[str, dict], lines_by_file: Dict[str, List[Tuple[int, int]]]) -> dict:
202237
"""Pick the agent/model to use as the top-level record field.
203238
@@ -276,13 +311,17 @@ def main() -> int:
276311
if not isinstance(pending, dict):
277312
pending = {}
278313

314+
min_ts = head_commit_ts(repo_root)
279315
events_by_file = read_events_per_file(
280316
session_log,
281317
files_touched,
282-
head_commit_ts(repo_root),
318+
min_ts,
283319
lines_by_file,
284320
)
285321

322+
# Read agent-stated intent events from session.jsonl (written by set_intent MCP tool)
323+
intent_events = read_intent_events(session_log, min_ts)
324+
286325
# Top-level agent/model/session come from the dominant event (most lines written)
287326
event = dominant_event(events_by_file, lines_by_file) or {}
288327

@@ -307,9 +346,31 @@ def main() -> int:
307346
flags = []
308347
flags = [str(f) for f in flags]
309348

310-
intent = pending.get("intent")
311-
if intent is not None:
312-
intent = str(intent)
349+
# Intent priority: agent-stated intent event > pending context > fallback
350+
intent = None
351+
intent_type = None
352+
if intent_events:
353+
# Use the most recent intent event (prefer matching session, else latest)
354+
best_intent = None
355+
if session_id and session_id != "unknown":
356+
for ie in reversed(intent_events):
357+
if str(ie.get("session_id") or "") == session_id:
358+
best_intent = ie
359+
break
360+
if not best_intent:
361+
best_intent = intent_events[-1]
362+
intent = str(best_intent.get("description") or "").strip() or None
363+
intent_type = str(best_intent.get("intent_type") or "").strip() or None
364+
365+
if not intent:
366+
raw = pending.get("intent")
367+
if raw is not None:
368+
intent = str(raw).strip() or None
369+
370+
if not intent_type:
371+
raw = pending.get("intent_type")
372+
if raw is not None:
373+
intent_type = str(raw).strip() or None
313374

314375
# Per-file attribution — each file maps to the agent/model that wrote it.
315376
# Files with a session event that matches the dominant agent are omitted (finalize
@@ -364,6 +425,8 @@ def main() -> int:
364425
payload["attribution"] = attribution
365426
if intent:
366427
payload["intent"] = intent
428+
if intent_type:
429+
payload["intent_type"] = intent_type
367430
if trust is not None:
368431
payload["trust"] = trust
369432

‎scripts/record-context.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ def main() -> int:
6464
parser.add_argument("--prompt", default="")
6565
parser.add_argument("--files-read", default="")
6666
parser.add_argument("--intent", default="")
67+
parser.add_argument("--intent-type", default="",
68+
choices=["bugfix","feature","refactor","test","docs",
69+
"security","performance","config","dependency",""])
6770
parser.add_argument("--trust", type=int, default=None)
6871
parser.add_argument("--flags", default="")
6972
args = parser.parse_args()
@@ -90,6 +93,7 @@ def main() -> int:
9093
"prompt": args.prompt or str(payload.get("prompt") or ""),
9194
"files_read": parse_json_array(args.files_read) or payload.get("files_read") or [],
9295
"intent": args.intent or str(payload.get("intent") or ""),
96+
"intent_type": args.intent_type or str(payload.get("intent_type") or ""),
9397
"flags": parse_json_array(args.flags) or payload.get("flags") or [],
9498
}
9599

‎scripts/tests/test_capture_prompts.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,53 @@ def test_write_agent_trace_persists_structured_context_metadata(self):
208208
self.assertEqual(metadata["author"], "Prakhar")
209209
self.assertEqual(metadata["capture_tool"], "afterFileEdit")
210210

211+
def test_write_agent_trace_persists_intent_type(self):
212+
with tempfile.TemporaryDirectory() as tmp:
213+
repo = Path(tmp) / "repo"
214+
repo.mkdir()
215+
subprocess.run(["git", "init", "-b", "main"], cwd=repo, check=True, capture_output=True)
216+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True)
217+
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
218+
(repo / "README.md").write_text("test\n", encoding="utf-8")
219+
subprocess.run(["git", "add", "README.md"], cwd=repo, check=True)
220+
subprocess.run(["git", "commit", "-m", "init"], cwd=repo, check=True, capture_output=True)
221+
222+
pending = {
223+
"agent": "cursor",
224+
"git_author": "Prakhar",
225+
"model": "cursor-test",
226+
"session_id": "sess-2",
227+
"lines": {"src/app.py": [[1, 5]]},
228+
"prompt_excerpt": "extract auth middleware",
229+
"prompt_hash": "def456",
230+
"intent": "eliminate duplicate token validation across route handlers",
231+
"intent_type": "refactor",
232+
"files_read": [],
233+
"trust": 85,
234+
"flags": [],
235+
"tool": "afterFileEdit",
236+
}
237+
238+
original = os.environ.get("HOME")
239+
try:
240+
os.environ["HOME"] = tmp
241+
traces_path = self.mod.write_agent_trace(
242+
str(repo), pending, "cafebabe", "2026-05-21T00:00:00Z"
243+
)
244+
finally:
245+
if original is not None:
246+
os.environ["HOME"] = original
247+
248+
self.assertIsNotNone(traces_path)
249+
raw = Path(traces_path).read_text(encoding="utf-8").strip()
250+
trace = json.loads(raw)
251+
metadata = trace["metadata"]["agentdiff"]
252+
self.assertEqual(metadata["intent_type"], "refactor")
253+
self.assertEqual(
254+
metadata["intent"],
255+
"eliminate duplicate token validation across route handlers",
256+
)
257+
211258

212259
if __name__ == "__main__":
213260
unittest.main()

0 commit comments

Comments
 (0)