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}