Skip to main content

ta_submit/
config.rs

1//! Workflow configuration structures
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7/// Top-level workflow configuration from .ta/workflow.toml
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub struct WorkflowConfig {
10    /// Submit adapter configuration
11    #[serde(default)]
12    pub submit: SubmitConfig,
13
14    /// Source sync configuration (v0.11.1)
15    #[serde(default)]
16    pub source: SourceConfig,
17
18    /// Diff viewing configuration
19    #[serde(default)]
20    pub diff: DiffConfig,
21
22    /// Display / output configuration
23    #[serde(default)]
24    pub display: DisplayConfig,
25
26    /// Build configuration
27    #[serde(default)]
28    pub build: BuildConfig,
29
30    /// Garbage collection / lifecycle configuration
31    #[serde(default)]
32    pub gc: GcConfig,
33
34    /// Follow-up goal behavior configuration
35    #[serde(default)]
36    pub follow_up: FollowUpConfig,
37
38    /// Pre-draft verification gate configuration
39    #[serde(default)]
40    pub verify: VerifyConfig,
41
42    /// Shell TUI configuration
43    #[serde(default)]
44    pub shell: ShellConfig,
45
46    /// Desktop notification configuration
47    #[serde(default)]
48    pub notify: NotifyConfig,
49
50    /// Staging directory management (v0.11.3)
51    #[serde(default)]
52    pub staging: StagingConfig,
53
54    /// Constitution / compliance checker configuration (v0.12.0)
55    #[serde(default)]
56    pub constitution: ConstitutionConfig,
57
58    /// Agent sandboxing configuration (v0.14.0)
59    #[serde(default)]
60    pub sandbox: SandboxConfig,
61
62    /// Audit log attestation configuration (v0.14.1)
63    #[serde(default)]
64    pub audit: AuditConfig,
65
66    /// Draft approval governance configuration (v0.14.2)
67    #[serde(default)]
68    pub governance: GovernanceConfig,
69
70    /// VCS configuration (v0.13.17.3)
71    #[serde(default)]
72    pub vcs: VcsConfig,
73
74    /// Plan file configuration (v0.14.12).
75    #[serde(default)]
76    pub plan: PlanConfig,
77
78    /// Supervisor agent configuration (v0.13.17.4)
79    #[serde(default)]
80    pub supervisor: SupervisorConfig,
81
82    /// Draft review configuration (v0.15.4)
83    #[serde(default)]
84    pub draft: DraftReviewConfig,
85
86    /// Workflow behavior configuration (v0.14.3)
87    #[serde(default)]
88    pub workflow: WorkflowSection,
89
90    /// Commands to run after agent exit to produce hard validation evidence (v0.13.17).
91    ///
92    /// Each command is run in the staging workspace. Results are embedded in the
93    /// DraftPackage as `validation_log`. Non-zero exit code blocks `ta draft approve`
94    /// unless `--override` is passed.
95    ///
96    /// Default (empty): no required checks. Set in `.ta/workflow.toml`:
97    /// ```toml
98    /// required_checks = ["cargo build --workspace", "cargo test --workspace"]
99    /// ```
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub required_checks: Vec<String>,
102
103    /// Per-file conflict resolution policy for `ta draft apply` (v0.14.3.5).
104    ///
105    /// Specifies how conflicts are resolved when both the agent and an external
106    /// commit touch the same file. Override globally or per-file/glob:
107    ///
108    /// ```toml
109    /// [apply.conflict_policy]
110    /// default = "merge"            # attempt 3-way merge first
111    /// "PLAN.md" = "keep-source"   # TA owns this — never let agents overwrite
112    /// "Cargo.lock" = "keep-source" # auto-generated, always regenerated by cargo
113    /// "docs/**" = "merge"
114    /// "src/**" = "abort"          # code conflicts need human review
115    /// ```
116    ///
117    /// Valid values: `"abort"`, `"merge"`, `"keep-source"`, `"force-overwrite"`.
118    /// Default when section is absent: the `--conflict-resolution` CLI flag value.
119    #[serde(default)]
120    pub apply: ApplyConfig,
121
122    /// Config-driven TA project/local file classification (v0.14.3.5).
123    ///
124    /// Specifies which `.ta/` files belong to the project (committed to VCS)
125    /// and which are machine-local only (gitignored). At `ta init` time, these
126    /// are written with the defaults from `partitioning.rs`.
127    ///
128    /// Example:
129    /// ```toml
130    /// [ta.project]
131    /// include_paths = ["workflow.toml", "policy.yaml", "agents/"]
132    ///
133    /// [ta.local]
134    /// exclude_paths = ["staging/", "goals/", "store/"]
135    /// ```
136    #[serde(default)]
137    pub ta: TaPathConfig,
138
139    /// Commit auto-staging configuration (v0.14.3.7).
140    ///
141    /// Lists additional files/globs that are always staged alongside a draft
142    /// apply commit, merged with the built-in lock file list. Use this for
143    /// project-specific generated files that are always correct to include.
144    ///
145    /// ```toml
146    /// [commit]
147    /// auto_stage = [
148    ///     "Cargo.lock",
149    ///     ".ta/plan_history.jsonl",
150    ///     "docs/generated/**",
151    /// ]
152    /// ```
153    #[serde(default)]
154    pub commit: CommitConfig,
155
156    /// Project metadata shown in TA Studio (v0.14.18).
157    ///
158    /// ```toml
159    /// [project]
160    /// name = "My Pipeline Project"
161    /// ```
162    #[serde(default)]
163    pub project: ProjectSection,
164
165    /// Per-language static analysis configuration (v0.15.14.3).
166    ///
167    /// Keys are language names (`python`, `typescript`, `rust`, `go`).
168    ///
169    /// ```toml
170    /// [analysis.python]
171    /// tool = "mypy"
172    /// args = ["--strict"]
173    /// on_failure = "agent"
174    /// max_iterations = 3
175    ///
176    /// [analysis.rust]
177    /// tool = "cargo-clippy"
178    /// args = ["-D", "warnings"]
179    /// on_failure = "warn"
180    /// ```
181    #[serde(default)]
182    pub analysis: HashMap<String, ta_goal::analysis::AnalysisConfig>,
183
184    /// Security level profile configuration (v0.15.14.4).
185    ///
186    /// Sets a named preset of security defaults. Individual settings always
187    /// override the level preset.
188    ///
189    /// ```toml
190    /// [security]
191    /// level = "mid"   # "low" | "mid" | "high"
192    /// ```
193    #[serde(default)]
194    pub security: SecurityConfig,
195
196    /// Named agent profiles that supervisor and workflow steps can reference.
197    ///
198    /// ```toml
199    /// [agent_profiles.supervisor]
200    /// framework = "claude"
201    /// model = "claude-sonnet-4-6"
202    /// ```
203    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
204    pub agent_profiles: HashMap<String, AgentProfile>,
205}
206
207/// Commit auto-staging configuration (v0.14.3.7).
208///
209/// Files in `auto_stage` (and the built-in lock file list) are staged
210/// automatically during `ta draft apply --git-commit` even when they
211/// are not in the draft's artifact list.
212#[derive(Debug, Clone, Default, Serialize, Deserialize)]
213pub struct CommitConfig {
214    /// Additional files or glob patterns to auto-stage alongside draft apply commits.
215    ///
216    /// Merged with the built-in lock file list:
217    /// `Cargo.lock`, `package-lock.json`, `go.sum`, `Pipfile.lock`,
218    /// `poetry.lock`, `yarn.lock`, `bun.lockb`, `flake.lock`.
219    ///
220    /// Each entry is matched against working-tree paths using simple glob rules.
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub auto_stage: Vec<String>,
223}
224
225/// Project metadata section in workflow.toml (v0.14.18).
226///
227/// ```toml
228/// [project]
229/// name = "My Pipeline Project"
230/// ```
231#[derive(Debug, Clone, Default, Serialize, Deserialize)]
232#[serde(default)]
233pub struct ProjectSection {
234    /// Human-readable project name shown in TA Studio.
235    pub name: Option<String>,
236}
237
238/// Apply conflict resolution configuration (v0.14.3.5).
239#[derive(Debug, Clone, Default, Serialize, Deserialize)]
240pub struct ApplyConfig {
241    /// Per-file conflict resolution policy.
242    ///
243    /// Keys are glob patterns (or exact filenames) relative to workspace root.
244    /// Values are one of: `"abort"`, `"merge"`, `"keep-source"`, `"force-overwrite"`.
245    ///
246    /// Special key `"default"` sets the fallback for files not matched by any pattern.
247    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
248    pub conflict_policy: std::collections::HashMap<String, String>,
249}
250
251impl ApplyConfig {
252    /// Resolve the conflict policy for a given file path.
253    ///
254    /// Checks patterns in order (most specific first by pattern length).
255    /// Falls back to `default` key, then to `None` (caller uses CLI flag).
256    pub fn policy_for(&self, rel_path: &str) -> Option<&str> {
257        if self.conflict_policy.is_empty() {
258            return None;
259        }
260        // Exact match first.
261        if let Some(v) = self.conflict_policy.get(rel_path) {
262            return Some(v.as_str());
263        }
264        // Glob pattern match — check all patterns, prefer longest matching pattern.
265        let mut best: Option<(&str, usize)> = None;
266        for (pattern, value) in &self.conflict_policy {
267            if pattern == "default" {
268                continue;
269            }
270            if glob_matches(pattern, rel_path) {
271                let specificity = pattern.len();
272                if best.is_none() || specificity > best.unwrap().1 {
273                    best = Some((value.as_str(), specificity));
274                }
275            }
276        }
277        if let Some((v, _)) = best {
278            return Some(v);
279        }
280        // Default key fallback.
281        self.conflict_policy.get("default").map(|s| s.as_str())
282    }
283}
284
285/// Simple glob matcher supporting `*` (any single component) and `**` (any path).
286fn glob_matches(pattern: &str, path: &str) -> bool {
287    if pattern.ends_with("/**") || pattern.ends_with("/*") {
288        let prefix = pattern.trim_end_matches('*').trim_end_matches('/');
289        return path.starts_with(&format!("{}/", prefix)) || path.starts_with(prefix);
290    }
291    if let Some(suffix) = pattern.strip_prefix("**/") {
292        return path.ends_with(suffix) || path.contains(&format!("/{}", suffix));
293    }
294    if pattern.contains('*') {
295        // Simple wildcard: replace * with "match anything in a component".
296        let re_pat = pattern.replace('.', "\\.").replace('*', "[^/]*");
297        return regex_lite_match(&re_pat, path);
298    }
299    false
300}
301
302fn regex_lite_match(pattern: &str, text: &str) -> bool {
303    // Minimal regex-like matching without external crates.
304    // Only handles patterns from glob_matches above.
305    // Anchored to the full string.
306    let anchored = format!("^{}$", pattern);
307    let re = anchored;
308    // Manual matching: split on [^/]* and check surrounding context.
309    let parts: Vec<&str> = re.split("[^/]*").collect();
310    if parts.len() == 1 {
311        return text == pattern;
312    }
313    // For simple patterns like "Cargo\\.lock": try ends_with / exact.
314    let plain = pattern.replace("\\.", ".").replace("[^/]*", "*");
315    let plain_parts: Vec<&str> = plain.split('*').collect();
316    let mut pos = 0;
317    for (i, part) in plain_parts.iter().enumerate() {
318        if part.is_empty() {
319            continue;
320        }
321        if i == 0 {
322            if !text.starts_with(part) {
323                return false;
324            }
325            pos = part.len();
326        } else if i == plain_parts.len() - 1 {
327            return text[pos..].ends_with(part);
328        } else if let Some(idx) = text[pos..].find(part) {
329            pos += idx + part.len();
330        } else {
331            return false;
332        }
333    }
334    true
335}
336
337/// TA project/local file classification configuration (v0.14.3.5).
338///
339/// Specifies which `.ta/` files belong to the project (committed to VCS)
340/// and which are machine-local only (gitignored). Populated at `ta init`.
341#[derive(Debug, Clone, Default, Serialize, Deserialize)]
342pub struct TaPathConfig {
343    /// `.ta/` file paths that belong to the project (VCS-committed, shared with team).
344    #[serde(default)]
345    pub project: TaProjectPaths,
346    /// `.ta/` file paths that are machine-local only (gitignored, never shared).
347    #[serde(default)]
348    pub local: TaLocalPaths,
349}
350
351/// Project-scoped `.ta/` paths (committed to VCS).
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct TaProjectPaths {
354    /// Paths inside `.ta/` to include in VCS. Defaults to `SHARED_TA_PATHS`.
355    #[serde(default = "default_project_include_paths")]
356    pub include_paths: Vec<String>,
357}
358
359impl Default for TaProjectPaths {
360    fn default() -> Self {
361        Self {
362            include_paths: default_project_include_paths(),
363        }
364    }
365}
366
367fn default_project_include_paths() -> Vec<String> {
368    // Mirror of ta-workspace::partitioning::SHARED_TA_PATHS — kept in sync manually.
369    // These are `.ta/` paths committed to VCS and shared with the team.
370    vec![
371        "workflow.toml".to_string(),
372        "policy.yaml".to_string(),
373        "constitution.toml".to_string(),
374        "memory.toml".to_string(),
375        "bmad.toml".to_string(),
376        "agents/".to_string(),
377        "constitutions/".to_string(),
378        "memory/".to_string(),
379        "templates/".to_string(),
380        "plan_history.jsonl".to_string(),
381        "release-history.json".to_string(),
382    ]
383}
384
385/// Local-scoped `.ta/` paths (gitignored, never shared).
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct TaLocalPaths {
388    /// Paths inside `.ta/` to exclude from VCS. Defaults to `LOCAL_TA_PATHS`.
389    #[serde(default = "default_local_exclude_paths")]
390    pub exclude_paths: Vec<String>,
391}
392
393impl Default for TaLocalPaths {
394    fn default() -> Self {
395        Self {
396            exclude_paths: default_local_exclude_paths(),
397        }
398    }
399}
400
401fn default_local_exclude_paths() -> Vec<String> {
402    // Mirror of ta-workspace::partitioning::LOCAL_TA_PATHS — kept in sync manually.
403    // These are `.ta/` paths that are machine-local only (gitignored, never shared).
404    vec![
405        "daemon.toml".to_string(),
406        "daemon.local.toml".to_string(),
407        "workflow.local.toml".to_string(),
408        "local.workflow.toml".to_string(), // deprecated name — kept so existing files stay gitignored
409        "memory.rvf".to_string(),
410        "staging/".to_string(),
411        "store/".to_string(),
412        "goals/".to_string(),
413        "events/".to_string(),
414        "sessions/".to_string(),
415        "release.lock".to_string(),
416        "velocity-stats.jsonl".to_string(),
417        "audit-ledger.jsonl".to_string(),
418        "taignore".to_string(),
419        "interactions/".to_string(),
420    ]
421}
422
423/// Constitution / compliance checker configuration.
424///
425/// Controls which project-specific checkers run during `ta draft build`.
426/// These are disabled by default so non-TA projects don't receive
427/// TA-internal checks. The TA repo enables them via `.ta/workflow.toml`.
428///
429/// ```toml
430/// [constitution]
431/// s4_scan = true  # scan for inject_*/restore_* imbalance (§4)
432/// ```
433#[derive(Debug, Clone, Default, Serialize, Deserialize)]
434pub struct ConstitutionConfig {
435    /// Run the §4 inject/restore balance scanner on changed .rs files.
436    ///
437    /// Default: `false`. Enable in `.ta/workflow.toml` for the TA repo.
438    /// External projects should leave this unset.
439    #[serde(default)]
440    pub s4_scan: bool,
441}
442
443/// Agent sandboxing configuration (v0.14.0).
444///
445/// Controls whether agents run in a sandboxed process environment that limits
446/// filesystem access, network reach, and syscall surface.
447///
448/// ```toml
449/// [sandbox]
450/// enabled = true
451/// provider = "native"   # "native" (OS sandbox-exec/landlock) | "openshell" | "oci"
452///
453/// # Paths the agent is allowed to read (in addition to its working dir)
454/// allow_read = ["/usr/lib", "/etc/ssl"]
455///
456/// # Paths the agent is allowed to write (staging workspace is always included)
457/// allow_write = []
458///
459/// # Hostnames/CIDR ranges the agent may connect to. Empty = block all network.
460/// allow_network = ["api.anthropic.com", "api.github.com"]
461/// ```
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct SandboxConfig {
464    /// Whether sandboxing is enabled. Default: false (safe default — no breakage on upgrade).
465    #[serde(default)]
466    pub enabled: bool,
467
468    /// Sandbox provider. Default: "native" (macOS sandbox-exec or Linux landlock/seccomp).
469    #[serde(default = "default_sandbox_provider")]
470    pub provider: String,
471
472    /// Additional paths the agent may read (beyond its working dir and /usr, /lib, /etc/ssl).
473    #[serde(default)]
474    pub allow_read: Vec<String>,
475
476    /// Additional writable paths (the staging workspace root is always writable).
477    #[serde(default)]
478    pub allow_write: Vec<String>,
479
480    /// Network destinations the agent is allowed to reach. Empty = block all outbound.
481    /// Entries may be hostnames, IPs, or CIDR blocks (e.g., "api.anthropic.com", "10.0.0.0/8").
482    #[serde(default)]
483    pub allow_network: Vec<String>,
484}
485
486fn default_sandbox_provider() -> String {
487    "native".to_string()
488}
489
490impl Default for SandboxConfig {
491    fn default() -> Self {
492        Self {
493            enabled: false,
494            provider: default_sandbox_provider(),
495            allow_read: Vec::new(),
496            allow_write: Vec::new(),
497            allow_network: Vec::new(),
498        }
499    }
500}
501
502/// Audit log attestation configuration (v0.14.1).
503///
504/// ```toml
505/// [audit]
506/// attestation = true
507/// # keys_dir defaults to .ta/keys/ (relative to workspace root)
508/// keys_dir = ".ta/keys"
509/// ```
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct AuditConfig {
512    /// Enable Ed25519 attestation signing of every audit event.
513    /// Keys are auto-generated in `keys_dir` on first use.
514    #[serde(default)]
515    pub attestation: bool,
516
517    /// Directory for attestation key files.
518    /// Defaults to `.ta/keys` (relative to workspace root).
519    #[serde(default = "default_keys_dir")]
520    pub keys_dir: String,
521}
522
523fn default_keys_dir() -> String {
524    ".ta/keys".to_string()
525}
526
527impl Default for AuditConfig {
528    fn default() -> Self {
529        Self {
530            attestation: false,
531            keys_dir: default_keys_dir(),
532        }
533    }
534}
535
536/// Draft approval governance configuration (v0.14.2).
537///
538/// Controls how many approvals a draft requires before it can be applied,
539/// and which identities are permitted to approve.
540///
541/// ```toml
542/// [governance]
543/// require_approvals = 2
544/// approvers = ["alice", "bob", "charlie"]
545/// # override_identity allows emergency bypass (logged to audit trail).
546/// override_identity = "emergency-admin"
547/// ```
548#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct GovernanceConfig {
550    /// Minimum number of distinct approvals required before a draft can be applied.
551    /// Default: 1 (single-approver, backward-compatible).
552    #[serde(default = "default_require_approvals")]
553    pub require_approvals: usize,
554
555    /// Allowlist of reviewer identities permitted to approve.
556    /// Empty list = any reviewer is accepted (default, backward-compatible).
557    #[serde(default)]
558    pub approvers: Vec<String>,
559
560    /// Identity allowed to use `--override` to bypass the quorum requirement.
561    /// The override is recorded in the audit log for accountability.
562    #[serde(default)]
563    pub override_identity: Option<String>,
564}
565
566fn default_require_approvals() -> usize {
567    1
568}
569
570impl Default for GovernanceConfig {
571    fn default() -> Self {
572        Self {
573            require_approvals: default_require_approvals(),
574            approvers: Vec::new(),
575            override_identity: None,
576        }
577    }
578}
579
580/// VCS environment isolation configuration for spawned agents (v0.13.17.3).
581///
582/// Controls how TA configures the agent's VCS environment so it operates
583/// on the staging directory instead of the developer's real repository.
584///
585/// ```toml
586/// [vcs.agent]
587/// git_mode = "isolated"   # "isolated" | "inherit-read" | "none"
588/// p4_mode = "shelve"      # "shelve" | "read-only" | "inherit"
589/// init_baseline_commit = true
590/// ceiling_always = true
591/// ```
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct VcsAgentConfig {
594    /// Git isolation mode.
595    ///
596    /// - `"isolated"` (default): `git init` in staging with a baseline commit.
597    ///   Agent gets its own isolated `.git`. `GIT_CEILING_DIRECTORIES` blocks
598    ///   upward traversal into the developer's real repo.
599    /// - `"inherit-read"`: Sets `GIT_CEILING_DIRECTORIES` only. Agent can read
600    ///   parent git history (log, blame) but write operations are scoped away.
601    /// - `"none"`: Sets `GIT_DIR=/dev/null`. All git operations fail immediately.
602    #[serde(default = "default_git_mode")]
603    pub git_mode: String,
604
605    /// Perforce isolation mode.
606    ///
607    /// - `"shelve"` (default): Agent uses a dedicated staging P4 workspace.
608    ///   Submit is blocked; shelve is allowed.
609    /// - `"read-only"`: Injects `P4CLIENT=` (empty). No P4 writes possible.
610    /// - `"inherit"`: Agent inherits the developer's P4CLIENT. Only for
611    ///   workflows that explicitly need live P4 access.
612    #[serde(default = "default_p4_mode")]
613    pub p4_mode: String,
614
615    /// Whether to create an initial "pre-agent" baseline commit in isolated git mode.
616    ///
617    /// When `true` (default), `git init` + `git add -A` + `git commit -m "pre-agent baseline"`
618    /// runs before the agent starts. The agent can then use `git diff`, `git log`, etc.
619    /// against a clean history.
620    #[serde(default = "default_true")]
621    pub init_baseline_commit: bool,
622
623    /// Whether to always set `GIT_CEILING_DIRECTORIES` regardless of mode.
624    ///
625    /// When `true` (default), `GIT_CEILING_DIRECTORIES` is set to the staging dir's
626    /// parent even in `inherit-read` and `isolated` modes, preventing git from
627    /// traversing into parent directories beyond the ceiling.
628    #[serde(default = "default_true")]
629    pub ceiling_always: bool,
630}
631
632fn default_git_mode() -> String {
633    "isolated".to_string()
634}
635fn default_p4_mode() -> String {
636    "shelve".to_string()
637}
638fn default_true() -> bool {
639    true
640}
641
642impl Default for VcsAgentConfig {
643    fn default() -> Self {
644        Self {
645            git_mode: default_git_mode(),
646            p4_mode: default_p4_mode(),
647            init_baseline_commit: true,
648            ceiling_always: true,
649        }
650    }
651}
652
653/// VCS configuration section (v0.13.17.3).
654#[derive(Debug, Clone, Default, Serialize, Deserialize)]
655pub struct VcsConfig {
656    /// Agent environment isolation settings.
657    #[serde(default)]
658    pub agent: VcsAgentConfig,
659}
660
661/// Plan file configuration (v0.14.12).
662///
663/// Allows projects to name their plan file something other than `PLAN.md`.
664///
665/// ```toml
666/// [plan]
667/// file = "ROADMAP.md"
668/// ```
669#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct PlanConfig {
671    /// Name of the plan file relative to workspace root. Default: "PLAN.md".
672    #[serde(default = "default_plan_file")]
673    pub file: String,
674}
675
676impl Default for PlanConfig {
677    fn default() -> Self {
678        Self {
679            file: default_plan_file(),
680        }
681    }
682}
683
684fn default_plan_file() -> String {
685    "PLAN.md".to_string()
686}
687
688/// Resolve the plan file path given a workspace root and workflow config.
689///
690/// Respects the `[plan] file` config setting so projects can name their
691/// plan file something other than the default `PLAN.md`.
692pub fn resolve_plan_path(
693    workspace_root: &std::path::Path,
694    config: &WorkflowConfig,
695) -> std::path::PathBuf {
696    workspace_root.join(&config.plan.file)
697}
698
699/// Supervisor agent configuration (v0.13.17.4).
700///
701/// Controls the AI-powered review that runs after the main agent exits
702/// but before `ta draft build`. The supervisor checks goal alignment
703/// and constitution compliance.
704///
705/// ```toml
706/// [supervisor]
707/// enabled = true
708/// agent = "builtin"              # "builtin" | "claude-code" | "codex" | "ollama" | manifest name
709/// verdict_on_block = "warn"      # "warn" | "block"
710/// constitution_path = ".ta/constitution.toml"
711/// skip_if_no_constitution = true
712/// heartbeat_stale_secs = 30     # kill if no token received for this long (replaces timeout_secs)
713/// # timeout_secs = 120           # deprecated — use heartbeat_stale_secs instead
714/// # api_key_env = "OPENAI_API_KEY"  # optional: pre-flight check for codex / custom agents
715/// ```
716#[derive(Debug, Clone, Serialize, Deserialize)]
717pub struct SupervisorConfig {
718    /// Enable the supervisor agent. Default: true when any agent is configured.
719    #[serde(default = "default_supervisor_enabled")]
720    pub enabled: bool,
721
722    /// Which agent runs the review.
723    /// - "builtin" / "claude-code": spawns the `claude` CLI (uses its own auth).
724    /// - "codex": spawns `codex --approval-mode full-auto --quiet`.
725    /// - "ollama": invokes via `ta agent run ollama --headless`.
726    /// - any other string: looks up `.ta/agents/<name>.toml` manifest.
727    #[serde(default = "default_supervisor_agent")]
728    pub agent: String,
729
730    /// Behavior when verdict is Block. "warn" = show in draft view only.
731    /// "block" = refuse `ta draft approve` without `--override`.
732    #[serde(default = "default_verdict_on_block")]
733    pub verdict_on_block: String,
734
735    /// Path to the project constitution file (relative to workspace root).
736    /// If absent, falls back to `.ta/constitution.toml`, then `docs/TA-CONSTITUTION.md`.
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub constitution_path: Option<std::path::PathBuf>,
739
740    /// Don't fail if the constitution file is absent.
741    #[serde(default = "default_supervisor_skip_no_constitution")]
742    pub skip_if_no_constitution: bool,
743
744    /// Kill supervisor if no token is received for this many seconds (default 30).
745    ///
746    /// Replaces the wall-clock `timeout_secs`: a supervisor actively streaming a large diff
747    /// will never be killed — only one that stops producing output for `heartbeat_stale_secs`
748    /// is terminated. Set higher (e.g. 60) if your supervisor uses a slow model.
749    #[serde(default = "default_supervisor_heartbeat_stale_secs")]
750    pub heartbeat_stale_secs: u64,
751
752    /// Deprecated: wall-clock timeout in seconds. Accepted for backward compatibility.
753    /// When present, emits a deprecation warning and the value is ignored in favour of
754    /// `heartbeat_stale_secs`. Will be removed in a future version.
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub timeout_secs: Option<u64>,
757
758    /// Optional env var name to pre-flight check before spawning the supervisor agent.
759    /// When set, TA verifies the var is present and prints an actionable message if missing.
760    /// The agent binary handles the credential itself — TA never reads or forwards the value.
761    /// Example: `api_key_env = "OPENAI_API_KEY"` for the codex agent.
762    #[serde(default, skip_serializing_if = "Option::is_none")]
763    pub api_key_env: Option<String>,
764
765    /// Optional agent profile name from `[agent_profiles]`. When set, resolves the
766    /// `framework` and `model` for the supervisor from the profile table, overriding
767    /// the bare `agent` string. Any registered framework works — not just claude.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub agent_profile: Option<String>,
770
771    /// Allow session hooks to fire in the supervisor subprocess. Default: false.
772    ///
773    /// By default, TA sets `CLAUDE_CODE_DISABLE_HOOKS=1` when spawning the supervisor
774    /// so that `SessionStart` and other hooks do not write JSON to stdout (which could
775    /// be mistaken for supervisor content and trigger false stall timeouts). Set to
776    /// `true` only if a custom hook must run during supervisor invocations.
777    #[serde(default)]
778    pub enable_hooks: bool,
779}
780
781fn default_supervisor_enabled() -> bool {
782    true
783}
784fn default_supervisor_agent() -> String {
785    "builtin".to_string()
786}
787fn default_verdict_on_block() -> String {
788    "warn".to_string()
789}
790fn default_supervisor_heartbeat_stale_secs() -> u64 {
791    30
792}
793fn default_supervisor_skip_no_constitution() -> bool {
794    true
795}
796
797impl Default for SupervisorConfig {
798    fn default() -> Self {
799        Self {
800            enabled: default_supervisor_enabled(),
801            agent: default_supervisor_agent(),
802            verdict_on_block: default_verdict_on_block(),
803            constitution_path: None,
804            skip_if_no_constitution: default_supervisor_skip_no_constitution(),
805            heartbeat_stale_secs: default_supervisor_heartbeat_stale_secs(),
806            timeout_secs: None,
807            api_key_env: None,
808            agent_profile: None,
809            enable_hooks: false,
810        }
811    }
812}
813
814/// Named agent profile — associates a framework name with an optional model override.
815///
816/// Used by `[agent_profiles]` in `workflow.toml`:
817/// ```toml
818/// [agent_profiles.supervisor]
819/// framework = "claude"
820/// model = "claude-sonnet-4-6"
821/// ```
822#[derive(Debug, Clone, Serialize, Deserialize, Default)]
823pub struct AgentProfile {
824    /// Agent framework: "claude" / "claude-code", "codex", "ollama", or a manifest name.
825    #[serde(default)]
826    pub framework: String,
827    /// Optional model override forwarded to the agent binary (e.g. `--model <value>`).
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub model: Option<String>,
830}
831
832/// Asset diff configuration for `[draft.asset_diff]` in `workflow.toml` (v0.15.4).
833///
834/// Controls whether `ta draft view` runs an agent diff summary and supervisor
835/// confidence check for image and video artifacts.
836///
837/// ```toml
838/// [draft.asset_diff]
839/// enabled = true
840/// supervisor = true
841/// visual_diff = false
842/// visual_diff_threshold = 0.3
843/// agent = "builtin"
844/// timeout_secs = 60
845/// ```
846#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct AssetDiffConfig {
848    /// Enable agent diff summaries for image/video artifacts (default: true).
849    #[serde(default = "default_asset_diff_enabled")]
850    pub enabled: bool,
851
852    /// Write a visual diff placeholder file alongside the review (default: false).
853    #[serde(default)]
854    pub visual_diff: bool,
855
856    /// Threshold (0–1) for classifying localized vs. global changes (default: 0.3).
857    #[serde(default = "default_visual_diff_threshold")]
858    pub visual_diff_threshold: f32,
859
860    /// Run supervisor confidence check (default: true).
861    #[serde(default = "default_asset_diff_supervisor")]
862    pub supervisor: bool,
863
864    /// Agent binary: "builtin"/"claude-code" → `claude` CLI, others by name (default: "builtin").
865    #[serde(default = "default_asset_diff_agent")]
866    pub agent: String,
867
868    /// Timeout per agent call in seconds (default: 60).
869    #[serde(default = "default_asset_diff_timeout")]
870    pub timeout_secs: u64,
871}
872
873fn default_asset_diff_enabled() -> bool {
874    true
875}
876fn default_visual_diff_threshold() -> f32 {
877    0.3
878}
879fn default_asset_diff_supervisor() -> bool {
880    true
881}
882fn default_asset_diff_agent() -> String {
883    "builtin".to_string()
884}
885fn default_asset_diff_timeout() -> u64 {
886    60
887}
888
889impl Default for AssetDiffConfig {
890    fn default() -> Self {
891        Self {
892            enabled: default_asset_diff_enabled(),
893            visual_diff: false,
894            visual_diff_threshold: default_visual_diff_threshold(),
895            supervisor: default_asset_diff_supervisor(),
896            agent: default_asset_diff_agent(),
897            timeout_secs: default_asset_diff_timeout(),
898        }
899    }
900}
901
902/// Draft review configuration (groups settings that apply during `ta draft view`).
903#[derive(Debug, Clone, Default, Serialize, Deserialize)]
904pub struct DraftReviewConfig {
905    /// Asset diff configuration (v0.15.4).
906    #[serde(default)]
907    pub asset_diff: AssetDiffConfig,
908
909    /// Whether an explicit `ta draft approve` step is required before applying (v0.15.14.0).
910    ///
911    /// - `false` (default): single-author flow — `ta draft apply` accepts `PendingReview`
912    ///   directly, auto-approving on apply. No separate approve step needed.
913    /// - `true`: multi-author flow — `ta draft apply` requires `Approved` state; applying
914    ///   from `PendingReview` errors with a clear message directing the user to approve first.
915    ///
916    /// ```toml
917    /// [draft]
918    /// approval_required = false   # set true for multi-author approval workflows
919    /// ```
920    #[serde(default)]
921    pub approval_required: bool,
922}
923
924/// Context injection mode for CLAUDE.md (v0.14.3.2).
925///
926/// Controls how plan and community context are delivered to the agent:
927///
928/// - `inject` (default): Inject plan + community context directly into CLAUDE.md.
929/// - `mcp`: Zero-injection — skip plan + community from CLAUDE.md entirely.
930///   Register `ta_plan_status` and community hub as MCP tools instead.
931///   Recommended for projects with large plans (>50 phases) or many community resources.
932/// - `hybrid`: Skip plan + community from CLAUDE.md, still inject memory context and
933///   the original CLAUDE.md. Adds a one-line note pointing to the MCP tools.
934///   Recommended for projects with large plans where agents support tool calling.
935///
936/// ```toml
937/// [workflow]
938/// context_mode = "inject"  # "inject" | "mcp" | "hybrid"
939/// ```
940#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
941#[serde(rename_all = "lowercase")]
942pub enum ContextMode {
943    /// Inject plan + community context into CLAUDE.md (default, current behavior).
944    #[default]
945    Inject,
946    /// Zero-injection: skip plan + community from CLAUDE.md; register as MCP tools instead.
947    Mcp,
948    /// Inject memory + CLAUDE.md only; skip plan + community. Add a one-line tool hint.
949    Hybrid,
950}
951
952/// Workflow behavior configuration (v0.14.3).
953///
954/// Controls plan phase ordering enforcement and related guardrails.
955///
956/// ```toml
957/// [workflow]
958/// enforce_phase_order = "warn"  # "warn" | "block" | "off"
959/// ```
960#[derive(Debug, Clone, Serialize, Deserialize)]
961pub struct WorkflowSection {
962    /// Phase ordering enforcement mode.
963    ///
964    /// - `"warn"` (default): Print a warning when starting a goal for a phase
965    ///   that has an earlier pending phase, but allow the goal to proceed.
966    /// - `"block"`: Prompt the user to confirm before proceeding. In
967    ///   non-interactive (headless) mode, behaves like `"warn"`.
968    /// - `"off"`: Skip the check entirely.
969    #[serde(default = "default_enforce_phase_order")]
970    pub enforce_phase_order: String,
971
972    /// Maximum character budget for the injected CLAUDE.md context (v0.14.3.1).
973    ///
974    /// When the assembled injection exceeds this limit, sections are trimmed
975    /// in priority order (solutions → parent context → memory → plan window)
976    /// until it fits. Set to 0 to disable trimming.
977    ///
978    /// Default: 40,000 characters.
979    ///
980    /// ```toml
981    /// [workflow]
982    /// context_budget_chars = 40000
983    /// ```
984    #[serde(default = "default_context_budget_chars")]
985    pub context_budget_chars: usize,
986
987    /// Number of completed phases to show individually before the current
988    /// phase in the windowed plan checklist (v0.14.3.1).
989    ///
990    /// Default: 5
991    #[serde(default = "default_plan_done_window")]
992    pub plan_done_window: usize,
993
994    /// Number of pending phases to show individually after the current
995    /// phase in the windowed plan checklist (v0.14.3.1).
996    ///
997    /// Default: 5
998    #[serde(default = "default_plan_pending_window")]
999    pub plan_pending_window: usize,
1000
1001    /// Context injection mode (v0.14.3.2).
1002    ///
1003    /// Controls whether plan + community context are injected into CLAUDE.md
1004    /// or served exclusively via MCP tools (`ta_plan_status`, `community_search`).
1005    ///
1006    /// Default: `inject` (current behavior — no change for existing projects).
1007    ///
1008    /// ```toml
1009    /// [workflow]
1010    /// context_mode = "hybrid"
1011    /// ```
1012    #[serde(default)]
1013    pub context_mode: ContextMode,
1014}
1015
1016fn default_enforce_phase_order() -> String {
1017    "warn".to_string()
1018}
1019
1020fn default_context_budget_chars() -> usize {
1021    40_000
1022}
1023
1024fn default_plan_done_window() -> usize {
1025    5
1026}
1027
1028fn default_plan_pending_window() -> usize {
1029    5
1030}
1031
1032impl Default for WorkflowSection {
1033    fn default() -> Self {
1034        Self {
1035            enforce_phase_order: default_enforce_phase_order(),
1036            context_budget_chars: default_context_budget_chars(),
1037            plan_done_window: default_plan_done_window(),
1038            plan_pending_window: default_plan_pending_window(),
1039            context_mode: ContextMode::default(),
1040        }
1041    }
1042}
1043
1044/// Submit adapter configuration
1045#[derive(Debug, Clone, Serialize, Deserialize)]
1046pub struct SubmitConfig {
1047    /// Adapter type: "git", "svn", "perforce", or "none"
1048    #[serde(default = "default_adapter")]
1049    pub adapter: String,
1050
1051    /// Run full submit workflow (stage + submit) on `ta draft apply`.
1052    /// Default: true when adapter != "none". `--no-submit` overrides.
1053    /// Replaces the deprecated `auto_commit` + `auto_push` pair.
1054    #[serde(default)]
1055    pub auto_submit: Option<bool>,
1056
1057    /// Auto-create review (PR/CL review) after submit.
1058    /// Default: true when adapter != "none".
1059    #[serde(default)]
1060    pub auto_review: Option<bool>,
1061
1062    /// Co-author trailer appended to every commit made through TA.
1063    /// Format: "Name <email>". The email should match a GitHub account's
1064    /// verified email for the contribution to appear in GitHub's graph.
1065    /// Set to empty string to disable. Default: "Trusted Autonomy <ta@trustedautonomy.dev>"
1066    #[serde(default = "default_co_author")]
1067    pub co_author: String,
1068
1069    /// Git-specific configuration
1070    #[serde(default)]
1071    pub git: GitConfig,
1072
1073    /// Perforce-specific configuration
1074    #[serde(default)]
1075    pub perforce: PerforceConfig,
1076
1077    /// SVN-specific configuration
1078    #[serde(default)]
1079    pub svn: SvnConfig,
1080}
1081
1082impl SubmitConfig {
1083    /// Whether the full submit workflow should run by default.
1084    ///
1085    /// Resolution order:
1086    /// 1. `auto_submit` if explicitly set
1087    /// 2. `true` when adapter is not "none" (default behavior)
1088    pub fn effective_auto_submit(&self) -> bool {
1089        self.auto_submit.unwrap_or(self.adapter != "none")
1090    }
1091
1092    /// Whether review should be opened after submit.
1093    ///
1094    /// Resolution: explicit `auto_review` > default (true when adapter != "none").
1095    pub fn effective_auto_review(&self) -> bool {
1096        self.auto_review.unwrap_or(self.adapter != "none")
1097    }
1098}
1099
1100impl Default for SubmitConfig {
1101    fn default() -> Self {
1102        Self {
1103            adapter: default_adapter(),
1104            auto_submit: None,
1105            auto_review: None,
1106            co_author: default_co_author(),
1107            git: GitConfig::default(),
1108            perforce: PerforceConfig::default(),
1109            svn: SvnConfig::default(),
1110        }
1111    }
1112}
1113
1114/// Perforce adapter configuration
1115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1116pub struct PerforceConfig {
1117    /// Perforce workspace/client name
1118    pub workspace: Option<String>,
1119
1120    /// Shelve changes instead of submitting to depot. Default: true.
1121    #[serde(default = "default_shelve")]
1122    pub shelve_by_default: bool,
1123}
1124
1125fn default_shelve() -> bool {
1126    true
1127}
1128
1129/// SVN adapter configuration
1130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1131pub struct SvnConfig {
1132    /// SVN repository URL (for commit messages / metadata)
1133    pub repo_url: Option<String>,
1134}
1135
1136/// Source-level configuration section (`[source]` in workflow.toml).
1137///
1138/// Groups adapter-agnostic sync settings. Provider-specific options
1139/// live in `[submit.git]`, `[submit.svn]`, etc.
1140#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1141pub struct SourceConfig {
1142    /// Sync behavior configuration.
1143    #[serde(default)]
1144    pub sync: SyncConfig,
1145}
1146
1147/// Sync upstream configuration (`[source.sync]` in workflow.toml).
1148#[derive(Debug, Clone, Serialize, Deserialize)]
1149pub struct SyncConfig {
1150    /// Automatically sync upstream after `ta draft apply` succeeds.
1151    /// Default: false.
1152    #[serde(default)]
1153    pub auto_sync: bool,
1154
1155    /// Git sync strategy: "merge" (default), "rebase", or "ff-only".
1156    /// Other adapters ignore this field.
1157    #[serde(default = "default_sync_strategy")]
1158    pub strategy: String,
1159
1160    /// Remote name to sync from. Default: "origin".
1161    #[serde(default = "default_remote")]
1162    pub remote: String,
1163
1164    /// Branch to sync from. Default: "main".
1165    #[serde(default = "default_sync_branch")]
1166    pub branch: String,
1167}
1168
1169impl Default for SyncConfig {
1170    fn default() -> Self {
1171        Self {
1172            auto_sync: false,
1173            strategy: default_sync_strategy(),
1174            remote: default_remote(),
1175            branch: default_sync_branch(),
1176        }
1177    }
1178}
1179
1180fn default_sync_strategy() -> String {
1181    "merge".to_string()
1182}
1183
1184fn default_sync_branch() -> String {
1185    "main".to_string()
1186}
1187
1188/// Git adapter configuration
1189#[derive(Debug, Clone, Serialize, Deserialize)]
1190pub struct GitConfig {
1191    /// Branch naming prefix (e.g., "ta/", "feature/")
1192    #[serde(default = "default_branch_prefix")]
1193    pub branch_prefix: String,
1194
1195    /// Target branch for PRs (e.g., "main", "develop")
1196    #[serde(default = "default_target_branch")]
1197    pub target_branch: String,
1198
1199    /// Merge strategy: "squash", "merge", "rebase"
1200    #[serde(default = "default_merge_strategy")]
1201    pub merge_strategy: String,
1202
1203    /// Path to PR body template (optional)
1204    pub pr_template: Option<PathBuf>,
1205
1206    /// Git remote name
1207    #[serde(default = "default_remote")]
1208    pub remote: String,
1209
1210    /// Enable GitHub auto-merge after PR creation (v0.11.2.3).
1211    /// When true, runs `gh pr merge --auto --squash` after `gh pr create`.
1212    #[serde(default)]
1213    pub auto_merge: bool,
1214
1215    /// Protected branches that agents must never commit to directly (§15).
1216    /// Defaults to ["main", "master", "trunk", "dev"] when empty.
1217    #[serde(default)]
1218    pub protected_branches: Vec<String>,
1219}
1220
1221impl Default for GitConfig {
1222    fn default() -> Self {
1223        Self {
1224            branch_prefix: default_branch_prefix(),
1225            target_branch: default_target_branch(),
1226            merge_strategy: default_merge_strategy(),
1227            pr_template: None,
1228            remote: default_remote(),
1229            auto_merge: false,
1230            protected_branches: vec![],
1231        }
1232    }
1233}
1234
1235// Serde default functions
1236fn default_adapter() -> String {
1237    "none".to_string()
1238}
1239
1240fn default_co_author() -> String {
1241    "Trusted Autonomy <266386695+trustedautonomy-agent@users.noreply.github.com>".to_string()
1242}
1243
1244fn default_branch_prefix() -> String {
1245    "ta/".to_string()
1246}
1247
1248fn default_target_branch() -> String {
1249    "main".to_string()
1250}
1251
1252fn default_merge_strategy() -> String {
1253    "squash".to_string()
1254}
1255
1256fn default_remote() -> String {
1257    "origin".to_string()
1258}
1259
1260/// Diff viewing configuration
1261#[derive(Debug, Clone, Serialize, Deserialize)]
1262pub struct DiffConfig {
1263    /// Open files in external handlers by default when using `ta pr view --file`
1264    #[serde(default = "default_open_external")]
1265    pub open_external: bool,
1266
1267    /// Optional path override for diff-handlers.toml (defaults to .ta/diff-handlers.toml)
1268    pub handlers_file: Option<PathBuf>,
1269}
1270
1271impl Default for DiffConfig {
1272    fn default() -> Self {
1273        Self {
1274            open_external: default_open_external(),
1275            handlers_file: None,
1276        }
1277    }
1278}
1279
1280fn default_open_external() -> bool {
1281    true
1282}
1283
1284/// Failure handling strategy for build commands.
1285#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1286#[serde(rename_all = "snake_case")]
1287pub enum BuildOnFail {
1288    /// Notify the user but continue (default).
1289    #[default]
1290    Notify,
1291    /// Block release pipeline if build/test fails.
1292    BlockRelease,
1293    /// Block advancement to the next plan phase.
1294    BlockNextPhase,
1295    /// Re-launch an agent to fix the issue.
1296    Agent,
1297}
1298
1299impl std::fmt::Display for BuildOnFail {
1300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1301        match self {
1302            Self::Notify => write!(f, "notify"),
1303            Self::BlockRelease => write!(f, "block_release"),
1304            Self::BlockNextPhase => write!(f, "block_next_phase"),
1305            Self::Agent => write!(f, "agent"),
1306        }
1307    }
1308}
1309
1310/// Build pipeline configuration
1311#[derive(Debug, Clone, Serialize, Deserialize)]
1312pub struct BuildConfig {
1313    /// Summary enforcement level at `ta draft build` time.
1314    /// - "ignore": No check — artifacts without descriptions are silently accepted.
1315    /// - "warning" (default): Print a warning listing artifacts missing descriptions.
1316    /// - "error": Fail the build if any non-exempt artifact lacks a description.
1317    ///
1318    /// Exempt files (lockfiles, config manifests, docs) always get auto-summaries.
1319    #[serde(default = "default_summary_enforcement")]
1320    pub summary_enforcement: String,
1321
1322    /// Build adapter: "cargo", "npm", "script", "webhook", "auto" (default), or "none".
1323    #[serde(default = "default_build_adapter")]
1324    pub adapter: String,
1325
1326    /// Custom build command override (used by script adapter, or overrides cargo/npm default).
1327    #[serde(default)]
1328    pub command: Option<String>,
1329
1330    /// Custom test command override.
1331    #[serde(default)]
1332    pub test_command: Option<String>,
1333
1334    /// Webhook URL for the webhook adapter.
1335    #[serde(default)]
1336    pub webhook_url: Option<String>,
1337
1338    /// Behavior on build/test failure.
1339    #[serde(default)]
1340    pub on_fail: BuildOnFail,
1341
1342    /// Timeout per build/test command in seconds. Default: 600 (10 minutes).
1343    #[serde(default = "default_build_timeout")]
1344    pub timeout_secs: u64,
1345}
1346
1347impl Default for BuildConfig {
1348    fn default() -> Self {
1349        Self {
1350            summary_enforcement: default_summary_enforcement(),
1351            adapter: default_build_adapter(),
1352            command: None,
1353            test_command: None,
1354            webhook_url: None,
1355            on_fail: BuildOnFail::default(),
1356            timeout_secs: default_build_timeout(),
1357        }
1358    }
1359}
1360
1361fn default_summary_enforcement() -> String {
1362    "warning".to_string()
1363}
1364
1365fn default_build_adapter() -> String {
1366    "auto".to_string()
1367}
1368
1369fn default_build_timeout() -> u64 {
1370    600
1371}
1372
1373/// Display / output configuration
1374#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1375pub struct DisplayConfig {
1376    /// Enable ANSI color output in terminal adapter. Default: false.
1377    /// Override per-command with `--color`.
1378    #[serde(default)]
1379    pub color: bool,
1380}
1381
1382/// Garbage collection / draft lifecycle configuration
1383#[derive(Debug, Clone, Serialize, Deserialize)]
1384pub struct GcConfig {
1385    /// Number of days after which drafts in terminal states (Applied, Denied, Closed)
1386    /// become eligible for staging directory cleanup. Default: 7.
1387    #[serde(default = "default_stale_threshold_days")]
1388    pub stale_threshold_days: u64,
1389
1390    /// Number of days after which the startup hint fires for pending/approved drafts.
1391    /// Default: 3 (informational only — a Friday draft hints on Monday morning).
1392    /// Set higher (e.g., 5) to reduce noise. Must be ≤ stale_threshold_days.
1393    #[serde(default = "default_stale_hint_days")]
1394    pub stale_hint_days: u64,
1395
1396    /// Emit a one-line warning on `ta` startup if stale drafts exist. Default: true.
1397    #[serde(default = "default_health_check")]
1398    pub health_check: bool,
1399}
1400
1401impl Default for GcConfig {
1402    fn default() -> Self {
1403        Self {
1404            stale_threshold_days: default_stale_threshold_days(),
1405            stale_hint_days: default_stale_hint_days(),
1406            health_check: default_health_check(),
1407        }
1408    }
1409}
1410
1411fn default_stale_threshold_days() -> u64 {
1412    7
1413}
1414
1415fn default_stale_hint_days() -> u64 {
1416    3
1417}
1418
1419/// Follow-up goal behavior configuration
1420#[derive(Debug, Clone, Serialize, Deserialize)]
1421pub struct FollowUpConfig {
1422    /// Default mode for --follow-up: "extend" reuses parent staging, "standalone" creates fresh copy.
1423    #[serde(default = "default_follow_up_mode")]
1424    pub default_mode: String,
1425
1426    /// Auto-supersede parent draft when building from same staging directory.
1427    #[serde(default = "default_auto_supersede")]
1428    pub auto_supersede: bool,
1429
1430    /// Re-snapshot source before applying when source has changed since goal start.
1431    #[serde(default = "default_rebase_on_apply")]
1432    pub rebase_on_apply: bool,
1433}
1434
1435impl Default for FollowUpConfig {
1436    fn default() -> Self {
1437        Self {
1438            default_mode: default_follow_up_mode(),
1439            auto_supersede: default_auto_supersede(),
1440            rebase_on_apply: default_rebase_on_apply(),
1441        }
1442    }
1443}
1444
1445fn default_follow_up_mode() -> String {
1446    "extend".to_string()
1447}
1448
1449fn default_auto_supersede() -> bool {
1450    true
1451}
1452
1453fn default_rebase_on_apply() -> bool {
1454    true
1455}
1456
1457fn default_health_check() -> bool {
1458    true
1459}
1460
1461/// Failure handling strategy for verification commands.
1462#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1463#[serde(rename_all = "lowercase")]
1464pub enum VerifyOnFailure {
1465    /// Do not create a draft. Print which command failed with output.
1466    #[default]
1467    Block,
1468    /// Create the draft but attach verification warnings visible in `ta draft view`.
1469    Warn,
1470    /// Re-launch the agent with the failure output injected as context.
1471    Agent,
1472}
1473
1474impl std::fmt::Display for VerifyOnFailure {
1475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1476        match self {
1477            Self::Block => write!(f, "block"),
1478            Self::Warn => write!(f, "warn"),
1479            Self::Agent => write!(f, "agent"),
1480        }
1481    }
1482}
1483
1484/// A single verification command with optional per-command timeout.
1485///
1486/// Used in `[[verify.commands]]` TOML arrays for per-command configuration.
1487/// When only a string is needed, the flat `commands` list (backward compat) works too.
1488#[derive(Debug, Clone, Serialize, Deserialize)]
1489pub struct VerifyCommand {
1490    /// The shell command to run.
1491    pub run: String,
1492
1493    /// Per-command timeout in seconds. If omitted, `default_timeout_secs` is used.
1494    pub timeout_secs: Option<u64>,
1495}
1496
1497/// Pre-draft verification gate configuration.
1498///
1499/// Commands run in the staging directory after the agent exits but before
1500/// the draft is created. If any command fails, behavior depends on `on_failure`.
1501///
1502/// Supports two command formats (backward compatible):
1503/// - Flat string list: `commands = ["cmd1", "cmd2"]` (legacy)
1504/// - Structured commands: `[[verify.commands]]` with `run` and optional `timeout_secs`
1505#[derive(Debug, Clone, Serialize, Deserialize)]
1506pub struct VerifyConfig {
1507    /// Commands to run sequentially. All must exit 0 for verification to pass.
1508    /// Accepts either plain strings or `VerifyCommand` objects.
1509    #[serde(default, deserialize_with = "deserialize_verify_commands")]
1510    pub commands: Vec<VerifyCommand>,
1511
1512    /// Behavior when a command fails: "block" (default), "warn", or "agent".
1513    #[serde(default)]
1514    pub on_failure: VerifyOnFailure,
1515
1516    /// Legacy: global timeout per command in seconds. Default: 300 (5 minutes).
1517    /// Superseded by `default_timeout_secs`; kept for backward compat.
1518    #[serde(default = "default_verify_timeout")]
1519    pub timeout: u64,
1520
1521    /// Default timeout per command in seconds when not specified per-command.
1522    /// If set, takes priority over `timeout`. Default: 300.
1523    pub default_timeout_secs: Option<u64>,
1524
1525    /// Heartbeat interval in seconds for long-running verification commands.
1526    /// A progress message is emitted every N seconds. Default: 30.
1527    #[serde(default = "default_heartbeat_interval")]
1528    pub heartbeat_interval_secs: u64,
1529}
1530
1531impl VerifyConfig {
1532    /// Effective default timeout: `default_timeout_secs` if set, else legacy `timeout`.
1533    pub fn effective_default_timeout(&self) -> u64 {
1534        self.default_timeout_secs.unwrap_or(self.timeout)
1535    }
1536
1537    /// Resolve the timeout for a specific command.
1538    pub fn command_timeout(&self, cmd: &VerifyCommand) -> u64 {
1539        cmd.timeout_secs
1540            .unwrap_or_else(|| self.effective_default_timeout())
1541    }
1542}
1543
1544impl Default for VerifyConfig {
1545    fn default() -> Self {
1546        Self {
1547            commands: Vec::new(),
1548            on_failure: VerifyOnFailure::default(),
1549            timeout: default_verify_timeout(),
1550            default_timeout_secs: None,
1551            heartbeat_interval_secs: default_heartbeat_interval(),
1552        }
1553    }
1554}
1555
1556fn default_verify_timeout() -> u64 {
1557    300
1558}
1559
1560fn default_heartbeat_interval() -> u64 {
1561    30
1562}
1563
1564/// Deserialize commands from either a list of strings or a list of VerifyCommand objects.
1565fn deserialize_verify_commands<'de, D>(deserializer: D) -> Result<Vec<VerifyCommand>, D::Error>
1566where
1567    D: serde::Deserializer<'de>,
1568{
1569    #[derive(Deserialize)]
1570    #[serde(untagged)]
1571    enum CommandItem {
1572        Simple(String),
1573        Structured(VerifyCommand),
1574    }
1575
1576    let items: Vec<CommandItem> = Vec::deserialize(deserializer)?;
1577    Ok(items
1578        .into_iter()
1579        .map(|item| match item {
1580            CommandItem::Simple(s) => VerifyCommand {
1581                run: s,
1582                timeout_secs: None,
1583            },
1584            CommandItem::Structured(c) => c,
1585        })
1586        .collect())
1587}
1588
1589/// Shell TUI configuration
1590#[derive(Debug, Clone, Serialize, Deserialize)]
1591pub struct ShellConfig {
1592    /// Number of lines to backfill when attaching to a tail stream. Default: 5.
1593    #[serde(default = "default_tail_backfill_lines")]
1594    pub tail_backfill_lines: usize,
1595
1596    /// Maximum number of lines retained in the TUI output buffer. Default: 50000.
1597    /// Older lines are dropped when this limit is exceeded.
1598    #[serde(default = "default_output_buffer_lines")]
1599    pub output_buffer_lines: usize,
1600
1601    /// Alias for `output_buffer_lines` — configurable as `scrollback_lines` (v0.10.18.2).
1602    /// If set, overrides `output_buffer_lines`. Minimum enforced: 10,000.
1603    #[serde(default)]
1604    pub scrollback_lines: Option<usize>,
1605
1606    /// Automatically tail agent output when a goal starts. Default: true.
1607    #[serde(default = "default_auto_tail")]
1608    pub auto_tail: bool,
1609}
1610
1611impl ShellConfig {
1612    /// Effective scrollback buffer size: `scrollback_lines` if set, else `output_buffer_lines`.
1613    /// Enforces a minimum of 10,000 lines.
1614    pub fn effective_scrollback(&self) -> usize {
1615        let raw = self.scrollback_lines.unwrap_or(self.output_buffer_lines);
1616        raw.max(10_000)
1617    }
1618}
1619
1620impl Default for ShellConfig {
1621    fn default() -> Self {
1622        Self {
1623            tail_backfill_lines: default_tail_backfill_lines(),
1624            output_buffer_lines: default_output_buffer_lines(),
1625            scrollback_lines: None,
1626            auto_tail: default_auto_tail(),
1627        }
1628    }
1629}
1630
1631fn default_tail_backfill_lines() -> usize {
1632    5
1633}
1634
1635fn default_output_buffer_lines() -> usize {
1636    50000
1637}
1638
1639fn default_auto_tail() -> bool {
1640    true
1641}
1642
1643/// Desktop notification configuration.
1644///
1645/// When enabled, TA sends a system notification (macOS/Linux) when a draft
1646/// is ready for review, so users don't have to watch the terminal.
1647#[derive(Debug, Clone, Serialize, Deserialize)]
1648pub struct NotifyConfig {
1649    /// Enable desktop notifications. Default: true.
1650    #[serde(default = "default_notify_enabled")]
1651    pub enabled: bool,
1652
1653    /// Title prefix for notifications. Default: "TA".
1654    #[serde(default = "default_notify_title")]
1655    pub title: String,
1656}
1657
1658impl Default for NotifyConfig {
1659    fn default() -> Self {
1660        Self {
1661            enabled: default_notify_enabled(),
1662            title: default_notify_title(),
1663        }
1664    }
1665}
1666
1667fn default_notify_enabled() -> bool {
1668    true
1669}
1670
1671fn default_notify_title() -> String {
1672    "TA".to_string()
1673}
1674
1675/// How the staging workspace copies the source project (v0.13.13).
1676///
1677/// Configured in `workflow.toml` under `[staging]`:
1678/// ```toml
1679/// [staging]
1680/// strategy = "smart"   # "full" | "smart" | "refs-cow"
1681/// ```
1682///
1683/// - **Full** (default): byte-for-byte copy, always works, may be slow for large workspaces.
1684/// - **Smart**: symlinks `.taignore`/`protected_paths` entries instead of copying them —
1685///   near-zero staging cost for large ignored directories (e.g., `node_modules/`, UE Content/).
1686/// - **RefsCow**: Windows ReFS Dev Drive only — instant zero-cost clone via
1687///   `FSCTL_DUPLICATE_EXTENTS_TO_FILE`; auto-falls back to `smart` on NTFS.
1688#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1689#[serde(rename_all = "kebab-case")]
1690pub enum StagingStrategy {
1691    /// Byte-for-byte copy of the source project. Always works; may be slow for large workspaces.
1692    #[default]
1693    Full,
1694    /// Symlink excluded directories instead of copying. Fast for large workspaces with many ignored dirs.
1695    Smart,
1696    /// Windows ReFS CoW clone. Auto-falls back to `smart` on non-ReFS volumes.
1697    RefsCow,
1698    /// Windows Projected File System — zero-disk-cost virtual workspace (v0.15.8).
1699    ///
1700    /// Files appear in staging instantly; reads hydrate from source on demand.
1701    /// Writes land in `.projfs-scratch/`. Auto-falls back to `smart` when
1702    /// `Client-ProjFS` is not installed (requires Windows 10 1809+).
1703    ProjFs,
1704}
1705
1706impl StagingStrategy {
1707    pub fn as_str(&self) -> &'static str {
1708        match self {
1709            Self::Full => "full",
1710            Self::Smart => "smart",
1711            Self::RefsCow => "refs-cow",
1712            Self::ProjFs => "projfs",
1713        }
1714    }
1715}
1716
1717/// Staging directory management (v0.11.3, extended v0.13.13).
1718#[derive(Debug, Clone, Serialize, Deserialize)]
1719pub struct StagingConfig {
1720    /// Auto-remove staging after successful apply. Default: true.
1721    #[serde(default = "default_auto_clean")]
1722    pub auto_clean: bool,
1723    /// Minimum free disk space in MB. Default: 2048.
1724    #[serde(default = "default_min_disk_mb")]
1725    pub min_disk_mb: u64,
1726    /// Staging strategy for large workspaces (v0.13.13). Default: Full.
1727    #[serde(default)]
1728    pub strategy: StagingStrategy,
1729}
1730
1731impl Default for StagingConfig {
1732    fn default() -> Self {
1733        Self {
1734            auto_clean: default_auto_clean(),
1735            min_disk_mb: default_min_disk_mb(),
1736            strategy: StagingStrategy::Full,
1737        }
1738    }
1739}
1740
1741fn default_auto_clean() -> bool {
1742    true
1743}
1744fn default_min_disk_mb() -> u64 {
1745    2048
1746}
1747
1748/// Check available disk space in MB.
1749pub fn check_disk_space_mb(path: &std::path::Path) -> Result<u64, String> {
1750    #[cfg(unix)]
1751    {
1752        use std::os::unix::ffi::OsStrExt;
1753        let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())
1754            .map_err(|e| format!("invalid path: {}", e))?;
1755        let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
1756        let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
1757        if rc != 0 {
1758            return Err(format!(
1759                "statvfs failed for {}: {}",
1760                path.display(),
1761                std::io::Error::last_os_error()
1762            ));
1763        }
1764        Ok((stat.f_bavail as u64) * (stat.f_frsize as u64) / (1024 * 1024))
1765    }
1766    #[cfg(not(unix))]
1767    {
1768        let _ = path;
1769        Ok(u64::MAX)
1770    }
1771}
1772
1773/// Security level profile configuration (v0.15.14.4).
1774///
1775/// Sets a named preset of security defaults. Individual settings always override
1776/// the level preset. See `ta_goal::SecurityProfile::from_level` for the full
1777/// default table per level.
1778///
1779/// ```toml
1780/// [security]
1781/// level = "mid"   # "low" (default) | "mid" | "high"
1782///
1783/// # Override individual controls beyond the level preset:
1784/// # [security.secrets]
1785/// # scan = "off"   # "off" | "warn" | "block"
1786///
1787/// # [security.forbidden_tools]
1788/// # extra = ["Bash(*aws*)", "Bash(*gcloud*)"]
1789/// ```
1790#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1791pub struct SecurityConfig {
1792    /// Security level preset.
1793    ///
1794    /// - `"low"` (default): Frictionless solo-developer mode.
1795    /// - `"mid"`: Sensible team defaults (sandbox on, forbidden patterns, hash chain).
1796    /// - `"high"`: High-assurance regulated mode (approval gate, HMAC chain, no WebSearch).
1797    #[serde(default)]
1798    pub level: ta_goal::SecurityLevel,
1799
1800    /// Secret scanning mode override (individual override for `[security.secrets] scan`).
1801    #[serde(default, skip_serializing_if = "Option::is_none")]
1802    pub secret_scan: Option<ta_goal::SecretScanMode>,
1803
1804    /// Extra forbidden tool patterns added on top of the level preset.
1805    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1806    pub extra_forbidden_tools: Vec<String>,
1807}
1808
1809impl SecurityConfig {
1810    /// Convert to `SecurityOverrides` for use with `SecurityProfile::from_level`.
1811    pub fn to_overrides(&self) -> ta_goal::SecurityOverrides {
1812        ta_goal::SecurityOverrides {
1813            secret_scan_mode: self.secret_scan,
1814            extra_forbidden_tools: self.extra_forbidden_tools.clone(),
1815            ..Default::default()
1816        }
1817    }
1818}
1819
1820impl WorkflowConfig {
1821    /// Load workflow config from .ta/workflow.toml, merging any local override file on top.
1822    ///
1823    /// Override file resolution (first found wins):
1824    ///   1. `<dir>/workflow.local.toml`  — canonical name
1825    ///   2. `<dir>/local.workflow.toml`  — deprecated name (warns and falls back)
1826    pub fn load(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
1827        let content = std::fs::read_to_string(path)?;
1828        let mut base: toml::Value = toml::from_str(&content)?;
1829
1830        let dir = path.parent().unwrap_or(std::path::Path::new("."));
1831        let canonical_local = dir.join("workflow.local.toml");
1832        let deprecated_local = dir.join("local.workflow.toml");
1833
1834        let local_path = if canonical_local.exists() {
1835            Some(canonical_local)
1836        } else if deprecated_local.exists() {
1837            tracing::warn!(
1838                path = %deprecated_local.display(),
1839                "'.ta/local.workflow.toml' is deprecated — rename to '.ta/workflow.local.toml'"
1840            );
1841            Some(deprecated_local)
1842        } else {
1843            None
1844        };
1845
1846        if let Some(local) = local_path {
1847            let local_content = std::fs::read_to_string(&local)?;
1848            let local_val: toml::Value = toml::from_str(&local_content)?;
1849            merge_toml_values(&mut base, local_val);
1850        }
1851
1852        let config = base.try_into()?;
1853        Ok(config)
1854    }
1855
1856    /// Try to load config, returning default if file doesn't exist
1857    pub fn load_or_default(path: &std::path::Path) -> Self {
1858        Self::load(path).unwrap_or_default()
1859    }
1860}
1861
1862/// Recursively merge `overrides` into `base`. Tables are merged key-by-key;
1863/// all other values are replaced by the override.
1864fn merge_toml_values(base: &mut toml::Value, overrides: toml::Value) {
1865    match (base, overrides) {
1866        (toml::Value::Table(base_map), toml::Value::Table(override_map)) => {
1867            for (k, v) in override_map {
1868                let entry = base_map
1869                    .entry(k)
1870                    .or_insert(toml::Value::Table(Default::default()));
1871                merge_toml_values(entry, v);
1872            }
1873        }
1874        (base, overrides) => *base = overrides,
1875    }
1876}
1877
1878#[cfg(test)]
1879mod tests {
1880    use super::*;
1881
1882    #[test]
1883    fn build_config_defaults_to_warning() {
1884        let config = BuildConfig::default();
1885        assert_eq!(config.summary_enforcement, "warning");
1886    }
1887
1888    #[test]
1889    fn build_config_defaults() {
1890        let config = BuildConfig::default();
1891        assert_eq!(config.adapter, "auto");
1892        assert!(config.command.is_none());
1893        assert!(config.test_command.is_none());
1894        assert!(config.webhook_url.is_none());
1895        assert_eq!(config.on_fail, BuildOnFail::Notify);
1896        assert_eq!(config.timeout_secs, 600);
1897    }
1898
1899    #[test]
1900    fn workflow_config_default_has_build_section() {
1901        let config = WorkflowConfig::default();
1902        assert_eq!(config.build.summary_enforcement, "warning");
1903        assert_eq!(config.build.adapter, "auto");
1904    }
1905
1906    #[test]
1907    fn parse_toml_with_build_section() {
1908        let toml = r#"
1909[build]
1910summary_enforcement = "error"
1911"#;
1912        let config: WorkflowConfig = toml::from_str(toml).unwrap();
1913        assert_eq!(config.build.summary_enforcement, "error");
1914    }
1915
1916    #[test]
1917    fn parse_toml_with_build_adapter_config() {
1918        let toml = r#"
1919[build]
1920adapter = "cargo"
1921command = "cargo build --release"
1922test_command = "cargo test --release"
1923on_fail = "block_release"
1924timeout_secs = 1200
1925"#;
1926        let config: WorkflowConfig = toml::from_str(toml).unwrap();
1927        assert_eq!(config.build.adapter, "cargo");
1928        assert_eq!(
1929            config.build.command.as_deref(),
1930            Some("cargo build --release")
1931        );
1932        assert_eq!(
1933            config.build.test_command.as_deref(),
1934            Some("cargo test --release")
1935        );
1936        assert_eq!(config.build.on_fail, BuildOnFail::BlockRelease);
1937        assert_eq!(config.build.timeout_secs, 1200);
1938    }
1939
1940    #[test]
1941    fn parse_toml_with_build_script_adapter() {
1942        let toml = r#"
1943[build]
1944adapter = "script"
1945command = "make all"
1946test_command = "make test"
1947on_fail = "agent"
1948"#;
1949        let config: WorkflowConfig = toml::from_str(toml).unwrap();
1950        assert_eq!(config.build.adapter, "script");
1951        assert_eq!(config.build.command.as_deref(), Some("make all"));
1952        assert_eq!(config.build.on_fail, BuildOnFail::Agent);
1953    }
1954
1955    #[test]
1956    fn parse_toml_without_build_section_uses_default() {
1957        let toml = r#"
1958[submit]
1959adapter = "git"
1960"#;
1961        let config: WorkflowConfig = toml::from_str(toml).unwrap();
1962        assert_eq!(config.build.summary_enforcement, "warning");
1963        assert_eq!(config.build.adapter, "auto");
1964    }
1965
1966    #[test]
1967    fn build_on_fail_display() {
1968        assert_eq!(BuildOnFail::Notify.to_string(), "notify");
1969        assert_eq!(BuildOnFail::BlockRelease.to_string(), "block_release");
1970        assert_eq!(BuildOnFail::BlockNextPhase.to_string(), "block_next_phase");
1971        assert_eq!(BuildOnFail::Agent.to_string(), "agent");
1972    }
1973
1974    #[test]
1975    fn gc_config_defaults() {
1976        let config = GcConfig::default();
1977        assert_eq!(config.stale_threshold_days, 7);
1978        assert_eq!(config.stale_hint_days, 3);
1979        assert!(config.health_check);
1980    }
1981
1982    #[test]
1983    fn workflow_config_default_has_gc_section() {
1984        let config = WorkflowConfig::default();
1985        assert_eq!(config.gc.stale_threshold_days, 7);
1986        assert_eq!(config.gc.stale_hint_days, 3);
1987        assert!(config.gc.health_check);
1988    }
1989
1990    #[test]
1991    fn parse_toml_with_gc_section() {
1992        let toml = r#"
1993[gc]
1994stale_threshold_days = 14
1995stale_hint_days = 5
1996health_check = false
1997"#;
1998        let config: WorkflowConfig = toml::from_str(toml).unwrap();
1999        assert_eq!(config.gc.stale_threshold_days, 14);
2000        assert_eq!(config.gc.stale_hint_days, 5);
2001        assert!(!config.gc.health_check);
2002    }
2003
2004    #[test]
2005    fn load_or_default_returns_default_for_missing_file() {
2006        let config = WorkflowConfig::load_or_default(std::path::Path::new("/nonexistent/path"));
2007        assert_eq!(config.build.summary_enforcement, "warning");
2008        assert_eq!(config.submit.adapter, "none");
2009    }
2010
2011    #[test]
2012    fn follow_up_config_defaults() {
2013        let config = FollowUpConfig::default();
2014        assert_eq!(config.default_mode, "extend");
2015        assert!(config.auto_supersede);
2016        assert!(config.rebase_on_apply);
2017    }
2018
2019    #[test]
2020    fn workflow_config_default_has_follow_up_section() {
2021        let config = WorkflowConfig::default();
2022        assert_eq!(config.follow_up.default_mode, "extend");
2023        assert!(config.follow_up.auto_supersede);
2024        assert!(config.follow_up.rebase_on_apply);
2025    }
2026
2027    #[test]
2028    fn parse_toml_with_follow_up_section() {
2029        let toml = r#"
2030[follow_up]
2031default_mode = "standalone"
2032auto_supersede = false
2033rebase_on_apply = false
2034"#;
2035        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2036        assert_eq!(config.follow_up.default_mode, "standalone");
2037        assert!(!config.follow_up.auto_supersede);
2038        assert!(!config.follow_up.rebase_on_apply);
2039    }
2040
2041    #[test]
2042    fn verify_config_defaults() {
2043        let config = VerifyConfig::default();
2044        assert!(config.commands.is_empty());
2045        assert_eq!(config.on_failure, VerifyOnFailure::Block);
2046        assert_eq!(config.timeout, 300);
2047        assert_eq!(config.heartbeat_interval_secs, 30);
2048        assert!(config.default_timeout_secs.is_none());
2049        assert_eq!(config.effective_default_timeout(), 300);
2050    }
2051
2052    #[test]
2053    fn workflow_config_default_has_verify_section() {
2054        let config = WorkflowConfig::default();
2055        assert!(config.verify.commands.is_empty());
2056        assert_eq!(config.verify.on_failure, VerifyOnFailure::Block);
2057        assert_eq!(config.verify.timeout, 300);
2058    }
2059
2060    #[test]
2061    fn parse_toml_with_verify_section() {
2062        let toml = r#"
2063[verify]
2064commands = [
2065    "cargo build --workspace",
2066    "cargo test --workspace",
2067]
2068on_failure = "warn"
2069timeout = 600
2070"#;
2071        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2072        assert_eq!(config.verify.commands.len(), 2);
2073        assert_eq!(config.verify.commands[0].run, "cargo build --workspace");
2074        assert_eq!(config.verify.on_failure, VerifyOnFailure::Warn);
2075        assert_eq!(config.verify.timeout, 600);
2076    }
2077
2078    #[test]
2079    fn parse_toml_with_verify_agent_mode() {
2080        let toml = r#"
2081[verify]
2082commands = ["make test"]
2083on_failure = "agent"
2084"#;
2085        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2086        assert_eq!(config.verify.on_failure, VerifyOnFailure::Agent);
2087        assert_eq!(config.verify.timeout, 300); // default
2088    }
2089
2090    #[test]
2091    fn parse_toml_without_verify_section_uses_default() {
2092        let toml = r#"
2093[submit]
2094adapter = "git"
2095"#;
2096        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2097        assert!(config.verify.commands.is_empty());
2098        assert_eq!(config.verify.on_failure, VerifyOnFailure::Block);
2099    }
2100
2101    #[test]
2102    fn parse_toml_with_per_command_timeout() {
2103        let toml = r#"
2104[verify]
2105default_timeout_secs = 300
2106heartbeat_interval_secs = 15
2107
2108[[verify.commands]]
2109run = "cargo fmt --all -- --check"
2110timeout_secs = 60
2111
2112[[verify.commands]]
2113run = "cargo test --workspace"
2114timeout_secs = 900
2115"#;
2116        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2117        assert_eq!(config.verify.commands.len(), 2);
2118        assert_eq!(config.verify.commands[0].run, "cargo fmt --all -- --check");
2119        assert_eq!(config.verify.commands[0].timeout_secs, Some(60));
2120        assert_eq!(config.verify.commands[1].run, "cargo test --workspace");
2121        assert_eq!(config.verify.commands[1].timeout_secs, Some(900));
2122        assert_eq!(config.verify.default_timeout_secs, Some(300));
2123        assert_eq!(config.verify.heartbeat_interval_secs, 15);
2124        assert_eq!(config.verify.effective_default_timeout(), 300);
2125        assert_eq!(
2126            config.verify.command_timeout(&config.verify.commands[0]),
2127            60
2128        );
2129        assert_eq!(
2130            config.verify.command_timeout(&config.verify.commands[1]),
2131            900
2132        );
2133    }
2134
2135    #[test]
2136    fn per_command_timeout_falls_back_to_default() {
2137        let config = VerifyConfig {
2138            commands: vec![VerifyCommand {
2139                run: "test".to_string(),
2140                timeout_secs: None,
2141            }],
2142            default_timeout_secs: Some(600),
2143            ..Default::default()
2144        };
2145        assert_eq!(config.command_timeout(&config.commands[0]), 600);
2146    }
2147
2148    #[test]
2149    fn effective_timeout_falls_back_to_legacy() {
2150        let config = VerifyConfig {
2151            timeout: 900,
2152            default_timeout_secs: None,
2153            ..Default::default()
2154        };
2155        assert_eq!(config.effective_default_timeout(), 900);
2156    }
2157
2158    #[test]
2159    fn verify_on_failure_display() {
2160        assert_eq!(VerifyOnFailure::Block.to_string(), "block");
2161        assert_eq!(VerifyOnFailure::Warn.to_string(), "warn");
2162        assert_eq!(VerifyOnFailure::Agent.to_string(), "agent");
2163    }
2164
2165    #[test]
2166    fn shell_config_defaults() {
2167        let config = ShellConfig::default();
2168        assert_eq!(config.tail_backfill_lines, 5);
2169        assert_eq!(config.output_buffer_lines, 50000);
2170        assert!(config.scrollback_lines.is_none());
2171        assert!(config.auto_tail);
2172        assert_eq!(config.effective_scrollback(), 50000);
2173    }
2174
2175    #[test]
2176    fn workflow_config_default_has_shell_section() {
2177        let config = WorkflowConfig::default();
2178        assert_eq!(config.shell.tail_backfill_lines, 5);
2179        assert_eq!(config.shell.output_buffer_lines, 50000);
2180        assert!(config.shell.auto_tail);
2181    }
2182
2183    #[test]
2184    fn parse_toml_with_shell_section() {
2185        let toml = r#"
2186[shell]
2187tail_backfill_lines = 20
2188output_buffer_lines = 5000
2189auto_tail = false
2190"#;
2191        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2192        assert_eq!(config.shell.tail_backfill_lines, 20);
2193        assert_eq!(config.shell.output_buffer_lines, 5000);
2194        assert!(!config.shell.auto_tail);
2195    }
2196
2197    #[test]
2198    fn parse_toml_without_shell_section_uses_default() {
2199        let toml = r#"
2200[submit]
2201adapter = "git"
2202"#;
2203        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2204        assert_eq!(config.shell.tail_backfill_lines, 5);
2205        assert_eq!(config.shell.output_buffer_lines, 50000);
2206        assert!(config.shell.auto_tail);
2207    }
2208
2209    // ── v0.11.0.1: auto_submit / auto_review / backward compat ──
2210
2211    #[test]
2212    fn effective_auto_submit_defaults_true_when_adapter_set() {
2213        let config = SubmitConfig {
2214            adapter: "git".to_string(),
2215            ..Default::default()
2216        };
2217        assert!(config.effective_auto_submit());
2218    }
2219
2220    #[test]
2221    fn effective_auto_submit_defaults_false_when_no_adapter() {
2222        let config = SubmitConfig::default(); // adapter = "none"
2223        assert!(!config.effective_auto_submit());
2224    }
2225
2226    #[test]
2227    fn effective_auto_submit_explicit_override() {
2228        let config = SubmitConfig {
2229            adapter: "git".to_string(),
2230            auto_submit: Some(false),
2231            ..Default::default()
2232        };
2233        assert!(!config.effective_auto_submit());
2234    }
2235
2236    #[test]
2237    fn effective_auto_review_defaults_true_when_adapter_set() {
2238        let config = SubmitConfig {
2239            adapter: "git".to_string(),
2240            ..Default::default()
2241        };
2242        assert!(config.effective_auto_review());
2243    }
2244
2245    #[test]
2246    fn effective_auto_review_defaults_false_when_no_adapter() {
2247        let config = SubmitConfig::default();
2248        assert!(!config.effective_auto_review());
2249    }
2250
2251    #[test]
2252    fn effective_auto_review_explicit_override() {
2253        let config = SubmitConfig {
2254            adapter: "git".to_string(),
2255            auto_review: Some(false),
2256            ..Default::default()
2257        };
2258        assert!(!config.effective_auto_review());
2259    }
2260
2261    #[test]
2262    fn parse_toml_with_auto_submit() {
2263        let toml = r#"
2264[submit]
2265adapter = "git"
2266auto_submit = true
2267auto_review = false
2268"#;
2269        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2270        assert!(config.submit.effective_auto_submit());
2271        assert!(!config.submit.effective_auto_review());
2272    }
2273
2274    #[test]
2275    fn parse_toml_with_deprecated_auto_commit_auto_push() {
2276        // Old-style config with removed fields should still parse (fields are ignored).
2277        // With adapter = "none", effective_auto_submit() defaults to false.
2278        let toml = r#"
2279[submit]
2280adapter = "none"
2281auto_review = true
2282"#;
2283        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2284        assert!(!config.submit.effective_auto_submit());
2285        assert!(config.submit.effective_auto_review());
2286    }
2287
2288    #[test]
2289    fn sync_config_defaults() {
2290        let config = SyncConfig::default();
2291        assert!(!config.auto_sync);
2292        assert_eq!(config.strategy, "merge");
2293        assert_eq!(config.remote, "origin");
2294        assert_eq!(config.branch, "main");
2295    }
2296
2297    #[test]
2298    fn parse_toml_with_source_sync_section() {
2299        let toml = r#"
2300[source.sync]
2301auto_sync = true
2302strategy = "rebase"
2303remote = "upstream"
2304branch = "develop"
2305"#;
2306        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2307        assert!(config.source.sync.auto_sync);
2308        assert_eq!(config.source.sync.strategy, "rebase");
2309        assert_eq!(config.source.sync.remote, "upstream");
2310        assert_eq!(config.source.sync.branch, "develop");
2311    }
2312
2313    #[test]
2314    fn parse_toml_without_source_section_uses_default() {
2315        let toml = r#"
2316[submit]
2317adapter = "git"
2318"#;
2319        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2320        assert!(!config.source.sync.auto_sync);
2321        assert_eq!(config.source.sync.strategy, "merge");
2322    }
2323
2324    #[test]
2325    fn parse_toml_with_adapter_specific_sections() {
2326        let toml = r#"
2327[submit]
2328adapter = "git"
2329
2330[submit.git]
2331branch_prefix = "feature/"
2332target_branch = "develop"
2333remote = "upstream"
2334
2335[submit.perforce]
2336workspace = "my-ws"
2337shelve_by_default = false
2338
2339[submit.svn]
2340repo_url = "svn://example.com/trunk"
2341"#;
2342        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2343        assert_eq!(config.submit.git.branch_prefix, "feature/");
2344        assert_eq!(config.submit.git.target_branch, "develop");
2345        assert_eq!(config.submit.git.remote, "upstream");
2346        assert_eq!(config.submit.perforce.workspace.as_deref(), Some("my-ws"));
2347        assert!(!config.submit.perforce.shelve_by_default);
2348        assert_eq!(
2349            config.submit.svn.repo_url.as_deref(),
2350            Some("svn://example.com/trunk")
2351        );
2352    }
2353
2354    #[test]
2355    fn git_config_auto_merge_default_false() {
2356        let config = GitConfig::default();
2357        assert!(!config.auto_merge);
2358    }
2359
2360    #[test]
2361    fn git_config_auto_merge_from_toml() {
2362        let toml = r#"
2363[submit.git]
2364auto_merge = true
2365"#;
2366        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2367        assert!(config.submit.git.auto_merge);
2368    }
2369
2370    #[test]
2371    fn sandbox_config_defaults() {
2372        let config = SandboxConfig::default();
2373        assert!(!config.enabled);
2374        assert_eq!(config.provider, "native");
2375        assert!(config.allow_read.is_empty());
2376        assert!(config.allow_write.is_empty());
2377        assert!(config.allow_network.is_empty());
2378    }
2379
2380    #[test]
2381    fn sandbox_config_from_toml() {
2382        let toml = r#"
2383[sandbox]
2384enabled = true
2385provider = "native"
2386allow_read = ["/usr/lib"]
2387allow_write = ["/tmp/scratch"]
2388allow_network = ["api.anthropic.com"]
2389"#;
2390        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2391        assert!(config.sandbox.enabled);
2392        assert_eq!(config.sandbox.provider, "native");
2393        assert_eq!(config.sandbox.allow_read, vec!["/usr/lib"]);
2394        assert_eq!(config.sandbox.allow_write, vec!["/tmp/scratch"]);
2395        assert_eq!(config.sandbox.allow_network, vec!["api.anthropic.com"]);
2396    }
2397
2398    #[test]
2399    fn workflow_config_default_has_sandbox_section() {
2400        let config = WorkflowConfig::default();
2401        assert!(!config.sandbox.enabled, "sandbox disabled by default");
2402    }
2403
2404    #[test]
2405    fn workflow_section_defaults_to_warn() {
2406        let config = WorkflowConfig::default();
2407        assert_eq!(
2408            config.workflow.enforce_phase_order, "warn",
2409            "enforce_phase_order should default to 'warn'"
2410        );
2411    }
2412
2413    #[test]
2414    fn workflow_section_parse_toml() {
2415        let toml = r#"
2416[workflow]
2417enforce_phase_order = "block"
2418"#;
2419        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2420        assert_eq!(config.workflow.enforce_phase_order, "block");
2421    }
2422
2423    #[test]
2424    fn workflow_section_parse_toml_off() {
2425        let toml = r#"
2426[workflow]
2427enforce_phase_order = "off"
2428"#;
2429        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2430        assert_eq!(config.workflow.enforce_phase_order, "off");
2431    }
2432
2433    // ── v0.14.3.5: ApplyConfig tests ────────────────────────────────────────
2434
2435    #[test]
2436    fn apply_config_empty_returns_none() {
2437        let cfg = ApplyConfig::default();
2438        assert!(cfg.policy_for("PLAN.md").is_none());
2439        assert!(cfg.policy_for("src/main.rs").is_none());
2440    }
2441
2442    #[test]
2443    fn apply_config_exact_match() {
2444        let mut cfg = ApplyConfig::default();
2445        cfg.conflict_policy
2446            .insert("PLAN.md".to_string(), "keep-source".to_string());
2447        cfg.conflict_policy
2448            .insert("Cargo.lock".to_string(), "keep-source".to_string());
2449
2450        assert_eq!(cfg.policy_for("PLAN.md"), Some("keep-source"));
2451        assert_eq!(cfg.policy_for("Cargo.lock"), Some("keep-source"));
2452        assert_eq!(cfg.policy_for("src/main.rs"), None);
2453    }
2454
2455    #[test]
2456    fn apply_config_default_key_fallback() {
2457        let mut cfg = ApplyConfig::default();
2458        cfg.conflict_policy
2459            .insert("default".to_string(), "merge".to_string());
2460
2461        // No specific pattern — falls back to "default".
2462        assert_eq!(cfg.policy_for("src/anything.rs"), Some("merge"));
2463        assert_eq!(cfg.policy_for("PLAN.md"), Some("merge"));
2464    }
2465
2466    #[test]
2467    fn apply_config_glob_override_wins_over_default() {
2468        let mut cfg = ApplyConfig::default();
2469        cfg.conflict_policy
2470            .insert("default".to_string(), "merge".to_string());
2471        cfg.conflict_policy
2472            .insert("src/**".to_string(), "abort".to_string());
2473
2474        assert_eq!(cfg.policy_for("src/main.rs"), Some("abort"));
2475        assert_eq!(cfg.policy_for("src/nested/lib.rs"), Some("abort"));
2476        // Non-src falls back to default.
2477        assert_eq!(cfg.policy_for("docs/USAGE.md"), Some("merge"));
2478    }
2479
2480    #[test]
2481    fn apply_config_parse_from_toml() {
2482        let toml = r#"
2483[apply.conflict_policy]
2484default = "merge"
2485"PLAN.md" = "keep-source"
2486"Cargo.lock" = "keep-source"
2487"src/**" = "abort"
2488"#;
2489        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2490        assert_eq!(config.apply.policy_for("PLAN.md"), Some("keep-source"));
2491        assert_eq!(config.apply.policy_for("Cargo.lock"), Some("keep-source"));
2492        assert_eq!(config.apply.policy_for("src/lib.rs"), Some("abort"));
2493        assert_eq!(config.apply.policy_for("docs/USAGE.md"), Some("merge"));
2494    }
2495
2496    #[test]
2497    fn ta_path_config_defaults_are_populated() {
2498        let cfg = TaPathConfig::default();
2499        assert!(!cfg.project.include_paths.is_empty());
2500        assert!(cfg
2501            .project
2502            .include_paths
2503            .contains(&"workflow.toml".to_string()));
2504        assert!(!cfg.local.exclude_paths.is_empty());
2505        assert!(cfg.local.exclude_paths.contains(&"staging/".to_string()));
2506    }
2507
2508    #[test]
2509    fn ta_path_config_parse_from_toml() {
2510        let toml = r#"
2511[ta.project]
2512include_paths = ["workflow.toml", "agents/"]
2513
2514[ta.local]
2515exclude_paths = ["staging/", "goals/"]
2516"#;
2517        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2518        assert_eq!(
2519            config.ta.project.include_paths,
2520            vec!["workflow.toml", "agents/"]
2521        );
2522        assert_eq!(config.ta.local.exclude_paths, vec!["staging/", "goals/"]);
2523    }
2524
2525    #[test]
2526    fn plan_config_defaults_to_plan_md() {
2527        let config = PlanConfig::default();
2528        assert_eq!(config.file, "PLAN.md");
2529    }
2530
2531    #[test]
2532    fn plan_config_custom_file_resolves_path() {
2533        let config = PlanConfig {
2534            file: "ROADMAP.md".to_string(),
2535        };
2536        let workflow = WorkflowConfig {
2537            plan: config,
2538            ..Default::default()
2539        };
2540        let root = std::path::Path::new("/workspace");
2541        let path = resolve_plan_path(root, &workflow);
2542        assert_eq!(path, std::path::Path::new("/workspace/ROADMAP.md"));
2543    }
2544
2545    #[test]
2546    fn plan_config_default_resolves_to_plan_md() {
2547        let workflow = WorkflowConfig::default();
2548        let root = std::path::Path::new("/project");
2549        let path = resolve_plan_path(root, &workflow);
2550        assert_eq!(path, std::path::Path::new("/project/PLAN.md"));
2551    }
2552
2553    #[test]
2554    fn plan_config_parses_from_toml() {
2555        let toml = r#"
2556[plan]
2557file = "ROADMAP.md"
2558"#;
2559        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2560        assert_eq!(config.plan.file, "ROADMAP.md");
2561    }
2562
2563    #[test]
2564    fn project_section_parses_name() {
2565        let toml = r#"
2566[project]
2567name = "My Pipeline Project"
2568"#;
2569        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2570        assert_eq!(config.project.name.as_deref(), Some("My Pipeline Project"));
2571    }
2572
2573    #[test]
2574    fn project_section_defaults_to_none_name() {
2575        let config = WorkflowConfig::default();
2576        assert!(config.project.name.is_none());
2577    }
2578
2579    // ── analysis config (v0.15.14.3) ─────────────────────────────────────────
2580
2581    #[test]
2582    fn analysis_config_parses_python() {
2583        let toml = r#"
2584[analysis.python]
2585tool = "mypy"
2586args = ["--strict"]
2587on_failure = "agent"
2588max_iterations = 3
2589"#;
2590        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2591        let py = config
2592            .analysis
2593            .get("python")
2594            .expect("python analysis config");
2595        assert_eq!(py.tool, "mypy");
2596        assert_eq!(py.args, vec!["--strict"]);
2597        assert_eq!(py.on_failure, ta_goal::analysis::OnFailure::Agent);
2598        assert_eq!(py.max_iterations, 3);
2599    }
2600
2601    #[test]
2602    fn analysis_config_parses_multiple_languages() {
2603        let toml = r#"
2604[analysis.rust]
2605tool = "cargo-clippy"
2606args = ["-D", "warnings"]
2607on_failure = "warn"
2608
2609[analysis.go]
2610tool = "golangci-lint"
2611args = ["run"]
2612on_failure = "agent"
2613"#;
2614        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2615        assert_eq!(config.analysis.len(), 2);
2616        let rust = config.analysis.get("rust").unwrap();
2617        assert_eq!(rust.tool, "cargo-clippy");
2618        assert_eq!(rust.on_failure, ta_goal::analysis::OnFailure::Warn);
2619        let go = config.analysis.get("go").unwrap();
2620        assert_eq!(go.on_failure, ta_goal::analysis::OnFailure::Agent);
2621    }
2622
2623    #[test]
2624    fn analysis_config_defaults_to_empty_map() {
2625        let config = WorkflowConfig::default();
2626        assert!(config.analysis.is_empty());
2627    }
2628
2629    #[test]
2630    fn analysis_config_parses_on_max_iterations() {
2631        let toml = r#"
2632[analysis.typescript]
2633tool = "pyright"
2634on_max_iterations = "fail"
2635"#;
2636        let config: WorkflowConfig = toml::from_str(toml).unwrap();
2637        let ts = config.analysis.get("typescript").unwrap();
2638        assert_eq!(
2639            ts.on_max_iterations,
2640            ta_goal::analysis::OnMaxIterations::Fail
2641        );
2642    }
2643}