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}