Skip to main content

agent_doc_core/
frontmatter.rs

1//! # Module: frontmatter
2//!
3//! ## Spec
4//! - Defines the YAML frontmatter schema (`Frontmatter`) embedded between `---\n` delimiters at
5//!   the top of agent-doc documents.
6//! - `parse(content)` splits a document string into `(Frontmatter, body_str)`. Documents that do
7//!   not start with `---\n` return a default `Frontmatter` and the full content as body.
8//!   A UTF-8 BOM or horizontal whitespace before the opening marker is tolerated so
9//!   editor-created session docs do not silently become unmanaged.
10//!   Unterminated frontmatter (no closing `---`) returns `Err`.
11//! - `parse_for_file(content, file)` is the path-aware variant: it wraps YAML parse failures with
12//!   the document path, resolves project-local required SSH defaults from `.agent-doc/config.toml`,
13//!   and fails closed when a configured SSH-dependent document cannot resolve any targets.
14//! - `write(fm, body)` serialises `Frontmatter` to YAML and prepends it to `body`, producing a
15//!   complete document string.
16//! - `set_session_id` / `set_resume_id` are convenience wrappers: parse → mutate one field →
17//!   write. They create frontmatter when none exists.
18//! - `ensure_session` is idempotent: if `session` is already set the document is returned
19//!   unchanged; otherwise a UUID v4 is generated and written.
20//! - `set_format_and_write` atomically sets `agent_doc_format` + `agent_doc_write` and clears
21//!   the deprecated `agent_doc_mode` field.
22//! - `merge_fields(content, yaml_fields)` applies additive key/value patches to the frontmatter;
23//!   unknown keys are logged to stderr and discarded; the document body is preserved unchanged.
24//! - `set_tmux_session` is retained for backward compatibility; `tmux_session` in frontmatter is
25//!   deprecated — runtime session is now determined by `--window` or `current_tmux_session()`.
26//! - `AgentDocFormat` (append | template) controls document structure; serialised as `inline` /
27//!   `template` in YAML; `inline` and `append` are accepted as aliases on deserialise.
28//! - `AgentDocWrite` (merge | crdt) controls the write/merge strategy.
29//! - `ResolvedMode` is the canonical (format, write) pair after deprecation migration.
30//!   `Frontmatter::resolve_mode()` applies a three-level priority: explicit `agent_doc_format` /
31//!   `agent_doc_write` fields > deprecated `agent_doc_mode` string > defaults
32//!   (format=template, write=crdt).
33//! - Deprecated `agent_doc_mode` values: `"append"` → format=Append; `"template"` or `"stream"`
34//!   → format=Template; all keep write=Crdt.
35//! - `StreamConfig` carries optional CRDT stream parameters: write-back interval, ANSI stripping,
36//!   target component, and chain-of-thought routing.
37//! - `prompt_presets` stores reusable named prompt snippets for orchestrated tasks and round-trips
38//!   exact preset names/values, including symbolic keys such as `#1`.
39//! - `codex_network_access` stores an explicit Codex network policy (`inherit`, `enabled`,
40//!   `disabled`) so agent-doc can own the `CODEX_SANDBOX_NETWORK_DISABLED` contract instead of
41//!   silently inheriting an ambient launcher override.
42//! - Field renames and aliases ensure backward compatibility:
43//!   `session` / `agent_doc_session` → `session`;
44//!   `mode` / `response_mode` / `agent_doc_mode` → `mode`.
45//!
46//! ## Agentic Contracts
47//! - `parse` never panics; it returns `Err` only for an unterminated frontmatter block or a YAML
48//!   deserialisation failure. All missing optional fields default to `None`.
49//! - `write(parse(doc).0, parse(doc).1)` round-trips the document: re-parsing the result yields
50//!   semantically equivalent `Frontmatter` and an identical body string, without accumulating
51//!   blank lines at the start of the body.
52//! - `ensure_session` is idempotent: calling it twice on the same document returns the same
53//!   session ID and does not modify the document on the second call.
54//! - `merge_fields` is additive: it never removes existing fields unless an explicit `null`
55//!   value is supplied for that field in `yaml_fields`.
56//! - `set_format_and_write` is the only function that actively clears a field (`mode = None`);
57//!   all other helpers are strictly additive.
58//! - `resolve_mode` is pure and total: it always returns a valid `ResolvedMode` regardless of
59//!   which combination of deprecated and current fields are populated.
60//! - `prompt_presets` is deterministic: parse/write preserves preset names and bodies exactly,
61//!   including multiline values and symbolic names like `#1`.
62//! - `codex_network_access` round-trips as a stable enum and is preserved across
63//!   parse/write/merge cycles.
64//! - Serialised YAML always uses the canonical field names (`agent_doc_session`,
65//!   `agent_doc_mode`, `agent_doc_format`, `agent_doc_write`); legacy aliases are read-only.
66//!
67//! ## Evals
68//! - parse_no_frontmatter: content without `---` prefix → default Frontmatter, full content as body
69//! - parse_all_fields: document with all known fields → each field correctly populated
70//! - parse_null_fields: YAML `null` values → all fields `None`
71//! - parse_unterminated_frontmatter: `---` open with no close → `Err` "Unterminated frontmatter"
72//! - parse_closing_at_eof: closing `---` without trailing newline → body = ""
73//! - write_roundtrip: write then parse → identical Frontmatter and identical body
74//! - repeated_parse_write_roundtrip_preserves_body_prefix: repeated parse/write cycles preserve
75//!   the exact body prefix and do not accumulate blank lines before the first heading
76//! - write_default_frontmatter: default Frontmatter → output starts with `---\n`, ends with `---\n`
77//! - set_session_id_creates_frontmatter: plain doc → frontmatter added with correct session
78//! - set_session_id_preserves_other_fields: existing fields not clobbered by session update
79//! - ensure_session_no_frontmatter: plain doc → UUID generated, written, returned
80//! - ensure_session_existing_session: session already set → content unchanged, same ID returned
81//! - parse_legacy_session_field: `session:` alias → populates `fm.session`
82//! - parse_mode_shorthand_alias: `mode:` alias → populates `fm.mode`
83//! - write_uses_agent_doc_mode_field: deprecated mode serialised as `agent_doc_mode:`, not `mode:`
84//! - write_uses_new_field_name: session serialised as `agent_doc_session:`, not `session:`
85//! - resolve_mode_defaults: empty Frontmatter → format=Template, write=Crdt
86//! - resolve_mode_new_fields_override_deprecated: both present → new fields win
87//! - set_format_and_write_clears_deprecated_mode: deprecated `mode` cleared after migration
88//! - merge_fields_adds_new_field: new key added without disturbing existing fields
89//! - merge_fields_ignores_unknown: unknown key logged to stderr, not stored
90//! - parse_prompt_presets_roundtrip: preset map with multiline values → parse/write preserves keys
91//!   and bodies exactly
92//! - parse_codex_network_access_field: `codex_network_access: enabled` → enum value
93//! - resolve_harness_model: harness-specific model fields override `model`
94//!   for `claude-code`, `codex`, and `opencode`
95//! - parse_harness_model_fields: harness model fields round-trip through parse/write
96//! - merge_fields_claude_model / merge_fields_codex_model / merge_fields_opencode_model:
97//!   merge patches for harness model fields
98
99use anyhow::Result;
100use serde::{Deserialize, Serialize};
101use std::fmt;
102use uuid::Uuid;
103
104use crate::model_tier::Tier;
105
106#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum CollaborationMode {
109    #[default]
110    SingleUser,
111    Shared,
112}
113
114#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum CodexNetworkAccess {
117    #[default]
118    Inherit,
119    Enabled,
120    Disabled,
121}
122
123/// Canonical queue activation control (`#queue-state-unify`).
124///
125/// The `queue:` frontmatter key subsumes the deprecated `queue_active:` boolean
126/// and the `auto` attribute on the `agent:queue` marker. `start` (alias `go`)
127/// activates the queue; `stop` deactivates it. Parsed leniently (case- and
128/// whitespace-insensitive); unknown values resolve to `None` and are ignored so
129/// a typo never silently flips activation.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum QueueControl {
132    /// `start` / `go` — activate the queue.
133    Start,
134    /// `stop` — deactivate the queue.
135    Stop,
136}
137
138impl QueueControl {
139    /// Parse a raw `queue:` value. `start`/`go` → [`Start`], `stop` → [`Stop`].
140    /// Returns `None` for empty/unknown values.
141    pub fn parse(raw: &str) -> Option<Self> {
142        match raw.trim().to_ascii_lowercase().as_str() {
143            "start" | "go" => Some(Self::Start),
144            "stop" => Some(Self::Stop),
145            _ => None,
146        }
147    }
148
149    /// Whether this control activates the queue.
150    pub fn is_active(self) -> bool {
151        matches!(self, Self::Start)
152    }
153}
154
155/// Document format: controls document structure.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
157pub enum AgentDocFormat {
158    /// Alternating ## User / ## Assistant blocks (also known as "inline")
159    #[clap(alias = "inline")]
160    Append,
161    /// In-place component patching with <!-- agent:name --> markers
162    Template,
163}
164
165impl fmt::Display for AgentDocFormat {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        match self {
168            Self::Append => write!(f, "inline"),
169            Self::Template => write!(f, "template"),
170        }
171    }
172}
173
174impl Serialize for AgentDocFormat {
175    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
176    where
177        S: serde::Serializer,
178    {
179        match self {
180            Self::Append => serializer.serialize_str("inline"),
181            Self::Template => serializer.serialize_str("template"),
182        }
183    }
184}
185
186impl<'de> Deserialize<'de> for AgentDocFormat {
187    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
188    where
189        D: serde::Deserializer<'de>,
190    {
191        let s = String::deserialize(deserializer)?;
192        match s.as_str() {
193            "append" | "inline" => Ok(Self::Append),
194            "template" => Ok(Self::Template),
195            other => Err(serde::de::Error::unknown_variant(
196                other,
197                &["inline", "append", "template"],
198            )),
199        }
200    }
201}
202
203/// Write strategy: controls how responses are merged into the document.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
205#[serde(rename_all = "lowercase")]
206pub enum AgentDocWrite {
207    /// 3-way merge via git merge-file
208    Merge,
209    /// CRDT-based conflict-free merge (yrs)
210    Crdt,
211}
212
213impl fmt::Display for AgentDocWrite {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        match self {
216            Self::Merge => write!(f, "merge"),
217            Self::Crdt => write!(f, "crdt"),
218        }
219    }
220}
221
222/// Resolved mode pair — the canonical representation after deprecation migration.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct ResolvedMode {
225    pub format: AgentDocFormat,
226    pub write: AgentDocWrite,
227}
228
229impl ResolvedMode {
230    pub fn is_template(&self) -> bool {
231        self.format == AgentDocFormat::Template
232    }
233
234    pub fn is_append(&self) -> bool {
235        self.format == AgentDocFormat::Append
236    }
237
238    pub fn is_crdt(&self) -> bool {
239        self.write == AgentDocWrite::Crdt
240    }
241}
242
243/// Configuration for stream mode (real-time CRDT write-back).
244#[derive(Debug, Default, Clone, Serialize, Deserialize)]
245pub struct StreamConfig {
246    /// Write-back interval in milliseconds (default: 200)
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub interval: Option<u64>,
249    /// Strip ANSI escape codes from agent output (default: true)
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub strip_ansi: Option<bool>,
252    /// Target component name for stream output (default: "exchange")
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub target: Option<String>,
255    /// Include chain-of-thought (thinking) blocks in output (default: false)
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub thinking: Option<bool>,
258    /// Route thinking to a separate component (e.g., "log"). If unset, thinking
259    /// is interleaved with response text in the target component.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub thinking_target: Option<String>,
262    /// Maximum lines for console capture (default: 50). Limits the content
263    /// written to the console component to prevent indefinite growth.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub max_lines: Option<usize>,
266}
267
268/// Live finalize-pipeline state tracker (`#fm-run-id-step`, Phase 5 of
269/// `tasks/agent-doc/plans/reentrant-finalize-pipeline.md`).
270///
271/// Surfaces the current cycle's identity and step directly in frontmatter so any
272/// `agent-doc` invocation — or an editor plugin / JB `Run Agent Doc` action — can
273/// read where a crashed or resumed cycle left off without replaying the git log or
274/// parsing `.agent-doc/cycle_state`.
275///
276/// This is ephemeral runtime state, not user configuration: the orchestration layer
277/// writes it at each phase transition and clears it once the cycle reaches a terminal
278/// phase. `step` is a free-form string (the orchestration `CyclePhase` rendered
279/// lowercase, e.g. `preflight_started` / `response_captured` / `write_applied` /
280/// `committed`) so an older core build round-trips a newer phase name without data
281/// loss, and so `agent-doc-core` need not depend on the orchestration `CyclePhase`
282/// enum.
283#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct AgentDocPipeline {
285    /// Logical turn identifier — the backlog/queue task ID driving this cycle
286    /// (e.g. `#fm-run-id-step`), or an auto-generated id when the queue prompt has
287    /// no backlog match.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub turn_id: Option<String>,
290    /// Cycle-state run identifier (the `cycle-<millis>` capture/cycle id) for
291    /// correlating frontmatter state with `.agent-doc/captures` + cycle_state.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub run_id: Option<String>,
294    /// Current pipeline step — the orchestration `CyclePhase` rendered as a
295    /// lowercase string. Free-form so a newer phase round-trips through an older
296    /// core build.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub step: Option<String>,
299    /// Backlog ID for the queued task, when the driving queue prompt resolved to
300    /// (or created) a tracked backlog item. Distinct from `turn_id` only when the
301    /// turn id is auto-generated.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub queue_task_id: Option<String>,
304}
305
306impl AgentDocPipeline {
307    /// True when no field is populated. Used by `skip_serializing_if` so a drained
308    /// or terminal cycle leaves no `agent_doc_pipeline:` block behind in the document.
309    pub fn is_empty(&self) -> bool {
310        self.turn_id.is_none()
311            && self.run_id.is_none()
312            && self.step.is_none()
313            && self.queue_task_id.is_none()
314    }
315}
316
317#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "lowercase")]
319pub enum PendingCaptureGuardMode {
320    #[default]
321    Warn,
322    #[serde(alias = "error")]
323    Strict,
324    Off,
325}
326
327/// Lint dialect mode for the `tagpath lint --dialect agent-doc` finalize gate.
328///
329/// Resolution precedence: CLI `--lint=...` > frontmatter
330/// `agent_doc_lint_dialect` > workspace `.agent-doc/config.toml` `[lint]
331/// dialect` > default (`warn`).
332///
333/// - `Warn` (default): lint runs; errors block the cycle, warnings surface on
334///   stderr but do not block.
335/// - `Strict`: warnings are promoted to errors.
336/// - `Off`: lint gate is skipped entirely. Used to migrate documents with
337///   historical malformed directives while staging fixes.
338#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "lowercase")]
340pub enum LintDialectMode {
341    #[default]
342    Warn,
343    #[serde(alias = "error")]
344    Strict,
345    Off,
346}
347
348#[derive(Debug, Default, Clone, Serialize, Deserialize)]
349pub struct Frontmatter {
350    /// Document/routing UUID — permanent identifier for tmux pane routing.
351    /// Serialized as `agent_doc_session` in YAML; reads legacy `session` via alias.
352    #[serde(
353        default,
354        skip_serializing_if = "Option::is_none",
355        rename = "agent_doc_session",
356        alias = "session"
357    )]
358    pub session: Option<String>,
359    /// Agent conversation ID — used for `--resume` with agent backends.
360    /// Separate from `session` so the routing key never changes.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub resume: Option<String>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub agent: Option<String>,
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub model: Option<String>,
367    /// Per-harness model override for Claude Code sessions.
368    /// When running under Claude Code, this takes precedence over `model`.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub claude_model: Option<String>,
371    /// Per-harness model override for Codex sessions.
372    /// When running under Codex, this takes precedence over `model`.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub codex_model: Option<String>,
375    /// Per-harness model override for OpenCode sessions.
376    /// When running under OpenCode, this takes precedence over `model`.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub opencode_model: Option<String>,
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub branch: Option<String>,
381    /// **Deprecated.** Tmux session name for pane affinity (e.g., "claude").
382    /// Session is now determined at runtime by `--window` argument (sync) or
383    /// `current_tmux_session()` (route/start). Still read for backward compatibility
384    /// and auto-repaired by sync when it differs from the context session.
385    /// Will be removed in a future version.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub tmux_session: Option<String>,
388    /// **Deprecated.** Use `agent_doc_format` + `agent_doc_write` instead.
389    /// Kept for backward compatibility. Values: "append", "template", "stream".
390    /// Serialized as `agent_doc_mode` in YAML; reads legacy `response_mode` and shorthand `mode` via aliases.
391    #[serde(
392        default,
393        skip_serializing_if = "Option::is_none",
394        rename = "agent_doc_mode",
395        alias = "mode",
396        alias = "response_mode"
397    )]
398    pub mode: Option<String>,
399    /// Document format: controls document structure (append | template).
400    #[serde(
401        default,
402        skip_serializing_if = "Option::is_none",
403        rename = "agent_doc_format"
404    )]
405    pub format: Option<AgentDocFormat>,
406    /// Write strategy: controls merge behavior (merge | crdt).
407    #[serde(
408        default,
409        skip_serializing_if = "Option::is_none",
410        rename = "agent_doc_write"
411    )]
412    pub write_mode: Option<AgentDocWrite>,
413    /// Stream mode configuration (used when write strategy is CRDT).
414    #[serde(
415        default,
416        skip_serializing_if = "Option::is_none",
417        rename = "agent_doc_stream"
418    )]
419    pub stream_config: Option<StreamConfig>,
420    /// Additional CLI arguments to pass to the agent process (harness-neutral).
421    /// Takes precedence over harness-specific aliases. Space-separated string.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub agent_args: Option<String>,
424    /// Additional CLI arguments to pass to the `claude` process.
425    /// Backward-compatible alias for `agent_args`. `agent_args` takes precedence when both are set.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub claude_args: Option<String>,
428    /// Additional CLI arguments to pass to the `codex` process.
429    /// Codex-only alias for `agent_args`. `agent_args` takes precedence when both are set.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub codex_args: Option<String>,
432    /// Additional CLI arguments to pass to the `opencode` process.
433    /// OpenCode-only alias for `agent_args`. `agent_args` takes precedence when both are set.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub opencode_args: Option<String>,
436    /// Explicit Codex network policy. `inherit` keeps the launcher's ambient
437    /// `CODEX_SANDBOX_NETWORK_DISABLED` setting, `enabled` removes it, and
438    /// `disabled` forces it on for the child session.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub codex_network_access: Option<CodexNetworkAccess>,
441    /// Required SSH targets that must be reachable for Codex-backed work to
442    /// proceed. When present, agent-doc probes these targets before trusting a
443    /// resumed Codex session and fails closed when the capability cannot be
444    /// proven.
445    #[serde(default, skip_serializing_if = "Vec::is_empty")]
446    pub required_ssh_targets: Vec<String>,
447    /// Optional profile name resolved via project-local `.agent-doc/config.toml`
448    /// SSH mappings when the document omits direct `required_ssh_targets`.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub required_ssh_profile: Option<String>,
451    /// Maximum number of managed-capability-proof attempts before the dispatch
452    /// gate is set to `Failed`. A transient network blip is retried (with
453    /// backoff) up to this many times so it no longer permanently wedges the
454    /// session. `1` disables retry (legacy behavior). Default `3`.
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub managed_proof_max_attempts: Option<u32>,
457    /// Base back-off (seconds) between managed-capability-proof retries. The
458    /// delay grows exponentially from this value, capped internally. Default `2`.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub managed_proof_retry_backoff_secs: Option<u64>,
461    /// Override for the managed-capability child network/writable-root probe
462    /// timeout (seconds). Slow-network environments can raise it above the
463    /// default `45`.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub managed_proof_probe_timeout_secs: Option<u64>,
466    /// When true, passes `--no-mcp` to the `claude` process.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub no_mcp: Option<bool>,
469    /// When true, passes `--enable-tool-search` to the `claude` process.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub enable_tool_search: Option<bool>,
472    /// Debounce duration in milliseconds for preflight mtime settling.
473    /// Default: 2000ms. Set to 0 to disable debounce (run immediately).
474    #[serde(
475        default,
476        skip_serializing_if = "Option::is_none",
477        rename = "agent_doc_debounce"
478    )]
479    pub debounce_ms: Option<u64>,
480    /// Linked resources: local file paths or URLs (relative to this document's directory).
481    /// Changes in linked docs are surfaced during preflight.
482    #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "related_docs")]
483    pub links: Vec<String>,
484    /// Explicit opt-in for automatic compaction/reload policies.
485    /// Session-accretion heuristics never compact by themselves; omit to disable.
486    #[serde(
487        default,
488        skip_serializing_if = "Option::is_none",
489        rename = "agent_doc_auto_compact"
490    )]
491    pub auto_compact: Option<usize>,
492    /// Explicit opt-in for the supervisor idle-queue watch to pre-emptively
493    /// interleave a context-clear (`/clear`) before an active queue head when
494    /// session accretion is unhealthy (`#nm1x-no-preempt-clear`). Off by default.
495    #[serde(
496        default,
497        skip_serializing_if = "Option::is_none",
498        rename = "agent_doc_queue_context_reset"
499    )]
500    pub queue_context_reset: Option<bool>,
501    /// Explicit opt-in for opportunistic gated-review auto-verification
502    /// (`#optverify`). When true, preflight may flip a gated `[/]` review item
503    /// carrying a verify predicate to `[x]` once its proof marker is provable
504    /// from `ops.log` (and no disproof marker fired). Off by default — without
505    /// the opt-in, preflight only *surfaces* the provable/failed status and
506    /// never silently flips a human gate.
507    #[serde(
508        default,
509        skip_serializing_if = "Option::is_none",
510        rename = "agent_doc_gate_autoverify"
511    )]
512    pub gate_autoverify: Option<bool>,
513    /// Context-usage percentage (0–100) at or above which an opted-in editor
514    /// (JB `Run Agent Doc`) pre-emptively runs `/clear` before the next dispatch
515    /// (`#clear-opt-in-threshold`). Resolution: this frontmatter value, then the
516    /// project config `.agent-doc/config.toml`, then the built-in default of 50.
517    /// The threshold value is owned by the binary so all editors share it; the
518    /// context-percentage comparison and the actual pre-emptive clear are the
519    /// editor's responsibility.
520    #[serde(
521        default,
522        skip_serializing_if = "Option::is_none",
523        rename = "agent_doc_clear_threshold"
524    )]
525    pub clear_threshold: Option<u8>,
526    /// Explicit per-document opt-in/opt-out for supervisor auto-recycle
527    /// (`#ctlrecycle` R3 / `#suprecyclequeue`). When the route-owned supervisor
528    /// detects it is running a stale binary at an idle / inter-queue-item
529    /// boundary, `true` lets it `execve` hot-reload onto the freshly-installed
530    /// build (preserving the live agent child + pane); `false` force-disables
531    /// that swap so a stale supervisor only logs `supervisor_binary_stale_detected`.
532    /// Resolution: env `AGENT_DOC_SUPERVISOR_AUTO_RECYCLE`, then this frontmatter
533    /// value, then the project config `agent_doc_supervisor_auto_recycle`, then
534    /// the built-in default of ON.
535    #[serde(
536        default,
537        skip_serializing_if = "Option::is_none",
538        rename = "agent_doc_supervisor_auto_recycle"
539    )]
540    pub supervisor_auto_recycle: Option<bool>,
541    /// Explicit per-document opt-in/opt-out for restart-agent on a harness change
542    /// (`#agentreloadrestart`). When the resolved harness from current frontmatter
543    /// (`agent:`) differs from the one the running supervisor launched with,
544    /// `true` lets the supervisor auto-restart the agent at the next idle/turn
545    /// boundary so changing `agent` (e.g. claude→opencode) takes effect without a
546    /// manual restart; `false` disables that auto-restart (the change then applies
547    /// only on the next manual restart). Resolution: env
548    /// `AGENT_DOC_AGENT_CHANGE_RESTART`, then this frontmatter value, then the
549    /// project config `agent_doc_agent_change_restart`, then the built-in default
550    /// of ON.
551    #[serde(
552        default,
553        skip_serializing_if = "Option::is_none",
554        rename = "agent_doc_agent_change_restart"
555    )]
556    pub agent_change_restart: Option<bool>,
557    /// Explicit per-document opt-in/opt-out for supervisor auto-install
558    /// (`#supautoinstall`). When a DOGFOODING supervisor (an agent-doc session
559    /// editing agent-doc's own source) finds the source newer than the installed
560    /// binary at an idle boundary, `true` lets it `cargo build --release` +
561    /// `cargo install` + `agent-doc lib-install` so the `#ctlrecycle` recycle then
562    /// hot-reloads onto it; `false` force-disables that build so it only logs
563    /// `supervisor_source_newer_detected`. Resolution: env
564    /// `AGENT_DOC_SUPERVISOR_AUTO_INSTALL`, then this frontmatter value, then the
565    /// project config `agent_doc_supervisor_auto_install`, then the built-in
566    /// default of ON. Never fires for a non-agent-doc dogfood session document,
567    /// even when this field is `true`.
568    #[serde(
569        default,
570        skip_serializing_if = "Option::is_none",
571        rename = "agent_doc_supervisor_auto_install"
572    )]
573    pub supervisor_auto_install: Option<bool>,
574    /// Required model tier for this document. When set, preflight emits this as
575    /// `required_tier`, which the skill uses as a hard gate: if the running model's
576    /// tier is below this value, the skill writes a switch prompt and stops.
577    /// Values: `auto | low | med | high`. Default: absent (no gate).
578    #[serde(
579        default,
580        skip_serializing_if = "Option::is_none",
581        rename = "agent_doc_model_tier"
582    )]
583    pub model_tier: Option<Tier>,
584    /// Pending-capture guard mode for `session-check`.
585    /// Values: `warn` (default when absent), `strict`, `off`.
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub pending_capture_guard: Option<PendingCaptureGuardMode>,
588    /// Pending-done guard mode for `session-check`.
589    /// Values: `warn` (default when absent), `strict`, `off`.
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    pub pending_done_guard: Option<PendingCaptureGuardMode>,
592    /// Review-done guard mode for `--done`.
593    /// Values: `off` (default when absent), `warn`, `strict`/`error`.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub review_done_guard: Option<PendingCaptureGuardMode>,
596    /// When true, closeout may auto-apply `--done` for clear completion signals.
597    /// Default is absent/false; explicit `--done` and explicit `#id done`
598    /// directives still work without this opt-in.
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub auto_done: Option<bool>,
601    /// Lint dialect mode for the agent-doc finalize lint gate. Values:
602    /// `warn` (default), `strict`, `off`. See `lint_gate` module for the
603    /// full precedence chain.
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub agent_doc_lint_dialect: Option<LintDialectMode>,
606    /// Document-level lifecycle hooks: shell commands executed at key events.
607    ///
608    /// Supported events: `session_start`, `post_write`, `post_commit`.
609    /// Each event maps to a list of shell commands (run via `sh -c`).
610    ///
611    /// Template variables substituted before execution:
612    /// - `{{session_id}}` — document session UUID
613    /// - `{{file}}` — document file path
614    /// - `{{agent}}` — agent name (or empty string)
615    /// - `{{model}}` — model name (or empty string)
616    ///
617    /// Execution is best-effort: failures log to stderr and never block the session.
618    ///
619    /// Example:
620    /// ```yaml
621    /// hooks:
622    ///   session_start:
623    ///     - "curl -s -X POST https://api.example.com/sessions -d '{\"session_id\": \"{{session_id}}\"}'"
624    ///   post_write:
625    ///     - "notify-send 'agent-doc' 'Response written to {{file}}'"
626    /// ```
627    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
628    pub hooks: std::collections::HashMap<String, Vec<String>>,
629    /// Environment variables to inject into the Claude session.
630    /// Keys are env var names, values are strings supporting shell expansion
631    /// (`$(command)`, `$VAR`, `${VAR}`). Order is preserved: later values can
632    /// reference earlier keys. A `null` value (YAML `KEY: null`) means "unset
633    /// this key in the child env" — supported by every consumer (start.rs,
634    /// run.rs, stream.rs, parallel.rs, supervisor/env.rs).
635    #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
636    pub env: indexmap::IndexMap<String, Option<String>>,
637    /// Reusable prompt snippets that can be expanded into orchestrated task prompts.
638    ///
639    /// Example:
640    /// ```yaml
641    /// prompt_presets:
642    ///   "#1": |
643    ///     Today is 2026-04-25.
644    ///     Keep the work tree clean.
645    /// ```
646    #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
647    pub prompt_presets: indexmap::IndexMap<String, String>,
648    /// Lower-agent dispatch policy for planning/job-packet generation.
649    /// Values are currently interpreted by `plan` as manual/auto/off text;
650    /// unknown values round-trip so newer policy names do not break older binaries.
651    #[serde(
652        default,
653        skip_serializing_if = "Option::is_none",
654        rename = "agent_doc_dispatch"
655    )]
656    pub dispatch: Option<String>,
657    /// Whether the supervisor starts from the parent process env before
658    /// applying the `env:` overlay. Default `true` — the supervised claude
659    /// child inherits PATH/HOME/TERM/TMUX/... from the shell that launched
660    /// agent-doc, then frontmatter `env:` overrides/unsets specific keys.
661    /// Set `false` for a sealed env (only the explicit `env:` keys, nothing
662    /// from the parent). Consumed by `supervisor/env.rs::EnvSpec`.
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub agent_doc_env_inherit: Option<bool>,
665    /// Explicit working directory for the supervised claude child process.
666    /// Resolved relative to the document's parent directory if a relative path
667    /// is given. When absent, the supervisor falls back to project-root detection
668    /// (`.agent-doc/` walk) or the document's parent directory.
669    /// See `src/agent-doc/specs/supervisor.md` § Core Invariants / CWD determinism.
670    #[serde(
671        default,
672        skip_serializing_if = "Option::is_none",
673        rename = "agent_doc_cwd"
674    )]
675    pub cwd: Option<String>,
676    /// Canonical queue activation control (`#queue-state-unify`).
677    /// `start` (alias `go`) activates the queue; `stop` deactivates it. This is
678    /// the preferred surface and subsumes the deprecated `queue_active:` boolean
679    /// and the `auto` marker attribute. [`normalize_queue_control`] folds an
680    /// explicit value onto `queue_active` at parse time so every existing reader
681    /// honors it; the canonical key wins over a stale `queue_active:`.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub queue: Option<String>,
684    /// **Deprecated** in favor of the canonical `queue:` control. Whether the
685    /// queue is currently active (consuming prompts). Set to `true` when the
686    /// queue is activated via `queue: start`/`go`, `auto`, a start fence, or
687    /// `do queue`/`run queue`. Cleared when the queue drains to empty. Still read
688    /// and written for backward compatibility.
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub queue_active: Option<bool>,
691    /// Opt-in length threshold for the `#queue-prompt-echo-in-response` echo.
692    /// When `Some(n)` and a consumed queue prompt is longer than `n` characters,
693    /// the echo records a bounded summary (first line + truncation note + a
694    /// pointer to the full `agent:queue` text) instead of copying the whole
695    /// prompt verbatim. `None` (default) preserves the verbatim copy
696    /// (`#queue-prompt-echo-summary`; the user deferred summarization with "for
697    /// now, let's try copying the entire prompt", so it stays opt-in).
698    #[serde(
699        default,
700        skip_serializing_if = "Option::is_none",
701        rename = "agent_doc_queue_prompt_echo_max_chars"
702    )]
703    pub queue_prompt_echo_max_chars: Option<usize>,
704    /// Collaboration scope for the document.
705    /// `shared` opts the document into stricter cross-document security checks.
706    #[serde(
707        default,
708        skip_serializing_if = "Option::is_none",
709        rename = "agent_doc_collaboration"
710    )]
711    pub collaboration: Option<CollaborationMode>,
712    /// Audit note / approval token for cross-document reads or transfers in shared mode.
713    #[serde(
714        default,
715        skip_serializing_if = "Option::is_none",
716        rename = "agent_doc_security_review"
717    )]
718    pub security_review: Option<String>,
719    /// Live finalize-pipeline state tracker (`#fm-run-id-step`). Ephemeral runtime
720    /// state written at each cycle phase transition and cleared at a terminal phase;
721    /// surfaces `run_id` / `step` / `turn_id` / `queue_task_id` for crash re-entrancy
722    /// and observability without a dedicated git commit. See [`AgentDocPipeline`].
723    #[serde(
724        default,
725        skip_serializing_if = "AgentDocPipeline::is_empty",
726        rename = "agent_doc_pipeline"
727    )]
728    pub pipeline: AgentDocPipeline,
729}
730
731/// Resolve a user-authored prompt preset reference to the frontmatter key.
732///
733/// Exact keys win. As a convenience for command-like preset names, a bare
734/// reference such as `preset review` also resolves to a `#review` key when
735/// only the hashtag form is defined.
736pub fn resolve_prompt_preset_key(
737    prompt_presets: &indexmap::IndexMap<String, String>,
738    requested: &str,
739) -> Option<String> {
740    let requested = requested.trim();
741    if prompt_presets.contains_key(requested) {
742        return Some(requested.to_string());
743    }
744    if !requested.starts_with('#') {
745        let hashtag_key = format!("#{requested}");
746        if prompt_presets.contains_key(hashtag_key.as_str()) {
747            return Some(hashtag_key);
748        }
749    }
750    None
751}
752
753impl Frontmatter {
754    /// Does this frontmatter carry any agent-doc-managed field?
755    ///
756    /// Used by the opt-in document gate
757    /// ([`crate::project_config::is_agent_doc_document`]) to recognize a `.md`
758    /// that the user clearly authored as an agent-doc session. Plain
759    /// notes/README frontmatter (title/date/tags) carries none of these, so it
760    /// is not auto-converted. This is the single source of truth — keep it in
761    /// sync when adding new session-defining frontmatter fields.
762    pub fn has_agent_doc_marker(&self) -> bool {
763        self.session.is_some()
764            || self.format.is_some()
765            || self.write_mode.is_some()
766            || self.mode.is_some()
767            || self.stream_config.is_some()
768            || self.agent.is_some()
769            || self.resume.is_some()
770            || self.model.is_some()
771            || self.claude_model.is_some()
772            || self.codex_model.is_some()
773            || self.opencode_model.is_some()
774            || self.agent_args.is_some()
775            || self.claude_args.is_some()
776            || self.codex_args.is_some()
777            || self.opencode_args.is_some()
778            || self.branch.is_some()
779            || self.tmux_session.is_some()
780            || self.queue_active.is_some()
781            || self.dispatch.is_some()
782            || self.collaboration.is_some()
783            || !self.prompt_presets.is_empty()
784            || !self.pipeline.is_empty()
785    }
786
787    /// Resolve the canonical (format, write) pair from all three fields.
788    ///
789    /// Priority:
790    /// 1. Explicit `agent_doc_format` / `agent_doc_write` fields (highest)
791    /// 2. Deprecated `agent_doc_mode` field (auto-migrated)
792    /// 3. Defaults: format=template, write=crdt
793    pub fn resolve_mode(&self) -> ResolvedMode {
794        // Start with defaults
795        let mut format = AgentDocFormat::Template;
796        let mut write = AgentDocWrite::Crdt;
797
798        // Apply deprecated mode if present (lowest priority)
799        if let Some(ref mode_str) = self.mode {
800            match mode_str.as_str() {
801                "append" => {
802                    format = AgentDocFormat::Append;
803                    // write stays crdt (user preference: always crdt)
804                }
805                "template" => {
806                    format = AgentDocFormat::Template;
807                    // write stays crdt
808                }
809                "stream" => {
810                    format = AgentDocFormat::Template;
811                    write = AgentDocWrite::Crdt;
812                }
813                _ => {} // unknown mode, use defaults
814            }
815        }
816
817        // Override with explicit new fields (highest priority)
818        if let Some(f) = self.format {
819            format = f;
820        }
821        if let Some(w) = self.write_mode {
822            write = w;
823        }
824
825        ResolvedMode { format, write }
826    }
827
828    /// Resolve the effective model for attribution, given the active harness.
829    ///
830    /// Priority: harness-specific field (`claude_model` / `codex_model` / `opencode_model`)
831    /// over generic `model`.
832    /// Harness mapping: `"claude-code"` uses `claude_model`, `"codex"` uses `codex_model`,
833    /// and `"opencode"` uses `opencode_model`.
834    pub fn resolve_harness_model(&self, harness: &str) -> Option<&str> {
835        let harness_specific = match harness {
836            "claude-code" => self.claude_model.as_deref(),
837            "codex" => self.codex_model.as_deref(),
838            "opencode" => self.opencode_model.as_deref(),
839            _ => None,
840        };
841        harness_specific.or(self.model.as_deref())
842    }
843
844    pub fn collaboration_mode(&self) -> CollaborationMode {
845        self.collaboration.unwrap_or_default()
846    }
847
848    pub fn has_security_review(&self) -> bool {
849        self.security_review
850            .as_deref()
851            .map(str::trim)
852            .is_some_and(|value| !value.is_empty())
853    }
854}
855
856/// Fold the canonical `queue:` control onto the deprecated `queue_active`
857/// boolean (`#queue-state-unify`). An explicit, recognized `queue:` value wins
858/// over any stale `queue_active:` already present, so the canonical key is the
859/// single source of truth. Unrecognized/empty `queue:` values leave
860/// `queue_active` untouched. Callers run this immediately after deserializing
861/// frontmatter so every downstream reader of `queue_active` honors `queue:`.
862pub fn normalize_queue_control(fm: &mut Frontmatter) {
863    if let Some(control) = fm.queue.as_deref().and_then(QueueControl::parse) {
864        fm.queue_active = Some(control.is_active());
865    }
866}
867
868/// Parse YAML frontmatter from a document. Returns (frontmatter, body).
869/// If no frontmatter block is present, returns defaults and the full content as body.
870pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
871    let Some((yaml, body)) = split_frontmatter(content)? else {
872        return Ok((Frontmatter::default(), content));
873    };
874    let mut fm: Frontmatter = serde_yaml::from_str(yaml)?;
875    normalize_queue_control(&mut fm);
876    Ok((fm, body))
877}
878
879/// Wrap a frontmatter parse failure with the target document display string
880/// and a repair hint.
881pub fn contextualize_parse_error(file_display: &str, err: anyhow::Error) -> anyhow::Error {
882    anyhow::anyhow!(
883        "invalid YAML frontmatter in {}: {}\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
884        file_display,
885        err
886    )
887}
888
889/// Pure SSH-resolver context for [`parse_with_ssh_resolver`] / [`ensure_session_with_ssh_resolver`].
890///
891/// Callers in the orchestration layer pre-compute the project config + canonical
892/// relative path before invoking the pure parse path, so this module never has
893/// to touch `std::fs::canonicalize` or walk the project root from a `&Path`.
894pub struct SshResolverContext<'a> {
895    /// Resolved project config (already loaded from `.agent-doc/config.toml`).
896    pub project: &'a crate::project_config::ProjectConfig,
897    /// Path of the document relative to the project root, forward-slashed
898    /// (e.g. `tasks/agent-doc/plan.md`). Empty string when no project root.
899    pub doc_relative: &'a str,
900    /// Display string for the document path — used only for error messages.
901    pub file_display: &'a str,
902}
903
904/// Parse frontmatter using a pre-resolved SSH-defaults context.
905///
906/// Pure: no `&Path`, no `std::fs`. The file-based `parse_for_file` wrapper
907/// in `agent_doc::frontmatter_io` resolves the context and delegates here.
908pub fn parse_with_ssh_resolver<'a>(
909    content: &'a str,
910    ctx: &SshResolverContext<'_>,
911) -> Result<(Frontmatter, &'a str)> {
912    let Some((yaml, body)) = split_frontmatter(content)? else {
913        let mut fm = Frontmatter::default();
914        apply_required_ssh_defaults(&mut fm, ctx)?;
915        return Ok((fm, content));
916    };
917    match serde_yaml::from_str(yaml) {
918        Ok(mut fm) => {
919            normalize_queue_control(&mut fm);
920            apply_required_ssh_defaults(&mut fm, ctx)?;
921            Ok((fm, body))
922        }
923        Err(err) => Err(contextualize_yaml_parse_error(ctx.file_display, yaml, err)),
924    }
925}
926
927/// Ensure a session id using a pre-resolved SSH-defaults context.
928pub fn ensure_session_with_ssh_resolver(
929    content: &str,
930    ctx: &SshResolverContext<'_>,
931) -> Result<(String, String)> {
932    let (mut fm, body) = parse_with_ssh_resolver(content, ctx)?;
933    if let Some(existing) = fm.session.clone() {
934        return Ok((content.to_string(), existing));
935    }
936    let session_id = Uuid::new_v4().to_string();
937    fm.session = Some(session_id.clone());
938    let updated = write(&fm, body)?;
939    Ok((updated, session_id))
940}
941
942/// Write frontmatter back into a document, preserving the body.
943///
944/// `#writer-emits-deprecated-queue-active-false`: emit the canonical `queue:`
945/// control, never the deprecated `queue_active:` line — regardless of which path
946/// set the queue state. Any caller that set `queue_active` directly (then called
947/// `write()`) has it folded onto `queue` here, and a `queue_active` carried over
948/// from a parse (where `normalize_queue_control` mirrors `queue:` onto it) is
949/// dropped so it is not double-emitted alongside the canonical key. Readers still
950/// resolve both forms (`normalize_queue_control` folds `queue:` back onto
951/// `queue_active` on parse), and a doc with no queue state writes neither.
952pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
953    let fm = if fm.queue_active.is_some() {
954        let mut canonical = fm.clone();
955        if canonical.queue.is_none() {
956            canonical.queue = Some(
957                if fm.queue_active == Some(true) {
958                    "start"
959                } else {
960                    "stop"
961                }
962                .to_string(),
963            );
964        }
965        canonical.queue_active = None;
966        std::borrow::Cow::Owned(canonical)
967    } else {
968        std::borrow::Cow::Borrowed(fm)
969    };
970    let yaml = serde_yaml::to_string(fm.as_ref())?;
971    Ok(format!("---\n{}---\n{}", yaml, body))
972}
973
974/// Update the session ID in a document string. Creates frontmatter if missing.
975pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
976    let (mut fm, body) = parse(content)?;
977    fm.session = Some(session_id.to_string());
978    write(&fm, body)
979}
980
981/// Update the resume (agent conversation) ID in a document string.
982pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
983    let (mut fm, body) = parse(content)?;
984    fm.resume = Some(resume_id.to_string());
985    write(&fm, body)
986}
987
988/// Update the live finalize-pipeline tracker in a document's frontmatter,
989/// preserving every other field and the body (`#fm-run-id-step`).
990///
991/// Each `Some` argument overwrites that field; each `None` leaves the existing
992/// value untouched. Use [`clear_pipeline_state`] to drop the whole
993/// `agent_doc_pipeline:` block at a terminal cycle phase.
994pub fn set_pipeline_state(
995    content: &str,
996    run_id: Option<&str>,
997    step: Option<&str>,
998    turn_id: Option<&str>,
999    queue_task_id: Option<&str>,
1000) -> Result<String> {
1001    let (mut fm, body) = parse(content)?;
1002    if let Some(v) = run_id {
1003        fm.pipeline.run_id = Some(v.to_string());
1004    }
1005    if let Some(v) = step {
1006        fm.pipeline.step = Some(v.to_string());
1007    }
1008    if let Some(v) = turn_id {
1009        fm.pipeline.turn_id = Some(v.to_string());
1010    }
1011    if let Some(v) = queue_task_id {
1012        fm.pipeline.queue_task_id = Some(v.to_string());
1013    }
1014    write(&fm, body)
1015}
1016
1017/// Drop the live finalize-pipeline tracker from a document's frontmatter,
1018/// preserving every other field and the body (`#fm-run-id-step`).
1019///
1020/// Called at a terminal cycle phase so a drained cycle leaves no stale
1021/// `agent_doc_pipeline:` block behind. No-op when the block is already absent.
1022pub fn clear_pipeline_state(content: &str) -> Result<String> {
1023    let (mut fm, body) = parse(content)?;
1024    fm.pipeline = AgentDocPipeline::default();
1025    write(&fm, body)
1026}
1027
1028/// `#queue-active-deprecated-line-stuck`: byte-precise removal of the deprecated
1029/// top-level `queue_active:` line from the leading `--- ... ---` frontmatter
1030/// region, but ONLY when the canonical `queue:` control is also present (so the
1031/// queue state is never lost). Every other byte is preserved verbatim.
1032///
1033/// This is the migration counterpart to [`merge_queue_state`]/[`write`], which
1034/// drop `queue_active` when they re-serialize. Those never fire on a doc whose
1035/// hot path preserves frontmatter byte-precisely, and the diff layer classifies
1036/// any `queue_active:` line as managed state (so its removal reads as a no-op and
1037/// is never committed) — leaving a legacy `queue_active: true|false` line stuck
1038/// in the file forever. Preflight repair calls this once to drop it directly on
1039/// disk + snapshot so the canonical `queue:` becomes the sole queue control.
1040/// Returns the input unchanged when there is no leading frontmatter region, no
1041/// `queue:` line, or no deprecated `queue_active:` line.
1042pub fn strip_deprecated_queue_active_line(content: &str) -> String {
1043    let lines: Vec<&str> = content.split('\n').collect();
1044    if lines.first().map(|l| l.trim_end()) != Some("---") {
1045        return content.to_string();
1046    }
1047    let Some(close_idx) = lines
1048        .iter()
1049        .enumerate()
1050        .skip(1)
1051        .find(|(_, l)| l.trim_end() == "---")
1052        .map(|(i, _)| i)
1053    else {
1054        return content.to_string();
1055    };
1056    let fm_lines = &lines[1..close_idx];
1057    let has_canonical_queue = fm_lines
1058        .iter()
1059        .any(|l| l.trim_start().starts_with("queue:"));
1060    let has_deprecated = fm_lines
1061        .iter()
1062        .any(|l| l.trim_start().starts_with("queue_active:"));
1063    if !has_canonical_queue || !has_deprecated {
1064        return content.to_string();
1065    }
1066    let out: Vec<&str> = lines
1067        .iter()
1068        .enumerate()
1069        .filter(|(i, line)| {
1070            // Only drop the deprecated line inside the frontmatter region.
1071            !(*i > 0 && *i < close_idx && line.trim_start().starts_with("queue_active:"))
1072        })
1073        .map(|(_, line)| *line)
1074        .collect();
1075    out.join("\n")
1076}
1077
1078/// #22a8 (Phase 5b write-side): byte-precise removal of the `agent_doc_pipeline:`
1079/// block (its key line plus indented children) from the leading `--- ... ---`
1080/// frontmatter region. Every other byte is preserved verbatim — unlike
1081/// [`clear_pipeline_state`], this never re-serializes the rest of the
1082/// frontmatter, so it cannot reorder or reformat other fields. Returns the input
1083/// unchanged when there is no leading frontmatter region or no block.
1084pub fn strip_pipeline_block_lines(content: &str) -> String {
1085    let lines: Vec<&str> = content.split('\n').collect();
1086    if lines.first().map(|l| l.trim_end()) != Some("---") {
1087        return content.to_string();
1088    }
1089    let Some(close_idx) = lines
1090        .iter()
1091        .enumerate()
1092        .skip(1)
1093        .find(|(_, l)| l.trim_end() == "---")
1094        .map(|(i, _)| i)
1095    else {
1096        return content.to_string();
1097    };
1098    let mut out: Vec<&str> = Vec::with_capacity(lines.len());
1099    let mut skipping = false;
1100    for (i, line) in lines.iter().enumerate() {
1101        if i == 0 || i >= close_idx {
1102            skipping = false;
1103            out.push(line);
1104            continue;
1105        }
1106        if skipping {
1107            if line.starts_with(' ') || line.starts_with('\t') {
1108                continue;
1109            }
1110            skipping = false;
1111        }
1112        if line.trim_start().starts_with("agent_doc_pipeline:") {
1113            skipping = true;
1114            continue;
1115        }
1116        out.push(line);
1117    }
1118    out.join("\n")
1119}
1120
1121/// #22a8 (Phase 5b write-side): byte-precise mirror of the `agent_doc_pipeline:`
1122/// block into a document's frontmatter. Removes any existing block, then — when
1123/// `pipeline` is non-empty — renders a fresh block (via the struct's canonical
1124/// serializer, so `#`-prefixed ids are correctly quoted) and splices it in just
1125/// before the closing `---`. All other frontmatter bytes are preserved, so the
1126/// diff layer's matching strip cancels the write to `no_changes`. Clears the
1127/// block (leaving nothing behind) when `pipeline.is_empty()`.
1128pub fn splice_pipeline_block(content: &str, pipeline: &AgentDocPipeline) -> Result<String> {
1129    let stripped = strip_pipeline_block_lines(content);
1130    if pipeline.is_empty() {
1131        return Ok(stripped);
1132    }
1133    #[derive(serde::Serialize)]
1134    struct Wrap<'a> {
1135        agent_doc_pipeline: &'a AgentDocPipeline,
1136    }
1137    let rendered = serde_yaml::to_string(&Wrap {
1138        agent_doc_pipeline: pipeline,
1139    })
1140    .map_err(|e| anyhow::anyhow!("serialize agent_doc_pipeline block: {e}"))?;
1141    let block_lines: Vec<&str> = rendered.trim_end_matches('\n').split('\n').collect();
1142
1143    let lines: Vec<&str> = stripped.split('\n').collect();
1144    if lines.first().map(|l| l.trim_end()) != Some("---") {
1145        return Ok(stripped);
1146    }
1147    let Some(close_idx) = lines
1148        .iter()
1149        .enumerate()
1150        .skip(1)
1151        .find(|(_, l)| l.trim_end() == "---")
1152        .map(|(i, _)| i)
1153    else {
1154        return Ok(stripped);
1155    };
1156    let mut out: Vec<String> = Vec::with_capacity(lines.len() + block_lines.len());
1157    for (i, line) in lines.iter().enumerate() {
1158        if i == close_idx {
1159            for bl in &block_lines {
1160                out.push((*bl).to_string());
1161            }
1162        }
1163        out.push((*line).to_string());
1164    }
1165    Ok(out.join("\n"))
1166}
1167
1168/// Set both agent_doc_format and agent_doc_write, clearing deprecated agent_doc_mode.
1169pub fn set_format_and_write(
1170    content: &str,
1171    format: AgentDocFormat,
1172    write_mode: AgentDocWrite,
1173) -> Result<String> {
1174    let (mut fm, body) = parse(content)?;
1175    fm.format = Some(format);
1176    fm.write_mode = Some(write_mode);
1177    fm.mode = None;
1178    write(&fm, body)
1179}
1180
1181/// Merge YAML key/value pairs into a document's frontmatter.
1182///
1183/// Takes a YAML string of fields to merge (additive — never removes keys).
1184/// Only known frontmatter fields are applied; unknown keys are ignored.
1185/// Returns the updated document content.
1186pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
1187    let (mut fm, body) = parse(content)?;
1188    let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
1189        .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
1190    let mapping = patch
1191        .as_mapping()
1192        .unwrap_or(&serde_yaml::Mapping::new())
1193        .clone();
1194
1195    for (key, value) in &mapping {
1196        let key_str = key.as_str().unwrap_or("");
1197        let val_str = || value.as_str().map(|s| s.to_string());
1198        match key_str {
1199            "agent_doc_session" | "session" => fm.session = val_str(),
1200            "resume" => fm.resume = val_str(),
1201            "agent" => fm.agent = val_str(),
1202            "model" => fm.model = val_str(),
1203            "claude_model" => fm.claude_model = val_str(),
1204            "codex_model" => fm.codex_model = val_str(),
1205            "opencode_model" => fm.opencode_model = val_str(),
1206            "branch" => fm.branch = val_str(),
1207            "tmux_session" => fm.tmux_session = val_str(),
1208            "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
1209            "agent_doc_format" => {
1210                if let Some(s) = value.as_str()
1211                    && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
1212                {
1213                    fm.format = Some(f);
1214                }
1215            }
1216            "agent_doc_write" => {
1217                if let Some(s) = value.as_str()
1218                    && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
1219                {
1220                    fm.write_mode = Some(w);
1221                }
1222            }
1223            "pending_capture_guard" => {
1224                if let Some(s) = value.as_str()
1225                    && let Ok(mode) =
1226                        serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
1227                {
1228                    fm.pending_capture_guard = Some(mode);
1229                }
1230            }
1231            "pending_done_guard" => {
1232                if let Some(s) = value.as_str()
1233                    && let Ok(mode) =
1234                        serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
1235                {
1236                    fm.pending_done_guard = Some(mode);
1237                }
1238            }
1239            "review_done_guard" => {
1240                if let Some(s) = value.as_str()
1241                    && let Ok(mode) =
1242                        serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
1243                {
1244                    fm.review_done_guard = Some(mode);
1245                }
1246            }
1247            "auto_done" => {
1248                if let Some(enabled) = value.as_bool() {
1249                    fm.auto_done = Some(enabled);
1250                }
1251            }
1252            "prompt_presets" => {
1253                if let Ok(presets) =
1254                    serde_yaml::from_value::<indexmap::IndexMap<String, String>>(value.clone())
1255                {
1256                    fm.prompt_presets = presets;
1257                }
1258            }
1259            "agent_args" => fm.agent_args = val_str(),
1260            "claude_args" => fm.claude_args = val_str(),
1261            "codex_args" => fm.codex_args = val_str(),
1262            "opencode_args" => fm.opencode_args = val_str(),
1263            "codex_network_access" => {
1264                if let Some(s) = value.as_str()
1265                    && let Ok(mode) =
1266                        serde_yaml::from_str::<CodexNetworkAccess>(&format!("\"{}\"", s))
1267                {
1268                    fm.codex_network_access = Some(mode);
1269                }
1270            }
1271            "required_ssh_targets" => {
1272                if let Ok(targets) = serde_yaml::from_value::<Vec<String>>(value.clone()) {
1273                    fm.required_ssh_targets = targets;
1274                }
1275            }
1276            "required_ssh_profile" => fm.required_ssh_profile = val_str(),
1277            "queue" => {
1278                fm.queue = val_str();
1279                normalize_queue_control(&mut fm);
1280            }
1281            "queue_active" => {
1282                fm.queue_active = value.as_bool();
1283            }
1284            "agent_doc_collaboration" => {
1285                if let Some(s) = value.as_str()
1286                    && let Ok(mode) =
1287                        serde_yaml::from_str::<CollaborationMode>(&format!("\"{}\"", s))
1288                {
1289                    fm.collaboration = Some(mode);
1290                }
1291            }
1292            "agent_doc_security_review" => fm.security_review = val_str(),
1293            _ => {
1294                eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
1295            }
1296        }
1297    }
1298
1299    write(&fm, body)
1300}
1301
1302/// Persist the canonical `queue:` activation control (`#queue-state-unify`
1303/// phase 4), clearing the deprecated `queue_active:` line so `queue:` is the
1304/// single written source of truth. `active` → `queue: start`, else `queue: stop`.
1305/// Reads still resolve correctly because [`normalize_queue_control`] folds
1306/// `queue:` back onto `queue_active` on parse.
1307pub fn merge_queue_state(content: &str, active: bool) -> Result<String> {
1308    let (mut fm, body) = parse(content)?;
1309    fm.queue = Some(if active { "start" } else { "stop" }.to_string());
1310    fm.queue_active = None;
1311    write(&fm, body)
1312}
1313
1314/// Update the tmux_session name in a document string.
1315///
1316/// **Deprecated.** `tmux_session` in frontmatter is deprecated — session is now
1317/// determined at runtime. This function is retained for backward compatibility
1318/// (claim and sync still write it so older binaries can read it).
1319pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
1320    let (mut fm, body) = parse(content)?;
1321    fm.tmux_session = Some(session_name.to_string());
1322    write(&fm, body)
1323}
1324
1325/// Ensure the document has a session ID. If no frontmatter exists, creates one
1326/// with a new UUID v4. If frontmatter exists but session is None/null, generates
1327/// a UUID and sets it. If session already exists, returns as-is.
1328/// Returns (updated_content, session_id).
1329pub fn ensure_session(content: &str) -> Result<(String, String)> {
1330    let (fm, _body) = parse(content)?;
1331    if let Some(ref session_id) = fm.session {
1332        // Session already set — return content unchanged
1333        return Ok((content.to_string(), session_id.clone()));
1334    }
1335    let session_id = Uuid::new_v4().to_string();
1336    let updated = set_session_id(content, &session_id)?;
1337    Ok((updated, session_id))
1338}
1339
1340fn split_frontmatter(content: &str) -> Result<Option<(&str, &str)>> {
1341    let (opening_end, rest) = if let Some(rest) = content.strip_prefix("---\n") {
1342        (4, rest)
1343    } else {
1344        let without_bom = content.strip_prefix('\u{feff}').unwrap_or(content);
1345        let bom_len = content.len() - without_bom.len();
1346        let horizontal_ws_len = without_bom
1347            .bytes()
1348            .take_while(|byte| matches!(byte, b' ' | b'\t'))
1349            .count();
1350        let marker_start = bom_len + horizontal_ws_len;
1351        if (bom_len == 0 && horizontal_ws_len == 0) || !content[marker_start..].starts_with("---\n")
1352        {
1353            return Ok(None);
1354        }
1355        (marker_start + 4, &content[marker_start + 4..])
1356    };
1357    let (end, closing_len) = rest
1358        .find("\n---\n")
1359        .map(|end| (end, 5))
1360        .or_else(|| rest.find("\n---").map(|end| (end, 4)))
1361        .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
1362    let yaml = &rest[..end];
1363    let body_start = opening_end + end + closing_len; // opening marker + yaml + closing marker
1364    let body = if body_start <= content.len() {
1365        &content[body_start..]
1366    } else {
1367        ""
1368    };
1369    Ok(Some((yaml, body)))
1370}
1371
1372/// Split a document into `(Some(yaml_without_fences), body)` or `(None, content)`.
1373///
1374/// Merge-path convenience wrapper over the internal splitter that never errors:
1375/// an unterminated / malformed leading block is treated as "no frontmatter" so a
1376/// convergence merge degrades gracefully instead of panicking. `body` is the
1377/// document text after the closing `---` fence (the same slice [`parse`] returns).
1378pub fn split_frontmatter_parts(content: &str) -> (Option<&str>, &str) {
1379    match split_frontmatter(content) {
1380        Ok(Some((yaml, body))) => (Some(yaml), body),
1381        _ => (None, content),
1382    }
1383}
1384
1385/// Top-level frontmatter keys that agent-doc itself manages as runtime state
1386/// (`#fmreset`). On a *same-key* conflict — both the agent side (`ours`) and the
1387/// operator side (`theirs`) changed the key relative to `base` — the AGENT value
1388/// wins for these keys so a routine managed write (session/resume identity,
1389/// pipeline tracker) is not clobbered. Every other key is operator-authoritative.
1390/// Operator-only or agent-only edits always apply to their own key regardless of
1391/// this set, so an operator's frontmatter edit is never reverted.
1392///
1393/// Note: `queue` / `queue_active` are intentionally NOT here — the operator owns
1394/// `queue: start|stop`, and an agent-only drain flip still applies because it is
1395/// an agent-only change (operator did not touch it).
1396fn is_agent_managed_frontmatter_key(key: &str) -> bool {
1397    matches!(
1398        key,
1399        "agent_doc_session" | "session" | "resume" | "agent_doc_pipeline"
1400    )
1401}
1402
1403/// Field-level three-way merge of the YAML frontmatter region (`#fmreset`).
1404///
1405/// Inputs are the raw YAML blocks (WITHOUT the `---` fences) for the merge base,
1406/// the agent side (`ours`), and the operator side (`theirs`); `None` means that
1407/// side had no frontmatter. Returns the merged block WITH fences and a trailing
1408/// newline (ready to prepend to a body), or `None` when no side had frontmatter.
1409///
1410/// Resolution per top-level key:
1411/// - changed on only one side → that side's value (operator edits and agent
1412///   managed writes both apply to their own keys; this is what stops the operator
1413///   frontmatter "reset");
1414/// - changed on both sides to the same value → that value (convergent);
1415/// - changed on both sides differently → operator (`theirs`) wins, EXCEPT for the
1416///   agent-managed keys ([`is_agent_managed_frontmatter_key`]) where the agent
1417///   value wins;
1418/// - unchanged on both → base value.
1419///
1420/// Unknown / operator-authored keys are preserved verbatim because the merge runs
1421/// over a `serde_yaml` mapping, never the typed [`Frontmatter`] struct (which would
1422/// silently drop unmodeled keys). A side with no frontmatter is treated as "did
1423/// not touch frontmatter" (its keys mirror base) so a transient absent side can
1424/// never delete operator keys. When any present side does not parse as a YAML
1425/// mapping, the merge falls back conservatively to the whole operator block when
1426/// the operator changed it, else the agent block.
1427pub fn merge_frontmatter_3way(
1428    base_yaml: Option<&str>,
1429    ours_yaml: Option<&str>,
1430    theirs_yaml: Option<&str>,
1431) -> Option<String> {
1432    if base_yaml.is_none() && ours_yaml.is_none() && theirs_yaml.is_none() {
1433        return None;
1434    }
1435
1436    let wrap = |yaml: &str| {
1437        let trimmed = yaml.trim_end_matches('\n');
1438        if trimmed.is_empty() {
1439            "---\n---\n".to_string()
1440        } else {
1441            format!("---\n{trimmed}\n---\n")
1442        }
1443    };
1444
1445    // Parse each present side as a YAML mapping. `Some(None)` = absent side;
1446    // `Some(Some(map))` = parsed mapping; `None` = present but not a mapping.
1447    let parse = |yaml: Option<&str>| -> Option<Option<serde_yaml::Mapping>> {
1448        match yaml {
1449            None => Some(None),
1450            Some(s) if s.trim().is_empty() => Some(Some(serde_yaml::Mapping::new())),
1451            Some(s) => match serde_yaml::from_str::<serde_yaml::Value>(s) {
1452                Ok(serde_yaml::Value::Mapping(m)) => Some(Some(m)),
1453                Ok(serde_yaml::Value::Null) => Some(Some(serde_yaml::Mapping::new())),
1454                _ => None,
1455            },
1456        }
1457    };
1458
1459    let (base_m, ours_m, theirs_m) = match (parse(base_yaml), parse(ours_yaml), parse(theirs_yaml))
1460    {
1461        (Some(b), Some(o), Some(t)) => (b, o, t),
1462        _ => {
1463            // Conservative whole-block fallback: operator wins if it changed
1464            // the block; otherwise keep the agent block, else operator, else base.
1465            let operator_changed = base_yaml != theirs_yaml;
1466            let chosen = if operator_changed {
1467                theirs_yaml
1468            } else {
1469                ours_yaml.or(theirs_yaml)
1470            };
1471            return chosen.or(base_yaml).map(wrap);
1472        }
1473    };
1474
1475    let base_m = base_m.unwrap_or_default();
1476    // A side with no frontmatter at all is treated as "unchanged from base" so it
1477    // cannot spuriously delete keys the other side authored.
1478    let ours_m = ours_m.unwrap_or_else(|| base_m.clone());
1479    let theirs_m = theirs_m.unwrap_or_else(|| base_m.clone());
1480
1481    let key_str = |k: &serde_yaml::Value| k.as_str().map(|s| s.to_string());
1482
1483    // Key order: operator first (operator owns layout), then agent-only, then base-only.
1484    let mut order: Vec<serde_yaml::Value> = Vec::new();
1485    let push_unique = |order: &mut Vec<serde_yaml::Value>, k: &serde_yaml::Value| {
1486        if !order.iter().any(|o| o == k) {
1487            order.push(k.clone());
1488        }
1489    };
1490    for k in theirs_m.keys() {
1491        push_unique(&mut order, k);
1492    }
1493    for k in ours_m.keys() {
1494        push_unique(&mut order, k);
1495    }
1496    for k in base_m.keys() {
1497        push_unique(&mut order, k);
1498    }
1499
1500    let mut merged = serde_yaml::Mapping::new();
1501    for key in &order {
1502        let b = base_m.get(key);
1503        let o = ours_m.get(key);
1504        let t = theirs_m.get(key);
1505
1506        let agent_changed = o != b;
1507        let operator_changed = t != b;
1508
1509        let chosen: Option<&serde_yaml::Value> = if !agent_changed && !operator_changed {
1510            b.or(o).or(t)
1511        } else if agent_changed && operator_changed {
1512            if o == t {
1513                t
1514            } else if key_str(key)
1515                .as_deref()
1516                .is_some_and(is_agent_managed_frontmatter_key)
1517            {
1518                o
1519            } else {
1520                t
1521            }
1522        } else if operator_changed {
1523            t
1524        } else {
1525            o
1526        };
1527
1528        if let Some(v) = chosen {
1529            merged.insert(key.clone(), v.clone());
1530        }
1531    }
1532
1533    if merged.is_empty() {
1534        return Some("---\n---\n".to_string());
1535    }
1536    let yaml = serde_yaml::to_string(&serde_yaml::Value::Mapping(merged)).ok()?;
1537    Some(wrap(&yaml))
1538}
1539
1540/// Apply required-SSH defaults to a frontmatter using a pre-resolved context.
1541///
1542/// Pure: no `&Path`, no `std::fs`. The caller is responsible for resolving
1543/// the project config and computing the document's project-relative path
1544/// before invoking.
1545pub fn apply_required_ssh_defaults(
1546    fm: &mut Frontmatter,
1547    ctx: &SshResolverContext<'_>,
1548) -> Result<()> {
1549    if !fm.required_ssh_targets.is_empty() {
1550        fm.required_ssh_targets = normalize_required_ssh_targets(&fm.required_ssh_targets);
1551        return Ok(());
1552    }
1553
1554    if ctx.doc_relative.is_empty() {
1555        return Ok(());
1556    }
1557
1558    let resolved = if let Some(profile_name) = fm.required_ssh_profile.as_deref() {
1559        resolve_required_ssh_profile_targets(&ctx.project.ssh, profile_name, ctx.file_display)?
1560    } else if let Some(doc_cfg) = ctx.project.ssh.docs.get(ctx.doc_relative) {
1561        let mut targets = doc_cfg.targets.clone();
1562        if let Some(profile_name) = doc_cfg.profile.as_deref() {
1563            targets.extend(resolve_required_ssh_profile_targets(
1564                &ctx.project.ssh,
1565                profile_name,
1566                ctx.file_display,
1567            )?);
1568        }
1569        if targets.is_empty() {
1570            anyhow::bail!(
1571                "document {} is configured as SSH-dependent in .agent-doc/config.toml but no required_ssh_targets resolved for `{}`.\n\nAdd direct targets under [ssh.docs.\"{}\"] or define a valid profile under [ssh.profiles.<name>].",
1572                ctx.file_display,
1573                ctx.doc_relative,
1574                ctx.doc_relative
1575            );
1576        }
1577        targets
1578    } else {
1579        Vec::new()
1580    };
1581
1582    fm.required_ssh_targets = normalize_required_ssh_targets(&resolved);
1583    Ok(())
1584}
1585
1586fn resolve_required_ssh_profile_targets(
1587    ssh: &crate::project_config::SshConfig,
1588    profile_name: &str,
1589    file_display: &str,
1590) -> Result<Vec<String>> {
1591    let profile = ssh.profiles.get(profile_name).ok_or_else(|| {
1592        anyhow::anyhow!(
1593            "document {} requires SSH profile `{}` but .agent-doc/config.toml has no matching [ssh.profiles.{}] entry.",
1594            file_display,
1595            profile_name,
1596            profile_name
1597        )
1598    })?;
1599    if profile.targets.is_empty() {
1600        anyhow::bail!(
1601            "document {} requires SSH profile `{}` but [ssh.profiles.{}] has no targets.",
1602            file_display,
1603            profile_name,
1604            profile_name
1605        );
1606    }
1607    Ok(profile.targets.clone())
1608}
1609
1610fn normalize_required_ssh_targets(targets: &[String]) -> Vec<String> {
1611    let mut normalized = Vec::new();
1612    for target in targets {
1613        let trimmed = target.trim();
1614        if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) {
1615            continue;
1616        }
1617        normalized.push(trimmed.to_string());
1618    }
1619    normalized
1620}
1621
1622fn contextualize_yaml_parse_error(
1623    file_display: &str,
1624    yaml: &str,
1625    err: serde_yaml::Error,
1626) -> anyhow::Error {
1627    let excerpt = err
1628        .location()
1629        .and_then(|location| render_frontmatter_excerpt(yaml, location.line(), location.column()));
1630    let mut message = format!("invalid YAML frontmatter in {}: {}", file_display, err);
1631    if let Some(excerpt) = excerpt {
1632        message.push_str("\n\nFrontmatter excerpt:\n");
1633        message.push_str(&excerpt);
1634    }
1635    message.push_str(
1636        "\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
1637    );
1638    anyhow::anyhow!(message)
1639}
1640
1641fn render_frontmatter_excerpt(yaml: &str, line: usize, column: usize) -> Option<String> {
1642    let lines: Vec<&str> = yaml.lines().collect();
1643    if line == 0 || line > lines.len() {
1644        return None;
1645    }
1646
1647    let start = line.saturating_sub(1).max(1);
1648    let end = (line + 1).min(lines.len());
1649    let width = end.to_string().len();
1650    let mut rendered = Vec::new();
1651
1652    for current in start..=end {
1653        let marker = if current == line { '>' } else { ' ' };
1654        rendered.push(format!(
1655            "{} {:>width$} | {}",
1656            marker,
1657            current,
1658            lines[current - 1],
1659            width = width
1660        ));
1661        if current == line {
1662            rendered.push(format!(
1663                "  {:>width$} | {}^",
1664                "",
1665                " ".repeat(column.saturating_sub(1)),
1666                width = width
1667            ));
1668        }
1669    }
1670
1671    Some(rendered.join("\n"))
1672}
1673
1674#[cfg(test)]
1675mod tests {
1676    use super::*;
1677
1678    // ---- #fmreset: field-level frontmatter 3-way merge ----
1679
1680    fn yaml_of(merged: &str) -> serde_yaml::Mapping {
1681        let (y, _) = split_frontmatter_parts(merged);
1682        serde_yaml::from_str(y.unwrap()).unwrap()
1683    }
1684
1685    #[test]
1686    fn fm3way_operator_only_edit_wins_not_reverted() {
1687        let base = "agent_doc_format: template\nclaude_model: sonnet";
1688        let ours = base; // agent untouched
1689        let theirs = "agent_doc_format: template\nclaude_model: opus"; // operator edit
1690        let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1691        let m = yaml_of(&merged);
1692        assert_eq!(m["claude_model"].as_str(), Some("opus"));
1693    }
1694
1695    #[test]
1696    fn fm3way_agent_only_managed_write_applies() {
1697        let base = "agent_doc_session: sid\nclaude_model: sonnet";
1698        let ours = "agent_doc_session: sid\nresume: conv-1\nclaude_model: sonnet";
1699        let theirs = base; // operator untouched
1700        let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1701        let m = yaml_of(&merged);
1702        assert_eq!(m["resume"].as_str(), Some("conv-1"));
1703        assert_eq!(m["claude_model"].as_str(), Some("sonnet"));
1704    }
1705
1706    #[test]
1707    fn fm3way_concurrent_operator_edit_and_agent_managed_write_field_wise() {
1708        let base = "agent_doc_session: sid\nclaude_model: sonnet";
1709        let ours = "agent_doc_session: sid\nresume: conv-1\nclaude_model: sonnet"; // agent wrote resume
1710        let theirs = "agent_doc_session: sid\nclaude_model: opus"; // operator edited model
1711        let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1712        let m = yaml_of(&merged);
1713        assert_eq!(m["resume"].as_str(), Some("conv-1"), "agent write kept");
1714        assert_eq!(
1715            m["claude_model"].as_str(),
1716            Some("opus"),
1717            "operator edit kept"
1718        );
1719    }
1720
1721    #[test]
1722    fn fm3way_same_key_conflict_operator_wins_for_operator_key() {
1723        // Both changed claude_model differently → operator (theirs) wins.
1724        let base = "claude_model: sonnet";
1725        let ours = "claude_model: haiku"; // agent
1726        let theirs = "claude_model: opus"; // operator
1727        let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1728        let m = yaml_of(&merged);
1729        assert_eq!(m["claude_model"].as_str(), Some("opus"));
1730    }
1731
1732    #[test]
1733    fn fm3way_same_key_conflict_agent_wins_for_managed_key() {
1734        // Both changed resume differently → agent (ours) wins (managed key).
1735        let base = "resume: c0";
1736        let ours = "resume: agent-new"; // agent
1737        let theirs = "resume: operator-typo"; // operator (rare/unintended)
1738        let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1739        let m = yaml_of(&merged);
1740        assert_eq!(m["resume"].as_str(), Some("agent-new"));
1741    }
1742
1743    #[test]
1744    fn fm3way_operator_adds_unknown_key_preserved() {
1745        let base = "agent_doc_format: template";
1746        let ours = base;
1747        let theirs = "agent_doc_format: template\nmy_custom_key: hello";
1748        let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1749        let m = yaml_of(&merged);
1750        assert_eq!(m["my_custom_key"].as_str(), Some("hello"));
1751    }
1752
1753    #[test]
1754    fn fm3way_operator_deletes_key() {
1755        let base = "agent_doc_format: template\nbranch: feature";
1756        let ours = base; // agent untouched
1757        let theirs = "agent_doc_format: template"; // operator removed branch
1758        let merged = merge_frontmatter_3way(Some(base), Some(ours), Some(theirs)).unwrap();
1759        let m = yaml_of(&merged);
1760        assert!(
1761            !m.contains_key("branch"),
1762            "operator deletion honored: {merged}"
1763        );
1764        assert!(m.contains_key("agent_doc_format"));
1765    }
1766
1767    #[test]
1768    fn fm3way_none_when_no_frontmatter() {
1769        assert!(merge_frontmatter_3way(None, None, None).is_none());
1770    }
1771
1772    #[test]
1773    fn parse_no_frontmatter() {
1774        let content = "# Hello\n\nBody text.\n";
1775        let (fm, body) = parse(content).unwrap();
1776        assert!(fm.session.is_none());
1777        assert!(fm.agent.is_none());
1778        assert!(fm.model.is_none());
1779        assert!(fm.branch.is_none());
1780        assert_eq!(body, content);
1781    }
1782
1783    #[test]
1784    fn parse_tolerates_leading_horizontal_space_before_frontmatter() {
1785        let content = " ---\nagent_doc_session: abc-123\n---\nBody\n";
1786        let (fm, body) = parse(content).unwrap();
1787        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1788        assert_eq!(body, "Body\n");
1789    }
1790
1791    #[test]
1792    fn parse_tolerates_bom_before_frontmatter() {
1793        let content = "\u{feff}---\nagent_doc_session: abc-123\n---\nBody\n";
1794        let (fm, body) = parse(content).unwrap();
1795        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1796        assert_eq!(body, "Body\n");
1797    }
1798
1799    #[test]
1800    fn parse_keeps_leading_blank_line_as_body_not_frontmatter() {
1801        let content = "\n---\nagent_doc_session: abc-123\n---\nBody\n";
1802        let (fm, body) = parse(content).unwrap();
1803        assert!(fm.session.is_none());
1804        assert_eq!(body, content);
1805    }
1806
1807    #[test]
1808    fn parse_all_fields() {
1809        let content =
1810            "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
1811        let (fm, body) = parse(content).unwrap();
1812        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1813        assert_eq!(fm.agent.as_deref(), Some("claude"));
1814        assert_eq!(fm.model.as_deref(), Some("opus"));
1815        assert_eq!(fm.branch.as_deref(), Some("main"));
1816        assert!(body.contains("Body"));
1817    }
1818
1819    #[test]
1820    fn queue_control_parse_aliases() {
1821        assert_eq!(QueueControl::parse("start"), Some(QueueControl::Start));
1822        assert_eq!(QueueControl::parse("go"), Some(QueueControl::Start));
1823        assert_eq!(QueueControl::parse("STOP"), Some(QueueControl::Stop));
1824        assert_eq!(QueueControl::parse("  Start  "), Some(QueueControl::Start));
1825        assert_eq!(QueueControl::parse("auto"), None);
1826        assert_eq!(QueueControl::parse(""), None);
1827        assert!(QueueControl::Start.is_active());
1828        assert!(!QueueControl::Stop.is_active());
1829    }
1830
1831    #[test]
1832    fn parse_queue_start_activates() {
1833        // `queue: start` (canonical) folds onto the deprecated queue_active bool
1834        // so every existing reader honors it (#queue-state-unify).
1835        let (fm, _) = parse("---\nagent_doc_format: template\nqueue: start\n---\n\n").unwrap();
1836        assert_eq!(fm.queue.as_deref(), Some("start"));
1837        assert_eq!(fm.queue_active, Some(true));
1838    }
1839
1840    #[test]
1841    fn parse_queue_go_alias_activates() {
1842        let (fm, _) = parse("---\nqueue: go\n---\n\n").unwrap();
1843        assert_eq!(fm.queue_active, Some(true));
1844    }
1845
1846    #[test]
1847    fn parse_queue_stop_deactivates() {
1848        let (fm, _) = parse("---\nqueue: stop\n---\n\n").unwrap();
1849        assert_eq!(fm.queue_active, Some(false));
1850    }
1851
1852    #[test]
1853    fn parse_queue_canonical_wins_over_stale_queue_active() {
1854        // An explicit `queue:` control overrides a stale `queue_active:` line.
1855        let (fm, _) = parse("---\nqueue: stop\nqueue_active: true\n---\n\n").unwrap();
1856        assert_eq!(fm.queue_active, Some(false));
1857
1858        let (fm, _) = parse("---\nqueue: start\nqueue_active: false\n---\n\n").unwrap();
1859        assert_eq!(fm.queue_active, Some(true));
1860    }
1861
1862    #[test]
1863    fn pipeline_is_empty_default() {
1864        let p = AgentDocPipeline::default();
1865        assert!(p.is_empty());
1866    }
1867
1868    #[test]
1869    fn parse_pipeline_all_fields() {
1870        let content = "---\nagent_doc_pipeline:\n  turn_id: \"#fm-run-id-step\"\n  run_id: cycle-1780600334196\n  step: response_captured\n  queue_task_id: \"#fm-run-id-step\"\n---\nBody\n";
1871        let (fm, _) = parse(content).unwrap();
1872        assert_eq!(fm.pipeline.turn_id.as_deref(), Some("#fm-run-id-step"));
1873        assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-1780600334196"));
1874        assert_eq!(fm.pipeline.step.as_deref(), Some("response_captured"));
1875        assert_eq!(
1876            fm.pipeline.queue_task_id.as_deref(),
1877            Some("#fm-run-id-step")
1878        );
1879        assert!(!fm.pipeline.is_empty());
1880    }
1881
1882    #[test]
1883    fn empty_pipeline_is_not_serialized() {
1884        // A drained/terminal cycle must leave no `agent_doc_pipeline:` block behind.
1885        let fm = Frontmatter {
1886            session: Some("abc".to_string()),
1887            ..Default::default()
1888        };
1889        let out = write(&fm, "Body\n").unwrap();
1890        assert!(!out.contains("agent_doc_pipeline"));
1891    }
1892
1893    #[test]
1894    fn set_pipeline_state_merges_without_disturbing_other_fields() {
1895        let content =
1896            "---\nagent_doc_session: abc-123\nagent: claude\nqueue: start\n---\n\n## Body\n";
1897        // Set run_id + step, leave turn_id/queue_task_id untouched.
1898        let updated =
1899            set_pipeline_state(content, Some("cycle-99"), Some("write_applied"), None, None)
1900                .unwrap();
1901        let (fm, body) = parse(&updated).unwrap();
1902        assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-99"));
1903        assert_eq!(fm.pipeline.step.as_deref(), Some("write_applied"));
1904        assert!(fm.pipeline.turn_id.is_none());
1905        // Other frontmatter fields and the body survive untouched.
1906        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1907        assert_eq!(fm.agent.as_deref(), Some("claude"));
1908        assert_eq!(fm.queue_active, Some(true));
1909        assert!(body.contains("## Body"));
1910
1911        // A second merge updates step + turn_id while preserving the prior run_id.
1912        let updated2 = set_pipeline_state(
1913            &updated,
1914            None,
1915            Some("committed"),
1916            Some("#fm-run-id-step"),
1917            None,
1918        )
1919        .unwrap();
1920        let (fm2, _) = parse(&updated2).unwrap();
1921        assert_eq!(fm2.pipeline.run_id.as_deref(), Some("cycle-99"));
1922        assert_eq!(fm2.pipeline.step.as_deref(), Some("committed"));
1923        assert_eq!(fm2.pipeline.turn_id.as_deref(), Some("#fm-run-id-step"));
1924    }
1925
1926    #[test]
1927    fn splice_pipeline_block_is_byte_precise_and_strip_cancels() {
1928        // #22a8: the write-side splice must touch ONLY the pipeline block, and
1929        // the diff-side strip must cancel it back to the original bytes.
1930        let base = "---\nagent_doc_session: abc-123\nagent: claude\nprompt_presets:\n  '#x': do the thing\nqueue: start\n---\n\n## Body\n- item\n";
1931        let pipeline = AgentDocPipeline {
1932            run_id: Some("cycle-99".into()),
1933            step: Some("response_captured".into()),
1934            turn_id: Some("#22a8".into()),
1935            queue_task_id: None,
1936        };
1937        let spliced = splice_pipeline_block(base, &pipeline).unwrap();
1938        // The block is present and parseable…
1939        assert!(spliced.contains("agent_doc_pipeline:"));
1940        let (fm, _) = parse(&spliced).unwrap();
1941        assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-99"));
1942        assert_eq!(fm.pipeline.turn_id.as_deref(), Some("#22a8"));
1943        // …every non-block field/byte is preserved (presets, order, body).
1944        assert_eq!(fm.agent.as_deref(), Some("claude"));
1945        // Stripping the block yields the ORIGINAL bytes exactly.
1946        assert_eq!(strip_pipeline_block_lines(&spliced), base);
1947        // Clearing (empty pipeline) also returns the original bytes.
1948        assert_eq!(
1949            splice_pipeline_block(&spliced, &AgentDocPipeline::default()).unwrap(),
1950            base
1951        );
1952        // Idempotent: re-splicing the same state does not stack blocks.
1953        let twice = splice_pipeline_block(&spliced, &pipeline).unwrap();
1954        assert_eq!(twice.matches("agent_doc_pipeline:").count(), 1);
1955    }
1956
1957    #[test]
1958    fn clear_pipeline_state_drops_block_only() {
1959        let content = "---\nagent_doc_session: abc-123\nagent_doc_pipeline:\n  run_id: cycle-1\n  step: response_captured\n---\n\nBody\n";
1960        let cleared = clear_pipeline_state(content).unwrap();
1961        assert!(!cleared.contains("agent_doc_pipeline"));
1962        let (fm, body) = parse(&cleared).unwrap();
1963        assert!(fm.pipeline.is_empty());
1964        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1965        assert!(body.contains("Body"));
1966    }
1967
1968    #[test]
1969    fn parse_queue_unknown_value_leaves_queue_active_untouched() {
1970        // A typo must never silently flip activation.
1971        let (fm, _) = parse("---\nqueue: bogus\nqueue_active: true\n---\n\n").unwrap();
1972        assert_eq!(fm.queue_active, Some(true));
1973        let (fm, _) = parse("---\nqueue: bogus\n---\n\n").unwrap();
1974        assert_eq!(fm.queue_active, None);
1975    }
1976
1977    #[test]
1978    fn write_emits_canonical_queue_for_direct_set_queue_active() {
1979        // #writer-emits-deprecated-queue-active-false: a path that sets
1980        // `queue_active` directly on the struct (bypassing merge_queue_state) and
1981        // then calls write() must still emit the canonical `queue:` control, never
1982        // the deprecated `queue_active:` line.
1983        let mut fm = Frontmatter {
1984            queue_active: Some(true),
1985            ..Default::default()
1986        };
1987        let out = write(&fm, "body\n").unwrap();
1988        assert!(out.contains("queue: start"), "{out}");
1989        assert!(!out.contains("queue_active:"), "{out}");
1990
1991        fm.queue_active = Some(false);
1992        let out = write(&fm, "body\n").unwrap();
1993        assert!(out.contains("queue: stop"), "{out}");
1994        assert!(!out.contains("queue_active:"), "{out}");
1995
1996        // A doc parsed from canonical `queue:` (queue_active mirrored on) must
1997        // not double-emit the deprecated line on re-serialize.
1998        let (fm, body) =
1999            parse("---\nagent_doc_format: template\nqueue: start\n---\n\nx\n").unwrap();
2000        let out = write(&fm, &body).unwrap();
2001        assert!(out.contains("queue: start"), "{out}");
2002        assert!(!out.contains("queue_active:"), "{out}");
2003
2004        // No queue state → neither key is emitted.
2005        let fm = Frontmatter::default();
2006        let out = write(&fm, "body\n").unwrap();
2007        assert!(
2008            !out.contains("queue_active:") && !out.contains("queue:"),
2009            "{out}"
2010        );
2011    }
2012
2013    #[test]
2014    fn merge_queue_state_writes_canonical_and_drops_legacy() {
2015        // #queue-state-unify phase 4: writer emits `queue: start|stop` and
2016        // removes any legacy `queue_active:` line so `queue:` is the sole field.
2017        let legacy = "---\nagent_doc_format: template\nqueue_active: true\n---\n\nbody\n";
2018        let active = merge_queue_state(legacy, true).unwrap();
2019        assert!(active.contains("queue: start"), "{active}");
2020        assert!(!active.contains("queue_active:"), "{active}");
2021
2022        let stopped = merge_queue_state(legacy, false).unwrap();
2023        assert!(stopped.contains("queue: stop"), "{stopped}");
2024        assert!(!stopped.contains("queue_active:"), "{stopped}");
2025
2026        // Round-trips back to the internal queue_active for readers.
2027        let (fm, _) = parse(&active).unwrap();
2028        assert_eq!(fm.queue_active, Some(true));
2029        let (fm, _) = parse(&stopped).unwrap();
2030        assert_eq!(fm.queue_active, Some(false));
2031    }
2032
2033    #[test]
2034    fn strip_deprecated_queue_active_line_drops_legacy_when_canonical_present() {
2035        // #queue-active-deprecated-line-stuck: a doc carrying BOTH `queue:` and the
2036        // deprecated `queue_active:` (the stuck shape the diff layer never lets a
2037        // normal write remove) has the legacy line dropped byte-precisely, leaving
2038        // every other field untouched.
2039        let stuck = "---\nagent: claude\nqueue: start\nqueue_active: true\n---\n\nbody\n";
2040        let out = strip_deprecated_queue_active_line(stuck);
2041        assert_eq!(
2042            out, "---\nagent: claude\nqueue: start\n---\n\nbody\n",
2043            "legacy line dropped, canonical queue + other fields preserved"
2044        );
2045        // Idempotent.
2046        assert_eq!(strip_deprecated_queue_active_line(&out), out);
2047    }
2048
2049    #[test]
2050    fn strip_deprecated_queue_active_line_keeps_legacy_without_canonical() {
2051        // Without a canonical `queue:` the legacy line is the only state — never
2052        // drop it (that would lose the queue state).
2053        let legacy_only = "---\nqueue_active: true\n---\n\nbody\n";
2054        assert_eq!(strip_deprecated_queue_active_line(legacy_only), legacy_only);
2055        // No frontmatter at all → unchanged.
2056        assert_eq!(
2057            strip_deprecated_queue_active_line("no frontmatter\nqueue_active: true\n"),
2058            "no frontmatter\nqueue_active: true\n"
2059        );
2060    }
2061
2062    #[test]
2063    fn parse_legacy_queue_active_still_honored() {
2064        // Back-compat: docs without the canonical key keep working.
2065        let (fm, _) = parse("---\nqueue_active: true\n---\n\n").unwrap();
2066        assert_eq!(fm.queue.as_deref(), None);
2067        assert_eq!(fm.queue_active, Some(true));
2068    }
2069
2070    #[test]
2071    fn parse_partial_fields() {
2072        let content = "---\nsession: xyz\n---\n# Doc\n";
2073        let (fm, body) = parse(content).unwrap();
2074        assert_eq!(fm.session.as_deref(), Some("xyz"));
2075        assert!(fm.agent.is_none());
2076        assert!(body.contains("# Doc"));
2077    }
2078
2079    #[test]
2080    fn parse_model_tier_high() {
2081        let content = "---\nagent_doc_model_tier: high\n---\nBody\n";
2082        let (fm, _) = parse(content).unwrap();
2083        assert_eq!(fm.model_tier, Some(Tier::High));
2084    }
2085
2086    #[test]
2087    fn parse_collaboration_mode_shared() {
2088        let content = "---\nagent_doc_collaboration: shared\n---\nBody\n";
2089        let (fm, _) = parse(content).unwrap();
2090        assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
2091    }
2092
2093    #[test]
2094    fn parse_security_review_roundtrip() {
2095        let content = "---\nagent_doc_collaboration: shared\nagent_doc_security_review: sec-2026-04-29\n---\nBody\n";
2096        let (fm, _) = parse(content).unwrap();
2097        assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
2098        assert_eq!(fm.security_review.as_deref(), Some("sec-2026-04-29"));
2099        assert!(fm.has_security_review());
2100        let written = write(&fm, "Body\n").unwrap();
2101        assert!(written.contains("agent_doc_collaboration: shared"));
2102        assert!(written.contains("agent_doc_security_review: sec-2026-04-29"));
2103    }
2104
2105    #[test]
2106    fn parse_model_tier_low() {
2107        let content = "---\nagent_doc_model_tier: low\n---\nBody\n";
2108        let (fm, _) = parse(content).unwrap();
2109        assert_eq!(fm.model_tier, Some(Tier::Low));
2110    }
2111
2112    #[test]
2113    fn parse_model_tier_med() {
2114        let content = "---\nagent_doc_model_tier: med\n---\nBody\n";
2115        let (fm, _) = parse(content).unwrap();
2116        assert_eq!(fm.model_tier, Some(Tier::Med));
2117    }
2118
2119    #[test]
2120    fn parse_model_tier_auto() {
2121        let content = "---\nagent_doc_model_tier: auto\n---\nBody\n";
2122        let (fm, _) = parse(content).unwrap();
2123        assert_eq!(fm.model_tier, Some(Tier::Auto));
2124    }
2125
2126    #[test]
2127    fn parse_model_tier_absent() {
2128        let content = "---\nagent: claude\n---\nBody\n";
2129        let (fm, _) = parse(content).unwrap();
2130        assert_eq!(fm.model_tier, None);
2131    }
2132
2133    #[test]
2134    fn parse_pending_capture_guard_strict() {
2135        let content = "---\npending_capture_guard: strict\n---\nBody\n";
2136        let (fm, _) = parse(content).unwrap();
2137        assert_eq!(
2138            fm.pending_capture_guard,
2139            Some(PendingCaptureGuardMode::Strict)
2140        );
2141    }
2142
2143    #[test]
2144    fn parse_pending_capture_guard_invalid_rejected() {
2145        let content = "---\npending_capture_guard: loudly\n---\nBody\n";
2146        assert!(parse(content).is_err());
2147    }
2148
2149    #[test]
2150    fn parse_pending_done_guard_strict() {
2151        let content = "---\npending_done_guard: strict\n---\nBody\n";
2152        let (fm, _) = parse(content).unwrap();
2153        assert_eq!(fm.pending_done_guard, Some(PendingCaptureGuardMode::Strict));
2154    }
2155
2156    #[test]
2157    fn parse_pending_done_guard_invalid_rejected() {
2158        let content = "---\npending_done_guard: loudly\n---\nBody\n";
2159        assert!(parse(content).is_err());
2160    }
2161
2162    #[test]
2163    fn parse_model_tier_invalid_rejected() {
2164        let content = "---\nagent_doc_model_tier: ultra\n---\nBody\n";
2165        let result = parse(content);
2166        assert!(result.is_err(), "invalid tier value should fail to parse");
2167    }
2168
2169    #[test]
2170    fn write_model_tier_roundtrip() {
2171        let fm = Frontmatter {
2172            model_tier: Some(Tier::High),
2173            ..Default::default()
2174        };
2175        let doc = write(&fm, "Body\n").unwrap();
2176        let (parsed, _) = parse(&doc).unwrap();
2177        assert_eq!(parsed.model_tier, Some(Tier::High));
2178        assert!(doc.contains("agent_doc_model_tier: high"));
2179    }
2180
2181    #[test]
2182    fn parse_null_fields() {
2183        let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
2184        let (fm, body) = parse(content).unwrap();
2185        assert!(fm.session.is_none());
2186        assert!(fm.agent.is_none());
2187        assert!(fm.model.is_none());
2188        assert!(fm.branch.is_none());
2189        assert!(body.contains("Body"));
2190    }
2191
2192    #[test]
2193    fn parse_unterminated_frontmatter() {
2194        let content = "---\nsession: abc\nno closing block";
2195        let err = parse(content).unwrap_err();
2196        assert!(err.to_string().contains("Unterminated frontmatter"));
2197    }
2198
2199    #[test]
2200    fn parse_closing_at_eof() {
2201        let content = "---\nsession: abc\n---";
2202        let (fm, body) = parse(content).unwrap();
2203        assert_eq!(fm.session.as_deref(), Some("abc"));
2204        assert_eq!(body, "");
2205    }
2206
2207    #[test]
2208    fn parse_empty_body() {
2209        let content = "---\nsession: abc\n---\n";
2210        let (fm, _body) = parse(content).unwrap();
2211        assert_eq!(fm.session.as_deref(), Some("abc"));
2212    }
2213
2214    #[test]
2215    fn write_roundtrip() {
2216        // Start from write output to ensure consistent formatting
2217        let fm = Frontmatter {
2218            session: Some("test-id".to_string()),
2219            resume: Some("resume-id".to_string()),
2220            agent: Some("claude".to_string()),
2221            model: Some("opus".to_string()),
2222            claude_model: None,
2223            codex_model: None,
2224            opencode_model: None,
2225            branch: Some("dev".to_string()),
2226            tmux_session: None,
2227            mode: None,
2228            format: None,
2229            write_mode: None,
2230            stream_config: None,
2231            agent_args: None,
2232            claude_args: None,
2233            codex_args: None,
2234            opencode_args: None,
2235            codex_network_access: None,
2236            required_ssh_targets: Vec::new(),
2237            required_ssh_profile: None,
2238            managed_proof_max_attempts: None,
2239            managed_proof_retry_backoff_secs: None,
2240            managed_proof_probe_timeout_secs: None,
2241            no_mcp: None,
2242            enable_tool_search: None,
2243            debounce_ms: None,
2244            links: vec![],
2245            auto_compact: None,
2246            queue_context_reset: None,
2247            gate_autoverify: None,
2248            clear_threshold: None,
2249            supervisor_auto_recycle: None,
2250            agent_change_restart: None,
2251            supervisor_auto_install: None,
2252            model_tier: None,
2253            pending_capture_guard: None,
2254            pending_done_guard: None,
2255            review_done_guard: None,
2256            auto_done: None,
2257            agent_doc_lint_dialect: None,
2258            hooks: std::collections::HashMap::new(),
2259            env: indexmap::IndexMap::new(),
2260            prompt_presets: indexmap::IndexMap::new(),
2261            dispatch: None,
2262            agent_doc_env_inherit: None,
2263            cwd: None,
2264            queue: None,
2265            queue_active: None,
2266            queue_prompt_echo_max_chars: None,
2267            collaboration: None,
2268            security_review: None,
2269            pipeline: AgentDocPipeline::default(),
2270        };
2271        let body = "# Hello\n\nBody text.\n";
2272        let written = write(&fm, body).unwrap();
2273        let (fm2, body2) = parse(&written).unwrap();
2274        assert_eq!(fm2.session, fm.session);
2275        assert_eq!(fm2.agent, fm.agent);
2276        assert_eq!(fm2.model, fm.model);
2277        assert_eq!(fm2.claude_model, fm.claude_model);
2278        assert_eq!(fm2.codex_model, fm.codex_model);
2279        assert_eq!(fm2.opencode_model, fm.opencode_model);
2280        assert_eq!(fm2.branch, fm.branch);
2281        assert_eq!(fm2.collaboration, fm.collaboration);
2282        assert_eq!(fm2.security_review, fm.security_review);
2283        assert_eq!(body2, body);
2284    }
2285
2286    #[test]
2287    fn repeated_parse_write_roundtrip_preserves_body_prefix() {
2288        let fm = Frontmatter {
2289            session: Some("abc".to_string()),
2290            agent: Some("codex".to_string()),
2291            ..Default::default()
2292        };
2293        let original = write(&fm, "## Status\n\nBody\n").unwrap();
2294        let mut current = original.clone();
2295
2296        for _ in 0..3 {
2297            let (fm, body) = parse(&current).unwrap();
2298            assert_eq!(body, "## Status\n\nBody\n");
2299            current = write(&fm, body).unwrap();
2300        }
2301
2302        let (fm, body) = parse(&current).unwrap();
2303        assert_eq!(fm.session.as_deref(), Some("abc"));
2304        assert_eq!(fm.agent.as_deref(), Some("codex"));
2305        assert_eq!(body, "## Status\n\nBody\n");
2306        assert_eq!(current, original);
2307    }
2308
2309    #[test]
2310    fn write_default_frontmatter() {
2311        let fm = Frontmatter::default();
2312        let result = write(&fm, "body\n").unwrap();
2313        assert!(result.starts_with("---\n"));
2314        assert!(result.ends_with("---\nbody\n"));
2315    }
2316
2317    #[test]
2318    fn write_preserves_body_content() {
2319        let fm = Frontmatter::default();
2320        let body = "# Title\n\nSome **markdown** with `code`.\n";
2321        let result = write(&fm, body).unwrap();
2322        assert!(result.contains("# Title"));
2323        assert!(result.contains("Some **markdown** with `code`."));
2324    }
2325
2326    #[test]
2327    fn set_session_id_creates_frontmatter() {
2328        let content = "# No frontmatter\n\nJust body.\n";
2329        let result = set_session_id(content, "new-session").unwrap();
2330        let (fm, body) = parse(&result).unwrap();
2331        assert_eq!(fm.session.as_deref(), Some("new-session"));
2332        assert!(body.contains("# No frontmatter"));
2333    }
2334
2335    #[test]
2336    fn set_session_id_updates_existing() {
2337        let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
2338        let result = set_session_id(content, "new-id").unwrap();
2339        let (fm, body) = parse(&result).unwrap();
2340        assert_eq!(fm.session.as_deref(), Some("new-id"));
2341        assert_eq!(fm.agent.as_deref(), Some("claude"));
2342        assert!(body.contains("Body"));
2343    }
2344
2345    #[test]
2346    fn set_session_id_preserves_other_fields() {
2347        let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
2348        let result = set_session_id(content, "new").unwrap();
2349        let (fm, _) = parse(&result).unwrap();
2350        assert_eq!(fm.session.as_deref(), Some("new"));
2351        assert_eq!(fm.agent.as_deref(), Some("claude"));
2352        assert_eq!(fm.model.as_deref(), Some("opus"));
2353        assert_eq!(fm.branch.as_deref(), Some("dev"));
2354    }
2355
2356    #[test]
2357    fn ensure_session_no_frontmatter() {
2358        let content = "# Hello\n\nBody.\n";
2359        let (updated, sid) = ensure_session(content).unwrap();
2360        // Should have generated a UUID
2361        assert_eq!(sid.len(), 36); // UUID v4 string length
2362        let (fm, body) = parse(&updated).unwrap();
2363        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
2364        assert!(body.contains("# Hello"));
2365    }
2366
2367    #[test]
2368    fn ensure_session_null_session() {
2369        let content = "---\nsession:\nagent: claude\n---\nBody\n";
2370        let (updated, sid) = ensure_session(content).unwrap();
2371        assert_eq!(sid.len(), 36);
2372        let (fm, body) = parse(&updated).unwrap();
2373        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
2374        assert_eq!(fm.agent.as_deref(), Some("claude"));
2375        assert!(body.contains("Body"));
2376    }
2377
2378    #[test]
2379    fn ensure_session_existing_session() {
2380        let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
2381        let (updated, sid) = ensure_session(content).unwrap();
2382        assert_eq!(sid, "existing-id");
2383        // Content should be unchanged
2384        assert_eq!(updated, content);
2385    }
2386
2387    #[test]
2388    fn parse_legacy_session_field() {
2389        // Old `session:` field should still parse via serde alias
2390        let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
2391        let (fm, body) = parse(content).unwrap();
2392        assert_eq!(fm.session.as_deref(), Some("legacy-id"));
2393        assert_eq!(fm.agent.as_deref(), Some("claude"));
2394        assert!(body.contains("Body"));
2395    }
2396
2397    #[test]
2398    fn parse_agent_doc_mode_canonical() {
2399        let content = "---\nagent_doc_mode: template\n---\nBody\n";
2400        let (fm, _) = parse(content).unwrap();
2401        assert_eq!(fm.mode.as_deref(), Some("template"));
2402    }
2403
2404    #[test]
2405    fn parse_mode_shorthand_alias() {
2406        let content = "---\nmode: template\n---\nBody\n";
2407        let (fm, _) = parse(content).unwrap();
2408        assert_eq!(fm.mode.as_deref(), Some("template"));
2409    }
2410
2411    #[test]
2412    fn parse_response_mode_legacy_alias() {
2413        let content = "---\nresponse_mode: template\n---\nBody\n";
2414        let (fm, _) = parse(content).unwrap();
2415        assert_eq!(fm.mode.as_deref(), Some("template"));
2416    }
2417
2418    #[test]
2419    fn write_uses_agent_doc_mode_field() {
2420        #[allow(deprecated)]
2421        let fm = Frontmatter {
2422            mode: Some("template".to_string()),
2423            ..Default::default()
2424        };
2425        let result = write(&fm, "body\n").unwrap();
2426        assert!(result.contains("agent_doc_mode:"));
2427        assert!(!result.contains("response_mode:"));
2428        assert!(!result.contains("\nmode:"));
2429    }
2430
2431    #[test]
2432    fn write_uses_new_field_name() {
2433        let fm = Frontmatter {
2434            session: Some("test-id".to_string()),
2435            ..Default::default()
2436        };
2437        let result = write(&fm, "body\n").unwrap();
2438        assert!(result.contains("agent_doc_session:"));
2439        assert!(!result.contains("\nsession:"));
2440    }
2441
2442    #[test]
2443    fn write_pending_capture_guard_roundtrip() {
2444        let fm = Frontmatter {
2445            pending_capture_guard: Some(PendingCaptureGuardMode::Strict),
2446            ..Default::default()
2447        };
2448        let written = write(&fm, "body\n").unwrap();
2449        assert!(written.contains("pending_capture_guard: strict"));
2450        let (parsed, _) = parse(&written).unwrap();
2451        assert_eq!(
2452            parsed.pending_capture_guard,
2453            Some(PendingCaptureGuardMode::Strict)
2454        );
2455    }
2456
2457    #[test]
2458    fn write_pending_done_guard_roundtrip() {
2459        let fm = Frontmatter {
2460            pending_done_guard: Some(PendingCaptureGuardMode::Strict),
2461            ..Default::default()
2462        };
2463        let written = write(&fm, "body\n").unwrap();
2464        assert!(written.contains("pending_done_guard: strict"));
2465        let (parsed, _) = parse(&written).unwrap();
2466        assert_eq!(
2467            parsed.pending_done_guard,
2468            Some(PendingCaptureGuardMode::Strict)
2469        );
2470    }
2471
2472    #[test]
2473    fn parse_review_done_guard_error_alias() {
2474        let content = "---\nreview_done_guard: error\n---\nBody\n";
2475        let (fm, _) = parse(content).unwrap();
2476        assert_eq!(fm.review_done_guard, Some(PendingCaptureGuardMode::Strict));
2477    }
2478
2479    #[test]
2480    fn write_review_done_guard_roundtrip() {
2481        let fm = Frontmatter {
2482            review_done_guard: Some(PendingCaptureGuardMode::Strict),
2483            ..Default::default()
2484        };
2485        let written = write(&fm, "body\n").unwrap();
2486        assert!(written.contains("review_done_guard: strict"));
2487        let (parsed, _) = parse(&written).unwrap();
2488        assert_eq!(
2489            parsed.review_done_guard,
2490            Some(PendingCaptureGuardMode::Strict)
2491        );
2492    }
2493
2494    #[test]
2495    fn parse_auto_done_bool() {
2496        let content = "---\nauto_done: true\n---\nBody\n";
2497        let (fm, _) = parse(content).unwrap();
2498        assert_eq!(fm.auto_done, Some(true));
2499    }
2500
2501    #[test]
2502    fn write_auto_done_roundtrip() {
2503        let fm = Frontmatter {
2504            auto_done: Some(true),
2505            ..Default::default()
2506        };
2507        let written = write(&fm, "Body\n").unwrap();
2508        assert!(written.contains("auto_done: true"));
2509        let (parsed, _) = parse(&written).unwrap();
2510        assert_eq!(parsed.auto_done, Some(true));
2511    }
2512
2513    #[test]
2514    fn parse_prompt_presets_roundtrip() {
2515        let content = "---\nprompt_presets:\n  \"#1\": |\n    Today is 2026-04-25.\n    Keep the work tree clean.\n  release-check: |\n    Run cargo test.\n---\nBody\n";
2516        let (fm, body) = parse(content).unwrap();
2517        assert_eq!(body, "Body\n");
2518        assert_eq!(
2519            fm.prompt_presets.get("#1").map(String::as_str),
2520            Some("Today is 2026-04-25.\nKeep the work tree clean.\n")
2521        );
2522        assert_eq!(
2523            fm.prompt_presets.get("release-check").map(String::as_str),
2524            Some("Run cargo test.")
2525        );
2526
2527        let written = write(&fm, body).unwrap();
2528        let (parsed, body2) = parse(&written).unwrap();
2529        assert_eq!(body2, "Body\n");
2530        assert_eq!(parsed.prompt_presets, fm.prompt_presets);
2531    }
2532
2533    #[test]
2534    fn resolve_prompt_preset_key_accepts_bare_hashtag_alias() {
2535        let mut prompt_presets = indexmap::IndexMap::new();
2536        prompt_presets.insert("#spec-test".to_string(), "Run checks.".to_string());
2537        prompt_presets.insert("release-check".to_string(), "Publish.".to_string());
2538
2539        assert_eq!(
2540            resolve_prompt_preset_key(&prompt_presets, "spec-test").as_deref(),
2541            Some("#spec-test")
2542        );
2543        assert_eq!(
2544            resolve_prompt_preset_key(&prompt_presets, "#spec-test").as_deref(),
2545            Some("#spec-test")
2546        );
2547        assert_eq!(
2548            resolve_prompt_preset_key(&prompt_presets, "release-check").as_deref(),
2549            Some("release-check")
2550        );
2551        assert!(resolve_prompt_preset_key(&prompt_presets, "missing").is_none());
2552    }
2553
2554    // --- resolve_mode tests ---
2555
2556    #[test]
2557    fn resolve_mode_defaults() {
2558        let fm = Frontmatter::default();
2559        let resolved = fm.resolve_mode();
2560        assert_eq!(resolved.format, AgentDocFormat::Template);
2561        assert_eq!(resolved.write, AgentDocWrite::Crdt);
2562    }
2563
2564    #[test]
2565    fn resolve_mode_from_deprecated_append() {
2566        let content = "---\nagent_doc_mode: append\n---\nBody\n";
2567        let (fm, _) = parse(content).unwrap();
2568        let resolved = fm.resolve_mode();
2569        assert_eq!(resolved.format, AgentDocFormat::Append);
2570        assert_eq!(resolved.write, AgentDocWrite::Crdt);
2571    }
2572
2573    #[test]
2574    fn resolve_mode_from_deprecated_template() {
2575        let content = "---\nagent_doc_mode: template\n---\nBody\n";
2576        let (fm, _) = parse(content).unwrap();
2577        let resolved = fm.resolve_mode();
2578        assert_eq!(resolved.format, AgentDocFormat::Template);
2579        assert_eq!(resolved.write, AgentDocWrite::Crdt);
2580    }
2581
2582    #[test]
2583    fn resolve_mode_from_deprecated_stream() {
2584        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
2585        let (fm, _) = parse(content).unwrap();
2586        let resolved = fm.resolve_mode();
2587        assert_eq!(resolved.format, AgentDocFormat::Template);
2588        assert_eq!(resolved.write, AgentDocWrite::Crdt);
2589    }
2590
2591    #[test]
2592    fn resolve_mode_new_fields_override_deprecated() {
2593        let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
2594        let (fm, _) = parse(content).unwrap();
2595        let resolved = fm.resolve_mode();
2596        assert_eq!(resolved.format, AgentDocFormat::Template);
2597        assert_eq!(resolved.write, AgentDocWrite::Merge);
2598    }
2599
2600    #[test]
2601    fn resolve_mode_explicit_new_fields_only() {
2602        let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
2603        let (fm, _) = parse(content).unwrap();
2604        let resolved = fm.resolve_mode();
2605        assert_eq!(resolved.format, AgentDocFormat::Append);
2606        assert_eq!(resolved.write, AgentDocWrite::Crdt);
2607    }
2608
2609    #[test]
2610    fn resolve_mode_partial_new_field_format_only() {
2611        let content = "---\nagent_doc_format: append\n---\nBody\n";
2612        let (fm, _) = parse(content).unwrap();
2613        let resolved = fm.resolve_mode();
2614        assert_eq!(resolved.format, AgentDocFormat::Append);
2615        assert_eq!(resolved.write, AgentDocWrite::Crdt); // default
2616    }
2617
2618    #[test]
2619    fn resolve_mode_partial_new_field_write_only() {
2620        let content = "---\nagent_doc_write: merge\n---\nBody\n";
2621        let (fm, _) = parse(content).unwrap();
2622        let resolved = fm.resolve_mode();
2623        assert_eq!(resolved.format, AgentDocFormat::Template); // default
2624        assert_eq!(resolved.write, AgentDocWrite::Merge);
2625    }
2626
2627    #[test]
2628    fn resolve_mode_helper_methods() {
2629        let fm = Frontmatter::default();
2630        let resolved = fm.resolve_mode();
2631        assert!(resolved.is_template());
2632        assert!(!resolved.is_append());
2633        assert!(resolved.is_crdt());
2634    }
2635
2636    #[test]
2637    fn parse_new_format_field() {
2638        let content = "---\nagent_doc_format: template\n---\nBody\n";
2639        let (fm, _) = parse(content).unwrap();
2640        assert_eq!(fm.format, Some(AgentDocFormat::Template));
2641    }
2642
2643    #[test]
2644    fn parse_new_write_field() {
2645        let content = "---\nagent_doc_write: crdt\n---\nBody\n";
2646        let (fm, _) = parse(content).unwrap();
2647        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2648    }
2649
2650    #[test]
2651    fn write_uses_new_format_write_fields() {
2652        let fm = Frontmatter {
2653            format: Some(AgentDocFormat::Template),
2654            write_mode: Some(AgentDocWrite::Crdt),
2655            ..Default::default()
2656        };
2657        let result = write(&fm, "body\n").unwrap();
2658        assert!(result.contains("agent_doc_format:"));
2659        assert!(result.contains("agent_doc_write:"));
2660        assert!(!result.contains("agent_doc_mode:"));
2661    }
2662
2663    #[test]
2664    fn set_format_and_write_clears_deprecated_mode() {
2665        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
2666        let result =
2667            set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
2668        let (fm, _) = parse(&result).unwrap();
2669        assert!(fm.mode.is_none());
2670        assert_eq!(fm.format, Some(AgentDocFormat::Template));
2671        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2672    }
2673
2674    // --- merge_fields tests ---
2675
2676    #[test]
2677    fn merge_fields_adds_new_field() {
2678        let content = "---\nagent_doc_session: abc\n---\nBody\n";
2679        let result = merge_fields(content, "model: opus").unwrap();
2680        let (fm, body) = parse(&result).unwrap();
2681        assert_eq!(fm.session.as_deref(), Some("abc"));
2682        assert_eq!(fm.model.as_deref(), Some("opus"));
2683        assert!(body.contains("Body"));
2684    }
2685
2686    #[test]
2687    fn merge_fields_updates_existing_field() {
2688        let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
2689        let result = merge_fields(content, "model: opus").unwrap();
2690        let (fm, _) = parse(&result).unwrap();
2691        assert_eq!(fm.model.as_deref(), Some("opus"));
2692        assert_eq!(fm.session.as_deref(), Some("abc"));
2693    }
2694
2695    #[test]
2696    fn merge_fields_multiple_fields() {
2697        let content = "---\nagent_doc_session: abc\n---\nBody\n";
2698        let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
2699        let (fm, _) = parse(&result).unwrap();
2700        assert_eq!(fm.model.as_deref(), Some("opus"));
2701        assert_eq!(fm.agent.as_deref(), Some("claude"));
2702        assert_eq!(fm.branch.as_deref(), Some("main"));
2703    }
2704
2705    #[test]
2706    fn merge_fields_format_enum() {
2707        let content = "---\nagent_doc_session: abc\n---\nBody\n";
2708        let result = merge_fields(content, "agent_doc_format: append").unwrap();
2709        let (fm, _) = parse(&result).unwrap();
2710        assert_eq!(fm.format, Some(AgentDocFormat::Append));
2711    }
2712
2713    #[test]
2714    fn merge_fields_write_enum() {
2715        let content = "---\nagent_doc_session: abc\n---\nBody\n";
2716        let result = merge_fields(content, "agent_doc_write: merge").unwrap();
2717        let (fm, _) = parse(&result).unwrap();
2718        assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
2719    }
2720
2721    #[test]
2722    fn merge_fields_ignores_unknown() {
2723        let content = "---\nagent_doc_session: abc\n---\nBody\n";
2724        let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
2725        let (fm, _) = parse(&result).unwrap();
2726        assert_eq!(fm.model.as_deref(), Some("opus"));
2727    }
2728
2729    #[test]
2730    fn merge_fields_preserves_body() {
2731        let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
2732        let result = merge_fields(content, "model: opus").unwrap();
2733        assert!(result.contains("# Title"));
2734        assert!(result.contains("Some **markdown** content."));
2735    }
2736
2737    #[test]
2738    fn set_format_and_write_clears_deprecated() {
2739        let content = "---\nagent_doc_mode: append\n---\nBody\n";
2740        let result =
2741            set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
2742        let (fm, _) = parse(&result).unwrap();
2743        assert!(fm.mode.is_none());
2744        assert_eq!(fm.format, Some(AgentDocFormat::Template));
2745        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2746    }
2747
2748    #[test]
2749    fn hooks_roundtrip() {
2750        let content = "---\nhooks:\n  session_start:\n    - \"echo start {{session_id}}\"\n  post_write:\n    - \"notify {{file}}\"\n---\nBody\n";
2751        let (fm, _) = parse(content).unwrap();
2752        assert_eq!(
2753            fm.hooks.get("session_start"),
2754            Some(&vec!["echo start {{session_id}}".to_string()])
2755        );
2756        assert_eq!(
2757            fm.hooks.get("post_write"),
2758            Some(&vec!["notify {{file}}".to_string()])
2759        );
2760    }
2761
2762    #[test]
2763    fn hooks_omitted_when_empty() {
2764        let fm = Frontmatter::default();
2765        let result = write(&fm, "body\n").unwrap();
2766        assert!(!result.contains("hooks"));
2767    }
2768
2769    #[test]
2770    fn hooks_absent_parses_as_empty() {
2771        let content = "---\nsession: abc\n---\nBody\n";
2772        let (fm, _) = parse(content).unwrap();
2773        assert!(fm.hooks.is_empty());
2774    }
2775
2776    #[test]
2777    fn parse_no_mcp_field() {
2778        let content = "---\nno_mcp: true\n---\nBody\n";
2779        let (fm, _) = parse(content).unwrap();
2780        assert_eq!(fm.no_mcp, Some(true));
2781    }
2782
2783    #[test]
2784    fn parse_enable_tool_search_field() {
2785        let content = "---\nenable_tool_search: true\n---\nBody\n";
2786        let (fm, _) = parse(content).unwrap();
2787        assert_eq!(fm.enable_tool_search, Some(true));
2788    }
2789
2790    #[test]
2791    fn parse_missing_flags_default_none() {
2792        let content = "---\nsession: abc\n---\nBody\n";
2793        let (fm, _) = parse(content).unwrap();
2794        assert!(fm.no_mcp.is_none());
2795        assert!(fm.enable_tool_search.is_none());
2796    }
2797
2798    #[test]
2799    fn parse_env_map() {
2800        let content = "---\nenv:\n  FOO: bar\n  BAZ: \"$(echo hello)\"\n---\nBody\n";
2801        let (fm, _) = parse(content).unwrap();
2802        assert_eq!(fm.env.len(), 2);
2803        assert_eq!(fm.env["FOO"], Some("bar".to_string()));
2804        assert_eq!(fm.env["BAZ"], Some("$(echo hello)".to_string()));
2805        // Verify order is preserved
2806        let keys: Vec<&String> = fm.env.keys().collect();
2807        assert_eq!(keys, vec!["FOO", "BAZ"]);
2808    }
2809
2810    #[test]
2811    fn parse_env_empty_default() {
2812        let content = "---\nsession: abc\n---\nBody\n";
2813        let (fm, _) = parse(content).unwrap();
2814        assert!(fm.env.is_empty());
2815    }
2816
2817    #[test]
2818    fn parse_env_unset_via_null() {
2819        let content = "---\nenv:\n  SET_ME: value\n  UNSET_ME: null\n---\nBody\n";
2820        let (fm, _) = parse(content).unwrap();
2821        assert_eq!(fm.env.len(), 2);
2822        assert_eq!(fm.env["SET_ME"], Some("value".to_string()));
2823        assert_eq!(fm.env["UNSET_ME"], None);
2824    }
2825
2826    #[test]
2827    fn write_roundtrip_with_env() {
2828        let mut env: indexmap::IndexMap<String, Option<String>> = indexmap::IndexMap::new();
2829        env.insert("KEY1".to_string(), Some("value1".to_string()));
2830        env.insert("KEY2".to_string(), Some("$KEY1".to_string()));
2831        env.insert("KEY3".to_string(), None);
2832        let fm = Frontmatter {
2833            env,
2834            ..Default::default()
2835        };
2836        let written = write(&fm, "body\n").unwrap();
2837        let (fm2, _) = parse(&written).unwrap();
2838        assert_eq!(fm2.env.len(), 3);
2839        assert_eq!(fm2.env["KEY1"], Some("value1".to_string()));
2840        assert_eq!(fm2.env["KEY2"], Some("$KEY1".to_string()));
2841        assert_eq!(fm2.env["KEY3"], None);
2842    }
2843
2844    #[test]
2845    fn parse_agent_args_field() {
2846        let content = "---\nagent_args: \"--json -s workspace-write\"\n---\nBody\n";
2847        let (fm, _) = parse(content).unwrap();
2848        assert_eq!(fm.agent_args.as_deref(), Some("--json -s workspace-write"));
2849        assert!(fm.claude_args.is_none());
2850        assert!(fm.codex_args.is_none());
2851        assert!(fm.opencode_args.is_none());
2852    }
2853
2854    #[test]
2855    fn parse_agent_args_and_harness_aliases() {
2856        let content = "---\nagent_args: \"--json\"\nclaude_args: \"--dangerously-skip-permissions\"\ncodex_args: \"-s danger-full-access\"\nopencode_args: \"--dangerously-skip-permissions\"\ncodex_network_access: enabled\nrequired_ssh_targets:\n  - monsterrodholders-server\n---\nBody\n";
2857        let (fm, _) = parse(content).unwrap();
2858        assert_eq!(fm.agent_args.as_deref(), Some("--json"));
2859        assert_eq!(
2860            fm.claude_args.as_deref(),
2861            Some("--dangerously-skip-permissions")
2862        );
2863        assert_eq!(fm.codex_args.as_deref(), Some("-s danger-full-access"));
2864        assert_eq!(
2865            fm.opencode_args.as_deref(),
2866            Some("--dangerously-skip-permissions")
2867        );
2868        assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Enabled));
2869        assert_eq!(
2870            fm.required_ssh_targets,
2871            vec!["monsterrodholders-server".to_string()]
2872        );
2873    }
2874
2875    #[test]
2876    fn write_roundtrip_agent_args() {
2877        let fm = Frontmatter {
2878            agent_args: Some("--json -s workspace-write".to_string()),
2879            codex_args: Some("-s danger-full-access".to_string()),
2880            opencode_args: Some("--dangerously-skip-permissions".to_string()),
2881            codex_network_access: Some(CodexNetworkAccess::Enabled),
2882            required_ssh_targets: vec!["monsterrodholders-server".to_string()],
2883            ..Default::default()
2884        };
2885        let written = write(&fm, "body\n").unwrap();
2886        let (fm2, _) = parse(&written).unwrap();
2887        assert_eq!(fm2.agent_args.as_deref(), Some("--json -s workspace-write"));
2888        assert_eq!(fm2.codex_args.as_deref(), Some("-s danger-full-access"));
2889        assert_eq!(
2890            fm2.opencode_args.as_deref(),
2891            Some("--dangerously-skip-permissions")
2892        );
2893        assert_eq!(fm2.codex_network_access, Some(CodexNetworkAccess::Enabled));
2894        assert_eq!(
2895            fm2.required_ssh_targets,
2896            vec!["monsterrodholders-server".to_string()]
2897        );
2898    }
2899
2900    #[test]
2901    fn merge_fields_agent_args() {
2902        let content = "---\nclaude_args: old\ncodex_args: old-codex\nopencode_args: old-opencode\n---\nBody\n";
2903        let result = merge_fields(content, "agent_args: new").unwrap();
2904        let (fm, _) = parse(&result).unwrap();
2905        assert_eq!(fm.agent_args.as_deref(), Some("new"));
2906        assert_eq!(fm.claude_args.as_deref(), Some("old"));
2907        assert_eq!(fm.codex_args.as_deref(), Some("old-codex"));
2908        assert_eq!(fm.opencode_args.as_deref(), Some("old-opencode"));
2909    }
2910
2911    #[test]
2912    fn merge_fields_codex_args() {
2913        let content = "---\nagent_args: old\n---\nBody\n";
2914        let result = merge_fields(content, "codex_args: new").unwrap();
2915        let (fm, _) = parse(&result).unwrap();
2916        assert_eq!(fm.agent_args.as_deref(), Some("old"));
2917        assert_eq!(fm.codex_args.as_deref(), Some("new"));
2918    }
2919
2920    #[test]
2921    fn merge_fields_opencode_args() {
2922        let content = "---\nagent_args: old\n---\nBody\n";
2923        let result = merge_fields(content, "opencode_args: new").unwrap();
2924        let (fm, _) = parse(&result).unwrap();
2925        assert_eq!(fm.agent_args.as_deref(), Some("old"));
2926        assert_eq!(fm.opencode_args.as_deref(), Some("new"));
2927    }
2928
2929    #[test]
2930    fn merge_fields_codex_network_access() {
2931        let content = "Body\n";
2932        let result = merge_fields(content, "codex_network_access: disabled").unwrap();
2933        let (fm, _) = parse(&result).unwrap();
2934        assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Disabled));
2935    }
2936
2937    #[test]
2938    fn merge_fields_required_ssh_targets() {
2939        let content = "Body\n";
2940        let result = merge_fields(
2941            content,
2942            "required_ssh_targets:\n  - monsterrodholders-server\n  - root@50.28.2.199",
2943        )
2944        .unwrap();
2945        let (fm, _) = parse(&result).unwrap();
2946        assert_eq!(
2947            fm.required_ssh_targets,
2948            vec![
2949                "monsterrodholders-server".to_string(),
2950                "root@50.28.2.199".to_string(),
2951            ]
2952        );
2953    }
2954
2955    #[test]
2956    fn merge_fields_required_ssh_profile() {
2957        let content = "Body\n";
2958        let result = merge_fields(content, "required_ssh_profile: monsterrodholders").unwrap();
2959        let (fm, _) = parse(&result).unwrap();
2960        assert_eq!(
2961            fm.required_ssh_profile.as_deref(),
2962            Some("monsterrodholders")
2963        );
2964    }
2965
2966    // --- resolve_harness_model tests ---
2967
2968    #[test]
2969    fn resolve_harness_model_claude_code_uses_claude_model() {
2970        let fm = Frontmatter {
2971            model: Some("gpt-5".to_string()),
2972            claude_model: Some("opus".to_string()),
2973            codex_model: Some("o3-pro".to_string()),
2974            ..Default::default()
2975        };
2976        assert_eq!(fm.resolve_harness_model("claude-code"), Some("opus"));
2977    }
2978
2979    #[test]
2980    fn resolve_harness_model_codex_uses_codex_model() {
2981        let fm = Frontmatter {
2982            model: Some("gpt-5".to_string()),
2983            claude_model: Some("opus".to_string()),
2984            codex_model: Some("o3-pro".to_string()),
2985            opencode_model: Some("zai/glm-5".to_string()),
2986            ..Default::default()
2987        };
2988        assert_eq!(fm.resolve_harness_model("codex"), Some("o3-pro"));
2989    }
2990
2991    #[test]
2992    fn resolve_harness_model_opencode_uses_opencode_model() {
2993        let fm = Frontmatter {
2994            model: Some("gpt-5".to_string()),
2995            claude_model: Some("opus".to_string()),
2996            codex_model: Some("o3-pro".to_string()),
2997            opencode_model: Some("zai/glm-5".to_string()),
2998            ..Default::default()
2999        };
3000        assert_eq!(fm.resolve_harness_model("opencode"), Some("zai/glm-5"));
3001    }
3002
3003    #[test]
3004    fn resolve_harness_model_falls_back_to_generic() {
3005        let fm = Frontmatter {
3006            model: Some("gpt-5".to_string()),
3007            ..Default::default()
3008        };
3009        assert_eq!(fm.resolve_harness_model("claude-code"), Some("gpt-5"));
3010        assert_eq!(fm.resolve_harness_model("codex"), Some("gpt-5"));
3011        assert_eq!(fm.resolve_harness_model("opencode"), Some("gpt-5"));
3012        assert_eq!(fm.resolve_harness_model("default"), Some("gpt-5"));
3013    }
3014
3015    #[test]
3016    fn resolve_harness_model_none_when_no_model() {
3017        let fm = Frontmatter::default();
3018        assert_eq!(fm.resolve_harness_model("claude-code"), None);
3019    }
3020
3021    #[test]
3022    fn resolve_harness_model_unknown_harness_uses_generic() {
3023        let fm = Frontmatter {
3024            model: Some("sonnet".to_string()),
3025            claude_model: Some("opus".to_string()),
3026            ..Default::default()
3027        };
3028        assert_eq!(fm.resolve_harness_model("unknown"), Some("sonnet"));
3029    }
3030
3031    #[test]
3032    fn parse_harness_model_fields() {
3033        let content = "---\nmodel: gpt-5\nclaude_model: opus\ncodex_model: o3-pro\nopencode_model: zai/glm-5\n---\nBody\n";
3034        let (fm, _) = parse(content).unwrap();
3035        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
3036        assert_eq!(fm.claude_model.as_deref(), Some("opus"));
3037        assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
3038        assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
3039    }
3040
3041    #[test]
3042    fn write_roundtrip_harness_model_fields() {
3043        let fm = Frontmatter {
3044            model: Some("gpt-5".to_string()),
3045            claude_model: Some("opus".to_string()),
3046            codex_model: Some("o3-pro".to_string()),
3047            opencode_model: Some("zai/glm-5".to_string()),
3048            ..Default::default()
3049        };
3050        let doc = write(&fm, "Body\n").unwrap();
3051        let (fm2, body) = parse(&doc).unwrap();
3052        assert_eq!(fm2.model.as_deref(), Some("gpt-5"));
3053        assert_eq!(fm2.claude_model.as_deref(), Some("opus"));
3054        assert_eq!(fm2.codex_model.as_deref(), Some("o3-pro"));
3055        assert_eq!(fm2.opencode_model.as_deref(), Some("zai/glm-5"));
3056        assert_eq!(body, "Body\n");
3057    }
3058
3059    #[test]
3060    fn merge_fields_claude_model() {
3061        let content = "---\nmodel: gpt-5\n---\nBody\n";
3062        let result = merge_fields(content, "claude_model: opus").unwrap();
3063        let (fm, _) = parse(&result).unwrap();
3064        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
3065        assert_eq!(fm.claude_model.as_deref(), Some("opus"));
3066    }
3067
3068    #[test]
3069    fn merge_fields_codex_model() {
3070        let content = "---\nmodel: gpt-5\n---\nBody\n";
3071        let result = merge_fields(content, "codex_model: o3-pro").unwrap();
3072        let (fm, _) = parse(&result).unwrap();
3073        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
3074        assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
3075    }
3076
3077    #[test]
3078    fn merge_fields_opencode_model() {
3079        let content = "---\nmodel: gpt-5\n---\nBody\n";
3080        let result = merge_fields(content, "opencode_model: zai/glm-5").unwrap();
3081        let (fm, _) = parse(&result).unwrap();
3082        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
3083        assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
3084    }
3085}