Skip to main content

agent_doc_core/
project_config.rs

1//! # Module: project_config
2//!
3//! Project-level configuration loaded from `.agent-doc/config.toml`.
4//! Shared between binary and library for consistent project config handling.
5//!
6//! ## Spec
7//! - Defines `ProjectConfig`: per-project settings (tmux_session, components).
8//! - Defines `ComponentConfig`: per-component patch configuration (mode, timestamps, hooks).
9//! - `load_project()` reads and parses the project config file. On absence, I/O error, or parse
10//!   error, returns `ProjectConfig::default()` and emits a warning to stderr (never panics).
11//! - `project_tmux_session()` is a convenience wrapper returning the configured tmux session name.
12//! - `save_project()` serialises `ProjectConfig` to TOML and writes it to
13//!   `.agent-doc/config.toml`, creating the directory if needed.
14//!
15//! ## Agentic Contracts
16//! - Never panics on missing config: `load_project()` returns defaults when the file is absent.
17//! - Project config errors are non-fatal: errors are surfaced as stderr warnings, not propagated.
18//! - Atomic-safe directory creation: `save_project()` calls `create_dir_all` before writing.
19
20use anyhow::Result;
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct GuardConfig {
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub pending_capture: Option<crate::frontmatter::PendingCaptureGuardMode>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub pending_done: Option<crate::frontmatter::PendingCaptureGuardMode>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub review_done: Option<crate::frontmatter::PendingCaptureGuardMode>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub auto_done: Option<bool>,
34}
35
36/// Workspace-level lint configuration (`[lint]` section in
37/// `.agent-doc/config.toml`).
38///
39/// Currently exposes a single key, `dialect`, controlling the
40/// `tagpath lint --dialect agent-doc` finalize gate. See
41/// `crate::frontmatter::LintDialectMode` for the resolved semantics.
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct LintConfig {
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub dialect: Option<crate::frontmatter::LintDialectMode>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49pub struct SshProfileConfig {
50    /// Resolved SSH targets for a named profile.
51    #[serde(default)]
52    pub targets: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct SshDocConfig {
57    /// Optional profile name to resolve for this document path.
58    #[serde(default)]
59    pub profile: Option<String>,
60    /// Optional direct targets for this document path.
61    #[serde(default)]
62    pub targets: Vec<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66pub struct SshConfig {
67    /// Named SSH profiles that expand to concrete targets.
68    #[serde(default)]
69    pub profiles: BTreeMap<String, SshProfileConfig>,
70    /// Relative document paths that require SSH metadata resolution.
71    #[serde(default)]
72    pub docs: BTreeMap<String, SshDocConfig>,
73}
74
75/// Component patch configuration (mode, timestamps, max entries, hooks).
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
77pub struct ComponentConfig {
78    /// Patch mode: "replace" (default), "append", "prepend".
79    /// `patch` is the primary key; `mode` is a backward-compatible alias.
80    #[serde(default = "default_patch_mode", alias = "mode")]
81    pub patch: String,
82    /// Merge strategy: "append-friendly" (default) or "strict".
83    /// "append-friendly" auto-resolves conflicts where both sides only appended.
84    /// "strict" preserves all conflict markers for manual resolution.
85    /// Currently parsed for config validation; merge runs at document level.
86    #[serde(default = "default_merge_strategy")]
87    #[allow(dead_code)]
88    pub merge_strategy: String,
89    /// Auto-prefix entries with ISO timestamp (for append/prepend modes)
90    #[serde(default)]
91    pub timestamp: bool,
92    /// Auto-trim old entries in append/prepend modes (0 = unlimited)
93    #[serde(default)]
94    pub max_entries: usize,
95    /// Trim component content to the last N lines after patching (0 = unlimited).
96    /// Currently used by template.rs post-patch processing.
97    #[serde(default)]
98    #[allow(dead_code)]
99    pub max_lines: usize,
100    /// Shell command to run before patching (stdin: content, stdout: transformed)
101    #[serde(default)]
102    pub pre_patch: Option<String>,
103    /// Shell command to run after patching (fire-and-forget)
104    #[serde(default)]
105    pub post_patch: Option<String>,
106}
107
108fn default_patch_mode() -> String {
109    "replace".to_string()
110}
111
112fn default_merge_strategy() -> String {
113    "append-friendly".to_string()
114}
115
116/// Opt-in document gating (`[documents]` section in `.agent-doc/config.toml`).
117///
118/// Controls which `.md` files agent-doc may auto-convert into a session. By
119/// default a plain `.md` is **not** an agent-doc document — it must opt in via
120/// frontmatter (an `agent_doc_*` field), a matching `include` glob here, or the
121/// `auto_session_for_all_md` escape hatch. See
122/// [`is_agent_doc_document`].
123#[derive(Debug, Clone, Serialize, Deserialize, Default)]
124pub struct DocumentsConfig {
125    /// Project-relative globs whose matching `.md` files are treated as
126    /// agent-doc session documents (e.g. `["tasks/**/*.md", "plan.md"]`).
127    #[serde(default)]
128    pub include: Vec<String>,
129    /// Legacy escape hatch restoring the old "every `.md` is a session"
130    /// behavior. Default `false`.
131    #[serde(default)]
132    pub auto_session_for_all_md: bool,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
136pub struct VisionConfig {
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub provider: Option<String>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub api_key: Option<String>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub model: Option<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub endpoint: Option<String>,
145    #[serde(default)]
146    pub agents: BTreeMap<String, AgentVisionConfig>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, Default)]
150pub struct AgentVisionConfig {
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub mode: Option<String>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub provider: Option<String>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub api_key: Option<String>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub model: Option<String>,
159}
160
161impl VisionConfig {
162    pub fn agent_config(&self, agent_name: &str) -> Option<&AgentVisionConfig> {
163        self.agents.get(agent_name)
164    }
165
166    pub fn effective_provider(&self, agent_name: Option<&str>) -> Option<&str> {
167        if let Some(name) = agent_name
168            && let Some(ac) = self.agents.get(name)
169            && let Some(ref p) = ac.provider
170        {
171            return Some(p.as_str());
172        }
173        self.provider.as_deref()
174    }
175
176    pub fn effective_model(&self, agent_name: Option<&str>) -> Option<&str> {
177        if let Some(name) = agent_name
178            && let Some(ac) = self.agents.get(name)
179            && let Some(ref m) = ac.model
180        {
181            return Some(m.as_str());
182        }
183        self.model.as_deref()
184    }
185
186    pub fn effective_api_key(&self, agent_name: Option<&str>) -> Option<&str> {
187        if let Some(name) = agent_name
188            && let Some(ac) = self.agents.get(name)
189            && let Some(ref k) = ac.api_key
190        {
191            return Some(k.as_str());
192        }
193        self.api_key.as_deref()
194    }
195
196    pub fn agent_mode(&self, agent_name: &str) -> Option<&str> {
197        self.agents
198            .get(agent_name)
199            .and_then(|ac| ac.mode.as_deref())
200    }
201}
202
203/// Project-level configuration, read from `.agent-doc/config.toml` relative to CWD.
204#[derive(Debug, Default, Serialize, Deserialize)]
205pub struct ProjectConfig {
206    /// Target tmux session name for this project.
207    #[serde(default)]
208    pub tmux_session: Option<String>,
209    /// Explicit opt-in for automatic compaction/reload policies.
210    /// Session-accretion heuristics never compact by themselves; omit to disable.
211    #[serde(default, alias = "auto_compact")]
212    pub agent_doc_auto_compact: Option<usize>,
213    /// Explicit opt-in for the supervisor idle-queue watch to pre-emptively
214    /// interleave a context-clear (`/clear`) before an active queue head when
215    /// session accretion is unhealthy (`#nm1x-no-preempt-clear`). Off by default
216    /// so a manual `Run Agent Doc` / auto-loop drain never fires a pre-emptive
217    /// `/clear` that churns the session or is rejected mid-turn.
218    #[serde(default, alias = "queue_context_reset")]
219    pub agent_doc_queue_context_reset: Option<bool>,
220    /// Explicit opt-in for opportunistic gated-review auto-verification
221    /// (`#optverify`). When true, preflight may auto-flip a gated `[/]` review
222    /// item carrying a verify predicate to `[x]` once its proof marker is
223    /// provable from `ops.log`. A per-document frontmatter
224    /// `agent_doc_gate_autoverify` takes precedence. Off by default — never
225    /// silently flip a human gate without this opt-in.
226    #[serde(default, alias = "gate_autoverify")]
227    pub agent_doc_gate_autoverify: Option<bool>,
228    /// Project-default context-usage percentage (0–100) at or above which an
229    /// opted-in editor pre-emptively runs `/clear` (`#clear-opt-in-threshold`).
230    /// A per-document frontmatter `agent_doc_clear_threshold` takes precedence;
231    /// absent here too, the built-in default of 50 applies.
232    #[serde(default, alias = "clear_threshold")]
233    pub agent_doc_clear_threshold: Option<u8>,
234    /// Explicit opt-in for the in-process supervisor (`agent-doc start
235    /// --route-owned`) to self-recycle onto a freshly-installed binary at a turn
236    /// or inter-queue-item boundary (`#ctlrecycle` R3 / `#suprecyclequeue`). When
237    /// true, a stale supervisor hot-reloads via `execve` (preserving the live agent
238    /// child + pane) without a manual `agent-doc session restart-supervisor`.
239    /// Resolution precedence: the env var `AGENT_DOC_SUPERVISOR_AUTO_RECYCLE`
240    /// (truthy enables, `0`/`false`/`off` force-disables), then a per-document
241    /// frontmatter `agent_doc_supervisor_auto_recycle` opt-in/opt-out, then this
242    /// project value. Off by default — the image swap is high blast-radius, so
243    /// projects opt in (dog-fooding repos that rebuild constantly set this so the
244    /// supervisor always promotes the latest build automatically).
245    #[serde(default, alias = "supervisor_auto_recycle")]
246    pub agent_doc_supervisor_auto_recycle: Option<bool>,
247    /// Per-project opt-in/opt-out for restart-agent on a harness change
248    /// (`#agentreloadrestart`). When the resolved harness from current frontmatter
249    /// (`agent:`) differs from the one the running supervisor launched with, true
250    /// lets the supervisor auto-restart the agent at the next idle/turn boundary so
251    /// changing `agent` (e.g. claude→opencode) takes effect without a manual
252    /// restart. Resolution precedence: env `AGENT_DOC_AGENT_CHANGE_RESTART`, then
253    /// per-document frontmatter `agent_doc_agent_change_restart`, then this project
254    /// value, then the built-in default of ON.
255    #[serde(default, alias = "agent_change_restart")]
256    pub agent_doc_agent_change_restart: Option<bool>,
257    /// Per-project opt-in/opt-out for supervisor auto-install (`#supautoinstall`).
258    /// When a DOGFOODING supervisor finds agent-doc's own source newer than the
259    /// installed binary at an idle boundary, this controls whether it rebuilds +
260    /// installs (then the `#ctlrecycle` recycle promotes the new build) or only logs
261    /// `supervisor_source_newer_detected`. Resolution precedence: env
262    /// `AGENT_DOC_SUPERVISOR_AUTO_INSTALL` (truthy enables, `0`/`false`/`off`
263    /// force-disables), then the per-document frontmatter
264    /// `agent_doc_supervisor_auto_install`, then this project value, then the
265    /// built-in default of ON. Never fires for a non-agent-doc dogfood session
266    /// document, so the default is safe for ordinary users and ordinary project
267    /// docs cannot force it on.
268    #[serde(default, alias = "supervisor_auto_install")]
269    pub agent_doc_supervisor_auto_install: Option<bool>,
270    /// Optional project-default document for `#agent-doc-bug` / dogfooding bug
271    /// backlog capture. When absent or empty, bug backlog items stay in the
272    /// current session document.
273    #[serde(
274        default,
275        alias = "bug_target_document",
276        alias = "agent_doc_bug_backlog_document"
277    )]
278    pub agent_doc_bug_target_document: Option<String>,
279    /// Guard behavior overrides (for example pending-capture enforcement).
280    #[serde(default)]
281    pub guards: GuardConfig,
282    /// Lint behavior overrides for the agent-doc finalize lint gate.
283    #[serde(default)]
284    pub lint: LintConfig,
285    /// Project-local SSH requirement mappings for known ops documents.
286    #[serde(default)]
287    pub ssh: SshConfig,
288    /// Component-specific configuration (patch modes, timestamps, max_entries, hooks).
289    #[serde(default)]
290    pub components: BTreeMap<String, ComponentConfig>,
291    /// Opt-in gating for which `.md` files become agent-doc session documents.
292    #[serde(default)]
293    pub documents: DocumentsConfig,
294    /// Vision (describe-image) provider configuration.
295    #[serde(default)]
296    pub vision: VisionConfig,
297    /// `#stash-session-ttl-prune`: idle TTL in seconds for stash-window agent-doc
298    /// panes. When set (> 0), resync logs which panes *would* be reaped. Set
299    /// `stash_session_ttl_prune_enabled = true` to enable destructive kill.
300    /// Default 0 (disabled).
301    #[serde(default)]
302    pub stash_session_ttl_secs: u64,
303    /// `#stash-session-ttl-prune`: enable destructive kill of idle stash panes
304    /// that exceed `stash_session_ttl_secs`. Report-only when false.
305    /// Default false.
306    #[serde(default)]
307    pub stash_session_ttl_prune_enabled: bool,
308}
309
310/// Parse a TOML string into a [`ProjectConfig`]. Pure — no fs I/O.
311///
312/// File-based loading (with legacy `components.toml` migration) lives in
313/// `crate::project_config_io` in the main `agent-doc` crate.
314pub fn parse_project_toml(content: &str) -> Result<ProjectConfig> {
315    toml::from_str(content).map_err(anyhow::Error::from)
316}
317
318/// Parse a legacy `components.toml` body (flat `[name]` tables of
319/// [`ComponentConfig`] fields) into a map. Used by the file-based migrator
320/// in `crate::project_config_io`.
321pub fn parse_legacy_components_toml(content: &str) -> Result<BTreeMap<String, ComponentConfig>> {
322    toml::from_str(content).map_err(anyhow::Error::from)
323}
324
325/// Opt-in predicate: is this `.md` an agent-doc session document?
326///
327/// Pure — takes the project-relative path, the document content (for
328/// frontmatter inspection), and the resolved project config. Returns `true`
329/// when ANY of the following hold (in cheapest-first order):
330///
331/// 1. `documents.auto_session_for_all_md = true` (legacy escape hatch).
332/// 2. Frontmatter already carries any agent-doc-managed field (see
333///    [`crate::frontmatter::Frontmatter::has_agent_doc_marker`]) — existing
334///    sessions and harness/model/preset config stay sessions.
335/// 3. The project-relative path matches a `documents.include` glob.
336///
337/// A plain `.md` with no agent-doc frontmatter, not matching any configured
338/// glob, with the escape hatch off, returns `false` — callers must fail closed
339/// instead of silently converting it.
340pub fn is_agent_doc_document(rel_path: &str, content: &str, config: &ProjectConfig) -> bool {
341    if config.documents.auto_session_for_all_md {
342        return true;
343    }
344    if let Ok((fm, _)) = crate::frontmatter::parse(content)
345        && fm.has_agent_doc_marker()
346    {
347        return true;
348    }
349    let norm = rel_path.trim_start_matches("./");
350    config
351        .documents
352        .include
353        .iter()
354        .any(|pattern| glob_match(pattern.trim_start_matches("./"), norm))
355}
356
357/// Minimal path-glob matcher supporting `*` (any run of non-`/` chars), `?`
358/// (one non-`/` char), and `**` (spans `/` boundaries, including zero
359/// segments). Both inputs are `/`-separated, project-relative paths. Pure, no
360/// I/O — avoids pulling in a glob crate for the small set of patterns
361/// `[documents] include` needs.
362pub fn glob_match(pattern: &str, path: &str) -> bool {
363    let pat: Vec<&str> = pattern.split('/').collect();
364    let seg: Vec<&str> = path.split('/').collect();
365    glob_match_segments(&pat, &seg)
366}
367
368fn glob_match_segments(pat: &[&str], seg: &[&str]) -> bool {
369    match pat.first() {
370        None => seg.is_empty(),
371        Some(&"**") => {
372            // `**` matches zero or more whole path segments.
373            if pat.len() == 1 {
374                return true;
375            }
376            // Try consuming 0..=seg.len() leading segments.
377            (0..=seg.len()).any(|skip| glob_match_segments(&pat[1..], &seg[skip..]))
378        }
379        Some(first) => match seg.first() {
380            Some(s) if glob_match_segment(first, s) => glob_match_segments(&pat[1..], &seg[1..]),
381            _ => false,
382        },
383    }
384}
385
386/// Match a single path segment against a pattern segment containing `*`/`?`
387/// (neither of which may match a `/`, which never appears inside a segment).
388fn glob_match_segment(pattern: &str, text: &str) -> bool {
389    let pat: Vec<char> = pattern.chars().collect();
390    let txt: Vec<char> = text.chars().collect();
391    let (mut pi, mut ti) = (0usize, 0usize);
392    let (mut star_pi, mut star_ti): (Option<usize>, usize) = (None, 0);
393    while ti < txt.len() {
394        if pi < pat.len() && (pat[pi] == '?' || pat[pi] == txt[ti]) {
395            pi += 1;
396            ti += 1;
397        } else if pi < pat.len() && pat[pi] == '*' {
398            star_pi = Some(pi);
399            star_ti = ti;
400            pi += 1;
401        } else if let Some(sp) = star_pi {
402            pi = sp + 1;
403            star_ti += 1;
404            ti = star_ti;
405        } else {
406            return false;
407        }
408    }
409    while pi < pat.len() && pat[pi] == '*' {
410        pi += 1;
411    }
412    pi == pat.len()
413}
414
415#[cfg(test)]
416mod documents_gate_tests {
417    use super::*;
418
419    fn cfg(include: &[&str], all_md: bool) -> ProjectConfig {
420        ProjectConfig {
421            documents: DocumentsConfig {
422                include: include.iter().map(|s| s.to_string()).collect(),
423                auto_session_for_all_md: all_md,
424            },
425            ..Default::default()
426        }
427    }
428
429    #[test]
430    fn parses_agent_doc_bug_target_document() {
431        let cfg = parse_project_toml(
432            r#"
433agent_doc_bug_target_document = "tasks/agent-doc/agent-doc-bugs2.md"
434"#,
435        )
436        .unwrap();
437
438        assert_eq!(
439            cfg.agent_doc_bug_target_document.as_deref(),
440            Some("tasks/agent-doc/agent-doc-bugs2.md")
441        );
442    }
443
444    #[test]
445    fn glob_match_literal_and_star() {
446        assert!(glob_match("plan.md", "plan.md"));
447        assert!(!glob_match("plan.md", "other.md"));
448        assert!(glob_match("*.md", "notes.md"));
449        assert!(!glob_match("*.md", "notes.txt"));
450        // `*` does not cross a path separator.
451        assert!(!glob_match("*.md", "tasks/notes.md"));
452    }
453
454    #[test]
455    fn glob_match_doublestar_spans_dirs() {
456        assert!(glob_match("tasks/**/*.md", "tasks/agent-doc/bugs.md"));
457        assert!(glob_match("tasks/**/*.md", "tasks/a/b/c/deep.md"));
458        // `**` matches zero segments too.
459        assert!(glob_match("tasks/**/*.md", "tasks/flat.md"));
460        assert!(!glob_match("tasks/**/*.md", "other/flat.md"));
461        assert!(glob_match("**/*.md", "anything/here.md"));
462        assert!(glob_match("**", "anything/at/all.md"));
463    }
464
465    #[test]
466    fn glob_match_question_mark() {
467        assert!(glob_match("plan?.md", "plan1.md"));
468        assert!(!glob_match("plan?.md", "plan.md"));
469        assert!(!glob_match("plan?.md", "plan12.md"));
470    }
471
472    #[test]
473    fn plain_md_is_not_a_session_by_default() {
474        let c = cfg(&[], false);
475        assert!(!is_agent_doc_document("notes.md", "# Just notes\n", &c));
476        assert!(!is_agent_doc_document(
477            "tasks/foo.md",
478            "---\ntitle: foo\n---\nbody\n",
479            &c
480        ));
481    }
482
483    #[test]
484    fn frontmatter_agent_doc_field_opts_in() {
485        let c = cfg(&[], false);
486        assert!(is_agent_doc_document(
487            "notes.md",
488            "---\nagent_doc_format: template\n---\nbody\n",
489            &c
490        ));
491        assert!(is_agent_doc_document(
492            "notes.md",
493            "---\nagent_doc_session: 123\n---\nbody\n",
494            &c
495        ));
496        assert!(is_agent_doc_document(
497            "notes.md",
498            "---\nsession: legacy-id\n---\nbody\n",
499            &c
500        ));
501        assert!(is_agent_doc_document(
502            "notes.md",
503            "---\nagent_doc_write: crdt\n---\nbody\n",
504            &c
505        ));
506    }
507
508    #[test]
509    fn include_glob_opts_in() {
510        let c = cfg(&["tasks/**/*.md", "plan.md"], false);
511        assert!(is_agent_doc_document("tasks/agent-doc/x.md", "plain\n", &c));
512        assert!(is_agent_doc_document("plan.md", "plain\n", &c));
513        assert!(is_agent_doc_document("./plan.md", "plain\n", &c));
514        assert!(!is_agent_doc_document("README.md", "plain\n", &c));
515    }
516
517    #[test]
518    fn escape_hatch_opts_in_everything() {
519        let c = cfg(&[], true);
520        assert!(is_agent_doc_document("README.md", "plain notes\n", &c));
521        assert!(is_agent_doc_document("anywhere/file.md", "plain\n", &c));
522    }
523}