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/// Apply required-SSH defaults to a frontmatter using a pre-resolved context.
1373///
1374/// Pure: no `&Path`, no `std::fs`. The caller is responsible for resolving
1375/// the project config and computing the document's project-relative path
1376/// before invoking.
1377pub fn apply_required_ssh_defaults(
1378    fm: &mut Frontmatter,
1379    ctx: &SshResolverContext<'_>,
1380) -> Result<()> {
1381    if !fm.required_ssh_targets.is_empty() {
1382        fm.required_ssh_targets = normalize_required_ssh_targets(&fm.required_ssh_targets);
1383        return Ok(());
1384    }
1385
1386    if ctx.doc_relative.is_empty() {
1387        return Ok(());
1388    }
1389
1390    let resolved = if let Some(profile_name) = fm.required_ssh_profile.as_deref() {
1391        resolve_required_ssh_profile_targets(&ctx.project.ssh, profile_name, ctx.file_display)?
1392    } else if let Some(doc_cfg) = ctx.project.ssh.docs.get(ctx.doc_relative) {
1393        let mut targets = doc_cfg.targets.clone();
1394        if let Some(profile_name) = doc_cfg.profile.as_deref() {
1395            targets.extend(resolve_required_ssh_profile_targets(
1396                &ctx.project.ssh,
1397                profile_name,
1398                ctx.file_display,
1399            )?);
1400        }
1401        if targets.is_empty() {
1402            anyhow::bail!(
1403                "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>].",
1404                ctx.file_display,
1405                ctx.doc_relative,
1406                ctx.doc_relative
1407            );
1408        }
1409        targets
1410    } else {
1411        Vec::new()
1412    };
1413
1414    fm.required_ssh_targets = normalize_required_ssh_targets(&resolved);
1415    Ok(())
1416}
1417
1418fn resolve_required_ssh_profile_targets(
1419    ssh: &crate::project_config::SshConfig,
1420    profile_name: &str,
1421    file_display: &str,
1422) -> Result<Vec<String>> {
1423    let profile = ssh.profiles.get(profile_name).ok_or_else(|| {
1424        anyhow::anyhow!(
1425            "document {} requires SSH profile `{}` but .agent-doc/config.toml has no matching [ssh.profiles.{}] entry.",
1426            file_display,
1427            profile_name,
1428            profile_name
1429        )
1430    })?;
1431    if profile.targets.is_empty() {
1432        anyhow::bail!(
1433            "document {} requires SSH profile `{}` but [ssh.profiles.{}] has no targets.",
1434            file_display,
1435            profile_name,
1436            profile_name
1437        );
1438    }
1439    Ok(profile.targets.clone())
1440}
1441
1442fn normalize_required_ssh_targets(targets: &[String]) -> Vec<String> {
1443    let mut normalized = Vec::new();
1444    for target in targets {
1445        let trimmed = target.trim();
1446        if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) {
1447            continue;
1448        }
1449        normalized.push(trimmed.to_string());
1450    }
1451    normalized
1452}
1453
1454fn contextualize_yaml_parse_error(
1455    file_display: &str,
1456    yaml: &str,
1457    err: serde_yaml::Error,
1458) -> anyhow::Error {
1459    let excerpt = err
1460        .location()
1461        .and_then(|location| render_frontmatter_excerpt(yaml, location.line(), location.column()));
1462    let mut message = format!("invalid YAML frontmatter in {}: {}", file_display, err);
1463    if let Some(excerpt) = excerpt {
1464        message.push_str("\n\nFrontmatter excerpt:\n");
1465        message.push_str(&excerpt);
1466    }
1467    message.push_str(
1468        "\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
1469    );
1470    anyhow::anyhow!(message)
1471}
1472
1473fn render_frontmatter_excerpt(yaml: &str, line: usize, column: usize) -> Option<String> {
1474    let lines: Vec<&str> = yaml.lines().collect();
1475    if line == 0 || line > lines.len() {
1476        return None;
1477    }
1478
1479    let start = line.saturating_sub(1).max(1);
1480    let end = (line + 1).min(lines.len());
1481    let width = end.to_string().len();
1482    let mut rendered = Vec::new();
1483
1484    for current in start..=end {
1485        let marker = if current == line { '>' } else { ' ' };
1486        rendered.push(format!(
1487            "{} {:>width$} | {}",
1488            marker,
1489            current,
1490            lines[current - 1],
1491            width = width
1492        ));
1493        if current == line {
1494            rendered.push(format!(
1495                "  {:>width$} | {}^",
1496                "",
1497                " ".repeat(column.saturating_sub(1)),
1498                width = width
1499            ));
1500        }
1501    }
1502
1503    Some(rendered.join("\n"))
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508    use super::*;
1509
1510    #[test]
1511    fn parse_no_frontmatter() {
1512        let content = "# Hello\n\nBody text.\n";
1513        let (fm, body) = parse(content).unwrap();
1514        assert!(fm.session.is_none());
1515        assert!(fm.agent.is_none());
1516        assert!(fm.model.is_none());
1517        assert!(fm.branch.is_none());
1518        assert_eq!(body, content);
1519    }
1520
1521    #[test]
1522    fn parse_tolerates_leading_horizontal_space_before_frontmatter() {
1523        let content = " ---\nagent_doc_session: abc-123\n---\nBody\n";
1524        let (fm, body) = parse(content).unwrap();
1525        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1526        assert_eq!(body, "Body\n");
1527    }
1528
1529    #[test]
1530    fn parse_tolerates_bom_before_frontmatter() {
1531        let content = "\u{feff}---\nagent_doc_session: abc-123\n---\nBody\n";
1532        let (fm, body) = parse(content).unwrap();
1533        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1534        assert_eq!(body, "Body\n");
1535    }
1536
1537    #[test]
1538    fn parse_keeps_leading_blank_line_as_body_not_frontmatter() {
1539        let content = "\n---\nagent_doc_session: abc-123\n---\nBody\n";
1540        let (fm, body) = parse(content).unwrap();
1541        assert!(fm.session.is_none());
1542        assert_eq!(body, content);
1543    }
1544
1545    #[test]
1546    fn parse_all_fields() {
1547        let content =
1548            "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
1549        let (fm, body) = parse(content).unwrap();
1550        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1551        assert_eq!(fm.agent.as_deref(), Some("claude"));
1552        assert_eq!(fm.model.as_deref(), Some("opus"));
1553        assert_eq!(fm.branch.as_deref(), Some("main"));
1554        assert!(body.contains("Body"));
1555    }
1556
1557    #[test]
1558    fn queue_control_parse_aliases() {
1559        assert_eq!(QueueControl::parse("start"), Some(QueueControl::Start));
1560        assert_eq!(QueueControl::parse("go"), Some(QueueControl::Start));
1561        assert_eq!(QueueControl::parse("STOP"), Some(QueueControl::Stop));
1562        assert_eq!(QueueControl::parse("  Start  "), Some(QueueControl::Start));
1563        assert_eq!(QueueControl::parse("auto"), None);
1564        assert_eq!(QueueControl::parse(""), None);
1565        assert!(QueueControl::Start.is_active());
1566        assert!(!QueueControl::Stop.is_active());
1567    }
1568
1569    #[test]
1570    fn parse_queue_start_activates() {
1571        // `queue: start` (canonical) folds onto the deprecated queue_active bool
1572        // so every existing reader honors it (#queue-state-unify).
1573        let (fm, _) = parse("---\nagent_doc_format: template\nqueue: start\n---\n\n").unwrap();
1574        assert_eq!(fm.queue.as_deref(), Some("start"));
1575        assert_eq!(fm.queue_active, Some(true));
1576    }
1577
1578    #[test]
1579    fn parse_queue_go_alias_activates() {
1580        let (fm, _) = parse("---\nqueue: go\n---\n\n").unwrap();
1581        assert_eq!(fm.queue_active, Some(true));
1582    }
1583
1584    #[test]
1585    fn parse_queue_stop_deactivates() {
1586        let (fm, _) = parse("---\nqueue: stop\n---\n\n").unwrap();
1587        assert_eq!(fm.queue_active, Some(false));
1588    }
1589
1590    #[test]
1591    fn parse_queue_canonical_wins_over_stale_queue_active() {
1592        // An explicit `queue:` control overrides a stale `queue_active:` line.
1593        let (fm, _) = parse("---\nqueue: stop\nqueue_active: true\n---\n\n").unwrap();
1594        assert_eq!(fm.queue_active, Some(false));
1595
1596        let (fm, _) = parse("---\nqueue: start\nqueue_active: false\n---\n\n").unwrap();
1597        assert_eq!(fm.queue_active, Some(true));
1598    }
1599
1600    #[test]
1601    fn pipeline_is_empty_default() {
1602        let p = AgentDocPipeline::default();
1603        assert!(p.is_empty());
1604    }
1605
1606    #[test]
1607    fn parse_pipeline_all_fields() {
1608        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";
1609        let (fm, _) = parse(content).unwrap();
1610        assert_eq!(fm.pipeline.turn_id.as_deref(), Some("#fm-run-id-step"));
1611        assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-1780600334196"));
1612        assert_eq!(fm.pipeline.step.as_deref(), Some("response_captured"));
1613        assert_eq!(
1614            fm.pipeline.queue_task_id.as_deref(),
1615            Some("#fm-run-id-step")
1616        );
1617        assert!(!fm.pipeline.is_empty());
1618    }
1619
1620    #[test]
1621    fn empty_pipeline_is_not_serialized() {
1622        // A drained/terminal cycle must leave no `agent_doc_pipeline:` block behind.
1623        let fm = Frontmatter {
1624            session: Some("abc".to_string()),
1625            ..Default::default()
1626        };
1627        let out = write(&fm, "Body\n").unwrap();
1628        assert!(!out.contains("agent_doc_pipeline"));
1629    }
1630
1631    #[test]
1632    fn set_pipeline_state_merges_without_disturbing_other_fields() {
1633        let content =
1634            "---\nagent_doc_session: abc-123\nagent: claude\nqueue: start\n---\n\n## Body\n";
1635        // Set run_id + step, leave turn_id/queue_task_id untouched.
1636        let updated =
1637            set_pipeline_state(content, Some("cycle-99"), Some("write_applied"), None, None)
1638                .unwrap();
1639        let (fm, body) = parse(&updated).unwrap();
1640        assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-99"));
1641        assert_eq!(fm.pipeline.step.as_deref(), Some("write_applied"));
1642        assert!(fm.pipeline.turn_id.is_none());
1643        // Other frontmatter fields and the body survive untouched.
1644        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1645        assert_eq!(fm.agent.as_deref(), Some("claude"));
1646        assert_eq!(fm.queue_active, Some(true));
1647        assert!(body.contains("## Body"));
1648
1649        // A second merge updates step + turn_id while preserving the prior run_id.
1650        let updated2 = set_pipeline_state(
1651            &updated,
1652            None,
1653            Some("committed"),
1654            Some("#fm-run-id-step"),
1655            None,
1656        )
1657        .unwrap();
1658        let (fm2, _) = parse(&updated2).unwrap();
1659        assert_eq!(fm2.pipeline.run_id.as_deref(), Some("cycle-99"));
1660        assert_eq!(fm2.pipeline.step.as_deref(), Some("committed"));
1661        assert_eq!(fm2.pipeline.turn_id.as_deref(), Some("#fm-run-id-step"));
1662    }
1663
1664    #[test]
1665    fn splice_pipeline_block_is_byte_precise_and_strip_cancels() {
1666        // #22a8: the write-side splice must touch ONLY the pipeline block, and
1667        // the diff-side strip must cancel it back to the original bytes.
1668        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";
1669        let pipeline = AgentDocPipeline {
1670            run_id: Some("cycle-99".into()),
1671            step: Some("response_captured".into()),
1672            turn_id: Some("#22a8".into()),
1673            queue_task_id: None,
1674        };
1675        let spliced = splice_pipeline_block(base, &pipeline).unwrap();
1676        // The block is present and parseable…
1677        assert!(spliced.contains("agent_doc_pipeline:"));
1678        let (fm, _) = parse(&spliced).unwrap();
1679        assert_eq!(fm.pipeline.run_id.as_deref(), Some("cycle-99"));
1680        assert_eq!(fm.pipeline.turn_id.as_deref(), Some("#22a8"));
1681        // …every non-block field/byte is preserved (presets, order, body).
1682        assert_eq!(fm.agent.as_deref(), Some("claude"));
1683        // Stripping the block yields the ORIGINAL bytes exactly.
1684        assert_eq!(strip_pipeline_block_lines(&spliced), base);
1685        // Clearing (empty pipeline) also returns the original bytes.
1686        assert_eq!(
1687            splice_pipeline_block(&spliced, &AgentDocPipeline::default()).unwrap(),
1688            base
1689        );
1690        // Idempotent: re-splicing the same state does not stack blocks.
1691        let twice = splice_pipeline_block(&spliced, &pipeline).unwrap();
1692        assert_eq!(twice.matches("agent_doc_pipeline:").count(), 1);
1693    }
1694
1695    #[test]
1696    fn clear_pipeline_state_drops_block_only() {
1697        let content = "---\nagent_doc_session: abc-123\nagent_doc_pipeline:\n  run_id: cycle-1\n  step: response_captured\n---\n\nBody\n";
1698        let cleared = clear_pipeline_state(content).unwrap();
1699        assert!(!cleared.contains("agent_doc_pipeline"));
1700        let (fm, body) = parse(&cleared).unwrap();
1701        assert!(fm.pipeline.is_empty());
1702        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1703        assert!(body.contains("Body"));
1704    }
1705
1706    #[test]
1707    fn parse_queue_unknown_value_leaves_queue_active_untouched() {
1708        // A typo must never silently flip activation.
1709        let (fm, _) = parse("---\nqueue: bogus\nqueue_active: true\n---\n\n").unwrap();
1710        assert_eq!(fm.queue_active, Some(true));
1711        let (fm, _) = parse("---\nqueue: bogus\n---\n\n").unwrap();
1712        assert_eq!(fm.queue_active, None);
1713    }
1714
1715    #[test]
1716    fn write_emits_canonical_queue_for_direct_set_queue_active() {
1717        // #writer-emits-deprecated-queue-active-false: a path that sets
1718        // `queue_active` directly on the struct (bypassing merge_queue_state) and
1719        // then calls write() must still emit the canonical `queue:` control, never
1720        // the deprecated `queue_active:` line.
1721        let mut fm = Frontmatter {
1722            queue_active: Some(true),
1723            ..Default::default()
1724        };
1725        let out = write(&fm, "body\n").unwrap();
1726        assert!(out.contains("queue: start"), "{out}");
1727        assert!(!out.contains("queue_active:"), "{out}");
1728
1729        fm.queue_active = Some(false);
1730        let out = write(&fm, "body\n").unwrap();
1731        assert!(out.contains("queue: stop"), "{out}");
1732        assert!(!out.contains("queue_active:"), "{out}");
1733
1734        // A doc parsed from canonical `queue:` (queue_active mirrored on) must
1735        // not double-emit the deprecated line on re-serialize.
1736        let (fm, body) =
1737            parse("---\nagent_doc_format: template\nqueue: start\n---\n\nx\n").unwrap();
1738        let out = write(&fm, &body).unwrap();
1739        assert!(out.contains("queue: start"), "{out}");
1740        assert!(!out.contains("queue_active:"), "{out}");
1741
1742        // No queue state → neither key is emitted.
1743        let fm = Frontmatter::default();
1744        let out = write(&fm, "body\n").unwrap();
1745        assert!(
1746            !out.contains("queue_active:") && !out.contains("queue:"),
1747            "{out}"
1748        );
1749    }
1750
1751    #[test]
1752    fn merge_queue_state_writes_canonical_and_drops_legacy() {
1753        // #queue-state-unify phase 4: writer emits `queue: start|stop` and
1754        // removes any legacy `queue_active:` line so `queue:` is the sole field.
1755        let legacy = "---\nagent_doc_format: template\nqueue_active: true\n---\n\nbody\n";
1756        let active = merge_queue_state(legacy, true).unwrap();
1757        assert!(active.contains("queue: start"), "{active}");
1758        assert!(!active.contains("queue_active:"), "{active}");
1759
1760        let stopped = merge_queue_state(legacy, false).unwrap();
1761        assert!(stopped.contains("queue: stop"), "{stopped}");
1762        assert!(!stopped.contains("queue_active:"), "{stopped}");
1763
1764        // Round-trips back to the internal queue_active for readers.
1765        let (fm, _) = parse(&active).unwrap();
1766        assert_eq!(fm.queue_active, Some(true));
1767        let (fm, _) = parse(&stopped).unwrap();
1768        assert_eq!(fm.queue_active, Some(false));
1769    }
1770
1771    #[test]
1772    fn strip_deprecated_queue_active_line_drops_legacy_when_canonical_present() {
1773        // #queue-active-deprecated-line-stuck: a doc carrying BOTH `queue:` and the
1774        // deprecated `queue_active:` (the stuck shape the diff layer never lets a
1775        // normal write remove) has the legacy line dropped byte-precisely, leaving
1776        // every other field untouched.
1777        let stuck = "---\nagent: claude\nqueue: start\nqueue_active: true\n---\n\nbody\n";
1778        let out = strip_deprecated_queue_active_line(stuck);
1779        assert_eq!(
1780            out, "---\nagent: claude\nqueue: start\n---\n\nbody\n",
1781            "legacy line dropped, canonical queue + other fields preserved"
1782        );
1783        // Idempotent.
1784        assert_eq!(strip_deprecated_queue_active_line(&out), out);
1785    }
1786
1787    #[test]
1788    fn strip_deprecated_queue_active_line_keeps_legacy_without_canonical() {
1789        // Without a canonical `queue:` the legacy line is the only state — never
1790        // drop it (that would lose the queue state).
1791        let legacy_only = "---\nqueue_active: true\n---\n\nbody\n";
1792        assert_eq!(strip_deprecated_queue_active_line(legacy_only), legacy_only);
1793        // No frontmatter at all → unchanged.
1794        assert_eq!(
1795            strip_deprecated_queue_active_line("no frontmatter\nqueue_active: true\n"),
1796            "no frontmatter\nqueue_active: true\n"
1797        );
1798    }
1799
1800    #[test]
1801    fn parse_legacy_queue_active_still_honored() {
1802        // Back-compat: docs without the canonical key keep working.
1803        let (fm, _) = parse("---\nqueue_active: true\n---\n\n").unwrap();
1804        assert_eq!(fm.queue.as_deref(), None);
1805        assert_eq!(fm.queue_active, Some(true));
1806    }
1807
1808    #[test]
1809    fn parse_partial_fields() {
1810        let content = "---\nsession: xyz\n---\n# Doc\n";
1811        let (fm, body) = parse(content).unwrap();
1812        assert_eq!(fm.session.as_deref(), Some("xyz"));
1813        assert!(fm.agent.is_none());
1814        assert!(body.contains("# Doc"));
1815    }
1816
1817    #[test]
1818    fn parse_model_tier_high() {
1819        let content = "---\nagent_doc_model_tier: high\n---\nBody\n";
1820        let (fm, _) = parse(content).unwrap();
1821        assert_eq!(fm.model_tier, Some(Tier::High));
1822    }
1823
1824    #[test]
1825    fn parse_collaboration_mode_shared() {
1826        let content = "---\nagent_doc_collaboration: shared\n---\nBody\n";
1827        let (fm, _) = parse(content).unwrap();
1828        assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
1829    }
1830
1831    #[test]
1832    fn parse_security_review_roundtrip() {
1833        let content = "---\nagent_doc_collaboration: shared\nagent_doc_security_review: sec-2026-04-29\n---\nBody\n";
1834        let (fm, _) = parse(content).unwrap();
1835        assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
1836        assert_eq!(fm.security_review.as_deref(), Some("sec-2026-04-29"));
1837        assert!(fm.has_security_review());
1838        let written = write(&fm, "Body\n").unwrap();
1839        assert!(written.contains("agent_doc_collaboration: shared"));
1840        assert!(written.contains("agent_doc_security_review: sec-2026-04-29"));
1841    }
1842
1843    #[test]
1844    fn parse_model_tier_low() {
1845        let content = "---\nagent_doc_model_tier: low\n---\nBody\n";
1846        let (fm, _) = parse(content).unwrap();
1847        assert_eq!(fm.model_tier, Some(Tier::Low));
1848    }
1849
1850    #[test]
1851    fn parse_model_tier_med() {
1852        let content = "---\nagent_doc_model_tier: med\n---\nBody\n";
1853        let (fm, _) = parse(content).unwrap();
1854        assert_eq!(fm.model_tier, Some(Tier::Med));
1855    }
1856
1857    #[test]
1858    fn parse_model_tier_auto() {
1859        let content = "---\nagent_doc_model_tier: auto\n---\nBody\n";
1860        let (fm, _) = parse(content).unwrap();
1861        assert_eq!(fm.model_tier, Some(Tier::Auto));
1862    }
1863
1864    #[test]
1865    fn parse_model_tier_absent() {
1866        let content = "---\nagent: claude\n---\nBody\n";
1867        let (fm, _) = parse(content).unwrap();
1868        assert_eq!(fm.model_tier, None);
1869    }
1870
1871    #[test]
1872    fn parse_pending_capture_guard_strict() {
1873        let content = "---\npending_capture_guard: strict\n---\nBody\n";
1874        let (fm, _) = parse(content).unwrap();
1875        assert_eq!(
1876            fm.pending_capture_guard,
1877            Some(PendingCaptureGuardMode::Strict)
1878        );
1879    }
1880
1881    #[test]
1882    fn parse_pending_capture_guard_invalid_rejected() {
1883        let content = "---\npending_capture_guard: loudly\n---\nBody\n";
1884        assert!(parse(content).is_err());
1885    }
1886
1887    #[test]
1888    fn parse_pending_done_guard_strict() {
1889        let content = "---\npending_done_guard: strict\n---\nBody\n";
1890        let (fm, _) = parse(content).unwrap();
1891        assert_eq!(fm.pending_done_guard, Some(PendingCaptureGuardMode::Strict));
1892    }
1893
1894    #[test]
1895    fn parse_pending_done_guard_invalid_rejected() {
1896        let content = "---\npending_done_guard: loudly\n---\nBody\n";
1897        assert!(parse(content).is_err());
1898    }
1899
1900    #[test]
1901    fn parse_model_tier_invalid_rejected() {
1902        let content = "---\nagent_doc_model_tier: ultra\n---\nBody\n";
1903        let result = parse(content);
1904        assert!(result.is_err(), "invalid tier value should fail to parse");
1905    }
1906
1907    #[test]
1908    fn write_model_tier_roundtrip() {
1909        let fm = Frontmatter {
1910            model_tier: Some(Tier::High),
1911            ..Default::default()
1912        };
1913        let doc = write(&fm, "Body\n").unwrap();
1914        let (parsed, _) = parse(&doc).unwrap();
1915        assert_eq!(parsed.model_tier, Some(Tier::High));
1916        assert!(doc.contains("agent_doc_model_tier: high"));
1917    }
1918
1919    #[test]
1920    fn parse_null_fields() {
1921        let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
1922        let (fm, body) = parse(content).unwrap();
1923        assert!(fm.session.is_none());
1924        assert!(fm.agent.is_none());
1925        assert!(fm.model.is_none());
1926        assert!(fm.branch.is_none());
1927        assert!(body.contains("Body"));
1928    }
1929
1930    #[test]
1931    fn parse_unterminated_frontmatter() {
1932        let content = "---\nsession: abc\nno closing block";
1933        let err = parse(content).unwrap_err();
1934        assert!(err.to_string().contains("Unterminated frontmatter"));
1935    }
1936
1937    #[test]
1938    fn parse_closing_at_eof() {
1939        let content = "---\nsession: abc\n---";
1940        let (fm, body) = parse(content).unwrap();
1941        assert_eq!(fm.session.as_deref(), Some("abc"));
1942        assert_eq!(body, "");
1943    }
1944
1945    #[test]
1946    fn parse_empty_body() {
1947        let content = "---\nsession: abc\n---\n";
1948        let (fm, _body) = parse(content).unwrap();
1949        assert_eq!(fm.session.as_deref(), Some("abc"));
1950    }
1951
1952    #[test]
1953    fn write_roundtrip() {
1954        // Start from write output to ensure consistent formatting
1955        let fm = Frontmatter {
1956            session: Some("test-id".to_string()),
1957            resume: Some("resume-id".to_string()),
1958            agent: Some("claude".to_string()),
1959            model: Some("opus".to_string()),
1960            claude_model: None,
1961            codex_model: None,
1962            opencode_model: None,
1963            branch: Some("dev".to_string()),
1964            tmux_session: None,
1965            mode: None,
1966            format: None,
1967            write_mode: None,
1968            stream_config: None,
1969            agent_args: None,
1970            claude_args: None,
1971            codex_args: None,
1972            opencode_args: None,
1973            codex_network_access: None,
1974            required_ssh_targets: Vec::new(),
1975            required_ssh_profile: None,
1976            managed_proof_max_attempts: None,
1977            managed_proof_retry_backoff_secs: None,
1978            managed_proof_probe_timeout_secs: None,
1979            no_mcp: None,
1980            enable_tool_search: None,
1981            debounce_ms: None,
1982            links: vec![],
1983            auto_compact: None,
1984            queue_context_reset: None,
1985            gate_autoverify: None,
1986            clear_threshold: None,
1987            supervisor_auto_recycle: None,
1988            agent_change_restart: None,
1989            supervisor_auto_install: None,
1990            model_tier: None,
1991            pending_capture_guard: None,
1992            pending_done_guard: None,
1993            review_done_guard: None,
1994            auto_done: None,
1995            agent_doc_lint_dialect: None,
1996            hooks: std::collections::HashMap::new(),
1997            env: indexmap::IndexMap::new(),
1998            prompt_presets: indexmap::IndexMap::new(),
1999            dispatch: None,
2000            agent_doc_env_inherit: None,
2001            cwd: None,
2002            queue: None,
2003            queue_active: None,
2004            queue_prompt_echo_max_chars: None,
2005            collaboration: None,
2006            security_review: None,
2007            pipeline: AgentDocPipeline::default(),
2008        };
2009        let body = "# Hello\n\nBody text.\n";
2010        let written = write(&fm, body).unwrap();
2011        let (fm2, body2) = parse(&written).unwrap();
2012        assert_eq!(fm2.session, fm.session);
2013        assert_eq!(fm2.agent, fm.agent);
2014        assert_eq!(fm2.model, fm.model);
2015        assert_eq!(fm2.claude_model, fm.claude_model);
2016        assert_eq!(fm2.codex_model, fm.codex_model);
2017        assert_eq!(fm2.opencode_model, fm.opencode_model);
2018        assert_eq!(fm2.branch, fm.branch);
2019        assert_eq!(fm2.collaboration, fm.collaboration);
2020        assert_eq!(fm2.security_review, fm.security_review);
2021        assert_eq!(body2, body);
2022    }
2023
2024    #[test]
2025    fn repeated_parse_write_roundtrip_preserves_body_prefix() {
2026        let fm = Frontmatter {
2027            session: Some("abc".to_string()),
2028            agent: Some("codex".to_string()),
2029            ..Default::default()
2030        };
2031        let original = write(&fm, "## Status\n\nBody\n").unwrap();
2032        let mut current = original.clone();
2033
2034        for _ in 0..3 {
2035            let (fm, body) = parse(&current).unwrap();
2036            assert_eq!(body, "## Status\n\nBody\n");
2037            current = write(&fm, body).unwrap();
2038        }
2039
2040        let (fm, body) = parse(&current).unwrap();
2041        assert_eq!(fm.session.as_deref(), Some("abc"));
2042        assert_eq!(fm.agent.as_deref(), Some("codex"));
2043        assert_eq!(body, "## Status\n\nBody\n");
2044        assert_eq!(current, original);
2045    }
2046
2047    #[test]
2048    fn write_default_frontmatter() {
2049        let fm = Frontmatter::default();
2050        let result = write(&fm, "body\n").unwrap();
2051        assert!(result.starts_with("---\n"));
2052        assert!(result.ends_with("---\nbody\n"));
2053    }
2054
2055    #[test]
2056    fn write_preserves_body_content() {
2057        let fm = Frontmatter::default();
2058        let body = "# Title\n\nSome **markdown** with `code`.\n";
2059        let result = write(&fm, body).unwrap();
2060        assert!(result.contains("# Title"));
2061        assert!(result.contains("Some **markdown** with `code`."));
2062    }
2063
2064    #[test]
2065    fn set_session_id_creates_frontmatter() {
2066        let content = "# No frontmatter\n\nJust body.\n";
2067        let result = set_session_id(content, "new-session").unwrap();
2068        let (fm, body) = parse(&result).unwrap();
2069        assert_eq!(fm.session.as_deref(), Some("new-session"));
2070        assert!(body.contains("# No frontmatter"));
2071    }
2072
2073    #[test]
2074    fn set_session_id_updates_existing() {
2075        let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
2076        let result = set_session_id(content, "new-id").unwrap();
2077        let (fm, body) = parse(&result).unwrap();
2078        assert_eq!(fm.session.as_deref(), Some("new-id"));
2079        assert_eq!(fm.agent.as_deref(), Some("claude"));
2080        assert!(body.contains("Body"));
2081    }
2082
2083    #[test]
2084    fn set_session_id_preserves_other_fields() {
2085        let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
2086        let result = set_session_id(content, "new").unwrap();
2087        let (fm, _) = parse(&result).unwrap();
2088        assert_eq!(fm.session.as_deref(), Some("new"));
2089        assert_eq!(fm.agent.as_deref(), Some("claude"));
2090        assert_eq!(fm.model.as_deref(), Some("opus"));
2091        assert_eq!(fm.branch.as_deref(), Some("dev"));
2092    }
2093
2094    #[test]
2095    fn ensure_session_no_frontmatter() {
2096        let content = "# Hello\n\nBody.\n";
2097        let (updated, sid) = ensure_session(content).unwrap();
2098        // Should have generated a UUID
2099        assert_eq!(sid.len(), 36); // UUID v4 string length
2100        let (fm, body) = parse(&updated).unwrap();
2101        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
2102        assert!(body.contains("# Hello"));
2103    }
2104
2105    #[test]
2106    fn ensure_session_null_session() {
2107        let content = "---\nsession:\nagent: claude\n---\nBody\n";
2108        let (updated, sid) = ensure_session(content).unwrap();
2109        assert_eq!(sid.len(), 36);
2110        let (fm, body) = parse(&updated).unwrap();
2111        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
2112        assert_eq!(fm.agent.as_deref(), Some("claude"));
2113        assert!(body.contains("Body"));
2114    }
2115
2116    #[test]
2117    fn ensure_session_existing_session() {
2118        let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
2119        let (updated, sid) = ensure_session(content).unwrap();
2120        assert_eq!(sid, "existing-id");
2121        // Content should be unchanged
2122        assert_eq!(updated, content);
2123    }
2124
2125    #[test]
2126    fn parse_legacy_session_field() {
2127        // Old `session:` field should still parse via serde alias
2128        let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
2129        let (fm, body) = parse(content).unwrap();
2130        assert_eq!(fm.session.as_deref(), Some("legacy-id"));
2131        assert_eq!(fm.agent.as_deref(), Some("claude"));
2132        assert!(body.contains("Body"));
2133    }
2134
2135    #[test]
2136    fn parse_agent_doc_mode_canonical() {
2137        let content = "---\nagent_doc_mode: template\n---\nBody\n";
2138        let (fm, _) = parse(content).unwrap();
2139        assert_eq!(fm.mode.as_deref(), Some("template"));
2140    }
2141
2142    #[test]
2143    fn parse_mode_shorthand_alias() {
2144        let content = "---\nmode: template\n---\nBody\n";
2145        let (fm, _) = parse(content).unwrap();
2146        assert_eq!(fm.mode.as_deref(), Some("template"));
2147    }
2148
2149    #[test]
2150    fn parse_response_mode_legacy_alias() {
2151        let content = "---\nresponse_mode: template\n---\nBody\n";
2152        let (fm, _) = parse(content).unwrap();
2153        assert_eq!(fm.mode.as_deref(), Some("template"));
2154    }
2155
2156    #[test]
2157    fn write_uses_agent_doc_mode_field() {
2158        #[allow(deprecated)]
2159        let fm = Frontmatter {
2160            mode: Some("template".to_string()),
2161            ..Default::default()
2162        };
2163        let result = write(&fm, "body\n").unwrap();
2164        assert!(result.contains("agent_doc_mode:"));
2165        assert!(!result.contains("response_mode:"));
2166        assert!(!result.contains("\nmode:"));
2167    }
2168
2169    #[test]
2170    fn write_uses_new_field_name() {
2171        let fm = Frontmatter {
2172            session: Some("test-id".to_string()),
2173            ..Default::default()
2174        };
2175        let result = write(&fm, "body\n").unwrap();
2176        assert!(result.contains("agent_doc_session:"));
2177        assert!(!result.contains("\nsession:"));
2178    }
2179
2180    #[test]
2181    fn write_pending_capture_guard_roundtrip() {
2182        let fm = Frontmatter {
2183            pending_capture_guard: Some(PendingCaptureGuardMode::Strict),
2184            ..Default::default()
2185        };
2186        let written = write(&fm, "body\n").unwrap();
2187        assert!(written.contains("pending_capture_guard: strict"));
2188        let (parsed, _) = parse(&written).unwrap();
2189        assert_eq!(
2190            parsed.pending_capture_guard,
2191            Some(PendingCaptureGuardMode::Strict)
2192        );
2193    }
2194
2195    #[test]
2196    fn write_pending_done_guard_roundtrip() {
2197        let fm = Frontmatter {
2198            pending_done_guard: Some(PendingCaptureGuardMode::Strict),
2199            ..Default::default()
2200        };
2201        let written = write(&fm, "body\n").unwrap();
2202        assert!(written.contains("pending_done_guard: strict"));
2203        let (parsed, _) = parse(&written).unwrap();
2204        assert_eq!(
2205            parsed.pending_done_guard,
2206            Some(PendingCaptureGuardMode::Strict)
2207        );
2208    }
2209
2210    #[test]
2211    fn parse_review_done_guard_error_alias() {
2212        let content = "---\nreview_done_guard: error\n---\nBody\n";
2213        let (fm, _) = parse(content).unwrap();
2214        assert_eq!(fm.review_done_guard, Some(PendingCaptureGuardMode::Strict));
2215    }
2216
2217    #[test]
2218    fn write_review_done_guard_roundtrip() {
2219        let fm = Frontmatter {
2220            review_done_guard: Some(PendingCaptureGuardMode::Strict),
2221            ..Default::default()
2222        };
2223        let written = write(&fm, "body\n").unwrap();
2224        assert!(written.contains("review_done_guard: strict"));
2225        let (parsed, _) = parse(&written).unwrap();
2226        assert_eq!(
2227            parsed.review_done_guard,
2228            Some(PendingCaptureGuardMode::Strict)
2229        );
2230    }
2231
2232    #[test]
2233    fn parse_auto_done_bool() {
2234        let content = "---\nauto_done: true\n---\nBody\n";
2235        let (fm, _) = parse(content).unwrap();
2236        assert_eq!(fm.auto_done, Some(true));
2237    }
2238
2239    #[test]
2240    fn write_auto_done_roundtrip() {
2241        let fm = Frontmatter {
2242            auto_done: Some(true),
2243            ..Default::default()
2244        };
2245        let written = write(&fm, "Body\n").unwrap();
2246        assert!(written.contains("auto_done: true"));
2247        let (parsed, _) = parse(&written).unwrap();
2248        assert_eq!(parsed.auto_done, Some(true));
2249    }
2250
2251    #[test]
2252    fn parse_prompt_presets_roundtrip() {
2253        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";
2254        let (fm, body) = parse(content).unwrap();
2255        assert_eq!(body, "Body\n");
2256        assert_eq!(
2257            fm.prompt_presets.get("#1").map(String::as_str),
2258            Some("Today is 2026-04-25.\nKeep the work tree clean.\n")
2259        );
2260        assert_eq!(
2261            fm.prompt_presets.get("release-check").map(String::as_str),
2262            Some("Run cargo test.")
2263        );
2264
2265        let written = write(&fm, body).unwrap();
2266        let (parsed, body2) = parse(&written).unwrap();
2267        assert_eq!(body2, "Body\n");
2268        assert_eq!(parsed.prompt_presets, fm.prompt_presets);
2269    }
2270
2271    #[test]
2272    fn resolve_prompt_preset_key_accepts_bare_hashtag_alias() {
2273        let mut prompt_presets = indexmap::IndexMap::new();
2274        prompt_presets.insert("#spec-test".to_string(), "Run checks.".to_string());
2275        prompt_presets.insert("release-check".to_string(), "Publish.".to_string());
2276
2277        assert_eq!(
2278            resolve_prompt_preset_key(&prompt_presets, "spec-test").as_deref(),
2279            Some("#spec-test")
2280        );
2281        assert_eq!(
2282            resolve_prompt_preset_key(&prompt_presets, "#spec-test").as_deref(),
2283            Some("#spec-test")
2284        );
2285        assert_eq!(
2286            resolve_prompt_preset_key(&prompt_presets, "release-check").as_deref(),
2287            Some("release-check")
2288        );
2289        assert!(resolve_prompt_preset_key(&prompt_presets, "missing").is_none());
2290    }
2291
2292    // --- resolve_mode tests ---
2293
2294    #[test]
2295    fn resolve_mode_defaults() {
2296        let fm = Frontmatter::default();
2297        let resolved = fm.resolve_mode();
2298        assert_eq!(resolved.format, AgentDocFormat::Template);
2299        assert_eq!(resolved.write, AgentDocWrite::Crdt);
2300    }
2301
2302    #[test]
2303    fn resolve_mode_from_deprecated_append() {
2304        let content = "---\nagent_doc_mode: append\n---\nBody\n";
2305        let (fm, _) = parse(content).unwrap();
2306        let resolved = fm.resolve_mode();
2307        assert_eq!(resolved.format, AgentDocFormat::Append);
2308        assert_eq!(resolved.write, AgentDocWrite::Crdt);
2309    }
2310
2311    #[test]
2312    fn resolve_mode_from_deprecated_template() {
2313        let content = "---\nagent_doc_mode: template\n---\nBody\n";
2314        let (fm, _) = parse(content).unwrap();
2315        let resolved = fm.resolve_mode();
2316        assert_eq!(resolved.format, AgentDocFormat::Template);
2317        assert_eq!(resolved.write, AgentDocWrite::Crdt);
2318    }
2319
2320    #[test]
2321    fn resolve_mode_from_deprecated_stream() {
2322        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
2323        let (fm, _) = parse(content).unwrap();
2324        let resolved = fm.resolve_mode();
2325        assert_eq!(resolved.format, AgentDocFormat::Template);
2326        assert_eq!(resolved.write, AgentDocWrite::Crdt);
2327    }
2328
2329    #[test]
2330    fn resolve_mode_new_fields_override_deprecated() {
2331        let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
2332        let (fm, _) = parse(content).unwrap();
2333        let resolved = fm.resolve_mode();
2334        assert_eq!(resolved.format, AgentDocFormat::Template);
2335        assert_eq!(resolved.write, AgentDocWrite::Merge);
2336    }
2337
2338    #[test]
2339    fn resolve_mode_explicit_new_fields_only() {
2340        let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
2341        let (fm, _) = parse(content).unwrap();
2342        let resolved = fm.resolve_mode();
2343        assert_eq!(resolved.format, AgentDocFormat::Append);
2344        assert_eq!(resolved.write, AgentDocWrite::Crdt);
2345    }
2346
2347    #[test]
2348    fn resolve_mode_partial_new_field_format_only() {
2349        let content = "---\nagent_doc_format: append\n---\nBody\n";
2350        let (fm, _) = parse(content).unwrap();
2351        let resolved = fm.resolve_mode();
2352        assert_eq!(resolved.format, AgentDocFormat::Append);
2353        assert_eq!(resolved.write, AgentDocWrite::Crdt); // default
2354    }
2355
2356    #[test]
2357    fn resolve_mode_partial_new_field_write_only() {
2358        let content = "---\nagent_doc_write: merge\n---\nBody\n";
2359        let (fm, _) = parse(content).unwrap();
2360        let resolved = fm.resolve_mode();
2361        assert_eq!(resolved.format, AgentDocFormat::Template); // default
2362        assert_eq!(resolved.write, AgentDocWrite::Merge);
2363    }
2364
2365    #[test]
2366    fn resolve_mode_helper_methods() {
2367        let fm = Frontmatter::default();
2368        let resolved = fm.resolve_mode();
2369        assert!(resolved.is_template());
2370        assert!(!resolved.is_append());
2371        assert!(resolved.is_crdt());
2372    }
2373
2374    #[test]
2375    fn parse_new_format_field() {
2376        let content = "---\nagent_doc_format: template\n---\nBody\n";
2377        let (fm, _) = parse(content).unwrap();
2378        assert_eq!(fm.format, Some(AgentDocFormat::Template));
2379    }
2380
2381    #[test]
2382    fn parse_new_write_field() {
2383        let content = "---\nagent_doc_write: crdt\n---\nBody\n";
2384        let (fm, _) = parse(content).unwrap();
2385        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2386    }
2387
2388    #[test]
2389    fn write_uses_new_format_write_fields() {
2390        let fm = Frontmatter {
2391            format: Some(AgentDocFormat::Template),
2392            write_mode: Some(AgentDocWrite::Crdt),
2393            ..Default::default()
2394        };
2395        let result = write(&fm, "body\n").unwrap();
2396        assert!(result.contains("agent_doc_format:"));
2397        assert!(result.contains("agent_doc_write:"));
2398        assert!(!result.contains("agent_doc_mode:"));
2399    }
2400
2401    #[test]
2402    fn set_format_and_write_clears_deprecated_mode() {
2403        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
2404        let result =
2405            set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
2406        let (fm, _) = parse(&result).unwrap();
2407        assert!(fm.mode.is_none());
2408        assert_eq!(fm.format, Some(AgentDocFormat::Template));
2409        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2410    }
2411
2412    // --- merge_fields tests ---
2413
2414    #[test]
2415    fn merge_fields_adds_new_field() {
2416        let content = "---\nagent_doc_session: abc\n---\nBody\n";
2417        let result = merge_fields(content, "model: opus").unwrap();
2418        let (fm, body) = parse(&result).unwrap();
2419        assert_eq!(fm.session.as_deref(), Some("abc"));
2420        assert_eq!(fm.model.as_deref(), Some("opus"));
2421        assert!(body.contains("Body"));
2422    }
2423
2424    #[test]
2425    fn merge_fields_updates_existing_field() {
2426        let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
2427        let result = merge_fields(content, "model: opus").unwrap();
2428        let (fm, _) = parse(&result).unwrap();
2429        assert_eq!(fm.model.as_deref(), Some("opus"));
2430        assert_eq!(fm.session.as_deref(), Some("abc"));
2431    }
2432
2433    #[test]
2434    fn merge_fields_multiple_fields() {
2435        let content = "---\nagent_doc_session: abc\n---\nBody\n";
2436        let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
2437        let (fm, _) = parse(&result).unwrap();
2438        assert_eq!(fm.model.as_deref(), Some("opus"));
2439        assert_eq!(fm.agent.as_deref(), Some("claude"));
2440        assert_eq!(fm.branch.as_deref(), Some("main"));
2441    }
2442
2443    #[test]
2444    fn merge_fields_format_enum() {
2445        let content = "---\nagent_doc_session: abc\n---\nBody\n";
2446        let result = merge_fields(content, "agent_doc_format: append").unwrap();
2447        let (fm, _) = parse(&result).unwrap();
2448        assert_eq!(fm.format, Some(AgentDocFormat::Append));
2449    }
2450
2451    #[test]
2452    fn merge_fields_write_enum() {
2453        let content = "---\nagent_doc_session: abc\n---\nBody\n";
2454        let result = merge_fields(content, "agent_doc_write: merge").unwrap();
2455        let (fm, _) = parse(&result).unwrap();
2456        assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
2457    }
2458
2459    #[test]
2460    fn merge_fields_ignores_unknown() {
2461        let content = "---\nagent_doc_session: abc\n---\nBody\n";
2462        let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
2463        let (fm, _) = parse(&result).unwrap();
2464        assert_eq!(fm.model.as_deref(), Some("opus"));
2465    }
2466
2467    #[test]
2468    fn merge_fields_preserves_body() {
2469        let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
2470        let result = merge_fields(content, "model: opus").unwrap();
2471        assert!(result.contains("# Title"));
2472        assert!(result.contains("Some **markdown** content."));
2473    }
2474
2475    #[test]
2476    fn set_format_and_write_clears_deprecated() {
2477        let content = "---\nagent_doc_mode: append\n---\nBody\n";
2478        let result =
2479            set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
2480        let (fm, _) = parse(&result).unwrap();
2481        assert!(fm.mode.is_none());
2482        assert_eq!(fm.format, Some(AgentDocFormat::Template));
2483        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
2484    }
2485
2486    #[test]
2487    fn hooks_roundtrip() {
2488        let content = "---\nhooks:\n  session_start:\n    - \"echo start {{session_id}}\"\n  post_write:\n    - \"notify {{file}}\"\n---\nBody\n";
2489        let (fm, _) = parse(content).unwrap();
2490        assert_eq!(
2491            fm.hooks.get("session_start"),
2492            Some(&vec!["echo start {{session_id}}".to_string()])
2493        );
2494        assert_eq!(
2495            fm.hooks.get("post_write"),
2496            Some(&vec!["notify {{file}}".to_string()])
2497        );
2498    }
2499
2500    #[test]
2501    fn hooks_omitted_when_empty() {
2502        let fm = Frontmatter::default();
2503        let result = write(&fm, "body\n").unwrap();
2504        assert!(!result.contains("hooks"));
2505    }
2506
2507    #[test]
2508    fn hooks_absent_parses_as_empty() {
2509        let content = "---\nsession: abc\n---\nBody\n";
2510        let (fm, _) = parse(content).unwrap();
2511        assert!(fm.hooks.is_empty());
2512    }
2513
2514    #[test]
2515    fn parse_no_mcp_field() {
2516        let content = "---\nno_mcp: true\n---\nBody\n";
2517        let (fm, _) = parse(content).unwrap();
2518        assert_eq!(fm.no_mcp, Some(true));
2519    }
2520
2521    #[test]
2522    fn parse_enable_tool_search_field() {
2523        let content = "---\nenable_tool_search: true\n---\nBody\n";
2524        let (fm, _) = parse(content).unwrap();
2525        assert_eq!(fm.enable_tool_search, Some(true));
2526    }
2527
2528    #[test]
2529    fn parse_missing_flags_default_none() {
2530        let content = "---\nsession: abc\n---\nBody\n";
2531        let (fm, _) = parse(content).unwrap();
2532        assert!(fm.no_mcp.is_none());
2533        assert!(fm.enable_tool_search.is_none());
2534    }
2535
2536    #[test]
2537    fn parse_env_map() {
2538        let content = "---\nenv:\n  FOO: bar\n  BAZ: \"$(echo hello)\"\n---\nBody\n";
2539        let (fm, _) = parse(content).unwrap();
2540        assert_eq!(fm.env.len(), 2);
2541        assert_eq!(fm.env["FOO"], Some("bar".to_string()));
2542        assert_eq!(fm.env["BAZ"], Some("$(echo hello)".to_string()));
2543        // Verify order is preserved
2544        let keys: Vec<&String> = fm.env.keys().collect();
2545        assert_eq!(keys, vec!["FOO", "BAZ"]);
2546    }
2547
2548    #[test]
2549    fn parse_env_empty_default() {
2550        let content = "---\nsession: abc\n---\nBody\n";
2551        let (fm, _) = parse(content).unwrap();
2552        assert!(fm.env.is_empty());
2553    }
2554
2555    #[test]
2556    fn parse_env_unset_via_null() {
2557        let content = "---\nenv:\n  SET_ME: value\n  UNSET_ME: null\n---\nBody\n";
2558        let (fm, _) = parse(content).unwrap();
2559        assert_eq!(fm.env.len(), 2);
2560        assert_eq!(fm.env["SET_ME"], Some("value".to_string()));
2561        assert_eq!(fm.env["UNSET_ME"], None);
2562    }
2563
2564    #[test]
2565    fn write_roundtrip_with_env() {
2566        let mut env: indexmap::IndexMap<String, Option<String>> = indexmap::IndexMap::new();
2567        env.insert("KEY1".to_string(), Some("value1".to_string()));
2568        env.insert("KEY2".to_string(), Some("$KEY1".to_string()));
2569        env.insert("KEY3".to_string(), None);
2570        let fm = Frontmatter {
2571            env,
2572            ..Default::default()
2573        };
2574        let written = write(&fm, "body\n").unwrap();
2575        let (fm2, _) = parse(&written).unwrap();
2576        assert_eq!(fm2.env.len(), 3);
2577        assert_eq!(fm2.env["KEY1"], Some("value1".to_string()));
2578        assert_eq!(fm2.env["KEY2"], Some("$KEY1".to_string()));
2579        assert_eq!(fm2.env["KEY3"], None);
2580    }
2581
2582    #[test]
2583    fn parse_agent_args_field() {
2584        let content = "---\nagent_args: \"--json -s workspace-write\"\n---\nBody\n";
2585        let (fm, _) = parse(content).unwrap();
2586        assert_eq!(fm.agent_args.as_deref(), Some("--json -s workspace-write"));
2587        assert!(fm.claude_args.is_none());
2588        assert!(fm.codex_args.is_none());
2589        assert!(fm.opencode_args.is_none());
2590    }
2591
2592    #[test]
2593    fn parse_agent_args_and_harness_aliases() {
2594        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";
2595        let (fm, _) = parse(content).unwrap();
2596        assert_eq!(fm.agent_args.as_deref(), Some("--json"));
2597        assert_eq!(
2598            fm.claude_args.as_deref(),
2599            Some("--dangerously-skip-permissions")
2600        );
2601        assert_eq!(fm.codex_args.as_deref(), Some("-s danger-full-access"));
2602        assert_eq!(
2603            fm.opencode_args.as_deref(),
2604            Some("--dangerously-skip-permissions")
2605        );
2606        assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Enabled));
2607        assert_eq!(
2608            fm.required_ssh_targets,
2609            vec!["monsterrodholders-server".to_string()]
2610        );
2611    }
2612
2613    #[test]
2614    fn write_roundtrip_agent_args() {
2615        let fm = Frontmatter {
2616            agent_args: Some("--json -s workspace-write".to_string()),
2617            codex_args: Some("-s danger-full-access".to_string()),
2618            opencode_args: Some("--dangerously-skip-permissions".to_string()),
2619            codex_network_access: Some(CodexNetworkAccess::Enabled),
2620            required_ssh_targets: vec!["monsterrodholders-server".to_string()],
2621            ..Default::default()
2622        };
2623        let written = write(&fm, "body\n").unwrap();
2624        let (fm2, _) = parse(&written).unwrap();
2625        assert_eq!(fm2.agent_args.as_deref(), Some("--json -s workspace-write"));
2626        assert_eq!(fm2.codex_args.as_deref(), Some("-s danger-full-access"));
2627        assert_eq!(
2628            fm2.opencode_args.as_deref(),
2629            Some("--dangerously-skip-permissions")
2630        );
2631        assert_eq!(fm2.codex_network_access, Some(CodexNetworkAccess::Enabled));
2632        assert_eq!(
2633            fm2.required_ssh_targets,
2634            vec!["monsterrodholders-server".to_string()]
2635        );
2636    }
2637
2638    #[test]
2639    fn merge_fields_agent_args() {
2640        let content = "---\nclaude_args: old\ncodex_args: old-codex\nopencode_args: old-opencode\n---\nBody\n";
2641        let result = merge_fields(content, "agent_args: new").unwrap();
2642        let (fm, _) = parse(&result).unwrap();
2643        assert_eq!(fm.agent_args.as_deref(), Some("new"));
2644        assert_eq!(fm.claude_args.as_deref(), Some("old"));
2645        assert_eq!(fm.codex_args.as_deref(), Some("old-codex"));
2646        assert_eq!(fm.opencode_args.as_deref(), Some("old-opencode"));
2647    }
2648
2649    #[test]
2650    fn merge_fields_codex_args() {
2651        let content = "---\nagent_args: old\n---\nBody\n";
2652        let result = merge_fields(content, "codex_args: new").unwrap();
2653        let (fm, _) = parse(&result).unwrap();
2654        assert_eq!(fm.agent_args.as_deref(), Some("old"));
2655        assert_eq!(fm.codex_args.as_deref(), Some("new"));
2656    }
2657
2658    #[test]
2659    fn merge_fields_opencode_args() {
2660        let content = "---\nagent_args: old\n---\nBody\n";
2661        let result = merge_fields(content, "opencode_args: new").unwrap();
2662        let (fm, _) = parse(&result).unwrap();
2663        assert_eq!(fm.agent_args.as_deref(), Some("old"));
2664        assert_eq!(fm.opencode_args.as_deref(), Some("new"));
2665    }
2666
2667    #[test]
2668    fn merge_fields_codex_network_access() {
2669        let content = "Body\n";
2670        let result = merge_fields(content, "codex_network_access: disabled").unwrap();
2671        let (fm, _) = parse(&result).unwrap();
2672        assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Disabled));
2673    }
2674
2675    #[test]
2676    fn merge_fields_required_ssh_targets() {
2677        let content = "Body\n";
2678        let result = merge_fields(
2679            content,
2680            "required_ssh_targets:\n  - monsterrodholders-server\n  - root@50.28.2.199",
2681        )
2682        .unwrap();
2683        let (fm, _) = parse(&result).unwrap();
2684        assert_eq!(
2685            fm.required_ssh_targets,
2686            vec![
2687                "monsterrodholders-server".to_string(),
2688                "root@50.28.2.199".to_string(),
2689            ]
2690        );
2691    }
2692
2693    #[test]
2694    fn merge_fields_required_ssh_profile() {
2695        let content = "Body\n";
2696        let result = merge_fields(content, "required_ssh_profile: monsterrodholders").unwrap();
2697        let (fm, _) = parse(&result).unwrap();
2698        assert_eq!(
2699            fm.required_ssh_profile.as_deref(),
2700            Some("monsterrodholders")
2701        );
2702    }
2703
2704    // --- resolve_harness_model tests ---
2705
2706    #[test]
2707    fn resolve_harness_model_claude_code_uses_claude_model() {
2708        let fm = Frontmatter {
2709            model: Some("gpt-5".to_string()),
2710            claude_model: Some("opus".to_string()),
2711            codex_model: Some("o3-pro".to_string()),
2712            ..Default::default()
2713        };
2714        assert_eq!(fm.resolve_harness_model("claude-code"), Some("opus"));
2715    }
2716
2717    #[test]
2718    fn resolve_harness_model_codex_uses_codex_model() {
2719        let fm = Frontmatter {
2720            model: Some("gpt-5".to_string()),
2721            claude_model: Some("opus".to_string()),
2722            codex_model: Some("o3-pro".to_string()),
2723            opencode_model: Some("zai/glm-5".to_string()),
2724            ..Default::default()
2725        };
2726        assert_eq!(fm.resolve_harness_model("codex"), Some("o3-pro"));
2727    }
2728
2729    #[test]
2730    fn resolve_harness_model_opencode_uses_opencode_model() {
2731        let fm = Frontmatter {
2732            model: Some("gpt-5".to_string()),
2733            claude_model: Some("opus".to_string()),
2734            codex_model: Some("o3-pro".to_string()),
2735            opencode_model: Some("zai/glm-5".to_string()),
2736            ..Default::default()
2737        };
2738        assert_eq!(fm.resolve_harness_model("opencode"), Some("zai/glm-5"));
2739    }
2740
2741    #[test]
2742    fn resolve_harness_model_falls_back_to_generic() {
2743        let fm = Frontmatter {
2744            model: Some("gpt-5".to_string()),
2745            ..Default::default()
2746        };
2747        assert_eq!(fm.resolve_harness_model("claude-code"), Some("gpt-5"));
2748        assert_eq!(fm.resolve_harness_model("codex"), Some("gpt-5"));
2749        assert_eq!(fm.resolve_harness_model("opencode"), Some("gpt-5"));
2750        assert_eq!(fm.resolve_harness_model("default"), Some("gpt-5"));
2751    }
2752
2753    #[test]
2754    fn resolve_harness_model_none_when_no_model() {
2755        let fm = Frontmatter::default();
2756        assert_eq!(fm.resolve_harness_model("claude-code"), None);
2757    }
2758
2759    #[test]
2760    fn resolve_harness_model_unknown_harness_uses_generic() {
2761        let fm = Frontmatter {
2762            model: Some("sonnet".to_string()),
2763            claude_model: Some("opus".to_string()),
2764            ..Default::default()
2765        };
2766        assert_eq!(fm.resolve_harness_model("unknown"), Some("sonnet"));
2767    }
2768
2769    #[test]
2770    fn parse_harness_model_fields() {
2771        let content = "---\nmodel: gpt-5\nclaude_model: opus\ncodex_model: o3-pro\nopencode_model: zai/glm-5\n---\nBody\n";
2772        let (fm, _) = parse(content).unwrap();
2773        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2774        assert_eq!(fm.claude_model.as_deref(), Some("opus"));
2775        assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
2776        assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
2777    }
2778
2779    #[test]
2780    fn write_roundtrip_harness_model_fields() {
2781        let fm = Frontmatter {
2782            model: Some("gpt-5".to_string()),
2783            claude_model: Some("opus".to_string()),
2784            codex_model: Some("o3-pro".to_string()),
2785            opencode_model: Some("zai/glm-5".to_string()),
2786            ..Default::default()
2787        };
2788        let doc = write(&fm, "Body\n").unwrap();
2789        let (fm2, body) = parse(&doc).unwrap();
2790        assert_eq!(fm2.model.as_deref(), Some("gpt-5"));
2791        assert_eq!(fm2.claude_model.as_deref(), Some("opus"));
2792        assert_eq!(fm2.codex_model.as_deref(), Some("o3-pro"));
2793        assert_eq!(fm2.opencode_model.as_deref(), Some("zai/glm-5"));
2794        assert_eq!(body, "Body\n");
2795    }
2796
2797    #[test]
2798    fn merge_fields_claude_model() {
2799        let content = "---\nmodel: gpt-5\n---\nBody\n";
2800        let result = merge_fields(content, "claude_model: opus").unwrap();
2801        let (fm, _) = parse(&result).unwrap();
2802        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2803        assert_eq!(fm.claude_model.as_deref(), Some("opus"));
2804    }
2805
2806    #[test]
2807    fn merge_fields_codex_model() {
2808        let content = "---\nmodel: gpt-5\n---\nBody\n";
2809        let result = merge_fields(content, "codex_model: o3-pro").unwrap();
2810        let (fm, _) = parse(&result).unwrap();
2811        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2812        assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
2813    }
2814
2815    #[test]
2816    fn merge_fields_opencode_model() {
2817        let content = "---\nmodel: gpt-5\n---\nBody\n";
2818        let result = merge_fields(content, "opencode_model: zai/glm-5").unwrap();
2819        let (fm, _) = parse(&result).unwrap();
2820        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2821        assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
2822    }
2823}