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