use clap_noun_verb::Result;
use clap_noun_verb_macros::verb;
use ggen_lsp::a2a_mcp::a2a::receipt::{
AlignmentEvidence, ExpectedPathEvidence, McpInvocationEvidence, ObservedPathEvidence,
OcelEvent, OcelObject, OcelObjectRef, ReceiptOcelSlice,
};
use ggen_lsp::a2a_mcp::a2a::{
A2ARefusalState, A2AState, A2ATaskReceipt, Avatar8, Jtbd8, Task, TaskState,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn get_task_dir() -> Result<PathBuf> {
let mut dir = dirs::home_dir().ok_or_else(|| {
clap_noun_verb::NounVerbError::execution_error("Could not determine home directory")
})?;
dir.push(".ggen/a2a/tasks");
fs::create_dir_all(&dir).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!(
"Failed to create task directory: {}",
e
))
})?;
Ok(dir)
}
#[derive(Serialize, Deserialize)]
struct A2aTaskOutput {
id: String,
state: String,
title: String,
}
#[verb]
fn create(title: String) -> Result<A2aTaskOutput> {
let task = Task::new(title.clone(), "user".to_string());
let task_path = get_task_dir()?.join(format!("{}.json", task.id));
let task_json = serde_json::to_string_pretty(&task).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!("Failed to serialize task: {}", e))
})?;
fs::write(task_path, task_json).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!("Failed to write task file: {}", e))
})?;
let ocel_trace = serde_json::json!({
"event": "a2a.task.created",
"objects": {
"task": task.id.to_string(),
"mcp_server": "ggen",
"title": title.clone()
}
});
log::info!("OCEL: {}", ocel_trace);
Ok(A2aTaskOutput {
id: task.id.to_string(),
state: format!("{:?}", task.state),
title: task.title.clone(),
})
}
#[verb]
fn status(id: String) -> Result<A2aTaskOutput> {
let task_path = get_task_dir()?.join(format!("{}.json", id));
if !task_path.exists() {
return Err(clap_noun_verb::NounVerbError::execution_error(format!(
"Task {} not found",
id
)));
}
let content = fs::read_to_string(&task_path).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!("Failed to read task: {}", e))
})?;
let task: Task = serde_json::from_str(&content).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!("Invalid task JSON: {}", e))
})?;
Ok(A2aTaskOutput {
id: task.id.to_string(),
state: format!("{:?}", task.state),
title: task.title.clone(),
})
}
#[verb]
fn execute(id: String, fail: Option<bool>) -> Result<A2aTaskOutput> {
let task_path = get_task_dir()?.join(format!("{}.json", id));
if !task_path.exists() {
return Err(clap_noun_verb::NounVerbError::execution_error(format!(
"Task {} not found",
id
)));
}
let content = fs::read_to_string(&task_path).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!("Failed to read task: {}", e))
})?;
let mut task: Task = serde_json::from_str(&content).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!("Invalid task JSON: {}", e))
})?;
let should_fail = fail.unwrap_or(false);
if !should_fail {
task = task.with_metadata("tool_invoked".to_string(), "ggen.construct".to_string());
log::info!(
"OCEL: {}",
serde_json::json!({
"event": "a2a.mcp.tool.invoked",
"objects": { "task": task.id.to_string(), "tool": "ggen.construct" }
})
);
let artifact_content = b"real_artifact_content_for_task";
let artifact_hash = blake3::hash(artifact_content).to_hex().to_string();
let artifact = ggen_lsp::a2a_mcp::a2a::Artifact::new(
"artifact_needed.md".to_string(),
ggen_lsp::a2a_mcp::a2a::ArtifactType::Output,
ggen_lsp::a2a_mcp::a2a::ArtifactContent::Text(
"real_artifact_content_for_task".to_string(),
),
);
task = task.with_artifact("artifact_needed.md".to_string(), artifact);
log::info!(
"OCEL: {}",
serde_json::json!({
"event": "a2a.artifact.emitted",
"objects": { "task": task.id.to_string(), "artifact": "artifact_needed.md", "hash": artifact_hash }
})
);
task.state = TaskState::Completed;
task.completed_at = Some(chrono::Utc::now());
log::info!(
"OCEL: {}",
serde_json::json!({
"event": "a2a.task.closed",
"objects": { "task": task.id.to_string(), "state": "Completed" }
})
);
} else {
task.state = TaskState::Failed;
task.failure_reason = Some("Agent requested failure for simulation".to_string());
task.completed_at = Some(chrono::Utc::now());
log::info!(
"OCEL: {}",
serde_json::json!({
"event": "a2a.task.closed",
"objects": { "task": task.id.to_string(), "state": "Failed", "reason": "simulation" }
})
);
}
let task_json = serde_json::to_string_pretty(&task).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!("Failed to serialize task: {}", e))
})?;
fs::write(&task_path, task_json).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!("Failed to write task file: {}", e))
})?;
Ok(A2aTaskOutput {
id: task.id.to_string(),
state: format!("{:?}", task.state),
title: task.title.clone(),
})
}
#[derive(Deserialize)]
struct RunInput {
tasks: Vec<TaskEntry>,
}
#[derive(Deserialize, Serialize)]
struct TaskEntry {
avatar: Avatar8,
jtbd: Jtbd8,
task_id: String,
}
#[derive(Serialize)]
struct VerifyOutput {
receipt_chain: Vec<A2ATaskReceipt>,
status: String,
}
fn do_verify(run: Option<String>, out: Option<String>) -> Result<VerifyOutput> {
let run_path = run.unwrap_or_else(|| "artifacts/a2a/avatar-jtbd-run.json".to_string());
let out_path = out.unwrap_or_else(|| "artifacts/a2a/a2a-verification.receipt.json".to_string());
let content = fs::read_to_string(&run_path).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!("Failed to read run file: {}", e))
})?;
let run_data: RunInput = serde_json::from_str(&content).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!("Invalid run JSON: {}", e))
})?;
let mut receipts = Vec::new();
let mut overall_status = "Aligned".to_string();
for entry in run_data.tasks {
let receipt_id = uuid::Uuid::new_v4().to_string();
let task_path = get_task_dir()?.join(format!("{}.json", entry.task_id));
let task_exists = task_path.exists();
let mut task_state = A2AState::CreatedOnly;
let mut refusal = None;
let mut tool_call_hash = None;
let mut observed_artifact_hash = None;
let mut missing_events = vec![];
let mut ocel_events = vec![];
let ocel_objects = vec![
OcelObject {
id: entry.task_id.clone(),
r#type: "A2ATask".to_string(),
},
OcelObject {
id: format!("{:?}", entry.avatar),
r#type: "Avatar8".to_string(),
},
OcelObject {
id: format!("{:?}", entry.jtbd),
r#type: "Jtbd8".to_string(),
},
OcelObject {
id: "ggen".to_string(),
r#type: "MCPServer".to_string(),
},
OcelObject {
id: receipt_id.clone(),
r#type: "Receipt".to_string(),
},
];
let mut expected_ocel = ReceiptOcelSlice {
schema: "CanonicalOCEL.v1".to_string(),
events: vec![
OcelEvent {
id: format!("expected_task_created_{}", entry.task_id),
activity: "a2a.task.created".to_string(),
timestamp: "2026-05-21T00:00:00.000Z".to_string(),
objects: vec![OcelObjectRef {
id: entry.task_id.clone(),
r#type: "A2ATask".to_string(),
qualifier: Some("task".to_string()),
}],
attributes: std::collections::HashMap::new(),
},
OcelEvent {
id: format!("expected_tool_invoked_{}", entry.task_id),
activity: "a2a.mcp.tool.invoked".to_string(),
timestamp: "2026-05-21T00:00:01.000Z".to_string(),
objects: vec![],
attributes: std::collections::HashMap::new(),
},
OcelEvent {
id: format!("expected_artifact_emitted_{}", entry.task_id),
activity: "a2a.artifact.emitted".to_string(),
timestamp: "2026-05-21T00:00:02.000Z".to_string(),
objects: vec![],
attributes: std::collections::HashMap::new(),
},
OcelEvent {
id: format!("expected_task_closed_{}", entry.task_id),
activity: "a2a.task.closed".to_string(),
timestamp: "2026-05-21T00:00:03.000Z".to_string(),
objects: vec![],
attributes: std::collections::HashMap::new(),
},
],
objects: vec![],
canonical_hash: "".to_string(),
};
expected_ocel.compute_and_set_hash();
if task_exists {
let content = fs::read_to_string(&task_path).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!(
"Failed to read task {}: {}",
entry.task_id, e
))
})?;
if let Ok(task) = serde_json::from_str::<Task>(&content) {
ocel_events.push(OcelEvent {
id: format!("evt_task_created_{}", entry.task_id),
activity: "a2a.task.created".to_string(),
timestamp: task.created_at.to_rfc3339(),
objects: vec![
OcelObjectRef {
id: entry.task_id.clone(),
r#type: "A2ATask".to_string(),
qualifier: Some("task".to_string()),
},
OcelObjectRef {
id: format!("{:?}", entry.avatar),
r#type: "Avatar8".to_string(),
qualifier: Some("actor".to_string()),
},
OcelObjectRef {
id: format!("{:?}", entry.jtbd),
r#type: "Jtbd8".to_string(),
qualifier: Some("job".to_string()),
},
OcelObjectRef {
id: "ggen".to_string(),
r#type: "MCPServer".to_string(),
qualifier: Some("tool-server".to_string()),
},
],
attributes: std::iter::once(("state".to_string(), "CreatedOnly".to_string()))
.collect(),
});
if let Some(tool_val) = task.metadata.get("tool_invoked") {
let tool = tool_val.clone();
tool_call_hash = Some(
blake3::hash(format!("{}_{}", tool, task.id).as_bytes())
.to_hex()
.to_string(),
);
ocel_events.push(OcelEvent {
id: format!("evt_tool_invoked_{}", entry.task_id),
activity: "a2a.mcp.tool.invoked".to_string(),
timestamp: task.created_at.to_rfc3339(),
objects: vec![
OcelObjectRef {
id: entry.task_id.clone(),
r#type: "A2ATask".to_string(),
qualifier: Some("task".to_string()),
},
OcelObjectRef {
id: "ggen".to_string(),
r#type: "MCPServer".to_string(),
qualifier: Some("tool-server".to_string()),
},
],
attributes: std::iter::once(("tool".to_string(), tool)).collect(),
});
} else {
missing_events.push("a2a.mcp.tool.invoked".to_string());
}
if let Some(artifact) = task.artifacts.get("artifact_needed.md") {
task_state = A2AState::ArtifactEmitted;
let content_str = match &artifact.content {
ggen_lsp::a2a_mcp::a2a::ArtifactContent::Text(t) => t.clone(),
_ => "".to_string(),
};
observed_artifact_hash =
Some(blake3::hash(content_str.as_bytes()).to_hex().to_string());
ocel_events.push(OcelEvent {
id: format!("evt_artifact_emitted_{}", entry.task_id),
activity: "a2a.artifact.emitted".to_string(),
timestamp: task.updated_at.to_rfc3339(),
objects: vec![
OcelObjectRef {
id: entry.task_id.clone(),
r#type: "A2ATask".to_string(),
qualifier: Some("task".to_string()),
},
OcelObjectRef {
id: "artifact_needed.md".to_string(),
r#type: "Artifact".to_string(),
qualifier: Some("output".to_string()),
},
],
attributes: std::collections::HashMap::new(),
});
} else {
missing_events.push("a2a.artifact.emitted".to_string());
}
if task.state == TaskState::Completed {
task_state = A2AState::Closed;
let closed_at = task.completed_at.unwrap_or(task.updated_at);
ocel_events.push(OcelEvent {
id: format!("evt_task_closed_{}", entry.task_id),
activity: "a2a.task.closed".to_string(),
timestamp: closed_at.to_rfc3339(),
objects: vec![OcelObjectRef {
id: entry.task_id.clone(),
r#type: "A2ATask".to_string(),
qualifier: Some("task".to_string()),
}],
attributes: std::iter::once(("state".to_string(), "Closed".to_string()))
.collect(),
});
} else {
missing_events.push("a2a.task.closed".to_string());
}
}
} else {
refusal = Some(A2ARefusalState::TaskMissing);
missing_events.push("a2a.task.created".to_string());
missing_events.push("a2a.mcp.tool.invoked".to_string());
missing_events.push("a2a.artifact.emitted".to_string());
missing_events.push("a2a.task.closed".to_string());
}
if !missing_events.is_empty() && refusal.is_none() {
refusal = Some(A2ARefusalState::BoundaryEvidenceMissing);
}
if refusal.is_some() {
overall_status = "ReceiptScaffoldRefused".to_string();
}
ocel_events.push(OcelEvent {
id: format!("evt_task_validated_{}", entry.task_id),
activity: "a2a.task.validated".to_string(),
timestamp: "2026-05-21T00:00:04.000Z".to_string(),
objects: vec![
OcelObjectRef {
id: entry.task_id.clone(),
r#type: "A2ATask".to_string(),
qualifier: Some("task".to_string()),
},
OcelObjectRef {
id: receipt_id.clone(),
r#type: "Receipt".to_string(),
qualifier: Some("validation".to_string()),
},
],
attributes: [
("state".to_string(), format!("{:?}", task_state)),
("refusal_state".to_string(), format!("{:?}", refusal)),
]
.into_iter()
.collect(),
});
let mut ocel = ReceiptOcelSlice {
schema: "CanonicalOCEL.v1".to_string(),
events: ocel_events,
objects: ocel_objects,
canonical_hash: "".to_string(),
};
ocel.compute_and_set_hash();
let mut receipt = A2ATaskReceipt {
receipt_type: "A2ATaskReceipt".to_string(),
receipt_schema: "A2ATaskReceipt.v1".to_string(),
hash_algorithm: "BLAKE3".to_string(),
avatar: entry.avatar,
jtbd: entry.jtbd,
task_id: entry.task_id,
status: task_state,
mcp: McpInvocationEvidence {
server: "ggen".to_string(),
transport: "stdio".to_string(),
tool_name: "ggen.construct".to_string(),
tool_call_hash,
},
expected_path: ExpectedPathEvidence {
route_id: "a2a.v1".to_string(),
expected_ocel_hash: Some(expected_ocel.canonical_hash.clone()),
expected_artifact: "artifact_needed.md".to_string(),
},
observed_path: ObservedPathEvidence {
observed_ocel_hash: ocel.canonical_hash.clone(),
ocel,
observed_artifact_hash,
},
alignment: AlignmentEvidence {
expected_vs_observed: if refusal.is_some() {
"Refused".to_string()
} else {
"Aligned".to_string()
},
missing_events,
unexpected_events: vec![],
refusal_state: refusal.clone(),
},
refusal_state: refusal,
previous_receipt_hash: None,
receipt_hash: "".to_string(),
};
receipt.compute_and_set_hash();
receipts.push(receipt);
}
if let Some(parent) = PathBuf::from(&out_path).parent() {
fs::create_dir_all(parent).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!(
"Failed to create parent directory for receipt output: {}",
e
))
})?;
}
let output_json = serde_json::to_string_pretty(&receipts).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!(
"Failed to serialize receipts: {}",
e
))
})?;
fs::write(&out_path, output_json).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!(
"Failed to write receipts file: {}",
e
))
})?;
let ocel_trace = serde_json::json!({
"event": "a2a.task.validated",
"objects": {
"receipt_count": receipts.len(),
"verifier": "ggen a2a verify"
}
});
log::info!("OCEL: {}", ocel_trace);
Ok(VerifyOutput {
receipt_chain: receipts,
status: overall_status,
})
}
#[verb]
fn verify(
run: Option<String>, _expected: Option<String>, out: Option<String>,
) -> Result<VerifyOutput> {
do_verify(run, out)
}
#[verb]
fn prune() -> Result<serde_json::Value> {
let task_dir = get_task_dir()?;
let mut count = 0;
if task_dir.exists() {
for entry in fs::read_dir(&task_dir).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!(
"Failed to read task directory: {}",
e
))
})? {
let entry = entry.map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!(
"Failed to read directory entry: {}",
e
))
})?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
fs::remove_file(path).map_err(|e| {
clap_noun_verb::NounVerbError::execution_error(format!(
"Failed to remove task file: {}",
e
))
})?;
count += 1;
}
}
}
Ok(serde_json::json!({
"status": "success",
"tasks_pruned": count,
"message": format!("Successfully pruned {} tasks", count)
}))
}