Skip to main content

cli/cli/commands/
schemas.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `heddle schemas <verb>` — runtime introspection for CLI JSON output shapes.
3//!
4//! This module owns the JSON schema mirrors for `--output json`-emitting
5//! verbs. Schema registration metadata comes from the active command
6//! catalog; the schemas themselves are schemars-derived mirror structs
7//! rather than threading
8//! `JsonSchema` through every output struct in the workspace
9//! (`repo`, `objects`, etc.). The cost: when a real output struct
10//! changes, the mirror here must change too. The benefit:
11//! `heddle doctor schemas` validates the documented samples against
12//! these schemas, catching the mirror drift the same way it catches
13//! doc drift.
14//!
15//! See [`super::doctor_schemas`] for the drift checker.
16
17use std::{collections::BTreeMap, sync::OnceLock};
18
19use anyhow::{Result, anyhow};
20use schemars::{JsonSchema, schema_for};
21use serde::Serialize;
22use serde_json::Value;
23
24use super::{RecoveryAdvice, command_catalog};
25use crate::cli::{Cli, should_output_json};
26
27static SCHEMA_VERBS: OnceLock<Vec<&'static str>> = OnceLock::new();
28static DOCUMENTED_SCHEMA_VERBS: OnceLock<Vec<&'static str>> = OnceLock::new();
29static OPAQUE_SCHEMA_VERBS: OnceLock<Vec<&'static str>> = OnceLock::new();
30
31macro_rules! schema_registry {
32    ($(($verbs:expr, $schema:ty)),+ $(,)?) => {
33        fn schema_for_registered_verb(verb: &str) -> Option<Value> {
34            $(
35                if $verbs.contains(&verb) {
36                    let root = schema_for!($schema);
37                    return serde_json::to_value(&root).ok();
38                }
39            )+
40            None
41        }
42
43        #[cfg(test)]
44        fn schema_implementation_verbs() -> Vec<&'static str> {
45            let mut verbs = Vec::new();
46            $(
47                for verb in $verbs {
48                    if !verbs.contains(verb) {
49                        verbs.push(*verb);
50                    }
51                }
52            )+
53            verbs
54        }
55    };
56}
57
58schema_registry! {
59    (&["init"], InitSchema),
60    (&["status"], StatusSchema),
61    (&["verify"], VerifySchema),
62    (&["adopt"], AdoptSchema),
63    (&["capture"], CaptureSchema),
64    (&["commit"], CommitSchema),
65    (&["checkpoint"], CheckpointSchema),
66    (&["undo", "undo --redo"], UndoSchema),
67    (&["undo --list"], UndoListSchema),
68    (&["clean"], CleanSchema),
69    (&["diff"], DiffSchema),
70    (&["switch"], SwitchCheckoutSchema),
71    (&["merge --preview"], MergePreviewSchema),
72    (&["ready"], ReadySchema),
73    (&["land"], LandSchema),
74    (&["sync"], SyncSchema),
75    (&["continue", "abort"], OperatorCommandSchema),
76    (&["start"], ThreadStartSchema),
77    (&["thread create", "thread switch", "thread rename"], ThreadStartSchema),
78    (&["thread current"], ThreadCurrentSchema),
79    (&["thread captures"], ThreadCapturesSchema),
80    (&["thread refresh", "thread drop"], ThreadCommandSchema),
81    (&["thread promote"], ThreadCommandSchema),
82    (&["thread move"], ThreadMoveSchema),
83    (&["thread absorb"], ThreadAbsorbSchema),
84    (&["thread resolve"], ThreadResolveSchema),
85    (&["thread approve"], ThreadApprovalSchema),
86    (&["thread approvals"], ThreadApprovalListSchema),
87    (&["thread revoke-approval"], ThreadRevokeApprovalSchema),
88    (&["thread check-merge"], ThreadMergeEligibilitySchema),
89    (&["thread cleanup"], ThreadCleanupSchema),
90    (&["thread marker list"], ThreadMarkerListSchema),
91    (&["thread marker create", "thread marker delete", "thread marker show"], ThreadMarkerOpSchema),
92    (&["thread show"], ThreadShowSchema),
93    (&["clone"], CloneSchema),
94    (&["remote list"], RemoteListSchema),
95    (&["remote show"], RemoteInfoSchema),
96    (&["remote add", "remote remove", "remote set-default"], RemoteMutationSchema),
97    (&["fetch"], FetchSchema),
98    (&["pull"], PullSchema),
99    (&["push"], PushSchema),
100    (&["bridge git status"], BridgeGitStatusSchema),
101    (&["expand"], ExpandSchema),
102    (&["log"], LogSchema),
103    (&["log --reflog"], LogReflogSchema),
104    (&["log --timeline"], TimelineLogSchema),
105    (&["timeline fork", "timeline reset", "timeline recover"], TimelineActionSchema),
106    (&["show"], ShowSchema),
107    (&["thread list"], ThreadListSchema),
108    (&["schemas"], SchemasListSchema),
109    (&["review show"], ReviewShowSchema),
110    (&["review sign"], ReviewSignSchema),
111    (&["review next"], ReviewNextSchema),
112    (&["review health"], ReviewHealthSchema),
113    (&["retro"], RetroSchema),
114    (&["discuss open", "discuss append", "discuss resolve", "discuss show"], DiscussionEnvelopeSchema),
115    (&["discuss list"], DiscussionListSchema),
116    (&["query"], QuerySchema),
117    (&["query --attribution"], BlameSchema),
118    (&["transaction commit"], TransactionCommitSchema),
119    (&["bridge git init"], BridgeInitSchema),
120    (&["bridge git export"], BridgeExportSchema),
121    (&["bridge git import"], BridgeImportSchema),
122    (&["bridge git sync"], BridgeSyncSchema),
123    (&["bridge git reconcile"], BridgeGitReconcileSchema),
124    (&["bridge git push"], BridgePushSchema),
125    (&["bridge git pull"], BridgePullSchema),
126    (&["stash push", "stash pop", "stash apply", "stash drop", "stash clear"], StashMutationSchema),
127    (&["stash list"], StashListSchema),
128    (&["stash show"], StashShowSchema),
129    (&["revert"], RevertSchema),
130    (&["doctor"], DiagnoseSchema),
131    (&["doctor docs"], DoctorDocsSchema),
132    (&["doctor schemas"], DoctorSchemasSchema),
133    (&["actor spawn", "actor show"], ActorSingleSchema),
134    (&["actor list"], ActorListSchema),
135    (&["actor done"], ActorDoneSchema),
136    (&["actor explain"], ActorExplainSchema),
137    (&["agent serve"], AgentServeSchema),
138    (&["agent status"], AgentDaemonStatusSchema),
139    (&["agent stop"], AgentStopSchema),
140    (&["agent reserve", "agent heartbeat", "agent release"], AgentReservationEnvelopeSchema),
141    (&["agent capture"], CaptureSchema),
142    (&["agent ready"], ReadySchema),
143    (&["agent list"], AgentReservationListSchema),
144    (&["session start", "session end", "session show"], SessionEnvelopeSchema),
145    (&["session segment"], SessionSegmentEnvelopeSchema),
146    (&["session list"], SessionListSchema),
147    (&["git-overlay"], GitOverlayGuideSchema),
148    (&["watch"], WatchLineSchema),
149    (&["integration list", "integration doctor"], IntegrationStatusListSchema),
150    (&["try"], TrySchema),
151    (&["fsck"], FsckSchema),
152    (&["resolve"], ResolveSchema),
153    (&["maintenance index"], IndexSchema),
154    (&["error"], ErrorEnvelopeSchema),
155}
156
157/// All verbs whose `--output json` output has a schema mirror, derived from
158/// the active command catalog.
159pub fn schema_verbs() -> &'static [&'static str] {
160    SCHEMA_VERBS
161        .get_or_init(command_catalog::schema_verbs)
162        .as_slice()
163}
164
165/// Schema verbs that `heddle doctor schemas` must check against
166/// `docs/json-schemas.md`, derived from the active command catalog.
167pub fn documented_schema_verbs() -> &'static [&'static str] {
168    DOCUMENTED_SCHEMA_VERBS
169        .get_or_init(command_catalog::documented_schema_verbs)
170        .as_slice()
171}
172
173/// Runtime schema verbs that intentionally expose only an opaque JSON
174/// object shape. Coverage reports count these separately from
175/// concrete schema mirrors.
176pub(crate) fn opaque_schema_verbs() -> &'static [&'static str] {
177    OPAQUE_SCHEMA_VERBS
178        .get_or_init(command_catalog::opaque_schema_verbs)
179        .as_slice()
180}
181
182/// Generate the schema for `verb`. Returns `None` if no schema is registered.
183pub fn schema_for_verb(verb: &str) -> Option<Value> {
184    let verb = resolve_schema_verb(verb)?;
185    if !schema_verbs().contains(&verb) {
186        return None;
187    }
188    let mut schema = schema_for_registered_verb(verb).or_else(|| {
189        opaque_schema_verbs()
190            .contains(&verb)
191            .then(|| serde_json::to_value(schema_for!(GenericJsonObjectSchema)).ok())
192            .flatten()
193    })?;
194    add_op_id_replay_fields_if_supported(verb, &mut schema);
195    add_json_discriminator_if_advertised(verb, &mut schema);
196    Some(schema)
197}
198
199fn resolve_schema_verb(verb: &str) -> Option<&'static str> {
200    let verb = verb.trim();
201    if let Some(registered) = schema_verbs()
202        .iter()
203        .copied()
204        .find(|registered| *registered == verb)
205    {
206        return Some(registered);
207    }
208
209    let matches = matching_schema_verbs(verb, schema_verbs());
210    if matches.len() == 1 {
211        matches.first().copied()
212    } else {
213        None
214    }
215}
216
217#[cfg(test)]
218const OP_ID_REPLAY_FIELD_NAMES: &[&str] = &[
219    "op_id",
220    "operation_record",
221    "idempotency_status",
222    "replayed",
223];
224
225fn add_op_id_replay_fields_if_supported(verb: &str, schema: &mut Value) {
226    if !schema_verb_supports_op_id(verb) {
227        return;
228    }
229
230    let Some(object) = schema.as_object_mut() else {
231        return;
232    };
233    let properties = object
234        .entry("properties".to_string())
235        .or_insert_with(|| serde_json::json!({}));
236    let Some(properties) = properties.as_object_mut() else {
237        return;
238    };
239
240    properties
241        .entry("op_id".to_string())
242        .or_insert_with(|| serde_json::json!({ "type": ["string", "null"] }));
243    properties
244        .entry("idempotency_status".to_string())
245        .or_insert_with(|| serde_json::json!({ "type": ["string", "null"] }));
246    properties
247        .entry("replayed".to_string())
248        .or_insert_with(|| serde_json::json!({ "type": ["boolean", "null"] }));
249    properties
250        .entry("operation_record".to_string())
251        .or_insert_with(|| {
252            serde_json::json!({
253                "anyOf": [
254                    {
255                        "type": "object",
256                        "properties": {
257                            "op_id": { "type": "string" },
258                            "command": { "type": "string" },
259                            "idempotency_status": { "type": "string" },
260                            "replayed": { "type": "boolean" }
261                        },
262                        "required": [
263                            "command",
264                            "idempotency_status",
265                            "op_id",
266                            "replayed"
267                        ]
268                    },
269                    { "type": "null" }
270                ]
271            })
272        });
273}
274
275fn add_json_discriminator_if_advertised(verb: &str, schema: &mut Value) {
276    let mut discriminators = command_catalog::command_json_discriminators_for_schema_verb(verb);
277    if schema.get("anyOf").is_some() {
278        for discriminator in command_catalog::command_json_discriminators()
279            .into_iter()
280            .filter(|discriminator| {
281                discriminator.display == verb && discriminator.schema_verb.as_deref() != Some(verb)
282            })
283        {
284            discriminators.push(discriminator);
285        }
286    }
287    discriminators.sort_by(|left, right| {
288        (&left.field, &left.value, &left.display).cmp(&(&right.field, &right.value, &right.display))
289    });
290    discriminators.dedup_by(|left, right| left.field == right.field && left.value == right.value);
291
292    if discriminators.is_empty() {
293        return;
294    };
295
296    if add_json_discriminators_to_union_branches(verb, schema, &discriminators) {
297        return;
298    }
299
300    let field = discriminators[0].field.as_str();
301    let values = discriminators
302        .iter()
303        .filter(|discriminator| discriminator.field == field)
304        .map(|discriminator| discriminator.value.as_str())
305        .collect::<Vec<_>>();
306    add_json_discriminator_to_schema_object(schema, field, &values);
307}
308
309fn add_json_discriminators_to_union_branches(
310    verb: &str,
311    schema: &mut Value,
312    discriminators: &[command_catalog::CommandJsonDiscriminator],
313) -> bool {
314    let Some(branches) = schema
315        .get_mut("anyOf")
316        .and_then(|value| value.as_array_mut())
317    else {
318        return false;
319    };
320
321    let mut injected = 0usize;
322    for branch in branches {
323        let Some(branch_ref) = branch
324            .get("$ref")
325            .and_then(|value| value.as_str())
326            .map(str::to_string)
327        else {
328            continue;
329        };
330        let Some(discriminator) = discriminator_for_union_branch(verb, &branch_ref, discriminators)
331        else {
332            continue;
333        };
334        let original_branch = branch.clone();
335        let mut discriminator_schema = serde_json::json!({ "type": "object" });
336        add_json_discriminator_to_schema_object(
337            &mut discriminator_schema,
338            &discriminator.field,
339            &[&discriminator.value],
340        );
341        *branch = serde_json::json!({
342            "allOf": [original_branch, discriminator_schema],
343        });
344        injected += 1;
345    }
346
347    injected > 0
348}
349
350fn discriminator_for_union_branch<'a>(
351    verb: &str,
352    branch_ref: &str,
353    discriminators: &'a [command_catalog::CommandJsonDiscriminator],
354) -> Option<&'a command_catalog::CommandJsonDiscriminator> {
355    if discriminators.len() == 1 {
356        return discriminators.first();
357    }
358
359    let def_name = schema_ref_name(branch_ref)?;
360    if verb == "inspect" {
361        let value = match def_name {
362            "ShowSchema" => "inspect_state",
363            "ThreadShowSchema" => "thread_show",
364            _ => return None,
365        };
366        return discriminators
367            .iter()
368            .find(|discriminator| discriminator.value == value);
369    }
370
371    None
372}
373
374fn schema_ref_name(reference: &str) -> Option<&str> {
375    reference
376        .strip_prefix("#/$defs/")
377        .or_else(|| reference.strip_prefix("#/definitions/"))
378}
379
380fn add_json_discriminator_to_schema_object(schema: &mut Value, field: &str, values: &[&str]) {
381    let enum_values = values
382        .iter()
383        .map(|value| Value::String((*value).to_string()))
384        .collect::<Vec<_>>();
385
386    let Some(object) = schema.as_object_mut() else {
387        return;
388    };
389    let properties = object
390        .entry("properties".to_string())
391        .or_insert_with(|| serde_json::json!({}));
392    let Some(properties) = properties.as_object_mut() else {
393        return;
394    };
395    properties.insert(
396        field.to_string(),
397        serde_json::json!({
398            "type": "string",
399            "enum": enum_values,
400        }),
401    );
402
403    let required = object
404        .entry("required".to_string())
405        .or_insert_with(|| serde_json::json!([]));
406    let Some(required) = required.as_array_mut() else {
407        return;
408    };
409    if !required
410        .iter()
411        .any(|required_field| required_field.as_str() == Some(field))
412    {
413        required.push(Value::String(field.to_string()));
414    }
415}
416
417fn schema_verb_supports_op_id(verb: &str) -> bool {
418    command_catalog::command_runtime_contract_for_schema_verb(verb)
419        .is_some_and(|contract| contract.supports_op_id)
420}
421
422/// Public entrypoint for `heddle schemas [<verb>]`.
423///
424/// `verb_parts` are the raw trailing tokens after `schemas`.
425/// Lookup is a flat string match after filtering global flags that
426/// clap cannot see because the schema command intentionally accepts
427/// literal command flags like `--preview` and `--reflog`.
428pub fn cmd_schemas(cli: &Cli, verb_parts: &[String]) -> Result<()> {
429    let verb_parts = normalize_schema_verb_parts(verb_parts)?;
430    if verb_parts.is_empty() {
431        let out = serde_json::json!({
432            "output_kind": "schemas",
433            "status": "completed",
434            "schema_verbs": schema_verbs(),
435            "documented_schema_verbs": documented_schema_verbs(),
436        });
437        return render_schema_json(&out);
438    }
439
440    let verb = verb_parts.join(" ");
441    let schema = schema_for_verb(&verb)
442        .ok_or_else(|| anyhow!(schema_not_registered_advice(&verb, schema_verbs())))?;
443
444    // `heddle schemas` always emits machine-readable JSON.
445    let _json = should_output_json(cli, None);
446    render_schema_json(&schema)
447}
448
449fn render_schema_json(value: &serde_json::Value) -> Result<()> {
450    println!("{}", serde_json::to_string_pretty(value)?);
451    Ok(())
452}
453
454fn schema_not_registered_advice(verb: &str, known_verbs: &[&str]) -> RecoveryAdvice {
455    let matches = suggested_schema_verbs(verb, known_verbs);
456    let primary_command = matches
457        .first()
458        .map(|matched| format!("heddle schemas {matched}"))
459        .unwrap_or_else(|| "heddle schemas".to_string());
460    let hint = if matches.is_empty() {
461        "Run `heddle schemas` to list schema-backed verbs, or inspect the command catalog with `heddle help --output json`.".to_string()
462    } else {
463        format!(
464            "`{verb}` is not exact; available schema verb{}: {}.",
465            if matches.len() == 1 { "" } else { "s" },
466            matches
467                .iter()
468                .map(|matched| format!("`{matched}`"))
469                .collect::<Vec<_>>()
470                .join(", ")
471        )
472    };
473    let mut recovery_commands = Vec::new();
474    push_unique_command(&mut recovery_commands, primary_command.clone());
475    push_unique_command(&mut recovery_commands, "heddle schemas".to_string());
476    push_unique_command(
477        &mut recovery_commands,
478        "heddle help --output json".to_string(),
479    );
480    for matched in matches.iter().skip(1) {
481        push_unique_command(&mut recovery_commands, format!("heddle schemas {matched}"));
482    }
483
484    RecoveryAdvice::safety_refusal(
485        "schema_not_registered",
486        format!("No JSON schema is registered for `{verb}`"),
487        hint,
488        format!("`{verb}` is not in the runtime schema registry"),
489        "schema lookup does not change repository state; retrying the same unknown verb will fail until a schema is registered",
490        "no repository objects, refs, metadata, or worktree files were changed",
491        primary_command,
492        recovery_commands,
493    )
494}
495
496fn suggested_schema_verbs<'a>(verb: &str, known_verbs: &'a [&'a str]) -> Vec<&'a str> {
497    let exactish = matching_schema_verbs(verb, known_verbs);
498    if !exactish.is_empty() {
499        return exactish;
500    }
501
502    let normalized = verb.trim();
503    if normalized.is_empty() {
504        return Vec::new();
505    }
506    let bare = command_catalog::schema_verb_without_flags(normalized);
507    if bare.is_empty() {
508        return Vec::new();
509    }
510
511    known_verbs
512        .iter()
513        .copied()
514        .filter(|known| {
515            known.starts_with(normalized)
516                || command_catalog::schema_verb_without_flags(known).starts_with(&bare)
517        })
518        .take(5)
519        .collect()
520}
521
522fn push_unique_command(commands: &mut Vec<String>, command: String) {
523    if !commands.iter().any(|existing| existing == &command) {
524        commands.push(command);
525    }
526}
527
528fn matching_schema_verbs<'a>(verb: &str, known_verbs: &'a [&'a str]) -> Vec<&'a str> {
529    let normalized = verb.trim();
530    if normalized.is_empty() {
531        return Vec::new();
532    }
533    let bare = command_catalog::schema_verb_without_flags(normalized);
534    let prefix = format!("{bare} ");
535    known_verbs
536        .iter()
537        .copied()
538        .filter(|known| {
539            *known != normalized
540                && (*known == bare
541                    || known.starts_with(&prefix)
542                    || command_catalog::schema_verb_without_flags(known) == bare)
543        })
544        .collect()
545}
546
547fn normalize_schema_verb_parts(parts: &[String]) -> Result<Vec<String>> {
548    let mut normalized = Vec::new();
549    let mut iter = parts.iter();
550    while let Some(part) = iter.next() {
551        match part.as_str() {
552            "--no-color" | "-q" | "--quiet" | "-v" | "--verbose" => {}
553            "--output" | "--repo" => {
554                iter.next()
555                    .ok_or_else(|| anyhow!("missing value for `{part}`"))?;
556            }
557            _ if part.starts_with("--output=") || part.starts_with("--repo=") => {}
558            _ => normalized.push(part.clone()),
559        }
560    }
561    Ok(normalized)
562}
563
564// ---------------------------------------------------------------------------
565// Mirror types
566// ---------------------------------------------------------------------------
567//
568// Each mirror struct mirrors the JSON wire shape of a single
569// `--output json`-emitting verb. The struct's `serde` attributes match the
570// real serializer; the `schemars` derive produces a JSON Schema we
571// emit verbatim.
572//
573// When you add or rename a field on a real output struct, update the
574// matching mirror here and the entry in `docs/json-schemas.md`. CI
575// runs `heddle doctor schemas` which validates the doc samples
576// against these schemas.
577
578// ---- shared sub-types ------------------------------------------------------
579//
580// Variants here are referenced only through the schemars derive,
581// which the dead-code lint can't see. The annotation keeps the
582// surface honest without polluting downstream warnings.
583#[allow(dead_code)]
584#[derive(Debug, Serialize, JsonSchema)]
585#[serde(rename_all = "snake_case")]
586pub enum ThreadModeSchema {
587    Materialized,
588    Virtualized,
589    Solid,
590}
591
592#[allow(dead_code)]
593#[derive(Debug, Serialize, JsonSchema)]
594#[serde(rename_all = "snake_case")]
595pub enum ThreadStateSchema {
596    Draft,
597    Active,
598    Ready,
599    Blocked,
600    Merged,
601    Abandoned,
602    Promoted,
603}
604
605#[allow(dead_code)]
606#[derive(Debug, Serialize, JsonSchema)]
607#[serde(rename_all = "snake_case")]
608pub enum ThreadFreshnessSchema {
609    Current,
610    Stale,
611    Unknown,
612}
613
614#[allow(dead_code)]
615#[derive(Debug, Serialize, JsonSchema)]
616#[serde(rename_all = "snake_case")]
617pub enum ThreadImpactCategorySchema {
618    DependencyGraph,
619    BuildRuntimeConfig,
620    GeneratedOutputs,
621    RepoWideRefactor,
622    PublicApiSurface,
623}
624
625#[allow(dead_code)]
626#[derive(Debug, Serialize, JsonSchema)]
627#[serde(rename_all = "kebab-case")]
628pub enum CoordinationStatusSchema {
629    Clean,
630    Ahead,
631    Diverged,
632    Blocked,
633    MergeReady,
634}
635
636#[derive(Debug, Serialize, JsonSchema)]
637pub struct ActorInfoSchema {
638    pub provider: Option<String>,
639    pub model: Option<String>,
640}
641
642#[derive(Debug, Serialize, JsonSchema)]
643pub struct StateInfoSchema {
644    pub change_id: String,
645    pub content_hash: String,
646    pub intent: Option<String>,
647}
648
649#[derive(Debug, Serialize, JsonSchema)]
650pub struct GitCheckpointInfoSchema {
651    pub git_commit: String,
652    pub committed_at: String,
653}
654
655#[derive(Debug, Serialize, JsonSchema)]
656pub struct ChangesInfoSchema {
657    pub modified: Vec<String>,
658    pub added: Vec<String>,
659    pub deleted: Vec<String>,
660}
661
662#[derive(Debug, Serialize, JsonSchema)]
663pub struct GitIndexInfoSchema {
664    pub commit_mode: String,
665    pub has_staged_changes: bool,
666    pub staged_paths: Vec<String>,
667    pub unstaged_paths: Vec<String>,
668    pub untracked_paths: Vec<String>,
669    pub will_commit: Vec<String>,
670    pub preserved_after_commit: Vec<String>,
671}
672
673#[derive(Debug, Serialize, JsonSchema)]
674pub struct GenericJsonObjectSchema {
675    #[serde(flatten)]
676    pub fields: BTreeMap<String, Value>,
677}
678
679#[derive(Debug, Serialize, JsonSchema)]
680pub struct IntegrationStatusListSchema(pub Vec<IntegrationStatusSchema>);
681
682#[derive(Debug, Serialize, JsonSchema)]
683pub struct IntegrationStatusSchema {
684    pub harness: String,
685    pub scope: String,
686    pub method: String,
687    pub status: String,
688    pub healthy: bool,
689    pub paths: Vec<String>,
690    pub capabilities: Vec<String>,
691    pub capability_paths: Vec<String>,
692    pub path_mode: String,
693}
694
695#[derive(Debug, Serialize, JsonSchema)]
696pub struct IndexSchema {
697    pub output_kind: String,
698    pub present: bool,
699    pub path: String,
700    pub file_entries: usize,
701    pub directory_entries: usize,
702    pub untracked_directory_entries: usize,
703    pub snapshot_bytes: u64,
704    pub journal_bytes: u64,
705    pub journal_ops: usize,
706    pub journal_replay_ms: u128,
707    pub dump: Option<String>,
708}
709
710#[derive(Debug, Serialize, JsonSchema)]
711pub struct BlameSchema {
712    pub output_kind: Option<String>,
713    pub status: Option<String>,
714    pub file: String,
715    pub context: Vec<BlameContextSnippetSchema>,
716    pub lines: Vec<BlameLineSchema>,
717}
718
719#[derive(Debug, Serialize, JsonSchema)]
720pub struct BlameLineSchema {
721    pub line_number: usize,
722    pub content: String,
723    pub change_id: String,
724    pub principal: BlamePrincipalSchema,
725    pub agent: Option<BlameAgentSchema>,
726    pub timestamp: String,
727    pub origins: Option<Vec<BlameOriginSchema>>,
728}
729
730#[derive(Debug, Serialize, JsonSchema)]
731pub struct BlameOriginSchema {
732    pub change_id: String,
733    pub principal: BlamePrincipalSchema,
734    pub agent: Option<BlameAgentSchema>,
735    pub timestamp: String,
736}
737
738#[derive(Debug, Serialize, JsonSchema)]
739pub struct BlamePrincipalSchema {
740    pub name: String,
741    pub email: String,
742}
743
744#[derive(Debug, Serialize, JsonSchema)]
745pub struct BlameAgentSchema {
746    pub provider: String,
747    pub model: String,
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub session_id: Option<String>,
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub policy_id: Option<String>,
752}
753
754#[derive(Debug, Serialize, JsonSchema)]
755pub struct BlameContextSnippetSchema {
756    pub annotation_id: String,
757    pub kind: String,
758    pub content: String,
759    pub revision_count: usize,
760}
761
762#[derive(Debug, Serialize, JsonSchema)]
763pub struct FsckSchema {
764    pub valid: bool,
765    pub errors: Vec<FsckErrorSchema>,
766    pub warnings: Vec<String>,
767    pub objects_checked: usize,
768    pub bridge_checked: bool,
769}
770
771#[derive(Debug, Serialize, JsonSchema)]
772pub struct FsckErrorSchema {
773    pub kind: String,
774    pub message: String,
775    pub object: Option<String>,
776}
777
778#[derive(Debug, Serialize, JsonSchema)]
779pub struct ResolveSchema {
780    pub output_kind: String,
781    pub message: Option<String>,
782    pub resolved: Option<Vec<String>>,
783    pub remaining: Option<Vec<String>>,
784    pub conflicts: Option<Vec<String>>,
785    pub continued: Option<bool>,
786    pub continuation_status: Option<String>,
787    pub continuation_message: Option<String>,
788    pub next_action: Option<String>,
789    pub recommended_action: Option<String>,
790}
791
792#[allow(dead_code, clippy::large_enum_variant)]
793#[derive(Debug, Serialize, JsonSchema)]
794#[serde(untagged)]
795pub enum InspectSchema {
796    #[allow(dead_code)]
797    State(ShowSchema),
798    #[allow(dead_code)]
799    Thread(ThreadShowSchema),
800}
801
802#[derive(Debug, Serialize, JsonSchema)]
803pub struct RetroSchema {
804    pub since: Option<String>,
805    pub until: Option<String>,
806    pub duration_secs: Option<i64>,
807    pub states_captured: Vec<RetroStateEntrySchema>,
808    pub agents_active: Vec<RetroAgentEntrySchema>,
809    pub markers_created: Vec<RetroMarkerEntrySchema>,
810    pub context_annotations: Vec<RetroContextAnnotationEntrySchema>,
811    pub verify_signals: Vec<RetroVerifySignalSchema>,
812    pub merges: Vec<RetroOperationEntrySchema>,
813    pub undos: Vec<RetroOperationEntrySchema>,
814}
815
816#[derive(Debug, Serialize, JsonSchema)]
817pub struct RetroStateEntrySchema {
818    pub change_id: String,
819    pub intent: Option<String>,
820    pub confidence: Option<f32>,
821    pub agent: Option<String>,
822    pub principal: String,
823    pub timestamp: String,
824}
825
826#[derive(Debug, Serialize, JsonSchema)]
827pub struct RetroAgentEntrySchema {
828    pub session_id: String,
829    pub provider: Option<String>,
830    pub model: Option<String>,
831    pub status: String,
832    pub started_at: String,
833    pub completed_at: Option<String>,
834    pub tokens: RetroAgentTokensSchema,
835}
836
837#[derive(Debug, Serialize, JsonSchema)]
838pub struct RetroAgentTokensSchema {
839    pub input: Option<u64>,
840    pub output: Option<u64>,
841    pub reasoning: Option<u64>,
842    pub tool_calls: Option<u32>,
843}
844
845#[derive(Debug, Serialize, JsonSchema)]
846pub struct RetroMarkerEntrySchema {
847    pub name: String,
848    pub state: String,
849    pub timestamp: String,
850}
851
852#[derive(Debug, Serialize, JsonSchema)]
853pub struct RetroContextAnnotationEntrySchema {
854    pub path: String,
855    pub scope: String,
856    pub kind: String,
857    pub content_excerpt: String,
858    pub attribution: String,
859    pub created_at: String,
860}
861
862#[derive(Debug, Serialize, JsonSchema)]
863pub struct RetroVerifySignalSchema {
864    pub kind: String,
865    pub label: String,
866    pub timestamp: String,
867}
868
869#[derive(Debug, Serialize, JsonSchema)]
870pub struct RetroOperationEntrySchema {
871    pub description: String,
872    pub timestamp: String,
873}
874
875#[derive(Debug, Serialize, JsonSchema)]
876pub struct DiscussionSchema {
877    pub id: String,
878    pub file: String,
879    pub symbol: String,
880    pub opened_against_state: String,
881    pub opened_at_secs: i64,
882    pub visibility: String,
883    pub body_changed_since_open: bool,
884    pub orphaned: bool,
885    pub resolution: DiscussionResolutionSchema,
886    pub turns: Vec<DiscussionTurnSchema>,
887    pub resolved_annotation_id: Option<String>,
888}
889
890/// Per-discussion verbs (`open`/`append`/`resolve`/`show`) emit the
891/// discussion payload flattened beneath an `output_kind` discriminator,
892/// mirroring `DiscussionEnvelope` in `discuss.rs`. `discuss list` reuses
893/// the bare [`DiscussionSchema`] for its inner items — those carry no
894/// per-item discriminator (the list envelope owns it), so the
895/// discriminator lives on this wrapper rather than on the shared inner
896/// struct.
897#[derive(Debug, Serialize, JsonSchema)]
898pub struct DiscussionEnvelopeSchema {
899    pub output_kind: String,
900    #[serde(flatten)]
901    pub discussion: DiscussionSchema,
902}
903
904#[derive(Debug, Serialize, JsonSchema)]
905pub struct DiscussionResolutionSchema {
906    pub kind: String,
907    pub annotation_id: Option<String>,
908    pub state_id: Option<String>,
909    pub reason: Option<String>,
910}
911
912#[derive(Debug, Serialize, JsonSchema)]
913pub struct DiscussionTurnSchema {
914    pub author_name: String,
915    pub author_email: String,
916    pub body: String,
917    pub posted_at_secs: i64,
918}
919
920#[derive(Debug, Serialize, JsonSchema)]
921pub struct DiscussionListSchema {
922    pub output_kind: String,
923    pub discussions: Vec<DiscussionSchema>,
924}
925
926#[derive(Debug, Serialize, JsonSchema)]
927pub struct QuerySchema {
928    pub output_kind: String,
929    pub hits: Vec<QueryHitSchema>,
930}
931
932#[derive(Debug, Serialize, JsonSchema)]
933pub struct QueryHitSchema {
934    pub seq: u64,
935    pub timestamp_secs: i64,
936    pub verb: String,
937    pub actor_email: String,
938    pub operation_id: Option<String>,
939    pub thread: Option<String>,
940    pub symbols: Vec<String>,
941    pub signal_kinds: Vec<String>,
942    pub change_id: Option<String>,
943}
944
945#[derive(Debug, Serialize, JsonSchema)]
946pub struct OperationRecordSchema {
947    pub op_id: String,
948    pub command: String,
949    pub idempotency_status: String,
950    pub replayed: bool,
951}
952
953// ---- core loop write/read helpers -----------------------------------------
954
955#[derive(Debug, Serialize, JsonSchema)]
956pub struct InitSchema {
957    pub output_kind: String,
958    pub status: String,
959    pub action: String,
960    pub path: String,
961    pub repository_mode: String,
962    pub git_detected: bool,
963    pub heddle_initialized: bool,
964    pub installed_heddleignore: bool,
965    pub principal_configured: bool,
966    pub principal_status: String,
967    pub principal_source: Option<String>,
968    pub principal: Option<InitPrincipalSchema>,
969    pub principal_recommended_action: Option<String>,
970    pub side_effects: Vec<String>,
971    pub message: String,
972    pub next_action: Option<String>,
973    pub recommended_action: Option<String>,
974}
975
976#[derive(Debug, Serialize, JsonSchema)]
977pub struct InitPrincipalSchema {
978    pub name: String,
979    pub email: String,
980}
981
982#[derive(Debug, Serialize, JsonSchema)]
983pub struct CaptureSchema {
984    pub output_kind: Option<String>,
985    pub status: String,
986    pub action: String,
987    pub change_id: String,
988    pub content_hash: String,
989    pub intent: Option<String>,
990    pub confidence: Option<f32>,
991    pub principal: CommitPrincipalSchema,
992    pub agent: Option<CommitAgentSchema>,
993    pub promotion_suggested: bool,
994    pub heavy_impact_paths: Vec<String>,
995    pub signed: bool,
996    pub message: String,
997    pub next_action: Option<String>,
998    pub next_action_template: Option<ActionTemplateSchema>,
999    pub recommended_action: Option<String>,
1000    pub recommended_action_template: Option<ActionTemplateSchema>,
1001}
1002
1003#[derive(Debug, Serialize, JsonSchema)]
1004pub struct CommitSchema {
1005    pub output_kind: Option<String>,
1006    pub status: String,
1007    pub action: String,
1008    pub change_id: String,
1009    pub git_commit: Option<String>,
1010    pub git_previous_commit: Option<String>,
1011    pub summary: String,
1012    pub confidence: Option<f32>,
1013    pub git_index: Option<GitIndexInfoSchema>,
1014    pub included_pending_capture: Option<String>,
1015    pub principal: CommitPrincipalSchema,
1016    pub agent: Option<CommitAgentSchema>,
1017    pub next_action: Option<String>,
1018    pub next_action_template: Option<ActionTemplateSchema>,
1019    pub recommended_action: Option<String>,
1020    pub recommended_action_template: Option<ActionTemplateSchema>,
1021    pub op_id: Option<String>,
1022    pub operation_record: Option<OperationRecordSchema>,
1023    pub idempotency_status: Option<String>,
1024    pub replayed: Option<bool>,
1025}
1026
1027#[derive(Debug, Serialize, JsonSchema)]
1028pub struct CommitPrincipalSchema {
1029    pub name: String,
1030    pub email: String,
1031}
1032
1033#[derive(Debug, Serialize, JsonSchema)]
1034pub struct CommitAgentSchema {
1035    pub provider: String,
1036    pub model: String,
1037    #[serde(default, skip_serializing_if = "Option::is_none")]
1038    pub session_id: Option<String>,
1039    #[serde(default, skip_serializing_if = "Option::is_none")]
1040    pub segment_id: Option<String>,
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    pub policy_id: Option<String>,
1043}
1044
1045#[derive(Debug, Serialize, JsonSchema)]
1046pub struct CheckpointSchema {
1047    pub output_kind: Option<String>,
1048    pub status: String,
1049    pub action: String,
1050    pub change_id: String,
1051    pub git_commit: String,
1052    pub summary: String,
1053    pub capability: String,
1054    pub storage_model: String,
1055    pub committed_at: String,
1056    pub next_action: Option<String>,
1057    pub next_action_template: Option<ActionTemplateSchema>,
1058    pub recommended_action: Option<String>,
1059    pub recommended_action_template: Option<ActionTemplateSchema>,
1060}
1061
1062#[derive(Debug, Serialize, JsonSchema)]
1063pub struct OperatorCommandSchema {
1064    pub output_kind: Option<String>,
1065    pub status: String,
1066    pub action: String,
1067    pub message: String,
1068    pub blockers: Vec<String>,
1069    pub warnings: Vec<String>,
1070    pub next_action: Option<String>,
1071    pub next_action_template: Option<ActionTemplateSchema>,
1072    pub recommended_action: Option<String>,
1073    pub recommended_action_template: Option<ActionTemplateSchema>,
1074}
1075
1076#[derive(Debug, Serialize, JsonSchema)]
1077pub struct UndoSchema {
1078    pub output_kind: Option<String>,
1079    pub status: Option<String>,
1080    pub action: String,
1081    pub message: String,
1082    pub batches: Vec<Value>,
1083    pub next_action: Option<String>,
1084    pub next_action_template: Option<ActionTemplateSchema>,
1085    pub recommended_action: Option<String>,
1086    pub recommended_action_template: Option<ActionTemplateSchema>,
1087    /// heddle#305: the pre-undo state preserved for recovery, and the marker
1088    /// pointing at it. Present only on a completed `undo`.
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub recovery_state: Option<String>,
1091    #[serde(default, skip_serializing_if = "Option::is_none")]
1092    pub recovery_marker: Option<String>,
1093}
1094
1095/// `heddle undo --list --output json` history view. Distinct from
1096/// [`UndoSchema`] (the rewind/redo payload): the list view carries only the
1097/// discriminator and the oplog batches, with none of the action/status/
1098/// recovery fields a real undo emits. Mirrors `OpListOutput` in `undo.rs`.
1099#[derive(Debug, Serialize, JsonSchema)]
1100pub struct UndoListSchema {
1101    pub output_kind: Option<String>,
1102    pub batches: Vec<Value>,
1103}
1104
1105#[derive(Debug, Serialize, JsonSchema)]
1106pub struct CleanSchema {
1107    pub output_kind: String,
1108    pub removed: Vec<String>,
1109    pub dry_run: bool,
1110}
1111
1112#[derive(Debug, Serialize, JsonSchema)]
1113pub struct DiffSchema {
1114    pub output_kind: Option<String>,
1115    pub status: Option<String>,
1116    pub from_state: Option<String>,
1117    pub to_state: Option<String>,
1118    pub changed_path_count: usize,
1119    pub stats: DiffStatsSchema,
1120    /// Worktree-mode diff (`heddle diff` with no revision args) groups the
1121    /// per-file changes into `{modified, added, deleted}` category arrays,
1122    /// mirroring the `status` command's `changes` shape so a UI can derive
1123    /// add/modify/delete badges from `diff` alone. A state-to-state diff
1124    /// (`heddle diff <a> <b>`) instead emits a flat `array<object>` here.
1125    pub changes: DiffChangesSchema,
1126    pub semantic_changes: Option<Vec<Value>>,
1127    pub context: Option<Vec<Value>>,
1128    pub broader_guidance: Option<Vec<Value>>,
1129    /// Rendered unified-diff text, suitable for `patch(1)` / `git apply`.
1130    /// Present whenever line-level hunks exist, regardless of the
1131    /// `--patch` CLI flag — JSON consumers always get a parseable diff.
1132    pub patch: Option<String>,
1133}
1134
1135/// `changes` admits the two documented shapes the `diff` command emits:
1136/// worktree mode (`heddle diff` with no revision args) groups entries into
1137/// `{modified, added, deleted}` category arrays; a state-to-state diff
1138/// (`heddle diff <a> <b>`) emits a flat `array<object>`. The schema is a
1139/// union of both so either documented output validates.
1140#[derive(Debug, Serialize, JsonSchema)]
1141#[serde(untagged)]
1142#[allow(dead_code)]
1143pub enum DiffChangesSchema {
1144    /// Worktree-mode: per-file diff entries bucketed by category, mirroring
1145    /// the `status` command's `{modified, added, deleted}` field names. Each
1146    /// entry carries its path plus the per-file diff fields (`kind`,
1147    /// `old_path`, `lines`, …). A `renamed` entry buckets under `modified`
1148    /// (its `kind`/`old_path` identify the rename).
1149    Grouped(DiffChangesGroupedSchema),
1150    /// State-to-state: a flat array of per-file diff entries.
1151    Flat(Vec<Value>),
1152}
1153
1154#[derive(Debug, Serialize, JsonSchema)]
1155pub struct DiffChangesGroupedSchema {
1156    pub modified: Vec<Value>,
1157    pub added: Vec<Value>,
1158    pub deleted: Vec<Value>,
1159}
1160
1161#[derive(Debug, Serialize, JsonSchema)]
1162pub struct DiffStatsSchema {
1163    pub files_changed: usize,
1164    pub additions: usize,
1165    pub modifications: usize,
1166    pub deletions: usize,
1167    pub renames: usize,
1168}
1169
1170#[derive(Debug, Serialize, JsonSchema)]
1171pub struct SwitchCheckoutSchema {
1172    pub output_kind: Option<String>,
1173    pub status: Option<String>,
1174    pub action: Option<String>,
1175    pub name: Option<String>,
1176    pub message: String,
1177    pub thread: Option<ThreadSummarySchema>,
1178    pub path: Option<String>,
1179    pub execution_path: Option<String>,
1180    pub target: Option<String>,
1181    pub intent: Option<String>,
1182    pub next_action: Option<String>,
1183    pub next_action_template: Option<ActionTemplateSchema>,
1184    pub recommended_action: Option<String>,
1185    pub recommended_action_template: Option<ActionTemplateSchema>,
1186}
1187
1188#[derive(Debug, Serialize, JsonSchema)]
1189pub struct MergePreviewSchema {
1190    pub output_kind: Option<String>,
1191    pub status: Option<String>,
1192    pub action: Option<String>,
1193    pub message: Option<String>,
1194    pub would_merge: bool,
1195    pub applied: bool,
1196    pub blockers: Option<Vec<String>>,
1197    pub warnings: Option<Vec<String>>,
1198    pub next_action: Option<String>,
1199    pub next_action_template: Option<ActionTemplateSchema>,
1200    pub recommended_action: Option<String>,
1201    pub recommended_action_template: Option<ActionTemplateSchema>,
1202    pub fast_forward: Option<bool>,
1203    pub preview_only: Option<bool>,
1204    pub merge_state: Option<String>,
1205    pub conflicts: Option<Vec<String>>,
1206    pub preview_summary: Option<Vec<String>>,
1207    pub thread_state: Option<String>,
1208    pub freshness: Option<String>,
1209    pub changed_paths: Option<Vec<String>>,
1210    pub changed_path_count: Option<usize>,
1211    pub impact_categories: Option<Vec<String>>,
1212    pub promotion_suggested: Option<bool>,
1213    pub heavy_impact_paths: Option<Vec<String>>,
1214    pub merge_relation: Option<String>,
1215    pub conflict_count: Option<usize>,
1216    pub thread_health: Option<String>,
1217    pub diff: Option<Value>,
1218}
1219
1220#[derive(Debug, Serialize, JsonSchema)]
1221pub struct ReadySchema {
1222    pub output_kind: Option<String>,
1223    pub status: String,
1224    pub action: String,
1225    pub message: String,
1226    pub blockers: Vec<String>,
1227    pub warnings: Vec<String>,
1228    pub next_action: Option<String>,
1229    pub next_action_template: Option<ActionTemplateSchema>,
1230    pub recommended_action: Option<String>,
1231    pub recommended_action_template: Option<ActionTemplateSchema>,
1232    pub captured: bool,
1233    pub captured_state: Option<String>,
1234    pub thread_state: Option<String>,
1235    pub readiness: ReadyReadinessSchema,
1236    pub report: Value,
1237    #[serde(rename = "verification")]
1238    pub verification: RepositoryVerificationStateSchema,
1239}
1240
1241#[derive(Debug, Serialize, JsonSchema)]
1242pub struct ReadyReadinessSchema {
1243    pub status: String,
1244    pub captured: bool,
1245    pub captured_state: Option<String>,
1246    pub checks: ReadyChecksSchema,
1247    pub integration: String,
1248    pub freshness: String,
1249    pub merge_type: String,
1250    pub changed_path_count: usize,
1251    pub changed_paths: Vec<String>,
1252    pub conflict_count: usize,
1253    pub conflicts: Vec<String>,
1254    pub impact: String,
1255    pub impact_categories: Vec<String>,
1256    pub blockers: Vec<String>,
1257}
1258
1259#[derive(Debug, Serialize, JsonSchema)]
1260pub struct ReadyChecksSchema {
1261    pub status: String,
1262    pub reason: String,
1263}
1264
1265#[derive(Debug, Serialize, JsonSchema)]
1266pub struct SyncSchema {
1267    #[serde(flatten)]
1268    pub operator: OperatorCommandSchema,
1269    pub thread: Option<String>,
1270    pub current_state: Option<String>,
1271    pub chosen_path: Option<String>,
1272}
1273
1274#[derive(Debug, Serialize, JsonSchema)]
1275pub struct LandSchema {
1276    pub output_kind: Option<String>,
1277    pub status: String,
1278    pub action: String,
1279    pub message: String,
1280    pub blockers: Option<Vec<String>>,
1281    pub warnings: Option<Vec<String>>,
1282    pub next_action: Option<String>,
1283    pub next_action_template: Option<ActionTemplateSchema>,
1284    pub recommended_action: Option<String>,
1285    pub recommended_action_template: Option<ActionTemplateSchema>,
1286    pub thread: String,
1287    pub captured: bool,
1288    pub checkpointed: bool,
1289    pub git_commit: Option<String>,
1290    pub synced: bool,
1291    pub integrated: bool,
1292    pub pushed: bool,
1293    pub pushed_remote: Option<String>,
1294    pub performed_steps: Vec<String>,
1295    pub skipped_steps: Vec<String>,
1296    pub merge_state: Option<String>,
1297    pub chosen_path: String,
1298}
1299
1300#[derive(Debug, Serialize, JsonSchema)]
1301pub struct ThreadStartSchema {
1302    pub output_kind: Option<String>,
1303    pub status: Option<String>,
1304    pub action: Option<String>,
1305    pub name: String,
1306    pub message: String,
1307    pub next_action: Option<String>,
1308    pub next_action_template: Option<ActionTemplateSchema>,
1309    pub recommended_action: Option<String>,
1310    pub recommended_action_template: Option<ActionTemplateSchema>,
1311    pub thread: Option<ThreadSummarySchema>,
1312    pub path: Option<String>,
1313    pub execution_path: Option<String>,
1314    pub fskit_readiness: Option<FsKitReadinessSchema>,
1315}
1316
1317#[derive(Debug, Serialize, JsonSchema)]
1318pub struct FsKitReadinessSchema {
1319    pub state: String,
1320    pub backend: String,
1321    pub action: String,
1322    pub settings_url: Option<String>,
1323}
1324
1325#[derive(Debug, Serialize, JsonSchema)]
1326pub struct ThreadCurrentSchema {
1327    pub thread: String,
1328}
1329
1330#[derive(Debug, Serialize, JsonSchema)]
1331#[serde(transparent)]
1332pub struct ThreadCapturesSchema(pub Vec<ThreadCaptureEntrySchema>);
1333
1334#[derive(Debug, Serialize, JsonSchema)]
1335pub struct ThreadCaptureEntrySchema {
1336    pub change_id: String,
1337    pub created_at: String,
1338    pub intent: Option<String>,
1339    pub confidence: Option<f32>,
1340    pub agent: Option<String>,
1341    pub message: String,
1342    pub summary: Option<ThreadCaptureSummarySchema>,
1343}
1344
1345#[derive(Debug, Serialize, JsonSchema)]
1346pub struct ThreadCaptureSummarySchema {
1347    pub added: usize,
1348    pub modified: usize,
1349    pub deleted: usize,
1350    pub total: usize,
1351}
1352
1353#[derive(Debug, Serialize, JsonSchema)]
1354pub struct ThreadCommandSchema {
1355    pub output_kind: String,
1356    pub status: String,
1357    pub action: String,
1358    pub name: String,
1359    pub message: String,
1360    pub next_action: Option<String>,
1361    pub next_action_template: Option<ActionTemplateSchema>,
1362    pub recommended_action: Option<String>,
1363    pub recommended_action_template: Option<ActionTemplateSchema>,
1364    pub thread: Option<ThreadSummarySchema>,
1365    pub path: Option<String>,
1366    pub execution_path: Option<String>,
1367}
1368
1369#[derive(Debug, Serialize, JsonSchema)]
1370pub struct ThreadMoveSchema {
1371    pub from_thread: String,
1372    pub to_thread: String,
1373    pub moved_paths: Vec<String>,
1374    pub source_change_id: Option<String>,
1375    pub target_change_id: String,
1376    pub message: String,
1377}
1378
1379#[derive(Debug, Serialize, JsonSchema)]
1380pub struct ThreadAbsorbSchema {
1381    pub thread: String,
1382    pub into: String,
1383    pub preview_only: bool,
1384    pub conflicts: Vec<String>,
1385    pub merge_state: Option<String>,
1386    pub message: String,
1387}
1388
1389#[derive(Debug, Serialize, JsonSchema)]
1390pub struct ThreadResolveSchema {
1391    #[serde(flatten)]
1392    pub operator: OperatorCommandSchema,
1393    pub thread: String,
1394}
1395
1396#[derive(Debug, Serialize, JsonSchema)]
1397pub struct ThreadApprovalSchema {
1398    pub id: String,
1399    pub repo_path: String,
1400    pub source_thread: String,
1401    pub target_thread: String,
1402    pub source_state: String,
1403    pub approver_user_id: String,
1404    pub note: String,
1405    pub approved_at: u64,
1406    pub expires_at: u64,
1407}
1408
1409#[derive(Debug, Serialize, JsonSchema)]
1410#[serde(transparent)]
1411pub struct ThreadApprovalListSchema(pub Vec<ThreadApprovalSchema>);
1412
1413#[derive(Debug, Serialize, JsonSchema)]
1414pub struct ThreadRevokeApprovalSchema {
1415    pub output_kind: String,
1416    pub deleted: bool,
1417    pub id: String,
1418}
1419
1420#[derive(Debug, Serialize, JsonSchema)]
1421pub struct ThreadMergeEligibilitySchema {
1422    pub allowed: bool,
1423    pub unmet: Vec<ThreadMergeRequirementSchema>,
1424    pub valid_approvals: Vec<ThreadApprovalSchema>,
1425}
1426
1427#[derive(Debug, Serialize, JsonSchema)]
1428pub struct ThreadMergeRequirementSchema {
1429    pub policy_id: String,
1430    pub kind: String,
1431    pub group_id: String,
1432    pub reason: String,
1433    pub needed: u32,
1434    pub have: u32,
1435}
1436
1437#[derive(Debug, Serialize, JsonSchema)]
1438pub struct ThreadCleanupSchema {
1439    #[serde(flatten)]
1440    pub operator: OperatorCommandSchema,
1441    pub dry_run: bool,
1442    pub merged: Vec<ThreadDroppedSchema>,
1443    pub auto: Vec<ThreadDroppedSchema>,
1444    pub reclaimed_bytes: u64,
1445    pub would_reclaim_bytes: u64,
1446    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1447    pub skipped: Vec<ThreadCleanupSkippedSchema>,
1448}
1449
1450#[derive(Debug, Serialize, JsonSchema)]
1451pub struct ThreadDroppedSchema {
1452    pub thread: String,
1453    pub id: String,
1454    pub reason: String,
1455    pub age_seconds: i64,
1456    pub bytes: u64,
1457    pub execution_path: Option<String>,
1458}
1459
1460#[derive(Debug, Serialize, JsonSchema)]
1461pub struct ThreadCleanupSkippedSchema {
1462    pub thread: String,
1463    pub id: String,
1464    pub reason: String,
1465    pub note: String,
1466}
1467
1468#[derive(Debug, Serialize, JsonSchema)]
1469pub struct ThreadMarkerListSchema {
1470    pub output_kind: String,
1471    pub markers: Vec<ThreadMarkerEntrySchema>,
1472}
1473
1474#[derive(Debug, Serialize, JsonSchema)]
1475pub struct ThreadMarkerEntrySchema {
1476    pub name: String,
1477    pub change_id: String,
1478}
1479
1480#[derive(Debug, Serialize, JsonSchema)]
1481pub struct ThreadMarkerOpSchema {
1482    pub output_kind: String,
1483    pub name: Option<String>,
1484    pub change_id: Option<String>,
1485    pub deleted: Option<Vec<ThreadMarkerEntrySchema>>,
1486    pub count: Option<usize>,
1487    pub message: String,
1488}
1489
1490#[derive(Debug, Serialize, JsonSchema)]
1491pub struct ThreadShowSchema {
1492    pub output_kind: Option<String>,
1493    pub repository_label: String,
1494    pub repository_context: Option<RepositoryContextInfoSchema>,
1495    #[serde(flatten)]
1496    pub summary: ThreadSummarySchema,
1497    pub next_action: Option<String>,
1498    pub next_action_template: Option<ActionTemplateSchema>,
1499    pub recommended_action: Option<String>,
1500    pub recommended_action_template: Option<ActionTemplateSchema>,
1501    #[serde(rename = "verification")]
1502    pub trust: RepositoryVerificationStateSchema,
1503    pub recovery_commands: Vec<String>,
1504}
1505
1506#[derive(Debug, Serialize, JsonSchema)]
1507pub struct ThreadSummarySchema {
1508    pub name: String,
1509    pub operation: OpaqueObject,
1510    pub remote_tracking: OpaqueObject,
1511    pub base_state: Option<String>,
1512    pub base_root: Option<String>,
1513    pub current_state: Option<String>,
1514    pub path: Option<String>,
1515    pub execution_path: Option<String>,
1516    #[serde(default, skip_serializing_if = "Option::is_none")]
1517    pub session_id: Option<String>,
1518    #[serde(default, skip_serializing_if = "Option::is_none")]
1519    pub heddle_session_id: Option<String>,
1520    pub actor: Option<ActorInfoSchema>,
1521    pub harness: Option<String>,
1522    pub thinking_level: Option<String>,
1523    #[serde(default, skip_serializing_if = "Option::is_none")]
1524    pub native_actor_key: Option<String>,
1525    #[serde(default, skip_serializing_if = "Option::is_none")]
1526    pub native_parent_actor_key: Option<String>,
1527    #[serde(default, skip_serializing_if = "Option::is_none")]
1528    pub probe_source: Option<String>,
1529    #[serde(default, skip_serializing_if = "Option::is_none")]
1530    pub probe_confidence: Option<f32>,
1531    pub usage_summary: OpaqueObject,
1532    pub last_progress_at: Option<String>,
1533    pub last_activity_at: Option<String>,
1534    pub report_flush_state: Option<String>,
1535    pub attach_reason: Option<String>,
1536    pub thread_mode: Option<ThreadModeSchema>,
1537    pub thread_state: Option<ThreadStateSchema>,
1538    pub freshness: Option<ThreadFreshnessSchema>,
1539    pub visibility: String,
1540    pub target_thread: Option<String>,
1541    pub parent_thread: Option<String>,
1542    pub child_threads: Vec<String>,
1543    pub sibling_threads: Vec<String>,
1544    pub stack_depth: usize,
1545    pub stale_from_parent: bool,
1546    pub task: Option<String>,
1547    pub changed_paths: Vec<String>,
1548    pub promotion_suggested: bool,
1549    pub impact_categories: Vec<ThreadImpactCategorySchema>,
1550    pub heavy_impact_paths: Vec<String>,
1551    pub verification_summary: Value,
1552    pub confidence_summary: Value,
1553    pub integration_policy_result: Value,
1554    pub coordination_status: CoordinationStatusSchema,
1555    pub is_current: bool,
1556    pub is_isolated: bool,
1557    pub thread_health: String,
1558    pub blockers: Vec<String>,
1559    // Runtime `ThreadSummary.recommended_action` is a `String` serialized
1560    // through `serialize_empty_action_as_null`, so the wire value is
1561    // `string | null` (HeddleCo/heddle#645 presence contract).
1562    pub recommended_action: Option<String>,
1563    pub recommended_action_template: Option<ActionTemplateSchema>,
1564    pub git_branch_tip: Option<String>,
1565    pub history_imported: bool,
1566    pub auto: bool,
1567    pub shared_target_dir: Option<String>,
1568}
1569
1570#[derive(Debug, Serialize, JsonSchema)]
1571pub struct CloneSchema {
1572    pub output_kind: Option<String>,
1573    pub action: Option<String>,
1574    pub status: Option<String>,
1575    pub success: Option<bool>,
1576    pub cloned: Option<bool>,
1577    pub transport: Option<String>,
1578    pub remote: Option<String>,
1579    pub local: Option<String>,
1580    pub branch: Option<String>,
1581    pub repository_capability: Option<String>,
1582    pub commits_imported: Option<u64>,
1583    pub states_created: Option<u64>,
1584    pub objects: Option<usize>,
1585    pub state: Option<String>,
1586}
1587
1588#[derive(Debug, Serialize, JsonSchema)]
1589pub struct AdoptSchema {
1590    pub output_kind: Option<String>,
1591    pub status: Option<String>,
1592    pub action: Option<String>,
1593    pub adopted: bool,
1594    pub initialized: bool,
1595    pub path: String,
1596    pub refs: Vec<String>,
1597    pub commits_imported: usize,
1598    pub states_created: usize,
1599    pub branches_synced: usize,
1600    pub tags_synced: usize,
1601    pub skipped_non_commit_refs: usize,
1602    pub already_in_sync: bool,
1603    pub recommended_action: Option<String>,
1604    pub recommended_action_template: Option<ActionTemplateSchema>,
1605    #[serde(rename = "verification")]
1606    pub trust: RepositoryVerificationStateSchema,
1607}
1608
1609#[derive(Debug, Serialize, JsonSchema)]
1610pub struct RemoteListSchema {
1611    pub output_kind: Option<String>,
1612    pub remotes: Vec<RemoteInfoSchema>,
1613}
1614
1615#[derive(Debug, Serialize, JsonSchema)]
1616pub struct RemoteInfoSchema {
1617    pub output_kind: Option<String>,
1618    pub name: String,
1619    pub url: String,
1620    pub source: String,
1621    pub is_default: bool,
1622}
1623
1624#[derive(Debug, Serialize, JsonSchema)]
1625pub struct RemoteMutationSchema {
1626    pub output_kind: Option<String>,
1627    pub status: String,
1628    pub action: String,
1629    pub name: String,
1630    pub url: Option<String>,
1631    pub default: Option<String>,
1632    pub message: String,
1633}
1634
1635#[derive(Debug, Serialize, JsonSchema)]
1636pub struct ActorSingleSchema {
1637    pub output_kind: String,
1638    pub actor: ActorEntrySchema,
1639    #[serde(rename = "verification")]
1640    pub trust: RepositoryVerificationStateSchema,
1641}
1642
1643#[derive(Debug, Serialize, JsonSchema)]
1644pub struct ActorListSchema {
1645    pub output_kind: String,
1646    pub actors: Vec<ActorEntrySchema>,
1647    pub active_only: bool,
1648    #[serde(rename = "verification")]
1649    pub trust: RepositoryVerificationStateSchema,
1650}
1651
1652#[derive(Debug, Serialize, JsonSchema)]
1653pub struct ActorDoneSchema {
1654    pub output_kind: String,
1655    pub session_id: String,
1656    pub status: String,
1657    pub thread: String,
1658    #[serde(default, skip_serializing_if = "Option::is_none")]
1659    pub coordination_status: Option<String>,
1660    #[serde(default, skip_serializing_if = "Option::is_none")]
1661    pub recommended_action: Option<String>,
1662    #[serde(default, skip_serializing_if = "Option::is_none")]
1663    pub recommended_action_template: Option<ActionTemplateSchema>,
1664    #[serde(rename = "verification")]
1665    pub trust: RepositoryVerificationStateSchema,
1666}
1667
1668#[derive(Debug, Serialize, JsonSchema)]
1669pub struct ActorExplainSchema {
1670    pub output_kind: String,
1671    #[serde(default, skip_serializing_if = "Option::is_none")]
1672    pub attached: Option<bool>,
1673    #[serde(default, skip_serializing_if = "Option::is_none")]
1674    pub active_actor: Option<Value>,
1675    #[serde(default, skip_serializing_if = "Option::is_none")]
1676    pub reason: Option<String>,
1677    #[serde(default, skip_serializing_if = "Option::is_none")]
1678    pub repository: Option<String>,
1679    #[serde(default, skip_serializing_if = "Option::is_none")]
1680    pub detected: Option<Value>,
1681    #[serde(default, skip_serializing_if = "Option::is_none")]
1682    pub environment: Option<Value>,
1683    #[serde(default, skip_serializing_if = "Option::is_none")]
1684    pub recommended_action: Option<String>,
1685    #[serde(default, skip_serializing_if = "Option::is_none")]
1686    pub recommended_action_template: Option<ActionTemplateSchema>,
1687    #[serde(default, skip_serializing_if = "Option::is_none")]
1688    pub session_id: Option<String>,
1689    #[serde(default, skip_serializing_if = "Option::is_none")]
1690    pub thread: Option<String>,
1691    #[serde(default, skip_serializing_if = "Option::is_none")]
1692    pub heddle_session_id: Option<String>,
1693    #[serde(default, skip_serializing_if = "Option::is_none")]
1694    pub client_instance_id: Option<String>,
1695    #[serde(default, skip_serializing_if = "Option::is_none")]
1696    pub native_actor_key: Option<String>,
1697    #[serde(default, skip_serializing_if = "Option::is_none")]
1698    pub native_parent_actor_key: Option<String>,
1699    #[serde(default, skip_serializing_if = "Option::is_none")]
1700    pub native_instance_key: Option<String>,
1701    #[serde(default, skip_serializing_if = "Option::is_none")]
1702    pub probe_source: Option<String>,
1703    #[serde(default, skip_serializing_if = "Option::is_none")]
1704    pub probe_confidence: Option<f32>,
1705    #[serde(default, skip_serializing_if = "Option::is_none")]
1706    pub attach_reason: Option<String>,
1707    #[serde(default, skip_serializing_if = "Option::is_none")]
1708    pub attach_precedence: Option<Vec<String>>,
1709    #[serde(default, skip_serializing_if = "Option::is_none")]
1710    pub winning_rule: Option<String>,
1711    #[serde(rename = "verification")]
1712    pub trust: RepositoryVerificationStateSchema,
1713}
1714
1715#[derive(Debug, Serialize, JsonSchema)]
1716pub struct ActorEntrySchema {
1717    pub session_id: String,
1718    #[serde(default, skip_serializing_if = "Option::is_none")]
1719    pub client_instance_id: Option<String>,
1720    #[serde(default, skip_serializing_if = "Option::is_none")]
1721    pub native_actor_key: Option<String>,
1722    #[serde(default, skip_serializing_if = "Option::is_none")]
1723    pub native_parent_actor_key: Option<String>,
1724    #[serde(default, skip_serializing_if = "Option::is_none")]
1725    pub native_instance_key: Option<String>,
1726    #[serde(default, skip_serializing_if = "Option::is_none")]
1727    pub heddle_session_id: Option<String>,
1728    pub thread: String,
1729    #[serde(default, skip_serializing_if = "Option::is_none")]
1730    pub thread_id: Option<String>,
1731    pub base_state: String,
1732    #[serde(default, skip_serializing_if = "Option::is_none")]
1733    pub path: Option<String>,
1734    #[serde(default, skip_serializing_if = "Option::is_none")]
1735    pub provider: Option<String>,
1736    #[serde(default, skip_serializing_if = "Option::is_none")]
1737    pub model: Option<String>,
1738    #[serde(default, skip_serializing_if = "Option::is_none")]
1739    pub harness: Option<String>,
1740    #[serde(default, skip_serializing_if = "Option::is_none")]
1741    pub thinking_level: Option<String>,
1742    pub usage_summary: Value,
1743    #[serde(default, skip_serializing_if = "Option::is_none")]
1744    pub last_progress_at: Option<String>,
1745    #[serde(default, skip_serializing_if = "Option::is_none")]
1746    pub report_flush_state: Option<String>,
1747    #[serde(default, skip_serializing_if = "Option::is_none")]
1748    pub attach_reason: Option<String>,
1749    pub attach_precedence: Vec<String>,
1750    #[serde(default, skip_serializing_if = "Option::is_none")]
1751    pub winning_attach_rule: Option<String>,
1752    #[serde(default, skip_serializing_if = "Option::is_none")]
1753    pub probe_source: Option<String>,
1754    #[serde(default, skip_serializing_if = "Option::is_none")]
1755    pub probe_confidence: Option<f32>,
1756    pub status: String,
1757    pub started_at: String,
1758    pub actor_chain: Vec<ActorChainEntrySchema>,
1759}
1760
1761#[derive(Debug, Serialize, JsonSchema)]
1762pub struct ActorChainEntrySchema {
1763    pub session_id: String,
1764    #[serde(default, skip_serializing_if = "Option::is_none")]
1765    pub native_actor_key: Option<String>,
1766    #[serde(default, skip_serializing_if = "Option::is_none")]
1767    pub native_parent_actor_key: Option<String>,
1768    pub thread: String,
1769    pub status: String,
1770    #[serde(default, skip_serializing_if = "Option::is_none")]
1771    pub provider: Option<String>,
1772    #[serde(default, skip_serializing_if = "Option::is_none")]
1773    pub model: Option<String>,
1774    #[serde(default, skip_serializing_if = "Option::is_none")]
1775    pub harness: Option<String>,
1776}
1777
1778#[derive(Debug, Serialize, JsonSchema)]
1779pub struct AgentServeSchema {
1780    pub output_kind: String,
1781    pub status: String,
1782    pub socket_path: String,
1783    pub pid_path: String,
1784}
1785
1786#[derive(Debug, Serialize, JsonSchema)]
1787pub struct AgentDaemonStatusSchema {
1788    pub output_kind: String,
1789    pub running: bool,
1790    pub pid: Option<u32>,
1791    pub socket_path: String,
1792    pub pid_path: String,
1793    #[serde(rename = "verification")]
1794    pub trust: RepositoryVerificationStateSchema,
1795}
1796
1797#[derive(Debug, Serialize, JsonSchema)]
1798pub struct AgentStopSchema {
1799    pub output_kind: String,
1800    pub stopped: bool,
1801    pub swept_stale: bool,
1802    pub pid: Option<i32>,
1803    pub reason: Option<String>,
1804}
1805
1806#[derive(Debug, Serialize, JsonSchema)]
1807pub struct AgentReservationEnvelopeSchema {
1808    pub reservation: AgentReservationSchema,
1809}
1810
1811#[derive(Debug, Serialize, JsonSchema)]
1812pub struct AgentReservationListSchema {
1813    pub reservations: Vec<AgentReservationSchema>,
1814    pub alive_only: bool,
1815    pub thread: Option<String>,
1816    #[serde(rename = "verification")]
1817    pub trust: RepositoryVerificationStateSchema,
1818}
1819
1820#[derive(Debug, Serialize, JsonSchema)]
1821pub struct AgentReservationSchema {
1822    pub session_id: String,
1823    pub reservation_token: Option<String>,
1824    pub thread: String,
1825    pub anchor_state: Option<String>,
1826    pub anchor_root: Option<String>,
1827    pub status: String,
1828    pub path: Option<String>,
1829    pub task: Option<String>,
1830    pub provider: Option<String>,
1831    pub model: Option<String>,
1832    pub harness: Option<String>,
1833    pub thinking_level: Option<String>,
1834    pub probe_source: Option<String>,
1835    pub probe_confidence: Option<f32>,
1836}
1837
1838#[derive(Debug, Serialize, JsonSchema)]
1839pub struct SessionEnvelopeSchema {
1840    pub session: SessionEntrySchema,
1841}
1842
1843#[derive(Debug, Serialize, JsonSchema)]
1844pub struct SessionSegmentEnvelopeSchema {
1845    pub segment: SessionSegmentSchema,
1846}
1847
1848#[derive(Debug, Serialize, JsonSchema)]
1849pub struct SessionListSchema {
1850    pub sessions: Vec<SessionEntrySchema>,
1851    pub active_only: bool,
1852    #[serde(rename = "verification")]
1853    pub trust: RepositoryVerificationStateSchema,
1854}
1855
1856#[derive(Debug, Serialize, JsonSchema)]
1857pub struct SessionEntrySchema {
1858    pub id: String,
1859    pub principal: String,
1860    pub created_at: String,
1861    #[serde(default, skip_serializing_if = "Option::is_none")]
1862    pub ended_at: Option<String>,
1863    pub active: bool,
1864    pub segments: Vec<SessionSegmentSchema>,
1865}
1866
1867#[derive(Debug, Serialize, JsonSchema)]
1868pub struct SessionSegmentSchema {
1869    pub id: String,
1870    pub provider: String,
1871    pub model: String,
1872    pub started_at: String,
1873    #[serde(default, skip_serializing_if = "Option::is_none")]
1874    pub policy_id: Option<String>,
1875}
1876
1877#[derive(Debug, Serialize, JsonSchema)]
1878pub struct FetchSchema {
1879    pub output_kind: Option<String>,
1880    pub remote: String,
1881    #[serde(default, skip_serializing_if = "Option::is_none")]
1882    pub ref_scope: Option<String>,
1883    #[serde(default, skip_serializing_if = "Option::is_none")]
1884    pub tags_included: Option<bool>,
1885    pub refs_fetched: usize,
1886    pub objects_fetched: usize,
1887}
1888
1889#[derive(Debug, Serialize, JsonSchema)]
1890pub struct PullSchema {
1891    pub output_kind: Option<String>,
1892    pub action: Option<String>,
1893    pub status: Option<String>,
1894    pub pulled: Option<bool>,
1895    pub changed: Option<bool>,
1896    pub success: Option<bool>,
1897    pub transport: Option<String>,
1898    pub remote: Option<String>,
1899    #[serde(default, skip_serializing_if = "Option::is_none")]
1900    pub branch: Option<String>,
1901    #[serde(default, skip_serializing_if = "Option::is_none")]
1902    pub old_git_head: Option<String>,
1903    #[serde(default, skip_serializing_if = "Option::is_none")]
1904    pub new_git_head: Option<String>,
1905    #[serde(default, skip_serializing_if = "Option::is_none")]
1906    pub old_state: Option<String>,
1907    #[serde(default, skip_serializing_if = "Option::is_none")]
1908    pub new_state: Option<String>,
1909    #[serde(default, skip_serializing_if = "Option::is_none")]
1910    pub ref_scope: Option<String>,
1911    #[serde(default, skip_serializing_if = "Option::is_none")]
1912    pub thread: Option<String>,
1913    pub state: Option<String>,
1914    pub objects: Option<usize>,
1915    pub states_created: Option<usize>,
1916    pub commits_seen: Option<usize>,
1917    #[serde(default, skip_serializing_if = "Option::is_none")]
1918    pub commits_seen_scope: Option<String>,
1919    pub materialized_checkout: Option<bool>,
1920    #[serde(default, skip_serializing_if = "Option::is_none")]
1921    pub changed_path_count: Option<usize>,
1922    #[serde(default, skip_serializing_if = "Option::is_none")]
1923    pub changed_paths: Option<Vec<String>>,
1924}
1925
1926#[derive(Debug, Serialize, JsonSchema)]
1927pub struct PushSchema {
1928    pub output_kind: String,
1929    pub action: String,
1930    pub status: String,
1931    pub pushed: bool,
1932    pub changed: bool,
1933    pub success: bool,
1934    pub transport: String,
1935    pub remote: Option<String>,
1936    #[serde(default, skip_serializing_if = "Option::is_none")]
1937    pub push_scope: Option<String>,
1938    #[serde(default, skip_serializing_if = "Option::is_none")]
1939    pub ref_scope: Option<String>,
1940    #[serde(default, skip_serializing_if = "Option::is_none")]
1941    pub git_notes_ref: Option<String>,
1942    /// Full ref names this push wrote at the destination (sorted; empty
1943    /// for a no-op push). Present on the Git-overlay refs path; omitted
1944    /// on the native Heddle transport. Verify with `git ls-remote`.
1945    #[serde(default, skip_serializing_if = "Option::is_none")]
1946    pub refs_written: Option<Vec<String>>,
1947    #[serde(default, skip_serializing_if = "Option::is_none")]
1948    pub git_notes_visibility_warning: Option<String>,
1949    #[serde(default, skip_serializing_if = "Option::is_none")]
1950    pub git_tracking_remote: Option<String>,
1951    #[serde(default, skip_serializing_if = "Option::is_none")]
1952    pub git_remote_configured: Option<GitRemoteConfiguredSchema>,
1953    #[serde(default, skip_serializing_if = "Option::is_none")]
1954    pub git_upstream_configured: Option<GitUpstreamConfiguredSchema>,
1955    #[serde(default, skip_serializing_if = "Option::is_none")]
1956    pub tags_included: Option<bool>,
1957    #[serde(default, skip_serializing_if = "Option::is_none")]
1958    pub force: Option<bool>,
1959    #[serde(default, skip_serializing_if = "Option::is_none")]
1960    pub force_discard_warning: Option<String>,
1961    #[serde(default, skip_serializing_if = "Option::is_none")]
1962    pub thread: Option<String>,
1963    pub state: Option<String>,
1964    pub objects: Option<usize>,
1965    // Required AND nullable (the `#[schemars(required)]` shorthand strips
1966    // the null variant from `Option<T>`, mis-declaring the wire contract —
1967    // HeddleCo/heddle#645 conformance): push always emits these fields,
1968    // serializing null for the no-action case.
1969    pub next_action: NullableStringSchema,
1970    pub next_action_template: NullableActionTemplateSchema,
1971    pub recommended_action: NullableStringSchema,
1972    pub recommended_action_template: NullableActionTemplateSchema,
1973}
1974
1975#[derive(Debug, Serialize, JsonSchema)]
1976pub struct GitRemoteConfiguredSchema {
1977    pub name: String,
1978    pub url: String,
1979}
1980
1981#[derive(Debug, Serialize, JsonSchema)]
1982pub struct GitUpstreamConfiguredSchema {
1983    pub branch: String,
1984    pub remote: String,
1985}
1986
1987#[derive(Debug, Serialize, JsonSchema)]
1988pub struct ParallelThreadInfoSchema {
1989    pub name: String,
1990    pub coordination_status: CoordinationStatusSchema,
1991    pub current_state: Option<String>,
1992}
1993
1994/// Operation banner — kept opaque because the underlying
1995/// [`repo::RepositoryOperationStatus`] is a workspace type and its
1996/// shape is internal. `Value` here means "any JSON object or null".
1997type OpaqueObject = Option<Value>;
1998
1999#[derive(Debug, Serialize, JsonSchema)]
2000pub struct RepositoryContextInfoSchema {
2001    pub kind: String,
2002    pub parent_repository: Option<String>,
2003    pub target_thread: Option<String>,
2004    pub parent_thread: Option<String>,
2005}
2006
2007// ---- status ---------------------------------------------------------------
2008
2009#[derive(Debug, Serialize, JsonSchema)]
2010pub struct StatusSchema {
2011    pub output_kind: Option<String>,
2012    pub repository_capability: String,
2013    pub repository_label: String,
2014    pub repository_context: Option<RepositoryContextInfoSchema>,
2015    pub storage_model: String,
2016    pub hosted_enabled: bool,
2017    pub operation: OpaqueObject,
2018    pub remote_tracking: OpaqueObject,
2019    pub git_overlay_health: GitOverlayHealthSchema,
2020    #[serde(rename = "verification")]
2021    pub trust: RepositoryVerificationStateSchema,
2022    pub thread: Option<String>,
2023    pub base_state: Option<String>,
2024    pub base_root: Option<String>,
2025    pub current_state: Option<String>,
2026    pub path: Option<String>,
2027    pub execution_path: Option<String>,
2028    #[serde(default, skip_serializing_if = "Option::is_none")]
2029    pub session_id: Option<String>,
2030    #[serde(default, skip_serializing_if = "Option::is_none")]
2031    pub heddle_session_id: Option<String>,
2032    #[serde(default, skip_serializing_if = "Option::is_none")]
2033    pub actor: Option<ActorInfoSchema>,
2034    #[serde(default, skip_serializing_if = "Option::is_none")]
2035    pub harness: Option<String>,
2036    #[serde(default, skip_serializing_if = "Option::is_none")]
2037    pub thinking_level: Option<String>,
2038    #[serde(default, skip_serializing_if = "Option::is_none")]
2039    pub usage_summary: OpaqueObject,
2040    #[serde(default, skip_serializing_if = "Option::is_none")]
2041    pub last_progress_at: Option<String>,
2042    #[serde(default, skip_serializing_if = "Option::is_none")]
2043    pub report_flush_state: Option<String>,
2044    #[serde(default, skip_serializing_if = "Option::is_none")]
2045    pub attach_reason: Option<String>,
2046    pub thread_mode: Option<ThreadModeSchema>,
2047    pub thread_state: Option<ThreadStateSchema>,
2048    pub freshness: Option<ThreadFreshnessSchema>,
2049    #[serde(default, skip_serializing_if = "Option::is_none")]
2050    pub target_thread: Option<String>,
2051    #[serde(default, skip_serializing_if = "Option::is_none")]
2052    pub parent_thread: Option<String>,
2053    pub child_threads: Vec<String>,
2054    #[serde(default, skip_serializing_if = "Option::is_none")]
2055    pub task: Option<String>,
2056    pub promotion_suggested: bool,
2057    pub impact_categories: Vec<ThreadImpactCategorySchema>,
2058    pub heavy_impact_paths: Vec<String>,
2059    pub changed_path_count: usize,
2060    pub worktree_changed_path_count: usize,
2061    pub thread_changed_path_count: usize,
2062    pub blockers: Vec<String>,
2063    pub recommended_action: NullableStringSchema,
2064    pub recommended_action_template: Option<ActionTemplateSchema>,
2065    pub recovery_commands: Vec<String>,
2066    pub recovery_action_templates: Vec<ActionTemplateSchema>,
2067    pub thread_health: String,
2068    pub coordination_status: CoordinationStatusSchema,
2069    pub is_isolated: bool,
2070    pub parallel_threads: Vec<ParallelThreadInfoSchema>,
2071    pub state: Option<StateInfoSchema>,
2072    pub git_checkpoint: Option<GitCheckpointInfoSchema>,
2073    pub changes: ChangesInfoSchema,
2074    pub git_index: Option<GitIndexInfoSchema>,
2075}
2076
2077// ---- verify ---------------------------------------------------------------
2078
2079#[derive(Debug, Serialize, JsonSchema)]
2080pub struct VerifySchema {
2081    pub output_kind: String,
2082    pub clean: bool,
2083    pub repository_label: String,
2084    pub repository_context: Option<RepositoryContextInfoSchema>,
2085    #[serde(flatten)]
2086    pub verification: RepositoryVerificationStateSchema,
2087}
2088
2089#[derive(Debug, Serialize, JsonSchema)]
2090pub struct RepositoryVerificationStateSchema {
2091    #[serde(rename = "verified")]
2092    pub verified: bool,
2093    pub status: String,
2094    pub repository_mode: String,
2095    pub heddle_initialized: bool,
2096    pub git_branch: Option<String>,
2097    pub heddle_thread: Option<String>,
2098    pub worktree_dirty: bool,
2099    pub worktree_state: String,
2100    pub import_state: String,
2101    pub mapping_state: String,
2102    pub remote_drift: String,
2103    pub active_operation: Option<String>,
2104    pub default_remote: Option<String>,
2105    pub clone_verification: String,
2106    pub machine_contract: String,
2107    #[serde(default, skip_serializing_if = "Option::is_none")]
2108    pub machine_contract_coverage: Option<MachineContractCoverageSchema>,
2109    pub workflow_status: String,
2110    pub workflow_summary: String,
2111    pub summary: String,
2112    pub recommended_action: Option<String>,
2113    pub recommended_action_template: Option<ActionTemplateSchema>,
2114    pub recovery_commands: Vec<String>,
2115    pub recovery_action_templates: Vec<ActionTemplateSchema>,
2116    pub checks: Vec<VerificationCheckSchema>,
2117}
2118
2119#[derive(Debug, Serialize, JsonSchema)]
2120pub struct MachineContractCoverageSchema {
2121    pub status: String,
2122    #[serde(rename = "verified_scope")]
2123    pub verified_scope: String,
2124    pub advanced_scope: String,
2125    pub summary: String,
2126    pub catalog_commands_total: usize,
2127    pub catalog_mutating_commands_total: usize,
2128    pub json_commands_total: usize,
2129    pub json_mutating_commands_total: usize,
2130    pub json_commands_with_schema: usize,
2131    pub json_commands_with_accepted_opaque_schema: usize,
2132    pub json_commands_without_schema: usize,
2133    #[serde(rename = "verified_scope_json_commands_total")]
2134    pub verified_scope_json_commands_total: usize,
2135    #[serde(rename = "verified_scope_json_commands_with_schema")]
2136    pub verified_scope_json_commands_with_schema: usize,
2137    #[serde(rename = "verified_scope_json_commands_with_accepted_opaque_schema")]
2138    pub verified_scope_json_commands_with_accepted_opaque_schema: usize,
2139    #[serde(rename = "verified_scope_json_commands_without_schema")]
2140    pub verified_scope_json_commands_without_schema: usize,
2141    pub advanced_scope_json_commands_total: usize,
2142    pub advanced_scope_json_commands_with_accepted_opaque_schema: usize,
2143    pub mutating_commands_total: usize,
2144    pub mutating_commands_with_schema: usize,
2145    pub mutating_commands_with_accepted_opaque_schema: usize,
2146    pub mutating_commands_without_schema: usize,
2147    #[serde(rename = "verified_scope_mutating_commands_total")]
2148    pub verified_scope_mutating_commands_total: usize,
2149    #[serde(rename = "verified_scope_mutating_commands_with_schema")]
2150    pub verified_scope_mutating_commands_with_schema: usize,
2151    #[serde(rename = "verified_scope_mutating_commands_with_accepted_opaque_schema")]
2152    pub verified_scope_mutating_commands_with_accepted_opaque_schema: usize,
2153    #[serde(rename = "verified_scope_mutating_commands_without_schema")]
2154    pub verified_scope_mutating_commands_without_schema: usize,
2155    pub advanced_scope_mutating_commands_total: usize,
2156    pub advanced_scope_mutating_commands_with_accepted_opaque_schema: usize,
2157    pub schema_verbs_total: usize,
2158    pub documented_schema_verbs_total: usize,
2159    pub undocumented_schema_verbs_total: usize,
2160    pub opaque_schema_verbs_total: usize,
2161    pub accepted_opaque_schema_verbs_total: usize,
2162    pub unaccepted_opaque_schema_verbs_total: usize,
2163    pub supports_op_id_total: usize,
2164    pub jsonl_commands_total: usize,
2165    pub missing_schema_examples: Vec<String>,
2166    pub missing_mutating_schema_examples: Vec<String>,
2167    #[serde(rename = "verified_scope_missing_schema_examples")]
2168    pub verified_scope_missing_schema_examples: Vec<String>,
2169    #[serde(rename = "verified_scope_accepted_opaque_schema_examples")]
2170    pub verified_scope_accepted_opaque_schema_examples: Vec<String>,
2171    pub advanced_scope_accepted_opaque_schema_examples: Vec<String>,
2172    pub accepted_opaque_schema_examples: Vec<String>,
2173    pub unaccepted_opaque_schema_examples: Vec<String>,
2174    pub undocumented_schema_examples: Vec<String>,
2175}
2176
2177#[derive(Debug, Serialize, JsonSchema)]
2178pub struct VerificationCheckSchema {
2179    pub name: String,
2180    pub status: String,
2181    pub clean: bool,
2182    pub summary: String,
2183    pub recommended_action: Option<String>,
2184    pub recommended_action_template: Option<ActionTemplateSchema>,
2185    pub recovery_commands: Vec<String>,
2186    pub recovery_action_templates: Vec<ActionTemplateSchema>,
2187    pub details: std::collections::BTreeMap<String, String>,
2188}
2189
2190#[derive(Debug, Serialize, JsonSchema)]
2191pub struct ActionTemplateSchema {
2192    pub action: String,
2193    pub argv_template: Vec<String>,
2194    pub required_inputs: Vec<String>,
2195    /// Whether an agent may replace placeholders in `argv_template`.
2196    ///
2197    /// When `agent_may_fill` is false, treat `action` and `argv_template` as
2198    /// display-only: do not substitute `<name>`/`<url>` placeholders. Surface
2199    /// the template to a human or discard it. Substituting and running it will
2200    /// pass literal `<name>` to Heddle and fail.
2201    pub agent_may_fill: bool,
2202}
2203
2204// ---- bridge git status ----------------------------------------------------
2205
2206#[derive(Debug, Serialize, JsonSchema)]
2207pub struct BridgeGitStatusSchema {
2208    pub output_kind: Option<String>,
2209    pub repository_capability: String,
2210    pub storage_model: String,
2211    pub mirror_path: Option<String>,
2212    pub mirror_initialized: bool,
2213    pub git_overlay_import_hint: Option<GitOverlayImportHintSchema>,
2214    pub git_overlay_health: GitOverlayHealthSchema,
2215    pub recommended_action: Option<String>,
2216    pub recommended_action_template: Option<ActionTemplateSchema>,
2217    pub recovery_commands: Vec<String>,
2218    #[serde(rename = "verification")]
2219    pub trust: RepositoryVerificationStateSchema,
2220}
2221
2222#[derive(Debug, Serialize, JsonSchema)]
2223pub struct GitOverlayImportHintSchema {
2224    pub current_branch: String,
2225    pub missing_branch_count: usize,
2226    pub missing_branches: Vec<String>,
2227    pub recommended_command: String,
2228}
2229
2230#[derive(Debug, Serialize, JsonSchema)]
2231pub struct GitOverlayHealthSchema {
2232    pub status: String,
2233    pub clean: bool,
2234    pub summary: String,
2235    pub recovery_commands: Vec<String>,
2236    pub checks: Vec<GitOverlayHealthCheckSchema>,
2237}
2238
2239#[derive(Debug, Serialize, JsonSchema)]
2240pub struct GitOverlayHealthCheckSchema {
2241    pub name: String,
2242    pub status: String,
2243    pub summary: String,
2244}
2245
2246// ---- log ------------------------------------------------------------------
2247
2248#[derive(Debug, Serialize, JsonSchema)]
2249pub struct LogSchema {
2250    pub output_kind: Option<String>,
2251    pub status: Option<String>,
2252    pub repository_capability: String,
2253    pub storage_model: String,
2254    pub states: Vec<StateEntrySchema>,
2255}
2256
2257#[derive(Debug, Serialize, JsonSchema)]
2258pub struct StateEntrySchema {
2259    pub change_id: String,
2260    pub content_hash: String,
2261    pub intent: Option<String>,
2262    pub principal: String,
2263    pub agent: Option<String>,
2264    pub confidence: Option<f32>,
2265    pub created_at: String,
2266    pub parents: Vec<String>,
2267    pub git_checkpoint: Option<String>,
2268    pub collapsed: Option<CollapsedEntrySchema>,
2269}
2270
2271#[derive(Debug, Serialize, JsonSchema)]
2272pub struct CollapsedEntrySchema {
2273    pub expandable: bool,
2274    pub source_count: usize,
2275}
2276
2277#[derive(Debug, Serialize, JsonSchema)]
2278pub struct TimelineLogSchema {
2279    pub output_kind: String,
2280    pub status: String,
2281    pub repository_capability: String,
2282    pub storage_model: String,
2283    pub thread: String,
2284    pub cursor: TimelineCursorSchema,
2285    pub branches: Vec<TimelineBranchSchema>,
2286    pub steps: Vec<TimelineStepSchema>,
2287    pub active_branch_path: Vec<String>,
2288    pub actions: TimelineActionsSchema,
2289    pub recovery: Option<TimelineRecoverySchema>,
2290}
2291
2292#[derive(Debug, Serialize, JsonSchema)]
2293pub struct TimelineCursorSchema {
2294    pub branch_id: Option<String>,
2295    pub step_id: Option<String>,
2296    pub state: Option<String>,
2297    pub state_full: Option<String>,
2298}
2299
2300#[derive(Debug, Serialize, JsonSchema)]
2301pub struct TimelineBranchSchema {
2302    pub branch_id: String,
2303    pub parent_branch_id: Option<String>,
2304    pub forked_from_step_id: Option<String>,
2305    pub forked_from_state: Option<String>,
2306    pub reason: Option<String>,
2307    pub created_at_ms: Option<i64>,
2308    pub step_ids: Vec<String>,
2309    pub is_active: bool,
2310    pub is_on_active_path: bool,
2311}
2312
2313#[derive(Debug, Serialize, JsonSchema)]
2314pub struct TimelineStepSchema {
2315    pub step_id: String,
2316    pub branch_id: String,
2317    pub parent_step_id: Option<String>,
2318    pub native: Option<TimelineNativeSchema>,
2319    pub tool_name: Option<String>,
2320    pub status: Option<String>,
2321    pub changed: Option<bool>,
2322    pub touched_paths: Vec<String>,
2323    pub labels: Vec<String>,
2324    pub before_state: Option<String>,
2325    pub after_state: Option<String>,
2326    pub capture_state: Option<String>,
2327    pub cursor_state: Option<String>,
2328    pub cursor_state_full: Option<String>,
2329    pub payload_summary: Option<String>,
2330    pub payload_hash: Option<String>,
2331    pub capture_oplog_batch_id: Option<u64>,
2332    pub started_at_ms: Option<i64>,
2333    pub finished_at_ms: Option<i64>,
2334    pub operation_ids: Vec<String>,
2335    pub is_current: bool,
2336    pub is_on_active_branch_path: bool,
2337    pub can_seek: bool,
2338    pub can_fork: bool,
2339    pub can_reset: bool,
2340    pub can_materialize: bool,
2341    pub has_boundary_warning: bool,
2342}
2343
2344#[derive(Debug, Serialize, JsonSchema)]
2345pub struct TimelineNativeSchema {
2346    pub harness: String,
2347    pub session_id: Option<String>,
2348    pub message_id: Option<String>,
2349    pub tool_call_id: String,
2350}
2351
2352#[derive(Debug, Serialize, JsonSchema)]
2353pub struct TimelineActionsSchema {
2354    pub can_undo: bool,
2355    pub can_redo: bool,
2356}
2357
2358#[derive(Debug, Serialize, JsonSchema)]
2359pub struct TimelineRecoverySchema {
2360    pub status: String,
2361    pub branch_id: String,
2362    pub from_step_id: Option<String>,
2363    pub to_step_id: Option<String>,
2364    pub from_state: String,
2365    pub to_state: String,
2366    pub reason: String,
2367    pub moved_at_ms: i64,
2368    pub checkout_state: Option<String>,
2369}
2370
2371#[derive(Debug, Serialize, JsonSchema)]
2372pub struct TimelineActionSchema {
2373    pub output_kind: String,
2374    pub status: String,
2375    pub action: String,
2376    pub thread: String,
2377    pub branch_id: Option<String>,
2378    pub parent_branch_id: Option<String>,
2379    pub from_step_id: Option<String>,
2380    pub cursor_branch_id: Option<String>,
2381    pub cursor_step_id: Option<String>,
2382    pub operation_id: Option<String>,
2383    pub recovered_operation_id: Option<String>,
2384    pub materialized: Option<bool>,
2385    pub materialization_status: Option<String>,
2386    pub recovery_status: Option<String>,
2387    pub blocker_count: usize,
2388    pub branch_count: usize,
2389    pub step_count: usize,
2390}
2391
2392#[derive(Debug, Serialize, JsonSchema)]
2393pub struct ExpandSchema {
2394    pub output_kind: String,
2395    pub status: String,
2396    pub requested: String,
2397    pub collapsed: ExpandedCollapseSchema,
2398    pub captures: Vec<ExpandedCaptureSchema>,
2399}
2400
2401#[derive(Debug, Serialize, JsonSchema)]
2402pub struct ExpandedCollapseSchema {
2403    pub change_id: String,
2404    pub change_id_full: String,
2405    pub git_commit: Option<String>,
2406    pub thread: Option<String>,
2407    pub source_count: usize,
2408}
2409
2410#[derive(Debug, Serialize, JsonSchema)]
2411pub struct ExpandedCaptureSchema {
2412    pub change_id: String,
2413    pub change_id_full: String,
2414    pub content_hash: String,
2415    pub intent: Option<String>,
2416    pub principal: String,
2417    pub agent: Option<String>,
2418    pub confidence: Option<f32>,
2419    pub created_at: String,
2420    pub parents: Vec<String>,
2421}
2422
2423#[derive(Debug, Serialize, JsonSchema)]
2424pub struct LogReflogSchema {
2425    pub output_kind: Option<String>,
2426    pub status: Option<String>,
2427    pub repository_capability: String,
2428    pub storage_model: String,
2429    pub entries: Vec<ReflogEntrySchema>,
2430}
2431
2432#[derive(Debug, Serialize, JsonSchema)]
2433pub struct ReflogEntrySchema {
2434    pub source: String,
2435    pub reference: String,
2436    pub old_oid: String,
2437    pub new_oid: String,
2438    pub actor: String,
2439    pub timestamp: Option<String>,
2440    pub message: String,
2441}
2442
2443// ---- show -----------------------------------------------------------------
2444
2445#[derive(Debug, Serialize, JsonSchema)]
2446pub struct ShowSchema {
2447    pub output_kind: String,
2448    pub repository_capability: String,
2449    pub storage_model: String,
2450    pub change_id: String,
2451    pub change_id_full: String,
2452    pub content_hash: String,
2453    pub tree: String,
2454    pub parents: Vec<String>,
2455    pub intent: Option<String>,
2456    pub confidence: Option<f32>,
2457    pub principal: ShowPrincipalSchema,
2458    pub agent: Option<ShowAgentSchema>,
2459    pub created_at: String,
2460    pub status: String,
2461    pub verification: OpaqueObject,
2462    pub git_checkpoint: Option<String>,
2463}
2464
2465#[derive(Debug, Serialize, JsonSchema)]
2466pub struct ShowPrincipalSchema {
2467    pub name: String,
2468    pub email: String,
2469}
2470
2471#[derive(Debug, Serialize, JsonSchema)]
2472pub struct ShowAgentSchema {
2473    pub provider: Option<String>,
2474    pub model: Option<String>,
2475    #[serde(default, skip_serializing_if = "Option::is_none")]
2476    pub session_id: Option<String>,
2477    #[serde(default, skip_serializing_if = "Option::is_none")]
2478    pub policy_id: Option<String>,
2479}
2480
2481// ---- thread list ----------------------------------------------------------
2482
2483#[derive(Debug, Serialize, JsonSchema)]
2484pub struct ThreadListSchema {
2485    pub output_kind: Option<String>,
2486    pub repository_capability: String,
2487    pub repository_label: String,
2488    pub repository_context: Option<RepositoryContextInfoSchema>,
2489    pub storage_model: String,
2490    pub hosted_enabled: bool,
2491    pub threads: Vec<ThreadSummarySchema>,
2492    pub available_git_refs: Vec<AvailableGitRefSchema>,
2493    pub current: Option<String>,
2494    #[serde(rename = "verification")]
2495    pub trust: RepositoryVerificationStateSchema,
2496    pub recommended_action: Option<String>,
2497    pub recommended_action_template: Option<ActionTemplateSchema>,
2498    pub recovery_commands: Vec<String>,
2499    pub recovery_action_templates: Vec<ActionTemplateSchema>,
2500}
2501
2502#[derive(Debug, Serialize, JsonSchema)]
2503pub struct AvailableGitRefSchema {
2504    pub name: String,
2505    pub git_commit: String,
2506    pub recommended_action: Option<String>,
2507    pub recommended_action_template: Option<ActionTemplateSchema>,
2508}
2509
2510// ---- review ---------------------------------------------------------------
2511
2512#[derive(Debug, Serialize, JsonSchema)]
2513pub struct ReviewShowSchema {
2514    pub output_kind: String,
2515    pub change_id: String,
2516    pub headline: String,
2517    pub agent_narrative: Option<String>,
2518    pub files_changed: usize,
2519    pub in_budget_signals: Vec<Value>,
2520    pub all_signals: Vec<Value>,
2521    pub discussions: Vec<Value>,
2522    pub signing_kinds: Vec<String>,
2523    pub signatures: Vec<Value>,
2524}
2525
2526#[derive(Debug, Serialize, JsonSchema)]
2527pub struct ReviewSignSchema {
2528    pub output_kind: String,
2529    pub signature_id: String,
2530    pub change_id: String,
2531}
2532
2533/// `heddle review next --output json` emits a stable envelope keyed by
2534/// `output_kind: "review_next"`. When the scan window holds a pending
2535/// review, the pending state's view is flattened alongside `output_kind`
2536/// (`change_id`, `headline`, `existing_signatures`) and the same view is
2537/// echoed under `next`. When no pending review is found, only
2538/// `output_kind` and `next: null` are emitted — there is no top-level
2539/// `null`. Mirrors the envelope built in `review::run_next`.
2540#[derive(Debug, Serialize, JsonSchema)]
2541pub struct ReviewNextSchema {
2542    pub output_kind: String,
2543    pub change_id: Option<String>,
2544    pub headline: Option<String>,
2545    pub existing_signatures: Option<u32>,
2546    pub next: RequiredNullableNextState,
2547}
2548
2549/// `next` is ALWAYS present in the runtime envelope — either the pending
2550/// review state or an explicit JSON `null`. Modeling it as
2551/// `Option<ReviewNextStateSchema>` directly would let schemars drop the
2552/// field from the schema's `required` set, advertising a shape the command
2553/// never emits. This wrapper keeps the value nullable (its schema delegates
2554/// to `Option`'s nullable form) while reporting `_schemars_private_is_option
2555/// == false`, so the derive marks `next` required (heddle#272 Codex r7).
2556#[derive(Debug, Serialize)]
2557#[serde(transparent)]
2558pub struct RequiredNullableNextState(pub Option<ReviewNextStateSchema>);
2559
2560impl JsonSchema for RequiredNullableNextState {
2561    fn schema_name() -> std::borrow::Cow<'static, str> {
2562        std::borrow::Cow::Borrowed("RequiredNullableNextState")
2563    }
2564
2565    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
2566        <Option<ReviewNextStateSchema> as JsonSchema>::json_schema(generator)
2567    }
2568}
2569
2570/// The pending review state echoed under `review next`'s `next` field.
2571#[derive(Debug, Serialize, JsonSchema)]
2572pub struct ReviewNextStateSchema {
2573    pub change_id: String,
2574    pub headline: String,
2575    pub existing_signatures: u32,
2576}
2577
2578#[derive(Debug, Serialize, JsonSchema)]
2579pub struct ReviewHealthSchema {
2580    pub output_kind: String,
2581    pub entries: Vec<ReviewHealthEntrySchema>,
2582    pub window_states: usize,
2583}
2584
2585#[derive(Debug, Serialize, JsonSchema)]
2586pub struct ReviewHealthEntrySchema {
2587    pub module_id: String,
2588    pub fire_rate: f64,
2589    pub warn: bool,
2590}
2591
2592// ---- transaction commit ---------------------------------------------------
2593
2594#[derive(Debug, Serialize, JsonSchema)]
2595pub struct TransactionCommitSchema {
2596    pub change_id: String,
2597    pub op_count: u32,
2598}
2599
2600// ---- command/schema introspection ----------------------------------------
2601
2602#[derive(Debug, Serialize, JsonSchema)]
2603pub struct SchemasListSchema {
2604    pub output_kind: Option<String>,
2605    pub status: Option<String>,
2606    pub schema_verbs: Vec<String>,
2607    pub documented_schema_verbs: Vec<String>,
2608}
2609
2610#[derive(Debug, Serialize, JsonSchema)]
2611pub struct DoctorDocsSchema {
2612    pub output_kind: String,
2613    pub status: String,
2614    #[serde(rename = "verified")]
2615    pub verified: bool,
2616    pub recommended_action: Option<String>,
2617    pub files_scanned: usize,
2618    pub issues: Vec<DoctorDocsIssueSchema>,
2619}
2620
2621#[derive(Debug, Serialize, JsonSchema)]
2622pub struct DoctorDocsIssueSchema {
2623    pub file: String,
2624    pub line: usize,
2625    pub invocation: String,
2626    pub kind: String,
2627    pub detail: String,
2628    pub suggestion: Option<String>,
2629}
2630
2631#[derive(Debug, Serialize, JsonSchema)]
2632pub struct DoctorSchemasSchema {
2633    pub output_kind: String,
2634    pub status: String,
2635    #[serde(rename = "verified")]
2636    pub verified: bool,
2637    pub summary: String,
2638    pub recommended_action: Option<String>,
2639    pub recovery_commands: Vec<String>,
2640    pub registered_verbs: Vec<String>,
2641    pub documented_verbs: Vec<String>,
2642    pub undocumented_verbs: Vec<String>,
2643    pub unmatched_verbs: Vec<String>,
2644    pub passing_verbs: Vec<String>,
2645    pub issues: Vec<DoctorSchemaIssueSchema>,
2646    pub command_contract_schema_coverage: CommandContractSchemaCoverageSchema,
2647    pub doc_path: String,
2648}
2649
2650#[derive(Debug, Serialize, JsonSchema)]
2651pub struct DoctorSchemaIssueSchema {
2652    pub verb: String,
2653    pub line: usize,
2654    pub unknown_key: String,
2655    pub detail: String,
2656}
2657
2658#[derive(Debug, Serialize, JsonSchema)]
2659pub struct CommandContractSchemaCoverageSchema {
2660    pub status: String,
2661    #[serde(rename = "verified_scope")]
2662    pub verified_scope: String,
2663    pub advanced_scope: String,
2664    pub summary: String,
2665    pub catalog_commands_total: usize,
2666    pub catalog_mutating_commands_total: usize,
2667    pub json_commands_total: usize,
2668    pub json_mutating_commands_total: usize,
2669    pub json_commands_with_schema: usize,
2670    pub json_commands_with_accepted_opaque_schema: usize,
2671    pub json_commands_without_schema: usize,
2672    #[serde(rename = "verified_scope_json_commands_total")]
2673    pub verified_scope_json_commands_total: usize,
2674    #[serde(rename = "verified_scope_json_commands_with_schema")]
2675    pub verified_scope_json_commands_with_schema: usize,
2676    #[serde(rename = "verified_scope_json_commands_with_accepted_opaque_schema")]
2677    pub verified_scope_json_commands_with_accepted_opaque_schema: usize,
2678    #[serde(rename = "verified_scope_json_commands_without_schema")]
2679    pub verified_scope_json_commands_without_schema: usize,
2680    pub advanced_scope_json_commands_total: usize,
2681    pub advanced_scope_json_commands_with_accepted_opaque_schema: usize,
2682    pub mutating_commands_total: usize,
2683    pub mutating_commands_with_schema: usize,
2684    pub mutating_commands_with_accepted_opaque_schema: usize,
2685    pub mutating_commands_without_schema: usize,
2686    #[serde(rename = "verified_scope_mutating_commands_total")]
2687    pub verified_scope_mutating_commands_total: usize,
2688    #[serde(rename = "verified_scope_mutating_commands_with_schema")]
2689    pub verified_scope_mutating_commands_with_schema: usize,
2690    #[serde(rename = "verified_scope_mutating_commands_with_accepted_opaque_schema")]
2691    pub verified_scope_mutating_commands_with_accepted_opaque_schema: usize,
2692    #[serde(rename = "verified_scope_mutating_commands_without_schema")]
2693    pub verified_scope_mutating_commands_without_schema: usize,
2694    pub advanced_scope_mutating_commands_total: usize,
2695    pub advanced_scope_mutating_commands_with_accepted_opaque_schema: usize,
2696    pub undocumented_schema_verbs_total: usize,
2697    pub opaque_schema_verbs_total: usize,
2698    pub accepted_opaque_schema_verbs_total: usize,
2699    pub unaccepted_opaque_schema_verbs_total: usize,
2700    pub missing_schema_examples: Vec<String>,
2701    pub missing_mutating_schema_examples: Vec<String>,
2702    #[serde(rename = "verified_scope_missing_schema_examples")]
2703    pub verified_scope_missing_schema_examples: Vec<String>,
2704    #[serde(rename = "verified_scope_accepted_opaque_schema_examples")]
2705    pub verified_scope_accepted_opaque_schema_examples: Vec<String>,
2706    pub advanced_scope_accepted_opaque_schema_examples: Vec<String>,
2707    pub accepted_opaque_schema_examples: Vec<String>,
2708    pub unaccepted_opaque_schema_examples: Vec<String>,
2709    pub undocumented_schema_examples: Vec<String>,
2710}
2711
2712#[derive(Debug, Serialize, JsonSchema)]
2713pub struct GitOverlayGuideSchema {
2714    pub topic: String,
2715    pub summary: String,
2716    pub steps: Vec<String>,
2717}
2718
2719#[derive(Debug, Serialize, JsonSchema)]
2720pub struct WatchLineSchema {
2721    pub ts: String,
2722    pub thread: Option<String>,
2723    pub kind: String,
2724    pub change_id: Option<String>,
2725    pub intent: Option<String>,
2726    pub confidence: Option<f32>,
2727    pub actor: Option<ActorInfoSchema>,
2728    pub id: u64,
2729}
2730
2731#[derive(Debug, Serialize, JsonSchema)]
2732pub struct TrySchema {
2733    pub status: String,
2734    pub action: String,
2735    pub message: String,
2736    pub thread: String,
2737    pub thread_dropped: bool,
2738    pub cleanup_error: Option<String>,
2739    pub exit_code: Option<i32>,
2740    pub duration_ms: u128,
2741    pub captured_state: Option<String>,
2742    pub merge_state: Option<String>,
2743    pub next_action: Option<String>,
2744    pub next_action_template: Option<ActionTemplateSchema>,
2745    pub recommended_action: Option<String>,
2746    pub recommended_action_template: Option<ActionTemplateSchema>,
2747    pub recovery_commands: Vec<String>,
2748    pub recovery_action_templates: Vec<ActionTemplateSchema>,
2749}
2750
2751// ---- bridge ops -----------------------------------------------------------
2752
2753#[derive(Debug, Serialize, JsonSchema)]
2754pub struct BridgeInitSchema {
2755    pub initialized: bool,
2756    pub path: String,
2757}
2758
2759#[derive(Debug, Serialize, JsonSchema)]
2760pub struct ExportedRefSchema {
2761    pub name: String,
2762    pub tip: String,
2763}
2764
2765#[derive(Debug, Serialize, JsonSchema)]
2766pub struct BridgeExportSchema {
2767    pub states_exported: u64,
2768    pub commits_total: u64,
2769    pub threads_synced: u64,
2770    pub markers_synced: u64,
2771    pub branches: Vec<ExportedRefSchema>,
2772    pub tags: Vec<ExportedRefSchema>,
2773    pub destination: String,
2774}
2775
2776#[derive(Debug, Serialize, JsonSchema)]
2777pub struct BridgeImportSchema {
2778    pub output_kind: Option<String>,
2779    pub status: String,
2780    pub action: Option<String>,
2781    pub summary: String,
2782    pub commits_imported: u64,
2783    pub states_created: u64,
2784    pub branches_synced: u64,
2785    pub tags_synced: u64,
2786    pub skipped_non_commit_refs: u64,
2787    pub lossy_entries: Vec<LossyImportEntrySchema>,
2788    pub already_in_sync: bool,
2789    pub recommended_action: Option<String>,
2790    pub recommended_action_template: Option<ActionTemplateSchema>,
2791    pub recovery_commands: Vec<String>,
2792}
2793
2794#[derive(Debug, Serialize, JsonSchema)]
2795pub struct LossyImportEntrySchema {
2796    pub path: String,
2797    pub action: String,
2798    pub reason: String,
2799    pub git_object: Option<String>,
2800}
2801
2802#[derive(Debug, Serialize, JsonSchema)]
2803pub struct BridgeSyncSchema {
2804    pub output_kind: Option<String>,
2805    pub status: String,
2806    pub action: Option<String>,
2807    pub summary: String,
2808    pub states_exported: u64,
2809    pub commits_exported_total: u64,
2810    pub commits_imported: u64,
2811    pub threads_synced: u64,
2812    pub markers_synced: u64,
2813    pub recommended_action: Option<String>,
2814    pub recommended_action_template: Option<ActionTemplateSchema>,
2815    pub recovery_commands: Vec<String>,
2816}
2817
2818#[derive(Debug, Serialize, JsonSchema)]
2819pub struct BridgeGitReconcileSchema {
2820    pub output_kind: Option<String>,
2821    pub status: String,
2822    pub action: Option<String>,
2823    pub prefer: Option<String>,
2824    pub ref_name: String,
2825    pub preview: bool,
2826    pub summary: String,
2827    pub recommended_action: Option<String>,
2828    pub recommended_action_template: Option<ActionTemplateSchema>,
2829    pub recovery_commands: Vec<String>,
2830}
2831
2832#[derive(Debug, Serialize, JsonSchema)]
2833pub struct BridgePushSchema {
2834    pub output_kind: Option<String>,
2835    pub action: Option<String>,
2836    pub status: Option<String>,
2837    pub success: Option<bool>,
2838    pub pushed: bool,
2839    pub changed: Option<bool>,
2840    pub transport: Option<String>,
2841    pub remote: String,
2842}
2843
2844#[derive(Debug, Serialize, JsonSchema)]
2845pub struct BridgePullSchema {
2846    pub output_kind: Option<String>,
2847    pub action: Option<String>,
2848    pub status: Option<String>,
2849    pub success: Option<bool>,
2850    pub pulled: bool,
2851    pub changed: Option<bool>,
2852    pub transport: Option<String>,
2853    pub remote: String,
2854}
2855
2856// ---- stash / revert -------------------------------------------------------
2857
2858#[derive(Debug, Serialize, JsonSchema)]
2859pub struct StashMutationSchema {
2860    pub message: String,
2861    pub stash_index: Option<usize>,
2862}
2863
2864#[derive(Debug, Serialize, JsonSchema)]
2865pub struct StashListSchema {
2866    pub output_kind: String,
2867    pub stashes: Vec<StashListEntrySchema>,
2868}
2869
2870#[derive(Debug, Serialize, JsonSchema)]
2871pub struct StashListEntrySchema {
2872    pub index: usize,
2873    pub message: Option<String>,
2874    pub created_at: String,
2875}
2876
2877#[derive(Debug, Serialize, JsonSchema)]
2878pub struct StashShowSchema {
2879    pub output_kind: String,
2880    pub modified: Vec<String>,
2881    pub added: Vec<String>,
2882    pub deleted: Vec<String>,
2883}
2884
2885#[derive(Debug, Serialize, JsonSchema)]
2886pub struct RevertSchema {
2887    pub output_kind: String,
2888    pub change_id: Option<String>,
2889    pub reverted_state: String,
2890    pub files_affected: Vec<String>,
2891    pub message: String,
2892}
2893
2894// ---- diagnose -------------------------------------------------------------
2895
2896#[derive(Debug, Serialize, JsonSchema)]
2897pub struct DiagnoseSchema {
2898    pub output_kind: Option<String>,
2899    pub repository: String,
2900    pub repository_capability: String,
2901    pub storage_model: String,
2902    pub hosted_enabled: bool,
2903    pub git_overlay_import_hint: Option<GitOverlayImportHintSchema>,
2904    pub git_overlay_health: GitOverlayHealthSchema,
2905    #[serde(rename = "verification")]
2906    pub trust: RepositoryVerificationStateSchema,
2907    pub operation: OpaqueObject,
2908    pub remote_tracking: OpaqueObject,
2909    pub thread: Option<Value>,
2910    pub state: Option<Value>,
2911    pub changes: Value,
2912    pub workspace: Value,
2913    pub health: Value,
2914    pub recommended_action: Option<String>,
2915    pub recommended_action_template: Option<ActionTemplateSchema>,
2916    pub recovery_commands: Vec<String>,
2917    pub profile: Option<Value>,
2918}
2919
2920// ---- error envelope (cross-cutting) ---------------------------------------
2921//
2922// Emitted to **stderr** (not stdout) by any state-changing verb that fails
2923// when JSON output is selected. The 21 verb schemas above describe the
2924// stdout success shape; this schema describes the stderr failure shape so
2925// scripts and agents can parse failures without scraping freeform text.
2926//
2927// Field contract:
2928//
2929// - `code` — stable machine code; currently mirrors `kind`.
2930// - `error` — human-readable message (the anyhow chain rendered via `{:#}`).
2931//   Always present, never empty.
2932// - `exit_code` — process exit code emitted for the failure.
2933// - `hint` — single-line next-step recommendation. Empty string when no
2934//   actionable hint applies. JSON-mode runtime errors use a non-empty
2935//   fallback hint when no specific recovery class applies.
2936// - `kind` — stable predicate name keying the hint family. JSON-mode
2937//   runtime errors use `runtime_error` when the error didn't match a
2938//   known class. Current values include:
2939//   `repository_not_found`, `repository_exists`, `state_not_found`,
2940//   `thread_not_found`, `out_of_space`, `permission_denied`,
2941//   `read_only_filesystem`, and `runtime_error`. New kinds may be added
2942//   (additive); existing ones are stable.
2943// - `unsafe_condition`, `would_change`, `preserved` — typed safety facts.
2944// - `primary_command`, `primary_command_template` — the main recovery
2945//   action as a human-readable command string plus a fillable template
2946//   (always present for a valid action). The `_argv` sidecar was dropped
2947//   (HeddleCo/heddle#254): it was null for every placeholder action and
2948//   silently read as "no action" to agents — use the template instead.
2949// - `recovery_commands`, `recovery_action_templates` — all recovery
2950//   actions the runtime can represent, as command strings or fillable
2951//   templates.
2952
2953#[derive(Debug, Serialize, JsonSchema)]
2954pub struct ErrorEnvelopeSchema {
2955    pub error: String,
2956    pub exit_code: u8,
2957    pub hint: String,
2958    pub kind: String,
2959    pub op_id: Option<String>,
2960    pub idempotency_status: Option<String>,
2961    pub replayed: Option<bool>,
2962    pub unsafe_condition: String,
2963    pub would_change: String,
2964    pub preserved: String,
2965    pub primary_command: String,
2966    pub primary_command_template: NullableActionTemplateSchema,
2967    pub recovery_commands: Vec<String>,
2968    pub recovery_action_templates: Vec<ActionTemplateSchema>,
2969}
2970
2971#[derive(Debug, Serialize, JsonSchema)]
2972#[serde(untagged)]
2973#[allow(dead_code)]
2974pub enum NullableActionTemplateSchema {
2975    Template(ActionTemplateSchema),
2976    Null(()),
2977}
2978
2979#[derive(Debug, Serialize, JsonSchema)]
2980#[serde(untagged)]
2981#[allow(dead_code)]
2982pub enum NullableStringSchema {
2983    Value(String),
2984    Null(()),
2985}
2986
2987// ---------------------------------------------------------------------------
2988// Tests
2989// ---------------------------------------------------------------------------
2990
2991#[cfg(test)]
2992mod tests {
2993    use super::*;
2994
2995    fn required_fields(schema: &Value) -> Vec<&str> {
2996        schema
2997            .get("required")
2998            .and_then(|value| value.as_array())
2999            .expect("schema has required fields")
3000            .iter()
3001            .map(|value| value.as_str().expect("required field is a string"))
3002            .collect()
3003    }
3004
3005    fn property_schema<'a>(schema: &'a Value, property: &str) -> &'a Value {
3006        schema
3007            .get("properties")
3008            .and_then(|p| p.as_object())
3009            .and_then(|properties| properties.get(property))
3010            .unwrap_or_else(|| panic!("schema has `{property}` property"))
3011    }
3012
3013    fn resolve_schema_ref<'a>(root: &'a Value, reference: &str) -> &'a Value {
3014        reference
3015            .strip_prefix("#/$defs/")
3016            .or_else(|| reference.strip_prefix("#/definitions/"))
3017            .and_then(|name| {
3018                root.get("$defs")
3019                    .or_else(|| root.get("definitions"))
3020                    .and_then(|defs| defs.get(name))
3021            })
3022            .unwrap_or_else(|| panic!("schema reference `{reference}` resolves"))
3023    }
3024
3025    fn schema_declares_property(root: &Value, schema: &Value, property: &str) -> bool {
3026        if let Some(reference) = schema.get("$ref").and_then(|value| value.as_str()) {
3027            return schema_declares_property(root, resolve_schema_ref(root, reference), property);
3028        }
3029
3030        if schema
3031            .get("properties")
3032            .and_then(|properties| properties.get(property))
3033            .is_some()
3034        {
3035            return true;
3036        }
3037
3038        for combinator in ["anyOf", "oneOf"] {
3039            if let Some(schemas) = schema.get(combinator).and_then(|value| value.as_array()) {
3040                return !schemas.is_empty()
3041                    && schemas
3042                        .iter()
3043                        .all(|schema| schema_declares_property(root, schema, property));
3044            }
3045        }
3046
3047        schema
3048            .get("allOf")
3049            .and_then(|value| value.as_array())
3050            .is_some_and(|schemas| {
3051                schemas
3052                    .iter()
3053                    .any(|schema| schema_declares_property(root, schema, property))
3054            })
3055    }
3056
3057    fn schema_allows_null(root: &Value, schema: &Value) -> bool {
3058        if let Some(reference) = schema.get("$ref").and_then(|value| value.as_str()) {
3059            return schema_allows_null(root, resolve_schema_ref(root, reference));
3060        }
3061
3062        if schema.get("type") == Some(&Value::String("null".to_string())) {
3063            return true;
3064        }
3065        if schema
3066            .get("type")
3067            .and_then(|value| value.as_array())
3068            .is_some_and(|types| types.contains(&Value::String("null".to_string())))
3069        {
3070            return true;
3071        }
3072
3073        ["anyOf", "oneOf", "allOf"].iter().any(|combinator| {
3074            schema
3075                .get(*combinator)
3076                .and_then(|value| value.as_array())
3077                .is_some_and(|schemas| {
3078                    schemas
3079                        .iter()
3080                        .any(|schema| schema_allows_null(root, schema))
3081                })
3082        })
3083    }
3084
3085    fn collect_string_enums<'a>(root: &'a Value, schema: &'a Value, values: &mut Vec<&'a str>) {
3086        if let Some(reference) = schema.get("$ref").and_then(|value| value.as_str()) {
3087            collect_string_enums(root, resolve_schema_ref(root, reference), values);
3088        }
3089
3090        if let Some(enum_values) = schema.get("enum").and_then(|value| value.as_array()) {
3091            for value in enum_values {
3092                if let Some(value) = value.as_str() {
3093                    values.push(value);
3094                }
3095            }
3096        }
3097
3098        for combinator in ["anyOf", "oneOf", "allOf"] {
3099            if let Some(schemas) = schema.get(combinator).and_then(|value| value.as_array()) {
3100                for schema in schemas {
3101                    collect_string_enums(root, schema, values);
3102                }
3103            }
3104        }
3105    }
3106
3107    fn collect_discriminator_values<'a>(
3108        root: &'a Value,
3109        schema: &'a Value,
3110        field: &str,
3111        values: &mut Vec<&'a str>,
3112    ) {
3113        if let Some(reference) = schema.get("$ref").and_then(|value| value.as_str()) {
3114            collect_discriminator_values(root, resolve_schema_ref(root, reference), field, values);
3115            return;
3116        }
3117
3118        if let Some(property) = schema
3119            .get("properties")
3120            .and_then(|properties| properties.get(field))
3121        {
3122            collect_string_enums(root, property, values);
3123        }
3124
3125        for combinator in ["anyOf", "oneOf", "allOf"] {
3126            if let Some(schemas) = schema.get(combinator).and_then(|value| value.as_array()) {
3127                for schema in schemas {
3128                    collect_discriminator_values(root, schema, field, values);
3129                }
3130            }
3131        }
3132    }
3133
3134    fn schema_requires_discriminator(root: &Value, schema: &Value, field: &str) -> bool {
3135        if let Some(reference) = schema.get("$ref").and_then(|value| value.as_str()) {
3136            return schema_requires_discriminator(root, resolve_schema_ref(root, reference), field);
3137        }
3138
3139        if schema
3140            .get("properties")
3141            .and_then(|properties| properties.get(field))
3142            .is_some()
3143        {
3144            return schema
3145                .get("required")
3146                .and_then(|value| value.as_array())
3147                .is_some_and(|required| {
3148                    required
3149                        .iter()
3150                        .any(|required_field| required_field.as_str() == Some(field))
3151                });
3152        }
3153
3154        for combinator in ["anyOf", "oneOf"] {
3155            if let Some(schemas) = schema.get(combinator).and_then(|value| value.as_array()) {
3156                return !schemas.is_empty()
3157                    && schemas
3158                        .iter()
3159                        .all(|schema| schema_requires_discriminator(root, schema, field));
3160            }
3161        }
3162
3163        schema
3164            .get("allOf")
3165            .and_then(|value| value.as_array())
3166            .is_some_and(|schemas| {
3167                schemas
3168                    .iter()
3169                    .any(|schema| schema_requires_discriminator(root, schema, field))
3170            })
3171    }
3172
3173    /// Every schema verb advertised by the command contract table must
3174    /// produce a schema.
3175    /// Otherwise `heddle doctor schemas` would silently miss drift on
3176    /// that verb.
3177    #[test]
3178    fn registry_covers_every_listed_verb() {
3179        for verb in schema_verbs() {
3180            assert!(
3181                schema_for_verb(verb).is_some(),
3182                "verb '{verb}' is advertised by command contracts but schema_for_verb returned None"
3183            );
3184        }
3185    }
3186
3187    #[test]
3188    fn documented_registry_is_subset_of_runtime_registry() {
3189        let all = schema_verbs();
3190        for verb in documented_schema_verbs() {
3191            assert!(
3192                all.contains(verb),
3193                "documented schema verb '{verb}' is not advertised as a runtime schema"
3194            );
3195        }
3196    }
3197
3198    /// Every documented (non-opaque) verb whose catalog advertises an
3199    /// `output_kind` discriminator must declare the `output_kind`
3200    /// property on its *registered schema struct*, not merely rely on the
3201    /// runtime injection in [`schema_for_verb`].
3202    ///
3203    /// heddle#272 r6 (Codex P2): `schema_for_verb` injects the
3204    /// discriminator from the catalog after deriving the struct schema,
3205    /// so `heddle schemas <verb>` already surfaces `output_kind`. That
3206    /// injection masks the fact that the Rust mirror struct (e.g.
3207    /// `CleanSchema`, `DiffSchema`) never declares the field. The mirror
3208    /// is the source of truth a reader greps; it must be honest about the
3209    /// discriminator the runtime always emits. This check reads the
3210    /// *pre-injection* struct schema so a missing field fails CI rather
3211    /// than being papered over by the catalog.
3212    #[test]
3213    fn documented_swept_schema_structs_declare_output_kind() {
3214        let mut missing = Vec::new();
3215        for verb in documented_schema_verbs() {
3216            // Opaque verbs expose a generic object schema; their
3217            // discriminator is genuinely catalog-only (there is no
3218            // Serialize mirror struct to declare it on).
3219            if opaque_schema_verbs().contains(verb) {
3220                continue;
3221            }
3222            let Some(discriminator) =
3223                command_catalog::command_json_discriminator_for_schema_verb(verb)
3224            else {
3225                continue;
3226            };
3227            if discriminator.field != "output_kind" {
3228                continue;
3229            }
3230            let bare = schema_for_registered_verb(verb)
3231                .unwrap_or_else(|| panic!("documented verb `{verb}` has no registered schema"));
3232            let declares = schema_declares_property(&bare, &bare, "output_kind");
3233            if !declares {
3234                missing.push(format!(
3235                    "{verb}: catalog advertises output_kind=`{}` but the schema struct declares no `output_kind` property",
3236                    discriminator.value
3237                ));
3238            }
3239        }
3240        assert!(
3241            missing.is_empty(),
3242            "Documented swept schema structs missing the `output_kind` property. Add \
3243             `pub output_kind: String` to each mirror struct so it matches the runtime \
3244             emission (the catalog injection masks this at the `heddle schemas` layer, \
3245             but the struct must be honest):\n  - {}",
3246            missing.join("\n  - ")
3247        );
3248    }
3249
3250    #[test]
3251    fn implementation_registry_matches_command_contract_registry() {
3252        let advertised = schema_verbs();
3253        let mut implemented = schema_implementation_verbs();
3254        for verb in opaque_schema_verbs() {
3255            if !implemented.contains(verb) {
3256                implemented.push(*verb);
3257            }
3258            assert!(
3259                advertised.contains(verb),
3260                "opaque schema verb '{verb}' must also be advertised by active command contracts"
3261            );
3262        }
3263        for verb in advertised {
3264            assert!(
3265                implemented.contains(verb),
3266                "verb '{verb}' is advertised by command contracts but the schema implementation registry does not handle it"
3267            );
3268        }
3269        for verb in &implemented {
3270            if cfg!(all(feature = "git-overlay", feature = "semantic")) {
3271                assert!(
3272                    advertised.contains(verb),
3273                    "verb '{verb}' has a schema implementation but is not advertised by active command contracts"
3274                );
3275            } else if !advertised.contains(verb) {
3276                assert!(
3277                    schema_for_verb(verb).is_none(),
3278                    "inactive schema implementation '{verb}' must not be publicly resolvable"
3279                );
3280            }
3281        }
3282    }
3283
3284    #[test]
3285    fn command_catalog_schema_verbs_match_schema_list_except_error_envelope() {
3286        let catalog = command_catalog::build_command_catalog();
3287        let mut catalog_verbs = catalog
3288            .commands
3289            .iter()
3290            .flat_map(|command| command.schema_verbs.iter().map(String::as_str))
3291            .collect::<Vec<_>>();
3292        catalog_verbs.sort_unstable();
3293        catalog_verbs.dedup();
3294
3295        let mut listed_verbs = schema_verbs().to_vec();
3296        listed_verbs.sort_unstable();
3297        listed_verbs.retain(|verb| *verb != "error");
3298
3299        assert_eq!(
3300            catalog_verbs, listed_verbs,
3301            "`heddle help --output json` command schema verbs must match `heddle schemas` except for the cross-cutting JSON error envelope"
3302        );
3303    }
3304
3305    #[cfg(not(feature = "git-overlay"))]
3306    #[test]
3307    fn native_only_schema_registry_excludes_git_overlay_verbs() {
3308        let catalog = command_catalog::build_command_catalog();
3309        for verb in [
3310            "bridge git status",
3311            "bridge git init",
3312            "bridge git import",
3313            "bridge git export",
3314            "bridge git sync",
3315            "bridge git reconcile",
3316            "bridge git push",
3317            "bridge git pull",
3318            "bridge git reason",
3319            "git-overlay",
3320        ] {
3321            assert!(
3322                !schema_verbs().contains(&verb),
3323                "native-only schema listing must not advertise git-overlay verb `{verb}`"
3324            );
3325            assert!(
3326                !documented_schema_verbs().contains(&verb),
3327                "native-only documented schema listing must not advertise git-overlay verb `{verb}`"
3328            );
3329            assert!(
3330                schema_for_verb(verb).is_none(),
3331                "native-only schema lookup must reject git-overlay verb `{verb}`"
3332            );
3333            assert!(
3334                catalog.commands.iter().all(|command| {
3335                    !command
3336                        .schema_verbs
3337                        .iter()
3338                        .any(|schema_verb| schema_verb == verb)
3339                        && !command
3340                            .documented_schema_verbs
3341                            .iter()
3342                            .any(|schema_verb| schema_verb == verb)
3343                }),
3344                "native-only command catalog must not advertise git-overlay schema verb `{verb}`"
3345            );
3346        }
3347    }
3348
3349    #[test]
3350    fn unknown_verb_returns_none() {
3351        assert!(schema_for_verb("nope").is_none());
3352    }
3353
3354    #[test]
3355    fn status_schema_has_expected_top_level_properties() {
3356        let schema = schema_for_verb("status").expect("status schema");
3357        let properties = schema
3358            .get("properties")
3359            .and_then(|p| p.as_object())
3360            .expect("status schema has properties");
3361        for required in &[
3362            "repository_capability",
3363            "storage_model",
3364            "hosted_enabled",
3365            "thread",
3366            "current_state",
3367            "actor",
3368            "blockers",
3369            "changes",
3370        ] {
3371            assert!(
3372                properties.contains_key(*required),
3373                "status schema missing property '{required}'"
3374            );
3375        }
3376    }
3377
3378    #[test]
3379    fn action_template_agent_may_fill_schema_describes_false_semantics() {
3380        let schema = schema_for_verb("verify").expect("verify schema");
3381        let action_template = schema
3382            .get("$defs")
3383            .or_else(|| schema.get("definitions"))
3384            .and_then(|defs| defs.get("ActionTemplateSchema"))
3385            .expect("verify schema includes ActionTemplateSchema definition");
3386        let description = property_schema(action_template, "agent_may_fill")
3387            .get("description")
3388            .and_then(Value::as_str)
3389            .expect("agent_may_fill schema description is present");
3390
3391        assert!(
3392            description.contains("When `agent_may_fill` is false"),
3393            "agent_may_fill schema description must document false semantics: {description}"
3394        );
3395        assert!(
3396            description.contains("display-only"),
3397            "agent_may_fill schema description must warn agents not to execute display-only templates: {description}"
3398        );
3399        assert!(
3400            description.contains("do not substitute `<name>`/`<url>` placeholders"),
3401            "agent_may_fill schema description must prohibit placeholder substitution when false: {description}"
3402        );
3403    }
3404
3405    /// HeddleCo/heddle#645 conformance: the action-field presence contract.
3406    ///
3407    /// `next_action` / `recommended_action` encode "no action needed" as
3408    /// `null` and "not applicable to this output shape" as an absent
3409    /// field — never as `""` (the runtime maps empty selections to `None`
3410    /// via `next_action::normalized_action` /
3411    /// `serialize_empty_action_as_null`, and the serialization walker in
3412    /// `validate_next_actions_at_path` rejects any empty string that
3413    /// slips past). At the schema level that means: wherever one of these
3414    /// properties is *required*, its schema must allow `null` — a
3415    /// non-nullable required action field would force emitters to leak
3416    /// `""` for the no-action case.
3417    #[test]
3418    fn action_fields_follow_presence_contract_in_every_schema() {
3419        fn walk(root: &Value, schema: &Value, verb: &str, path: &str) {
3420            match schema {
3421                Value::Object(object) => {
3422                    if let Some(properties) = object.get("properties").and_then(|p| p.as_object()) {
3423                        let required: Vec<&str> = object
3424                            .get("required")
3425                            .and_then(|value| value.as_array())
3426                            .map(|fields| {
3427                                fields.iter().filter_map(|field| field.as_str()).collect()
3428                            })
3429                            .unwrap_or_default();
3430                        for (name, child) in properties {
3431                            if matches!(name.as_str(), "next_action" | "recommended_action")
3432                                && required.contains(&name.as_str())
3433                            {
3434                                assert!(
3435                                    schema_allows_null(root, child),
3436                                    "`{verb}` schema requires `{path}.{name}` without allowing \
3437                                     null; the action contract is null = no action, absent = \
3438                                     not applicable, never \"\": {child}"
3439                                );
3440                            }
3441                        }
3442                    }
3443                    for (key, child) in object {
3444                        walk(root, child, verb, &format!("{path}.{key}"));
3445                    }
3446                }
3447                Value::Array(items) => {
3448                    for (index, child) in items.iter().enumerate() {
3449                        walk(root, child, verb, &format!("{path}[{index}]"));
3450                    }
3451                }
3452                _ => {}
3453            }
3454        }
3455
3456        for verb in schema_verbs() {
3457            let schema =
3458                schema_for_verb(verb).unwrap_or_else(|| panic!("schema registered for `{verb}`"));
3459            walk(&schema, &schema, verb, "$");
3460        }
3461    }
3462
3463    #[test]
3464    fn status_schema_allows_null_recommended_action() {
3465        let schema = schema_for_verb("status").expect("status schema");
3466        let recommended_action = property_schema(&schema, "recommended_action");
3467        assert!(
3468            schema_allows_null(&schema, recommended_action),
3469            "status recommended_action must allow null because empty actions serialize as null: {recommended_action}"
3470        );
3471
3472        let required = required_fields(&schema);
3473        assert!(
3474            required.contains(&"recommended_action"),
3475            "status recommended_action should remain a stable emitted field: {schema}"
3476        );
3477    }
3478
3479    #[test]
3480    fn status_agent_context_fields_are_omittable() {
3481        let schema = schema_for_verb("status").expect("status schema");
3482        let required = required_fields(&schema);
3483        for field in [
3484            "path",
3485            "execution_path",
3486            "session_id",
3487            "heddle_session_id",
3488            "actor",
3489            "harness",
3490            "thinking_level",
3491            "usage_summary",
3492            "last_progress_at",
3493            "report_flush_state",
3494            "attach_reason",
3495            "target_thread",
3496            "parent_thread",
3497            "task",
3498        ] {
3499            assert!(
3500                !required.contains(&field),
3501                "status `{field}` is omitted when no agent/materialized context is recorded: {schema}"
3502            );
3503        }
3504    }
3505
3506    #[test]
3507    fn status_thread_mode_schema_matches_observed_modes() {
3508        let schema = schema_for_verb("status").expect("status schema");
3509        let mut values = Vec::new();
3510        collect_string_enums(
3511            &schema,
3512            property_schema(&schema, "thread_mode"),
3513            &mut values,
3514        );
3515
3516        for expected in ["materialized", "virtualized", "solid"] {
3517            assert!(
3518                values.contains(&expected),
3519                "status thread_mode schema missing observed mode `{expected}`: {values:?}"
3520            );
3521        }
3522        assert!(
3523            !values.contains(&"lightweight"),
3524            "status thread_mode schema must not advertise removed mode `lightweight`: {values:?}"
3525        );
3526    }
3527
3528    #[test]
3529    fn ready_schema_requires_stable_operator_and_readiness_fields() {
3530        let schema = schema_for_verb("ready").expect("ready schema");
3531        let properties = schema
3532            .get("properties")
3533            .and_then(|p| p.as_object())
3534            .expect("ready schema has properties");
3535        assert!(
3536            properties.contains_key("blockers"),
3537            "ready schema should still document blockers when emitted"
3538        );
3539        assert!(
3540            properties.contains_key("warnings"),
3541            "ready schema should still document warnings when emitted"
3542        );
3543        assert!(
3544            properties.contains_key("readiness"),
3545            "ready schema should document the stable readiness summary"
3546        );
3547        assert!(
3548            properties.contains_key("verification"),
3549            "ready schema should document the repository verification proof"
3550        );
3551
3552        let required = required_fields(&schema);
3553        for stable_field in ["blockers", "warnings", "readiness", "verification"] {
3554            assert!(
3555                required.contains(&stable_field),
3556                "ready schema must require `{stable_field}` because ready JSON always emits the stable field set: {schema}"
3557            );
3558        }
3559        assert!(
3560            properties.contains_key("captured_state"),
3561            "ready schema should document captured_state even though schemars models nullable Option fields as optional"
3562        );
3563    }
3564
3565    #[test]
3566    fn push_schema_requires_stable_runtime_fields() {
3567        let schema = schema_for_verb("push").expect("push schema");
3568        let required = required_fields(&schema);
3569        for stable_field in [
3570            "output_kind",
3571            "action",
3572            "status",
3573            "pushed",
3574            "changed",
3575            "success",
3576            "transport",
3577            "next_action",
3578            "next_action_template",
3579            "recommended_action",
3580            "recommended_action_template",
3581        ] {
3582            assert!(
3583                required.contains(&stable_field),
3584                "push schema must require stable emitted field `{stable_field}`: {schema}"
3585            );
3586        }
3587
3588        for skipped_when_none in [
3589            "remote",
3590            "push_scope",
3591            "ref_scope",
3592            "git_notes_ref",
3593            "git_notes_visibility_warning",
3594            "git_tracking_remote",
3595            "git_remote_configured",
3596            "git_upstream_configured",
3597            "tags_included",
3598            "force",
3599            "force_discard_warning",
3600            "thread",
3601            "state",
3602            "objects",
3603        ] {
3604            assert!(
3605                !required.contains(&skipped_when_none),
3606                "push schema must not require conditionally omitted field `{skipped_when_none}`: {schema}"
3607            );
3608        }
3609    }
3610
3611    #[test]
3612    fn advertised_json_discriminators_are_reflected_in_schemas() {
3613        use std::collections::{BTreeMap, BTreeSet};
3614
3615        for schema_verb in schema_verbs() {
3616            let mut discriminators =
3617                command_catalog::command_json_discriminators_for_schema_verb(schema_verb);
3618            if discriminators.is_empty() {
3619                continue;
3620            };
3621            let schema =
3622                schema_for_verb(schema_verb).unwrap_or_else(|| panic!("{schema_verb} schema"));
3623            if schema.get("anyOf").is_some() {
3624                // A union schema published under this verb covers every schema
3625                // verb its catalog entry documents — the expected discriminator
3626                // set must include the siblings (e.g. inspect's union carries
3627                // the `thread show` branch's thread_show).
3628                for sibling in command_catalog::sibling_documented_schema_verbs(schema_verb) {
3629                    discriminators.extend(
3630                        command_catalog::command_json_discriminators_for_schema_verb(sibling),
3631                    );
3632                }
3633                for discriminator in command_catalog::command_json_discriminators()
3634                    .into_iter()
3635                    .filter(|discriminator| {
3636                        discriminator.display == *schema_verb
3637                            && discriminator.schema_verb.as_deref() != Some(schema_verb)
3638                    })
3639                {
3640                    discriminators.push(discriminator);
3641                }
3642            }
3643
3644            let mut expected_by_field = BTreeMap::<String, BTreeSet<String>>::new();
3645            for discriminator in discriminators {
3646                expected_by_field
3647                    .entry(discriminator.field)
3648                    .or_default()
3649                    .insert(discriminator.value);
3650            }
3651
3652            for (field, expected) in expected_by_field {
3653                let mut actual = Vec::new();
3654                collect_discriminator_values(&schema, &schema, &field, &mut actual);
3655                let actual = actual
3656                    .into_iter()
3657                    .map(str::to_string)
3658                    .collect::<BTreeSet<_>>();
3659                assert_eq!(
3660                    actual, expected,
3661                    "{schema_verb} schema must narrow `{field}` to every catalog-advertised value"
3662                );
3663                assert!(
3664                    schema_requires_discriminator(&schema, &schema, &field),
3665                    "{schema_verb} schema must require discriminator field `{field}`"
3666                );
3667            }
3668        }
3669    }
3670
3671    #[test]
3672    fn oss_recovery_surfaces_do_not_use_opaque_generic_schema() {
3673        for verb in [
3674            "fsck",
3675            "resolve",
3676            "retro",
3677            "discuss open",
3678            "discuss append",
3679            "discuss resolve",
3680            "discuss list",
3681            "discuss show",
3682            "query",
3683            "query --attribution",
3684        ] {
3685            assert!(
3686                !opaque_schema_verbs().contains(&verb),
3687                "`{verb}` should have a concrete machine-contract schema, not the opaque generic object"
3688            );
3689            let schema = schema_for_verb(verb).unwrap_or_else(|| panic!("{verb} schema exists"));
3690            assert_ne!(
3691                schema.get("additionalProperties"),
3692                Some(&Value::Bool(true)),
3693                "`{verb}` schema should not accept arbitrary top-level fields"
3694            );
3695        }
3696    }
3697
3698    #[test]
3699    fn commit_schema_declares_op_id_replay_fields() {
3700        let schema = schema_for_verb("commit").expect("commit schema");
3701        let properties = schema
3702            .get("properties")
3703            .and_then(|p| p.as_object())
3704            .expect("commit schema has properties");
3705        for required in OP_ID_REPLAY_FIELD_NAMES {
3706            assert!(
3707                properties.contains_key(*required),
3708                "commit schema missing op-id replay property '{required}'"
3709            );
3710        }
3711    }
3712
3713    #[test]
3714    fn op_id_supported_schema_verbs_declare_replay_fields() {
3715        let mut checked = 0;
3716        for verb in schema_verbs() {
3717            if !schema_verb_supports_op_id(verb) {
3718                continue;
3719            }
3720            checked += 1;
3721            let schema =
3722                schema_for_verb(verb).unwrap_or_else(|| panic!("schema for `{verb}` exists"));
3723            let properties = schema
3724                .get("properties")
3725                .and_then(|p| p.as_object())
3726                .unwrap_or_else(|| panic!("schema for `{verb}` should expose properties"));
3727            for required in OP_ID_REPLAY_FIELD_NAMES {
3728                assert!(
3729                    properties.contains_key(*required),
3730                    "schema for op-id-supported verb `{verb}` missing replay property `{required}`"
3731                );
3732            }
3733        }
3734        assert!(
3735            checked > 1,
3736            "op-id schema coverage test should exercise more than commit"
3737        );
3738    }
3739
3740    #[test]
3741    fn log_schema_has_states_array() {
3742        let schema = schema_for_verb("log").expect("log schema");
3743        let properties = schema
3744            .get("properties")
3745            .and_then(|p| p.as_object())
3746            .unwrap();
3747        assert!(properties.contains_key("states"));
3748        assert!(properties.contains_key("repository_capability"));
3749    }
3750}