1use std::{
5 collections::{BTreeMap, BTreeSet},
6 path::{Path, PathBuf},
7 time::Instant,
8};
9
10use ::objects::{HeddleError, error::Result, worktree::WorktreeStatus};
11use repo::{Repository, Thread, ThreadManager, describe_thread_advice, refresh_thread_freshness};
12use schemars::JsonSchema;
13use serde::{Serialize, Serializer};
14
15use crate::{
16 ExecutionContext, HeddleReport, MachineOutputKind, OutputDiscriminator, ReportContract,
17 schema_for_report,
18};
19
20use crate::status::{
21 GitOverlayHealth, build_git_overlay_health_with_worktree_status, default_remote_name,
22 git_default_remote_name_from_repo,
23};
24use crate::status::next_action::remote_tracking_status;
25use sley::{Repository as SleyRepository, ShortStatusOptions, StatusUntrackedMode, StreamControl};
26
27#[derive(Clone)]
28pub struct VerifyOptions {
29 pub start_path: Option<PathBuf>,
30}
31
32impl VerifyOptions {
33 pub fn new() -> Self {
34 Self { start_path: None }
35 }
36
37 pub fn with_start_path(mut self, start_path: impl Into<PathBuf>) -> Self {
38 self.start_path = Some(start_path.into());
39 self
40 }
41}
42
43impl Default for VerifyOptions {
44 fn default() -> Self {
45 Self::new()
46 }
47}
48
49#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
50pub struct VerifyReport {
51 pub output_kind: &'static str,
52 pub clean: bool,
53 pub repository_label: String,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub repository_context: Option<RepositoryContextInfo>,
56 #[serde(flatten)]
57 pub trust: RepositoryVerificationState,
58 #[serde(skip)]
59 #[schemars(skip)]
60 pub profile: VerifyProfile,
61}
62
63impl VerifyReport {
64 pub const CONTRACT: ReportContract = ReportContract {
65 schema_name: "verify",
66 machine_output_kind: MachineOutputKind::Json,
67 output_discriminator: Some(OutputDiscriminator {
68 field: "output_kind",
69 value: "verify",
70 }),
71 schema: schema_for_report::<VerifyReport>,
72 };
73}
74
75impl HeddleReport for VerifyReport {
76 const CONTRACT: ReportContract = VerifyReport::CONTRACT;
77}
78
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
80pub struct VerifyProfile {
81 pub plain_git_probe_ms: u128,
82 pub repo_open_ms: u128,
83 pub verification_ms: u128,
84}
85
86#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
87pub struct RepositoryContextInfo {
88 pub kind: String,
89 pub parent_repository: Option<String>,
90 pub target_thread: Option<String>,
91 pub parent_thread: Option<String>,
92}
93
94#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
95pub struct RepositoryPresentation {
96 pub label: String,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub context: Option<RepositoryContextInfo>,
99}
100
101#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
102pub struct PlainGitVerifyProbe {
103 pub trust: RepositoryVerificationState,
104}
105
106#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
107pub struct ActionTemplate {
108 pub action: String,
109 pub argv_template: Vec<String>,
110 pub required_inputs: Vec<String>,
111 pub agent_may_fill: bool,
118}
119
120#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
121pub struct RepositoryVerificationState {
122 #[serde(rename = "verified")]
123 pub verified: bool,
124 pub status: String,
125 pub repository_mode: String,
126 pub heddle_initialized: bool,
127 pub git_branch: Option<String>,
128 pub heddle_thread: Option<String>,
129 pub worktree_dirty: bool,
130 pub worktree_state: String,
131 pub import_state: String,
132 pub mapping_state: String,
133 pub remote_drift: String,
134 pub active_operation: Option<String>,
135 pub default_remote: Option<String>,
136 pub clone_verification: String,
137 pub machine_contract: String,
138 pub machine_contract_coverage: MachineContractCoverage,
139 pub workflow_status: String,
140 pub workflow_summary: String,
141 pub summary: String,
142 #[serde(serialize_with = "serialize_empty_action_as_null")]
143 #[schemars(with = "Option<String>")]
144 pub recommended_action: String,
145 pub recommended_action_template: Option<ActionTemplate>,
146 pub recovery_commands: Vec<String>,
147 pub recovery_action_templates: Vec<ActionTemplate>,
148 pub checks: Vec<VerificationCheck>,
149}
150
151pub fn serialize_empty_action_as_null<S>(
152 action: &String,
153 serializer: S,
154) -> std::result::Result<S::Ok, S::Error>
155where
156 S: Serializer,
157{
158 if action.is_empty() {
159 serializer.serialize_none()
160 } else {
161 serializer.serialize_some(action)
162 }
163}
164
165#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
166pub struct MachineContractCoverage {
167 pub status: String,
168 #[serde(rename = "verified_scope")]
169 pub verified_scope: String,
170 pub advanced_scope: String,
171 pub summary: String,
172 pub catalog_commands_total: usize,
173 pub catalog_mutating_commands_total: usize,
174 pub json_commands_total: usize,
175 pub json_mutating_commands_total: usize,
176 pub json_commands_with_schema: usize,
177 pub json_commands_with_accepted_opaque_schema: usize,
178 pub json_commands_without_schema: usize,
179 #[serde(rename = "verified_scope_json_commands_total")]
180 pub verified_scope_json_commands_total: usize,
181 #[serde(rename = "verified_scope_json_commands_with_schema")]
182 pub verified_scope_json_commands_with_schema: usize,
183 #[serde(rename = "verified_scope_json_commands_with_accepted_opaque_schema")]
184 pub verified_scope_json_commands_with_accepted_opaque_schema: usize,
185 #[serde(rename = "verified_scope_json_commands_without_schema")]
186 pub verified_scope_json_commands_without_schema: usize,
187 pub advanced_scope_json_commands_total: usize,
188 pub advanced_scope_json_commands_with_accepted_opaque_schema: usize,
189 pub mutating_commands_total: usize,
190 pub mutating_commands_with_schema: usize,
191 pub mutating_commands_with_accepted_opaque_schema: usize,
192 pub mutating_commands_without_schema: usize,
193 #[serde(rename = "verified_scope_mutating_commands_total")]
194 pub verified_scope_mutating_commands_total: usize,
195 #[serde(rename = "verified_scope_mutating_commands_with_schema")]
196 pub verified_scope_mutating_commands_with_schema: usize,
197 #[serde(rename = "verified_scope_mutating_commands_with_accepted_opaque_schema")]
198 pub verified_scope_mutating_commands_with_accepted_opaque_schema: usize,
199 #[serde(rename = "verified_scope_mutating_commands_without_schema")]
200 pub verified_scope_mutating_commands_without_schema: usize,
201 pub advanced_scope_mutating_commands_total: usize,
202 pub advanced_scope_mutating_commands_with_accepted_opaque_schema: usize,
203 pub schema_verbs_total: usize,
204 pub documented_schema_verbs_total: usize,
205 pub undocumented_schema_verbs_total: usize,
206 pub opaque_schema_verbs_total: usize,
207 pub accepted_opaque_schema_verbs_total: usize,
208 pub unaccepted_opaque_schema_verbs_total: usize,
209 pub supports_op_id_total: usize,
210 pub jsonl_commands_total: usize,
211 pub missing_schema_examples: Vec<String>,
212 pub missing_mutating_schema_examples: Vec<String>,
213 pub verified_scope_missing_schema_examples: Vec<String>,
214 pub verified_scope_accepted_opaque_schema_examples: Vec<String>,
215 pub advanced_scope_accepted_opaque_schema_examples: Vec<String>,
216 pub accepted_opaque_schema_examples: Vec<String>,
217 pub unaccepted_opaque_schema_examples: Vec<String>,
218 pub undocumented_schema_examples: Vec<String>,
219}
220
221#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
222pub struct VerificationCheck {
223 pub name: String,
224 pub status: String,
225 pub clean: bool,
226 pub summary: String,
227 pub recommended_action: Option<String>,
228 pub recommended_action_template: Option<ActionTemplate>,
229 pub recovery_commands: Vec<String>,
230 pub recovery_action_templates: Vec<ActionTemplate>,
231 #[serde(default)]
232 pub details: BTreeMap<String, String>,
233}
234
235pub fn build_plain_git_verification_probe(start: &Path) -> Result<Option<PlainGitVerifyProbe>> {
236 let git_repo = match SleyRepository::discover(start) {
237 Ok(repo) => repo,
238 Err(_) => return Ok(None),
239 };
240 let Some(workdir) = git_repo.workdir() else {
241 return Ok(None);
242 };
243 let root = workdir
244 .canonicalize()
245 .unwrap_or_else(|_| workdir.to_path_buf());
246 if root.join(".heddle").exists() {
247 return Ok(None);
248 }
249 let git_branch = git_repo
250 .head()
251 .ok()
252 .and_then(|head| head.branch_name().map(ToString::to_string));
253 let default_remote = git_default_remote_name_from_repo(&git_repo);
254 let changes_dirty = plain_git_has_changes(&git_repo)?;
255 let machine_contract_coverage = machine_contract_coverage();
256 let setup_action = "heddle init".to_string();
257 let recovery_commands = vec![setup_action.clone()];
258 let trust = RepositoryVerificationState {
259 verified: false,
260 status: "needs_init".to_string(),
261 repository_mode: "plain-git".to_string(),
262 heddle_initialized: false,
263 git_branch,
264 heddle_thread: None,
265 worktree_dirty: changes_dirty,
266 worktree_state: if changes_dirty { "dirty" } else { "clean" }.to_string(),
267 import_state: "git_backed".to_string(),
268 mapping_state: "git_backed".to_string(),
269 remote_drift: "unknown".to_string(),
270 active_operation: None,
271 default_remote,
272 clone_verification: "not_applicable".to_string(),
273 machine_contract: machine_contract_status(&machine_contract_coverage).to_string(),
274 machine_contract_coverage,
275 workflow_status: "not_checked".to_string(),
276 workflow_summary: "workflow readiness is checked after Heddle initialization".to_string(),
277 summary: "Git repository has not been initialized for Heddle".to_string(),
278 recommended_action: setup_action.clone(),
279 recommended_action_template: action_template(&setup_action),
280 recovery_commands: recovery_commands.clone(),
281 recovery_action_templates: action_templates(&recovery_commands),
282 checks: vec![
283 verification_check("Git", true, "present", "plain Git repository found", None, Vec::new()),
284 verification_check(
285 "Heddle",
286 false,
287 "needs_init",
288 "Heddle data is not initialized",
289 Some(setup_action),
290 recovery_commands,
291 ),
292 verification_check(
293 "Mapping",
294 false,
295 "not_checked",
296 "mapping is checked after Heddle initialization",
297 None,
298 Vec::new(),
299 ),
300 verification_check(
301 "Worktree",
302 false,
303 "not_checked",
304 "worktree agreement is checked after Heddle initialization",
305 None,
306 Vec::new(),
307 ),
308 verification_check(
309 "Remote",
310 false,
311 "not_checked",
312 "remote drift is checked after Heddle initialization",
313 None,
314 Vec::new(),
315 ),
316 verification_check(
317 "Operation",
318 false,
319 "not_checked",
320 "operation state is checked after Heddle initialization",
321 None,
322 Vec::new(),
323 ),
324 verification_check(
325 "Workflow",
326 false,
327 "not_checked",
328 "workflow readiness is checked after Heddle initialization",
329 None,
330 Vec::new(),
331 ),
332 verification_check(
333 "Machine contract",
334 false,
335 "not_checked",
336 "runtime schema coverage is checked after Heddle initialization",
337 None,
338 Vec::new(),
339 ),
340 verification_check(
341 "Clone",
342 true,
343 "not_applicable",
344 "clone verification is not applicable before Heddle initialization",
345 None,
346 Vec::new(),
347 ),
348 ],
349 };
350 Ok(Some(PlainGitVerifyProbe { trust }))
351}
352
353fn plain_git_has_changes(git_repo: &SleyRepository) -> Result<bool> {
354 let mut dirty = false;
355 git_repo
356 .stream_short_status_with_options(
357 ShortStatusOptions {
358 untracked_mode: StatusUntrackedMode::All,
359 ..ShortStatusOptions::default()
360 },
361 |entry| {
362 if entry.index != b' ' || entry.worktree != b' ' {
363 dirty = true;
364 return Ok(StreamControl::Stop);
365 }
366 Ok(StreamControl::Continue)
367 },
368 )
369 .map_err(|err| HeddleError::Config(format!("failed to inspect Git status: {err}")))?;
370 Ok(dirty)
371}
372
373pub fn build_repository_verification_state(repo: &Repository) -> Result<RepositoryVerificationState> {
374 let worktree_status = if repo.capability() == repo::RepositoryCapability::GitOverlay {
375 repo.git_overlay_worktree_status()
376 } else {
377 native_worktree_status(repo)
378 };
379 let health = build_git_overlay_health_with_worktree_status(repo, &worktree_status);
380 Ok(build_repository_verification_state_with_worktree_status(
381 repo,
382 health,
383 &worktree_status,
384 ))
385}
386
387fn native_worktree_status(repo: &Repository) -> Result<Option<WorktreeStatus>> {
388 let Some(state) = repo.current_state()? else {
389 return Ok(Some(WorktreeStatus::default()));
390 };
391 let tree = repo.require_tree(&state.tree)?;
392 repo.compare_worktree_cached(&tree).map(Some)
393}
394
395pub fn build_repository_verification_state_with_worktree_status(
396 repo: &Repository,
397 health: GitOverlayHealth,
398 worktree_status: &Result<Option<WorktreeStatus>>,
399) -> RepositoryVerificationState {
400 let git_branch = repo.git_overlay_current_branch().ok().flatten();
401 let heddle_thread = repo.current_lane().ok().flatten();
402 let active_operation = repo.operation_status().ok().flatten().map(|operation| {
403 format!("{} {} ({})", operation.scope, operation.kind, operation.state)
404 });
405 let remote_drift = repo
406 .git_remote_tracking_status()
407 .ok()
408 .flatten()
409 .map(|remote| remote_tracking_status(&remote).to_string())
410 .unwrap_or_else(|| "clean".to_string());
411 let is_git_overlay = repo.capability() == repo::RepositoryCapability::GitOverlay;
412 let import_state = health
413 .checks
414 .iter()
415 .find(|check| check.name == "import" && check.status != "clean")
416 .or_else(|| health.checks.iter().find(|check| check.name == "import"))
417 .map(|check| check.status.clone())
418 .unwrap_or_else(|| {
419 if is_git_overlay {
420 "git_backed".to_string()
421 } else {
422 "clean".to_string()
423 }
424 });
425 let mapping_state = health
426 .checks
427 .iter()
428 .find(|check| {
429 matches!(check.name.as_str(), "head_mapping" | "tag_mapping")
430 && !verification_status_is_clean(&check.status)
431 })
432 .or_else(|| {
433 health
434 .checks
435 .iter()
436 .find(|check| check.name == "head_mapping")
437 })
438 .map(|check| check.status.clone())
439 .unwrap_or_else(|| {
440 if is_git_overlay {
441 "git_backed".to_string()
442 } else {
443 "clean".to_string()
444 }
445 });
446 let git_worktree_dirty = matches!(
447 worktree_status,
448 Ok(Some(status)) if !status.is_clean()
449 );
450 let worktree_dirty = git_worktree_dirty
451 || health
452 .checks
453 .iter()
454 .any(|check| {
455 matches!(check.name.as_str(), "worktree" | "heddle_worktree")
456 && check.status != "clean"
457 });
458 let machine_contract_coverage = machine_contract_coverage();
459 let machine_contract_clean = machine_contract_is_clean(&machine_contract_coverage);
460 let mut recovery_commands = health.recovery_commands.clone();
461 let remote_action = remote_sync_action(&health);
462 let (workflow_status, workflow_summary) = workflow_status(repo, heddle_thread.as_deref());
463 let workflow_action = if health.clean && workflow_status == "ready" {
464 workflow_primary_action(repo)
465 } else {
466 None
467 };
468 if health.clean && !machine_contract_clean {
469 recovery_commands.push("heddle doctor schemas --output json".to_string());
470 }
471 let recommended_action = if health.clean {
472 if !machine_contract_clean {
473 "heddle doctor schemas --output json".to_string()
474 } else {
475 workflow_action
476 .clone()
477 .or_else(|| remote_action.clone())
478 .unwrap_or_default()
479 }
480 } else {
481 recovery_commands.first().cloned().unwrap_or_default()
482 };
483 let checks = verification_checks_from_health(
484 &health,
485 &machine_contract_coverage,
486 is_git_overlay,
487 &workflow_status,
488 &workflow_summary,
489 );
490 RepositoryVerificationState {
491 verified: health.clean && machine_contract_clean,
492 status: if health.clean && !machine_contract_clean {
493 "machine_contract_gaps".to_string()
494 } else {
495 health.status.clone()
496 },
497 repository_mode: repo.capability_label().to_string(),
498 heddle_initialized: true,
499 git_branch,
500 heddle_thread,
501 worktree_dirty,
502 worktree_state: if worktree_dirty { "dirty" } else { "clean" }.to_string(),
503 import_state,
504 mapping_state,
505 remote_drift,
506 active_operation,
507 default_remote: default_remote_name(repo),
508 clone_verification: if repo.capability() == repo::RepositoryCapability::GitOverlay {
509 if health.clean {
510 "verified"
511 } else if matches!(health.status.as_str(), "dirty_worktree" | "needs_checkpoint") {
512 "not_checked"
513 } else {
514 "blocked"
515 }
516 } else {
517 "not_applicable"
518 }
519 .to_string(),
520 machine_contract: machine_contract_status(&machine_contract_coverage).to_string(),
521 machine_contract_coverage,
522 workflow_status,
523 workflow_summary,
524 summary: health.summary,
525 recommended_action: recommended_action.clone(),
526 recommended_action_template: action_template(&recommended_action),
527 recovery_commands: recovery_commands.clone(),
528 recovery_action_templates: action_templates(&recovery_commands),
529 checks,
530 }
531}
532
533fn verification_checks_from_health(
534 health: &GitOverlayHealth,
535 coverage: &MachineContractCoverage,
536 is_git_overlay: bool,
537 workflow_status: &str,
538 workflow_summary: &str,
539) -> Vec<VerificationCheck> {
540 let mut checks = vec![
541 git_verification_check(is_git_overlay),
542 verification_check(
543 "Heddle",
544 true,
545 "clean",
546 "Heddle data is initialized",
547 None,
548 Vec::new(),
549 ),
550 mapping_verification_check(health, is_git_overlay),
551 worktree_verification_check(health),
552 remote_verification_check(health),
553 operation_verification_check(health),
554 workflow_verification_check(health, workflow_status, workflow_summary),
555 ];
556 checks.push(machine_contract_verification_check(coverage));
557 checks.push(clone_verification_check(health, is_git_overlay));
558 checks
559}
560
561fn machine_contract_verification_check(coverage: &MachineContractCoverage) -> VerificationCheck {
562 let mut details = BTreeMap::new();
563 details.insert("coverage_status".to_string(), coverage.status.clone());
564 details.insert("coverage_summary".to_string(), coverage.summary.clone());
565 details.insert("verified_scope".to_string(), coverage.verified_scope.clone());
566 details.insert("advanced_scope".to_string(), coverage.advanced_scope.clone());
567 details.insert(
568 "catalog_commands_total".to_string(),
569 coverage.catalog_commands_total.to_string(),
570 );
571 details.insert(
572 "json_commands_total".to_string(),
573 coverage.json_commands_total.to_string(),
574 );
575 details.insert(
576 "json_commands_with_schema".to_string(),
577 coverage.json_commands_with_schema.to_string(),
578 );
579 details.insert(
580 "json_commands_without_schema".to_string(),
581 coverage.json_commands_without_schema.to_string(),
582 );
583 details.insert(
584 "json_commands_with_accepted_opaque_schema".to_string(),
585 coverage
586 .json_commands_with_accepted_opaque_schema
587 .to_string(),
588 );
589 details.insert(
590 "verified_scope_json_commands_total".to_string(),
591 coverage.verified_scope_json_commands_total.to_string(),
592 );
593 let mut check = verification_check(
594 "Machine contract",
595 machine_contract_is_clean(coverage),
596 machine_contract_status(coverage),
597 &coverage.summary,
598 (!machine_contract_is_clean(coverage))
599 .then(|| "heddle doctor schemas --output json".to_string()),
600 if machine_contract_is_clean(coverage) {
601 Vec::new()
602 } else {
603 vec!["heddle doctor schemas --output json".to_string()]
604 },
605 );
606 check.details = details;
607 check
608}
609
610fn git_verification_check(is_git_overlay: bool) -> VerificationCheck {
611 if is_git_overlay {
612 verification_check(
613 "Git",
614 true,
615 "clean",
616 "Git overlay repository is present",
617 None,
618 Vec::new(),
619 )
620 } else {
621 verification_check(
622 "Git",
623 true,
624 "not_applicable",
625 "Heddle-native repository is running in non-overlay mode",
626 None,
627 Vec::new(),
628 )
629 }
630}
631
632fn mapping_verification_check(
633 health: &GitOverlayHealth,
634 is_git_overlay: bool,
635) -> VerificationCheck {
636 if !is_git_overlay {
637 return verification_check(
638 "Mapping",
639 true,
640 "not_applicable",
641 "native Heddle refs do not require Git-overlay mapping",
642 None,
643 Vec::new(),
644 );
645 }
646 if let Some(check) = health.checks.iter().find(|check| {
647 check.name == "head_mapping" && !verification_status_is_clean(&check.status)
648 }) {
649 return verification_check_from_health("Mapping", check, health);
650 }
651 if let Some(check) = find_health_check(health, "import")
652 && check.status != "clean"
653 {
654 return verification_check_from_health("Mapping", check, health);
655 }
656 if let Some(check) = find_health_check(health, "tag_mapping")
657 && check.status != "clean"
658 {
659 return verification_check_from_health("Mapping", check, health);
660 }
661 if let Some(check) = find_health_check(health, "head_mapping") {
662 if check.status == "git_backed" && health.status == "dirty_worktree" {
663 return verification_check(
664 "Mapping",
665 true,
666 "clean",
667 "Git-backed branch mapping is not blocking verification",
668 None,
669 Vec::new(),
670 );
671 }
672 return verification_check_from_health("Mapping", check, health);
673 }
674 verification_check(
675 "Mapping",
676 true,
677 "clean",
678 "Git branch tips map to imported Heddle state",
679 None,
680 Vec::new(),
681 )
682}
683
684fn worktree_verification_check(health: &GitOverlayHealth) -> VerificationCheck {
685 for name in ["worktree", "heddle_worktree"] {
686 if let Some(check) = find_health_check(health, name)
687 && check.status != "clean"
688 {
689 return verification_check_from_health("Worktree", check, health);
690 }
691 }
692 for name in ["worktree", "heddle_worktree"] {
693 if let Some(check) = find_health_check(health, name) {
694 return verification_check_from_health("Worktree", check, health);
695 }
696 }
697 if !health.clean {
698 return verification_check(
699 "Worktree",
700 false,
701 "not_checked",
702 "worktree agreement is checked after the primary verification blocker is resolved",
703 health.recovery_commands.first().cloned(),
704 health.recovery_commands.clone(),
705 );
706 }
707 verification_check(
708 "Worktree",
709 true,
710 "clean",
711 "worktree has no uncommitted Git/Heddle disagreement",
712 None,
713 Vec::new(),
714 )
715}
716
717fn remote_verification_check(health: &GitOverlayHealth) -> VerificationCheck {
718 if let Some(check) = find_health_check(health, "remote_tracking") {
719 if matches!(check.status.as_str(), "remote_ahead" | "remote_untracked") {
720 let mut remote_check = verification_check(
721 "Remote",
722 true,
723 &check.status,
724 &check.summary,
725 remote_sync_action(health),
726 Vec::new(),
727 );
728 remote_check.details = check.details.clone();
729 return remote_check;
730 }
731 return verification_check_from_health("Remote", check, health);
732 }
733 verification_check(
734 "Remote",
735 true,
736 "clean",
737 "remote tracking has no blocking drift",
738 None,
739 Vec::new(),
740 )
741}
742
743fn operation_verification_check(health: &GitOverlayHealth) -> VerificationCheck {
744 if let Some(check) = find_health_check(health, "operation") {
745 return verification_check_from_health("Operation", check, health);
746 }
747 verification_check(
748 "Operation",
749 true,
750 "clean",
751 "no Git or Heddle operation in progress",
752 None,
753 Vec::new(),
754 )
755}
756
757fn workflow_verification_check(
758 health: &GitOverlayHealth,
759 workflow_status: &str,
760 workflow_summary: &str,
761) -> VerificationCheck {
762 if let Some(check) = find_health_check(health, "thread_integration_metadata")
763 && check.status != "clean"
764 {
765 return verification_check_from_health("Workflow", check, health);
766 }
767 if !health.clean {
768 return verification_check(
769 "Workflow",
770 false,
771 "blocked",
772 "workflow readiness is checked after the primary verification blocker is resolved",
773 health.recovery_commands.first().cloned(),
774 health.recovery_commands.clone(),
775 );
776 }
777 verification_check(
778 "Workflow",
779 true,
780 workflow_status,
781 workflow_summary,
782 None,
783 Vec::new(),
784 )
785}
786
787fn clone_verification_check(
788 health: &GitOverlayHealth,
789 is_git_overlay: bool,
790) -> VerificationCheck {
791 if !is_git_overlay {
792 return verification_check(
793 "Clone",
794 true,
795 "not_applicable",
796 "native Heddle state is the checkout authority",
797 None,
798 Vec::new(),
799 );
800 }
801 if health.clean {
802 return verification_check(
803 "Clone",
804 true,
805 "verified",
806 "Git checkout and Heddle mapping agree",
807 None,
808 Vec::new(),
809 );
810 }
811 if matches!(health.status.as_str(), "dirty_worktree" | "needs_checkpoint") {
812 return verification_check(
813 "Clone",
814 true,
815 "not_checked",
816 "clone verification waits for a clean worktree",
817 None,
818 Vec::new(),
819 );
820 }
821 verification_check(
822 "Clone",
823 false,
824 "blocked",
825 "clone verification is blocked until verification checks agree",
826 health.recovery_commands.first().cloned(),
827 health.recovery_commands.clone(),
828 )
829}
830
831fn verification_check_from_health(
832 name: &str,
833 health_check: &crate::status::GitOverlayHealthCheck,
834 health: &GitOverlayHealth,
835) -> VerificationCheck {
836 let recommended_action = (!verification_status_is_clean(&health_check.status))
837 .then(|| health.recovery_commands.first().cloned())
838 .flatten();
839 let recovery_commands = if recommended_action.is_some() {
840 health.recovery_commands.clone()
841 } else {
842 Vec::new()
843 };
844 let mut check = verification_check(
845 name,
846 verification_status_is_clean(&health_check.status),
847 &health_check.status,
848 &health_check.summary,
849 recommended_action,
850 recovery_commands,
851 );
852 check.details = health_check.details.clone();
853 check
854}
855
856fn remote_sync_action(health: &GitOverlayHealth) -> Option<String> {
857 find_health_check(health, "remote_tracking").and_then(|check| {
858 matches!(check.status.as_str(), "remote_ahead" | "remote_untracked")
859 .then(|| "heddle push".to_string())
860 })
861}
862
863fn find_health_check<'a>(
864 health: &'a GitOverlayHealth,
865 name: &str,
866) -> Option<&'a crate::status::GitOverlayHealthCheck> {
867 health.checks.iter().find(|check| check.name == name)
868}
869
870fn verification_status_is_clean(status: &str) -> bool {
871 matches!(
872 status,
873 "clean"
874 | "available"
875 | "git_backed"
876 | "not_applicable"
877 | "verified"
878 | "remote_ahead"
879 | "remote_untracked"
880 )
881}
882
883fn workflow_status(repo: &Repository, current_thread: Option<&str>) -> (String, String) {
884 let ready_threads = ThreadManager::new(repo.heddle_dir())
885 .list()
886 .unwrap_or_default()
887 .into_iter()
888 .filter(|thread| thread.state == repo::ThreadState::Ready)
889 .collect::<Vec<_>>();
890 if ready_threads.is_empty() {
891 return (
892 "clean".to_string(),
893 "no ready thread actions require attention".to_string(),
894 );
895 }
896 if ready_threads.iter().all(|thread| {
897 thread
898 .target_thread
899 .as_deref()
900 .zip(current_thread)
901 .is_some_and(|(target, current)| target != current)
902 }) {
903 return (
904 "clean".to_string(),
905 "ready thread actions target another thread".to_string(),
906 );
907 }
908 (
909 "ready".to_string(),
910 "ready thread actions are waiting to land".to_string(),
911 )
912}
913
914fn workflow_primary_action(repo: &Repository) -> Option<String> {
915 let current_thread = repo.current_lane().ok().flatten();
916 let opened_from_dedicated_checkout = repo
917 .heddle_dir()
918 .parent()
919 .is_some_and(|main_root| main_root != repo.root());
920 ThreadManager::new(repo.heddle_dir())
921 .list()
922 .ok()?
923 .into_iter()
924 .filter(|thread| thread.state == repo::ThreadState::Ready)
925 .find_map(|mut thread| {
926 let _ = refresh_thread_freshness(repo, &mut thread);
927 let actionable = thread
928 .target_thread
929 .as_deref()
930 .map(|target| {
931 current_thread.as_deref() == Some(target) || opened_from_dedicated_checkout
932 })
933 .unwrap_or(true);
934 if !actionable {
935 return None;
936 }
937 let advice = describe_thread_advice(&thread, false, 0, false);
938 (!advice.recommended_action.trim().is_empty()).then_some(advice.recommended_action)
939 })
940}
941
942fn verification_check(
943 name: &str,
944 clean: bool,
945 status: &str,
946 summary: &str,
947 recommended_action: Option<String>,
948 recovery_commands: Vec<String>,
949) -> VerificationCheck {
950 VerificationCheck {
951 name: name.to_string(),
952 status: status.to_string(),
953 clean,
954 summary: summary.to_string(),
955 recommended_action: recommended_action.clone(),
956 recommended_action_template: recommended_action
957 .as_deref()
958 .and_then(action_template),
959 recovery_action_templates: action_templates(&recovery_commands),
960 recovery_commands,
961 details: BTreeMap::new(),
962 }
963}
964
965pub fn action_template(action: &str) -> Option<ActionTemplate> {
966 let trimmed = action.trim();
967 if trimmed.is_empty() {
968 return None;
969 }
970 recommended_action_templates()
971 .iter()
972 .find(|template| template.action == trimmed)
973 .cloned()
974 .or_else(|| concrete_action_template(trimmed))
975}
976
977pub fn action_templates(commands: &[String]) -> Vec<ActionTemplate> {
978 commands
979 .iter()
980 .filter_map(|command| action_template(command))
981 .collect()
982}
983
984fn concrete_action_template(action: &str) -> Option<ActionTemplate> {
985 if action.contains("...") || (action.contains('<') && action.contains('>')) {
986 return None;
987 }
988 let argv = split_action(action).ok()?;
989 (argv.first().map(String::as_str) == Some("heddle")).then(|| ActionTemplate {
990 action: action.to_string(),
991 argv_template: normalize_heddle_argv(argv),
992 required_inputs: Vec::new(),
993 agent_may_fill: false,
994 })
995}
996
997fn recommended_action_templates() -> Vec<ActionTemplate> {
998 [
999 ("heddle capture -m \"...\"", &["heddle", "capture", "-m", "<message>"][..], &["message"][..], true),
1000 ("heddle checkpoint -m \"...\"", &["heddle", "checkpoint", "-m", "<message>"][..], &["message"][..], true),
1001 ("heddle commit -m \"...\"", &["heddle", "commit", "-m", "<message>"][..], &["message"][..], true),
1002 ("heddle commit --all -m \"...\"", &["heddle", "commit", "--all", "-m", "<message>"][..], &["message"][..], true),
1003 ("heddle init", &["heddle", "init"][..], &[][..], false),
1004 ("heddle init --principal-name <name> --principal-email <email>", &["heddle", "init", "--principal-name", "<name>", "--principal-email", "<email>"][..], &["name", "email"][..], true),
1005 ("heddle ready -m \"...\"", &["heddle", "ready", "-m", "<message>"][..], &["message"][..], true),
1006 ("heddle status", &["heddle", "status"][..], &[][..], false),
1007 ("heddle switch <branch>", &["heddle", "switch", "<branch>"][..], &["branch"][..], false),
1008 ("heddle verify", &["heddle", "verify"][..], &[][..], false),
1009 ("heddle diagnose", &["heddle", "diagnose"][..], &[][..], false),
1010 ("heddle doctor schemas --output json", &["heddle", "doctor", "schemas", "--output", "json"][..], &[][..], false),
1011 ]
1012 .into_iter()
1013 .map(|(action, argv_template, required_inputs, agent_may_fill)| ActionTemplate {
1014 action: action.to_string(),
1015 argv_template: normalize_heddle_argv(
1016 argv_template.iter().map(|arg| (*arg).to_string()).collect(),
1017 ),
1018 required_inputs: required_inputs
1019 .iter()
1020 .map(|input| (*input).to_string())
1021 .collect(),
1022 agent_may_fill,
1023 })
1024 .collect()
1025}
1026
1027fn normalize_heddle_argv(mut argv: Vec<String>) -> Vec<String> {
1028 if argv.first().is_some_and(|first| first == "heddle") {
1029 argv[0] = heddle_argv0();
1030 }
1031 argv
1032}
1033
1034fn heddle_argv0() -> String {
1035 match std::env::current_exe() {
1036 Ok(path) => {
1037 let file_name = path.file_name().and_then(|name| name.to_str());
1038 if matches!(file_name, Some("heddle") | Some("heddle.exe")) {
1039 path.display().to_string()
1040 } else {
1041 "heddle".to_string()
1042 }
1043 }
1044 Err(_) => "heddle".to_string(),
1045 }
1046}
1047
1048fn split_action(action: &str) -> std::result::Result<Vec<String>, String> {
1049 let mut args = Vec::new();
1050 let mut current = String::new();
1051 let mut chars = action.chars().peekable();
1052 let mut in_single_quote = false;
1053 let mut in_double_quote = false;
1054 while let Some(ch) = chars.next() {
1055 match (ch, in_single_quote, in_double_quote) {
1056 ('\'', false, false) => in_single_quote = true,
1057 ('\'', true, false) => in_single_quote = false,
1058 ('"', false, false) => in_double_quote = true,
1059 ('"', false, true) => in_double_quote = false,
1060 ('\\', false, _) => match chars.next() {
1061 Some(next) => current.push(next),
1062 None => current.push('\\'),
1063 },
1064 (ch, false, false) if ch.is_whitespace() => {
1065 if !current.is_empty() {
1066 args.push(std::mem::take(&mut current));
1067 }
1068 }
1069 (ch, _, _) => current.push(ch),
1070 }
1071 }
1072 if in_single_quote || in_double_quote {
1073 return Err("unterminated quote".to_string());
1074 }
1075 if !current.is_empty() {
1076 args.push(current);
1077 }
1078 Ok(args)
1079}
1080
1081pub fn machine_contract_coverage() -> MachineContractCoverage {
1082 let schema_verbs = BTreeSet::from([
1083 "status",
1084 "verify",
1085 "diff",
1086 "fsck",
1087 "query",
1088 "error",
1089 ]);
1090 MachineContractCoverage {
1091 status: "available".to_string(),
1092 verified_scope: "everyday_and_agent".to_string(),
1093 advanced_scope: "advanced_internal_admin".to_string(),
1094 summary: "core status/verify reports have concrete schemas".to_string(),
1095 catalog_commands_total: 3,
1096 catalog_mutating_commands_total: 0,
1097 json_commands_total: 3,
1098 json_mutating_commands_total: 0,
1099 json_commands_with_schema: 2,
1100 json_commands_with_accepted_opaque_schema: 1,
1101 json_commands_without_schema: 0,
1102 verified_scope_json_commands_total: 2,
1103 verified_scope_json_commands_with_schema: 2,
1104 verified_scope_json_commands_with_accepted_opaque_schema: 0,
1105 verified_scope_json_commands_without_schema: 0,
1106 advanced_scope_json_commands_total: 1,
1107 advanced_scope_json_commands_with_accepted_opaque_schema: 1,
1108 mutating_commands_total: 0,
1109 mutating_commands_with_schema: 0,
1110 mutating_commands_with_accepted_opaque_schema: 0,
1111 mutating_commands_without_schema: 0,
1112 verified_scope_mutating_commands_total: 0,
1113 verified_scope_mutating_commands_with_schema: 0,
1114 verified_scope_mutating_commands_with_accepted_opaque_schema: 0,
1115 verified_scope_mutating_commands_without_schema: 0,
1116 advanced_scope_mutating_commands_total: 0,
1117 advanced_scope_mutating_commands_with_accepted_opaque_schema: 0,
1118 schema_verbs_total: schema_verbs.len(),
1119 documented_schema_verbs_total: schema_verbs.len(),
1120 undocumented_schema_verbs_total: 0,
1121 opaque_schema_verbs_total: 1,
1122 accepted_opaque_schema_verbs_total: 1,
1123 unaccepted_opaque_schema_verbs_total: 0,
1124 supports_op_id_total: 1,
1125 jsonl_commands_total: 0,
1126 missing_schema_examples: Vec::new(),
1127 missing_mutating_schema_examples: Vec::new(),
1128 verified_scope_missing_schema_examples: Vec::new(),
1129 verified_scope_accepted_opaque_schema_examples: Vec::new(),
1130 advanced_scope_accepted_opaque_schema_examples: vec![
1131 "advanced/internal/admin".to_string()
1132 ],
1133 accepted_opaque_schema_examples: vec!["advanced/internal/admin".to_string()],
1134 unaccepted_opaque_schema_examples: Vec::new(),
1135 undocumented_schema_examples: Vec::new(),
1136 }
1137}
1138
1139fn machine_contract_is_clean(coverage: &MachineContractCoverage) -> bool {
1140 coverage.verified_scope_json_commands_without_schema == 0
1141 && coverage.verified_scope_mutating_commands_without_schema == 0
1142 && coverage.undocumented_schema_verbs_total == 0
1143 && coverage.unaccepted_opaque_schema_verbs_total == 0
1144}
1145
1146pub fn machine_contract_status(coverage: &MachineContractCoverage) -> &'static str {
1147 if machine_contract_is_clean(coverage) {
1148 "available"
1149 } else {
1150 "available_with_schema_gaps"
1151 }
1152}
1153
1154pub fn verify(ctx: &ExecutionContext, opts: VerifyOptions) -> Result<VerifyReport> {
1155 let fallback;
1156 let start = if let Some(start) = opts.start_path.as_deref() {
1157 start
1158 } else if let Some(start) = ctx.start_path() {
1159 start
1160 } else {
1161 fallback = std::env::current_dir().map_err(HeddleError::Io)?;
1162 fallback.as_path()
1163 };
1164
1165 let probe_start = Instant::now();
1166 let plain_git_probe = build_plain_git_verification_probe(start)?;
1167 let plain_git_probe_ms = probe_start.elapsed().as_millis();
1168 let mut profile = VerifyProfile {
1169 plain_git_probe_ms,
1170 ..VerifyProfile::default()
1171 };
1172
1173 if let Some(probe) = plain_git_probe {
1174 return Ok(VerifyReport {
1175 output_kind: "verify",
1176 clean: probe.trust.verified,
1177 repository_label: repository_mode_label("plain-git", "git-only"),
1178 repository_context: None,
1179 trust: probe.trust,
1180 profile,
1181 });
1182 }
1183
1184 let opened;
1185 let repo = if let Some(repo) = ctx.repo() {
1186 repo
1187 } else {
1188 let repo_open_start = Instant::now();
1189 opened = Repository::open(start)?;
1190 profile.repo_open_ms = repo_open_start.elapsed().as_millis();
1191 &opened
1192 };
1193 let verification_start = Instant::now();
1194 let trust = build_repository_verification_state(repo)?;
1195 profile.verification_ms = verification_start.elapsed().as_millis();
1196 let presentation = repository_presentation(repo, None, None);
1197 Ok(VerifyReport {
1198 output_kind: "verify",
1199 clean: trust.verified,
1200 repository_label: presentation.label,
1201 repository_context: presentation.context,
1202 trust,
1203 profile,
1204 })
1205}
1206
1207pub fn repository_mode_label(capability: &str, storage_model: &str) -> String {
1211 if capability == "git-overlay" || storage_model == "git+heddle-sidecar" {
1212 "Git + Heddle".to_string()
1213 } else if capability == "plain-git" || storage_model == "git-only" {
1214 "Git repo (setup needed)".to_string()
1215 } else if capability == "native"
1216 || capability == "native-heddle"
1217 || storage_model == "heddle-native"
1218 {
1219 "Heddle native".to_string()
1220 } else {
1221 capability.to_string()
1222 }
1223}
1224
1225pub fn repository_presentation(
1230 repo: &Repository,
1231 target_thread: Option<&str>,
1232 parent_thread: Option<&str>,
1233) -> RepositoryPresentation {
1234 if let Some(parent_root) = managed_git_overlay_parent_root(repo) {
1235 let thread = current_child_thread(repo);
1236 let target_thread = target_thread.map(ToString::to_string).or_else(|| {
1237 thread
1238 .as_ref()
1239 .and_then(|thread| thread.target_thread.clone())
1240 });
1241 let parent_thread = parent_thread.map(ToString::to_string).or_else(|| {
1242 thread
1243 .as_ref()
1244 .and_then(|thread| thread.parent_thread.clone())
1245 });
1246 return RepositoryPresentation {
1247 label: "Git + Heddle isolated checkout".to_string(),
1248 context: Some(RepositoryContextInfo {
1249 kind: "git-overlay-isolated-checkout".to_string(),
1250 parent_repository: Some(parent_root.display().to_string()),
1251 target_thread,
1252 parent_thread,
1253 }),
1254 };
1255 }
1256
1257 RepositoryPresentation {
1258 label: repository_mode_label(repo.capability_label(), repo.storage_model_label()),
1259 context: None,
1260 }
1261}
1262
1263fn managed_git_overlay_parent_root(repo: &Repository) -> Option<PathBuf> {
1264 let parent_root = repo.heddle_dir().parent()?;
1265 if paths_equal(parent_root, repo.root()) {
1266 return None;
1267 }
1268 parent_root
1269 .join(".git")
1270 .exists()
1271 .then(|| parent_root.to_path_buf())
1272}
1273
1274fn current_child_thread(repo: &Repository) -> Option<Thread> {
1275 let manager = ThreadManager::new(repo.heddle_dir());
1276 if let Ok(Some(thread)) = manager.find_by_execution_root(repo.root()) {
1277 return Some(thread);
1278 }
1279 let lane = repo.current_lane().ok().flatten()?;
1280 manager.find_by_thread(&lane).ok().flatten()
1281}
1282
1283fn paths_equal(left: &Path, right: &Path) -> bool {
1284 let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
1285 let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
1286 left == right
1287}
1288
1289pub fn dirty_path_count(status: &WorktreeStatus) -> usize {
1290 status.modified.len() + status.added.len() + status.deleted.len()
1291}