Skip to main content

verbs/
verify.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Repository verification facade.
3
4use std::{
5    collections::{BTreeMap, BTreeSet},
6    path::{Path, PathBuf},
7    time::Instant,
8};
9
10use ::objects::{HeddleError, error::Result, worktree::WorktreeStatus};
11use repo::{
12    Repository, Thread, ThreadManager, describe_thread_advice, discover_heddle_root,
13    refresh_thread_freshness,
14};
15use schemars::JsonSchema;
16use serde::{Serialize, Serializer};
17use sley::{Repository as SleyRepository, ShortStatusOptions, StatusUntrackedMode, StreamControl};
18
19use crate::{
20    ExecutionContext, HeddleReport, MachineOutputKind, OnboardingFacts, OutputDiscriminator,
21    ReportContract, plan_repository_onboarding, schema_for_report,
22    source_authority::{SourceAction, SourceAuthorityActions},
23    status::{
24        RepositoryVerificationHealth, build_repository_verification_health_with_worktree_status,
25        default_remote_name, git_default_remote_name_from_repo,
26        next_action::remote_tracking_status,
27    },
28};
29
30#[derive(Clone)]
31pub struct VerifyOptions {
32    pub start_path: Option<PathBuf>,
33    pub machine_contract_input: MachineContractInput,
34    pub action_audience: ActionAudience,
35    pub provenance: bool,
36}
37
38impl VerifyOptions {
39    pub fn new() -> Self {
40        Self {
41            start_path: None,
42            machine_contract_input: MachineContractInput::default(),
43            action_audience: ActionAudience::Human,
44            provenance: false,
45        }
46    }
47
48    pub fn with_start_path(mut self, start_path: impl Into<PathBuf>) -> Self {
49        self.start_path = Some(start_path.into());
50        self
51    }
52
53    pub fn with_machine_contract_input(mut self, input: MachineContractInput) -> Self {
54        self.machine_contract_input = input;
55        self
56    }
57
58    pub fn with_action_audience(mut self, audience: ActionAudience) -> Self {
59        self.action_audience = audience;
60        self
61    }
62
63    pub fn with_provenance(mut self, provenance: bool) -> Self {
64        self.provenance = provenance;
65        self
66    }
67}
68
69impl Default for VerifyOptions {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75#[derive(Debug, Clone, Copy, Serialize, JsonSchema, PartialEq, Eq)]
76#[serde(rename_all = "snake_case")]
77pub enum ActionAudience {
78    Human,
79    Agent,
80    Script,
81}
82
83#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
84pub struct MachineContractInput {
85    pub coverage: MachineContractCoverage,
86}
87
88impl MachineContractInput {
89    pub fn from_coverage(coverage: MachineContractCoverage) -> Self {
90        Self { coverage }
91    }
92}
93
94impl Default for MachineContractInput {
95    fn default() -> Self {
96        Self {
97            coverage: MachineContractCoverage::not_checked(),
98        }
99    }
100}
101
102#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
103pub struct VerifyReport {
104    pub output_kind: &'static str,
105    pub clean: bool,
106    pub repository_label: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub repository_context: Option<RepositoryContextInfo>,
109    #[serde(rename = "verification")]
110    pub trust: RepositoryVerificationState,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub provenance: Option<crate::ProvenanceReport>,
113    #[serde(skip)]
114    #[schemars(skip)]
115    pub profile: VerifyProfile,
116}
117
118impl VerifyReport {
119    pub const CONTRACT: ReportContract = ReportContract {
120        schema_name: "verify",
121        machine_output_kind: MachineOutputKind::Json,
122        output_discriminator: Some(OutputDiscriminator {
123            field: "output_kind",
124            value: "verify",
125        }),
126        schema: schema_for_report::<VerifyReport>,
127    };
128}
129
130impl HeddleReport for VerifyReport {
131    const CONTRACT: ReportContract = VerifyReport::CONTRACT;
132}
133
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
135pub struct VerifyProfile {
136    pub plain_git_probe_ms: u128,
137    pub repo_open_ms: u128,
138    pub verification_ms: u128,
139}
140
141#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
142pub struct RepositoryContextInfo {
143    pub kind: String,
144    pub parent_repository: Option<String>,
145    pub target_thread: Option<String>,
146    pub parent_thread: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
150pub struct RepositoryPresentation {
151    pub label: String,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub context: Option<RepositoryContextInfo>,
154}
155
156#[derive(Debug, Serialize, JsonSchema)]
157pub struct PlainGitVerifyProbe {
158    #[schemars(with = "String")]
159    pub root: PathBuf,
160    pub git_branch: Option<String>,
161    #[serde(skip)]
162    #[schemars(skip)]
163    pub changes: WorktreeStatus,
164    /// Canonical public field is `verification` (alpha: no dual-emit of `trust`).
165    #[serde(rename = "verification")]
166    pub trust: RepositoryVerificationState,
167}
168
169#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
170pub struct ActionTemplate {
171    pub action: String,
172    pub argv_template: Vec<String>,
173    pub required_inputs: Vec<String>,
174    /// Whether an agent may replace placeholders in `argv_template`.
175    ///
176    /// When `agent_may_fill` is false, treat `action` and `argv_template` as
177    /// display-only: do not substitute `<name>`/`<url>` placeholders. Surface
178    /// the template to a human or discard it. Substituting and running it will
179    /// pass literal `<name>` to Heddle and fail.
180    pub agent_may_fill: bool,
181}
182
183#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
184pub struct RepositoryVerificationState {
185    #[serde(rename = "verified")]
186    pub verified: bool,
187    pub status: String,
188    pub repository_mode: String,
189    pub heddle_initialized: bool,
190    pub git_branch: Option<String>,
191    pub heddle_thread: Option<String>,
192    pub worktree_dirty: bool,
193    pub worktree_state: String,
194    pub import_state: String,
195    pub mapping_state: String,
196    pub remote_drift: String,
197    pub active_operation: Option<String>,
198    pub default_remote: Option<String>,
199    pub clone_verification: String,
200    pub machine_contract: String,
201    pub machine_contract_coverage: MachineContractCoverage,
202    pub workflow_status: String,
203    pub workflow_summary: String,
204    pub summary: String,
205    #[serde(serialize_with = "serialize_empty_action_as_null")]
206    #[schemars(with = "Option<String>")]
207    pub recommended_action: String,
208    pub recommended_action_template: Option<ActionTemplate>,
209    pub recovery_commands: Vec<String>,
210    pub recovery_action_templates: Vec<ActionTemplate>,
211    pub checks: Vec<VerificationCheck>,
212}
213
214pub fn serialize_empty_action_as_null<S>(
215    action: &String,
216    serializer: S,
217) -> std::result::Result<S::Ok, S::Error>
218where
219    S: Serializer,
220{
221    if action.is_empty() {
222        serializer.serialize_none()
223    } else {
224        serializer.serialize_some(action)
225    }
226}
227
228#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
229pub struct MachineContractCoverage {
230    pub status: String,
231    #[serde(rename = "verified_scope")]
232    pub verified_scope: String,
233    pub advanced_scope: String,
234    pub summary: String,
235    pub catalog_commands_total: usize,
236    pub catalog_mutating_commands_total: usize,
237    pub json_commands_total: usize,
238    pub json_mutating_commands_total: usize,
239    pub json_commands_with_schema: usize,
240    pub json_commands_with_accepted_opaque_schema: usize,
241    pub json_commands_without_schema: usize,
242    #[serde(rename = "verified_scope_json_commands_total")]
243    pub verified_scope_json_commands_total: usize,
244    #[serde(rename = "verified_scope_json_commands_with_schema")]
245    pub verified_scope_json_commands_with_schema: usize,
246    #[serde(rename = "verified_scope_json_commands_with_accepted_opaque_schema")]
247    pub verified_scope_json_commands_with_accepted_opaque_schema: usize,
248    #[serde(rename = "verified_scope_json_commands_without_schema")]
249    pub verified_scope_json_commands_without_schema: usize,
250    pub advanced_scope_json_commands_total: usize,
251    pub advanced_scope_json_commands_with_accepted_opaque_schema: usize,
252    pub mutating_commands_total: usize,
253    pub mutating_commands_with_schema: usize,
254    pub mutating_commands_with_accepted_opaque_schema: usize,
255    pub mutating_commands_without_schema: usize,
256    #[serde(rename = "verified_scope_mutating_commands_total")]
257    pub verified_scope_mutating_commands_total: usize,
258    #[serde(rename = "verified_scope_mutating_commands_with_schema")]
259    pub verified_scope_mutating_commands_with_schema: usize,
260    #[serde(rename = "verified_scope_mutating_commands_with_accepted_opaque_schema")]
261    pub verified_scope_mutating_commands_with_accepted_opaque_schema: usize,
262    #[serde(rename = "verified_scope_mutating_commands_without_schema")]
263    pub verified_scope_mutating_commands_without_schema: usize,
264    pub advanced_scope_mutating_commands_total: usize,
265    pub advanced_scope_mutating_commands_with_accepted_opaque_schema: usize,
266    pub schema_verbs_total: usize,
267    pub documented_schema_verbs_total: usize,
268    pub undocumented_schema_verbs_total: usize,
269    pub opaque_schema_verbs_total: usize,
270    pub accepted_opaque_schema_verbs_total: usize,
271    pub unaccepted_opaque_schema_verbs_total: usize,
272    pub supports_op_id_total: usize,
273    pub jsonl_commands_total: usize,
274    pub missing_schema_examples: Vec<String>,
275    pub missing_mutating_schema_examples: Vec<String>,
276    pub verified_scope_missing_schema_examples: Vec<String>,
277    pub verified_scope_accepted_opaque_schema_examples: Vec<String>,
278    pub advanced_scope_accepted_opaque_schema_examples: Vec<String>,
279    pub accepted_opaque_schema_examples: Vec<String>,
280    pub unaccepted_opaque_schema_examples: Vec<String>,
281    pub undocumented_schema_examples: Vec<String>,
282}
283
284impl MachineContractCoverage {
285    pub fn not_checked() -> Self {
286        Self {
287            status: "not_checked".to_string(),
288            verified_scope: "not_checked".to_string(),
289            advanced_scope: "not_checked".to_string(),
290            summary: "Machine-contract proof was not supplied by this embedder".to_string(),
291            catalog_commands_total: 0,
292            catalog_mutating_commands_total: 0,
293            json_commands_total: 0,
294            json_mutating_commands_total: 0,
295            json_commands_with_schema: 0,
296            json_commands_with_accepted_opaque_schema: 0,
297            json_commands_without_schema: 0,
298            verified_scope_json_commands_total: 0,
299            verified_scope_json_commands_with_schema: 0,
300            verified_scope_json_commands_with_accepted_opaque_schema: 0,
301            verified_scope_json_commands_without_schema: 0,
302            advanced_scope_json_commands_total: 0,
303            advanced_scope_json_commands_with_accepted_opaque_schema: 0,
304            mutating_commands_total: 0,
305            mutating_commands_with_schema: 0,
306            mutating_commands_with_accepted_opaque_schema: 0,
307            mutating_commands_without_schema: 0,
308            verified_scope_mutating_commands_total: 0,
309            verified_scope_mutating_commands_with_schema: 0,
310            verified_scope_mutating_commands_with_accepted_opaque_schema: 0,
311            verified_scope_mutating_commands_without_schema: 0,
312            advanced_scope_mutating_commands_total: 0,
313            advanced_scope_mutating_commands_with_accepted_opaque_schema: 0,
314            schema_verbs_total: 0,
315            documented_schema_verbs_total: 0,
316            undocumented_schema_verbs_total: 0,
317            opaque_schema_verbs_total: 0,
318            accepted_opaque_schema_verbs_total: 0,
319            unaccepted_opaque_schema_verbs_total: 0,
320            supports_op_id_total: 0,
321            jsonl_commands_total: 0,
322            missing_schema_examples: Vec::new(),
323            missing_mutating_schema_examples: Vec::new(),
324            verified_scope_missing_schema_examples: Vec::new(),
325            verified_scope_accepted_opaque_schema_examples: Vec::new(),
326            advanced_scope_accepted_opaque_schema_examples: Vec::new(),
327            accepted_opaque_schema_examples: Vec::new(),
328            unaccepted_opaque_schema_examples: Vec::new(),
329            undocumented_schema_examples: Vec::new(),
330        }
331    }
332}
333
334#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
335pub struct VerificationCheck {
336    pub name: String,
337    pub status: String,
338    pub clean: bool,
339    pub summary: String,
340    pub recommended_action: Option<String>,
341    pub recommended_action_template: Option<ActionTemplate>,
342    pub recovery_commands: Vec<String>,
343    pub recovery_action_templates: Vec<ActionTemplate>,
344    #[serde(default)]
345    pub details: BTreeMap<String, String>,
346}
347
348pub fn build_plain_git_verification_probe(start: &Path) -> Result<Option<PlainGitVerifyProbe>> {
349    build_plain_git_verification_probe_with_machine_contract(
350        start,
351        &MachineContractInput::default(),
352    )
353}
354
355pub fn build_plain_git_verification_probe_with_machine_contract(
356    start: &Path,
357    machine_contract_input: &MachineContractInput,
358) -> Result<Option<PlainGitVerifyProbe>> {
359    // Native Heddle metadata owns the path before any ancestor Git repository
360    // is considered. In particular, do not ask Sley to scan the ancestor
361    // worktree: that can crawl unrelated siblings and fail on their
362    // permissions before Heddle opens its own repository.
363    if discover_heddle_root(start).is_some() {
364        return Ok(None);
365    }
366    let git_repo = match SleyRepository::open_from_environment(start) {
367        Ok(repo) => repo,
368        Err(_) => return Ok(None),
369    };
370    let Some(workdir) = git_repo.workdir() else {
371        return Ok(None);
372    };
373    let root = workdir
374        .canonicalize()
375        .unwrap_or_else(|_| workdir.to_path_buf());
376    if root.join(".heddle").exists() {
377        return Ok(None);
378    }
379
380    let git_branch = plain_git_current_branch(&git_repo);
381    let git_branches = plain_git_local_branches(&git_repo);
382    let git_tags = plain_git_local_tags(&git_repo);
383    let changes = plain_git_worktree_status(&git_repo)?;
384    let onboarding = plan_repository_onboarding(OnboardingFacts {
385        git_worktree: true,
386        git_has_commits: git_repo.head().ok().and_then(|head| head.oid).is_some(),
387        heddle_mode: None,
388    });
389
390    let default_remote = git_default_remote_name_from_repo(&git_repo);
391    let setup_action = onboarding
392        .recommended_command()
393        .expect("plain Git without Heddle metadata requires onboarding")
394        .to_string();
395    let recovery_commands = vec![setup_action.clone()];
396    let machine_contract_coverage = machine_contract_input.coverage.clone();
397    let mut details = BTreeMap::new();
398    details.insert("path".to_string(), root.display().to_string());
399    if let Some(branch) = &git_branch {
400        details.insert("git_branch".to_string(), branch.clone());
401    }
402    if let Some(remote) = &default_remote {
403        details.insert("default_remote".to_string(), remote.clone());
404    }
405    details.insert(
406        "git_branch_count".to_string(),
407        git_branches.len().to_string(),
408    );
409    details.insert("git_tag_count".to_string(), git_tags.len().to_string());
410    details.insert(
411        "onboarding_state".to_string(),
412        onboarding.state.as_str().to_string(),
413    );
414
415    let mut checks = vec![
416        VerificationCheck {
417            name: "Git".to_string(),
418            status: "present".to_string(),
419            clean: true,
420            summary: "plain Git repository found".to_string(),
421            recommended_action: None,
422            recommended_action_template: None,
423            recovery_commands: Vec::new(),
424            recovery_action_templates: Vec::new(),
425            details,
426        },
427        VerificationCheck {
428            name: "Heddle".to_string(),
429            status: "needs_init".to_string(),
430            clean: false,
431            summary: "Heddle data is not initialized".to_string(),
432            recommended_action: Some(setup_action.clone()),
433            recommended_action_template: action_template(&setup_action),
434            recovery_commands: recovery_commands.clone(),
435            recovery_action_templates: action_templates(&recovery_commands),
436            details: BTreeMap::new(),
437        },
438        VerificationCheck {
439            name: "Mapping".to_string(),
440            status: "git_backed".to_string(),
441            clean: true,
442            summary: onboarding.storage_summary().to_string(),
443            recommended_action: None,
444            recommended_action_template: None,
445            recovery_commands: Vec::new(),
446            recovery_action_templates: Vec::new(),
447            details: BTreeMap::new(),
448        },
449    ];
450    checks.push(verification_check(
451        "Worktree",
452        changes.is_clean(),
453        if changes.is_clean() {
454            "clean"
455        } else {
456            "dirty_worktree"
457        },
458        if changes.is_clean() {
459            "Git worktree is clean"
460        } else {
461            "Git worktree has uncommitted changes"
462        },
463        None,
464        Vec::new(),
465    ));
466    checks.push(verification_check(
467        "Remote",
468        false,
469        "unknown",
470        "remote drift is checked after Heddle initialization",
471        None,
472        Vec::new(),
473    ));
474    checks.push(verification_check(
475        "Operation",
476        true,
477        "clean",
478        "no Heddle operation in progress",
479        None,
480        Vec::new(),
481    ));
482    checks.push(verification_check(
483        "Workflow",
484        false,
485        "not_checked",
486        "workflow readiness is checked after Heddle initialization",
487        None,
488        Vec::new(),
489    ));
490    checks.push(machine_contract_verification_check(
491        &machine_contract_coverage,
492    ));
493    checks.push(verification_check(
494        "Clone",
495        true,
496        "not_applicable",
497        "clone verification is not applicable to this checkout",
498        None,
499        Vec::new(),
500    ));
501
502    let trust = RepositoryVerificationState {
503        verified: false,
504        status: "needs_init".to_string(),
505        repository_mode: "plain-git".to_string(),
506        heddle_initialized: false,
507        git_branch: git_branch.clone(),
508        heddle_thread: None,
509        worktree_dirty: !changes.is_clean(),
510        worktree_state: if changes.is_clean() { "clean" } else { "dirty" }.to_string(),
511        import_state: "git_backed".to_string(),
512        mapping_state: "git_backed".to_string(),
513        remote_drift: "unknown".to_string(),
514        active_operation: None,
515        default_remote,
516        clone_verification: "not_applicable".to_string(),
517        machine_contract: machine_contract_status(&machine_contract_coverage).to_string(),
518        machine_contract_coverage,
519        workflow_status: "not_checked".to_string(),
520        workflow_summary: "workflow readiness is checked after Heddle initialization".to_string(),
521        summary: "Git repository has not been initialized for Heddle".to_string(),
522        recommended_action: setup_action.clone(),
523        recommended_action_template: action_template(&setup_action),
524        recovery_commands: recovery_commands.clone(),
525        recovery_action_templates: action_templates(&recovery_commands),
526        checks,
527    };
528    Ok(Some(PlainGitVerifyProbe {
529        root,
530        git_branch,
531        changes,
532        trust,
533    }))
534}
535
536fn plain_git_current_branch(git_repo: &SleyRepository) -> Option<String> {
537    git_repo.head().ok()?.branch_name().map(str::to_string)
538}
539
540fn plain_git_local_branches(git_repo: &SleyRepository) -> Vec<String> {
541    let Ok(branches) = git_repo.references().list_refs() else {
542        return Vec::new();
543    };
544    let mut names = branches
545        .into_iter()
546        .filter_map(|branch| branch.name.strip_prefix("refs/heads/").map(str::to_string))
547        .filter(|branch| !branch.trim().is_empty())
548        .collect::<Vec<_>>();
549    names.sort();
550    names.dedup();
551    names
552}
553
554fn plain_git_local_tags(git_repo: &SleyRepository) -> Vec<String> {
555    let Ok(tags) = git_repo.references().list_refs() else {
556        return Vec::new();
557    };
558    let mut names = tags
559        .into_iter()
560        .filter_map(|tag| tag.name.strip_prefix("refs/tags/").map(str::to_string))
561        .filter(|tag| !tag.trim().is_empty())
562        .collect::<Vec<_>>();
563    names.sort();
564    names.dedup();
565    names
566}
567
568fn plain_git_worktree_status(git_repo: &SleyRepository) -> Result<WorktreeStatus> {
569    let mut added = BTreeSet::new();
570    let mut modified = BTreeSet::new();
571    let mut deleted = BTreeSet::new();
572    git_repo
573        .stream_short_status_with_options(
574            ShortStatusOptions {
575                untracked_mode: StatusUntrackedMode::All,
576                ..ShortStatusOptions::default()
577            },
578            |entry| {
579                let path = PathBuf::from(String::from_utf8_lossy(entry.path).into_owned());
580                if entry.index == b'?' && entry.worktree == b'?' {
581                    added.insert(path);
582                } else if entry.index == b'D' || entry.worktree == b'D' {
583                    deleted.insert(path);
584                } else if entry.index == b'A'
585                    || entry.index == b'R'
586                    || entry.index == b'C'
587                    || entry.head_oid.is_none()
588                {
589                    added.insert(path);
590                } else {
591                    modified.insert(path);
592                }
593                Ok(StreamControl::Continue)
594            },
595        )
596        .map_err(|error| HeddleError::Config(error.to_string()))?;
597
598    for path in &added {
599        modified.remove(path);
600    }
601    for path in &deleted {
602        modified.remove(path);
603    }
604
605    Ok(WorktreeStatus {
606        modified: modified.into_iter().collect(),
607        added: added.into_iter().collect(),
608        deleted: deleted.into_iter().collect(),
609    })
610}
611
612pub fn build_repository_verification_state(
613    repo: &Repository,
614) -> Result<RepositoryVerificationState> {
615    build_repository_verification_state_with_machine_contract(
616        repo,
617        &MachineContractInput::default(),
618    )
619}
620
621pub fn build_repository_verification_state_with_machine_contract(
622    repo: &Repository,
623    machine_contract_input: &MachineContractInput,
624) -> Result<RepositoryVerificationState> {
625    let worktree_status = if repo.capability() == repo::RepositoryCapability::GitOverlay {
626        repo.git_overlay_worktree_status()
627    } else {
628        native_worktree_status(repo)
629    };
630    let health = build_repository_verification_health_with_worktree_status(repo, &worktree_status);
631    Ok(
632        build_repository_verification_state_with_worktree_status_and_machine_contract(
633            repo,
634            health,
635            &worktree_status,
636            machine_contract_input,
637        ),
638    )
639}
640
641fn native_worktree_status(repo: &Repository) -> Result<Option<WorktreeStatus>> {
642    let Some(state) = repo.current_state()? else {
643        return Ok(Some(WorktreeStatus::default()));
644    };
645    let tree = repo.require_tree(&state.tree)?;
646    repo.compare_worktree_cached(&tree).map(Some)
647}
648
649pub fn build_repository_verification_state_with_worktree_status(
650    repo: &Repository,
651    health: RepositoryVerificationHealth,
652    worktree_status: &Result<Option<WorktreeStatus>>,
653) -> RepositoryVerificationState {
654    build_repository_verification_state_with_worktree_status_and_machine_contract(
655        repo,
656        health,
657        worktree_status,
658        &MachineContractInput::default(),
659    )
660}
661
662pub fn build_repository_verification_state_with_worktree_status_and_machine_contract(
663    repo: &Repository,
664    health: RepositoryVerificationHealth,
665    worktree_status: &Result<Option<WorktreeStatus>>,
666    machine_contract_input: &MachineContractInput,
667) -> RepositoryVerificationState {
668    let git_branch = repo.git_overlay_current_branch().ok().flatten();
669    let heddle_thread = repo.current_lane().ok().flatten();
670    let active_operation = repo.operation_status().ok().flatten().map(|operation| {
671        format!(
672            "{} {} ({})",
673            operation.scope, operation.kind, operation.state
674        )
675    });
676    let remote_drift = repo
677        .git_remote_tracking_status()
678        .ok()
679        .flatten()
680        .map(|remote| remote_tracking_status(&remote).to_string())
681        .unwrap_or_else(|| "clean".to_string());
682    let is_git_overlay = repo.capability() == repo::RepositoryCapability::GitOverlay;
683    let import_state = health
684        .checks
685        .iter()
686        .find(|check| check.name == "import" && check.status != "clean")
687        .or_else(|| health.checks.iter().find(|check| check.name == "import"))
688        .map(|check| check.status.clone())
689        .unwrap_or_else(|| {
690            if is_git_overlay {
691                "git_backed".to_string()
692            } else {
693                "clean".to_string()
694            }
695        });
696    let mapping_state = health
697        .checks
698        .iter()
699        .find(|check| {
700            matches!(check.name.as_str(), "head_mapping" | "tag_mapping")
701                && !verification_status_is_clean(&check.status)
702        })
703        .or_else(|| {
704            health
705                .checks
706                .iter()
707                .find(|check| check.name == "head_mapping")
708        })
709        .map(|check| check.status.clone())
710        .unwrap_or_else(|| {
711            if is_git_overlay {
712                "git_backed".to_string()
713            } else {
714                "clean".to_string()
715            }
716        });
717    let git_worktree_dirty = matches!(
718        worktree_status,
719        Ok(Some(status)) if !status.is_clean()
720    );
721    let worktree_dirty = git_worktree_dirty
722        || health.checks.iter().any(|check| {
723            matches!(check.name.as_str(), "worktree" | "heddle_worktree") && check.status != "clean"
724        });
725    let machine_contract_coverage = machine_contract_input.coverage.clone();
726    let machine_contract_clean = machine_contract_is_clean(&machine_contract_coverage);
727    let mut recovery_commands = health.recovery_commands.clone();
728    let remote_action = remote_sync_action(&health, repo.source_authority());
729    let (workflow_status, workflow_summary) = workflow_status(repo, heddle_thread.as_deref());
730    let workflow_action = if health.clean && workflow_status == "ready" {
731        workflow_primary_action(repo)
732    } else {
733        None
734    };
735    if health.clean && !machine_contract_clean {
736        recovery_commands.push("heddle doctor schemas --output json".to_string());
737    }
738    let recommended_action = if health.clean {
739        if !machine_contract_clean {
740            "heddle doctor schemas --output json".to_string()
741        } else {
742            workflow_action
743                .clone()
744                .or_else(|| remote_action.clone())
745                .unwrap_or_default()
746        }
747    } else {
748        recovery_commands.first().cloned().unwrap_or_default()
749    };
750    let checks = verification_checks_from_health(
751        &health,
752        &machine_contract_coverage,
753        is_git_overlay,
754        &workflow_status,
755        &workflow_summary,
756        workflow_action.as_deref(),
757        repo.source_authority(),
758    );
759    RepositoryVerificationState {
760        verified: health.clean && machine_contract_clean,
761        status: if health.clean && !machine_contract_clean {
762            "machine_contract_gaps".to_string()
763        } else {
764            health.status.clone()
765        },
766        repository_mode: repo.capability_label().to_string(),
767        heddle_initialized: true,
768        git_branch,
769        heddle_thread,
770        worktree_dirty,
771        worktree_state: if worktree_dirty { "dirty" } else { "clean" }.to_string(),
772        import_state,
773        mapping_state,
774        remote_drift,
775        active_operation,
776        default_remote: default_remote_name(repo),
777        clone_verification: if repo.capability() == repo::RepositoryCapability::GitOverlay {
778            if health.clean {
779                "verified"
780            } else if matches!(
781                health.status.as_str(),
782                "dirty_worktree" | "needs_checkpoint"
783            ) {
784                "not_checked"
785            } else {
786                "blocked"
787            }
788        } else {
789            "not_applicable"
790        }
791        .to_string(),
792        machine_contract: machine_contract_status(&machine_contract_coverage).to_string(),
793        machine_contract_coverage,
794        workflow_status,
795        workflow_summary,
796        summary: health.summary,
797        recommended_action: recommended_action.clone(),
798        recommended_action_template: action_template(&recommended_action),
799        recovery_commands: recovery_commands.clone(),
800        recovery_action_templates: action_templates(&recovery_commands),
801        checks,
802    }
803}
804
805fn verification_checks_from_health(
806    health: &RepositoryVerificationHealth,
807    coverage: &MachineContractCoverage,
808    is_git_overlay: bool,
809    workflow_status: &str,
810    workflow_summary: &str,
811    workflow_action: Option<&str>,
812    source_authority: repo::RepositorySourceAuthority,
813) -> Vec<VerificationCheck> {
814    let mut checks = vec![
815        git_verification_check(is_git_overlay),
816        verification_check(
817            "Heddle",
818            true,
819            "clean",
820            "Heddle data is initialized",
821            None,
822            Vec::new(),
823        ),
824        mapping_verification_check(health, is_git_overlay),
825        worktree_verification_check(health),
826        remote_verification_check(health, source_authority),
827        operation_verification_check(health),
828        workflow_verification_check(health, workflow_status, workflow_summary, workflow_action),
829    ];
830    checks.push(machine_contract_verification_check(coverage));
831    checks.push(clone_verification_check(health, is_git_overlay));
832    checks
833}
834
835fn machine_contract_verification_check(coverage: &MachineContractCoverage) -> VerificationCheck {
836    let mut details = BTreeMap::new();
837    details.insert("coverage_status".to_string(), coverage.status.clone());
838    details.insert("coverage_summary".to_string(), coverage.summary.clone());
839    details.insert(
840        "verified_scope".to_string(),
841        coverage.verified_scope.clone(),
842    );
843    details.insert(
844        "advanced_scope".to_string(),
845        coverage.advanced_scope.clone(),
846    );
847    details.insert(
848        "catalog_commands_total".to_string(),
849        coverage.catalog_commands_total.to_string(),
850    );
851    details.insert(
852        "json_commands_total".to_string(),
853        coverage.json_commands_total.to_string(),
854    );
855    details.insert(
856        "json_commands_with_schema".to_string(),
857        coverage.json_commands_with_schema.to_string(),
858    );
859    details.insert(
860        "json_commands_without_schema".to_string(),
861        coverage.json_commands_without_schema.to_string(),
862    );
863    details.insert(
864        "json_commands_with_accepted_opaque_schema".to_string(),
865        coverage
866            .json_commands_with_accepted_opaque_schema
867            .to_string(),
868    );
869    details.insert(
870        "verified_scope_json_commands_total".to_string(),
871        coverage.verified_scope_json_commands_total.to_string(),
872    );
873    let mut check = verification_check(
874        "Machine contract",
875        machine_contract_is_clean(coverage),
876        machine_contract_status(coverage),
877        &coverage.summary,
878        (!machine_contract_is_clean(coverage))
879            .then(|| "heddle doctor schemas --output json".to_string()),
880        if machine_contract_is_clean(coverage) {
881            Vec::new()
882        } else {
883            vec!["heddle doctor schemas --output json".to_string()]
884        },
885    );
886    check.details = details;
887    check
888}
889
890fn git_verification_check(is_git_overlay: bool) -> VerificationCheck {
891    if is_git_overlay {
892        verification_check(
893            "Git",
894            true,
895            "clean",
896            "Git overlay repository is present",
897            None,
898            Vec::new(),
899        )
900    } else {
901        verification_check(
902            "Git",
903            true,
904            "not_applicable",
905            "Heddle-native repository is running in non-overlay mode",
906            None,
907            Vec::new(),
908        )
909    }
910}
911
912fn mapping_verification_check(
913    health: &RepositoryVerificationHealth,
914    is_git_overlay: bool,
915) -> VerificationCheck {
916    if !is_git_overlay {
917        return verification_check(
918            "Mapping",
919            true,
920            "not_applicable",
921            "native Heddle refs do not require Git Projection Mapping",
922            None,
923            Vec::new(),
924        );
925    }
926    if let Some(check) = health
927        .checks
928        .iter()
929        .find(|check| check.name == "head_mapping" && !verification_status_is_clean(&check.status))
930    {
931        return verification_check_from_health("Mapping", check, health);
932    }
933    if let Some(check) = find_health_check(health, "import")
934        && check.status != "clean"
935    {
936        return verification_check_from_health("Mapping", check, health);
937    }
938    if let Some(check) = find_health_check(health, "tag_mapping")
939        && check.status != "clean"
940    {
941        return verification_check_from_health("Mapping", check, health);
942    }
943    if let Some(check) = find_health_check(health, "head_mapping") {
944        if check.status == "git_backed" && health.status == "dirty_worktree" {
945            return verification_check(
946                "Mapping",
947                true,
948                "clean",
949                "Git-backed branch mapping is not blocking verification",
950                None,
951                Vec::new(),
952            );
953        }
954        return verification_check_from_health("Mapping", check, health);
955    }
956    verification_check(
957        "Mapping",
958        true,
959        "clean",
960        "Git branch tips map to imported Heddle state",
961        None,
962        Vec::new(),
963    )
964}
965
966fn worktree_verification_check(health: &RepositoryVerificationHealth) -> VerificationCheck {
967    for name in ["worktree", "heddle_worktree"] {
968        if let Some(check) = find_health_check(health, name)
969            && check.status != "clean"
970        {
971            return verification_check_from_health("Worktree", check, health);
972        }
973    }
974    for name in ["worktree", "heddle_worktree"] {
975        if let Some(check) = find_health_check(health, name) {
976            return verification_check_from_health("Worktree", check, health);
977        }
978    }
979    if !health.clean {
980        return verification_check(
981            "Worktree",
982            false,
983            "not_checked",
984            "worktree agreement is checked after the primary verification blocker is resolved",
985            health.recovery_commands.first().cloned(),
986            health.recovery_commands.clone(),
987        );
988    }
989    verification_check(
990        "Worktree",
991        true,
992        "clean",
993        "worktree has no uncommitted Git/Heddle disagreement",
994        None,
995        Vec::new(),
996    )
997}
998
999fn remote_verification_check(
1000    health: &RepositoryVerificationHealth,
1001    source_authority: repo::RepositorySourceAuthority,
1002) -> VerificationCheck {
1003    if let Some(check) = find_health_check(health, "remote_tracking") {
1004        if matches!(check.status.as_str(), "remote_ahead" | "remote_untracked") {
1005            let mut remote_check = verification_check(
1006                "Remote",
1007                true,
1008                &check.status,
1009                &check.summary,
1010                remote_sync_action(health, source_authority),
1011                Vec::new(),
1012            );
1013            remote_check.details = check.details.clone();
1014            return remote_check;
1015        }
1016        return verification_check_from_health("Remote", check, health);
1017    }
1018    verification_check(
1019        "Remote",
1020        true,
1021        "clean",
1022        "remote tracking has no blocking drift",
1023        None,
1024        Vec::new(),
1025    )
1026}
1027
1028fn operation_verification_check(health: &RepositoryVerificationHealth) -> VerificationCheck {
1029    if let Some(check) = find_health_check(health, "operation") {
1030        return verification_check_from_health("Operation", check, health);
1031    }
1032    verification_check(
1033        "Operation",
1034        true,
1035        "clean",
1036        "no Git or Heddle operation in progress",
1037        None,
1038        Vec::new(),
1039    )
1040}
1041
1042fn workflow_verification_check(
1043    health: &RepositoryVerificationHealth,
1044    workflow_status: &str,
1045    workflow_summary: &str,
1046    workflow_action: Option<&str>,
1047) -> VerificationCheck {
1048    if let Some(check) = find_health_check(health, "thread_integration_metadata")
1049        && check.status != "clean"
1050    {
1051        return verification_check_from_health("Workflow", check, health);
1052    }
1053    if !health.clean {
1054        return verification_check(
1055            "Workflow",
1056            false,
1057            "blocked",
1058            "workflow readiness is checked after the primary verification blocker is resolved",
1059            health.recovery_commands.first().cloned(),
1060            health.recovery_commands.clone(),
1061        );
1062    }
1063    // When ready work is actionable ("ready"), surface the concrete land
1064    // command on the Workflow check itself so agents inspecting the
1065    // verification proof get the same runnable next action the top-level
1066    // recommendation carries (dropped when the check moved cli->core, which
1067    // passed `None`).
1068    let recommended_action = (workflow_status == "ready")
1069        .then(|| workflow_action.map(str::to_string))
1070        .flatten();
1071    verification_check(
1072        "Workflow",
1073        true,
1074        workflow_status,
1075        workflow_summary,
1076        recommended_action,
1077        Vec::new(),
1078    )
1079}
1080
1081fn clone_verification_check(
1082    health: &RepositoryVerificationHealth,
1083    is_git_overlay: bool,
1084) -> VerificationCheck {
1085    if !is_git_overlay {
1086        return verification_check(
1087            "Clone",
1088            true,
1089            "not_applicable",
1090            "native Heddle state is the checkout authority",
1091            None,
1092            Vec::new(),
1093        );
1094    }
1095    if health.clean {
1096        return verification_check(
1097            "Clone",
1098            true,
1099            "verified",
1100            "Git checkout and Heddle mapping agree",
1101            None,
1102            Vec::new(),
1103        );
1104    }
1105    if matches!(
1106        health.status.as_str(),
1107        "dirty_worktree" | "needs_checkpoint"
1108    ) {
1109        return verification_check(
1110            "Clone",
1111            true,
1112            "not_checked",
1113            "clone verification waits for a clean worktree",
1114            None,
1115            Vec::new(),
1116        );
1117    }
1118    verification_check(
1119        "Clone",
1120        false,
1121        "blocked",
1122        "clone verification is blocked until verification checks agree",
1123        health.recovery_commands.first().cloned(),
1124        health.recovery_commands.clone(),
1125    )
1126}
1127
1128fn verification_check_from_health(
1129    name: &str,
1130    health_check: &crate::status::RepositoryVerificationCheck,
1131    health: &RepositoryVerificationHealth,
1132) -> VerificationCheck {
1133    let recommended_action = (!verification_status_is_clean(&health_check.status))
1134        .then(|| health.recovery_commands.first().cloned())
1135        .flatten();
1136    let recovery_commands = if recommended_action.is_some() {
1137        health.recovery_commands.clone()
1138    } else {
1139        Vec::new()
1140    };
1141    let mut check = verification_check(
1142        name,
1143        verification_status_is_clean(&health_check.status),
1144        &health_check.status,
1145        &health_check.summary,
1146        recommended_action,
1147        recovery_commands,
1148    );
1149    check.details = health_check.details.clone();
1150    check
1151}
1152
1153fn remote_sync_action(
1154    health: &RepositoryVerificationHealth,
1155    source_authority: repo::RepositorySourceAuthority,
1156) -> Option<String> {
1157    find_health_check(health, "remote_tracking").and_then(|check| {
1158        matches!(check.status.as_str(), "remote_ahead" | "remote_untracked")
1159            .then(|| SourceAuthorityActions::new(source_authority).display(SourceAction::Push))
1160    })
1161}
1162
1163fn find_health_check<'a>(
1164    health: &'a RepositoryVerificationHealth,
1165    name: &str,
1166) -> Option<&'a crate::status::RepositoryVerificationCheck> {
1167    health.checks.iter().find(|check| check.name == name)
1168}
1169
1170fn verification_status_is_clean(status: &str) -> bool {
1171    matches!(
1172        status,
1173        "clean"
1174            | "available"
1175            | "git_backed"
1176            | "not_applicable"
1177            | "verified"
1178            | "remote_ahead"
1179            | "remote_untracked"
1180    )
1181}
1182
1183fn workflow_status(repo: &Repository, current_thread: Option<&str>) -> (String, String) {
1184    let ready_threads = ThreadManager::new(repo.heddle_dir())
1185        .list()
1186        .unwrap_or_default()
1187        .into_iter()
1188        .filter(|thread| thread.state == repo::ThreadState::Ready)
1189        .collect::<Vec<_>>();
1190    if ready_threads.is_empty() {
1191        return (
1192            "clean".to_string(),
1193            "no ready thread actions require attention".to_string(),
1194        );
1195    }
1196    // A dedicated checkout can safely print a parent-repo land command for a
1197    // ready thread even when its recorded `target_thread` differs from the
1198    // checkout's current lane: the merge runs against the parent repo. The
1199    // MAIN checkout, by contrast, must be on the thread's recorded target
1200    // before the ready work becomes the primary next action. This mirrors
1201    // `workflow_primary_action` / the pre-facade `actionable_from_current_thread`
1202    // classification — without the dedicated-checkout allowance, `ready`/`status`
1203    // from an isolated checkout wrongly report `workflow_status = "clean"` for a
1204    // thread that is in fact ready to land.
1205    let opened_from_dedicated_checkout = repo
1206        .heddle_dir()
1207        .parent()
1208        .is_some_and(|main_root| main_root != repo.root());
1209    let all_target_another_thread = ready_threads.iter().all(|thread| {
1210        let actionable = thread
1211            .target_thread
1212            .as_deref()
1213            .map(|target| current_thread == Some(target) || opened_from_dedicated_checkout)
1214            .unwrap_or(true);
1215        !actionable
1216    });
1217    if all_target_another_thread {
1218        return (
1219            "clean".to_string(),
1220            "ready thread actions target another thread".to_string(),
1221        );
1222    }
1223    (
1224        "ready".to_string(),
1225        "ready thread actions are waiting to land".to_string(),
1226    )
1227}
1228
1229fn workflow_primary_action(repo: &Repository) -> Option<String> {
1230    let current_thread = repo.current_lane().ok().flatten();
1231    let opened_from_dedicated_checkout = repo
1232        .heddle_dir()
1233        .parent()
1234        .is_some_and(|main_root| main_root != repo.root());
1235    ThreadManager::new(repo.heddle_dir())
1236        .list()
1237        .ok()?
1238        .into_iter()
1239        .filter(|thread| thread.state == repo::ThreadState::Ready)
1240        .find_map(|mut thread| {
1241            let _ = refresh_thread_freshness(repo, &mut thread);
1242            let actionable = thread
1243                .target_thread
1244                .as_deref()
1245                .map(|target| {
1246                    current_thread.as_deref() == Some(target) || opened_from_dedicated_checkout
1247                })
1248                .unwrap_or(true);
1249            if !actionable {
1250                return None;
1251            }
1252            let advice = describe_thread_advice(&thread, false, 0, false);
1253            (!advice.recommended_action.trim().is_empty()).then_some(advice.recommended_action)
1254        })
1255}
1256
1257fn verification_check(
1258    name: &str,
1259    clean: bool,
1260    status: &str,
1261    summary: &str,
1262    recommended_action: Option<String>,
1263    recovery_commands: Vec<String>,
1264) -> VerificationCheck {
1265    VerificationCheck {
1266        name: name.to_string(),
1267        status: status.to_string(),
1268        clean,
1269        summary: summary.to_string(),
1270        recommended_action: recommended_action.clone(),
1271        recommended_action_template: recommended_action.as_deref().and_then(action_template),
1272        recovery_action_templates: action_templates(&recovery_commands),
1273        recovery_commands,
1274        details: BTreeMap::new(),
1275    }
1276}
1277
1278pub fn action_template(action: &str) -> Option<ActionTemplate> {
1279    let trimmed = action.trim();
1280    if trimmed.is_empty() {
1281        return None;
1282    }
1283    recommended_action_templates()
1284        .iter()
1285        .find(|template| template.action == trimmed)
1286        .cloned()
1287        .or_else(|| concrete_action_template(trimmed))
1288}
1289
1290pub fn action_templates(commands: &[String]) -> Vec<ActionTemplate> {
1291    commands
1292        .iter()
1293        .filter_map(|command| action_template(command))
1294        .collect()
1295}
1296
1297fn concrete_action_template(action: &str) -> Option<ActionTemplate> {
1298    if action.contains("...") || (action.contains('<') && action.contains('>')) {
1299        return None;
1300    }
1301    let argv = split_action(action).ok()?;
1302    matches!(argv.first().map(String::as_str), Some("heddle" | "git")).then(|| ActionTemplate {
1303        action: action.to_string(),
1304        argv_template: normalize_heddle_argv(argv),
1305        required_inputs: Vec::new(),
1306        agent_may_fill: false,
1307    })
1308}
1309
1310fn recommended_action_templates() -> Vec<ActionTemplate> {
1311    [
1312        (
1313            "heddle capture -m \"...\"",
1314            &["heddle", "capture", "-m", "<message>"][..],
1315            &["message"][..],
1316            true,
1317        ),
1318        (
1319            "heddle commit -m \"...\"",
1320            &["heddle", "commit", "-m", "<message>"][..],
1321            &["message"][..],
1322            true,
1323        ),
1324        ("heddle init", &["heddle", "init"][..], &[][..], false),
1325        (
1326            "heddle init --principal-name <name> --principal-email <email>",
1327            &[
1328                "heddle",
1329                "init",
1330                "--principal-name",
1331                "<name>",
1332                "--principal-email",
1333                "<email>",
1334            ][..],
1335            &["name", "email"][..],
1336            true,
1337        ),
1338        (
1339            "heddle ready -m \"...\"",
1340            &["heddle", "ready", "-m", "<message>"][..],
1341            &["message"][..],
1342            true,
1343        ),
1344        ("heddle status", &["heddle", "status"][..], &[][..], false),
1345        (
1346            "heddle thread switch <branch>",
1347            &["heddle", "switch", "<branch>"][..],
1348            &["branch"][..],
1349            false,
1350        ),
1351        ("heddle verify", &["heddle", "verify"][..], &[][..], false),
1352        ("heddle doctor", &["heddle", "doctor"][..], &[][..], false),
1353        (
1354            "heddle doctor schemas --output json",
1355            &["heddle", "doctor", "schemas", "--output", "json"][..],
1356            &[][..],
1357            false,
1358        ),
1359    ]
1360    .into_iter()
1361    .map(
1362        |(action, argv_template, required_inputs, agent_may_fill)| ActionTemplate {
1363            action: action.to_string(),
1364            argv_template: normalize_heddle_argv(
1365                argv_template.iter().map(|arg| (*arg).to_string()).collect(),
1366            ),
1367            required_inputs: required_inputs
1368                .iter()
1369                .map(|input| (*input).to_string())
1370                .collect(),
1371            agent_may_fill,
1372        },
1373    )
1374    .collect()
1375}
1376
1377fn normalize_heddle_argv(mut argv: Vec<String>) -> Vec<String> {
1378    if argv.first().is_some_and(|first| first == "heddle") {
1379        argv[0] = heddle_argv0();
1380    }
1381    argv
1382}
1383
1384fn heddle_argv0() -> String {
1385    match std::env::current_exe() {
1386        Ok(path) => {
1387            let file_name = path.file_name().and_then(|name| name.to_str());
1388            if matches!(file_name, Some("heddle") | Some("heddle.exe")) {
1389                path.display().to_string()
1390            } else {
1391                "heddle".to_string()
1392            }
1393        }
1394        Err(_) => "heddle".to_string(),
1395    }
1396}
1397
1398fn split_action(action: &str) -> std::result::Result<Vec<String>, String> {
1399    let mut args = Vec::new();
1400    let mut current = String::new();
1401    let mut chars = action.chars().peekable();
1402    let mut in_single_quote = false;
1403    let mut in_double_quote = false;
1404    while let Some(ch) = chars.next() {
1405        match (ch, in_single_quote, in_double_quote) {
1406            ('\'', false, false) => in_single_quote = true,
1407            ('\'', true, false) => in_single_quote = false,
1408            ('"', false, false) => in_double_quote = true,
1409            ('"', false, true) => in_double_quote = false,
1410            ('\\', false, _) => match chars.next() {
1411                Some(next) => current.push(next),
1412                None => current.push('\\'),
1413            },
1414            (ch, false, false) if ch.is_whitespace() => {
1415                if !current.is_empty() {
1416                    args.push(std::mem::take(&mut current));
1417                }
1418            }
1419            (ch, _, _) => current.push(ch),
1420        }
1421    }
1422    if in_single_quote || in_double_quote {
1423        return Err("unterminated quote".to_string());
1424    }
1425    if !current.is_empty() {
1426        args.push(current);
1427    }
1428    Ok(args)
1429}
1430
1431fn machine_contract_is_clean(coverage: &MachineContractCoverage) -> bool {
1432    if matches!(coverage.status.as_str(), "not_checked" | "not_applicable") {
1433        return true;
1434    }
1435    coverage.verified_scope_json_commands_without_schema == 0
1436        && coverage.verified_scope_mutating_commands_without_schema == 0
1437        && coverage.undocumented_schema_verbs_total == 0
1438        && coverage.unaccepted_opaque_schema_verbs_total == 0
1439}
1440
1441pub fn machine_contract_status(coverage: &MachineContractCoverage) -> &'static str {
1442    match coverage.status.as_str() {
1443        "not_checked" => "not_checked",
1444        "not_applicable" => "not_applicable",
1445        _ if machine_contract_is_clean(coverage) => "available",
1446        _ => "available_with_schema_gaps",
1447    }
1448}
1449
1450pub fn verify(ctx: &ExecutionContext, opts: VerifyOptions) -> Result<VerifyReport> {
1451    let fallback;
1452    let start = if let Some(start) = opts.start_path.as_deref() {
1453        start
1454    } else if let Some(start) = ctx.start_path() {
1455        start
1456    } else {
1457        fallback = std::env::current_dir().map_err(HeddleError::Io)?;
1458        fallback.as_path()
1459    };
1460
1461    // An injected Heddle repository already proves this is not the plain-Git
1462    // observe path — skip the Sley discover + `.heddle` probe that would only
1463    // return `None` after redundant work.
1464    let mut profile = VerifyProfile::default();
1465    let opened;
1466    let repo = if let Some(repo) = ctx.repo() {
1467        repo
1468    } else {
1469        let probe_start = Instant::now();
1470        let plain_git_probe = build_plain_git_verification_probe_with_machine_contract(
1471            start,
1472            &opts.machine_contract_input,
1473        )?;
1474        profile.plain_git_probe_ms = probe_start.elapsed().as_millis();
1475
1476        if let Some(probe) = plain_git_probe {
1477            return Ok(VerifyReport {
1478                output_kind: "verify",
1479                clean: probe.trust.verified,
1480                repository_label: repository_mode_label("plain-git", "git-only"),
1481                repository_context: None,
1482                trust: probe.trust,
1483                provenance: None,
1484                profile,
1485            });
1486        }
1487
1488        let repo_open_start = Instant::now();
1489        opened = Repository::open(start)?;
1490        profile.repo_open_ms = repo_open_start.elapsed().as_millis();
1491        &opened
1492    };
1493    let verification_start = Instant::now();
1494    let trust = build_repository_verification_state_with_machine_contract(
1495        repo,
1496        &opts.machine_contract_input,
1497    )?;
1498    let provenance = opts
1499        .provenance
1500        .then(|| crate::verify_repository_provenance(repo))
1501        .transpose()?;
1502    profile.verification_ms = verification_start.elapsed().as_millis();
1503    let presentation = repository_presentation(repo, None, None);
1504    Ok(VerifyReport {
1505        output_kind: "verify",
1506        clean: trust.verified && provenance.as_ref().is_none_or(|report| report.clean),
1507        repository_label: presentation.label,
1508        repository_context: presentation.context,
1509        trust,
1510        provenance,
1511        profile,
1512    })
1513}
1514
1515/// Human-facing repository mode label. JSON keeps the exact repository mode
1516/// values; text output uses product language instead of storage implementation
1517/// names.
1518pub fn repository_mode_label(capability: &str, storage_model: &str) -> String {
1519    if capability == "git-overlay" || storage_model == "git+heddle-sidecar" {
1520        "Git + Heddle".to_string()
1521    } else if capability == "plain-git" || storage_model == "git-only" {
1522        "Git repo (setup needed)".to_string()
1523    } else if capability == "native"
1524        || capability == "native-heddle"
1525        || storage_model == "heddle-native"
1526    {
1527        "Heddle native".to_string()
1528    } else {
1529        capability.to_string()
1530    }
1531}
1532
1533/// Presentation-only repository identity. This deliberately leaves
1534/// `Repository::capability_label()` untouched: an isolated checkout that shares
1535/// a Git-overlay object store is still technically opened through the native
1536/// Heddle storage path, but user-facing status should say what manages it.
1537pub fn repository_presentation(
1538    repo: &Repository,
1539    target_thread: Option<&str>,
1540    parent_thread: Option<&str>,
1541) -> RepositoryPresentation {
1542    if let Some(parent_root) = managed_git_overlay_parent_root(repo) {
1543        let thread = current_child_thread(repo);
1544        let target_thread = target_thread.map(ToString::to_string).or_else(|| {
1545            thread
1546                .as_ref()
1547                .and_then(|thread| thread.target_thread.clone())
1548        });
1549        let parent_thread = parent_thread.map(ToString::to_string).or_else(|| {
1550            thread
1551                .as_ref()
1552                .and_then(|thread| thread.parent_thread.clone())
1553        });
1554        return RepositoryPresentation {
1555            label: "Git + Heddle isolated checkout".to_string(),
1556            context: Some(RepositoryContextInfo {
1557                kind: "git-overlay-isolated-checkout".to_string(),
1558                parent_repository: Some(parent_root.display().to_string()),
1559                target_thread,
1560                parent_thread,
1561            }),
1562        };
1563    }
1564
1565    RepositoryPresentation {
1566        label: repository_mode_label(repo.capability_label(), repo.storage_model_label()),
1567        context: None,
1568    }
1569}
1570
1571fn managed_git_overlay_parent_root(repo: &Repository) -> Option<PathBuf> {
1572    let parent_root = repo.heddle_dir().parent()?;
1573    if paths_equal(parent_root, repo.root()) {
1574        return None;
1575    }
1576    parent_root
1577        .join(".git")
1578        .exists()
1579        .then(|| parent_root.to_path_buf())
1580}
1581
1582fn current_child_thread(repo: &Repository) -> Option<Thread> {
1583    let manager = ThreadManager::new(repo.heddle_dir());
1584    if let Ok(Some(thread)) = manager.find_by_execution_root(repo.root()) {
1585        return Some(thread);
1586    }
1587    let lane = repo.current_lane().ok().flatten()?;
1588    manager.find_by_thread(&lane).ok().flatten()
1589}
1590
1591fn paths_equal(left: &Path, right: &Path) -> bool {
1592    let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
1593    let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
1594    left == right
1595}
1596
1597pub fn dirty_path_count(status: &WorktreeStatus) -> usize {
1598    status.modified.len() + status.added.len() + status.deleted.len()
1599}
1600
1601/// Classifies the primary recommended setup action for plain-Git / import
1602/// onboarding guidance.
1603#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1604pub enum RepositorySetupActionKind {
1605    Init,
1606    Adopt,
1607    GitImport,
1608    Other,
1609}
1610
1611/// Human-facing setup guidance derived from Repository Verification State.
1612///
1613/// Owned by core so status, verify, doctor, and mutation refusal text cannot
1614/// drift on setup-line / effect wording.
1615#[derive(Debug, Clone, PartialEq, Eq)]
1616pub struct RepositorySetupGuidance {
1617    pub setup_line: String,
1618    pub effect: String,
1619}
1620
1621/// Classify a recommended action string into a setup-action kind.
1622pub fn repository_setup_action_kind(action: &str) -> RepositorySetupActionKind {
1623    if action == "heddle init" {
1624        RepositorySetupActionKind::Init
1625    } else if action.starts_with("heddle adopt") {
1626        RepositorySetupActionKind::Adopt
1627    } else if action.starts_with("heddle bridge git import") {
1628        RepositorySetupActionKind::GitImport
1629    } else {
1630        RepositorySetupActionKind::Other
1631    }
1632}
1633
1634/// Build setup guidance when verification reports `needs_init` / `needs_import`.
1635pub fn repository_setup_guidance(
1636    trust: &RepositoryVerificationState,
1637) -> Option<RepositorySetupGuidance> {
1638    if !matches!(trust.status.as_str(), "needs_init" | "needs_import") {
1639        return None;
1640    }
1641    let action = trust.recommended_action.trim();
1642    if action.is_empty() {
1643        return None;
1644    }
1645    let kind = repository_setup_action_kind(action);
1646    let setup_line = match kind {
1647        RepositorySetupActionKind::Init => {
1648            format!("Git repo detected; initialize Heddle with {action}")
1649        }
1650        RepositorySetupActionKind::Adopt => {
1651            format!("Git repo detected; connect this branch with {action}")
1652        }
1653        RepositorySetupActionKind::GitImport => {
1654            format!("Git history not imported; import it with {action}")
1655        }
1656        RepositorySetupActionKind::Other => {
1657            format!("Run {action} to clear the primary setup blocker")
1658        }
1659    };
1660    let worktree_tail = if trust.worktree_state == "clean" {
1661        "and the Git worktree stays clean"
1662    } else {
1663        "and existing Git worktree changes stay untouched"
1664    };
1665    let effect = match kind {
1666        RepositorySetupActionKind::Init => format!(
1667            ".heddle metadata will be created; Git commits stay in Git storage, {worktree_tail}."
1668        ),
1669        RepositorySetupActionKind::Adopt
1670            if trust.repository_mode == "plain-git" && !trust.heddle_initialized =>
1671        {
1672            format!(".heddle metadata will be created, Git history imported, {worktree_tail}.")
1673        }
1674        RepositorySetupActionKind::Adopt => {
1675            format!(".heddle metadata is present; adoption imports Git history {worktree_tail}.")
1676        }
1677        RepositorySetupActionKind::GitImport => {
1678            format!(".heddle metadata is present; Git history import runs {worktree_tail}.")
1679        }
1680        RepositorySetupActionKind::Other => {
1681            format!("The recommended setup command runs {worktree_tail}.")
1682        }
1683    };
1684    Some(RepositorySetupGuidance { setup_line, effect })
1685}
1686
1687#[cfg(test)]
1688mod open_amortization_tests {
1689    use super::*;
1690    use crate::ExecutionContext;
1691
1692    #[test]
1693    fn verify_uses_injected_repo_without_reopening_start_path() {
1694        let temp = tempfile::tempdir().expect("temp repo");
1695        Repository::init_default(temp.path()).expect("init repo");
1696        let repo = Repository::open(temp.path()).expect("open repo");
1697        // If verify re-opened from start_path it would fail — prove injection.
1698        let bogus = temp.path().join("not-a-repo-start");
1699        let ctx = ExecutionContext::builder()
1700            .start_path(&bogus)
1701            .repo(repo)
1702            .build();
1703
1704        let report = verify(&ctx, VerifyOptions::new().with_start_path(&bogus))
1705            .expect("verify with injected repo must not re-open start_path");
1706
1707        assert_eq!(report.output_kind, "verify");
1708        assert_eq!(
1709            report.profile.repo_open_ms, 0,
1710            "injected repo must report zero facade open cost"
1711        );
1712        assert_eq!(
1713            report.profile.plain_git_probe_ms, 0,
1714            "injected heddle repo must skip plain-git probe"
1715        );
1716        assert!(report.trust.heddle_initialized);
1717    }
1718}
1719
1720#[cfg(test)]
1721mod setup_guidance_tests {
1722    use super::*;
1723
1724    fn bare_verification_state(
1725        status: &str,
1726        recommended_action: &str,
1727    ) -> RepositoryVerificationState {
1728        RepositoryVerificationState {
1729            verified: false,
1730            status: status.to_string(),
1731            repository_mode: "plain-git".to_string(),
1732            heddle_initialized: false,
1733            git_branch: Some("main".to_string()),
1734            heddle_thread: None,
1735            worktree_dirty: false,
1736            worktree_state: "clean".to_string(),
1737            import_state: "needs_import".to_string(),
1738            mapping_state: "needs_import".to_string(),
1739            remote_drift: "not_checked".to_string(),
1740            active_operation: None,
1741            default_remote: None,
1742            clone_verification: "not_applicable".to_string(),
1743            machine_contract: "not_checked".to_string(),
1744            machine_contract_coverage: MachineContractCoverage::not_checked(),
1745            workflow_status: "not_checked".to_string(),
1746            workflow_summary: String::new(),
1747            summary: status.to_string(),
1748            recommended_action: recommended_action.to_string(),
1749            recommended_action_template: None,
1750            recovery_commands: vec![recommended_action.to_string()],
1751            recovery_action_templates: Vec::new(),
1752            checks: Vec::new(),
1753        }
1754    }
1755
1756    #[test]
1757    fn repository_setup_guidance_distinguishes_init_from_adopt() {
1758        let mut init = bare_verification_state("needs_init", "heddle init");
1759        init.import_state = "git_backed".to_string();
1760        init.mapping_state = "git_backed".to_string();
1761
1762        let guidance = repository_setup_guidance(&init).expect("init guidance");
1763        assert!(guidance.setup_line.contains("initialize Heddle"));
1764        assert!(guidance.setup_line.contains("heddle init"));
1765        assert!(guidance.effect.contains("Git commits stay in Git storage"));
1766
1767        let mut convert = bare_verification_state("needs_import", "heddle adopt --ref main");
1768        convert.repository_mode = "git-overlay".to_string();
1769        convert.heddle_initialized = true;
1770
1771        let guidance = repository_setup_guidance(&convert).expect("conversion guidance");
1772        assert!(
1773            guidance
1774                .setup_line
1775                .contains("connect this branch with heddle adopt --ref main")
1776        );
1777        assert!(guidance.effect.contains("adoption imports Git history"));
1778    }
1779
1780    #[test]
1781    fn repository_setup_guidance_skips_non_setup_statuses() {
1782        let state = bare_verification_state("dirty_worktree", "heddle capture -m \"...\"");
1783        assert!(repository_setup_guidance(&state).is_none());
1784    }
1785}