Skip to main content

kura_cli/commands/
agent.rs

1use std::io::Read;
2
3use chrono::{DateTime, Utc};
4use clap::{Args, Subcommand, ValueEnum};
5use serde_json::{Value, json};
6use uuid::Uuid;
7
8use crate::util::{
9    api_request, exit_error, print_json_stderr, print_json_stdout, raw_api_request,
10    raw_api_request_with_query, read_json_from_file,
11};
12
13#[derive(Subcommand)]
14pub enum AgentCommands {
15    /// Get negotiated agent capabilities manifest
16    Capabilities,
17    /// Write one routine workout payload through the isolated vNext training route
18    #[command(visible_alias = "write-training")]
19    LogTraining(LogTrainingArgs),
20    /// Write canonical non-training events through the isolated vNext event route
21    WriteEvent(WriteEventArgs),
22    /// Retract or retract-and-replace canonical events through the isolated vNext correction route
23    WriteCorrection(WriteCorrectionArgs),
24    /// Legacy raw user-turn ingress on /v3/agent/evidence; not part of the vNext write harness
25    LogTurn(LogTurnArgs),
26    /// Get the focused logging bootstrap contract or one intent-native logging recipe
27    LoggingBootstrap {
28        /// Optional intent recipe id (for example: log_conversation)
29        #[arg(long)]
30        intent: Option<String>,
31    },
32    /// Get agent context bundle (system + user profile + key dimensions)
33    Context {
34        /// Max exercise_progression projections to include (default: 5)
35        #[arg(long)]
36        exercise_limit: Option<u32>,
37        /// Max strength_inference projections to include (default: 5)
38        #[arg(long)]
39        strength_limit: Option<u32>,
40        /// Max custom projections to include (default: 10)
41        #[arg(long)]
42        custom_limit: Option<u32>,
43        /// Optional task intent used for context ranking (e.g. "dunk progression")
44        #[arg(long)]
45        task_intent: Option<String>,
46        /// Include deployment-static system config in response payload (default: API default=true)
47        #[arg(long)]
48        include_system: Option<bool>,
49        /// Optional response token budget hint (min 400, max 12000)
50        #[arg(long)]
51        budget_tokens: Option<u32>,
52    },
53    /// Get deterministic section index for startup + targeted follow-up reads
54    SectionIndex {
55        /// Max exercise_progression projections to include (default: 5)
56        #[arg(long)]
57        exercise_limit: Option<u32>,
58        /// Max strength_inference projections to include (default: 5)
59        #[arg(long)]
60        strength_limit: Option<u32>,
61        /// Max custom projections to include (default: 10)
62        #[arg(long)]
63        custom_limit: Option<u32>,
64        /// Optional task intent used for startup section derivation
65        #[arg(long)]
66        task_intent: Option<String>,
67        /// Include deployment-static system config in response payload (default: API default=true)
68        #[arg(long)]
69        include_system: Option<bool>,
70        /// Optional response token budget hint (min 400, max 12000)
71        #[arg(long)]
72        budget_tokens: Option<u32>,
73    },
74    /// Fetch exactly one context section (optionally paged and field-projected)
75    SectionFetch {
76        /// Stable section id from section-index
77        #[arg(long)]
78        section: String,
79        /// Optional page size for paged sections (1..200)
80        #[arg(long)]
81        limit: Option<u32>,
82        /// Optional opaque cursor for paged sections
83        #[arg(long)]
84        cursor: Option<String>,
85        /// Optional comma-separated top-level fields to project
86        #[arg(long)]
87        fields: Option<String>,
88        /// Optional task intent for startup section derivation
89        #[arg(long)]
90        task_intent: Option<String>,
91    },
92    /// Validate a draft answer against the authoritative date-bound coaching serving view
93    AnswerAdmissibility {
94        /// Current user request, preferably passed verbatim
95        #[arg(long)]
96        task_intent: String,
97        /// Draft user-facing answer to validate
98        #[arg(long)]
99        draft_answer: String,
100    },
101    /// Legacy structured write surface for repairs and old agent flows
102    #[command(name = "write-structured", alias = "write-with-proof")]
103    WriteWithProof(WriteWithProofArgs),
104    /// Evidence lineage operations
105    Evidence {
106        #[command(subcommand)]
107        command: AgentEvidenceCommands,
108    },
109    /// Set user save-confirmation preference (persist-intent override)
110    SetSaveConfirmationMode {
111        /// auto | always | never
112        #[arg(value_enum)]
113        mode: SaveConfirmationMode,
114    },
115    /// Resolve visualization policy/output for a task intent
116    ResolveVisualization(ResolveVisualizationArgs),
117    /// Direct agent API access under /v1/agent/*
118    Request(AgentRequestArgs),
119}
120
121#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
122pub enum SaveConfirmationMode {
123    Auto,
124    Always,
125    Never,
126}
127
128#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
129pub enum SessionCompletionStatus {
130    Ongoing,
131    CompletedInBatch,
132}
133
134impl SessionCompletionStatus {
135    fn as_str(self) -> &'static str {
136        match self {
137            SessionCompletionStatus::Ongoing => "ongoing",
138            SessionCompletionStatus::CompletedInBatch => "completed_in_batch",
139        }
140    }
141}
142
143#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
144pub enum ConversationDraftMode {
145    Append,
146    Finalize,
147}
148
149impl ConversationDraftMode {
150    fn as_str(self) -> &'static str {
151        match self {
152            ConversationDraftMode::Append => "append",
153            ConversationDraftMode::Finalize => "finalize",
154        }
155    }
156}
157
158impl SaveConfirmationMode {
159    fn as_str(self) -> &'static str {
160        match self {
161            SaveConfirmationMode::Auto => "auto",
162            SaveConfirmationMode::Always => "always",
163            SaveConfirmationMode::Never => "never",
164        }
165    }
166}
167
168#[derive(Subcommand)]
169pub enum AgentEvidenceCommands {
170    /// Explain lineage claims for one persisted event
171    Event {
172        /// Target event UUID
173        #[arg(long)]
174        event_id: Uuid,
175    },
176}
177
178#[derive(Args)]
179pub struct AgentRequestArgs {
180    /// HTTP method (GET, POST, PUT, DELETE, PATCH)
181    pub method: String,
182
183    /// Agent path: relative (e.g. context) or absolute (/v1/agent/context)
184    pub path: String,
185
186    /// Request body as JSON string
187    #[arg(long, short = 'd')]
188    pub data: Option<String>,
189
190    /// Read request body from file (use '-' for stdin)
191    #[arg(long, short = 'f', conflicts_with = "data")]
192    pub data_file: Option<String>,
193
194    /// Query parameters (repeatable: key=value)
195    #[arg(long, short = 'q')]
196    pub query: Vec<String>,
197
198    /// Extra headers (repeatable: Key:Value)
199    #[arg(long, short = 'H')]
200    pub header: Vec<String>,
201
202    /// Skip pretty-printing (raw JSON for piping)
203    #[arg(long)]
204    pub raw: bool,
205
206    /// Include HTTP status and headers in response wrapper
207    #[arg(long, short = 'i')]
208    pub include: bool,
209}
210
211#[derive(Args)]
212pub struct LogTrainingArgs {
213    /// Inline JSON payload for /v4/agent/write-training; see `kura dev schema dev agent log-training` for explicit load examples
214    #[arg(
215        long,
216        short = 'd',
217        required_unless_present = "request_file",
218        conflicts_with = "request_file"
219    )]
220    pub data: Option<String>,
221
222    /// Read full JSON payload from file (use '-' for stdin)
223    #[arg(
224        long,
225        short = 'f',
226        required_unless_present = "data",
227        conflicts_with = "data"
228    )]
229    pub request_file: Option<String>,
230}
231
232#[derive(Args)]
233pub struct WriteEventArgs {
234    /// Inline JSON payload for /v4/agent/write-event
235    #[arg(
236        long,
237        short = 'd',
238        required_unless_present = "request_file",
239        conflicts_with = "request_file"
240    )]
241    pub data: Option<String>,
242
243    /// Read full JSON payload from file (use '-' for stdin)
244    #[arg(
245        long,
246        short = 'f',
247        required_unless_present = "data",
248        conflicts_with = "data"
249    )]
250    pub request_file: Option<String>,
251}
252
253#[derive(Args)]
254pub struct WriteCorrectionArgs {
255    /// Inline JSON payload for /v4/agent/write-correction
256    #[arg(
257        long,
258        short = 'd',
259        required_unless_present = "request_file",
260        conflicts_with = "request_file"
261    )]
262    pub data: Option<String>,
263
264    /// Read full JSON payload from file (use '-' for stdin)
265    #[arg(
266        long,
267        short = 'f',
268        required_unless_present = "data",
269        conflicts_with = "data"
270    )]
271    pub request_file: Option<String>,
272}
273
274#[derive(Args)]
275pub struct LogTurnArgs {
276    /// Raw user message to persist on the flat guided logging path
277    #[arg(value_name = "MESSAGE", required_unless_present = "message_file")]
278    pub message: Option<String>,
279
280    /// Read raw user message from file (use '-' for stdin)
281    #[arg(long, conflicts_with = "message")]
282    pub message_file: Option<String>,
283
284    /// Optional canonical session hint to bind the turn to one workout/day
285    #[arg(long)]
286    pub session_id: Option<String>,
287
288    /// Evidence modality (default: chat_message)
289    #[arg(long)]
290    pub modality: Option<String>,
291
292    /// RFC3339 timestamp for when the message was recorded
293    #[arg(long)]
294    pub recorded_at: Option<String>,
295
296    /// RFC3339 timestamp for when the underlying activity happened
297    #[arg(long)]
298    pub observed_at: Option<String>,
299
300    /// Optional idempotency key for replay-safe retries
301    #[arg(long)]
302    pub idempotency_key: Option<String>,
303}
304
305#[derive(Args)]
306pub struct WriteWithProofArgs {
307    /// JSON file containing events array or {"events":[...]} (use '-' for stdin)
308    #[arg(
309        long,
310        required_unless_present = "request_file",
311        conflicts_with = "request_file"
312    )]
313    pub events_file: Option<String>,
314
315    /// Read-after-write target in projection_type:key format (repeatable)
316    #[arg(
317        long,
318        required_unless_present = "request_file",
319        conflicts_with = "request_file"
320    )]
321    pub target: Vec<String>,
322
323    /// Max verification wait in milliseconds (100..10000)
324    #[arg(long)]
325    pub verify_timeout_ms: Option<u64>,
326
327    /// Reuse claim_guard.non_trivial_confirmation_challenge.confirmation_token on retry
328    /// to suppress duplicate monitor confirmation prompts for the same payload
329    #[arg(long, conflicts_with_all = ["request_file", "non_trivial_confirmation_file"])]
330    pub non_trivial_confirmation_token: Option<String>,
331
332    /// Full non_trivial_confirmation.v1 payload JSON file (use '-' for stdin)
333    #[arg(long, conflicts_with_all = ["request_file", "non_trivial_confirmation_token"])]
334    pub non_trivial_confirmation_file: Option<String>,
335
336    /// Optional high-level goal for auto-generated intent_handshake on high-impact writes
337    #[arg(long, conflicts_with_all = ["request_file", "intent_handshake_file"])]
338    pub intent_goal: Option<String>,
339
340    /// Full intent_handshake.v1 payload JSON file (use '-' for stdin)
341    #[arg(long, conflicts_with_all = ["request_file", "intent_goal"])]
342    pub intent_handshake_file: Option<String>,
343
344    /// Reuse the confirmation token from a prior confirm-first response for a high-impact retry
345    #[arg(long, conflicts_with_all = ["request_file", "high_impact_confirmation_file"])]
346    pub high_impact_confirmation_token: Option<String>,
347
348    /// Full high_impact_confirmation.v1 payload JSON file (use '-' for stdin)
349    #[arg(long, conflicts_with_all = ["request_file", "high_impact_confirmation_token"])]
350    pub high_impact_confirmation_file: Option<String>,
351
352    /// clarification_resolutions payload JSON file(s); each file may contain one object or an array
353    #[arg(long, conflicts_with = "request_file")]
354    pub clarification_resolution_file: Vec<String>,
355
356    /// Read the blocked structured-write response JSON and auto-reuse its clarification prompt_id
357    #[arg(long)]
358    pub resume_file: Option<String>,
359
360    /// Explicit clarification prompt UUID when retrying without --resume-file
361    #[arg(long)]
362    pub clarification_prompt_id: Option<Uuid>,
363
364    /// Clarification answer for training_vs_test prompts (for example: training_execution)
365    #[arg(long)]
366    pub clarification_route_family: Option<String>,
367
368    /// Clarification answer for protocol-variant prompts (for example: free_arms)
369    #[arg(long)]
370    pub clarification_protocol_variant: Option<String>,
371
372    /// Optional free-text note to persist alongside the clarification answer
373    #[arg(long)]
374    pub clarification_note: Option<String>,
375
376    /// Explicit session state for training writes: ongoing | completed-in-batch
377    #[arg(long, value_enum, conflicts_with = "request_file")]
378    pub session_status: Option<SessionCompletionStatus>,
379
380    /// Internal compatibility flag for server-owned conversational session draft mode
381    #[arg(long, value_enum, conflicts_with = "request_file", hide = true)]
382    pub conversation_draft_mode: Option<ConversationDraftMode>,
383
384    /// Full request payload JSON file for /v2/agent/write-with-proof
385    #[arg(long, conflicts_with_all = ["events_file", "target", "verify_timeout_ms", "non_trivial_confirmation_token", "non_trivial_confirmation_file", "intent_goal", "intent_handshake_file", "high_impact_confirmation_token", "high_impact_confirmation_file", "clarification_resolution_file", "session_status", "conversation_draft_mode"])]
386    pub request_file: Option<String>,
387}
388
389#[derive(Args)]
390pub struct ResolveVisualizationArgs {
391    /// Full request payload JSON file for /v1/agent/visualization/resolve
392    #[arg(long, conflicts_with = "task_intent")]
393    pub request_file: Option<String>,
394
395    /// Task intent (required unless --request-file is used)
396    #[arg(long, required_unless_present = "request_file")]
397    pub task_intent: Option<String>,
398
399    /// auto | always | never
400    #[arg(long)]
401    pub user_preference_override: Option<String>,
402
403    /// low | medium | high
404    #[arg(long)]
405    pub complexity_hint: Option<String>,
406
407    /// Allow rich rendering formats when true (default: true)
408    #[arg(long, default_value_t = true)]
409    pub allow_rich_rendering: bool,
410
411    /// Optional visualization_spec JSON file
412    #[arg(long)]
413    pub spec_file: Option<String>,
414
415    /// Optional telemetry session id
416    #[arg(long)]
417    pub telemetry_session_id: Option<String>,
418}
419
420pub async fn run(api_url: &str, token: Option<&str>, command: AgentCommands) -> i32 {
421    match command {
422        AgentCommands::Capabilities => capabilities(api_url, token).await,
423        AgentCommands::LogTraining(args) => log_training(api_url, token, args).await,
424        AgentCommands::WriteEvent(args) => write_event_vnext(api_url, token, args).await,
425        AgentCommands::WriteCorrection(args) => write_correction_vnext(api_url, token, args).await,
426        AgentCommands::LogTurn(args) => log_turn(api_url, token, args).await,
427        AgentCommands::LoggingBootstrap { intent } => {
428            logging_bootstrap(api_url, token, intent).await
429        }
430        AgentCommands::Context {
431            exercise_limit,
432            strength_limit,
433            custom_limit,
434            task_intent,
435            include_system,
436            budget_tokens,
437        } => {
438            context(
439                api_url,
440                token,
441                exercise_limit,
442                strength_limit,
443                custom_limit,
444                task_intent,
445                include_system,
446                budget_tokens,
447            )
448            .await
449        }
450        AgentCommands::SectionIndex {
451            exercise_limit,
452            strength_limit,
453            custom_limit,
454            task_intent,
455            include_system,
456            budget_tokens,
457        } => {
458            section_index(
459                api_url,
460                token,
461                exercise_limit,
462                strength_limit,
463                custom_limit,
464                task_intent,
465                include_system,
466                budget_tokens,
467            )
468            .await
469        }
470        AgentCommands::SectionFetch {
471            section,
472            limit,
473            cursor,
474            fields,
475            task_intent,
476        } => section_fetch(api_url, token, section, limit, cursor, fields, task_intent).await,
477        AgentCommands::AnswerAdmissibility {
478            task_intent,
479            draft_answer,
480        } => answer_admissibility(api_url, token, task_intent, draft_answer).await,
481        AgentCommands::WriteWithProof(args) => write_with_proof(api_url, token, args).await,
482        AgentCommands::Evidence { command } => match command {
483            AgentEvidenceCommands::Event { event_id } => {
484                evidence_event(api_url, token, event_id).await
485            }
486        },
487        AgentCommands::SetSaveConfirmationMode { mode } => {
488            set_save_confirmation_mode(api_url, token, mode).await
489        }
490        AgentCommands::ResolveVisualization(args) => {
491            resolve_visualization(api_url, token, args).await
492        }
493        AgentCommands::Request(args) => request(api_url, token, args).await,
494    }
495}
496
497async fn capabilities(api_url: &str, token: Option<&str>) -> i32 {
498    api_request(
499        api_url,
500        reqwest::Method::GET,
501        "/v1/agent/capabilities",
502        token,
503        None,
504        &[],
505        &[],
506        false,
507        false,
508    )
509    .await
510}
511
512fn normalize_logging_bootstrap_intent(intent: &str) -> Option<String> {
513    let normalized = intent.trim().to_ascii_lowercase();
514    if normalized.is_empty() {
515        None
516    } else {
517        Some(normalized)
518    }
519}
520
521fn extract_logging_bootstrap_contract(capabilities: &Value) -> Option<Value> {
522    capabilities
523        .pointer("/task_bootstrap_contracts/logging")
524        .cloned()
525        .filter(|value| value.is_object())
526}
527
528fn available_logging_bootstrap_intents(contract: &Value) -> Vec<String> {
529    let Some(recipes) = contract.get("intent_recipes").and_then(Value::as_array) else {
530        return Vec::new();
531    };
532    recipes
533        .iter()
534        .filter_map(|recipe| recipe.get("intent_id").and_then(Value::as_str))
535        .map(str::to_string)
536        .collect()
537}
538
539fn build_logging_bootstrap_output(contract: &Value, intent: Option<&str>) -> Result<Value, Value> {
540    let Some(intent) = intent else {
541        return Ok(contract.clone());
542    };
543    let Some(normalized_intent) = normalize_logging_bootstrap_intent(intent) else {
544        return Err(json!({
545            "error": "usage_error",
546            "message": "--intent must not be empty",
547        }));
548    };
549    let recipes = contract
550        .get("intent_recipes")
551        .and_then(Value::as_array)
552        .cloned()
553        .unwrap_or_default();
554    let Some(recipe) = recipes.into_iter().find(|recipe| {
555        recipe
556            .get("intent_id")
557            .and_then(Value::as_str)
558            .map(|value| value.eq_ignore_ascii_case(&normalized_intent))
559            .unwrap_or(false)
560    }) else {
561        return Err(json!({
562            "error": "usage_error",
563            "message": format!("Unknown logging bootstrap intent: {intent}"),
564            "available_intents": available_logging_bootstrap_intents(contract),
565        }));
566    };
567    Ok(json!({
568        "schema_version": contract.get("schema_version").cloned().unwrap_or(Value::Null),
569        "task_family": contract.get("task_family").cloned().unwrap_or(Value::Null),
570        "bootstrap_surface": contract.get("bootstrap_surface").cloned().unwrap_or(Value::Null),
571        "intent_recipe": recipe,
572        "save_states": contract.get("save_states").cloned().unwrap_or_else(|| json!([])),
573        "upgrade_hints": contract.get("upgrade_hints").cloned().unwrap_or_else(|| json!([])),
574        "integrity_guards": contract.get("integrity_guards").cloned().unwrap_or_else(|| json!([])),
575    }))
576}
577
578fn extract_preferred_structured_write_endpoint(capabilities: &Value) -> Option<String> {
579    capabilities
580        .get("preferred_structured_write_endpoint")
581        .or_else(|| capabilities.get("preferred_write_endpoint"))
582        .and_then(Value::as_str)
583        .map(str::trim)
584        .filter(|value| *value == "/v2/agent/write-with-proof")
585        .map(str::to_string)
586}
587
588async fn negotiated_write_with_proof_endpoint(api_url: &str, token: Option<&str>) -> String {
589    match raw_api_request(
590        api_url,
591        reqwest::Method::GET,
592        "/v1/agent/capabilities",
593        token,
594    )
595    .await
596    {
597        Ok((status, body)) if (200..=299).contains(&status) => {
598            extract_preferred_structured_write_endpoint(&body)
599                .unwrap_or_else(|| "/v2/agent/write-with-proof".to_string())
600        }
601        _ => "/v2/agent/write-with-proof".to_string(),
602    }
603}
604
605const LOG_TRAINING_SCHEMA_VERSION: &str = "write_training.v1";
606const LOG_TURN_SCHEMA_VERSION: &str = "agent_evidence_ingress_request.v1";
607const CLARIFICATION_RESOLUTION_SCHEMA_VERSION: &str = "agent_logging_clarification_resolution.v1";
608
609#[derive(Debug, Clone, PartialEq, Eq)]
610struct ResumeClarificationPrompt {
611    prompt_id: Uuid,
612    scope_kind: String,
613    accepted_resolution_fields: Vec<String>,
614}
615
616pub async fn log_turn(api_url: &str, token: Option<&str>, args: LogTurnArgs) -> i32 {
617    let body = build_log_turn_request(&args);
618    api_request(
619        api_url,
620        reqwest::Method::POST,
621        "/v3/agent/evidence",
622        token,
623        Some(body),
624        &[],
625        &[],
626        false,
627        false,
628    )
629    .await
630}
631
632pub async fn log_training(api_url: &str, token: Option<&str>, args: LogTrainingArgs) -> i32 {
633    let body = build_log_training_request(&args);
634    api_request(
635        api_url,
636        reqwest::Method::POST,
637        "/v4/agent/write-training",
638        token,
639        Some(body),
640        &[],
641        &[],
642        false,
643        false,
644    )
645    .await
646}
647
648pub async fn write_event_vnext(api_url: &str, token: Option<&str>, args: WriteEventArgs) -> i32 {
649    let body = resolve_write_vnext_request(
650        args.data.as_deref(),
651        args.request_file.as_deref(),
652        "write-event",
653        "/v4/agent/write-event",
654    );
655    api_request(
656        api_url,
657        reqwest::Method::POST,
658        "/v4/agent/write-event",
659        token,
660        Some(body),
661        &[],
662        &[],
663        false,
664        false,
665    )
666    .await
667}
668
669pub async fn write_correction_vnext(
670    api_url: &str,
671    token: Option<&str>,
672    args: WriteCorrectionArgs,
673) -> i32 {
674    let body = resolve_write_vnext_request(
675        args.data.as_deref(),
676        args.request_file.as_deref(),
677        "write-correction",
678        "/v4/agent/write-correction",
679    );
680    api_request(
681        api_url,
682        reqwest::Method::POST,
683        "/v4/agent/write-correction",
684        token,
685        Some(body),
686        &[],
687        &[],
688        false,
689        false,
690    )
691    .await
692}
693
694fn build_log_turn_request(args: &LogTurnArgs) -> Value {
695    let message = resolve_log_turn_message(args.message.as_deref(), args.message_file.as_deref());
696    let modality = normalize_non_empty_arg(args.modality.as_deref(), "--modality");
697    let recorded_at = normalize_rfc3339_arg(args.recorded_at.as_deref(), "--recorded-at");
698    let observed_at = normalize_rfc3339_arg(args.observed_at.as_deref(), "--observed-at");
699    let session_id = normalize_non_empty_arg(args.session_id.as_deref(), "--session-id");
700    let idempotency_key =
701        normalize_non_empty_arg(args.idempotency_key.as_deref(), "--idempotency-key");
702
703    let mut body = json!({
704        "schema_version": LOG_TURN_SCHEMA_VERSION,
705        "text_evidence": message,
706        "modality": modality.unwrap_or_else(|| "chat_message".to_string()),
707        "source": {
708            "surface": "cli",
709            "client": "kura-cli",
710            "command_family": "log_turn"
711        },
712        "metadata": {
713            "ingress_surface": "kura-cli.log-turn"
714        }
715    });
716    if let Some(session_id) = session_id {
717        body["session_hint"] = json!({
718            "session_id": session_id,
719        });
720    }
721    if let Some(recorded_at) = recorded_at {
722        body["recorded_at"] = json!(recorded_at);
723    }
724    if let Some(observed_at) = observed_at {
725        body["observed_at"] = json!(observed_at);
726    }
727    if let Some(idempotency_key) = idempotency_key {
728        body["idempotency_key"] = json!(idempotency_key);
729    }
730    body
731}
732
733fn build_log_training_request(args: &LogTrainingArgs) -> Value {
734    let mut body = resolve_log_training_request(args.data.as_deref(), args.request_file.as_deref());
735    let object = body.as_object_mut().unwrap_or_else(|| {
736        exit_error(
737            "log-training payload must be a JSON object",
738            Some(
739                "Pass a JSON object with date plus entries. Use `kura dev schema dev agent log-training` for the explicit load block and happy-path examples.",
740            ),
741        )
742    });
743
744    object
745        .entry("schema_version".to_string())
746        .or_insert_with(|| json!(LOG_TRAINING_SCHEMA_VERSION));
747
748    let source_context_value = object
749        .entry("source_context".to_string())
750        .or_insert_with(|| json!({}));
751    let source_context = source_context_value.as_object_mut().unwrap_or_else(|| {
752        exit_error(
753            "log-training source_context must be a JSON object when provided",
754            Some("Use source_context as an object, or omit it."),
755        )
756    });
757    source_context
758        .entry("surface".to_string())
759        .or_insert_with(|| json!("cli"));
760    source_context
761        .entry("client".to_string())
762        .or_insert_with(|| json!("kura-cli"));
763    source_context
764        .entry("command_family".to_string())
765        .or_insert_with(|| json!("write_training"));
766
767    body
768}
769
770async fn logging_bootstrap(api_url: &str, token: Option<&str>, intent: Option<String>) -> i32 {
771    let (status, body) = raw_api_request(
772        api_url,
773        reqwest::Method::GET,
774        "/v1/agent/capabilities",
775        token,
776    )
777    .await
778    .unwrap_or_else(|error| {
779        exit_error(
780            &format!("Failed to fetch /v1/agent/capabilities for logging bootstrap: {error}"),
781            Some("Retry once the API is reachable, or fall back to `kura agent capabilities`."),
782        )
783    });
784
785    if !(200..=299).contains(&status) {
786        print_json_stderr(&body);
787        return if (400..500).contains(&status) { 1 } else { 2 };
788    }
789
790    let Some(contract) = extract_logging_bootstrap_contract(&body) else {
791        exit_error(
792            "agent capabilities response is missing task_bootstrap_contracts.logging",
793            Some("Retry after `kura agent capabilities` succeeds, or inspect the full manifest."),
794        );
795    };
796
797    match build_logging_bootstrap_output(&contract, intent.as_deref()) {
798        Ok(output) => {
799            print_json_stdout(&output);
800            0
801        }
802        Err(error) => {
803            print_json_stderr(&error);
804            4
805        }
806    }
807}
808
809pub async fn context(
810    api_url: &str,
811    token: Option<&str>,
812    exercise_limit: Option<u32>,
813    strength_limit: Option<u32>,
814    custom_limit: Option<u32>,
815    task_intent: Option<String>,
816    include_system: Option<bool>,
817    budget_tokens: Option<u32>,
818) -> i32 {
819    let query = build_context_query(
820        exercise_limit,
821        strength_limit,
822        custom_limit,
823        task_intent,
824        include_system,
825        budget_tokens,
826    );
827
828    api_request(
829        api_url,
830        reqwest::Method::GET,
831        "/v1/agent/context",
832        token,
833        None,
834        &query,
835        &[],
836        false,
837        false,
838    )
839    .await
840}
841
842pub async fn section_index(
843    api_url: &str,
844    token: Option<&str>,
845    exercise_limit: Option<u32>,
846    strength_limit: Option<u32>,
847    custom_limit: Option<u32>,
848    task_intent: Option<String>,
849    include_system: Option<bool>,
850    budget_tokens: Option<u32>,
851) -> i32 {
852    let query = build_context_query(
853        exercise_limit,
854        strength_limit,
855        custom_limit,
856        task_intent,
857        include_system,
858        budget_tokens,
859    );
860    api_request(
861        api_url,
862        reqwest::Method::GET,
863        "/v1/agent/context/section-index",
864        token,
865        None,
866        &query,
867        &[],
868        false,
869        false,
870    )
871    .await
872}
873
874pub async fn section_fetch(
875    api_url: &str,
876    token: Option<&str>,
877    section: String,
878    limit: Option<u32>,
879    cursor: Option<String>,
880    fields: Option<String>,
881    task_intent: Option<String>,
882) -> i32 {
883    let query = build_section_fetch_query(section, limit, cursor, fields, task_intent);
884    api_request(
885        api_url,
886        reqwest::Method::GET,
887        "/v1/agent/context/section-fetch",
888        token,
889        None,
890        &query,
891        &[],
892        false,
893        false,
894    )
895    .await
896}
897
898pub async fn answer_admissibility(
899    api_url: &str,
900    token: Option<&str>,
901    task_intent: String,
902    draft_answer: String,
903) -> i32 {
904    let body = json!({
905        "task_intent": task_intent,
906        "draft_answer": draft_answer,
907    });
908    api_request(
909        api_url,
910        reqwest::Method::POST,
911        "/v1/agent/answer-admissibility",
912        token,
913        Some(body),
914        &[],
915        &[],
916        false,
917        false,
918    )
919    .await
920}
921
922async fn evidence_event(api_url: &str, token: Option<&str>, event_id: Uuid) -> i32 {
923    let path = format!("/v1/agent/evidence/event/{event_id}");
924    api_request(
925        api_url,
926        reqwest::Method::GET,
927        &path,
928        token,
929        None,
930        &[],
931        &[],
932        false,
933        false,
934    )
935    .await
936}
937
938async fn set_save_confirmation_mode(
939    api_url: &str,
940    token: Option<&str>,
941    mode: SaveConfirmationMode,
942) -> i32 {
943    let body = json!({
944        "timestamp": Utc::now().to_rfc3339(),
945        "event_type": "preference.set",
946        "data": {
947            "key": "save_confirmation_mode",
948            "value": mode.as_str(),
949        },
950        "metadata": {
951            "source": "cli",
952            "agent": "kura-cli",
953            "idempotency_key": Uuid::now_v7().to_string(),
954        }
955    });
956    api_request(
957        api_url,
958        reqwest::Method::POST,
959        "/v1/events",
960        token,
961        Some(body),
962        &[],
963        &[],
964        false,
965        false,
966    )
967    .await
968}
969
970async fn request(api_url: &str, token: Option<&str>, args: AgentRequestArgs) -> i32 {
971    let method = parse_method(&args.method);
972    let path = normalize_agent_path(&args.path);
973    let query = parse_query_pairs(&args.query);
974    let headers = parse_headers(&args.header);
975    let body = resolve_body(args.data.as_deref(), args.data_file.as_deref());
976
977    api_request(
978        api_url,
979        method,
980        &path,
981        token,
982        body,
983        &query,
984        &headers,
985        args.raw,
986        args.include,
987    )
988    .await
989}
990
991pub async fn write_with_proof(api_url: &str, token: Option<&str>, args: WriteWithProofArgs) -> i32 {
992    let body = if let Some(file) = args.request_file.as_deref() {
993        let mut request = load_full_request(file);
994        if let Some(clarification_resolutions) = resolve_clarification_resolutions(
995            &args.clarification_resolution_file,
996            args.resume_file.as_deref(),
997            args.clarification_prompt_id,
998            args.clarification_route_family.as_deref(),
999            args.clarification_protocol_variant.as_deref(),
1000            args.clarification_note.as_deref(),
1001        ) {
1002            request["clarification_resolutions"] = json!(clarification_resolutions);
1003        }
1004        request
1005    } else {
1006        build_request_from_events_and_targets(
1007            api_url,
1008            token,
1009            args.events_file.as_deref().unwrap_or(""),
1010            &args.target,
1011            args.verify_timeout_ms,
1012            args.intent_goal.as_deref(),
1013            args.intent_handshake_file.as_deref(),
1014            args.high_impact_confirmation_token.as_deref(),
1015            args.high_impact_confirmation_file.as_deref(),
1016            args.non_trivial_confirmation_token.as_deref(),
1017            args.non_trivial_confirmation_file.as_deref(),
1018            &args.clarification_resolution_file,
1019            args.resume_file.as_deref(),
1020            args.clarification_prompt_id,
1021            args.clarification_route_family.as_deref(),
1022            args.clarification_protocol_variant.as_deref(),
1023            args.clarification_note.as_deref(),
1024            args.session_status,
1025            args.conversation_draft_mode,
1026        )
1027        .await
1028    };
1029    let write_endpoint = negotiated_write_with_proof_endpoint(api_url, token).await;
1030
1031    api_request(
1032        api_url,
1033        reqwest::Method::POST,
1034        &write_endpoint,
1035        token,
1036        Some(body),
1037        &[],
1038        &[],
1039        false,
1040        false,
1041    )
1042    .await
1043}
1044
1045async fn resolve_visualization(
1046    api_url: &str,
1047    token: Option<&str>,
1048    args: ResolveVisualizationArgs,
1049) -> i32 {
1050    let body = if let Some(file) = args.request_file.as_deref() {
1051        match read_json_from_file(file) {
1052            Ok(v) => v,
1053            Err(e) => exit_error(
1054                &e,
1055                Some("Provide a valid JSON payload for /v1/agent/visualization/resolve."),
1056            ),
1057        }
1058    } else {
1059        let task_intent = match args.task_intent {
1060            Some(intent) if !intent.trim().is_empty() => intent,
1061            _ => exit_error(
1062                "task_intent is required unless --request-file is used.",
1063                Some("Use --task-intent or provide --request-file."),
1064            ),
1065        };
1066
1067        let mut body = json!({
1068            "task_intent": task_intent,
1069            "allow_rich_rendering": args.allow_rich_rendering
1070        });
1071        if let Some(mode) = args.user_preference_override {
1072            body["user_preference_override"] = json!(mode);
1073        }
1074        if let Some(complexity) = args.complexity_hint {
1075            body["complexity_hint"] = json!(complexity);
1076        }
1077        if let Some(session_id) = args.telemetry_session_id {
1078            body["telemetry_session_id"] = json!(session_id);
1079        }
1080        if let Some(spec_file) = args.spec_file.as_deref() {
1081            let spec = match read_json_from_file(spec_file) {
1082                Ok(v) => v,
1083                Err(e) => exit_error(&e, Some("Provide a valid JSON visualization_spec payload.")),
1084            };
1085            body["visualization_spec"] = spec;
1086        }
1087        body
1088    };
1089
1090    api_request(
1091        api_url,
1092        reqwest::Method::POST,
1093        "/v1/agent/visualization/resolve",
1094        token,
1095        Some(body),
1096        &[],
1097        &[],
1098        false,
1099        false,
1100    )
1101    .await
1102}
1103
1104fn parse_method(raw: &str) -> reqwest::Method {
1105    match raw.to_uppercase().as_str() {
1106        "GET" => reqwest::Method::GET,
1107        "POST" => reqwest::Method::POST,
1108        "PUT" => reqwest::Method::PUT,
1109        "DELETE" => reqwest::Method::DELETE,
1110        "PATCH" => reqwest::Method::PATCH,
1111        "HEAD" => reqwest::Method::HEAD,
1112        "OPTIONS" => reqwest::Method::OPTIONS,
1113        other => exit_error(
1114            &format!("Unknown HTTP method: {other}"),
1115            Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
1116        ),
1117    }
1118}
1119
1120fn normalize_agent_path(raw: &str) -> String {
1121    let trimmed = raw.trim();
1122    if trimmed.is_empty() {
1123        exit_error(
1124            "Agent path must not be empty.",
1125            Some("Use relative path like 'context' or absolute path '/v1/agent/context'."),
1126        );
1127    }
1128
1129    if trimmed.starts_with("/v1/agent") || trimmed.starts_with("/v2/agent") {
1130        return trimmed.to_string();
1131    }
1132    if trimmed.starts_with("v1/agent") || trimmed.starts_with("v2/agent") {
1133        return format!("/{trimmed}");
1134    }
1135    if trimmed.starts_with('/') {
1136        exit_error(
1137            &format!("Invalid agent path '{trimmed}'."),
1138            Some(
1139                "`kura agent request` only supports /v1/agent/* or /v2/agent/* paths. Use `kura api` for other endpoints.",
1140            ),
1141        );
1142    }
1143
1144    format!("/v1/agent/{}", trimmed.trim_start_matches('/'))
1145}
1146
1147fn parse_query_pairs(raw: &[String]) -> Vec<(String, String)> {
1148    raw.iter()
1149        .map(|entry| {
1150            entry.split_once('=').map_or_else(
1151                || {
1152                    exit_error(
1153                        &format!("Invalid query parameter: '{entry}'"),
1154                        Some("Format: key=value, e.g. --query event_type=set.logged"),
1155                    )
1156                },
1157                |(k, v)| (k.to_string(), v.to_string()),
1158            )
1159        })
1160        .collect()
1161}
1162
1163fn build_context_query(
1164    exercise_limit: Option<u32>,
1165    strength_limit: Option<u32>,
1166    custom_limit: Option<u32>,
1167    task_intent: Option<String>,
1168    include_system: Option<bool>,
1169    budget_tokens: Option<u32>,
1170) -> Vec<(String, String)> {
1171    let mut query = Vec::new();
1172    if let Some(v) = exercise_limit {
1173        query.push(("exercise_limit".to_string(), v.to_string()));
1174    }
1175    if let Some(v) = strength_limit {
1176        query.push(("strength_limit".to_string(), v.to_string()));
1177    }
1178    if let Some(v) = custom_limit {
1179        query.push(("custom_limit".to_string(), v.to_string()));
1180    }
1181    if let Some(v) = task_intent {
1182        query.push(("task_intent".to_string(), v));
1183    }
1184    if let Some(v) = include_system {
1185        query.push(("include_system".to_string(), v.to_string()));
1186    }
1187    if let Some(v) = budget_tokens {
1188        query.push(("budget_tokens".to_string(), v.to_string()));
1189    }
1190    query
1191}
1192
1193fn build_section_fetch_query(
1194    section: String,
1195    limit: Option<u32>,
1196    cursor: Option<String>,
1197    fields: Option<String>,
1198    task_intent: Option<String>,
1199) -> Vec<(String, String)> {
1200    let section = section.trim();
1201    if section.is_empty() {
1202        exit_error(
1203            "section must not be empty",
1204            Some("Provide --section using an id from /v1/agent/context/section-index"),
1205        );
1206    }
1207    let mut query = vec![("section".to_string(), section.to_string())];
1208    if let Some(v) = limit {
1209        query.push(("limit".to_string(), v.to_string()));
1210    }
1211    if let Some(v) = cursor {
1212        query.push(("cursor".to_string(), v));
1213    }
1214    if let Some(v) = fields {
1215        query.push(("fields".to_string(), v));
1216    }
1217    if let Some(v) = task_intent {
1218        query.push(("task_intent".to_string(), v));
1219    }
1220    query
1221}
1222
1223fn parse_headers(raw: &[String]) -> Vec<(String, String)> {
1224    raw.iter()
1225        .map(|entry| {
1226            entry.split_once(':').map_or_else(
1227                || {
1228                    exit_error(
1229                        &format!("Invalid header: '{entry}'"),
1230                        Some("Format: Key:Value, e.g. --header Content-Type:application/json"),
1231                    )
1232                },
1233                |(k, v)| (k.trim().to_string(), v.trim().to_string()),
1234            )
1235        })
1236        .collect()
1237}
1238
1239fn resolve_body(data: Option<&str>, data_file: Option<&str>) -> Option<serde_json::Value> {
1240    if let Some(raw) = data {
1241        match serde_json::from_str(raw) {
1242            Ok(v) => return Some(v),
1243            Err(e) => exit_error(
1244                &format!("Invalid JSON in --data: {e}"),
1245                Some("Provide valid JSON string"),
1246            ),
1247        }
1248    }
1249
1250    if let Some(file) = data_file {
1251        return match read_json_from_file(file) {
1252            Ok(v) => Some(v),
1253            Err(e) => exit_error(&e, Some("Provide a valid JSON file or use '-' for stdin")),
1254        };
1255    }
1256
1257    None
1258}
1259
1260fn read_text_from_file(path: &str, docs_hint: &str) -> String {
1261    let raw = if path == "-" {
1262        let mut buffer = String::new();
1263        std::io::stdin()
1264            .read_to_string(&mut buffer)
1265            .unwrap_or_else(|error| {
1266                exit_error(&format!("Failed to read stdin: {error}"), Some(docs_hint))
1267            });
1268        buffer
1269    } else {
1270        std::fs::read_to_string(path).unwrap_or_else(|error| {
1271            exit_error(
1272                &format!("Failed to read file '{path}': {error}"),
1273                Some(docs_hint),
1274            )
1275        })
1276    };
1277    let trimmed = raw.trim();
1278    if trimmed.is_empty() {
1279        exit_error("log-turn message must not be empty", Some(docs_hint));
1280    }
1281    trimmed.to_string()
1282}
1283
1284fn resolve_log_turn_message(message: Option<&str>, message_file: Option<&str>) -> String {
1285    if let Some(path) = message_file {
1286        return read_text_from_file(
1287            path,
1288            "Use MESSAGE directly or provide --message-file with raw text (use '-' for stdin).",
1289        );
1290    }
1291
1292    let message = message
1293        .map(str::trim)
1294        .filter(|value| !value.is_empty())
1295        .unwrap_or_else(|| {
1296            exit_error(
1297                "MESSAGE must not be empty",
1298                Some("Pass the raw user turn directly, for example: `kura log \"bench 4x5 80\"`."),
1299            )
1300        });
1301    message.to_string()
1302}
1303
1304fn resolve_log_training_request(data: Option<&str>, request_file: Option<&str>) -> Value {
1305    match (data, request_file) {
1306        (Some(raw), None) => serde_json::from_str::<Value>(raw).unwrap_or_else(|error| {
1307            exit_error(
1308                &format!("failed to parse --data as JSON: {error}"),
1309                Some("Pass a full routine training JSON object."),
1310            )
1311        }),
1312        (None, Some(path)) => read_json_from_file(path).unwrap_or_else(|error| {
1313            exit_error(
1314                &format!("failed to read log-training request file: {error}"),
1315                Some("Pass a JSON object file, or use --data with inline JSON."),
1316            )
1317        }),
1318        _ => exit_error(
1319            "provide exactly one of --data or --request-file",
1320            Some("Use `kura log --request-file payload.json` for routine training logging."),
1321        ),
1322    }
1323}
1324
1325fn resolve_write_vnext_request(
1326    data: Option<&str>,
1327    request_file: Option<&str>,
1328    command_name: &str,
1329    endpoint: &str,
1330) -> Value {
1331    match (data, request_file) {
1332        (Some(raw), None) => serde_json::from_str::<Value>(raw).unwrap_or_else(|error| {
1333            exit_error(
1334                &format!("failed to parse --data as JSON: {error}"),
1335                Some(&format!("Pass a full JSON object for {endpoint}.")),
1336            )
1337        }),
1338        (None, Some(path)) => read_json_from_file(path).unwrap_or_else(|error| {
1339            exit_error(
1340                &format!("failed to read {command_name} request file: {error}"),
1341                Some(&format!(
1342                    "Pass a JSON object file for {endpoint}, or use --data with inline JSON."
1343                )),
1344            )
1345        }),
1346        _ => exit_error(
1347            "provide exactly one of --data or --request-file",
1348            Some(&format!(
1349                "Use `kura agent {command_name} --request-file payload.json` for vNext writes."
1350            )),
1351        ),
1352    }
1353}
1354
1355fn normalize_non_empty_arg(raw: Option<&str>, field_name: &str) -> Option<String> {
1356    raw.map(str::trim)
1357        .filter(|value| !value.is_empty())
1358        .map(str::to_string)
1359        .or_else(|| {
1360            if raw.is_some() {
1361                exit_error(
1362                    &format!("{field_name} must not be empty"),
1363                    Some("Remove the flag or provide a non-empty value."),
1364                );
1365            }
1366            None
1367        })
1368}
1369
1370fn normalize_rfc3339_arg(raw: Option<&str>, field_name: &str) -> Option<String> {
1371    let normalized = normalize_non_empty_arg(raw, field_name)?;
1372    let parsed = DateTime::parse_from_rfc3339(&normalized).unwrap_or_else(|error| {
1373        exit_error(
1374            &format!("{field_name} must be RFC3339: {error}"),
1375            Some("Example: 2026-03-15T09:30:00Z"),
1376        )
1377    });
1378    Some(parsed.with_timezone(&Utc).to_rfc3339())
1379}
1380
1381fn load_full_request(path: &str) -> serde_json::Value {
1382    let mut payload = match read_json_from_file(path) {
1383        Ok(v) => v,
1384        Err(e) => exit_error(
1385            &e,
1386            Some(
1387                "Provide JSON with events, read_after_write_targets, and optional structured-write fields such as verify_timeout_ms or clarification_resolutions.",
1388            ),
1389        ),
1390    };
1391    if payload
1392        .get("events")
1393        .and_then(|value| value.as_array())
1394        .is_none()
1395    {
1396        exit_error(
1397            "request payload must include an events array",
1398            Some(
1399                "Use --request-file with {\"events\": [...], \"read_after_write_targets\": [...]} and optional clarification_resolutions/high_impact_confirmation fields.",
1400            ),
1401        );
1402    }
1403    if payload
1404        .get("read_after_write_targets")
1405        .and_then(|value| value.as_array())
1406        .is_none()
1407    {
1408        let conversation_draft_mode = payload
1409            .get("conversation_draft")
1410            .and_then(|value| value.get("mode"))
1411            .and_then(Value::as_str)
1412            .map(|value| value.trim().to_ascii_lowercase());
1413        if conversation_draft_mode.as_deref() == Some("append") {
1414            payload["read_after_write_targets"] = json!([]);
1415            return payload;
1416        }
1417        exit_error(
1418            "request payload must include read_after_write_targets array",
1419            Some("Set read_after_write_targets to [{\"projection_type\":\"...\",\"key\":\"...\"}]"),
1420        );
1421    }
1422    payload
1423}
1424
1425fn unwrap_resume_payload<'a>(payload: &'a Value) -> &'a Value {
1426    if payload
1427        .get("schema_version")
1428        .and_then(Value::as_str)
1429        .is_some_and(|value| value == "write_preflight.v1")
1430    {
1431        return payload;
1432    }
1433    if let Some(body) = payload.get("body") {
1434        return unwrap_resume_payload(body);
1435    }
1436    if let Some(received) = payload.get("received") {
1437        return unwrap_resume_payload(received);
1438    }
1439    payload
1440}
1441
1442fn extract_resume_clarification_prompts(payload: &Value) -> Vec<ResumeClarificationPrompt> {
1443    let root = unwrap_resume_payload(payload);
1444    root.get("blockers")
1445        .and_then(Value::as_array)
1446        .into_iter()
1447        .flatten()
1448        .filter_map(|blocker| blocker.get("details"))
1449        .filter_map(|details| details.get("clarification_prompts"))
1450        .filter_map(Value::as_array)
1451        .flatten()
1452        .filter_map(|prompt| {
1453            let prompt_id = prompt.get("prompt_id")?.as_str()?;
1454            let prompt_id = Uuid::parse_str(prompt_id).ok()?;
1455            let scope_kind = prompt.get("scope_kind")?.as_str()?.trim().to_string();
1456            let accepted_resolution_fields = prompt
1457                .get("accepted_resolution_fields")
1458                .and_then(Value::as_array)
1459                .map(|fields| {
1460                    fields
1461                        .iter()
1462                        .filter_map(Value::as_str)
1463                        .map(str::trim)
1464                        .filter(|field| !field.is_empty())
1465                        .map(str::to_string)
1466                        .collect::<Vec<_>>()
1467                })
1468                .unwrap_or_default();
1469            Some(ResumeClarificationPrompt {
1470                prompt_id,
1471                scope_kind,
1472                accepted_resolution_fields,
1473            })
1474        })
1475        .collect()
1476}
1477
1478fn select_resume_clarification_prompt(
1479    prompts: &[ResumeClarificationPrompt],
1480    explicit_prompt_id: Option<Uuid>,
1481) -> Result<ResumeClarificationPrompt, String> {
1482    if prompts.is_empty() {
1483        return Err(
1484            "resume_file does not contain a clarification_required blocker with clarification_prompts"
1485                .to_string(),
1486        );
1487    }
1488
1489    if let Some(prompt_id) = explicit_prompt_id {
1490        return prompts
1491            .iter()
1492            .find(|prompt| prompt.prompt_id == prompt_id)
1493            .cloned()
1494            .ok_or_else(|| {
1495                format!("resume_file does not contain clarification prompt {prompt_id}")
1496            });
1497    }
1498
1499    if prompts.len() == 1 {
1500        return Ok(prompts[0].clone());
1501    }
1502
1503    Err(
1504        "resume_file contains multiple clarification prompts; provide --clarification-prompt-id"
1505            .to_string(),
1506    )
1507}
1508
1509fn load_resume_clarification_prompt(
1510    resume_file: &str,
1511    explicit_prompt_id: Option<Uuid>,
1512) -> ResumeClarificationPrompt {
1513    let payload = read_json_from_file(resume_file).unwrap_or_else(|error| {
1514        exit_error(
1515            &error,
1516            Some("Provide the blocked structured-write response JSON via --resume-file."),
1517        )
1518    });
1519    let prompts = extract_resume_clarification_prompts(&payload);
1520    select_resume_clarification_prompt(&prompts, explicit_prompt_id).unwrap_or_else(|error| {
1521        exit_error(
1522            &error,
1523            Some(
1524                "Use the prior blocked structured-write response body, or pass --clarification-prompt-id when multiple prompts are present.",
1525            ),
1526        )
1527    })
1528}
1529
1530async fn build_request_from_events_and_targets(
1531    api_url: &str,
1532    token: Option<&str>,
1533    events_file: &str,
1534    raw_targets: &[String],
1535    verify_timeout_ms: Option<u64>,
1536    intent_goal: Option<&str>,
1537    intent_handshake_file: Option<&str>,
1538    high_impact_confirmation_token: Option<&str>,
1539    high_impact_confirmation_file: Option<&str>,
1540    non_trivial_confirmation_token: Option<&str>,
1541    non_trivial_confirmation_file: Option<&str>,
1542    clarification_resolution_files: &[String],
1543    resume_file: Option<&str>,
1544    clarification_prompt_id: Option<Uuid>,
1545    clarification_route_family: Option<&str>,
1546    clarification_protocol_variant: Option<&str>,
1547    clarification_note: Option<&str>,
1548    session_status: Option<SessionCompletionStatus>,
1549    conversation_draft_mode: Option<ConversationDraftMode>,
1550) -> serde_json::Value {
1551    if raw_targets.is_empty() && conversation_draft_mode != Some(ConversationDraftMode::Append) {
1552        exit_error(
1553            "--target is required when --request-file is not used",
1554            Some("Repeat --target projection_type:key for read-after-write checks."),
1555        );
1556    }
1557
1558    let parsed_targets = parse_targets(raw_targets);
1559    let events_payload = match read_json_from_file(events_file) {
1560        Ok(v) => v,
1561        Err(e) => exit_error(
1562            &e,
1563            Some("Provide --events-file as JSON array or object with events array."),
1564        ),
1565    };
1566
1567    let events = extract_events_array(events_payload);
1568    let intent_handshake =
1569        resolve_intent_handshake(api_url, token, &events, intent_goal, intent_handshake_file).await;
1570    let high_impact_confirmation = resolve_high_impact_confirmation(
1571        high_impact_confirmation_token,
1572        high_impact_confirmation_file,
1573    );
1574    let non_trivial_confirmation = resolve_non_trivial_confirmation(
1575        non_trivial_confirmation_token,
1576        non_trivial_confirmation_file,
1577    );
1578    let clarification_resolutions = resolve_clarification_resolutions(
1579        clarification_resolution_files,
1580        resume_file,
1581        clarification_prompt_id,
1582        clarification_route_family,
1583        clarification_protocol_variant,
1584        clarification_note,
1585    );
1586    build_write_with_proof_request(
1587        events,
1588        parsed_targets,
1589        verify_timeout_ms,
1590        intent_handshake,
1591        high_impact_confirmation,
1592        non_trivial_confirmation,
1593        clarification_resolutions,
1594        session_status,
1595        conversation_draft_mode,
1596    )
1597}
1598
1599fn resolve_optional_object_file(
1600    confirmation_file: Option<&str>,
1601    field_name: &str,
1602    docs_hint: &str,
1603) -> Option<serde_json::Value> {
1604    if let Some(path) = confirmation_file {
1605        let payload = match read_json_from_file(path) {
1606            Ok(v) => v,
1607            Err(e) => exit_error(&e, Some(docs_hint)),
1608        };
1609        if !payload.is_object() {
1610            exit_error(
1611                &format!("{field_name} payload must be a JSON object"),
1612                Some(docs_hint),
1613            );
1614        }
1615        return Some(payload);
1616    }
1617    None
1618}
1619
1620fn build_confirmation_payload(
1621    schema_version: &str,
1622    confirmation_token: &str,
1623    docs_hint: &str,
1624) -> serde_json::Value {
1625    let token = confirmation_token.trim();
1626    if token.is_empty() {
1627        exit_error(
1628            &format!("{schema_version} confirmation token must not be empty"),
1629            Some(docs_hint),
1630        );
1631    }
1632    json!({
1633        "schema_version": schema_version,
1634        "confirmed": true,
1635        "confirmed_at": Utc::now().to_rfc3339(),
1636        "confirmation_token": token,
1637    })
1638}
1639
1640fn resolve_non_trivial_confirmation(
1641    confirmation_token: Option<&str>,
1642    confirmation_file: Option<&str>,
1643) -> Option<serde_json::Value> {
1644    resolve_optional_object_file(
1645        confirmation_file,
1646        "non_trivial_confirmation",
1647        "Provide a valid JSON object for non_trivial_confirmation.v1.",
1648    )
1649    .or_else(|| confirmation_token.map(build_non_trivial_confirmation_from_token))
1650}
1651
1652fn resolve_high_impact_confirmation(
1653    confirmation_token: Option<&str>,
1654    confirmation_file: Option<&str>,
1655) -> Option<serde_json::Value> {
1656    resolve_optional_object_file(
1657        confirmation_file,
1658        "high_impact_confirmation",
1659        "Provide a valid JSON object for high_impact_confirmation.v1.",
1660    )
1661    .or_else(|| confirmation_token.map(build_high_impact_confirmation_from_token))
1662}
1663
1664fn build_non_trivial_confirmation_from_token(confirmation_token: &str) -> serde_json::Value {
1665    build_confirmation_payload(
1666        "non_trivial_confirmation.v1",
1667        confirmation_token,
1668        "Use the confirmation token from claim_guard.non_trivial_confirmation_challenge.",
1669    )
1670}
1671
1672fn build_high_impact_confirmation_from_token(confirmation_token: &str) -> serde_json::Value {
1673    build_confirmation_payload(
1674        "high_impact_confirmation.v1",
1675        confirmation_token,
1676        "Use the confirmation token from the prior high-impact confirm-first response.",
1677    )
1678}
1679
1680fn resolve_clarification_resolutions(
1681    clarification_resolution_files: &[String],
1682    resume_file: Option<&str>,
1683    clarification_prompt_id: Option<Uuid>,
1684    clarification_route_family: Option<&str>,
1685    clarification_protocol_variant: Option<&str>,
1686    clarification_note: Option<&str>,
1687) -> Option<Vec<serde_json::Value>> {
1688    let mut resolutions = Vec::new();
1689    for path in clarification_resolution_files {
1690        let payload = match read_json_from_file(path) {
1691            Ok(v) => v,
1692            Err(e) => exit_error(
1693                &e,
1694                Some("Provide a valid JSON object or array for clarification_resolutions entries."),
1695            ),
1696        };
1697        match payload {
1698            Value::Object(_) => resolutions.push(payload),
1699            Value::Array(entries) => {
1700                if entries.is_empty() {
1701                    exit_error(
1702                        "clarification_resolution_file must not contain an empty array",
1703                        Some("Provide one resolution object or an array of resolution objects."),
1704                    );
1705                }
1706                for (index, entry) in entries.into_iter().enumerate() {
1707                    if !entry.is_object() {
1708                        exit_error(
1709                            &format!(
1710                                "clarification_resolution_file entry {index} must be an object"
1711                            ),
1712                            Some(
1713                                "Each clarification_resolutions entry must be a JSON object matching the server schema.",
1714                            ),
1715                        );
1716                    }
1717                    resolutions.push(entry);
1718                }
1719            }
1720            _ => exit_error(
1721                "clarification_resolution_file must contain a JSON object or array",
1722                Some(
1723                    "Provide one resolution object or an array of resolution objects matching AgentLoggingClarificationResolution.",
1724                ),
1725            ),
1726        }
1727    }
1728
1729    let route_family =
1730        normalize_non_empty_arg(clarification_route_family, "--clarification-route-family");
1731    let protocol_variant = normalize_non_empty_arg(
1732        clarification_protocol_variant,
1733        "--clarification-protocol-variant",
1734    );
1735    let resolution_note = normalize_non_empty_arg(clarification_note, "--clarification-note");
1736
1737    let inline_requested = resume_file.is_some()
1738        || clarification_prompt_id.is_some()
1739        || route_family.is_some()
1740        || protocol_variant.is_some()
1741        || resolution_note.is_some();
1742
1743    if inline_requested {
1744        if route_family.is_none() && protocol_variant.is_none() && resolution_note.is_none() {
1745            exit_error(
1746                "Clarification retry requires an answer field.",
1747                Some(
1748                    "Use --clarification-route-family or --clarification-protocol-variant, optionally plus --clarification-note.",
1749                ),
1750            );
1751        }
1752
1753        let prompt = if let Some(path) = resume_file {
1754            load_resume_clarification_prompt(path, clarification_prompt_id)
1755        } else {
1756            ResumeClarificationPrompt {
1757                prompt_id: clarification_prompt_id.unwrap_or_else(|| {
1758                    exit_error(
1759                        "Clarification retry requires --clarification-prompt-id when --resume-file is not used.",
1760                        Some("Reuse the blocked response via --resume-file, or pass the prompt UUID directly."),
1761                    )
1762                }),
1763                scope_kind: String::new(),
1764                accepted_resolution_fields: Vec::new(),
1765            }
1766        };
1767
1768        if prompt
1769            .accepted_resolution_fields
1770            .iter()
1771            .any(|field| field == "resolved_route_family")
1772            && route_family.is_none()
1773        {
1774            exit_error(
1775                "Clarification retry requires --clarification-route-family for this prompt.",
1776                Some(
1777                    "Use the exact route-family answer the user provided, for example training_execution.",
1778                ),
1779            );
1780        }
1781        if prompt
1782            .accepted_resolution_fields
1783            .iter()
1784            .any(|field| field == "protocol_variant")
1785            && protocol_variant.is_none()
1786        {
1787            exit_error(
1788                "Clarification retry requires --clarification-protocol-variant for this prompt.",
1789                Some("Use the exact protocol variant the user provided, for example free_arms."),
1790            );
1791        }
1792
1793        let mut resolution = json!({
1794            "schema_version": CLARIFICATION_RESOLUTION_SCHEMA_VERSION,
1795            "prompt_id": prompt.prompt_id,
1796        });
1797        if let Some(route_family) = route_family {
1798            resolution["resolved_route_family"] = json!(route_family);
1799        }
1800        if let Some(protocol_variant) = protocol_variant {
1801            resolution["protocol_variant"] = json!(protocol_variant);
1802        }
1803        if let Some(resolution_note) = resolution_note {
1804            resolution["resolution_note"] = json!(resolution_note);
1805        }
1806        resolutions.push(resolution);
1807    }
1808
1809    if resolutions.is_empty() {
1810        None
1811    } else {
1812        Some(resolutions)
1813    }
1814}
1815
1816const PLAN_UPDATE_VOLUME_DELTA_HIGH_IMPACT_ABS_GTE: f64 = 15.0;
1817const PLAN_UPDATE_INTENSITY_DELTA_HIGH_IMPACT_ABS_GTE: f64 = 10.0;
1818const PLAN_UPDATE_FREQUENCY_DELTA_HIGH_IMPACT_ABS_GTE: f64 = 2.0;
1819const PLAN_UPDATE_DURATION_DELTA_WEEKS_HIGH_IMPACT_ABS_GTE: f64 = 2.0;
1820const SCHEDULE_EXCEPTION_LOW_IMPACT_MAX_RANGE_DAYS: i64 = 14;
1821
1822fn normalized_event_type(event: &Value) -> Option<String> {
1823    event
1824        .get("event_type")
1825        .and_then(Value::as_str)
1826        .map(str::trim)
1827        .filter(|value| !value.is_empty())
1828        .map(|value| value.to_lowercase())
1829}
1830
1831fn is_always_high_impact_event_type(event_type: &str) -> bool {
1832    matches!(
1833        event_type.trim().to_lowercase().as_str(),
1834        "training_plan.created"
1835            | "training_plan.archived"
1836            | "projection_rule.created"
1837            | "projection_rule.archived"
1838            | "weight_target.set"
1839            | "sleep_target.set"
1840            | "nutrition_target.set"
1841            | "workflow.onboarding.closed"
1842            | "workflow.onboarding.override_granted"
1843            | "workflow.onboarding.aborted"
1844            | "workflow.onboarding.restarted"
1845    )
1846}
1847
1848fn read_abs_f64(value: Option<&Value>) -> Option<f64> {
1849    let raw = value?;
1850    if let Some(number) = raw.as_f64() {
1851        return Some(number.abs());
1852    }
1853    if let Some(number) = raw.as_i64() {
1854        return Some((number as f64).abs());
1855    }
1856    if let Some(number) = raw.as_u64() {
1857        return Some((number as f64).abs());
1858    }
1859    raw.as_str()
1860        .and_then(|text| text.trim().parse::<f64>().ok())
1861        .map(f64::abs)
1862}
1863
1864fn read_plan_delta_abs(data: &Value, keys: &[&str]) -> Option<f64> {
1865    for key in keys {
1866        if let Some(number) = read_abs_f64(data.get(*key)) {
1867            return Some(number);
1868        }
1869        if let Some(number) = read_abs_f64(data.get("delta").and_then(|delta| delta.get(*key))) {
1870            return Some(number);
1871        }
1872    }
1873    None
1874}
1875
1876fn read_bool_like(value: Option<&Value>) -> Option<bool> {
1877    let raw = value?;
1878    if let Some(boolean) = raw.as_bool() {
1879        return Some(boolean);
1880    }
1881    if let Some(number) = raw.as_i64() {
1882        return match number {
1883            0 => Some(false),
1884            1 => Some(true),
1885            _ => None,
1886        };
1887    }
1888    raw.as_str()
1889        .and_then(|text| match text.trim().to_lowercase().as_str() {
1890            "true" | "yes" | "ja" | "1" | "on" | "active" => Some(true),
1891            "false" | "no" | "nein" | "0" | "off" | "inactive" => Some(false),
1892            _ => None,
1893        })
1894}
1895
1896fn parse_local_date_value(value: Option<&Value>) -> Option<chrono::NaiveDate> {
1897    value
1898        .and_then(Value::as_str)
1899        .map(str::trim)
1900        .filter(|value| !value.is_empty())
1901        .and_then(|value| chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").ok())
1902}
1903
1904fn selector_has_explicit_occurrence_anchor(selector: &Value) -> bool {
1905    selector
1906        .get("occurrence_id")
1907        .and_then(Value::as_str)
1908        .map(str::trim)
1909        .filter(|value| !value.is_empty())
1910        .is_some()
1911        || selector
1912            .get("occurrence_ids")
1913            .and_then(Value::as_array)
1914            .map(|values| {
1915                values.iter().any(|value| {
1916                    value
1917                        .as_str()
1918                        .map(str::trim)
1919                        .filter(|raw| !raw.is_empty())
1920                        .is_some()
1921                })
1922            })
1923            .unwrap_or(false)
1924}
1925
1926fn selector_has_bounded_temporal_anchor(selector: &Value) -> bool {
1927    if selector_has_explicit_occurrence_anchor(selector) {
1928        return true;
1929    }
1930    if parse_local_date_value(selector.get("local_date").or_else(|| selector.get("date"))).is_some()
1931    {
1932        return true;
1933    }
1934    if selector
1935        .get("local_dates")
1936        .and_then(Value::as_array)
1937        .map(|values| {
1938            values
1939                .iter()
1940                .any(|value| parse_local_date_value(Some(value)).is_some())
1941        })
1942        .unwrap_or(false)
1943    {
1944        return true;
1945    }
1946    if parse_local_date_value(selector.get("week_of")).is_some() {
1947        return true;
1948    }
1949
1950    let date_range = selector
1951        .get("date_range")
1952        .or_else(|| selector.get("between"))
1953        .unwrap_or(&Value::Null);
1954    let start = parse_local_date_value(date_range.get("start").or_else(|| date_range.get("from")));
1955    let end = parse_local_date_value(date_range.get("end").or_else(|| date_range.get("to")));
1956    match (start, end) {
1957        (Some(start), Some(end)) if end >= start => {
1958            (end - start).num_days() <= SCHEDULE_EXCEPTION_LOW_IMPACT_MAX_RANGE_DAYS
1959        }
1960        _ => false,
1961    }
1962}
1963
1964fn schedule_exception_scope_is_high_impact(data: &Value) -> bool {
1965    let scope_value = data
1966        .get("change_scope")
1967        .or_else(|| data.get("update_scope"))
1968        .or_else(|| {
1969            data.get("scope")
1970                .and_then(|scope| scope.get("change_scope"))
1971        })
1972        .or_else(|| data.get("scope").and_then(|scope| scope.get("scope")))
1973        .and_then(Value::as_str)
1974        .map(|raw| raw.trim().to_lowercase());
1975    if matches!(
1976        scope_value.as_deref(),
1977        Some(
1978            "bulk"
1979                | "future_block"
1980                | "full_rewrite"
1981                | "template_rewrite"
1982                | "replace_future_schedule"
1983                | "mesocycle_reset"
1984                | "phase_shift"
1985        )
1986    ) {
1987        return true;
1988    }
1989
1990    for key in ["days_affected", "occurrences_affected"] {
1991        if read_abs_f64(data.get("scope").and_then(|scope| scope.get(key))).unwrap_or(0.0)
1992            > SCHEDULE_EXCEPTION_LOW_IMPACT_MAX_RANGE_DAYS as f64
1993        {
1994            return true;
1995        }
1996    }
1997    if read_abs_f64(
1998        data.get("scope")
1999            .and_then(|scope| scope.get("weeks_affected")),
2000    )
2001    .unwrap_or(0.0)
2002        > 2.0
2003    {
2004        return true;
2005    }
2006    false
2007}
2008
2009fn training_schedule_exception_is_high_impact(event_type: &str, data: &Value) -> bool {
2010    if read_bool_like(data.get("requires_explicit_confirmation")).unwrap_or(false)
2011        || read_bool_like(data.get("rewrite_template")).unwrap_or(false)
2012        || read_bool_like(data.get("replace_future_schedule")).unwrap_or(false)
2013        || read_bool_like(data.get("replace_entire_weekly_template")).unwrap_or(false)
2014        || read_bool_like(data.get("clear_all")).unwrap_or(false)
2015        || schedule_exception_scope_is_high_impact(data)
2016    {
2017        return true;
2018    }
2019
2020    match event_type {
2021        "training_schedule.exception.cleared" => false,
2022        "training_schedule.exception.upsert" => {
2023            let selector = data.get("selector").unwrap_or(&Value::Null);
2024            !selector_has_bounded_temporal_anchor(selector)
2025        }
2026        _ => true,
2027    }
2028}
2029
2030fn training_plan_update_is_high_impact(data: &Value) -> bool {
2031    let scope = data
2032        .get("change_scope")
2033        .or_else(|| data.get("update_scope"))
2034        .and_then(Value::as_str)
2035        .map(|raw| raw.trim().to_lowercase());
2036    if matches!(
2037        scope.as_deref(),
2038        Some(
2039            "full_rewrite" | "structural" | "major_adjustment" | "mesocycle_reset" | "phase_shift"
2040        )
2041    ) {
2042        return true;
2043    }
2044
2045    if data
2046        .get("replace_entire_plan")
2047        .and_then(Value::as_bool)
2048        .unwrap_or(false)
2049        || data
2050            .get("archive_previous_plan")
2051            .and_then(Value::as_bool)
2052            .unwrap_or(false)
2053        || data
2054            .get("requires_explicit_confirmation")
2055            .and_then(Value::as_bool)
2056            .unwrap_or(false)
2057    {
2058        return true;
2059    }
2060
2061    let volume_delta = read_plan_delta_abs(
2062        data,
2063        &[
2064            "volume_delta_pct",
2065            "planned_volume_delta_pct",
2066            "total_volume_delta_pct",
2067        ],
2068    )
2069    .unwrap_or(0.0);
2070    if volume_delta >= PLAN_UPDATE_VOLUME_DELTA_HIGH_IMPACT_ABS_GTE {
2071        return true;
2072    }
2073
2074    let intensity_delta = read_plan_delta_abs(
2075        data,
2076        &[
2077            "intensity_delta_pct",
2078            "rir_delta",
2079            "rpe_delta",
2080            "effort_delta_pct",
2081        ],
2082    )
2083    .unwrap_or(0.0);
2084    if intensity_delta >= PLAN_UPDATE_INTENSITY_DELTA_HIGH_IMPACT_ABS_GTE {
2085        return true;
2086    }
2087
2088    let frequency_delta = read_plan_delta_abs(
2089        data,
2090        &["frequency_delta_per_week", "sessions_per_week_delta"],
2091    )
2092    .unwrap_or(0.0);
2093    if frequency_delta >= PLAN_UPDATE_FREQUENCY_DELTA_HIGH_IMPACT_ABS_GTE {
2094        return true;
2095    }
2096
2097    let duration_delta = read_plan_delta_abs(
2098        data,
2099        &["cycle_length_weeks_delta", "plan_duration_weeks_delta"],
2100    )
2101    .unwrap_or(0.0);
2102    duration_delta >= PLAN_UPDATE_DURATION_DELTA_WEEKS_HIGH_IMPACT_ABS_GTE
2103}
2104
2105fn is_high_impact_event(event: &Value) -> bool {
2106    let Some(event_type) = normalized_event_type(event) else {
2107        return false;
2108    };
2109    if event_type == "training_plan.updated" {
2110        return event
2111            .get("data")
2112            .is_some_and(training_plan_update_is_high_impact);
2113    }
2114    if event_type == "training_schedule.exception.upsert"
2115        || event_type == "training_schedule.exception.cleared"
2116    {
2117        return event
2118            .get("data")
2119            .is_some_and(|data| training_schedule_exception_is_high_impact(&event_type, data));
2120    }
2121    is_always_high_impact_event_type(&event_type)
2122}
2123
2124fn has_high_impact_events(events: &[Value]) -> bool {
2125    events.iter().any(is_high_impact_event)
2126}
2127
2128fn extract_temporal_basis_from_context_body(body: &Value) -> Option<Value> {
2129    body.pointer("/meta/temporal_basis")
2130        .cloned()
2131        .filter(|value| value.is_object())
2132}
2133
2134async fn fetch_temporal_basis_for_high_impact_write(
2135    api_url: &str,
2136    token: Option<&str>,
2137) -> serde_json::Value {
2138    let query = vec![
2139        ("include_system".to_string(), "false".to_string()),
2140        ("budget_tokens".to_string(), "400".to_string()),
2141    ];
2142    let (status, body) = raw_api_request_with_query(
2143        api_url,
2144        reqwest::Method::GET,
2145        "/v1/agent/context",
2146        token,
2147        &query,
2148    )
2149    .await
2150    .unwrap_or_else(|error| {
2151        exit_error(
2152            &format!("Failed to fetch /v1/agent/context for temporal_basis: {error}"),
2153            Some(
2154                "Retry once the API is reachable, or pass a full --intent-handshake-file payload.",
2155            ),
2156        )
2157    });
2158    if !(200..=299).contains(&status) {
2159        exit_error(
2160            &format!(
2161                "GET /v1/agent/context returned HTTP {status} while preparing temporal_basis."
2162            ),
2163            Some(
2164                "Use `kura agent context` to inspect the failure, or pass --intent-handshake-file.",
2165            ),
2166        );
2167    }
2168    extract_temporal_basis_from_context_body(&body).unwrap_or_else(|| {
2169        exit_error(
2170            "agent context response is missing meta.temporal_basis",
2171            Some(
2172                "Retry after `kura agent context` succeeds, or pass a full --intent-handshake-file payload.",
2173            ),
2174        )
2175    })
2176}
2177
2178fn build_default_intent_handshake(
2179    events: &[serde_json::Value],
2180    intent_goal: Option<&str>,
2181    temporal_basis: serde_json::Value,
2182) -> serde_json::Value {
2183    let event_types: Vec<String> = events.iter().filter_map(normalized_event_type).collect();
2184    let planned_action = if event_types.is_empty() {
2185        "apply high-impact structured write update".to_string()
2186    } else {
2187        format!("write events: {}", event_types.join(", "))
2188    };
2189
2190    json!({
2191        "schema_version": "intent_handshake.v1",
2192        "goal": intent_goal.unwrap_or("execute requested high-impact write safely"),
2193        "planned_action": planned_action,
2194        "assumptions": ["context and request intent are current"],
2195        "non_goals": ["no unrelated writes outside current task scope"],
2196        "impact_class": "high_impact_write",
2197        "success_criteria": "structured write returns verification and claim_guard for this action",
2198        "created_at": chrono::Utc::now().to_rfc3339(),
2199        "handshake_id": format!("cli-hs-{}", Uuid::now_v7()),
2200        "temporal_basis": temporal_basis,
2201    })
2202}
2203
2204async fn resolve_intent_handshake(
2205    api_url: &str,
2206    token: Option<&str>,
2207    events: &[serde_json::Value],
2208    intent_goal: Option<&str>,
2209    intent_handshake_file: Option<&str>,
2210) -> Option<serde_json::Value> {
2211    if let Some(payload) = resolve_optional_object_file(
2212        intent_handshake_file,
2213        "intent_handshake",
2214        "Provide a valid JSON object for intent_handshake.v1.",
2215    ) {
2216        return Some(payload);
2217    }
2218    if !has_high_impact_events(events) {
2219        return None;
2220    }
2221    let temporal_basis = fetch_temporal_basis_for_high_impact_write(api_url, token).await;
2222    Some(build_default_intent_handshake(
2223        events,
2224        intent_goal,
2225        temporal_basis,
2226    ))
2227}
2228
2229fn parse_targets(raw_targets: &[String]) -> Vec<serde_json::Value> {
2230    raw_targets
2231        .iter()
2232        .map(|raw| {
2233            let (projection_type, key) = raw.split_once(':').unwrap_or_else(|| {
2234                exit_error(
2235                    &format!("Invalid --target '{raw}'"),
2236                    Some("Use format projection_type:key, e.g. user_profile:me"),
2237                )
2238            });
2239            let projection_type = projection_type.trim();
2240            let key = key.trim();
2241            if projection_type.is_empty() || key.is_empty() {
2242                exit_error(
2243                    &format!("Invalid --target '{raw}'"),
2244                    Some("projection_type and key must both be non-empty."),
2245                );
2246            }
2247            json!({
2248                "projection_type": projection_type,
2249                "key": key,
2250            })
2251        })
2252        .collect()
2253}
2254
2255fn extract_events_array(events_payload: serde_json::Value) -> Vec<serde_json::Value> {
2256    if let Some(events) = events_payload.as_array() {
2257        return events.to_vec();
2258    }
2259    if let Some(events) = events_payload
2260        .get("events")
2261        .and_then(|value| value.as_array())
2262    {
2263        return events.to_vec();
2264    }
2265    exit_error(
2266        "events payload must be an array or object with events array",
2267        Some("Example: --events-file events.json where file is [{...}] or {\"events\": [{...}]}"),
2268    );
2269}
2270
2271fn build_write_with_proof_request(
2272    events: Vec<serde_json::Value>,
2273    parsed_targets: Vec<serde_json::Value>,
2274    verify_timeout_ms: Option<u64>,
2275    intent_handshake: Option<serde_json::Value>,
2276    high_impact_confirmation: Option<serde_json::Value>,
2277    non_trivial_confirmation: Option<serde_json::Value>,
2278    clarification_resolutions: Option<Vec<serde_json::Value>>,
2279    session_status: Option<SessionCompletionStatus>,
2280    conversation_draft_mode: Option<ConversationDraftMode>,
2281) -> serde_json::Value {
2282    let mut request = json!({
2283        "events": events,
2284        "read_after_write_targets": parsed_targets,
2285    });
2286    if let Some(timeout) = verify_timeout_ms {
2287        request["verify_timeout_ms"] = json!(timeout);
2288    }
2289    if let Some(intent_handshake) = intent_handshake {
2290        request["intent_handshake"] = intent_handshake;
2291    }
2292    if let Some(high_impact_confirmation) = high_impact_confirmation {
2293        request["high_impact_confirmation"] = high_impact_confirmation;
2294    }
2295    if let Some(non_trivial_confirmation) = non_trivial_confirmation {
2296        request["non_trivial_confirmation"] = non_trivial_confirmation;
2297    }
2298    if let Some(clarification_resolutions) = clarification_resolutions {
2299        request["clarification_resolutions"] = json!(clarification_resolutions);
2300    }
2301    if let Some(session_status) = session_status {
2302        request["session_completion"] = json!({
2303            "schema_version": "training_session_completion.v1",
2304            "status": session_status.as_str(),
2305        });
2306    }
2307    if let Some(conversation_draft_mode) = conversation_draft_mode {
2308        request["conversation_draft"] = json!({
2309            "schema_version": "agent_conversation_session_draft.v1",
2310            "mode": conversation_draft_mode.as_str(),
2311        });
2312    }
2313    request
2314}
2315
2316#[cfg(test)]
2317mod tests {
2318    use super::{
2319        ConversationDraftMode, LogTrainingArgs, LogTurnArgs, ResumeClarificationPrompt,
2320        SaveConfirmationMode, SessionCompletionStatus, build_context_query,
2321        build_default_intent_handshake, build_high_impact_confirmation_from_token,
2322        build_log_training_request, build_log_turn_request, build_logging_bootstrap_output,
2323        build_non_trivial_confirmation_from_token, build_section_fetch_query,
2324        build_write_with_proof_request, extract_events_array, extract_logging_bootstrap_contract,
2325        extract_preferred_structured_write_endpoint, extract_resume_clarification_prompts,
2326        extract_temporal_basis_from_context_body, has_high_impact_events, normalize_agent_path,
2327        parse_method, parse_targets, select_resume_clarification_prompt,
2328    };
2329    use serde_json::json;
2330    use uuid::Uuid;
2331
2332    #[test]
2333    fn normalize_agent_path_accepts_relative_path() {
2334        assert_eq!(
2335            normalize_agent_path("evidence/event/abc"),
2336            "/v1/agent/evidence/event/abc"
2337        );
2338    }
2339
2340    #[test]
2341    fn normalize_agent_path_accepts_absolute_agent_path() {
2342        assert_eq!(
2343            normalize_agent_path("/v1/agent/context"),
2344            "/v1/agent/context"
2345        );
2346    }
2347
2348    #[test]
2349    fn normalize_agent_path_accepts_absolute_v2_agent_path() {
2350        assert_eq!(
2351            normalize_agent_path("/v2/agent/write-with-proof"),
2352            "/v2/agent/write-with-proof"
2353        );
2354    }
2355
2356    #[test]
2357    fn extract_preferred_structured_write_endpoint_accepts_v2_only() {
2358        assert_eq!(
2359            extract_preferred_structured_write_endpoint(&json!({
2360                "preferred_structured_write_endpoint": "/v2/agent/write-with-proof"
2361            }))
2362            .as_deref(),
2363            Some("/v2/agent/write-with-proof")
2364        );
2365        assert!(
2366            extract_preferred_structured_write_endpoint(&json!({
2367                "preferred_write_endpoint": "/v1/agent/write-with-proof"
2368            }))
2369            .is_none()
2370        );
2371        assert!(
2372            extract_preferred_structured_write_endpoint(&json!({
2373                "preferred_write_endpoint": "https://bad.example"
2374            }))
2375            .is_none()
2376        );
2377        assert_eq!(
2378            extract_preferred_structured_write_endpoint(&json!({
2379                "preferred_write_endpoint": "/v2/agent/write-with-proof"
2380            }))
2381            .as_deref(),
2382            Some("/v2/agent/write-with-proof")
2383        );
2384    }
2385
2386    #[test]
2387    fn extract_logging_bootstrap_contract_reads_logging_node() {
2388        let capabilities = json!({
2389            "task_bootstrap_contracts": {
2390                "logging": {
2391                    "schema_version": "agent_logging_bootstrap_contract.v1",
2392                    "task_family": "logging"
2393                }
2394            }
2395        });
2396        let contract =
2397            extract_logging_bootstrap_contract(&capabilities).expect("logging bootstrap contract");
2398        assert_eq!(
2399            contract["schema_version"],
2400            json!("agent_logging_bootstrap_contract.v1")
2401        );
2402        assert_eq!(contract["task_family"], json!("logging"));
2403    }
2404
2405    #[test]
2406    fn build_logging_bootstrap_output_selects_one_intent_recipe() {
2407        let contract = json!({
2408            "schema_version": "agent_logging_bootstrap_contract.v1",
2409            "task_family": "logging",
2410            "bootstrap_surface": "/v1/agent/capabilities",
2411            "intent_recipes": [
2412                {
2413                    "intent_id": "log_conversation",
2414                    "endpoint": "/v3/agent/evidence",
2415                    "cli_entrypoint": "kura log"
2416                }
2417            ],
2418            "save_states": [{"save_state": "received"}],
2419            "upgrade_hints": [{"surface": "/v1/events"}],
2420            "integrity_guards": ["guard"]
2421        });
2422        let output = build_logging_bootstrap_output(&contract, Some("log_conversation"))
2423            .expect("bootstrap output");
2424        assert_eq!(
2425            output["intent_recipe"]["intent_id"],
2426            json!("log_conversation")
2427        );
2428        assert_eq!(output["intent_recipe"]["cli_entrypoint"], json!("kura log"));
2429        assert_eq!(output["bootstrap_surface"], json!("/v1/agent/capabilities"));
2430        assert_eq!(output["save_states"][0]["save_state"], json!("received"));
2431    }
2432
2433    #[test]
2434    fn build_log_training_request_uses_dedicated_training_shape() {
2435        let request = build_log_training_request(&LogTrainingArgs {
2436            data: Some(
2437                json!({
2438                    "date": "2026-03-20",
2439                    "entries": [
2440                        {
2441                            "block_type": "repetition_sets",
2442                            "exercise": {"label": "Back Squat"},
2443                            "sets": [{
2444                                "count": 5,
2445                                "reps": 5,
2446                                "load": {
2447                                    "relationship": "added_resistance",
2448                                    "components": [{
2449                                        "effect": "resistance",
2450                                        "modality": "weight",
2451                                        "value": 100,
2452                                        "unit": "kg"
2453                                    }]
2454                                },
2455                                "rir": 2
2456                            }]
2457                        }
2458                    ],
2459                    "session_id": "session:2026-03-20-lower"
2460                })
2461                .to_string(),
2462            ),
2463            request_file: None,
2464        });
2465
2466        assert_eq!(request["schema_version"], json!("write_training.v1"));
2467        assert_eq!(request["date"], json!("2026-03-20"));
2468        assert_eq!(
2469            request["entries"][0]["block_type"],
2470            json!("repetition_sets")
2471        );
2472        assert_eq!(
2473            request["entries"][0]["exercise"]["label"],
2474            json!("Back Squat")
2475        );
2476        assert_eq!(
2477            request["entries"][0]["sets"][0]["load"]["components"][0]["unit"],
2478            json!("kg")
2479        );
2480        assert_eq!(
2481            request["entries"][0]["sets"][0]["load"]["components"][0]["value"],
2482            json!(100)
2483        );
2484        assert_eq!(request["source_context"]["surface"], json!("cli"));
2485        assert_eq!(
2486            request["source_context"]["command_family"],
2487            json!("write_training")
2488        );
2489    }
2490
2491    #[test]
2492    fn build_log_turn_request_uses_evidence_ingress_shape() {
2493        let request = build_log_turn_request(&LogTurnArgs {
2494            message: Some("bench 4x5 80".to_string()),
2495            message_file: None,
2496            session_id: Some("2026-03-15-upper".to_string()),
2497            modality: None,
2498            recorded_at: Some("2026-03-15T09:30:00+01:00".to_string()),
2499            observed_at: None,
2500            idempotency_key: Some("idem-123".to_string()),
2501        });
2502        assert_eq!(
2503            request["schema_version"],
2504            json!("agent_evidence_ingress_request.v1")
2505        );
2506        assert_eq!(request["text_evidence"], json!("bench 4x5 80"));
2507        assert_eq!(request["modality"], json!("chat_message"));
2508        assert_eq!(
2509            request["session_hint"]["session_id"],
2510            json!("2026-03-15-upper")
2511        );
2512        assert_eq!(request["idempotency_key"], json!("idem-123"));
2513        assert_eq!(request["source"]["command_family"], json!("log_turn"));
2514    }
2515
2516    #[test]
2517    fn extract_resume_clarification_prompts_reads_blocked_response_shape() {
2518        let prompt_id = Uuid::now_v7();
2519        let prompts = extract_resume_clarification_prompts(&json!({
2520            "schema_version": "write_preflight.v1",
2521            "status": "blocked",
2522            "blockers": [
2523                {
2524                    "code": "logging_intent_clarification_required",
2525                    "details": {
2526                        "clarification_prompts": [
2527                            {
2528                                "prompt_id": prompt_id,
2529                                "scope_kind": "training_vs_test",
2530                                "accepted_resolution_fields": ["resolved_route_family"]
2531                            }
2532                        ]
2533                    }
2534                }
2535            ]
2536        }));
2537        assert_eq!(
2538            prompts,
2539            vec![ResumeClarificationPrompt {
2540                prompt_id,
2541                scope_kind: "training_vs_test".to_string(),
2542                accepted_resolution_fields: vec!["resolved_route_family".to_string()],
2543            }]
2544        );
2545    }
2546
2547    #[test]
2548    fn select_resume_clarification_prompt_accepts_single_prompt_without_explicit_id() {
2549        let prompt = ResumeClarificationPrompt {
2550            prompt_id: Uuid::now_v7(),
2551            scope_kind: "training_vs_test".to_string(),
2552            accepted_resolution_fields: vec!["resolved_route_family".to_string()],
2553        };
2554        let selected = select_resume_clarification_prompt(std::slice::from_ref(&prompt), None)
2555            .expect("prompt");
2556        assert_eq!(selected, prompt);
2557    }
2558
2559    #[test]
2560    fn parse_method_accepts_standard_http_methods() {
2561        for method in &[
2562            "get", "GET", "post", "PUT", "delete", "patch", "head", "OPTIONS",
2563        ] {
2564            let parsed = parse_method(method);
2565            assert!(!parsed.as_str().is_empty());
2566        }
2567    }
2568
2569    #[test]
2570    fn parse_targets_accepts_projection_type_key_format() {
2571        let parsed = parse_targets(&[
2572            "user_profile:me".to_string(),
2573            "training_timeline:overview".to_string(),
2574        ]);
2575        assert_eq!(parsed[0]["projection_type"], "user_profile");
2576        assert_eq!(parsed[0]["key"], "me");
2577        assert_eq!(parsed[1]["projection_type"], "training_timeline");
2578        assert_eq!(parsed[1]["key"], "overview");
2579    }
2580
2581    #[test]
2582    fn extract_events_array_supports_plain_array() {
2583        let events = extract_events_array(json!([
2584            {"event_type":"set.logged"},
2585            {"event_type":"metric.logged"}
2586        ]));
2587        assert_eq!(events.len(), 2);
2588    }
2589
2590    #[test]
2591    fn extract_events_array_supports_object_wrapper() {
2592        let events = extract_events_array(json!({
2593            "events": [{"event_type":"set.logged"}]
2594        }));
2595        assert_eq!(events.len(), 1);
2596    }
2597
2598    #[test]
2599    fn build_write_with_proof_request_serializes_expected_fields() {
2600        let request = build_write_with_proof_request(
2601            vec![json!({"event_type":"set.logged"})],
2602            vec![json!({"projection_type":"user_profile","key":"me"})],
2603            Some(1200),
2604            None,
2605            None,
2606            None,
2607            None,
2608            None,
2609            None,
2610        );
2611        assert_eq!(request["events"].as_array().unwrap().len(), 1);
2612        assert_eq!(
2613            request["read_after_write_targets"]
2614                .as_array()
2615                .unwrap()
2616                .len(),
2617            1
2618        );
2619        assert_eq!(request["verify_timeout_ms"], 1200);
2620    }
2621
2622    #[test]
2623    fn build_write_with_proof_request_includes_non_trivial_confirmation_when_present() {
2624        let request = build_write_with_proof_request(
2625            vec![json!({"event_type":"set.logged"})],
2626            vec![json!({"projection_type":"user_profile","key":"me"})],
2627            None,
2628            None,
2629            None,
2630            Some(json!({
2631                "schema_version": "non_trivial_confirmation.v1",
2632                "confirmed": true,
2633                "confirmed_at": "2026-02-25T12:00:00Z",
2634                "confirmation_token": "abc"
2635            })),
2636            None,
2637            None,
2638            None,
2639        );
2640        assert_eq!(
2641            request["non_trivial_confirmation"]["schema_version"],
2642            "non_trivial_confirmation.v1"
2643        );
2644        assert_eq!(
2645            request["non_trivial_confirmation"]["confirmation_token"],
2646            "abc"
2647        );
2648    }
2649
2650    #[test]
2651    fn build_write_with_proof_request_includes_high_impact_fields_when_present() {
2652        let request = build_write_with_proof_request(
2653            vec![json!({"event_type":"training_schedule.exception.upsert"})],
2654            vec![json!({"projection_type":"training_schedule","key":"effective"})],
2655            None,
2656            Some(json!({
2657                "schema_version": "intent_handshake.v1",
2658                "goal": "shift deload start",
2659                "impact_class": "high_impact_write",
2660                "temporal_basis": {
2661                    "schema_version": "temporal_basis.v1",
2662                    "context_generated_at": "2026-03-07T16:00:00Z",
2663                    "timezone": "Europe/Berlin",
2664                    "today_local_date": "2026-03-07"
2665                }
2666            })),
2667            Some(json!({
2668                "schema_version": "high_impact_confirmation.v1",
2669                "confirmed": true,
2670                "confirmed_at": "2026-03-07T16:05:00Z",
2671                "confirmation_token": "hi-123"
2672            })),
2673            None,
2674            None,
2675            None,
2676            None,
2677        );
2678        assert_eq!(
2679            request["intent_handshake"]["schema_version"],
2680            "intent_handshake.v1"
2681        );
2682        assert_eq!(
2683            request["high_impact_confirmation"]["confirmation_token"],
2684            "hi-123"
2685        );
2686    }
2687
2688    #[test]
2689    fn build_write_with_proof_request_includes_clarification_resolutions_when_present() {
2690        let request = build_write_with_proof_request(
2691            vec![json!({"event_type":"set.logged"})],
2692            vec![json!({"projection_type":"training_timeline","key":"today"})],
2693            None,
2694            None,
2695            None,
2696            None,
2697            Some(vec![json!({
2698                "schema_version": "logging_clarification_resolution.v1",
2699                "prompt_id": "3f6e2b68-63a6-44c2-a4df-73d80f3b23e0",
2700                "resolved_route_family": "training",
2701                "resolved_at": "2026-03-08T10:00:00Z"
2702            })]),
2703            None,
2704            None,
2705        );
2706        assert_eq!(
2707            request["clarification_resolutions"]
2708                .as_array()
2709                .unwrap()
2710                .len(),
2711            1
2712        );
2713        assert_eq!(
2714            request["clarification_resolutions"][0]["resolved_route_family"],
2715            "training"
2716        );
2717    }
2718
2719    #[test]
2720    fn build_write_with_proof_request_includes_session_completion_when_present() {
2721        let request = build_write_with_proof_request(
2722            vec![json!({"event_type":"set.logged"})],
2723            vec![json!({"projection_type":"training_timeline","key":"today"})],
2724            None,
2725            None,
2726            None,
2727            None,
2728            None,
2729            Some(SessionCompletionStatus::Ongoing),
2730            None,
2731        );
2732        assert_eq!(
2733            request["session_completion"]["schema_version"],
2734            "training_session_completion.v1"
2735        );
2736        assert_eq!(request["session_completion"]["status"], "ongoing");
2737    }
2738
2739    #[test]
2740    fn build_write_with_proof_request_includes_conversation_draft_when_present() {
2741        let request = build_write_with_proof_request(
2742            vec![json!({"event_type":"session.completed"})],
2743            Vec::new(),
2744            None,
2745            None,
2746            None,
2747            None,
2748            None,
2749            Some(SessionCompletionStatus::Ongoing),
2750            Some(ConversationDraftMode::Append),
2751        );
2752        assert_eq!(
2753            request["conversation_draft"]["schema_version"],
2754            "agent_conversation_session_draft.v1"
2755        );
2756        assert_eq!(request["conversation_draft"]["mode"], "append");
2757    }
2758
2759    #[test]
2760    fn build_non_trivial_confirmation_from_token_uses_expected_shape() {
2761        let payload = build_non_trivial_confirmation_from_token("tok-123");
2762        assert_eq!(payload["schema_version"], "non_trivial_confirmation.v1");
2763        assert_eq!(payload["confirmed"], true);
2764        assert_eq!(payload["confirmation_token"], "tok-123");
2765        assert!(payload["confirmed_at"].as_str().is_some());
2766    }
2767
2768    #[test]
2769    fn build_high_impact_confirmation_from_token_uses_expected_shape() {
2770        let payload = build_high_impact_confirmation_from_token("tok-456");
2771        assert_eq!(payload["schema_version"], "high_impact_confirmation.v1");
2772        assert_eq!(payload["confirmed"], true);
2773        assert_eq!(payload["confirmation_token"], "tok-456");
2774        assert!(payload["confirmed_at"].as_str().is_some());
2775    }
2776
2777    #[test]
2778    fn extract_temporal_basis_from_context_body_reads_meta_field() {
2779        let temporal_basis = extract_temporal_basis_from_context_body(&json!({
2780            "meta": {
2781                "temporal_basis": {
2782                    "schema_version": "temporal_basis.v1",
2783                    "timezone": "Europe/Berlin",
2784                    "today_local_date": "2026-03-07"
2785                }
2786            }
2787        }))
2788        .expect("temporal_basis must be extracted");
2789        assert_eq!(temporal_basis["schema_version"], "temporal_basis.v1");
2790        assert_eq!(temporal_basis["timezone"], "Europe/Berlin");
2791    }
2792
2793    #[test]
2794    fn build_default_intent_handshake_uses_event_types_and_temporal_basis() {
2795        let handshake = build_default_intent_handshake(
2796            &[json!({"event_type":"training_schedule.exception.upsert"})],
2797            Some("shift today's session"),
2798            json!({
2799                "schema_version": "temporal_basis.v1",
2800                "context_generated_at": "2026-03-07T16:00:00Z",
2801                "timezone": "Europe/Berlin",
2802                "today_local_date": "2026-03-07"
2803            }),
2804        );
2805        assert_eq!(handshake["schema_version"], "intent_handshake.v1");
2806        assert_eq!(handshake["goal"], "shift today's session");
2807        assert_eq!(handshake["impact_class"], "high_impact_write");
2808        assert_eq!(
2809            handshake["temporal_basis"]["today_local_date"],
2810            "2026-03-07"
2811        );
2812    }
2813
2814    #[test]
2815    fn high_impact_classification_keeps_bounded_schedule_exception_low_impact() {
2816        let events = vec![json!({
2817            "event_type": "training_schedule.exception.upsert",
2818            "data": {
2819                "exception_id": "deload-start-today",
2820                "operation": "patch",
2821                "selector": {
2822                    "local_date": "2026-03-07",
2823                    "session_name": "Technik + Power"
2824                },
2825                "progression_override": {
2826                    "deload_active": true,
2827                    "phase": "deload",
2828                    "volume_delta_pct": -30
2829                }
2830            }
2831        })];
2832        assert!(!has_high_impact_events(&events));
2833    }
2834
2835    #[test]
2836    fn high_impact_classification_escalates_unbounded_schedule_exception() {
2837        let events = vec![json!({
2838            "event_type": "training_schedule.exception.upsert",
2839            "data": {
2840                "exception_id": "rewrite-future-saturdays",
2841                "operation": "patch",
2842                "selector": {
2843                    "session_name": "Technik + Power"
2844                },
2845                "rewrite_template": true
2846            }
2847        })];
2848        assert!(has_high_impact_events(&events));
2849    }
2850
2851    #[test]
2852    fn save_confirmation_mode_serializes_expected_values() {
2853        assert_eq!(SaveConfirmationMode::Auto.as_str(), "auto");
2854        assert_eq!(SaveConfirmationMode::Always.as_str(), "always");
2855        assert_eq!(SaveConfirmationMode::Never.as_str(), "never");
2856    }
2857
2858    #[test]
2859    fn build_context_query_includes_budget_tokens_when_present() {
2860        let query = build_context_query(
2861            Some(3),
2862            Some(2),
2863            Some(1),
2864            Some("readiness check".to_string()),
2865            Some(false),
2866            Some(900),
2867        );
2868        assert!(query.contains(&("exercise_limit".to_string(), "3".to_string())));
2869        assert!(query.contains(&("strength_limit".to_string(), "2".to_string())));
2870        assert!(query.contains(&("custom_limit".to_string(), "1".to_string())));
2871        assert!(query.contains(&("task_intent".to_string(), "readiness check".to_string())));
2872        assert!(query.contains(&("include_system".to_string(), "false".to_string())));
2873        assert!(query.contains(&("budget_tokens".to_string(), "900".to_string())));
2874    }
2875
2876    #[test]
2877    fn build_context_query_supports_section_index_parity_params() {
2878        let query = build_context_query(
2879            Some(5),
2880            Some(5),
2881            Some(10),
2882            Some("startup".to_string()),
2883            Some(false),
2884            Some(1200),
2885        );
2886        assert!(query.contains(&("exercise_limit".to_string(), "5".to_string())));
2887        assert!(query.contains(&("strength_limit".to_string(), "5".to_string())));
2888        assert!(query.contains(&("custom_limit".to_string(), "10".to_string())));
2889        assert!(query.contains(&("task_intent".to_string(), "startup".to_string())));
2890        assert!(query.contains(&("include_system".to_string(), "false".to_string())));
2891        assert!(query.contains(&("budget_tokens".to_string(), "1200".to_string())));
2892    }
2893
2894    #[test]
2895    fn build_section_fetch_query_serializes_optional_params() {
2896        let query = build_section_fetch_query(
2897            "projections.exercise_progression".to_string(),
2898            Some(50),
2899            Some("abc123".to_string()),
2900            Some("data,meta".to_string()),
2901            Some("bench plateau".to_string()),
2902        );
2903        assert_eq!(
2904            query,
2905            vec![
2906                (
2907                    "section".to_string(),
2908                    "projections.exercise_progression".to_string(),
2909                ),
2910                ("limit".to_string(), "50".to_string()),
2911                ("cursor".to_string(), "abc123".to_string()),
2912                ("fields".to_string(), "data,meta".to_string()),
2913                ("task_intent".to_string(), "bench plateau".to_string()),
2914            ]
2915        );
2916    }
2917}