Skip to main content

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