Skip to main content

verbs/
save.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared save primitive for `capture` / internal checkpoint / ready auto-capture.
3//!
4//! Embedding surfaces call [`capture`] with typed input and receive a typed
5//! [`CaptureReport`]. Repo keeps atomic tree/state mutation; this module owns
6//! safety preflight, attribution/session resolution, Heddle snapshot, optional
7//! Git-overlay write-through, and report assembly. [`execute_save`] remains the
8//! lower-level primitive for compound verbs such as ready.
9
10use std::time::Instant;
11
12use anyhow::{Context, Result, anyhow};
13use chrono::Utc;
14use heddle_git_projection::{GitProjection, WriteThroughOutcome};
15use objects::{
16    HeddleError, RecoveryDetails,
17    lock::RepositoryLockExt,
18    object::{Agent, Attribution, ContentHash, Principal, State, StateId, ThreadName, Tree},
19    store::ObjectStore,
20    worktree::WorktreeStatus,
21};
22use oplog::{OpLogBackend, OpRecord};
23use refs::Head;
24use repo::{
25    ActorPresenceStore, CommitGraphIndex, GitCheckpointRecord, Hook, HookContext, HookManager,
26    OperationScope, Repository, RepositoryCapability, SessionManager, SnapshotProfile, Thread,
27    ThreadFreshness, ThreadIntegrationPolicy, ThreadManager, ThreadMode, ThreadState,
28    WorktreeStateLookupProfile, WorktreeStatusOptions, refresh_active_thread_metadata,
29    update_thread_state_from_state,
30};
31use schemars::JsonSchema;
32use serde::Serialize;
33use sley::Repository as SleyRepository;
34
35use crate::{
36    ActionTemplate, ExecutionContext, HeddleReport, IdentityCursor, MachineContractInput,
37    MachineOutputKind, OutputDiscriminator, ReportContract, RepositoryVerificationState,
38    SegmentRotation, attach_published_segment_fields,
39    build_repository_verification_health_with_worktree_status, build_repository_verification_state,
40    build_repository_verification_state_with_machine_contract,
41    build_repository_verification_state_with_worktree_status,
42    build_repository_verification_state_with_worktree_status_and_machine_contract,
43    cursor_segment_rotation, published_field, read_identity_cursor, schema_for_report,
44    stamp_identity_cursor,
45    status::next_action::{
46        contextual_thread_action, heddle_action, import_guidance_includes_active_branch,
47        remote_tracking_status,
48    },
49    verify::action_template,
50};
51
52const BULK_CAPTURE_WARNING_THRESHOLD: usize = 500;
53
54/// Fully-resolved inputs for the normal local capture operation.
55#[derive(Debug)]
56pub struct CaptureOptions {
57    pub intent: String,
58    pub confidence: Option<f32>,
59    pub force: bool,
60    pub agent: CaptureAgentOptions,
61    pub machine_contract_input: Option<MachineContractInput>,
62}
63
64/// Agent inputs supplied by an embedding surface.
65///
66/// Process environment parsing stays in the CLI adapter. Applying the identity
67/// patch, resolving repository/session precedence, and rotating a session
68/// segment are capture semantics and therefore live behind [`capture`].
69#[derive(Debug, Clone, Default)]
70pub struct CaptureAgentOptions {
71    pub provider: Option<String>,
72    pub model: Option<String>,
73    pub session: Option<String>,
74    pub segment: Option<String>,
75    pub policy: Option<String>,
76    pub environment_policy: Option<String>,
77    pub default_policy: Option<String>,
78    pub identity_patch: IdentityCursor,
79    pub no_policy: bool,
80    pub no_agent: bool,
81}
82
83/// Attribution resolved lazily after capture's non-mutating safety checks.
84#[derive(Debug, Clone)]
85struct CaptureAttribution {
86    attribution: Attribution,
87    principal_source: String,
88    /// Native harness session from the workspace identity stamp. This remains
89    /// distinct from Heddle `Session.id` and only advances the last-turn cursor.
90    harness_session_id: Option<String>,
91    warnings: Vec<String>,
92}
93
94/// Capture-specific timings owned by the operation implementation.
95#[derive(Debug, Clone, Copy, Default)]
96pub struct CaptureProfile {
97    pub worktree_status_ms: u128,
98    pub preflight_ms: u128,
99    pub attribution_ms: u128,
100    pub execute_save_ms: u128,
101}
102
103/// Final semantic report returned by the capture seam.
104///
105/// The CLI may project this into its stable JSON wire type or render it for a
106/// person, but it must not add recovery state or derive workflow actions.
107#[derive(Debug, Clone, Serialize, JsonSchema)]
108pub struct CaptureReport {
109    pub output_kind: &'static str,
110    pub state_id: String,
111    pub content_hash: String,
112    /// Git commit written for this state in Git Overlay mode.
113    pub git_checkpoint: Option<String>,
114    pub intent: Option<String>,
115    pub confidence: Option<f32>,
116    pub task_assignment_id: Option<String>,
117    pub principal: CapturePrincipalReport,
118    pub principal_source: String,
119    pub agent: Option<CaptureAgentReport>,
120    pub promotion_suggested: bool,
121    pub heavy_impact_paths: Vec<String>,
122    pub captured_path_count: usize,
123    pub warnings: Vec<String>,
124    pub signed: bool,
125    pub message: String,
126    pub recommended_action: Option<String>,
127    pub recommended_action_template: Option<ActionTemplate>,
128    pub verification: RepositoryVerificationState,
129    #[serde(skip)]
130    #[schemars(skip)]
131    pub captured_thread_targets_integration: bool,
132    #[serde(skip)]
133    #[schemars(skip)]
134    pub diagnostics: CaptureDiagnostics,
135}
136
137impl CaptureReport {
138    pub const CONTRACT: ReportContract = ReportContract {
139        schema_name: "capture",
140        machine_output_kind: MachineOutputKind::Json,
141        output_discriminator: Some(OutputDiscriminator {
142            field: "output_kind",
143            value: "capture",
144        }),
145        schema: schema_for_report::<CaptureReport>,
146    };
147}
148
149impl HeddleReport for CaptureReport {
150    const CONTRACT: ReportContract = CaptureReport::CONTRACT;
151}
152
153#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
154pub struct CapturePrincipalReport {
155    pub name: String,
156    pub email: String,
157}
158
159#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
160pub struct CaptureAgentReport {
161    pub provider: String,
162    pub model: String,
163    pub session_id: Option<String>,
164    pub segment_id: Option<String>,
165    pub policy_id: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub thought_level: Option<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub parent: Option<String>,
170}
171
172#[derive(Debug, Clone)]
173pub struct CaptureDiagnostics {
174    pub profile: CaptureProfile,
175    pub snapshot_profile: SnapshotProfile,
176    pub state_create_ms: u128,
177    pub captured_path_count_ms: u128,
178    pub post_verification_ms: u128,
179    pub thread_metadata_ms: u128,
180    pub previous_state_ms: u128,
181    pub previous_state_profile: WorktreeStateLookupProfile,
182    pub signature_lookup_ms: u128,
183}
184
185/// How far a save should write through into Git (Git-overlay only).
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
187#[serde(rename_all = "snake_case")]
188pub enum GitScope {
189    /// Heddle state only — no Git checkpoint (native capture).
190    None,
191    /// Checkpoint the staged Git index boundary (caller supplies the tree).
192    Staged,
193    /// Capture/checkpoint the full worktree (or current clean state).
194    WorktreeAll,
195}
196
197/// Public CLI / facade verb that requested the save.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
199#[serde(rename_all = "snake_case")]
200pub enum SaveVerb {
201    Capture,
202    Checkpoint,
203}
204
205/// Inputs for [`execute_save`]. Attribution is resolved by the caller so CLI
206/// env/harness/agent precedence stays at the embedding surface.
207#[derive(Debug)]
208pub struct SavePlan {
209    pub verb: SaveVerb,
210    pub intent: Option<String>,
211    pub confidence: Option<f32>,
212    pub attribution: Attribution,
213    pub git_scope: GitScope,
214    /// When set, snapshot this tree instead of walking the worktree
215    /// (staged-index commits).
216    pub supplied_tree: Option<Tree>,
217    /// Prefer the current HEAD state when present (checkpoint bootstrap path).
218    pub reuse_current_state: bool,
219    /// After ensuring state, refuse dirty Heddle worktree before Git write-through.
220    pub require_clean_worktree: bool,
221    /// Refuse a worktree snapshot whose tree is identical to the current state.
222    /// The comparison happens during the snapshot tree build, avoiding a
223    /// separate preflight walk.
224    pub require_worktree_change: bool,
225    pub worktree_status_options: WorktreeStatusOptions,
226    /// Authoritative parent-relative paths found by capture preflight. The
227    /// snapshot builder consumes these before the monitor cursor advances so
228    /// it can rewrite only the affected leaf-to-root chain.
229    pub known_worktree_changes: Option<WorktreeStatus>,
230    /// Run pre/post snapshot hooks when creating a new Heddle state.
231    pub run_hooks: bool,
232    /// Fold snapshot + GitCheckpoint oplog batches into one undo unit.
233    pub coalesce_snapshot_and_checkpoint: bool,
234    /// Export an unmapped checkpoint state on top of the checkout's current
235    /// Git tip. Used only by sequential multi-peer land.
236    pub linearize_git_parent: bool,
237    /// Optional precomputed git-overlay worktree status for verification reuse
238    /// on the no-new-state path. Post-mutation paths always recompute.
239    pub precomputed_worktree_status:
240        Option<repo::Result<Option<objects::worktree::WorktreeStatus>>>,
241    /// Optional embedding-surface machine-contract inventory. Passing it into
242    /// core verification lets callers reuse the post-save proof instead of
243    /// rebuilding the entire repository health envelope for presentation.
244    pub machine_contract_input: Option<MachineContractInput>,
245}
246
247impl SavePlan {
248    pub fn capture(intent: impl Into<String>, attribution: Attribution) -> Self {
249        Self {
250            verb: SaveVerb::Capture,
251            intent: Some(intent.into()),
252            confidence: None,
253            attribution,
254            git_scope: GitScope::None,
255            supplied_tree: None,
256            reuse_current_state: false,
257            require_clean_worktree: false,
258            require_worktree_change: false,
259            worktree_status_options: WorktreeStatusOptions::default(),
260            known_worktree_changes: None,
261            run_hooks: true,
262            coalesce_snapshot_and_checkpoint: false,
263            linearize_git_parent: false,
264            precomputed_worktree_status: None,
265            machine_contract_input: None,
266        }
267    }
268
269    pub fn checkpoint(message: Option<String>, attribution: Attribution, staged: bool) -> Self {
270        Self {
271            verb: SaveVerb::Checkpoint,
272            intent: message,
273            confidence: None,
274            attribution,
275            git_scope: if staged {
276                GitScope::Staged
277            } else {
278                GitScope::WorktreeAll
279            },
280            supplied_tree: None,
281            reuse_current_state: true,
282            require_clean_worktree: !staged,
283            require_worktree_change: false,
284            worktree_status_options: WorktreeStatusOptions::default(),
285            known_worktree_changes: None,
286            run_hooks: true,
287            coalesce_snapshot_and_checkpoint: false,
288            linearize_git_parent: false,
289            precomputed_worktree_status: None,
290            machine_contract_input: None,
291        }
292    }
293
294    pub fn with_confidence(mut self, confidence: Option<f32>) -> Self {
295        self.confidence = confidence;
296        self
297    }
298
299    pub fn with_supplied_tree(mut self, tree: Tree) -> Self {
300        self.supplied_tree = Some(tree);
301        self
302    }
303
304    pub fn with_worktree_status_options(mut self, options: WorktreeStatusOptions) -> Self {
305        self.worktree_status_options = options;
306        self
307    }
308
309    pub fn with_precomputed_worktree_status(
310        mut self,
311        status: repo::Result<Option<objects::worktree::WorktreeStatus>>,
312    ) -> Self {
313        self.precomputed_worktree_status = Some(status);
314        self
315    }
316}
317
318/// Result of a successful save.
319#[derive(Debug, Clone)]
320pub struct SaveReport {
321    pub verb: SaveVerb,
322    pub state_id: StateId,
323    pub content_hash: ContentHash,
324    pub intent: Option<String>,
325    pub confidence: Option<f32>,
326    pub signed: bool,
327    pub git_commit: Option<String>,
328    pub git_previous_commit: Option<String>,
329    pub summary: String,
330    pub principal: Principal,
331    pub agent: Option<Agent>,
332    pub promotion_suggested: bool,
333    pub heavy_impact_paths: Vec<String>,
334    /// Number of paths changed by this save relative to the state that was
335    /// current when the operation began.
336    pub captured_path_count: usize,
337    pub verification: RepositoryVerificationState,
338    pub created_new_state: bool,
339    pub git_checkpoint: Option<GitCheckpointRecord>,
340    pub snapshot_profile: SnapshotProfile,
341    pub state_create_ms: u128,
342    pub captured_path_count_ms: u128,
343    pub post_verification_ms: u128,
344    pub thread_metadata_ms: u128,
345    pub previous_state_ms: u128,
346    pub previous_state_profile: WorktreeStateLookupProfile,
347    pub signature_lookup_ms: u128,
348}
349
350/// Whether this plan should create a new Heddle state (vs reusing HEAD).
351pub fn plan_creates_new_state(plan: &SavePlan, has_current_state: bool) -> bool {
352    if plan.supplied_tree.is_some() {
353        return true;
354    }
355    if plan.reuse_current_state && has_current_state {
356        return false;
357    }
358    // Checkpoint without current state still bootstraps a capture.
359    if plan.verb == SaveVerb::Checkpoint && has_current_state {
360        return false;
361    }
362    true
363}
364
365/// Whether this plan should perform a Git-overlay write-through.
366pub fn plan_writes_git_checkpoint(plan: &SavePlan, capability: RepositoryCapability) -> bool {
367    plan.git_scope != GitScope::None && capability == RepositoryCapability::GitOverlay
368}
369
370/// Leaf path component for Git index → Heddle tree entry names.
371pub fn tree_leaf_name(path: &str) -> String {
372    path.rsplit('/').next().unwrap_or(path).to_string()
373}
374
375/// Capture the current worktree as one synchronous local operation.
376///
377/// The implementation owns the complete semantic sequence: authority-aware
378/// worktree checks, safety preflight, Heddle mutation, manual-resolution
379/// completion, Git checkpointing, and final report assembly. There are no
380/// genuine suspension points on this path.
381pub fn capture(ctx: &ExecutionContext, options: CaptureOptions) -> Result<CaptureReport> {
382    let repo = ctx.require_repo()?;
383    if options.intent.trim().is_empty() {
384        return Err(capture_refusal(
385            "missing_capture_intent",
386            "refusing to capture without an intent",
387            "Provide a short intent with `heddle capture -m \"...\"`.",
388            "no capture intent was supplied with -m/--message/--intent",
389            "capturing without intent would create a weak provenance record",
390            "repository state, refs, metadata, and worktree files were left unchanged",
391            vec!["heddle capture -m \"...\"".to_string()],
392        ));
393    }
394
395    preflight_unimported_git_history(repo, "capture")?;
396    let complete_thread_resolution = merge_resolution_is_complete(repo)?;
397    let worktree_status_started = Instant::now();
398    let mut reuse_current_state = false;
399    let mut no_changes = false;
400    let known_worktree_changes = if complete_thread_resolution {
401        None
402    } else {
403        let status = capture_worktree_status(repo, &ctx.worktree_status_options())?;
404        if repo.capability() == RepositoryCapability::GitOverlay && status.is_clean() {
405            if repo.pending_git_checkpoint_intent()?.is_some()
406                || uncheckpointed_state_is_ahead_of_git(repo)?
407            {
408                // A prior capture may have created the Heddle state before its
409                // Git write-through failed. Retrying the same verb completes
410                // that state instead of manufacturing a duplicate capture.
411                reuse_current_state = true;
412            } else {
413                no_changes = true;
414            }
415        }
416        Some(status)
417    };
418    if let Some(path) = known_worktree_changes
419        .as_ref()
420        .and_then(reserved_capture_path)
421    {
422        return Err(capture_refusal(
423            "reserved_materialization_path",
424            format!("Refusing to capture reserved path `{path}`"),
425            "Inject secrets with `heddle env run --profile <name> -- <cmd>` instead of writing `.env` files.",
426            format!("`{path}` is a reserved confidential-runtime materialization path"),
427            "capture would ingest reserved plaintext into Source History",
428            "repository state, refs, metadata, and worktree files were left unchanged",
429            vec!["heddle env run --profile <name> -- <cmd>".to_string()],
430        ));
431    }
432
433    let worktree_status = repo.git_overlay_worktree_status();
434    let worktree_status_ms = worktree_status_started.elapsed().as_millis();
435
436    let preflight_started = Instant::now();
437    preflight_large_capture(options.force, &worktree_status)?;
438    preflight_capture_mutation(
439        repo,
440        &worktree_status,
441        options.machine_contract_input.as_ref(),
442    )?;
443    if no_changes {
444        return Err(map_capture_error(anyhow!(HeddleError::NoChanges)));
445    }
446    let preflight_ms = preflight_started.elapsed().as_millis();
447    let attribution_started = Instant::now();
448    let resolved_attribution =
449        resolve_capture_attribution(repo, ctx.principal_fallback(), &options.agent)?;
450    let harness_session_id = resolved_attribution
451        .attribution
452        .agent
453        .is_some()
454        .then(|| resolved_attribution.harness_session_id.clone())
455        .flatten();
456    let mut warnings = resolved_attribution.warnings;
457    let attribution_ms = attribution_started.elapsed().as_millis();
458
459    let git_overlay = repo.capability() == RepositoryCapability::GitOverlay;
460    let plan = SavePlan {
461        verb: SaveVerb::Capture,
462        intent: Some(options.intent),
463        confidence: options.confidence,
464        attribution: resolved_attribution.attribution,
465        git_scope: if git_overlay {
466            GitScope::WorktreeAll
467        } else {
468            GitScope::None
469        },
470        supplied_tree: None,
471        reuse_current_state,
472        require_clean_worktree: git_overlay,
473        // Native repositories compare the built tree with their Heddle HEAD.
474        // Git-overlay performs its distinct authority-aware comparison above:
475        // an overlay can legitimately have no Heddle HEAD yet and still need
476        // to capture an empty tree that represents deletion from Git's base.
477        require_worktree_change: repo.capability() == RepositoryCapability::NativeHeddle
478            && !complete_thread_resolution,
479        worktree_status_options: ctx.worktree_status_options(),
480        known_worktree_changes,
481        run_hooks: true,
482        coalesce_snapshot_and_checkpoint: git_overlay,
483        // Git Overlay must extend the checkout's authoritative Git tip even
484        // when the lazily-bound Heddle parent has no byte-identical mapping.
485        // Otherwise the first capture after init can replace imported Git
486        // history with a reconstructed metadata root.
487        linearize_git_parent: git_overlay,
488        precomputed_worktree_status: Some(worktree_status),
489        machine_contract_input: options.machine_contract_input,
490    };
491    let execute_save_started = Instant::now();
492    let save = execute_save(repo, plan).map_err(map_capture_error)?;
493    let execute_save_ms = execute_save_started.elapsed().as_millis();
494    if let Some(session_id) = harness_session_id
495        && let Err(error) = crate::record_last_turn_capture(repo, &session_id, save.state_id)
496    {
497        warnings.push(format!(
498            "could not update the reconstructible last-turn anchor: {error}"
499        ));
500    }
501
502    let manual_resolution_action = if complete_thread_resolution {
503        complete_current_thread_manual_resolution(repo)?
504    } else {
505        None
506    };
507
508    let current_thread = current_thread(repo)?;
509    let captured_thread_targets_integration = current_thread
510        .as_ref()
511        .and_then(|thread| thread.target_thread.as_ref())
512        .is_some();
513    let task_assignment_id = active_task_assignment_id(repo, current_thread.as_ref())?;
514    let principal_source = resolved_attribution.principal_source;
515    warnings.extend(bulk_capture_warning(save.captured_path_count));
516
517    let mut recommended_action = non_empty_action(&save.verification.recommended_action);
518    let mut recommended_action_template = recommended_action
519        .as_deref()
520        .and_then(action_template)
521        .or_else(|| save.verification.recommended_action_template.clone());
522    if let Some(action) = manual_resolution_action {
523        recommended_action_template = action_template(&action);
524        recommended_action = Some(action);
525    }
526
527    let principal = CapturePrincipalReport {
528        name: save.principal.name_lossy().into_owned(),
529        email: save.principal.email_lossy().into_owned(),
530    };
531    let agent = save.agent.as_ref().map(|agent| CaptureAgentReport {
532        provider: agent.provider.clone(),
533        model: agent.model.clone(),
534        session_id: agent.session_id.clone(),
535        segment_id: agent.segment_id.clone(),
536        policy_id: agent.policy_id.clone(),
537        thought_level: agent.thought_level.clone(),
538        parent: agent.parent.clone(),
539    });
540    Ok(CaptureReport {
541        output_kind: "capture",
542        state_id: save.state_id.short(),
543        content_hash: save.content_hash.short(),
544        git_checkpoint: save.git_commit.clone(),
545        intent: save.intent.clone(),
546        confidence: save.confidence,
547        task_assignment_id,
548        principal,
549        principal_source,
550        agent,
551        promotion_suggested: save.promotion_suggested,
552        heavy_impact_paths: save.heavy_impact_paths.clone(),
553        captured_path_count: save.captured_path_count,
554        warnings,
555        signed: save.signed,
556        message: save.summary.clone(),
557        recommended_action,
558        recommended_action_template,
559        verification: save.verification.clone(),
560        captured_thread_targets_integration,
561        diagnostics: CaptureDiagnostics {
562            profile: CaptureProfile {
563                worktree_status_ms,
564                preflight_ms,
565                attribution_ms,
566                execute_save_ms,
567            },
568            snapshot_profile: save.snapshot_profile,
569            state_create_ms: save.state_create_ms,
570            captured_path_count_ms: save.captured_path_count_ms,
571            post_verification_ms: save.post_verification_ms,
572            thread_metadata_ms: save.thread_metadata_ms,
573            previous_state_ms: save.previous_state_ms,
574            previous_state_profile: save.previous_state_profile,
575            signature_lookup_ms: save.signature_lookup_ms,
576        },
577    })
578}
579
580/// Resolve attribution using explicit embedding inputs plus repository state.
581///
582/// This remains public for internal save paths that have not yet moved to the
583/// complete [`capture`] operation. New capture callers should use [`capture`]
584/// so safety checks continue to run before identity/session mutation.
585fn resolve_capture_attribution(
586    repo: &Repository,
587    principal_fallback: Option<(&str, &str)>,
588    options: &CaptureAgentOptions,
589) -> Result<CaptureAttribution> {
590    let resolved_principal = crate::resolve_principal(repo, principal_fallback)?;
591    let principal_source = resolved_principal.source.unwrap_or("unknown").to_string();
592    let principal = resolved_principal.principal;
593    if crate::principal_lacks_accountable_identity(
594        &principal.name_lossy(),
595        &principal.email_lossy(),
596    ) {
597        return Err(capture_refusal(
598            "capture_identity_required",
599            "Refusing to capture: no accountable identity is configured",
600            "Set `HEDDLE_PRINCIPAL_NAME` and `HEDDLE_PRINCIPAL_EMAIL`, or run `heddle init --principal-name <name> --principal-email <email>`, then retry the capture.",
601            "Heddle would otherwise have to record Unknown <unknown@example.com> on the captured state",
602            "capture would create durable Heddle history without a real principal",
603            "Heddle refs, captured states, Git refs, index, and worktree files were left unchanged",
604            vec![
605                "heddle init --principal-name <name> --principal-email <email>".to_string(),
606                "heddle capture -m \"...\"".to_string(),
607            ],
608        ));
609    }
610
611    if options.no_agent {
612        return Ok(CaptureAttribution {
613            attribution: Attribution::human(principal),
614            principal_source,
615            harness_session_id: None,
616            warnings: Vec::new(),
617        });
618    }
619
620    // Identity cursor persistence and Heddle session rotation are part of the
621    // capture transaction's semantic prelude. Failure remains best-effort to
622    // preserve the existing rule that missing agent metadata never blocks a
623    // human-attributed capture.
624    let (frozen, warnings) = match freeze_identity_for_capture(repo, &options.identity_patch) {
625        Ok(frozen) => (frozen, Vec::new()),
626        Err(error) => (
627            FrozenCaptureIdentity::default(),
628            vec![format!(
629                "could not freeze agent identity for this capture; recorded human attribution: {error}"
630            )],
631        ),
632    };
633    let harness_session_id = frozen.session.clone();
634    let current_session = SessionManager::new(repo.root()).get_current_session()?;
635    let provider = options
636        .provider
637        .clone()
638        .or(frozen.provider.clone())
639        .and_then(clean_attribution_value);
640    let model = options
641        .model
642        .clone()
643        .or(frozen.model.clone())
644        .and_then(clean_attribution_value);
645    let session_policy = current_session
646        .as_ref()
647        .and_then(|session| session.current_segment())
648        .and_then(|segment| segment.policy_id.clone())
649        .and_then(clean_attribution_value);
650    // Heddle Session.id — never the sidecar harness session.
651    let session_id = options
652        .session
653        .clone()
654        .or_else(|| current_session.as_ref().map(|session| session.id.clone()));
655    let segment_id = options
656        .segment
657        .clone()
658        .or(frozen.segment_id.clone())
659        .or_else(|| {
660            current_session
661                .as_ref()
662                .and_then(|session| session.current_segment_id.clone())
663        });
664    let policy = if options.no_policy {
665        None
666    } else {
667        options
668            .policy
669            .clone()
670            .or_else(|| options.environment_policy.clone())
671            .and_then(clean_attribution_value)
672            .or(session_policy)
673            .or_else(|| options.default_policy.clone())
674            .or_else(|| repo.config().policies.default_policy.clone())
675    };
676
677    let attribution = match (provider, model) {
678        (Some(provider), Some(model)) => {
679            let mut agent = Agent::new(provider, model);
680            if let (Some(session_id), Some(segment_id)) = (session_id, segment_id) {
681                agent = agent.with_session(session_id, segment_id);
682            }
683            if let Some(policy) = policy {
684                agent = agent.with_policy(policy);
685            }
686            if let Some(thought_level) = frozen.thought_level {
687                agent = agent.with_thought_level(thought_level);
688            }
689            if let Some(parent) = frozen.parent {
690                agent = agent.with_parent(parent);
691            }
692            Attribution::with_agent(principal, agent)
693        }
694        _ => Attribution::human(principal),
695    };
696    Ok(CaptureAttribution {
697        attribution,
698        principal_source,
699        harness_session_id,
700        warnings,
701    })
702}
703
704/// Resolve the author for internal save paths that already own orchestration.
705///
706/// Normal callers should use [`capture`], which preserves the required
707/// preflight-before-attribution ordering.
708pub fn resolve_capture_author(
709    repo: &Repository,
710    principal_fallback: Option<(&str, &str)>,
711    options: &CaptureAgentOptions,
712) -> Result<Attribution> {
713    resolve_capture_attribution(repo, principal_fallback, options)
714        .map(|resolved| resolved.attribution)
715}
716
717#[derive(Clone, Debug, Default)]
718struct FrozenCaptureIdentity {
719    provider: Option<String>,
720    model: Option<String>,
721    thought_level: Option<String>,
722    session: Option<String>,
723    parent: Option<String>,
724    segment_id: Option<String>,
725}
726
727fn freeze_identity_for_capture(
728    repo: &Repository,
729    identity_patch: &IdentityCursor,
730) -> Result<FrozenCaptureIdentity> {
731    let _guard = repo.locker().write()?;
732    let mut cursor = read_identity_cursor(repo.root());
733    if !identity_patch.is_empty() {
734        cursor = stamp_identity_cursor(repo.root(), identity_patch)?;
735    }
736    let mut manager = SessionManager::new(repo.root());
737    let session = manager.get_current_session()?;
738    let segment_id = apply_capture_segment_policy(&mut manager, session.as_ref(), &cursor)?;
739    Ok(FrozenCaptureIdentity {
740        provider: cursor.provider,
741        model: cursor.model,
742        thought_level: cursor.thought_level,
743        session: cursor.session,
744        parent: cursor.parent,
745        segment_id,
746    })
747}
748
749fn apply_capture_segment_policy(
750    manager: &mut SessionManager,
751    session: Option<&objects::object::Session>,
752    cursor: &IdentityCursor,
753) -> Result<Option<String>> {
754    let Some(session) = session else {
755        return Ok(None);
756    };
757    let current = session.current_segment();
758    let rotation = cursor_segment_rotation(
759        current.map(|segment| segment.provider.as_str()),
760        current.map(|segment| segment.model.as_str()),
761        current.and_then(|segment| segment.thought_level.as_deref()),
762        cursor.provider.as_deref(),
763        cursor.model.as_deref(),
764        cursor.thought_level.as_deref(),
765    );
766    if rotation == SegmentRotation::Rotate {
767        let provider = cursor
768            .provider
769            .clone()
770            .or_else(|| current.map(|segment| segment.provider.clone()))
771            .unwrap_or_default();
772        let model = cursor
773            .model
774            .clone()
775            .or_else(|| current.map(|segment| segment.model.clone()))
776            .unwrap_or_default();
777        if published_field(Some(&provider)).is_some() && published_field(Some(&model)).is_some() {
778            let segment = manager.add_segment(&session.id, provider, model, None)?;
779            if let Some(thought_level) = cursor.thought_level.clone()
780                && let Some(mut updated) = manager.get_session(&session.id)?
781            {
782                if let Some(current) = updated.current_segment_mut() {
783                    current.thought_level = Some(thought_level);
784                }
785                manager.save_session(&updated)?;
786            }
787            return Ok(Some(segment.id));
788        }
789    } else if rotation == SegmentRotation::Attach
790        && let Some(mut updated) = manager.get_session(&session.id)?
791    {
792        if let Some(segment) = updated.current_segment_mut() {
793            attach_published_segment_fields(
794                segment,
795                cursor.provider.as_deref(),
796                cursor.model.as_deref(),
797                cursor.thought_level.as_deref(),
798            );
799        }
800        manager.save_session(&updated)?;
801    }
802    Ok(session.current_segment_id.clone())
803}
804
805fn clean_attribution_value(value: String) -> Option<String> {
806    let trimmed = value.trim();
807    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
808        None
809    } else {
810        Some(value)
811    }
812}
813
814fn preflight_unimported_git_history(repo: &Repository, action: &str) -> Result<()> {
815    if repo.capability() != RepositoryCapability::GitOverlay {
816        return Ok(());
817    }
818    let Some(guidance) = repo.git_import_guidance()? else {
819        return Ok(());
820    };
821    if !import_guidance_includes_active_branch(&guidance) {
822        return Ok(());
823    }
824    let branches = preview_paths(&guidance.missing_branches);
825    let command = guidance.recommended_command;
826    Err(capture_refusal(
827        "git_history_needs_import",
828        format!("Refusing to {action}: Git history has not been imported into Heddle"),
829        format!("Run `{command}` before retrying `heddle {action}`."),
830        format!("Git branch(es) waiting for Heddle import: {branches}"),
831        format!(
832            "{action} would write new Heddle state before Heddle has adopted the existing Git history"
833        ),
834        "Git refs, Heddle refs, and worktree files were left unchanged",
835        vec![command],
836    ))
837}
838
839fn preflight_capture_mutation(
840    repo: &Repository,
841    worktree_status: &repo::Result<Option<WorktreeStatus>>,
842    machine_contract_input: Option<&MachineContractInput>,
843) -> Result<()> {
844    if repo.capability() != RepositoryCapability::GitOverlay {
845        return Ok(());
846    }
847    if repo.git_overlay_head_is_detached()? {
848        let primary = detached_head_primary_recovery(repo);
849        return Err(capture_refusal(
850            "git_head_detached",
851            "Refusing to capture: Git HEAD is detached",
852            format!("Run `{primary}` before retrying `heddle capture`."),
853            "Git HEAD points directly to a commit instead of an attached branch",
854            "capture would need to write a Git checkpoint through a branch and could reattach or advance the wrong ref",
855            "Git refs, Heddle refs, Git checkpoints, and worktree files were left unchanged",
856            vec![primary],
857        ));
858    }
859    if let Some(operation) = repo.operation_status()?
860        && matches!(operation.scope, OperationScope::Git)
861    {
862        return Err(capture_refusal(
863            "raw_git_operation_in_progress",
864            format!(
865                "Refusing to capture: an externally-started Git {} is in progress",
866                operation.kind
867            ),
868            format!(
869                "Inspect with `heddle verify`. Heddle did not start this raw Git {}, so finish or abort it with the Git-compatible tool that started it, then run `heddle verify` for the exact adoption command before retrying `heddle capture`.",
870                operation.kind
871            ),
872            format!(
873                "Git {} is {}; Heddle cannot safely turn sequencer state into a saved change inside the no-git runtime",
874                operation.kind, operation.state
875            ),
876            "capture would capture worktree/index contents while Git still has unresolved sequencer metadata",
877            "Git refs, Git sequencer files, Heddle refs, and worktree files were left unchanged",
878            vec!["heddle verify".to_string()],
879        ));
880    }
881
882    let health = build_repository_verification_health_with_worktree_status(repo, worktree_status);
883    let trust = if let Some(input) = machine_contract_input {
884        build_repository_verification_state_with_worktree_status_and_machine_contract(
885            repo,
886            health,
887            worktree_status,
888            input,
889        )
890    } else {
891        build_repository_verification_state_with_worktree_status(repo, health, worktree_status)
892    };
893    if !checkpoint_trust_allows_ref_update(&trust)
894        && !checkpoint_can_close_integrated_remote_gap(repo, &trust)
895    {
896        let (primary, recovery_commands) = capture_blocked_recovery(repo, &trust);
897        return Err(capture_refusal(
898            "git_checkpoint_preflight_blocked",
899            "Refusing to capture: Git checkpoint preflight is blocked",
900            format!("Run `{primary}` before retrying `heddle capture`."),
901            format!(
902                "repository verification status is {}; remote drift is {}: {}",
903                trust.status, trust.remote_drift, trust.summary
904            ),
905            "capture would write Heddle state before the Git checkpoint ref update is known to be safe",
906            "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
907            recovery_commands,
908        ));
909    }
910    if trust.status != "needs_reconcile" || uncheckpointed_state_is_ahead_of_git(repo)? {
911        return Ok(());
912    }
913    let (primary, recovery_commands) = capture_blocked_recovery(repo, &trust);
914    Err(capture_refusal(
915        "repository_verification_blocked",
916        format!(
917            "Refusing to capture: repository verification is blocked ({})",
918            trust.status
919        ),
920        format!("Run `{primary}` before retrying `heddle capture`."),
921        format!(
922            "repository verification status is {}: {}",
923            trust.status, trust.summary
924        ),
925        "capture would write new Heddle or Git state while Git and Heddle disagree",
926        "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
927        recovery_commands,
928    ))
929}
930
931fn detached_head_primary_recovery(repo: &Repository) -> String {
932    if let Ok(Head::Attached { thread }) = repo.refs().read_head()
933        && !thread.trim().is_empty()
934    {
935        return if thread.starts_with('-') {
936            heddle_action(["thread", "switch", "--", thread.as_str()])
937        } else {
938            heddle_action(["thread", "switch", thread.as_str()])
939        };
940    }
941    if let Ok(Some(detached_commit)) = repo.git_overlay_detached_head_commit()
942        && let Ok(branch_tips) = repo.git_overlay_branch_tips()
943        && let Some(tip) = branch_tips
944            .iter()
945            .filter(|tip| tip.history_imported)
946            .find(|tip| tip.git_commit == detached_commit)
947    {
948        return heddle_action(["thread", "switch", tip.branch.as_str()]);
949    }
950    heddle_action(["status"])
951}
952
953fn capture_blocked_recovery(
954    repo: &Repository,
955    trust: &RepositoryVerificationState,
956) -> (String, Vec<String>) {
957    if let Some(recovery) = capture_remote_drift_recovery(repo) {
958        return recovery;
959    }
960    let primary =
961        non_empty_action(&trust.recommended_action).unwrap_or_else(|| "heddle verify".to_string());
962    let recovery_commands = if trust.recovery_commands.is_empty() {
963        vec![primary.clone()]
964    } else {
965        trust.recovery_commands.clone()
966    };
967    (primary, recovery_commands)
968}
969
970fn capture_remote_drift_recovery(repo: &Repository) -> Option<(String, Vec<String>)> {
971    let remote = repo.git_remote_tracking_status().ok().flatten()?;
972    let status = remote_tracking_status(&remote);
973    if !matches!(
974        status,
975        "remote_behind" | "remote_diverged" | "remote_contains_undone_checkpoint"
976    ) {
977        return None;
978    }
979    let recovery_commands = crate::status::remote_drift_recovery_commands(repo, &remote, status);
980    let primary = recovery_commands.first()?.clone();
981    Some((primary, recovery_commands))
982}
983
984fn checkpoint_trust_allows_ref_update(trust: &RepositoryVerificationState) -> bool {
985    let status_allows_checkpoint = matches!(
986        trust.status.as_str(),
987        "clean" | "dirty_worktree" | "needs_checkpoint" | "remote_ahead" | "remote_untracked"
988    );
989    let remote_allows_checkpoint = matches!(
990        trust.remote_drift.as_str(),
991        "clean" | "remote_ahead" | "remote_untracked"
992    );
993    status_allows_checkpoint && remote_allows_checkpoint
994}
995
996fn checkpoint_can_close_integrated_remote_gap(
997    repo: &Repository,
998    trust: &RepositoryVerificationState,
999) -> bool {
1000    if trust.status != "needs_checkpoint"
1001        || !matches!(
1002            trust.remote_drift.as_str(),
1003            "remote_behind" | "remote_diverged"
1004        )
1005    {
1006        return false;
1007    }
1008    let Some(remote) = repo.git_remote_tracking_status().ok().flatten() else {
1009        return false;
1010    };
1011    let upstream = remote.upstream.trim();
1012    if upstream.is_empty() {
1013        return false;
1014    }
1015    let Ok(Some(upstream_state)) = repo.refs().get_thread(&ThreadName::new(upstream)) else {
1016        return false;
1017    };
1018    let Ok(Some(current_state)) = repo.head() else {
1019        return false;
1020    };
1021    let mut graph = CommitGraphIndex::new(repo);
1022    graph
1023        .is_ancestor(&upstream_state, &current_state)
1024        .unwrap_or(false)
1025}
1026
1027fn uncheckpointed_state_is_ahead_of_git(repo: &Repository) -> Result<bool> {
1028    let Some(branch) = repo.git_overlay_current_branch()? else {
1029        return Ok(false);
1030    };
1031    let Some(current) = repo.current_state()? else {
1032        return Ok(false);
1033    };
1034    if repo
1035        .latest_git_checkpoint_for_state(&current.state_id)?
1036        .is_some()
1037    {
1038        return Ok(false);
1039    }
1040    // An attached unborn branch has no Git tip to map. If the worktree already
1041    // matches an uncheckpointed Heddle state, capture must publish that state as
1042    // the branch's first commit instead of misreporting a no-op.
1043    let Some(tip) = repo.git_overlay_branch_tip(&branch)? else {
1044        return Ok(true);
1045    };
1046    let Some(mapped) = tip.mapped_state else {
1047        return Ok(false);
1048    };
1049    if mapped == current.state_id {
1050        return Ok(false);
1051    }
1052    let mut graph = CommitGraphIndex::new(repo);
1053    if !graph
1054        .is_ancestor(&mapped, &current.state_id)
1055        .unwrap_or(false)
1056        || graph
1057            .is_ancestor(&current.state_id, &mapped)
1058            .unwrap_or(false)
1059    {
1060        return Ok(false);
1061    }
1062    Ok(true)
1063}
1064
1065fn preflight_large_capture(
1066    force: bool,
1067    worktree_status: &repo::Result<Option<WorktreeStatus>>,
1068) -> Result<()> {
1069    if force {
1070        return Ok(());
1071    }
1072    let Ok(Some(status)) = worktree_status else {
1073        return Ok(());
1074    };
1075    let total = status.change_count();
1076    let delete_count = status.deleted.len();
1077    let add_count = status.added.len();
1078    if !crate::large_capture_requires_force(total, delete_count, add_count) {
1079        return Ok(());
1080    }
1081    let sample = status
1082        .deleted
1083        .iter()
1084        .chain(status.added.iter())
1085        .chain(status.modified.iter())
1086        .take(5)
1087        .map(|path| path.display().to_string())
1088        .collect::<Vec<_>>()
1089        .join(", ");
1090    let sample = if sample.is_empty() {
1091        "no sample paths available".to_string()
1092    } else {
1093        sample
1094    };
1095    Err(capture_refusal(
1096        "large_capture_requires_force",
1097        format!(
1098            "Large capture safety check: this would capture {total} changed paths ({delete_count} deletions, {add_count} additions)"
1099        ),
1100        "If this is intentional, rerun with `heddle capture --force -m \"...\"`.",
1101        format!("sample changed paths: {sample}"),
1102        "capture would preserve an unusually large Git-overlay worktree change without an explicit confirmation",
1103        "repository state, refs, metadata, and worktree files were left unchanged",
1104        vec!["heddle capture --force -m \"...\"".to_string()],
1105    ))
1106}
1107
1108fn merge_resolution_is_complete(repo: &Repository) -> Result<bool> {
1109    Ok(repo
1110        .merge_state_manager()
1111        .load()?
1112        .is_some_and(|merge_state| {
1113            merge_state
1114                .conflicts
1115                .iter()
1116                .all(|path| merge_state.resolved.contains(path))
1117        }))
1118}
1119
1120/// Compare against Heddle's current tree before asking Git for its index-based
1121/// status. These are distinct authorities: Sley's Git status cannot prime
1122/// Heddle's persisted worktree index, which the following snapshot consumes.
1123/// In particular, retaining this check prevents a fast forced retry after a
1124/// refused directory deletion from being misclassified as an unchanged tree.
1125fn capture_worktree_status(
1126    repo: &Repository,
1127    options: &WorktreeStatusOptions,
1128) -> Result<WorktreeStatus> {
1129    if repo.current_state_for_worktree_status()?.is_none()
1130        && let Some(status) = repo.git_overlay_worktree_status()?
1131    {
1132        return Ok(status);
1133    }
1134    let tree = match repo.current_state_for_worktree_status()? {
1135        Some(state) => repo.require_tree_for_worktree_status(&state.tree)?,
1136        None => Tree::new(),
1137    };
1138    Ok(repo.compare_worktree_cached_with_options(&tree, options)?)
1139}
1140
1141/// Complete a captured manual resolution and return its contextual land action.
1142///
1143/// This is shared by capture and the operator continuation path so the thread
1144/// metadata/ref/oplog transaction has one implementation.
1145pub fn complete_current_thread_manual_resolution(repo: &Repository) -> Result<Option<String>> {
1146    let Some(current_thread) = repo.current_lane()? else {
1147        return Ok(None);
1148    };
1149    let Some(current_state) = repo.head()? else {
1150        return Ok(None);
1151    };
1152    let Some(current_state_object) = repo.store().get_state(&current_state)? else {
1153        return Ok(None);
1154    };
1155    let manager = ThreadManager::new(repo.heddle_dir());
1156    let Some(mut thread) = manager.find_by_thread(&current_thread)? else {
1157        return Ok(None);
1158    };
1159    let Some(target_thread) = thread.target_thread.clone() else {
1160        return Ok(None);
1161    };
1162    let Some(target_state) = repo.refs().get_thread(&ThreadName::new(&target_thread))? else {
1163        return Ok(None);
1164    };
1165    let Some(target_state_object) = repo.store().get_state(&target_state)? else {
1166        return Ok(None);
1167    };
1168    let before = crate::capture_thread_update_before(repo, &manager, &thread)?;
1169
1170    thread.base_state = target_state.short();
1171    thread.base_root = target_state_object.tree.short();
1172    update_thread_state_from_state(&mut thread, &current_state_object);
1173    thread.state = ThreadState::Ready;
1174    thread.freshness = ThreadFreshness::Current;
1175    thread.integration_policy_result = ThreadIntegrationPolicy {
1176        status: Some("manual_resolved".to_string()),
1177        reason: Some("manual conflict resolution captured".to_string()),
1178        manual_resolution_state: Some(current_state.short()),
1179        conflicts_resolved_manually: true,
1180    };
1181    thread.updated_at = Utc::now();
1182    let thread_id = thread.id.clone();
1183    let target = thread.target_thread.clone();
1184    crate::save_thread_update(repo, &manager, &thread, before, current_state)?;
1185
1186    Ok(Some(manual_resolution_land_action(
1187        repo,
1188        &thread_id,
1189        target.as_deref(),
1190    )))
1191}
1192
1193fn manual_resolution_land_action(
1194    repo: &Repository,
1195    thread_id: &str,
1196    target_thread: Option<&str>,
1197) -> String {
1198    let action = crate::status::next_action::land_local_command(thread_id);
1199    contextual_thread_action(repo, thread_id, target_thread, &action)
1200}
1201
1202fn current_thread(repo: &Repository) -> Result<Option<Thread>> {
1203    let manager = ThreadManager::new(repo.heddle_dir());
1204    if let Some(thread) = manager.find_by_execution_root(repo.root())? {
1205        return Ok(Some(thread));
1206    }
1207    let Head::Attached { thread } = repo.head_ref()? else {
1208        return Ok(None);
1209    };
1210    let current_state_id = repo.refs().get_thread(&thread)?;
1211    let current_state = current_state_id.map(|state| state.short());
1212    let base_root = current_state_id
1213        .and_then(|state| repo.store().get_state(&state).ok().flatten())
1214        .map(|state| state.tree.short())
1215        .unwrap_or_default();
1216    let thread = thread.to_string();
1217    Ok(Some(Thread {
1218        id: thread.clone(),
1219        thread,
1220        target_thread: None,
1221        parent_thread: None,
1222        mode: ThreadMode::Materialized,
1223        state: ThreadState::Active,
1224        base_state: current_state.clone().unwrap_or_default(),
1225        base_root,
1226        current_state,
1227        merged_state: None,
1228        task: None,
1229        execution_path: repo.root().to_path_buf(),
1230        materialized_path: None,
1231        changed_paths: Vec::new(),
1232        impact_categories: Vec::new(),
1233        heavy_impact_paths: Vec::new(),
1234        promotion_suggested: false,
1235        freshness: ThreadFreshness::Unknown,
1236        verification_summary: Default::default(),
1237        confidence_summary: Default::default(),
1238        integration_policy_result: Default::default(),
1239        created_at: Utc::now(),
1240        updated_at: Utc::now(),
1241        ephemeral: None,
1242        auto: false,
1243        shared_target_dir: None,
1244    }))
1245}
1246
1247fn active_task_assignment_id(repo: &Repository, thread: Option<&Thread>) -> Result<Option<String>> {
1248    let Some(thread) = thread else {
1249        return Ok(None);
1250    };
1251    let store = ActorPresenceStore::new(repo.heddle_dir());
1252    Ok(store
1253        .active_entries()?
1254        .into_iter()
1255        .filter(|entry| entry.thread == thread.thread || entry.thread == thread.id)
1256        .max_by_key(|entry| entry.started_at)
1257        .and_then(|entry| entry.task_assignment_id))
1258}
1259
1260fn bulk_capture_warning(captured_path_count: usize) -> Option<String> {
1261    (captured_path_count >= BULK_CAPTURE_WARNING_THRESHOLD).then(|| {
1262        format!(
1263            "captured {captured_path_count} paths in one operation; check root .gitignore and .heddleignore rules if build artifacts or tool state were included"
1264        )
1265    })
1266}
1267
1268/// Return the first added or modified confidential-runtime materialization
1269/// path selected by the capture's authoritative worktree observation. Deleted
1270/// paths remove plaintext and ignored paths are not part of the capture, so
1271/// neither should block the operation.
1272fn reserved_capture_path(status: &WorktreeStatus) -> Option<String> {
1273    status
1274        .added
1275        .iter()
1276        .chain(&status.modified)
1277        .map(|path| path.to_string_lossy())
1278        .find(|path| env_store::is_reserved_materialization_path(path))
1279        .map(|path| path.into_owned())
1280}
1281
1282fn non_empty_action(action: &str) -> Option<String> {
1283    (!action.trim().is_empty()).then(|| action.to_string())
1284}
1285
1286fn map_capture_error(error: anyhow::Error) -> anyhow::Error {
1287    if error.chain().any(|cause| {
1288        cause
1289            .downcast_ref::<HeddleError>()
1290            .is_some_and(|error| matches!(error, HeddleError::NoChanges))
1291    }) {
1292        return capture_refusal(
1293            "nothing_to_capture",
1294            "nothing to capture: worktree has no changes eligible for Heddle capture",
1295            "Inspect the worktree with `heddle status`; make changes before running `heddle capture -m \"...\"`.",
1296            "the worktree has no modified, deleted, or untracked paths relative to the current Heddle state",
1297            "capture would not create a meaningful Heddle state",
1298            "repository state was left unchanged",
1299            vec!["heddle status".to_string()],
1300        );
1301    }
1302    if error.chain().any(|cause| {
1303        cause
1304            .downcast_ref::<std::io::Error>()
1305            .is_some_and(objects::fs_atomic::is_out_of_space)
1306    }) {
1307        return capture_refusal(
1308            "capture_out_of_space",
1309            format!("Capture aborted because the filesystem is out of space: {error:#}"),
1310            "Free disk space and re-run `heddle capture`. Your working tree changes are intact.",
1311            "the filesystem reported no remaining space while Heddle was writing captured objects",
1312            "retrying before freeing space may fail again or leave another incomplete object write",
1313            "the working tree was not modified; already-committed repository data remains behind atomic write boundaries",
1314            vec!["heddle capture -m \"...\"".to_string()],
1315        );
1316    }
1317    error
1318}
1319
1320#[allow(clippy::too_many_arguments)]
1321fn capture_refusal(
1322    kind: &'static str,
1323    error: impl Into<String>,
1324    hint: impl Into<String>,
1325    unsafe_condition: impl Into<String>,
1326    would_change: impl Into<String>,
1327    preserved: impl Into<String>,
1328    recovery_commands: Vec<String>,
1329) -> anyhow::Error {
1330    anyhow!(HeddleError::recovery(
1331        RecoveryDetails::safety_refusal(
1332            kind,
1333            error,
1334            hint,
1335            unsafe_condition,
1336            would_change,
1337            preserved,
1338        )
1339        .with_recovery_commands(recovery_commands),
1340    ))
1341}
1342
1343fn preview_paths(paths: &[String]) -> String {
1344    let shown = paths
1345        .iter()
1346        .take(12)
1347        .cloned()
1348        .collect::<Vec<_>>()
1349        .join(", ");
1350    let hidden = paths.len().saturating_sub(12);
1351    if hidden == 0 {
1352        shown
1353    } else {
1354        format!("{shown}, and {hidden} more")
1355    }
1356}
1357
1358/// Execute a save: optional Heddle snapshot + optional Git checkpoint write-through.
1359///
1360/// Callers own clap validation (missing message/intent) and plain-Git refusal.
1361/// Mutation composition, hooks, thread metadata, Git write-through, and post
1362/// verification live here.
1363pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
1364    // A plan that asks for a Git checkpoint on a non-overlay repo is a hard
1365    // error: `plan_writes_git_checkpoint` silently returns false for native
1366    // repos, so guard on the raw `git_scope` intent instead (the previous
1367    // `plan_writes_git_checkpoint(..) && capability != GitOverlay` was
1368    // self-contradictory and never fired).
1369    if plan.git_scope != GitScope::None && repo.capability() != RepositoryCapability::GitOverlay {
1370        return Err(anyhow!(HeddleError::recovery(
1371            RecoveryDetails::safety_refusal(
1372                "native_checkpoint_unavailable",
1373                "Git checkpointing is only available in Git-overlay repositories",
1374                "Use `heddle capture -m \"...\"` to save Heddle state in a native checkout.",
1375                "this checkout is not a Git-overlay repository",
1376                "checkpoint would try to write a Git commit where no active Git store is bound",
1377                "repository state, refs, and worktree files were left unchanged",
1378            ),
1379        )));
1380    }
1381
1382    let previous_state_started = Instant::now();
1383    let (previous_state, previous_state_profile) =
1384        repo.current_state_for_worktree_status_profiled()?;
1385    let previous_state_ms = previous_state_started.elapsed().as_millis();
1386    let has_current = previous_state.is_some();
1387    let mut created_new_state = false;
1388    let mut snapshot_profile = SnapshotProfile::default();
1389    let mut thread_metadata_ms = 0u128;
1390    let mut promotion_suggested = false;
1391    let mut heavy_impact_paths = Vec::new();
1392    let mut snapshot_state_id: Option<StateId> = None;
1393    let mut captured_path_count = 0usize;
1394    let mut state_create_ms = 0u128;
1395    let mut captured_path_count_ms = 0u128;
1396
1397    let mut state = if plan_creates_new_state(&plan, has_current) {
1398        created_new_state = true;
1399        let state_create_started = Instant::now();
1400        let execution = create_heddle_state(repo, &plan)?;
1401        state_create_ms = state_create_started.elapsed().as_millis();
1402        snapshot_profile = execution.profile;
1403        thread_metadata_ms = execution.thread_metadata_ms;
1404        promotion_suggested = execution.promotion_suggested;
1405        heavy_impact_paths = execution.heavy_impact_paths;
1406        snapshot_state_id = Some(execution.state.state_id);
1407        let previous_tree = match previous_state.as_ref() {
1408            Some(state) => state.tree,
1409            None => repo.store().put_tree(&Tree::new())?,
1410        };
1411        let captured_path_count_started = Instant::now();
1412        captured_path_count = repo
1413            .diff_trees(&previous_tree, &execution.state.tree)?
1414            .len();
1415        captured_path_count_ms = captured_path_count_started.elapsed().as_millis();
1416        execution.state
1417    } else {
1418        repo.current_state()?
1419            .ok_or_else(|| anyhow!("no captured state found for save"))?
1420    };
1421
1422    let mut git_commit = None;
1423    let mut git_previous_commit = None;
1424    let mut git_checkpoint = None;
1425
1426    if plan_writes_git_checkpoint(&plan, repo.capability()) {
1427        if plan.require_clean_worktree {
1428            let tree = repo.require_tree(&state.tree)?;
1429            let status = repo.compare_worktree_cached_detailed_with_options(
1430                &tree,
1431                &plan.worktree_status_options,
1432            )?;
1433            if !status.is_clean() {
1434                return Err(anyhow!(HeddleError::recovery(
1435                    RecoveryDetails::safety_refusal(
1436                        "dirty_worktree",
1437                        "Save worktree changes before checkpointing",
1438                        "Save the work with `heddle capture -m \"...\"`.",
1439                        "the current Heddle state was left unchanged; these paths have not been captured",
1440                        "a Git checkpoint would omit dirty worktree paths",
1441                        "the current Heddle state was left unchanged; these paths have not been captured",
1442                    ),
1443                )));
1444            }
1445        }
1446
1447        if let Some(existing) = repo.latest_git_checkpoint_for_state(&state.state_id)?
1448            && repo.pending_git_checkpoint_intent()?.is_none()
1449        {
1450            git_commit = Some(existing.git_commit.clone());
1451            git_checkpoint = Some(existing);
1452        } else {
1453            let previous = repo
1454                .pending_git_checkpoint_intent()?
1455                .and_then(|intent| intent.previous_git_oid)
1456                .or_else(|| git_rev_parse_head(repo.root()));
1457            git_previous_commit = previous.clone();
1458            let summary = checkpoint_summary(&plan, &state);
1459            let record = write_git_checkpoint(repo, &state, summary, plan.linearize_git_parent)?;
1460            if plan.coalesce_snapshot_and_checkpoint
1461                && let Some(state_id) = snapshot_state_id.as_ref()
1462            {
1463                coalesce_snapshot_and_checkpoint(repo, state_id, &record.git_commit)?;
1464            }
1465            git_commit = Some(record.git_commit.clone());
1466            git_checkpoint = Some(record);
1467        }
1468    }
1469
1470    // Post-mutation verification is always fresh when we created state or wrote
1471    // a Git checkpoint (those mutations flip health classification). Otherwise
1472    // reuse a caller-supplied worktree status to avoid a redundant walk.
1473    let captured_native_worktree = created_new_state
1474        && plan.supplied_tree.is_none()
1475        && repo.capability() == RepositoryCapability::NativeHeddle;
1476    let captured_worktree_status = Ok(Some(objects::worktree::WorktreeStatus::default()));
1477    let verification_started = Instant::now();
1478    let verification = if captured_native_worktree && git_checkpoint.is_none() {
1479        let health = build_repository_verification_health_with_worktree_status(
1480            repo,
1481            &captured_worktree_status,
1482        );
1483        if let Some(input) = &plan.machine_contract_input {
1484            build_repository_verification_state_with_worktree_status_and_machine_contract(
1485                repo,
1486                health,
1487                &captured_worktree_status,
1488                input,
1489            )
1490        } else {
1491            build_repository_verification_state_with_worktree_status(
1492                repo,
1493                health,
1494                &captured_worktree_status,
1495            )
1496        }
1497    } else if created_new_state || git_checkpoint.is_some() {
1498        if let Some(input) = &plan.machine_contract_input {
1499            build_repository_verification_state_with_machine_contract(repo, input)?
1500        } else {
1501            build_repository_verification_state(repo)?
1502        }
1503    } else if let Some(status) = &plan.precomputed_worktree_status {
1504        let health = build_repository_verification_health_with_worktree_status(repo, status);
1505        if let Some(input) = &plan.machine_contract_input {
1506            build_repository_verification_state_with_worktree_status_and_machine_contract(
1507                repo, health, status, input,
1508            )
1509        } else {
1510            build_repository_verification_state_with_worktree_status(repo, health, status)
1511        }
1512    } else {
1513        if let Some(input) = &plan.machine_contract_input {
1514            build_repository_verification_state_with_machine_contract(repo, input)?
1515        } else {
1516            build_repository_verification_state(repo)?
1517        }
1518    };
1519    let post_verification_ms = verification_started.elapsed().as_millis();
1520
1521    let summary = match plan.verb {
1522        SaveVerb::Capture => format!(
1523            "Captured state {} ({})",
1524            state.state_id.short(),
1525            state.hash().short()
1526        ),
1527        SaveVerb::Checkpoint => git_checkpoint
1528            .as_ref()
1529            .map(|r| r.summary.clone())
1530            .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short())),
1531    };
1532
1533    let signature_lookup_started = Instant::now();
1534    let signed = repo.get_state_signature(&state.id())?.is_some();
1535    let signature_lookup_ms = signature_lookup_started.elapsed().as_millis();
1536    Ok(SaveReport {
1537        verb: plan.verb,
1538        state_id: state.state_id,
1539        content_hash: state.hash(),
1540        intent: state.intent.clone(),
1541        confidence: state.confidence,
1542        signed,
1543        git_commit,
1544        git_previous_commit,
1545        summary,
1546        principal: state.attribution.principal.clone(),
1547        agent: state.attribution.agent.clone(),
1548        promotion_suggested,
1549        heavy_impact_paths,
1550        captured_path_count,
1551        verification,
1552        created_new_state,
1553        git_checkpoint,
1554        snapshot_profile,
1555        state_create_ms,
1556        captured_path_count_ms,
1557        post_verification_ms,
1558        thread_metadata_ms,
1559        previous_state_ms,
1560        previous_state_profile,
1561        signature_lookup_ms,
1562    })
1563}
1564
1565struct CreatedState {
1566    state: State,
1567    profile: SnapshotProfile,
1568    thread_metadata_ms: u128,
1569    promotion_suggested: bool,
1570    heavy_impact_paths: Vec<String>,
1571}
1572
1573fn create_heddle_state(repo: &Repository, plan: &SavePlan) -> Result<CreatedState> {
1574    let hook_manager = HookManager::new(repo);
1575    let hook_ctx = HookContext::new(repo);
1576    let mut post_hook_worktree_changes = None;
1577
1578    if plan.run_hooks {
1579        let pre_snapshot_ran = hook_manager.run(Hook::PreSnapshot, &hook_ctx)?;
1580        let pre_capture_payload = serde_json::json!({
1581            "thread": current_thread_name(repo),
1582            "intent": plan.intent.clone().unwrap_or_default(),
1583        });
1584        let pre_capture_response = hook_manager.run_with_payload(
1585            Hook::PreSnapshot,
1586            &hook_ctx,
1587            &pre_capture_payload,
1588            std::time::Duration::from_secs(5),
1589        )?;
1590        if let Some(resp) = pre_capture_response
1591            && !resp.abort.is_empty()
1592        {
1593            return Err(anyhow!(HeddleError::recovery(
1594                RecoveryDetails::safety_refusal(
1595                    "hook_veto",
1596                    format!("pre_capture hook vetoed: {}", resp.abort),
1597                    "Inspect `pre_capture` with `heddle hook list`, update the hook policy or inputs, then retry.",
1598                    format!("pre_capture hook vetoed capture: {}", resp.abort),
1599                    "capture would continue after repository policy explicitly aborted the operation",
1600                    "the operation stopped at the hook boundary before the protected action ran",
1601                )
1602                .with_recovery_commands(vec!["heddle hook list".to_string()]),
1603            )));
1604        }
1605        if pre_snapshot_ran && plan.supplied_tree.is_none() {
1606            // Hooks can mutate paths outside capture's preflight set. Rewalk
1607            // authoritatively so a settled monitor token cannot vouch for a
1608            // tree built from that stale set. No-hook captures keep the fast path.
1609            let authoritative_options = WorktreeStatusOptions {
1610                fsmonitor: repo::FsMonitorSettings {
1611                    mode: repo::FsMonitorMode::Off,
1612                },
1613            };
1614            post_hook_worktree_changes =
1615                Some(capture_worktree_status(repo, &authoritative_options)?);
1616        }
1617    }
1618    let mut execution = if let Some(tree) = plan.supplied_tree.clone() {
1619        repo.snapshot_tree_with_attribution_profiled(
1620            tree,
1621            plan.intent.clone(),
1622            plan.confidence,
1623            plan.attribution.clone(),
1624        )?
1625    } else if let Some(status) =
1626        post_hook_worktree_changes.or_else(|| plan.known_worktree_changes.clone())
1627    {
1628        repo.snapshot_with_attribution_profiled_from_status(
1629            plan.intent.clone(),
1630            plan.confidence,
1631            plan.attribution.clone(),
1632            status,
1633            plan.require_worktree_change,
1634        )?
1635    } else if plan.require_worktree_change {
1636        repo.snapshot_with_attribution_profiled_if_changed(
1637            plan.intent.clone(),
1638            plan.confidence,
1639            plan.attribution.clone(),
1640        )?
1641    } else {
1642        repo.snapshot_with_attribution_profiled(
1643            plan.intent.clone(),
1644            plan.confidence,
1645            plan.attribution.clone(),
1646        )?
1647    };
1648
1649    let thread_metadata_start = Instant::now();
1650    let refresh = refresh_active_thread_metadata(repo, &execution.state, &execution.tree)?;
1651    let thread_metadata_ms = thread_metadata_start.elapsed().as_millis();
1652
1653    if plan.run_hooks {
1654        hook_manager.run(Hook::PostSnapshot, &hook_ctx)?;
1655        let post_capture_payload = serde_json::json!({
1656            "state_id": execution.state.state_id.to_string_full(),
1657        });
1658        if let Err(err) = hook_manager.run_with_payload(
1659            Hook::PostSnapshot,
1660            &hook_ctx,
1661            &post_capture_payload,
1662            std::time::Duration::from_secs(5),
1663        ) {
1664            tracing::warn!(error = %err, "post_capture hook error swallowed");
1665        }
1666    }
1667
1668    Ok(CreatedState {
1669        state: execution.state,
1670        profile: std::mem::take(&mut execution.profile),
1671        thread_metadata_ms,
1672        promotion_suggested: refresh.promotion_suggested,
1673        heavy_impact_paths: refresh.heavy_impact_paths,
1674    })
1675}
1676
1677fn write_git_checkpoint(
1678    repo: &Repository,
1679    state: &State,
1680    summary: String,
1681    linearize_git_parent: bool,
1682) -> Result<GitCheckpointRecord> {
1683    let _lock = repo.locker().write()?;
1684    objects::fault_inject::maybe_fail_at("git_checkpoint_before_write_through")?;
1685    let mut bridge = GitProjection::new(repo);
1686    if linearize_git_parent {
1687        bridge.linearize_unmapped_tip_to_checkout();
1688    }
1689    let git_commit = match bridge
1690        .write_through_current_checkout_with_message(state.state_id, summary.clone())?
1691    {
1692        WriteThroughOutcome::Wrote(git_commit) => git_commit.to_string(),
1693        WriteThroughOutcome::Skipped(reason) => {
1694            return Err(anyhow!(HeddleError::recovery(
1695                RecoveryDetails::safety_refusal(
1696                    "checkpoint_git_write_skipped",
1697                    format!("Git checkpoint write-through was skipped: {reason}"),
1698                    "Inspect `heddle verify`, resolve the skip reason, then retry `heddle land`.",
1699                    format!("write-through skipped: {reason}"),
1700                    "checkpoint would need to write the current Heddle state into the Git branch and index",
1701                    "the current Heddle state was preserved; no Git checkpoint record was written",
1702                ),
1703            )));
1704        }
1705    };
1706    let intent = repo.pending_git_checkpoint_intent()?.ok_or_else(|| {
1707        anyhow!("Git checkpoint published without its durable finalization intent")
1708    })?;
1709    if intent.phase != repo::GitCheckpointIntentPhase::Published
1710        || intent.state_id != state.state_id.to_string_full()
1711        || intent.new_git_oid != git_commit
1712    {
1713        return Err(anyhow!(
1714            "published Git checkpoint does not match its durable finalization intent"
1715        ));
1716    }
1717    finalize_published_git_checkpoint(repo, &state.state_id, git_commit, summary, intent)
1718}
1719
1720/// Finish the metadata/oplog half of a checkpoint whose Git ref was already
1721/// published before a crash. Returns `None` when no matching published intent
1722/// exists, so callers can continue with their own recovery policy.
1723pub fn recover_published_git_checkpoint(
1724    repo: &Repository,
1725    state_id: &StateId,
1726) -> Result<Option<GitCheckpointRecord>> {
1727    let _lock = repo.locker().write()?;
1728    let Some(mut intent) = repo.pending_git_checkpoint_intent()? else {
1729        return Ok(None);
1730    };
1731    if intent.state_id != state_id.to_string_full() {
1732        return Ok(None);
1733    }
1734    let current_branch = repo.git_overlay_current_branch()?;
1735    if current_branch.as_deref() != Some(intent.branch.as_str()) {
1736        return Err(anyhow!(
1737            "pending Git checkpoint targets branch '{}' but the checkout is on '{}'",
1738            intent.branch,
1739            current_branch.as_deref().unwrap_or("detached HEAD")
1740        ));
1741    }
1742    let current_oid = git_rev_parse_head(repo.root());
1743    if intent.phase == repo::GitCheckpointIntentPhase::Prepared {
1744        if current_oid == intent.previous_git_oid {
1745            return Ok(None);
1746        }
1747        if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1748            return Err(anyhow!(
1749                "prepared Git checkpoint expected HEAD at {} or {}, found {}",
1750                intent.previous_git_oid.as_deref().unwrap_or("<unborn>"),
1751                intent.new_git_oid,
1752                current_oid.as_deref().unwrap_or("<unborn>")
1753            ));
1754        }
1755        let git_oid = intent.new_git_oid.clone();
1756        intent = repo.mark_git_checkpoint_published(state_id, &git_oid)?;
1757    }
1758    if intent.phase != repo::GitCheckpointIntentPhase::Published {
1759        return Ok(None);
1760    }
1761    if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1762        return Err(anyhow!(
1763            "published Git checkpoint expected HEAD at {}, found {}",
1764            intent.new_git_oid,
1765            current_oid.as_deref().unwrap_or("<unborn>")
1766        ));
1767    }
1768    let git_commit = intent.new_git_oid.clone();
1769    let summary = intent.summary.clone();
1770    finalize_published_git_checkpoint(repo, state_id, git_commit, summary, intent).map(Some)
1771}
1772
1773fn finalize_published_git_checkpoint(
1774    repo: &Repository,
1775    state_id: &StateId,
1776    git_commit: String,
1777    summary: String,
1778    intent: repo::GitCheckpointIntent,
1779) -> Result<GitCheckpointRecord> {
1780    let record = repo.record_git_checkpoint(state_id, git_commit.clone(), summary)?;
1781    objects::fault_inject::maybe_panic_at("git_checkpoint_after_metadata_before_oplog");
1782    let transaction_id = format!(
1783        "git-checkpoint:v1:{}:{}",
1784        state_id.to_string_full(),
1785        git_commit
1786    );
1787    repo.oplog().record_batch_exactly_once(
1788        vec![
1789            OpRecord::GitCheckpoint {
1790                branch: intent.branch,
1791                state: *state_id,
1792                previous_git_oid: intent.previous_git_oid,
1793                new_git_oid: git_commit.clone(),
1794            },
1795            OpRecord::TransactionCommit {
1796                transaction_id: transaction_id.clone(),
1797                op_count: 1,
1798            },
1799        ],
1800        Some(&repo.op_scope()),
1801        &transaction_id,
1802    )?;
1803    objects::fault_inject::maybe_panic_at("git_checkpoint_after_oplog_before_finalize");
1804    repo.finish_git_checkpoint_intent(state_id, &git_commit)?;
1805    Ok(record)
1806}
1807
1808fn coalesce_snapshot_and_checkpoint(
1809    repo: &Repository,
1810    state_id: &StateId,
1811    git_commit: &str,
1812) -> Result<()> {
1813    let snapshot_batch = repo
1814        .oplog()
1815        .recent_batches_scoped(8, Some(&repo.op_scope()))?
1816        .into_iter()
1817        .find(|batch| {
1818            batch.entries.iter().any(|entry| {
1819                matches!(
1820                    &entry.operation,
1821                    OpRecord::Snapshot { new_state, .. } if new_state == state_id
1822                )
1823            })
1824        })
1825        .ok_or_else(|| anyhow!("capture succeeded but its oplog batch was not found"))?;
1826    let checkpoint_batch = repo
1827        .oplog()
1828        .recent_batches_scoped(8, Some(&repo.op_scope()))?
1829        .into_iter()
1830        .find(|batch| {
1831            batch.entries.iter().any(|entry| {
1832                matches!(
1833                    &entry.operation,
1834                    OpRecord::GitCheckpoint { new_git_oid, .. } if new_git_oid == git_commit
1835                )
1836            })
1837        })
1838        .ok_or_else(|| anyhow!("Git checkpoint succeeded but its oplog batch was not found"))?;
1839    repo.oplog()
1840        .coalesce_batches(snapshot_batch.id, checkpoint_batch.id)
1841        .context(
1842            "capture completed but failed to record its state and Git checkpoint as one undo batch",
1843        )?;
1844    Ok(())
1845}
1846
1847fn checkpoint_summary(plan: &SavePlan, state: &State) -> String {
1848    plan.intent
1849        .clone()
1850        .or_else(|| state.intent.clone())
1851        .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short()))
1852}
1853
1854fn current_thread_name(repo: &Repository) -> String {
1855    match repo.head_ref() {
1856        Ok(Head::Attached { thread }) => thread.to_string(),
1857        _ => String::new(),
1858    }
1859}
1860
1861fn git_rev_parse_head(root: &std::path::Path) -> Option<String> {
1862    let git = SleyRepository::discover(root).ok()?;
1863    git.head().ok()?.oid.map(|id| id.to_string())
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868    use repo::RepositoryCapability;
1869    use tempfile::TempDir;
1870
1871    use super::*;
1872
1873    #[test]
1874    fn capture_interface_owns_mutation_and_returns_the_report_contract() {
1875        let temp = TempDir::new().expect("create temp repository");
1876        let repo = Repository::init_default(temp.path()).expect("initialize repository");
1877        std::fs::write(temp.path().join("tracked.txt"), "captured\n")
1878            .expect("write worktree change");
1879        let ctx = ExecutionContext::builder()
1880            .repo(repo)
1881            .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
1882            .build();
1883        let report = capture(
1884            &ctx,
1885            CaptureOptions {
1886                intent: "exercise the deep capture interface".into(),
1887                confidence: Some(0.9),
1888                force: false,
1889                agent: CaptureAgentOptions::default(),
1890                machine_contract_input: None,
1891            },
1892        )
1893        .expect("capture succeeds");
1894
1895        assert_eq!(report.output_kind, "capture");
1896        assert_eq!(report.captured_path_count, 1);
1897        assert_eq!(report.principal.name, "Ada");
1898        assert_eq!(report.principal.email, "ada@example.com");
1899        assert_eq!(report.principal_source, "user_config");
1900        assert_eq!(CaptureReport::CONTRACT.schema_name, "capture");
1901        assert_eq!(
1902            ctx.require_repo()
1903                .expect("repository")
1904                .head()
1905                .expect("read head")
1906                .expect("captured head")
1907                .short(),
1908            report.state_id
1909        );
1910
1911        let wire = serde_json::to_value(&report).expect("serialize report");
1912        assert_eq!(wire["output_kind"], "capture");
1913        assert!(wire.get("diagnostics").is_none());
1914        assert!(wire.get("captured_thread_targets_integration").is_none());
1915    }
1916
1917    #[test]
1918    fn manual_resolution_land_action_quotes_untrusted_thread_ids() {
1919        let temp = TempDir::new().expect("create temp repository");
1920        let repo = Repository::init_default(temp.path()).expect("initialize repository");
1921
1922        assert_eq!(
1923            manual_resolution_land_action(&repo, "bad;echo pwn", None),
1924            "heddle land --thread 'bad;echo pwn'"
1925        );
1926        assert_eq!(
1927            manual_resolution_land_action(&repo, "-danger", None),
1928            "heddle land --thread=-danger"
1929        );
1930    }
1931
1932    #[test]
1933    fn clean_overlay_refuses_before_requiring_identity() {
1934        let temp = TempDir::new().expect("create temp repository");
1935        SleyRepository::init(temp.path()).expect("initialize Git repository");
1936        let repo = Repository::init_git_overlay_sidecar(temp.path())
1937            .expect("initialize Git-overlay sidecar");
1938        let ctx = ExecutionContext::builder().repo(repo).build();
1939
1940        let error = capture(
1941            &ctx,
1942            CaptureOptions {
1943                intent: "nothing changed".into(),
1944                confidence: None,
1945                force: false,
1946                agent: CaptureAgentOptions::default(),
1947                machine_contract_input: None,
1948            },
1949        )
1950        .expect_err("clean overlay must refuse capture");
1951
1952        assert!(error.to_string().contains("nothing to capture"));
1953    }
1954
1955    #[test]
1956    fn capture_rejects_missing_intent_before_mutation() {
1957        let temp = TempDir::new().expect("create temp repository");
1958        let repo = Repository::init_default(temp.path()).expect("initialize repository");
1959        std::fs::write(temp.path().join("tracked.txt"), "uncaptured\n")
1960            .expect("write worktree change");
1961        let ctx = ExecutionContext::builder()
1962            .repo(repo)
1963            .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
1964            .build();
1965        let before = ctx.require_repo().expect("repository").head().unwrap();
1966
1967        let error = capture(
1968            &ctx,
1969            CaptureOptions {
1970                intent: "   ".into(),
1971                confidence: None,
1972                force: false,
1973                agent: CaptureAgentOptions::default(),
1974                machine_contract_input: None,
1975            },
1976        )
1977        .expect_err("blank intent must be rejected by the operation interface");
1978
1979        assert!(
1980            error
1981                .to_string()
1982                .contains("refusing to capture without an intent")
1983        );
1984        assert_eq!(
1985            ctx.require_repo().expect("repository").head().unwrap(),
1986            before
1987        );
1988    }
1989
1990    #[test]
1991    fn attribution_cleaning_only_rejects_the_placeholder_token() {
1992        assert_eq!(clean_attribution_value("  unknown  ".into()), None);
1993        assert_eq!(clean_attribution_value("   ".into()), None);
1994        assert_eq!(
1995            clean_attribution_value("unknown-model-v2".into()),
1996            Some("unknown-model-v2".into())
1997        );
1998    }
1999
2000    #[test]
2001    fn identity_freeze_waits_for_the_repository_write_lock() {
2002        use std::{sync::mpsc, time::Duration};
2003
2004        let temp = TempDir::new().expect("create temp repository");
2005        let repo = Repository::init_default(temp.path()).expect("initialize repository");
2006        let hold = repo.locker().write().expect("hold repository lock");
2007        let root = repo.root().to_path_buf();
2008        let (tx, rx) = mpsc::channel();
2009        let worker = std::thread::spawn(move || {
2010            let repo = Repository::open(root).expect("open worker repository");
2011            let patch = IdentityCursor {
2012                provider: Some("anthropic".into()),
2013                model: Some("opus".into()),
2014                ..IdentityCursor::default()
2015            };
2016            let frozen = freeze_identity_for_capture(&repo, &patch);
2017            let _ = tx.send(frozen.is_ok());
2018        });
2019
2020        assert!(
2021            rx.recv_timeout(Duration::from_millis(150)).is_err(),
2022            "identity mutation must wait for the repository lock"
2023        );
2024        drop(hold);
2025        assert!(
2026            rx.recv_timeout(Duration::from_secs(2))
2027                .expect("worker should finish after lock release")
2028        );
2029        worker.join().expect("join identity worker");
2030    }
2031
2032    #[test]
2033    fn reserved_capture_paths_consider_selected_additions_and_modifications_only() {
2034        let status = WorktreeStatus {
2035            added: vec![std::path::PathBuf::from("services/api/.env.local")],
2036            modified: Vec::new(),
2037            deleted: vec![std::path::PathBuf::from("old/.env")],
2038        };
2039        assert_eq!(
2040            reserved_capture_path(&status).as_deref(),
2041            Some("services/api/.env.local")
2042        );
2043
2044        let deletion_only = WorktreeStatus {
2045            deleted: vec![std::path::PathBuf::from(".env")],
2046            ..WorktreeStatus::default()
2047        };
2048        assert!(reserved_capture_path(&deletion_only).is_none());
2049    }
2050
2051    #[test]
2052    fn plan_creates_new_state_routing() {
2053        let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2054        let capture = SavePlan::capture("wip", attr.clone());
2055        assert!(plan_creates_new_state(&capture, true));
2056        assert!(plan_creates_new_state(&capture, false));
2057
2058        let checkpoint = SavePlan::checkpoint(Some("cp".into()), attr.clone(), false);
2059        assert!(!plan_creates_new_state(&checkpoint, true));
2060        assert!(plan_creates_new_state(&checkpoint, false));
2061
2062        let staged =
2063            SavePlan::checkpoint(Some("cp".into()), attr, true).with_supplied_tree(Tree::new());
2064        assert!(plan_creates_new_state(&staged, true));
2065    }
2066
2067    #[test]
2068    fn plan_writes_git_checkpoint_respects_scope_and_capability() {
2069        let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2070        let mut capture = SavePlan::capture("wip", attr);
2071        assert!(!plan_writes_git_checkpoint(
2072            &capture,
2073            RepositoryCapability::GitOverlay
2074        ));
2075        capture.git_scope = GitScope::WorktreeAll;
2076        assert!(plan_writes_git_checkpoint(
2077            &capture,
2078            RepositoryCapability::GitOverlay
2079        ));
2080        assert!(!plan_writes_git_checkpoint(
2081            &capture,
2082            RepositoryCapability::NativeHeddle
2083        ));
2084    }
2085
2086    #[test]
2087    fn save_plan_builders_set_expected_defaults() {
2088        let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2089        let capture = SavePlan::capture("intent", attr.clone());
2090        assert_eq!(capture.verb, SaveVerb::Capture);
2091        assert_eq!(capture.git_scope, GitScope::None);
2092        assert!(!capture.coalesce_snapshot_and_checkpoint);
2093
2094        let staged = SavePlan::checkpoint(None, attr, true);
2095        assert_eq!(staged.git_scope, GitScope::Staged);
2096        assert!(!staged.require_clean_worktree);
2097        assert!(staged.reuse_current_state);
2098    }
2099
2100    #[test]
2101    fn tree_leaf_name_returns_the_final_component() {
2102        assert_eq!(tree_leaf_name("a/b/c.rs"), "c.rs");
2103        assert_eq!(tree_leaf_name("solo"), "solo");
2104    }
2105}