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 = resolve_capture_attribution(
449        repo,
450        ctx.principal_fallback(),
451        ctx.hosted_principal(),
452        ctx.hosted_account_unclaimed(),
453        &options.agent,
454    )?;
455    let harness_session_id = resolved_attribution
456        .attribution
457        .agent
458        .is_some()
459        .then(|| resolved_attribution.harness_session_id.clone())
460        .flatten();
461    let mut warnings = resolved_attribution.warnings;
462    let attribution_ms = attribution_started.elapsed().as_millis();
463
464    let git_overlay = repo.capability() == RepositoryCapability::GitOverlay;
465    let plan = SavePlan {
466        verb: SaveVerb::Capture,
467        intent: Some(options.intent),
468        confidence: options.confidence,
469        attribution: resolved_attribution.attribution,
470        git_scope: if git_overlay {
471            GitScope::WorktreeAll
472        } else {
473            GitScope::None
474        },
475        supplied_tree: None,
476        reuse_current_state,
477        require_clean_worktree: git_overlay,
478        // Native repositories compare the built tree with their Heddle HEAD.
479        // Git-overlay performs its distinct authority-aware comparison above:
480        // an overlay can legitimately have no Heddle HEAD yet and still need
481        // to capture an empty tree that represents deletion from Git's base.
482        require_worktree_change: repo.capability() == RepositoryCapability::NativeHeddle
483            && !complete_thread_resolution,
484        worktree_status_options: ctx.worktree_status_options(),
485        known_worktree_changes,
486        run_hooks: true,
487        coalesce_snapshot_and_checkpoint: git_overlay,
488        // Git Overlay must extend the checkout's authoritative Git tip even
489        // when the lazily-bound Heddle parent has no byte-identical mapping.
490        // Otherwise the first capture after init can replace imported Git
491        // history with a reconstructed metadata root.
492        linearize_git_parent: git_overlay,
493        precomputed_worktree_status: Some(worktree_status),
494        machine_contract_input: options.machine_contract_input,
495    };
496    let execute_save_started = Instant::now();
497    let save = execute_save(repo, plan).map_err(map_capture_error)?;
498    let execute_save_ms = execute_save_started.elapsed().as_millis();
499    if let Some(session_id) = harness_session_id
500        && let Err(error) = crate::record_last_turn_capture(repo, &session_id, save.state_id)
501    {
502        warnings.push(format!(
503            "could not update the reconstructible last-turn anchor: {error}"
504        ));
505    }
506
507    let manual_resolution_action = if complete_thread_resolution {
508        complete_current_thread_manual_resolution(repo)?
509    } else {
510        None
511    };
512
513    let current_thread = current_thread(repo)?;
514    let captured_thread_targets_integration = current_thread
515        .as_ref()
516        .and_then(|thread| thread.target_thread.as_ref())
517        .is_some();
518    let task_assignment_id = active_task_assignment_id(repo, current_thread.as_ref())?;
519    let principal_source = resolved_attribution.principal_source;
520    warnings.extend(bulk_capture_warning(save.captured_path_count));
521
522    let mut recommended_action = non_empty_action(&save.verification.recommended_action);
523    let mut recommended_action_template = recommended_action
524        .as_deref()
525        .and_then(action_template)
526        .or_else(|| save.verification.recommended_action_template.clone());
527    if let Some(action) = manual_resolution_action {
528        recommended_action_template = action_template(&action);
529        recommended_action = Some(action);
530    }
531
532    let principal = CapturePrincipalReport {
533        name: save.principal.name_lossy().into_owned(),
534        email: save.principal.email_lossy().into_owned(),
535    };
536    let agent = save.agent.as_ref().map(|agent| CaptureAgentReport {
537        provider: agent.provider.clone(),
538        model: agent.model.clone(),
539        session_id: agent.session_id.clone(),
540        segment_id: agent.segment_id.clone(),
541        policy_id: agent.policy_id.clone(),
542        thought_level: agent.thought_level.clone(),
543        parent: agent.parent.clone(),
544    });
545    Ok(CaptureReport {
546        output_kind: "capture",
547        state_id: save.state_id.short(),
548        content_hash: save.content_hash.short(),
549        git_checkpoint: save.git_commit.clone(),
550        intent: save.intent.clone(),
551        confidence: save.confidence,
552        task_assignment_id,
553        principal,
554        principal_source,
555        agent,
556        promotion_suggested: save.promotion_suggested,
557        heavy_impact_paths: save.heavy_impact_paths.clone(),
558        captured_path_count: save.captured_path_count,
559        warnings,
560        signed: save.signed,
561        message: save.summary.clone(),
562        recommended_action,
563        recommended_action_template,
564        verification: save.verification.clone(),
565        captured_thread_targets_integration,
566        diagnostics: CaptureDiagnostics {
567            profile: CaptureProfile {
568                worktree_status_ms,
569                preflight_ms,
570                attribution_ms,
571                execute_save_ms,
572            },
573            snapshot_profile: save.snapshot_profile,
574            state_create_ms: save.state_create_ms,
575            captured_path_count_ms: save.captured_path_count_ms,
576            post_verification_ms: save.post_verification_ms,
577            thread_metadata_ms: save.thread_metadata_ms,
578            previous_state_ms: save.previous_state_ms,
579            previous_state_profile: save.previous_state_profile,
580            signature_lookup_ms: save.signature_lookup_ms,
581        },
582    })
583}
584
585/// Resolve attribution using explicit embedding inputs plus repository state.
586///
587/// This remains public for internal save paths that have not yet moved to the
588/// complete [`capture`] operation. New capture callers should use [`capture`]
589/// so safety checks continue to run before identity/session mutation.
590fn resolve_capture_attribution(
591    repo: &Repository,
592    principal_fallback: Option<(&str, &str)>,
593    hosted_principal: Option<(&str, &str)>,
594    hosted_account_unclaimed: bool,
595    options: &CaptureAgentOptions,
596) -> Result<CaptureAttribution> {
597    let resolved_principal = crate::apply_hosted_principal_fallback(
598        crate::resolve_principal(repo, principal_fallback)?,
599        hosted_principal,
600    );
601    let principal_source = resolved_principal
602        .source
603        .unwrap_or("not_configured")
604        .to_string();
605    let Some(principal) = resolved_principal.principal else {
606        let (summary, commands) = if hosted_account_unclaimed {
607            (
608                "Run `heddle claim` to attach the signed-in account to a human identity with an email, or configure a local principal, then retry the capture.",
609                vec![
610                    "heddle claim".to_string(),
611                    "heddle init --principal-name <name> --principal-email <email>".to_string(),
612                    "heddle capture -m \"...\"".to_string(),
613                ],
614            )
615        } else {
616            (
617                "Set `HEDDLE_PRINCIPAL_NAME` and `HEDDLE_PRINCIPAL_EMAIL`, or run `heddle init --principal-name <name> --principal-email <email>`, then retry the capture.",
618                vec![
619                    "heddle init --principal-name <name> --principal-email <email>".to_string(),
620                    "heddle capture -m \"...\"".to_string(),
621                ],
622            )
623        };
624        return Err(capture_refusal(
625            "capture_identity_required",
626            "Refusing to capture: no accountable identity is configured",
627            summary,
628            "No accountable name/email is configured for capture attribution",
629            "capture would create durable Heddle history without a real principal",
630            "Heddle refs, captured states, Git refs, index, and worktree files were left unchanged",
631            commands,
632        ));
633    };
634
635    if options.no_agent {
636        return Ok(CaptureAttribution {
637            attribution: Attribution::human(principal),
638            principal_source,
639            harness_session_id: None,
640            warnings: Vec::new(),
641        });
642    }
643
644    // Identity cursor persistence and Heddle session rotation are part of the
645    // capture transaction's semantic prelude. Failure remains best-effort to
646    // preserve the existing rule that missing agent metadata never blocks a
647    // human-attributed capture.
648    let (frozen, warnings) = match freeze_identity_for_capture(repo, &options.identity_patch) {
649        Ok(frozen) => (frozen, Vec::new()),
650        Err(error) => (
651            FrozenCaptureIdentity::default(),
652            vec![format!(
653                "could not freeze agent identity for this capture; recorded human attribution: {error}"
654            )],
655        ),
656    };
657    let harness_session_id = frozen.session.clone();
658    let current_session = SessionManager::new(repo.root()).get_current_session()?;
659    let provider = options
660        .provider
661        .clone()
662        .or(frozen.provider.clone())
663        .and_then(clean_attribution_value);
664    let model = options
665        .model
666        .clone()
667        .or(frozen.model.clone())
668        .and_then(clean_attribution_value);
669    let session_policy = current_session
670        .as_ref()
671        .and_then(|session| session.current_segment())
672        .and_then(|segment| segment.policy_id.clone())
673        .and_then(clean_attribution_value);
674    // Heddle Session.id — never the sidecar harness session.
675    let session_id = options
676        .session
677        .clone()
678        .or_else(|| current_session.as_ref().map(|session| session.id.clone()));
679    let segment_id = options
680        .segment
681        .clone()
682        .or(frozen.segment_id.clone())
683        .or_else(|| {
684            current_session
685                .as_ref()
686                .and_then(|session| session.current_segment_id.clone())
687        });
688    let policy = if options.no_policy {
689        None
690    } else {
691        options
692            .policy
693            .clone()
694            .or_else(|| options.environment_policy.clone())
695            .and_then(clean_attribution_value)
696            .or(session_policy)
697            .or_else(|| options.default_policy.clone())
698            .or_else(|| repo.config().policies.default_policy.clone())
699    };
700
701    let attribution = match (provider, model) {
702        (Some(provider), Some(model)) => {
703            let mut agent = Agent::new(provider, model);
704            if let (Some(session_id), Some(segment_id)) = (session_id, segment_id) {
705                agent = agent.with_session(session_id, segment_id);
706            }
707            if let Some(policy) = policy {
708                agent = agent.with_policy(policy);
709            }
710            if let Some(thought_level) = frozen.thought_level {
711                agent = agent.with_thought_level(thought_level);
712            }
713            if let Some(parent) = frozen.parent {
714                agent = agent.with_parent(parent);
715            }
716            Attribution::with_agent(principal, agent)
717        }
718        _ => Attribution::human(principal),
719    };
720    Ok(CaptureAttribution {
721        attribution,
722        principal_source,
723        harness_session_id,
724        warnings,
725    })
726}
727
728/// Resolve the author for internal save paths that already own orchestration.
729///
730/// Normal callers should use [`capture`], which preserves the required
731/// preflight-before-attribution ordering.
732pub fn resolve_capture_author(
733    repo: &Repository,
734    principal_fallback: Option<(&str, &str)>,
735    options: &CaptureAgentOptions,
736) -> Result<Attribution> {
737    resolve_capture_attribution(repo, principal_fallback, None, false, options)
738        .map(|resolved| resolved.attribution)
739}
740
741#[derive(Clone, Debug, Default)]
742struct FrozenCaptureIdentity {
743    provider: Option<String>,
744    model: Option<String>,
745    thought_level: Option<String>,
746    session: Option<String>,
747    parent: Option<String>,
748    segment_id: Option<String>,
749}
750
751fn freeze_identity_for_capture(
752    repo: &Repository,
753    identity_patch: &IdentityCursor,
754) -> Result<FrozenCaptureIdentity> {
755    let _guard = repo.locker().write()?;
756    let mut cursor = read_identity_cursor(repo.root());
757    if !identity_patch.is_empty() {
758        cursor = stamp_identity_cursor(repo.root(), identity_patch)?;
759    }
760    let mut manager = SessionManager::new(repo.root());
761    let session = manager.get_current_session()?;
762    let segment_id = apply_capture_segment_policy(&mut manager, session.as_ref(), &cursor)?;
763    Ok(FrozenCaptureIdentity {
764        provider: cursor.provider,
765        model: cursor.model,
766        thought_level: cursor.thought_level,
767        session: cursor.session,
768        parent: cursor.parent,
769        segment_id,
770    })
771}
772
773fn apply_capture_segment_policy(
774    manager: &mut SessionManager,
775    session: Option<&objects::object::Session>,
776    cursor: &IdentityCursor,
777) -> Result<Option<String>> {
778    let Some(session) = session else {
779        return Ok(None);
780    };
781    let current = session.current_segment();
782    let rotation = cursor_segment_rotation(
783        current.map(|segment| segment.provider.as_str()),
784        current.map(|segment| segment.model.as_str()),
785        current.and_then(|segment| segment.thought_level.as_deref()),
786        cursor.provider.as_deref(),
787        cursor.model.as_deref(),
788        cursor.thought_level.as_deref(),
789    );
790    if rotation == SegmentRotation::Rotate {
791        let provider = cursor
792            .provider
793            .clone()
794            .or_else(|| current.map(|segment| segment.provider.clone()))
795            .unwrap_or_default();
796        let model = cursor
797            .model
798            .clone()
799            .or_else(|| current.map(|segment| segment.model.clone()))
800            .unwrap_or_default();
801        if published_field(Some(&provider)).is_some() && published_field(Some(&model)).is_some() {
802            let segment = manager.add_segment(&session.id, provider, model, None)?;
803            if let Some(thought_level) = cursor.thought_level.clone()
804                && let Some(mut updated) = manager.get_session(&session.id)?
805            {
806                if let Some(current) = updated.current_segment_mut() {
807                    current.thought_level = Some(thought_level);
808                }
809                manager.save_session(&updated)?;
810            }
811            return Ok(Some(segment.id));
812        }
813    } else if rotation == SegmentRotation::Attach
814        && let Some(mut updated) = manager.get_session(&session.id)?
815    {
816        if let Some(segment) = updated.current_segment_mut() {
817            attach_published_segment_fields(
818                segment,
819                cursor.provider.as_deref(),
820                cursor.model.as_deref(),
821                cursor.thought_level.as_deref(),
822            );
823        }
824        manager.save_session(&updated)?;
825    }
826    Ok(session.current_segment_id.clone())
827}
828
829fn clean_attribution_value(value: String) -> Option<String> {
830    let trimmed = value.trim();
831    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
832        None
833    } else {
834        Some(value)
835    }
836}
837
838fn preflight_unimported_git_history(repo: &Repository, action: &str) -> Result<()> {
839    if repo.capability() != RepositoryCapability::GitOverlay {
840        return Ok(());
841    }
842    let Some(guidance) = repo.git_import_guidance()? else {
843        return Ok(());
844    };
845    if !import_guidance_includes_active_branch(&guidance) {
846        return Ok(());
847    }
848    let branches = preview_paths(&guidance.missing_branches);
849    let command = guidance.recommended_command;
850    Err(capture_refusal(
851        "git_history_needs_import",
852        format!("Refusing to {action}: Git history has not been imported into Heddle"),
853        format!("Run `{command}` before retrying `heddle {action}`."),
854        format!("Git branch(es) waiting for Heddle import: {branches}"),
855        format!(
856            "{action} would write new Heddle state before Heddle has adopted the existing Git history"
857        ),
858        "Git refs, Heddle refs, and worktree files were left unchanged",
859        vec![command],
860    ))
861}
862
863fn preflight_capture_mutation(
864    repo: &Repository,
865    worktree_status: &repo::Result<Option<WorktreeStatus>>,
866    machine_contract_input: Option<&MachineContractInput>,
867) -> Result<()> {
868    if repo.capability() != RepositoryCapability::GitOverlay {
869        return Ok(());
870    }
871    if repo.git_overlay_head_is_detached()? {
872        let primary = detached_head_primary_recovery(repo);
873        return Err(capture_refusal(
874            "git_head_detached",
875            "Refusing to capture: Git HEAD is detached",
876            format!("Run `{primary}` before retrying `heddle capture`."),
877            "Git HEAD points directly to a commit instead of an attached branch",
878            "capture would need to write a Git checkpoint through a branch and could reattach or advance the wrong ref",
879            "Git refs, Heddle refs, Git checkpoints, and worktree files were left unchanged",
880            vec![primary],
881        ));
882    }
883    if let Some(operation) = repo.operation_status()?
884        && matches!(operation.scope, OperationScope::Git)
885    {
886        return Err(capture_refusal(
887            "raw_git_operation_in_progress",
888            format!(
889                "Refusing to capture: an externally-started Git {} is in progress",
890                operation.kind
891            ),
892            format!(
893                "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`.",
894                operation.kind
895            ),
896            format!(
897                "Git {} is {}; Heddle cannot safely turn sequencer state into a saved change inside the no-git runtime",
898                operation.kind, operation.state
899            ),
900            "capture would capture worktree/index contents while Git still has unresolved sequencer metadata",
901            "Git refs, Git sequencer files, Heddle refs, and worktree files were left unchanged",
902            vec!["heddle verify".to_string()],
903        ));
904    }
905
906    let health = build_repository_verification_health_with_worktree_status(repo, worktree_status);
907    let trust = if let Some(input) = machine_contract_input {
908        build_repository_verification_state_with_worktree_status_and_machine_contract(
909            repo,
910            health,
911            worktree_status,
912            input,
913        )
914    } else {
915        build_repository_verification_state_with_worktree_status(repo, health, worktree_status)
916    };
917    if !checkpoint_trust_allows_ref_update(&trust)
918        && !checkpoint_can_close_integrated_remote_gap(repo, &trust)
919    {
920        let (primary, recovery_commands) = capture_blocked_recovery(repo, &trust);
921        return Err(capture_refusal(
922            "git_checkpoint_preflight_blocked",
923            "Refusing to capture: Git checkpoint preflight is blocked",
924            format!("Run `{primary}` before retrying `heddle capture`."),
925            format!(
926                "repository verification status is {}; remote drift is {}: {}",
927                trust.status, trust.remote_drift, trust.summary
928            ),
929            "capture would write Heddle state before the Git checkpoint ref update is known to be safe",
930            "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
931            recovery_commands,
932        ));
933    }
934    if trust.status != "needs_reconcile" || uncheckpointed_state_is_ahead_of_git(repo)? {
935        return Ok(());
936    }
937    let (primary, recovery_commands) = capture_blocked_recovery(repo, &trust);
938    Err(capture_refusal(
939        "repository_verification_blocked",
940        format!(
941            "Refusing to capture: repository verification is blocked ({})",
942            trust.status
943        ),
944        format!("Run `{primary}` before retrying `heddle capture`."),
945        format!(
946            "repository verification status is {}: {}",
947            trust.status, trust.summary
948        ),
949        "capture would write new Heddle or Git state while Git and Heddle disagree",
950        "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
951        recovery_commands,
952    ))
953}
954
955fn detached_head_primary_recovery(repo: &Repository) -> String {
956    if let Ok(Head::Attached { thread }) = repo.refs().read_head()
957        && !thread.trim().is_empty()
958    {
959        return if thread.starts_with('-') {
960            heddle_action(["thread", "switch", "--", thread.as_str()])
961        } else {
962            heddle_action(["thread", "switch", thread.as_str()])
963        };
964    }
965    if let Ok(Some(detached_commit)) = repo.git_overlay_detached_head_commit()
966        && let Ok(branch_tips) = repo.git_overlay_branch_tips()
967        && let Some(tip) = branch_tips
968            .iter()
969            .filter(|tip| tip.history_imported)
970            .find(|tip| tip.git_commit == detached_commit)
971    {
972        return heddle_action(["thread", "switch", tip.branch.as_str()]);
973    }
974    heddle_action(["status"])
975}
976
977fn capture_blocked_recovery(
978    repo: &Repository,
979    trust: &RepositoryVerificationState,
980) -> (String, Vec<String>) {
981    if let Some(recovery) = capture_remote_drift_recovery(repo) {
982        return recovery;
983    }
984    let primary =
985        non_empty_action(&trust.recommended_action).unwrap_or_else(|| "heddle verify".to_string());
986    let recovery_commands = if trust.recovery_commands.is_empty() {
987        vec![primary.clone()]
988    } else {
989        trust.recovery_commands.clone()
990    };
991    (primary, recovery_commands)
992}
993
994fn capture_remote_drift_recovery(repo: &Repository) -> Option<(String, Vec<String>)> {
995    let remote = repo.git_remote_tracking_status().ok().flatten()?;
996    let status = remote_tracking_status(&remote);
997    if !matches!(
998        status,
999        "remote_behind" | "remote_diverged" | "remote_contains_undone_checkpoint"
1000    ) {
1001        return None;
1002    }
1003    let recovery_commands = crate::status::remote_drift_recovery_commands(repo, &remote, status);
1004    let primary = recovery_commands.first()?.clone();
1005    Some((primary, recovery_commands))
1006}
1007
1008fn checkpoint_trust_allows_ref_update(trust: &RepositoryVerificationState) -> bool {
1009    let status_allows_checkpoint = matches!(
1010        trust.status.as_str(),
1011        "clean" | "dirty_worktree" | "needs_checkpoint" | "remote_ahead" | "remote_untracked"
1012    );
1013    let remote_allows_checkpoint = matches!(
1014        trust.remote_drift.as_str(),
1015        "clean" | "remote_ahead" | "remote_untracked"
1016    );
1017    status_allows_checkpoint && remote_allows_checkpoint
1018}
1019
1020fn checkpoint_can_close_integrated_remote_gap(
1021    repo: &Repository,
1022    trust: &RepositoryVerificationState,
1023) -> bool {
1024    if trust.status != "needs_checkpoint"
1025        || !matches!(
1026            trust.remote_drift.as_str(),
1027            "remote_behind" | "remote_diverged"
1028        )
1029    {
1030        return false;
1031    }
1032    let Some(remote) = repo.git_remote_tracking_status().ok().flatten() else {
1033        return false;
1034    };
1035    let upstream = remote.upstream.trim();
1036    if upstream.is_empty() {
1037        return false;
1038    }
1039    let Ok(Some(upstream_state)) = repo.refs().get_thread(&ThreadName::new(upstream)) else {
1040        return false;
1041    };
1042    let Ok(Some(current_state)) = repo.head() else {
1043        return false;
1044    };
1045    let mut graph = CommitGraphIndex::new(repo);
1046    graph
1047        .is_ancestor(&upstream_state, &current_state)
1048        .unwrap_or(false)
1049}
1050
1051fn uncheckpointed_state_is_ahead_of_git(repo: &Repository) -> Result<bool> {
1052    let Some(branch) = repo.git_overlay_current_branch()? else {
1053        return Ok(false);
1054    };
1055    let Some(current) = repo.current_state()? else {
1056        return Ok(false);
1057    };
1058    if repo
1059        .latest_git_checkpoint_for_state(&current.state_id)?
1060        .is_some()
1061    {
1062        return Ok(false);
1063    }
1064    // An attached unborn branch has no Git tip to map. If the worktree already
1065    // matches an uncheckpointed Heddle state, capture must publish that state as
1066    // the branch's first commit instead of misreporting a no-op.
1067    let Some(tip) = repo.git_overlay_branch_tip(&branch)? else {
1068        return Ok(true);
1069    };
1070    let Some(mapped) = tip.mapped_state else {
1071        return Ok(false);
1072    };
1073    if mapped == current.state_id {
1074        return Ok(false);
1075    }
1076    let mut graph = CommitGraphIndex::new(repo);
1077    if !graph
1078        .is_ancestor(&mapped, &current.state_id)
1079        .unwrap_or(false)
1080        || graph
1081            .is_ancestor(&current.state_id, &mapped)
1082            .unwrap_or(false)
1083    {
1084        return Ok(false);
1085    }
1086    Ok(true)
1087}
1088
1089fn preflight_large_capture(
1090    force: bool,
1091    worktree_status: &repo::Result<Option<WorktreeStatus>>,
1092) -> Result<()> {
1093    if force {
1094        return Ok(());
1095    }
1096    let Ok(Some(status)) = worktree_status else {
1097        return Ok(());
1098    };
1099    let total = status.change_count();
1100    let delete_count = status.deleted.len();
1101    let add_count = status.added.len();
1102    if !crate::large_capture_requires_force(total, delete_count, add_count) {
1103        return Ok(());
1104    }
1105    let sample = status
1106        .deleted
1107        .iter()
1108        .chain(status.added.iter())
1109        .chain(status.modified.iter())
1110        .take(5)
1111        .map(|path| path.display().to_string())
1112        .collect::<Vec<_>>()
1113        .join(", ");
1114    let sample = if sample.is_empty() {
1115        "no sample paths available".to_string()
1116    } else {
1117        sample
1118    };
1119    Err(capture_refusal(
1120        "large_capture_requires_force",
1121        format!(
1122            "Large capture safety check: this would capture {total} changed paths ({delete_count} deletions, {add_count} additions)"
1123        ),
1124        "If this is intentional, rerun with `heddle capture --force -m \"...\"`.",
1125        format!("sample changed paths: {sample}"),
1126        "capture would preserve an unusually large Git-overlay worktree change without an explicit confirmation",
1127        "repository state, refs, metadata, and worktree files were left unchanged",
1128        vec!["heddle capture --force -m \"...\"".to_string()],
1129    ))
1130}
1131
1132fn merge_resolution_is_complete(repo: &Repository) -> Result<bool> {
1133    Ok(repo
1134        .merge_state_manager()
1135        .load()?
1136        .is_some_and(|merge_state| {
1137            merge_state
1138                .conflicts
1139                .iter()
1140                .all(|path| merge_state.resolved.contains(path))
1141        }))
1142}
1143
1144/// Compare against Heddle's current tree before asking Git for its index-based
1145/// status. These are distinct authorities: Sley's Git status cannot prime
1146/// Heddle's persisted worktree index, which the following snapshot consumes.
1147/// In particular, retaining this check prevents a fast forced retry after a
1148/// refused directory deletion from being misclassified as an unchanged tree.
1149fn capture_worktree_status(
1150    repo: &Repository,
1151    options: &WorktreeStatusOptions,
1152) -> Result<WorktreeStatus> {
1153    if repo.current_state_for_worktree_status()?.is_none()
1154        && let Some(status) = repo.git_overlay_worktree_status()?
1155    {
1156        return Ok(status);
1157    }
1158    let tree = match repo.current_state_for_worktree_status()? {
1159        Some(state) => repo.require_tree_for_worktree_status(&state.tree)?,
1160        None => Tree::new(),
1161    };
1162    Ok(repo.compare_worktree_cached_with_options(&tree, options)?)
1163}
1164
1165/// Complete a captured manual resolution and return its contextual land action.
1166///
1167/// This is shared by capture and the operator continuation path so the thread
1168/// metadata/ref/oplog transaction has one implementation.
1169pub fn complete_current_thread_manual_resolution(repo: &Repository) -> Result<Option<String>> {
1170    let Some(current_thread) = repo.current_lane()? else {
1171        return Ok(None);
1172    };
1173    let Some(current_state) = repo.head()? else {
1174        return Ok(None);
1175    };
1176    let Some(current_state_object) = repo.store().get_state(&current_state)? else {
1177        return Ok(None);
1178    };
1179    let manager = ThreadManager::new(repo.heddle_dir());
1180    let Some(mut thread) = manager.find_by_thread(&current_thread)? else {
1181        return Ok(None);
1182    };
1183    let Some(target_thread) = thread.target_thread.clone() else {
1184        return Ok(None);
1185    };
1186    let Some(target_state) = repo.refs().get_thread(&ThreadName::new(&target_thread))? else {
1187        return Ok(None);
1188    };
1189    let Some(target_state_object) = repo.store().get_state(&target_state)? else {
1190        return Ok(None);
1191    };
1192    let before = crate::capture_thread_update_before(repo, &manager, &thread)?;
1193
1194    thread.base_state = target_state.short();
1195    thread.base_root = target_state_object.tree.short();
1196    update_thread_state_from_state(&mut thread, &current_state_object);
1197    thread.state = ThreadState::Ready;
1198    thread.freshness = ThreadFreshness::Current;
1199    thread.integration_policy_result = ThreadIntegrationPolicy {
1200        status: Some("manual_resolved".to_string()),
1201        reason: Some("manual conflict resolution captured".to_string()),
1202        manual_resolution_state: Some(current_state.short()),
1203        conflicts_resolved_manually: true,
1204    };
1205    thread.updated_at = Utc::now();
1206    let thread_name = thread.thread.clone();
1207    let target = thread.target_thread.clone();
1208    crate::save_thread_update(repo, &manager, &thread, before, current_state)?;
1209
1210    Ok(Some(manual_resolution_land_action(
1211        repo,
1212        &thread_name,
1213        target.as_deref(),
1214    )))
1215}
1216
1217fn manual_resolution_land_action(
1218    repo: &Repository,
1219    thread_id: &str,
1220    target_thread: Option<&str>,
1221) -> String {
1222    let action = crate::status::next_action::land_local_command(thread_id);
1223    contextual_thread_action(repo, thread_id, target_thread, &action)
1224}
1225
1226fn current_thread(repo: &Repository) -> Result<Option<Thread>> {
1227    let manager = ThreadManager::new(repo.heddle_dir());
1228    if let Some(thread) = manager.find_by_execution_root(repo.root())? {
1229        return Ok(Some(thread));
1230    }
1231    let Head::Attached { thread } = repo.head_ref()? else {
1232        return Ok(None);
1233    };
1234    let current_state_id = repo.refs().get_thread(&thread)?;
1235    let current_state = current_state_id.map(|state| state.short());
1236    let base_root = current_state_id
1237        .and_then(|state| repo.store().get_state(&state).ok().flatten())
1238        .map(|state| state.tree.short())
1239        .unwrap_or_default();
1240    let thread = thread.to_string();
1241    Ok(Some(Thread {
1242        id: thread.clone(),
1243        thread,
1244        target_thread: None,
1245        parent_thread: None,
1246        mode: ThreadMode::Materialized,
1247        state: ThreadState::Active,
1248        base_state: current_state.clone().unwrap_or_default(),
1249        base_root,
1250        current_state,
1251        merged_state: None,
1252        task: None,
1253        execution_path: repo.root().to_path_buf(),
1254        materialized_path: None,
1255        changed_paths: Vec::new(),
1256        impact_categories: Vec::new(),
1257        heavy_impact_paths: Vec::new(),
1258        promotion_suggested: false,
1259        freshness: ThreadFreshness::Unknown,
1260        verification_summary: Default::default(),
1261        confidence_summary: Default::default(),
1262        integration_policy_result: Default::default(),
1263        created_at: Utc::now(),
1264        updated_at: Utc::now(),
1265        ephemeral: None,
1266        auto: false,
1267        shared_target_dir: None,
1268    }))
1269}
1270
1271fn active_task_assignment_id(repo: &Repository, thread: Option<&Thread>) -> Result<Option<String>> {
1272    let Some(thread) = thread else {
1273        return Ok(None);
1274    };
1275    let store = ActorPresenceStore::new(repo.heddle_dir());
1276    Ok(store
1277        .active_entries()?
1278        .into_iter()
1279        .filter(|entry| entry.thread == thread.thread || entry.thread == thread.id)
1280        .max_by_key(|entry| entry.started_at)
1281        .and_then(|entry| entry.task_assignment_id))
1282}
1283
1284fn bulk_capture_warning(captured_path_count: usize) -> Option<String> {
1285    (captured_path_count >= BULK_CAPTURE_WARNING_THRESHOLD).then(|| {
1286        format!(
1287            "captured {captured_path_count} paths in one operation; check root .gitignore and .heddleignore rules if build artifacts or tool state were included"
1288        )
1289    })
1290}
1291
1292/// Return the first added or modified confidential-runtime materialization
1293/// path selected by the capture's authoritative worktree observation. Deleted
1294/// paths remove plaintext and ignored paths are not part of the capture, so
1295/// neither should block the operation.
1296fn reserved_capture_path(status: &WorktreeStatus) -> Option<String> {
1297    status
1298        .added
1299        .iter()
1300        .chain(&status.modified)
1301        .map(|path| path.to_string_lossy())
1302        .find(|path| env_store::is_reserved_materialization_path(path))
1303        .map(|path| path.into_owned())
1304}
1305
1306fn non_empty_action(action: &str) -> Option<String> {
1307    (!action.trim().is_empty()).then(|| action.to_string())
1308}
1309
1310fn map_capture_error(error: anyhow::Error) -> anyhow::Error {
1311    if error.chain().any(|cause| {
1312        cause
1313            .downcast_ref::<HeddleError>()
1314            .is_some_and(|error| matches!(error, HeddleError::NoChanges))
1315    }) {
1316        return capture_refusal(
1317            "nothing_to_capture",
1318            "nothing to capture: worktree has no changes eligible for Heddle capture",
1319            "Inspect the worktree with `heddle status`; make changes before running `heddle capture -m \"...\"`.",
1320            "the worktree has no modified, deleted, or untracked paths relative to the current Heddle state",
1321            "capture would not create a meaningful Heddle state",
1322            "repository state was left unchanged",
1323            vec!["heddle status".to_string()],
1324        );
1325    }
1326    if error.chain().any(|cause| {
1327        cause
1328            .downcast_ref::<std::io::Error>()
1329            .is_some_and(objects::fs_atomic::is_out_of_space)
1330    }) {
1331        return capture_refusal(
1332            "capture_out_of_space",
1333            format!("Capture aborted because the filesystem is out of space: {error:#}"),
1334            "Free disk space and re-run `heddle capture`. Your working tree changes are intact.",
1335            "the filesystem reported no remaining space while Heddle was writing captured objects",
1336            "retrying before freeing space may fail again or leave another incomplete object write",
1337            "the working tree was not modified; already-committed repository data remains behind atomic write boundaries",
1338            vec!["heddle capture -m \"...\"".to_string()],
1339        );
1340    }
1341    error
1342}
1343
1344#[allow(clippy::too_many_arguments)]
1345fn capture_refusal(
1346    kind: &'static str,
1347    error: impl Into<String>,
1348    hint: impl Into<String>,
1349    unsafe_condition: impl Into<String>,
1350    would_change: impl Into<String>,
1351    preserved: impl Into<String>,
1352    recovery_commands: Vec<String>,
1353) -> anyhow::Error {
1354    anyhow!(HeddleError::recovery(
1355        RecoveryDetails::safety_refusal(
1356            kind,
1357            error,
1358            hint,
1359            unsafe_condition,
1360            would_change,
1361            preserved,
1362        )
1363        .with_recovery_commands(recovery_commands),
1364    ))
1365}
1366
1367fn preview_paths(paths: &[String]) -> String {
1368    let shown = paths
1369        .iter()
1370        .take(12)
1371        .cloned()
1372        .collect::<Vec<_>>()
1373        .join(", ");
1374    let hidden = paths.len().saturating_sub(12);
1375    if hidden == 0 {
1376        shown
1377    } else {
1378        format!("{shown}, and {hidden} more")
1379    }
1380}
1381
1382/// Execute a save: optional Heddle snapshot + optional Git checkpoint write-through.
1383///
1384/// Callers own clap validation (missing message/intent) and plain-Git refusal.
1385/// Mutation composition, hooks, thread metadata, Git write-through, and post
1386/// verification live here.
1387pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
1388    // A plan that asks for a Git checkpoint on a non-overlay repo is a hard
1389    // error: `plan_writes_git_checkpoint` silently returns false for native
1390    // repos, so guard on the raw `git_scope` intent instead (the previous
1391    // `plan_writes_git_checkpoint(..) && capability != GitOverlay` was
1392    // self-contradictory and never fired).
1393    if plan.git_scope != GitScope::None && repo.capability() != RepositoryCapability::GitOverlay {
1394        return Err(anyhow!(HeddleError::recovery(
1395            RecoveryDetails::safety_refusal(
1396                "native_checkpoint_unavailable",
1397                "Git checkpointing is only available in Git-overlay repositories",
1398                "Use `heddle capture -m \"...\"` to save Heddle state in a native checkout.",
1399                "this checkout is not a Git-overlay repository",
1400                "checkpoint would try to write a Git commit where no active Git store is bound",
1401                "repository state, refs, and worktree files were left unchanged",
1402            ),
1403        )));
1404    }
1405
1406    let previous_state_started = Instant::now();
1407    let (previous_state, previous_state_profile) =
1408        repo.current_state_for_worktree_status_profiled()?;
1409    let previous_state_ms = previous_state_started.elapsed().as_millis();
1410    let native_thread_name = match repo.head_ref()? {
1411        refs::Head::Attached { thread } => Some(thread.to_string()),
1412        refs::Head::Detached { .. } => None,
1413    };
1414    if let (Some(name), Some(previous)) = (native_thread_name.as_deref(), previous_state.as_ref())
1415        && repo.native_thread(name).is_err()
1416    {
1417        repo.create_native_thread(name, previous.state_id, None, "")?;
1418    }
1419    let actor = format!("cli:{}", std::process::id());
1420    let _checkout_writer = match native_thread_name.as_deref() {
1421        Some(name) => match repo.native_thread(name) {
1422            Ok(replica) => Some(repo.acquire_checkout_writer(replica.thread_id(), &actor)?),
1423            Err(_) => None,
1424        },
1425        None => match repo::thread_replication::checkout::ThreadCheckout::open(repo.root()) {
1426            Ok(checkout) => Some(repo.acquire_checkout_writer(checkout.binding.thread, &actor)?),
1427            Err(_) => None,
1428        },
1429    };
1430    let has_current = previous_state.is_some();
1431    let mut created_new_state = false;
1432    let mut snapshot_profile = SnapshotProfile::default();
1433    let mut thread_metadata_ms = 0u128;
1434    let mut promotion_suggested = false;
1435    let mut heavy_impact_paths = Vec::new();
1436    let mut snapshot_state_id: Option<StateId> = None;
1437    let mut captured_path_count = 0usize;
1438    let mut state_create_ms = 0u128;
1439    let mut captured_path_count_ms = 0u128;
1440
1441    let mut state = if plan_creates_new_state(&plan, has_current) {
1442        created_new_state = true;
1443        let state_create_started = Instant::now();
1444        let execution = create_heddle_state(repo, &plan)?;
1445        state_create_ms = state_create_started.elapsed().as_millis();
1446        snapshot_profile = execution.profile;
1447        thread_metadata_ms = execution.thread_metadata_ms;
1448        promotion_suggested = execution.promotion_suggested;
1449        heavy_impact_paths = execution.heavy_impact_paths;
1450        snapshot_state_id = Some(execution.state.state_id);
1451        let previous_tree = match previous_state.as_ref() {
1452            Some(state) => state.tree,
1453            None => repo.store().put_tree(&Tree::new())?,
1454        };
1455        let captured_path_count_started = Instant::now();
1456        captured_path_count = repo
1457            .diff_trees(&previous_tree, &execution.state.tree)?
1458            .len();
1459        captured_path_count_ms = captured_path_count_started.elapsed().as_millis();
1460        execution.state
1461    } else {
1462        repo.current_state()?
1463            .ok_or_else(|| anyhow!("no captured state found for save"))?
1464    };
1465
1466    let mut git_commit = None;
1467    let mut git_previous_commit = None;
1468    let mut git_checkpoint = None;
1469
1470    if plan_writes_git_checkpoint(&plan, repo.capability()) {
1471        if plan.require_clean_worktree {
1472            let tree = repo.require_tree(&state.tree)?;
1473            let status = repo.compare_worktree_cached_detailed_with_options(
1474                &tree,
1475                &plan.worktree_status_options,
1476            )?;
1477            if !status.is_clean() {
1478                return Err(anyhow!(HeddleError::recovery(
1479                    RecoveryDetails::safety_refusal(
1480                        "dirty_worktree",
1481                        "Save worktree changes before checkpointing",
1482                        "Save the work with `heddle capture -m \"...\"`.",
1483                        "the current Heddle state was left unchanged; these paths have not been captured",
1484                        "a Git checkpoint would omit dirty worktree paths",
1485                        "the current Heddle state was left unchanged; these paths have not been captured",
1486                    ),
1487                )));
1488            }
1489        }
1490
1491        if let Some(existing) = repo.latest_git_checkpoint_for_state(&state.state_id)?
1492            && repo.pending_git_checkpoint_intent()?.is_none()
1493        {
1494            git_commit = Some(existing.git_commit.clone());
1495            git_checkpoint = Some(existing);
1496        } else {
1497            let previous = repo
1498                .pending_git_checkpoint_intent()?
1499                .and_then(|intent| intent.previous_git_oid)
1500                .or_else(|| git_rev_parse_head(repo.root()));
1501            git_previous_commit = previous.clone();
1502            let summary = checkpoint_summary(&plan, &state);
1503            let record = write_git_checkpoint(repo, &state, summary, plan.linearize_git_parent)?;
1504            if plan.coalesce_snapshot_and_checkpoint
1505                && let Some(state_id) = snapshot_state_id.as_ref()
1506            {
1507                coalesce_snapshot_and_checkpoint(repo, state_id, &record.git_commit)?;
1508            }
1509            git_commit = Some(record.git_commit.clone());
1510            git_checkpoint = Some(record);
1511        }
1512    }
1513
1514    // Post-mutation verification is always fresh when we created state or wrote
1515    // a Git checkpoint (those mutations flip health classification). Otherwise
1516    // reuse a caller-supplied worktree status to avoid a redundant walk.
1517    let captured_native_worktree = created_new_state
1518        && plan.supplied_tree.is_none()
1519        && repo.capability() == RepositoryCapability::NativeHeddle;
1520    let captured_worktree_status = Ok(Some(objects::worktree::WorktreeStatus::default()));
1521    let verification_started = Instant::now();
1522    let verification = if captured_native_worktree && git_checkpoint.is_none() {
1523        let health = build_repository_verification_health_with_worktree_status(
1524            repo,
1525            &captured_worktree_status,
1526        );
1527        if let Some(input) = &plan.machine_contract_input {
1528            build_repository_verification_state_with_worktree_status_and_machine_contract(
1529                repo,
1530                health,
1531                &captured_worktree_status,
1532                input,
1533            )
1534        } else {
1535            build_repository_verification_state_with_worktree_status(
1536                repo,
1537                health,
1538                &captured_worktree_status,
1539            )
1540        }
1541    } else if created_new_state || git_checkpoint.is_some() {
1542        if let Some(input) = &plan.machine_contract_input {
1543            build_repository_verification_state_with_machine_contract(repo, input)?
1544        } else {
1545            build_repository_verification_state(repo)?
1546        }
1547    } else if let Some(status) = &plan.precomputed_worktree_status {
1548        let health = build_repository_verification_health_with_worktree_status(repo, status);
1549        if let Some(input) = &plan.machine_contract_input {
1550            build_repository_verification_state_with_worktree_status_and_machine_contract(
1551                repo, health, status, input,
1552            )
1553        } else {
1554            build_repository_verification_state_with_worktree_status(repo, health, status)
1555        }
1556    } else {
1557        if let Some(input) = &plan.machine_contract_input {
1558            build_repository_verification_state_with_machine_contract(repo, input)?
1559        } else {
1560            build_repository_verification_state(repo)?
1561        }
1562    };
1563    let post_verification_ms = verification_started.elapsed().as_millis();
1564
1565    let summary = match plan.verb {
1566        SaveVerb::Capture => format!(
1567            "Captured state {} ({})",
1568            state.state_id.short(),
1569            state.hash().short()
1570        ),
1571        SaveVerb::Checkpoint => git_checkpoint
1572            .as_ref()
1573            .map(|r| r.summary.clone())
1574            .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short())),
1575    };
1576
1577    if created_new_state && let Some(name) = native_thread_name.as_deref() {
1578        if repo.native_thread(name).is_err() {
1579            repo.create_native_thread(
1580                name,
1581                previous_state
1582                    .as_ref()
1583                    .map(|previous| previous.state_id)
1584                    .unwrap_or(state.state_id),
1585                None,
1586                "",
1587            )?;
1588        }
1589        let genesis_base = repo.native_thread(name)?.genesis()?.base;
1590        if state.state_id != genesis_base && !state.parents.is_empty() {
1591            repo.record_native_capture(name, state.state_id)?;
1592        }
1593    }
1594    let signature_lookup_started = Instant::now();
1595    let signed = repo.get_state_signature(&state.id())?.is_some();
1596    let signature_lookup_ms = signature_lookup_started.elapsed().as_millis();
1597    Ok(SaveReport {
1598        verb: plan.verb,
1599        state_id: state.state_id,
1600        content_hash: state.hash(),
1601        intent: state.intent.clone(),
1602        confidence: state.confidence,
1603        signed,
1604        git_commit,
1605        git_previous_commit,
1606        summary,
1607        principal: state.attribution.principal.clone(),
1608        agent: state.attribution.agent.clone(),
1609        promotion_suggested,
1610        heavy_impact_paths,
1611        captured_path_count,
1612        verification,
1613        created_new_state,
1614        git_checkpoint,
1615        snapshot_profile,
1616        state_create_ms,
1617        captured_path_count_ms,
1618        post_verification_ms,
1619        thread_metadata_ms,
1620        previous_state_ms,
1621        previous_state_profile,
1622        signature_lookup_ms,
1623    })
1624}
1625
1626struct CreatedState {
1627    state: State,
1628    profile: SnapshotProfile,
1629    thread_metadata_ms: u128,
1630    promotion_suggested: bool,
1631    heavy_impact_paths: Vec<String>,
1632}
1633
1634fn create_heddle_state(repo: &Repository, plan: &SavePlan) -> Result<CreatedState> {
1635    let hook_manager = HookManager::new(repo);
1636    let hook_ctx = HookContext::new(repo);
1637    let mut post_hook_worktree_changes = None;
1638
1639    if plan.run_hooks {
1640        let pre_snapshot_ran = hook_manager.run(Hook::PreSnapshot, &hook_ctx)?;
1641        let pre_capture_payload = serde_json::json!({
1642            "thread": current_thread_name(repo),
1643            "intent": plan.intent.clone().unwrap_or_default(),
1644        });
1645        let pre_capture_response = hook_manager.run_with_payload(
1646            Hook::PreSnapshot,
1647            &hook_ctx,
1648            &pre_capture_payload,
1649            std::time::Duration::from_secs(5),
1650        )?;
1651        if let Some(resp) = pre_capture_response
1652            && !resp.abort.is_empty()
1653        {
1654            return Err(anyhow!(HeddleError::recovery(
1655                RecoveryDetails::safety_refusal(
1656                    "hook_veto",
1657                    format!("pre_capture hook vetoed: {}", resp.abort),
1658                    "Inspect `pre_capture` with `heddle hook list`, update the hook policy or inputs, then retry.",
1659                    format!("pre_capture hook vetoed capture: {}", resp.abort),
1660                    "capture would continue after repository policy explicitly aborted the operation",
1661                    "the operation stopped at the hook boundary before the protected action ran",
1662                )
1663                .with_recovery_commands(vec!["heddle hook list".to_string()]),
1664            )));
1665        }
1666        if pre_snapshot_ran && plan.supplied_tree.is_none() {
1667            // Hooks can mutate paths outside capture's preflight set. Rewalk
1668            // authoritatively so a settled monitor token cannot vouch for a
1669            // tree built from that stale set. No-hook captures keep the fast path.
1670            let authoritative_options = WorktreeStatusOptions {
1671                fsmonitor: repo::FsMonitorSettings {
1672                    mode: repo::FsMonitorMode::Off,
1673                },
1674            };
1675            post_hook_worktree_changes =
1676                Some(capture_worktree_status(repo, &authoritative_options)?);
1677        }
1678    }
1679    let mut execution = if let Some(tree) = plan.supplied_tree.clone() {
1680        repo.snapshot_tree_with_attribution_profiled(
1681            tree,
1682            plan.intent.clone(),
1683            plan.confidence,
1684            plan.attribution.clone(),
1685        )?
1686    } else if let Some(status) =
1687        post_hook_worktree_changes.or_else(|| plan.known_worktree_changes.clone())
1688    {
1689        repo.snapshot_with_attribution_profiled_from_status(
1690            plan.intent.clone(),
1691            plan.confidence,
1692            plan.attribution.clone(),
1693            status,
1694            plan.require_worktree_change,
1695        )?
1696    } else if plan.require_worktree_change {
1697        repo.snapshot_with_attribution_profiled_if_changed(
1698            plan.intent.clone(),
1699            plan.confidence,
1700            plan.attribution.clone(),
1701        )?
1702    } else {
1703        repo.snapshot_with_attribution_profiled(
1704            plan.intent.clone(),
1705            plan.confidence,
1706            plan.attribution.clone(),
1707        )?
1708    };
1709
1710    let thread_metadata_start = Instant::now();
1711    let refresh = refresh_active_thread_metadata(repo, &execution.state, &execution.tree)?;
1712    let thread_metadata_ms = thread_metadata_start.elapsed().as_millis();
1713
1714    if plan.run_hooks {
1715        hook_manager.run(Hook::PostSnapshot, &hook_ctx)?;
1716        let post_capture_payload = serde_json::json!({
1717            "state_id": execution.state.state_id.to_string_full(),
1718        });
1719        if let Err(err) = hook_manager.run_with_payload(
1720            Hook::PostSnapshot,
1721            &hook_ctx,
1722            &post_capture_payload,
1723            std::time::Duration::from_secs(5),
1724        ) {
1725            tracing::warn!(error = %err, "post_capture hook error swallowed");
1726        }
1727    }
1728
1729    Ok(CreatedState {
1730        state: execution.state,
1731        profile: std::mem::take(&mut execution.profile),
1732        thread_metadata_ms,
1733        promotion_suggested: refresh.promotion_suggested,
1734        heavy_impact_paths: refresh.heavy_impact_paths,
1735    })
1736}
1737
1738fn write_git_checkpoint(
1739    repo: &Repository,
1740    state: &State,
1741    summary: String,
1742    linearize_git_parent: bool,
1743) -> Result<GitCheckpointRecord> {
1744    let _lock = repo.locker().write()?;
1745    objects::fault_inject::maybe_fail_at("git_checkpoint_before_write_through")?;
1746    let mut bridge = GitProjection::new(repo);
1747    if linearize_git_parent {
1748        bridge.linearize_unmapped_tip_to_checkout();
1749    }
1750    let git_commit = match bridge
1751        .write_through_current_checkout_with_message(state.state_id, summary.clone())?
1752    {
1753        WriteThroughOutcome::Wrote(git_commit) => git_commit.to_string(),
1754        WriteThroughOutcome::Skipped(reason) => {
1755            return Err(anyhow!(HeddleError::recovery(
1756                RecoveryDetails::safety_refusal(
1757                    "checkpoint_git_write_skipped",
1758                    format!("Git checkpoint write-through was skipped: {reason}"),
1759                    "Inspect `heddle verify`, resolve the skip reason, then retry `heddle land`.",
1760                    format!("write-through skipped: {reason}"),
1761                    "checkpoint would need to write the current Heddle state into the Git branch and index",
1762                    "the current Heddle state was preserved; no Git checkpoint record was written",
1763                ),
1764            )));
1765        }
1766    };
1767    let intent = repo.pending_git_checkpoint_intent()?.ok_or_else(|| {
1768        anyhow!("Git checkpoint published without its durable finalization intent")
1769    })?;
1770    if intent.phase != repo::GitCheckpointIntentPhase::Published
1771        || intent.state_id != state.state_id.to_string_full()
1772        || intent.new_git_oid != git_commit
1773    {
1774        return Err(anyhow!(
1775            "published Git checkpoint does not match its durable finalization intent"
1776        ));
1777    }
1778    finalize_published_git_checkpoint(repo, &state.state_id, git_commit, summary, intent)
1779}
1780
1781/// Finish the metadata/oplog half of a checkpoint whose Git ref was already
1782/// published before a crash. Returns `None` when no matching published intent
1783/// exists, so callers can continue with their own recovery policy.
1784pub fn recover_published_git_checkpoint(
1785    repo: &Repository,
1786    state_id: &StateId,
1787) -> Result<Option<GitCheckpointRecord>> {
1788    let _lock = repo.locker().write()?;
1789    let Some(mut intent) = repo.pending_git_checkpoint_intent()? else {
1790        return Ok(None);
1791    };
1792    if intent.state_id != state_id.to_string_full() {
1793        return Ok(None);
1794    }
1795    let current_branch = repo.git_overlay_current_branch()?;
1796    if current_branch.as_deref() != Some(intent.branch.as_str()) {
1797        return Err(anyhow!(
1798            "pending Git checkpoint targets branch '{}' but the checkout is on '{}'",
1799            intent.branch,
1800            current_branch.as_deref().unwrap_or("detached HEAD")
1801        ));
1802    }
1803    let current_oid = git_rev_parse_head(repo.root());
1804    if intent.phase == repo::GitCheckpointIntentPhase::Prepared {
1805        if current_oid == intent.previous_git_oid {
1806            return Ok(None);
1807        }
1808        if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1809            return Err(anyhow!(
1810                "prepared Git checkpoint expected HEAD at {} or {}, found {}",
1811                intent.previous_git_oid.as_deref().unwrap_or("<unborn>"),
1812                intent.new_git_oid,
1813                current_oid.as_deref().unwrap_or("<unborn>")
1814            ));
1815        }
1816        let git_oid = intent.new_git_oid.clone();
1817        intent = repo.mark_git_checkpoint_published(state_id, &git_oid)?;
1818    }
1819    if intent.phase != repo::GitCheckpointIntentPhase::Published {
1820        return Ok(None);
1821    }
1822    if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1823        return Err(anyhow!(
1824            "published Git checkpoint expected HEAD at {}, found {}",
1825            intent.new_git_oid,
1826            current_oid.as_deref().unwrap_or("<unborn>")
1827        ));
1828    }
1829    let git_commit = intent.new_git_oid.clone();
1830    let summary = intent.summary.clone();
1831    finalize_published_git_checkpoint(repo, state_id, git_commit, summary, intent).map(Some)
1832}
1833
1834fn finalize_published_git_checkpoint(
1835    repo: &Repository,
1836    state_id: &StateId,
1837    git_commit: String,
1838    summary: String,
1839    intent: repo::GitCheckpointIntent,
1840) -> Result<GitCheckpointRecord> {
1841    let record = repo.record_git_checkpoint(state_id, git_commit.clone(), summary)?;
1842    objects::fault_inject::maybe_panic_at("git_checkpoint_after_metadata_before_oplog");
1843    let transaction_id = format!(
1844        "git-checkpoint:v1:{}:{}",
1845        state_id.to_string_full(),
1846        git_commit
1847    );
1848    repo.oplog().record_batch_exactly_once(
1849        vec![
1850            OpRecord::GitCheckpoint {
1851                branch: intent.branch,
1852                state: *state_id,
1853                previous_git_oid: intent.previous_git_oid,
1854                new_git_oid: git_commit.clone(),
1855            },
1856            OpRecord::TransactionCommit {
1857                transaction_id: transaction_id.clone(),
1858                op_count: 1,
1859            },
1860        ],
1861        Some(&repo.op_scope()),
1862        &transaction_id,
1863    )?;
1864    objects::fault_inject::maybe_panic_at("git_checkpoint_after_oplog_before_finalize");
1865    repo.finish_git_checkpoint_intent(state_id, &git_commit)?;
1866    Ok(record)
1867}
1868
1869fn coalesce_snapshot_and_checkpoint(
1870    repo: &Repository,
1871    state_id: &StateId,
1872    git_commit: &str,
1873) -> Result<()> {
1874    let snapshot_batch = repo
1875        .oplog()
1876        .recent_batches_scoped(8, Some(&repo.op_scope()))?
1877        .into_iter()
1878        .find(|batch| {
1879            batch.entries.iter().any(|entry| {
1880                matches!(
1881                    &entry.operation,
1882                    OpRecord::Snapshot { new_state, .. } if new_state == state_id
1883                )
1884            })
1885        })
1886        .ok_or_else(|| anyhow!("capture succeeded but its oplog batch was not found"))?;
1887    let checkpoint_batch = repo
1888        .oplog()
1889        .recent_batches_scoped(8, Some(&repo.op_scope()))?
1890        .into_iter()
1891        .find(|batch| {
1892            batch.entries.iter().any(|entry| {
1893                matches!(
1894                    &entry.operation,
1895                    OpRecord::GitCheckpoint { new_git_oid, .. } if new_git_oid == git_commit
1896                )
1897            })
1898        })
1899        .ok_or_else(|| anyhow!("Git checkpoint succeeded but its oplog batch was not found"))?;
1900    repo.oplog()
1901        .coalesce_batches(snapshot_batch.id, checkpoint_batch.id)
1902        .context(
1903            "capture completed but failed to record its state and Git checkpoint as one undo batch",
1904        )?;
1905    Ok(())
1906}
1907
1908fn checkpoint_summary(plan: &SavePlan, state: &State) -> String {
1909    plan.intent
1910        .clone()
1911        .or_else(|| state.intent.clone())
1912        .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short()))
1913}
1914
1915fn current_thread_name(repo: &Repository) -> String {
1916    match repo.head_ref() {
1917        Ok(Head::Attached { thread }) => thread.to_string(),
1918        _ => String::new(),
1919    }
1920}
1921
1922fn git_rev_parse_head(root: &std::path::Path) -> Option<String> {
1923    let git = SleyRepository::discover(root).ok()?;
1924    git.head().ok()?.oid.map(|id| id.to_string())
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929    use repo::RepositoryCapability;
1930    use tempfile::TempDir;
1931
1932    use super::*;
1933
1934    #[test]
1935    fn capture_interface_owns_mutation_and_returns_the_report_contract() {
1936        let temp = TempDir::new().expect("create temp repository");
1937        let repo = Repository::init_default(temp.path()).expect("initialize repository");
1938        std::fs::write(temp.path().join("tracked.txt"), "captured\n")
1939            .expect("write worktree change");
1940        let ctx = ExecutionContext::builder()
1941            .repo(repo)
1942            .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
1943            .build();
1944        let report = capture(
1945            &ctx,
1946            CaptureOptions {
1947                intent: "exercise the deep capture interface".into(),
1948                confidence: Some(0.9),
1949                force: false,
1950                agent: CaptureAgentOptions::default(),
1951                machine_contract_input: None,
1952            },
1953        )
1954        .expect("capture succeeds");
1955
1956        assert_eq!(report.output_kind, "capture");
1957        assert_eq!(report.captured_path_count, 1);
1958        assert_eq!(report.principal.name, "Ada");
1959        assert_eq!(report.principal.email, "ada@example.com");
1960        assert_eq!(report.principal_source, "user_config");
1961        assert_eq!(CaptureReport::CONTRACT.schema_name, "capture");
1962        assert_eq!(
1963            ctx.require_repo()
1964                .expect("repository")
1965                .head()
1966                .expect("read head")
1967                .expect("captured head")
1968                .short(),
1969            report.state_id
1970        );
1971
1972        let wire = serde_json::to_value(&report).expect("serialize report");
1973        assert_eq!(wire["output_kind"], "capture");
1974        assert!(wire.get("diagnostics").is_none());
1975        assert!(wire.get("captured_thread_targets_integration").is_none());
1976    }
1977
1978    #[test]
1979    fn manual_resolution_land_action_quotes_untrusted_thread_ids() {
1980        let temp = TempDir::new().expect("create temp repository");
1981        let repo = Repository::init_default(temp.path()).expect("initialize repository");
1982
1983        assert_eq!(
1984            manual_resolution_land_action(&repo, "bad;echo pwn", None),
1985            "heddle land --thread 'bad;echo pwn'"
1986        );
1987        assert_eq!(
1988            manual_resolution_land_action(&repo, "-danger", None),
1989            "heddle land --thread=-danger"
1990        );
1991    }
1992
1993    #[test]
1994    fn clean_overlay_refuses_before_requiring_identity() {
1995        let temp = TempDir::new().expect("create temp repository");
1996        SleyRepository::init(temp.path()).expect("initialize Git repository");
1997        let repo = Repository::init_git_overlay_sidecar(temp.path())
1998            .expect("initialize Git-overlay sidecar");
1999        let ctx = ExecutionContext::builder().repo(repo).build();
2000
2001        let error = capture(
2002            &ctx,
2003            CaptureOptions {
2004                intent: "nothing changed".into(),
2005                confidence: None,
2006                force: false,
2007                agent: CaptureAgentOptions::default(),
2008                machine_contract_input: None,
2009            },
2010        )
2011        .expect_err("clean overlay must refuse capture");
2012
2013        assert!(error.to_string().contains("nothing to capture"));
2014    }
2015
2016    #[test]
2017    fn capture_rejects_missing_intent_before_mutation() {
2018        let temp = TempDir::new().expect("create temp repository");
2019        let repo = Repository::init_default(temp.path()).expect("initialize repository");
2020        std::fs::write(temp.path().join("tracked.txt"), "uncaptured\n")
2021            .expect("write worktree change");
2022        let ctx = ExecutionContext::builder()
2023            .repo(repo)
2024            .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
2025            .build();
2026        let before = ctx.require_repo().expect("repository").head().unwrap();
2027
2028        let error = capture(
2029            &ctx,
2030            CaptureOptions {
2031                intent: "   ".into(),
2032                confidence: None,
2033                force: false,
2034                agent: CaptureAgentOptions::default(),
2035                machine_contract_input: None,
2036            },
2037        )
2038        .expect_err("blank intent must be rejected by the operation interface");
2039
2040        assert!(
2041            error
2042                .to_string()
2043                .contains("refusing to capture without an intent")
2044        );
2045        assert_eq!(
2046            ctx.require_repo().expect("repository").head().unwrap(),
2047            before
2048        );
2049    }
2050
2051    #[test]
2052    fn attribution_cleaning_only_rejects_the_placeholder_token() {
2053        assert_eq!(clean_attribution_value("  unknown  ".into()), None);
2054        assert_eq!(clean_attribution_value("   ".into()), None);
2055        assert_eq!(
2056            clean_attribution_value("unknown-model-v2".into()),
2057            Some("unknown-model-v2".into())
2058        );
2059    }
2060
2061    #[test]
2062    fn identity_freeze_waits_for_the_repository_write_lock() {
2063        use std::{sync::mpsc, time::Duration};
2064
2065        let temp = TempDir::new().expect("create temp repository");
2066        let repo = Repository::init_default(temp.path()).expect("initialize repository");
2067        let hold = repo.locker().write().expect("hold repository lock");
2068        let root = repo.root().to_path_buf();
2069        let (tx, rx) = mpsc::channel();
2070        let worker = std::thread::spawn(move || {
2071            let repo = Repository::open(root).expect("open worker repository");
2072            let patch = IdentityCursor {
2073                provider: Some("anthropic".into()),
2074                model: Some("opus".into()),
2075                ..IdentityCursor::default()
2076            };
2077            let frozen = freeze_identity_for_capture(&repo, &patch);
2078            let _ = tx.send(frozen.is_ok());
2079        });
2080
2081        assert!(
2082            rx.recv_timeout(Duration::from_millis(150)).is_err(),
2083            "identity mutation must wait for the repository lock"
2084        );
2085        drop(hold);
2086        assert!(
2087            rx.recv_timeout(Duration::from_secs(2))
2088                .expect("worker should finish after lock release")
2089        );
2090        worker.join().expect("join identity worker");
2091    }
2092
2093    #[test]
2094    fn reserved_capture_paths_consider_selected_additions_and_modifications_only() {
2095        let status = WorktreeStatus {
2096            added: vec![std::path::PathBuf::from("services/api/.env.local")],
2097            modified: Vec::new(),
2098            deleted: vec![std::path::PathBuf::from("old/.env")],
2099        };
2100        assert_eq!(
2101            reserved_capture_path(&status).as_deref(),
2102            Some("services/api/.env.local")
2103        );
2104
2105        let deletion_only = WorktreeStatus {
2106            deleted: vec![std::path::PathBuf::from(".env")],
2107            ..WorktreeStatus::default()
2108        };
2109        assert!(reserved_capture_path(&deletion_only).is_none());
2110    }
2111
2112    #[test]
2113    fn plan_creates_new_state_routing() {
2114        let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2115        let capture = SavePlan::capture("wip", attr.clone());
2116        assert!(plan_creates_new_state(&capture, true));
2117        assert!(plan_creates_new_state(&capture, false));
2118
2119        let checkpoint = SavePlan::checkpoint(Some("cp".into()), attr.clone(), false);
2120        assert!(!plan_creates_new_state(&checkpoint, true));
2121        assert!(plan_creates_new_state(&checkpoint, false));
2122
2123        let staged =
2124            SavePlan::checkpoint(Some("cp".into()), attr, true).with_supplied_tree(Tree::new());
2125        assert!(plan_creates_new_state(&staged, true));
2126    }
2127
2128    #[test]
2129    fn plan_writes_git_checkpoint_respects_scope_and_capability() {
2130        let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2131        let mut capture = SavePlan::capture("wip", attr);
2132        assert!(!plan_writes_git_checkpoint(
2133            &capture,
2134            RepositoryCapability::GitOverlay
2135        ));
2136        capture.git_scope = GitScope::WorktreeAll;
2137        assert!(plan_writes_git_checkpoint(
2138            &capture,
2139            RepositoryCapability::GitOverlay
2140        ));
2141        assert!(!plan_writes_git_checkpoint(
2142            &capture,
2143            RepositoryCapability::NativeHeddle
2144        ));
2145    }
2146
2147    #[test]
2148    fn save_plan_builders_set_expected_defaults() {
2149        let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2150        let capture = SavePlan::capture("intent", attr.clone());
2151        assert_eq!(capture.verb, SaveVerb::Capture);
2152        assert_eq!(capture.git_scope, GitScope::None);
2153        assert!(!capture.coalesce_snapshot_and_checkpoint);
2154
2155        let staged = SavePlan::checkpoint(None, attr, true);
2156        assert_eq!(staged.git_scope, GitScope::Staged);
2157        assert!(!staged.require_clean_worktree);
2158        assert!(staged.reuse_current_state);
2159    }
2160
2161    #[test]
2162    fn tree_leaf_name_returns_the_final_component() {
2163        assert_eq!(tree_leaf_name("a/b/c.rs"), "c.rs");
2164        assert_eq!(tree_leaf_name("solo"), "solo");
2165    }
2166}