diff --git a/Cargo.lock b/Cargo.lock index 176dc3e..e4ca81f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "agentdiff" -version = "0.1.28" +version = "0.1.29" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 57b9fa6..8fadf6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentdiff" -version = "0.1.28" +version = "0.1.29" edition = "2024" rust-version = "1.85" description = "Audit and trace autonomous AI code contributions in git repositories" diff --git a/scripts/finalize-ledger.py b/scripts/finalize-ledger.py index a0dd326..c35af74 100644 --- a/scripts/finalize-ledger.py +++ b/scripts/finalize-ledger.py @@ -63,6 +63,43 @@ def remove_if_exists(path: str) -> None: pass +def remove_consumed_intents(session_path: str) -> None: + """Strip type=intent events from session.jsonl after they've been committed. + + Intent events are one-shot: set_intent is called just before a commit, so + by the time finalize runs they've been consumed. Leaving them causes stale + intent to bleed into the next commit when two commits share the same second. + """ + if not os.path.exists(session_path): + return + kept = [] + try: + with open(session_path, "r", encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line: + continue + try: + event = json.loads(line) + if isinstance(event, dict) and event.get("type") == "intent": + continue + except Exception: + pass + kept.append(raw if raw.endswith("\n") else raw + "\n") + except (OSError, IOError): + return + tmp = session_path + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as f: + f.writelines(kept) + os.replace(tmp, session_path) + except (OSError, IOError): + try: + os.remove(tmp) + except OSError: + pass + + def sha_already_recorded(traces_path: str, sha: str) -> bool: """Skip finalize if this commit already has a trace recorded locally.""" 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 metadata["session_id"] = str(pending["session_id"]) if pending.get("intent"): metadata["intent"] = str(pending["intent"]) + if pending.get("intent_type"): + metadata["intent_type"] = str(pending["intent_type"]) if isinstance(pending.get("files_read"), list) and pending["files_read"]: metadata["files_read"] = [str(p) for p in pending["files_read"]] if git_author: @@ -228,9 +267,12 @@ def main() -> int: return 1 ts = ts_res.stdout.strip() + session_path = os.path.join(repo_root, ".git", "agentdiff", "session.jsonl") result = write_agent_trace(repo_root, pending, sha, ts) remove_if_exists(pending_ledger_path) remove_if_exists(pending_context_path) + if result is not None: + remove_consumed_intents(session_path) return 0 if result is not None else 1 diff --git a/scripts/prepare-ledger.py b/scripts/prepare-ledger.py index d6e4b62..81dbfb1 100644 --- a/scripts/prepare-ledger.py +++ b/scripts/prepare-ledger.py @@ -198,6 +198,41 @@ def read_events_per_file( return {fp: ev for fp, (_, ev) in best.items()} +def read_intent_events( + path: str, + min_ts: int, +) -> List[dict]: + """Read intent events from session.jsonl (type=intent, written by set_intent MCP tool). + + Returns all matching intent events sorted by timestamp (most recent last). + """ + if not os.path.exists(path): + return [] + results = [] + try: + with open(path, "r", encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line: + continue + try: + event = json.loads(line) + except Exception: + continue + if not isinstance(event, dict): + continue + if event.get("type") != "intent": + continue + event_ts = parse_event_ts(str(event.get("timestamp") or "")) + if event_ts < min_ts: + continue + results.append(event) + except Exception: + pass + results.sort(key=lambda e: parse_event_ts(str(e.get("timestamp") or ""))) + return results + + def dominant_event(events_by_file: Dict[str, dict], lines_by_file: Dict[str, List[Tuple[int, int]]]) -> dict: """Pick the agent/model to use as the top-level record field. @@ -276,13 +311,17 @@ def main() -> int: if not isinstance(pending, dict): pending = {} + min_ts = head_commit_ts(repo_root) events_by_file = read_events_per_file( session_log, files_touched, - head_commit_ts(repo_root), + min_ts, lines_by_file, ) + # Read agent-stated intent events from session.jsonl (written by set_intent MCP tool) + intent_events = read_intent_events(session_log, min_ts) + # Top-level agent/model/session come from the dominant event (most lines written) event = dominant_event(events_by_file, lines_by_file) or {} @@ -307,9 +346,31 @@ def main() -> int: flags = [] flags = [str(f) for f in flags] - intent = pending.get("intent") - if intent is not None: - intent = str(intent) + # Intent priority: agent-stated intent event > pending context > fallback + intent = None + intent_type = None + if intent_events: + # Use the most recent intent event (prefer matching session, else latest) + best_intent = None + if session_id and session_id != "unknown": + for ie in reversed(intent_events): + if str(ie.get("session_id") or "") == session_id: + best_intent = ie + break + if not best_intent: + best_intent = intent_events[-1] + intent = str(best_intent.get("description") or "").strip() or None + intent_type = str(best_intent.get("intent_type") or "").strip() or None + + if not intent: + raw = pending.get("intent") + if raw is not None: + intent = str(raw).strip() or None + + if not intent_type: + raw = pending.get("intent_type") + if raw is not None: + intent_type = str(raw).strip() or None # Per-file attribution — each file maps to the agent/model that wrote it. # Files with a session event that matches the dominant agent are omitted (finalize @@ -364,6 +425,8 @@ def main() -> int: payload["attribution"] = attribution if intent: payload["intent"] = intent + if intent_type: + payload["intent_type"] = intent_type if trust is not None: payload["trust"] = trust diff --git a/scripts/record-context.py b/scripts/record-context.py index d3e6ca8..fa8b197 100644 --- a/scripts/record-context.py +++ b/scripts/record-context.py @@ -64,6 +64,9 @@ def main() -> int: parser.add_argument("--prompt", default="") parser.add_argument("--files-read", default="") parser.add_argument("--intent", default="") + parser.add_argument("--intent-type", default="", + choices=["bugfix","feature","refactor","test","docs", + "security","performance","config","dependency",""]) parser.add_argument("--trust", type=int, default=None) parser.add_argument("--flags", default="") args = parser.parse_args() @@ -90,6 +93,7 @@ def main() -> int: "prompt": args.prompt or str(payload.get("prompt") or ""), "files_read": parse_json_array(args.files_read) or payload.get("files_read") or [], "intent": args.intent or str(payload.get("intent") or ""), + "intent_type": args.intent_type or str(payload.get("intent_type") or ""), "flags": parse_json_array(args.flags) or payload.get("flags") or [], } diff --git a/scripts/tests/test_capture_prompts.py b/scripts/tests/test_capture_prompts.py index a001b66..0b9c2ae 100644 --- a/scripts/tests/test_capture_prompts.py +++ b/scripts/tests/test_capture_prompts.py @@ -208,6 +208,53 @@ def test_write_agent_trace_persists_structured_context_metadata(self): self.assertEqual(metadata["author"], "Prakhar") self.assertEqual(metadata["capture_tool"], "afterFileEdit") + def test_write_agent_trace_persists_intent_type(self): + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-b", "main"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True) + (repo / "README.md").write_text("test\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=repo, check=True, capture_output=True) + + pending = { + "agent": "cursor", + "git_author": "Prakhar", + "model": "cursor-test", + "session_id": "sess-2", + "lines": {"src/app.py": [[1, 5]]}, + "prompt_excerpt": "extract auth middleware", + "prompt_hash": "def456", + "intent": "eliminate duplicate token validation across route handlers", + "intent_type": "refactor", + "files_read": [], + "trust": 85, + "flags": [], + "tool": "afterFileEdit", + } + + original = os.environ.get("HOME") + try: + os.environ["HOME"] = tmp + traces_path = self.mod.write_agent_trace( + str(repo), pending, "cafebabe", "2026-05-21T00:00:00Z" + ) + finally: + if original is not None: + os.environ["HOME"] = original + + self.assertIsNotNone(traces_path) + raw = Path(traces_path).read_text(encoding="utf-8").strip() + trace = json.loads(raw) + metadata = trace["metadata"]["agentdiff"] + self.assertEqual(metadata["intent_type"], "refactor") + self.assertEqual( + metadata["intent"], + "eliminate duplicate token validation across route handlers", + ) + if __name__ == "__main__": unittest.main() diff --git a/src/bin/agentdiff-mcp.rs b/src/bin/agentdiff-mcp.rs index b60b8cd..52747b5 100644 --- a/src/bin/agentdiff-mcp.rs +++ b/src/bin/agentdiff-mcp.rs @@ -8,7 +8,13 @@ use std::path::{Path, PathBuf}; use std::process::Command; const DEFAULT_PROTOCOL_VERSION: &str = "2024-11-05"; -const TOOL_NAME: &str = "record_context"; +const TOOL_RECORD_CONTEXT: &str = "record_context"; +const TOOL_SET_INTENT: &str = "set_intent"; + +const VALID_INTENT_TYPES: &[&str] = &[ + "bugfix", "feature", "refactor", "test", "docs", + "security", "performance", "config", "dependency", +]; #[derive(Debug, Clone, Deserialize, Default)] #[serde(default)] @@ -24,6 +30,16 @@ struct RecordContextArgs { flags: Option>, } +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct SetIntentArgs { + description: Option, + intent_type: Option, + session_id: Option, + agent: Option, + cwd: Option, +} + fn main() -> Result<()> { let default_cwd = std::env::current_dir().context("resolving current directory")?; @@ -117,7 +133,9 @@ fn handle_request(request: &Value, default_cwd: &Path) -> Option { } "notifications/initialized" => None, "ping" => id.map(|rid| response_ok(rid, json!({}))), - "tools/list" => id.map(|rid| response_ok(rid, json!({"tools":[tool_definition()]}))), + "tools/list" => id.map(|rid| { + response_ok(rid, json!({"tools":[record_context_definition(), set_intent_definition()]})) + }), "tools/call" => id.map(|rid| { let name = request .get("params") @@ -125,48 +143,72 @@ fn handle_request(request: &Value, default_cwd: &Path) -> Option { .and_then(Value::as_str) .unwrap_or(""); - if name != TOOL_NAME { - return response_error(rid, -32601, format!("unknown tool: {name}")); - } - let raw_args = request .get("params") .and_then(|p| p.get("arguments")) .cloned() .unwrap_or_else(|| json!({})); - let args: RecordContextArgs = match serde_json::from_value(raw_args) { - Ok(v) => v, - Err(err) => { - return response_error(rid, -32602, format!("invalid arguments: {err}")); + match name { + TOOL_RECORD_CONTEXT => { + let args: RecordContextArgs = match serde_json::from_value(raw_args) { + Ok(v) => v, + Err(err) => { + return response_error(rid, -32602, format!("invalid arguments: {err}")); + } + }; + match record_context(&args, default_cwd) { + Ok(out_path) => response_ok( + rid, + json!({ + "content": [{ + "type":"text", + "text": format!("recorded context in {}", out_path.display()) + }], + "structuredContent": { + "status":"recorded", + "path": out_path.display().to_string(), + "will_attach_on_next_commit": true + } + }), + ), + Err(err) => response_error(rid, -32000, format!("{err:#}")), + } } - }; - - match record_context(&args, default_cwd) { - Ok(out_path) => response_ok( - rid, - json!({ - "content": [{ - "type":"text", - "text": format!("recorded context in {}", out_path.display()) - }], - "structuredContent": { - "status":"recorded", - "path": out_path.display().to_string(), - "will_attach_on_next_commit": true + TOOL_SET_INTENT => { + let args: SetIntentArgs = match serde_json::from_value(raw_args) { + Ok(v) => v, + Err(err) => { + return response_error(rid, -32602, format!("invalid arguments: {err}")); } - }), - ), - Err(err) => response_error(rid, -32000, format!("{err:#}")), + }; + match set_intent(&args, default_cwd) { + Ok(out_path) => response_ok( + rid, + json!({ + "content": [{ + "type":"text", + "text": format!("intent recorded in {}", out_path.display()) + }], + "structuredContent": { + "status":"recorded", + "path": out_path.display().to_string() + } + }), + ), + Err(err) => response_error(rid, -32000, format!("{err:#}")), + } + } + _ => response_error(rid, -32601, format!("unknown tool: {name}")), } }), _ => id.map(|rid| response_error(rid, -32601, format!("method not found: {method}"))), } } -fn tool_definition() -> Value { +fn record_context_definition() -> Value { json!({ - "name": TOOL_NAME, + "name": TOOL_RECORD_CONTEXT, "description": "Record agent session context into .git/agentdiff/pending.json for the next commit.", "inputSchema": { "type": "object", @@ -187,6 +229,32 @@ fn tool_definition() -> Value { }) } +fn set_intent_definition() -> Value { + json!({ + "name": TOOL_SET_INTENT, + "description": "Record the intent behind your code changes before committing. Call this with a 1-2 sentence description of WHY you made the changes (not what you changed). This is stored in the agent trace and shown in PR comments to help reviewers understand the purpose.", + "inputSchema": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "1-2 sentence description of WHY you made these changes. Focus on the goal/reason, not the mechanics. Example: 'Extract auth middleware into shared module to eliminate duplicate token validation across 3 route handlers'" + }, + "intent_type": { + "type": "string", + "description": "Category of change", + "enum": ["bugfix", "feature", "refactor", "test", "docs", "security", "performance", "config", "dependency"] + }, + "session_id": {"type":"string"}, + "agent": {"type":"string"}, + "cwd": {"type":"string"} + }, + "required": ["description"], + "additionalProperties": false + } + }) +} + fn response_ok(id: Value, result: Value) -> Value { json!({ "jsonrpc":"2.0", @@ -238,6 +306,64 @@ fn record_context(args: &RecordContextArgs, default_cwd: &Path) -> Result Result { + let description = args + .description + .as_deref() + .filter(|s| !s.trim().is_empty()) + .context("description is required")?; + let description = if description.chars().count() > 500 { + let idx = description + .char_indices() + .nth(500) + .map(|(i, _)| i) + .unwrap_or(description.len()); + &description[..idx] + } else { + description + }; + + let cwd = args + .cwd + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| default_cwd.to_path_buf()); + let repo_root = find_repo_root(&cwd)?; + + let session_path = repo_root + .join(".git") + .join("agentdiff") + .join("session.jsonl"); + if let Some(parent) = session_path.parent() { + fs::create_dir_all(parent)?; + } + + let mut event = json!({ + "timestamp": Utc::now().to_rfc3339(), + "type": "intent", + "agent": args.agent.clone().unwrap_or_else(|| "unknown".to_string()), + "session_id": args.session_id.clone().unwrap_or_else(|| "unknown".to_string()), + "description": description + }); + + if let Some(ref intent_type) = args.intent_type { + if !VALID_INTENT_TYPES.contains(&intent_type.as_str()) { + bail!("invalid intent_type '{}'; must be one of: {}", intent_type, VALID_INTENT_TYPES.join(", ")); + } + event["intent_type"] = json!(intent_type); + } + + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&session_path) + .with_context(|| format!("opening {}", session_path.display()))?; + writeln!(file, "{}", event.to_string()) + .with_context(|| format!("writing to {}", session_path.display()))?; + + Ok(session_path) +} + fn find_repo_root(cwd: &Path) -> Result { let out = Command::new("git") .args(["-C", &cwd.display().to_string(), "rev-parse", "--show-toplevel"]) @@ -287,7 +413,7 @@ mod tests { } #[test] - fn tools_list_includes_record_context() { + fn tools_list_includes_both_tools() { let req = json!({ "jsonrpc":"2.0", "id":"abc", @@ -296,8 +422,10 @@ mod tests { }); let resp = handle_request(&req, Path::new(".")).expect("response"); let tools = resp["result"]["tools"].as_array().expect("tools array"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["name"], TOOL_NAME); + assert_eq!(tools.len(), 2); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert!(names.contains(&TOOL_RECORD_CONTEXT)); + assert!(names.contains(&TOOL_SET_INTENT)); } #[test] @@ -336,6 +464,60 @@ mod tests { assert_eq!(obj["trust"], 88); } + #[test] + fn set_intent_writes_to_session_jsonl() { + let repo = init_repo(); + let req = json!({ + "jsonrpc":"2.0", + "id":100, + "method":"tools/call", + "params":{ + "name":"set_intent", + "arguments":{ + "cwd": repo.display().to_string(), + "description": "Extract auth middleware to fix duplicate validation", + "intent_type": "refactor", + "session_id": "sess_intent_test", + "agent": "cursor" + } + } + }); + + let resp = handle_request(&req, Path::new(".")).expect("response"); + assert!(resp.get("result").is_some(), "expected result, got: {resp}"); + + let session_path = repo.join(".git").join("agentdiff").join("session.jsonl"); + assert!(session_path.exists()); + + let content = fs::read_to_string(&session_path).expect("read session.jsonl"); + let event: Value = serde_json::from_str(content.trim()).expect("parse event"); + assert_eq!(event["type"], "intent"); + assert_eq!(event["description"], "Extract auth middleware to fix duplicate validation"); + assert_eq!(event["intent_type"], "refactor"); + assert_eq!(event["agent"], "cursor"); + assert_eq!(event["session_id"], "sess_intent_test"); + } + + #[test] + fn set_intent_requires_description() { + let repo = init_repo(); + let req = json!({ + "jsonrpc":"2.0", + "id":101, + "method":"tools/call", + "params":{ + "name":"set_intent", + "arguments":{ + "cwd": repo.display().to_string(), + "description": "" + } + } + }); + + let resp = handle_request(&req, Path::new(".")).expect("response"); + assert!(resp.get("error").is_some()); + } + #[test] fn unknown_tool_returns_error() { let req = json!({ diff --git a/src/commands/report.rs b/src/commands/report.rs index aa27eff..71d7947 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -412,6 +412,7 @@ fn markdown_report(entries: &[Entry]) -> Result { #[derive(Default)] struct ContextGroup { intent: String, + intent_type: Option, lines: usize, files: HashSet, agents: HashSet, @@ -451,6 +452,10 @@ fn markdown_trace_report(traces: &[AgentTrace], include_context: bool) -> Result .and_then(|m| m.intent.clone()) .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "unspecified".to_string()); + let intent_type = meta + .as_ref() + .and_then(|m| m.intent_type.clone()) + .filter(|s| !s.trim().is_empty()); let line_count = trace_line_count(trace); total_lines += line_count; *agent_counts.entry(agent.clone()).or_default() += line_count; @@ -459,8 +464,12 @@ fn markdown_trace_report(traces: &[AgentTrace], include_context: bool) -> Result .entry(intent.clone()) .or_insert_with(|| ContextGroup { intent: intent.clone(), + intent_type: intent_type.clone(), ..Default::default() }); + if group.intent_type.is_none() && intent_type.is_some() { + group.intent_type = intent_type; + } group.lines += line_count; group.agents.insert(agent.clone()); group.trace_ids.push(short_id(&trace.id).to_string()); @@ -520,9 +529,13 @@ fn markdown_trace_report(traces: &[AgentTrace], include_context: bool) -> Result let mut groups: Vec<_> = context_groups.values().collect(); groups.sort_by(|a, b| b.lines.cmp(&a.lines).then_with(|| a.intent.cmp(&b.intent))); for group in groups.iter().take(5) { + let intent_label = match &group.intent_type { + Some(t) => format!("[{}] {}", t, group.intent), + None => group.intent.clone(), + }; out.push_str(&format!( "- Intent: {} ({} lines, {} file{})\n", - group.intent, + intent_label, group.lines, group.files.len(), if group.files.len() == 1 { "" } else { "s" } @@ -582,8 +595,8 @@ fn markdown_trace_report(traces: &[AgentTrace], include_context: bool) -> Result if include_context { out.push_str("\n
\nTrace details\n\n"); - out.push_str("| Trace | Agent | Intent | Files | Lines |\n"); - out.push_str("|-------|-------|--------|-------|-------|\n"); + out.push_str("| Trace | Agent | Type | Intent | Files | Lines |\n"); + out.push_str("|-------|-------|------|--------|-------|-------|\n"); for trace in traces.iter().take(30) { let meta = trace.agentdiff_metadata(); let intent = meta @@ -591,11 +604,17 @@ fn markdown_trace_report(traces: &[AgentTrace], include_context: bool) -> Result .and_then(|m| m.intent.clone()) .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "unspecified".to_string()); + let intent_type = meta + .as_ref() + .and_then(|m| m.intent_type.clone()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "-".to_string()); let files: HashSet = trace.files.iter().map(|f| f.path.clone()).collect(); out.push_str(&format!( - "| {} | {} | {} | {} | {} |\n", + "| {} | {} | {} | {} | {} | {} |\n", short_id(&trace.id), md_cell(trace.agent_name()), + md_cell(&intent_type), md_cell(&intent), md_cell(&limited_join(&files, 5)), trace_line_count(trace) @@ -620,6 +639,7 @@ fn context_json_report(traces: &[AgentTrace]) -> Result { "sha": trace.sha(), "agent": trace.agent_name(), "intent": meta.as_ref().and_then(|m| m.intent.clone()), + "intent_type": meta.as_ref().and_then(|m| m.intent_type.clone()), "prompt_excerpt": meta.as_ref().and_then(|m| m.prompt_excerpt.clone()), "files_read": meta.as_ref().map(|m| m.files_read.clone()).unwrap_or_default(), "flags": meta.as_ref().map(|m| m.flags.clone()).unwrap_or_default(), @@ -823,6 +843,7 @@ mod tests { metadata: Some(serde_json::json!({ "agentdiff": { "intent": "security hardening", + "intent_type": "security", "prompt_excerpt": "add route guard", "files_read": ["src/auth.rs"], "flags": ["security"], @@ -860,7 +881,7 @@ mod tests { fn markdown_trace_report_includes_review_context() { let md = markdown_trace_report(&[sample_trace_with_context()], true).unwrap(); assert!(md.contains("## Review Context")); - assert!(md.contains("Intent: security hardening")); + assert!(md.contains("[security] security hardening")); assert!(md.contains("Files read: src/auth.rs")); assert!(md.contains("| src/api.rs | 3 | cursor | security hardening | trace 550e8400 |")); } @@ -870,6 +891,7 @@ mod tests { let json = context_json_report(&[sample_trace_with_context()]).unwrap(); assert_eq!(json["total_traces"], 1); assert_eq!(json["traces"][0]["intent"], "security hardening"); + assert_eq!(json["traces"][0]["intent_type"], "security"); assert_eq!(json["traces"][0]["files_read"][0], "src/auth.rs"); } diff --git a/src/configure/agents_md.rs b/src/configure/agents_md.rs index 8e732df..b4e2277 100644 --- a/src/configure/agents_md.rs +++ b/src/configure/agents_md.rs @@ -28,7 +28,10 @@ fn managed_block() -> String { \n\ ### Before committing\n\ \n\ - Let the git hooks run — do **not** bypass them with `--no-verify`. The \ + 1. Call the `set_intent` MCP tool with a 1-2 sentence description of **why** \ + you made these changes (not what you changed). Include an `intent_type` from: \ + bugfix, feature, refactor, test, docs, security, performance, config, dependency.\n\ + 2. Let the git hooks run — do **not** bypass them with `--no-verify`. The \ `pre-commit` hook computes per-file attribution, and the `post-commit` hook \ signs and stores the trace. Skipping either breaks the attribution ledger.\n\ \n\ diff --git a/src/data.rs b/src/data.rs index 500517e..1da6e1b 100644 --- a/src/data.rs +++ b/src/data.rs @@ -115,6 +115,9 @@ pub struct AgentdiffMetadata { pub files_read: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub intent: Option, + /// Category: bugfix, feature, refactor, test, docs, security, performance, config, dependency + #[serde(skip_serializing_if = "Option::is_none")] + pub intent_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub trust: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -371,6 +374,7 @@ mod tests { "trust": 92, "flags": ["security"], "intent": "security hardening", + "intent_type": "security", "author": "Prakhar Khatri" } })),