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    /// Guard behavior overrides (for example pending-capture enforcement).
271    #[serde(default)]
272    pub guards: GuardConfig,
273    /// Lint behavior overrides for the agent-doc finalize lint gate.
274    #[serde(default)]
275    pub lint: LintConfig,
276    /// Project-local SSH requirement mappings for known ops documents.
277    #[serde(default)]
278    pub ssh: SshConfig,
279    /// Component-specific configuration (patch modes, timestamps, max_entries, hooks).
280    #[serde(default)]
281    pub components: BTreeMap<String, ComponentConfig>,
282    /// Opt-in gating for which `.md` files become agent-doc session documents.
283    #[serde(default)]
284    pub documents: DocumentsConfig,
285    /// Vision (describe-image) provider configuration.
286    #[serde(default)]
287    pub vision: VisionConfig,
288    /// `#stash-session-ttl-prune`: idle TTL in seconds for stash-window agent-doc
289    /// panes. When set (> 0), resync logs which panes *would* be reaped. Set
290    /// `stash_session_ttl_prune_enabled = true` to enable destructive kill.
291    /// Default 0 (disabled).
292    #[serde(default)]
293    pub stash_session_ttl_secs: u64,
294    /// `#stash-session-ttl-prune`: enable destructive kill of idle stash panes
295    /// that exceed `stash_session_ttl_secs`. Report-only when false.
296    /// Default false.
297    #[serde(default)]
298    pub stash_session_ttl_prune_enabled: bool,
299}
300
301/// Parse a TOML string into a [`ProjectConfig`]. Pure — no fs I/O.
302///
303/// File-based loading (with legacy `components.toml` migration) lives in
304/// `crate::project_config_io` in the main `agent-doc` crate.
305pub fn parse_project_toml(content: &str) -> Result<ProjectConfig> {
306    toml::from_str(content).map_err(anyhow::Error::from)
307}
308
309/// Parse a legacy `components.toml` body (flat `[name]` tables of
310/// [`ComponentConfig`] fields) into a map. Used by the file-based migrator
311/// in `crate::project_config_io`.
312pub fn parse_legacy_components_toml(content: &str) -> Result<BTreeMap<String, ComponentConfig>> {
313    toml::from_str(content).map_err(anyhow::Error::from)
314}
315
316/// Opt-in predicate: is this `.md` an agent-doc session document?
317///
318/// Pure — takes the project-relative path, the document content (for
319/// frontmatter inspection), and the resolved project config. Returns `true`
320/// when ANY of the following hold (in cheapest-first order):
321///
322/// 1. `documents.auto_session_for_all_md = true` (legacy escape hatch).
323/// 2. Frontmatter already carries any agent-doc-managed field (see
324///    [`crate::frontmatter::Frontmatter::has_agent_doc_marker`]) — existing
325///    sessions and harness/model/preset config stay sessions.
326/// 3. The project-relative path matches a `documents.include` glob.
327///
328/// A plain `.md` with no agent-doc frontmatter, not matching any configured
329/// glob, with the escape hatch off, returns `false` — callers must fail closed
330/// instead of silently converting it.
331pub fn is_agent_doc_document(rel_path: &str, content: &str, config: &ProjectConfig) -> bool {
332    if config.documents.auto_session_for_all_md {
333        return true;
334    }
335    if let Ok((fm, _)) = crate::frontmatter::parse(content)
336        && fm.has_agent_doc_marker()
337    {
338        return true;
339    }
340    let norm = rel_path.trim_start_matches("./");
341    config
342        .documents
343        .include
344        .iter()
345        .any(|pattern| glob_match(pattern.trim_start_matches("./"), norm))
346}
347
348/// Minimal path-glob matcher supporting `*` (any run of non-`/` chars), `?`
349/// (one non-`/` char), and `**` (spans `/` boundaries, including zero
350/// segments). Both inputs are `/`-separated, project-relative paths. Pure, no
351/// I/O — avoids pulling in a glob crate for the small set of patterns
352/// `[documents] include` needs.
353pub fn glob_match(pattern: &str, path: &str) -> bool {
354    let pat: Vec<&str> = pattern.split('/').collect();
355    let seg: Vec<&str> = path.split('/').collect();
356    glob_match_segments(&pat, &seg)
357}
358
359fn glob_match_segments(pat: &[&str], seg: &[&str]) -> bool {
360    match pat.first() {
361        None => seg.is_empty(),
362        Some(&"**") => {
363            // `**` matches zero or more whole path segments.
364            if pat.len() == 1 {
365                return true;
366            }
367            // Try consuming 0..=seg.len() leading segments.
368            (0..=seg.len()).any(|skip| glob_match_segments(&pat[1..], &seg[skip..]))
369        }
370        Some(first) => match seg.first() {
371            Some(s) if glob_match_segment(first, s) => glob_match_segments(&pat[1..], &seg[1..]),
372            _ => false,
373        },
374    }
375}
376
377/// Match a single path segment against a pattern segment containing `*`/`?`
378/// (neither of which may match a `/`, which never appears inside a segment).
379fn glob_match_segment(pattern: &str, text: &str) -> bool {
380    let pat: Vec<char> = pattern.chars().collect();
381    let txt: Vec<char> = text.chars().collect();
382    let (mut pi, mut ti) = (0usize, 0usize);
383    let (mut star_pi, mut star_ti): (Option<usize>, usize) = (None, 0);
384    while ti < txt.len() {
385        if pi < pat.len() && (pat[pi] == '?' || pat[pi] == txt[ti]) {
386            pi += 1;
387            ti += 1;
388        } else if pi < pat.len() && pat[pi] == '*' {
389            star_pi = Some(pi);
390            star_ti = ti;
391            pi += 1;
392        } else if let Some(sp) = star_pi {
393            pi = sp + 1;
394            star_ti += 1;
395            ti = star_ti;
396        } else {
397            return false;
398        }
399    }
400    while pi < pat.len() && pat[pi] == '*' {
401        pi += 1;
402    }
403    pi == pat.len()
404}
405
406#[cfg(test)]
407mod documents_gate_tests {
408    use super::*;
409
410    fn cfg(include: &[&str], all_md: bool) -> ProjectConfig {
411        ProjectConfig {
412            documents: DocumentsConfig {
413                include: include.iter().map(|s| s.to_string()).collect(),
414                auto_session_for_all_md: all_md,
415            },
416            ..Default::default()
417        }
418    }
419
420    #[test]
421    fn glob_match_literal_and_star() {
422        assert!(glob_match("plan.md", "plan.md"));
423        assert!(!glob_match("plan.md", "other.md"));
424        assert!(glob_match("*.md", "notes.md"));
425        assert!(!glob_match("*.md", "notes.txt"));
426        // `*` does not cross a path separator.
427        assert!(!glob_match("*.md", "tasks/notes.md"));
428    }
429
430    #[test]
431    fn glob_match_doublestar_spans_dirs() {
432        assert!(glob_match("tasks/**/*.md", "tasks/agent-doc/bugs.md"));
433        assert!(glob_match("tasks/**/*.md", "tasks/a/b/c/deep.md"));
434        // `**` matches zero segments too.
435        assert!(glob_match("tasks/**/*.md", "tasks/flat.md"));
436        assert!(!glob_match("tasks/**/*.md", "other/flat.md"));
437        assert!(glob_match("**/*.md", "anything/here.md"));
438        assert!(glob_match("**", "anything/at/all.md"));
439    }
440
441    #[test]
442    fn glob_match_question_mark() {
443        assert!(glob_match("plan?.md", "plan1.md"));
444        assert!(!glob_match("plan?.md", "plan.md"));
445        assert!(!glob_match("plan?.md", "plan12.md"));
446    }
447
448    #[test]
449    fn plain_md_is_not_a_session_by_default() {
450        let c = cfg(&[], false);
451        assert!(!is_agent_doc_document("notes.md", "# Just notes\n", &c));
452        assert!(!is_agent_doc_document(
453            "tasks/foo.md",
454            "---\ntitle: foo\n---\nbody\n",
455            &c
456        ));
457    }
458
459    #[test]
460    fn frontmatter_agent_doc_field_opts_in() {
461        let c = cfg(&[], false);
462        assert!(is_agent_doc_document(
463            "notes.md",
464            "---\nagent_doc_format: template\n---\nbody\n",
465            &c
466        ));
467        assert!(is_agent_doc_document(
468            "notes.md",
469            "---\nagent_doc_session: 123\n---\nbody\n",
470            &c
471        ));
472        assert!(is_agent_doc_document(
473            "notes.md",
474            "---\nsession: legacy-id\n---\nbody\n",
475            &c
476        ));
477        assert!(is_agent_doc_document(
478            "notes.md",
479            "---\nagent_doc_write: crdt\n---\nbody\n",
480            &c
481        ));
482    }
483
484    #[test]
485    fn include_glob_opts_in() {
486        let c = cfg(&["tasks/**/*.md", "plan.md"], false);
487        assert!(is_agent_doc_document("tasks/agent-doc/x.md", "plain\n", &c));
488        assert!(is_agent_doc_document("plan.md", "plain\n", &c));
489        assert!(is_agent_doc_document("./plan.md", "plain\n", &c));
490        assert!(!is_agent_doc_document("README.md", "plain\n", &c));
491    }
492
493    #[test]
494    fn escape_hatch_opts_in_everything() {
495        let c = cfg(&[], true);
496        assert!(is_agent_doc_document("README.md", "plain notes\n", &c));
497        assert!(is_agent_doc_document("anywhere/file.md", "plain\n", &c));
498    }
499}