Skip to main content

supercode_harness/
modules.rs

1//! §2 "The Bolt-on Capability Taxonomy" (`docs/composable-harness/
2//! COMPOSABLE-HARNESS-DESIGN.md`) — P3 of the composable-harness migration
3//! (design §5.2, phase **P3**: "A `ModuleId` enum (the 35 names) + resolved
4//! activation set on `Config`").
5//!
6//! [`ModuleId`] names all 35 §2 modules exactly as the table numbers them
7//! (1-35). [`ModuleActivation`] is the resolved activation set — "module
8//! activation is *pure config → set*, testable without the loop" (§5.3 risk
9//! 2's mitigation): [`ModuleActivation::from_harness`] computes it from a
10//! [`crate::configfile::HarnessConfig`] alone, no [`crate::Agent`] required.
11//!
12//! **Schema-collapse note.** Two of the 35 §2 rows have no *independent*
13//! `[capabilities.<name>]` table of their own in the §3.1 schema — they are
14//! represented as a field of a SIBLING module's table instead:
15//! - module 10 `permissions.approvals` is `[capabilities.permissions]`'s own
16//!   `enabled` flag (the `approval = "…"` mode lives in the same table as
17//!   modules 11-13's parent, not a nested `approvals` sub-table) —
18//!   [`ModuleId::PermissionsApprovals`] reads `capabilities.permissions.enabled`.
19//! - module 16 `mcp.server` is `[capabilities.mcp].serve` (a bool field, not
20//!   a nested table with its own `enabled`) — [`ModuleId::McpServer`] reads
21//!   that field directly.
22//!
23//! Every other module maps onto exactly the [`crate::configfile::MODULE_NAMES`]
24//! / [`crate::configfile::NESTED_MODULE_NAMES`] key P2 already resolves.
25
26use std::collections::BTreeSet;
27
28use crate::configfile::{module_enabled, module_setting_bool, HarnessConfig};
29
30/// The 35 §2 capability modules, numbered exactly as the design's module
31/// table (§2, rows 1-35).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub enum ModuleId {
34    /// 1. `tools.search` — glob/content-search/dir-listing tools.
35    ToolsSearch,
36    /// 2. `tools.apply_patch` — the apply_patch envelope tool.
37    ToolsApplyPatch,
38    /// 3. `tools.persistent_shell` — persistent/interactive shell.
39    ToolsPersistentShell,
40    /// 4. `tools.background` — background exec + monitor/event feed.
41    ToolsBackground,
42    /// 5. `tools.web` — web fetch + web search.
43    ToolsWeb,
44    /// 6. `tools.question` — structured user-question tool.
45    ToolsQuestion,
46    /// 7. `todos` — plan/task-checklist tool (`update_plan`).
47    Todos,
48    /// 8. `plan_mode` — plan enter/exit restriction mode.
49    PlanMode,
50    /// 9. `subagents` — spawn/named/background sub-agents.
51    Subagents,
52    /// 10. `permissions.approvals` — approval modes, interactive ask, caching.
53    PermissionsApprovals,
54    /// 11. `permissions.rules` — allow/ask/deny rule language.
55    PermissionsRules,
56    /// 12. `permissions.sandbox` — OS-level fs/network sandbox.
57    PermissionsSandbox,
58    /// 13. `permissions.protected_paths` — never-auto-approved paths.
59    PermissionsProtectedPaths,
60    /// 14. `trust` — project/workspace trust gate.
61    Trust,
62    /// 15. `mcp.client` — MCP stdio/remote/OAuth/resources/prompts.
63    McpClient,
64    /// 16. `mcp.server` — harness-as-MCP-server.
65    McpServer,
66    /// 17. `hooks` — config-registered lifecycle hooks.
67    Hooks,
68    /// 18. `plugins` — in-process extension API.
69    Plugins,
70    /// 19. `memory` — auto memory (agent-maintained, cross-session).
71    Memory,
72    /// 20. `checkpoint` — file checkpointing / shadow-git.
73    Checkpoint,
74    /// 21. `session.tree` — in-place tree, rewind, branch summaries.
75    SessionTree,
76    /// 22. `session.share` — public session-sharing links.
77    SessionShare,
78    /// 23. `reduction` — genuinely-optional lossless reduction policies.
79    Reduction,
80    /// 24. `deferred_tools` — deferred tool advertising + tool_search.
81    DeferredTools,
82    /// 25. `cache` — cache-aware architecture (plans, warnings, TTL).
83    Cache,
84    /// 26. `model.catalog` — aliases, fallback chains, small/utility model.
85    ModelCatalog,
86    /// 27. `model.oauth` — subscription OAuth login.
87    ModelOauth,
88    /// 28. `lsp` — LSP diagnostics in the edit path + query tool.
89    Lsp,
90    /// 29. `formatters` — format-on-write.
91    Formatters,
92    /// 30. `tui` — full-screen TUI.
93    Tui,
94    /// 31. `server` — full programmatic RPC/HTTP server.
95    Server,
96    /// 32. `notify` — external notify program / desktop / email.
97    Notify,
98    /// 33. `structured_output` — structured final output.
99    StructuredOutput,
100    /// 34. `telemetry` — OTel/analytics exporters.
101    Telemetry,
102    /// 35. `integrations` — cloud, IDE/ACP, CI bots, web UI, worktrees.
103    Integrations,
104}
105
106impl ModuleId {
107    /// Every module, in §2 table order (1-35).
108    pub const ALL: &'static [ModuleId] = &[
109        ModuleId::ToolsSearch,
110        ModuleId::ToolsApplyPatch,
111        ModuleId::ToolsPersistentShell,
112        ModuleId::ToolsBackground,
113        ModuleId::ToolsWeb,
114        ModuleId::ToolsQuestion,
115        ModuleId::Todos,
116        ModuleId::PlanMode,
117        ModuleId::Subagents,
118        ModuleId::PermissionsApprovals,
119        ModuleId::PermissionsRules,
120        ModuleId::PermissionsSandbox,
121        ModuleId::PermissionsProtectedPaths,
122        ModuleId::Trust,
123        ModuleId::McpClient,
124        ModuleId::McpServer,
125        ModuleId::Hooks,
126        ModuleId::Plugins,
127        ModuleId::Memory,
128        ModuleId::Checkpoint,
129        ModuleId::SessionTree,
130        ModuleId::SessionShare,
131        ModuleId::Reduction,
132        ModuleId::DeferredTools,
133        ModuleId::Cache,
134        ModuleId::ModelCatalog,
135        ModuleId::ModelOauth,
136        ModuleId::Lsp,
137        ModuleId::Formatters,
138        ModuleId::Tui,
139        ModuleId::Server,
140        ModuleId::Notify,
141        ModuleId::StructuredOutput,
142        ModuleId::Telemetry,
143        ModuleId::Integrations,
144    ];
145
146    /// The §3.1 config key this module reads (the same strings
147    /// [`crate::configfile::MODULE_NAMES`]/[`crate::configfile::NESTED_MODULE_NAMES`]
148    /// use), for diagnostics. The two schema-collapsed modules (see the
149    /// module doc comment) report their HOST table's name, since they have
150    /// no independent table of their own.
151    pub fn config_key(&self) -> &'static str {
152        match self {
153            ModuleId::ToolsSearch => "tools_search",
154            ModuleId::ToolsApplyPatch => "tools_apply_patch",
155            ModuleId::ToolsPersistentShell => "tools_persistent_shell",
156            ModuleId::ToolsBackground => "tools_background",
157            ModuleId::ToolsWeb => "tools_web",
158            ModuleId::ToolsQuestion => "tools_question",
159            ModuleId::Todos => "todos",
160            ModuleId::PlanMode => "plan_mode",
161            ModuleId::Subagents => "subagents",
162            ModuleId::PermissionsApprovals => "permissions",
163            ModuleId::PermissionsRules => "permissions.rules",
164            ModuleId::PermissionsSandbox => "permissions.sandbox",
165            ModuleId::PermissionsProtectedPaths => "permissions.protected_paths",
166            ModuleId::Trust => "trust",
167            ModuleId::McpClient => "mcp",
168            ModuleId::McpServer => "mcp.serve",
169            ModuleId::Hooks => "hooks",
170            ModuleId::Plugins => "plugins",
171            ModuleId::Memory => "memory",
172            ModuleId::Checkpoint => "checkpoint",
173            ModuleId::SessionTree => "session_tree",
174            ModuleId::SessionShare => "session_share",
175            ModuleId::Reduction => "reduction",
176            ModuleId::DeferredTools => "deferred_tools",
177            ModuleId::Cache => "cache",
178            ModuleId::ModelCatalog => "model_catalog",
179            ModuleId::ModelOauth => "model_oauth",
180            ModuleId::Lsp => "lsp",
181            ModuleId::Formatters => "formatters",
182            ModuleId::Tui => "tui",
183            ModuleId::Server => "server",
184            ModuleId::Notify => "notify",
185            ModuleId::StructuredOutput => "structured_output",
186            ModuleId::Telemetry => "telemetry",
187            ModuleId::Integrations => "integrations",
188        }
189    }
190
191    /// Whether this module is active in a resolved `HarnessConfig` — the
192    /// same [`module_enabled`]/`module_setting_bool` logic the P2 resolver
193    /// already computes (§3.5 step 7's "module-activation set"), reused
194    /// verbatim rather than re-derived, so [`ModuleActivation`] can never
195    /// diverge from [`crate::configfile::Resolved::modules`].
196    pub fn is_active(&self, hc: &HarnessConfig) -> bool {
197        match self {
198            ModuleId::McpServer => module_setting_bool(hc, "mcp", "serve"),
199            other => module_enabled(hc, other.config_key()),
200        }
201    }
202}
203
204impl std::fmt::Display for ModuleId {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        write!(f, "{}", self.config_key())
207    }
208}
209
210/// `[capabilities.tools_search]`'s three per-tool sub-flags (§3.1 module 1:
211/// `{ enabled, glob, content_search, list_dir }`), each defaulting to `true`
212/// per the schema when the module itself is active and the flag is unset.
213fn tools_search_subflag(hc: &HarnessConfig, key: &str) -> bool {
214    hc.capabilities
215        .get("tools_search")
216        .and_then(|c| c.settings.get(key))
217        .and_then(|v| v.as_bool())
218        .unwrap_or(true)
219}
220
221/// P4c: `[capabilities.tools_web]`'s two per-tool sub-flags (§3.1 module 5:
222/// `{ enabled, fetch, search }`), each defaulting to `true` per the schema
223/// when the module itself is active and the flag is unset — same shape as
224/// [`tools_search_subflag`].
225fn tools_web_subflag(hc: &HarnessConfig, key: &str) -> bool {
226    hc.capabilities
227        .get("tools_web")
228        .and_then(|c| c.settings.get(key))
229        .and_then(|v| v.as_bool())
230        .unwrap_or(true)
231}
232
233/// The resolved activation set for all 35 modules (§5.2 P3: "a resolved
234/// activation set on `Config`") — pure `HarnessConfig` → set, no loop
235/// required (§5.3 risk 2's testability mitigation). Also carries
236/// `tools_search`'s three per-tool sub-flags, since [`crate::tools::ToolRegistry::from_config`]
237/// needs them to decide which of `glob`/`search`/`list_dir` to register —
238/// and (P4c) `tools_web`'s two, for `web_fetch`/`web_search`.
239#[derive(Debug, Clone, Default)]
240pub struct ModuleActivation {
241    active: BTreeSet<ModuleId>,
242    /// `[capabilities.tools_search].glob` (default `true`).
243    pub tools_search_glob: bool,
244    /// `[capabilities.tools_search].content_search` (default `true`).
245    pub tools_search_content_search: bool,
246    /// `[capabilities.tools_search].list_dir` (default `true`).
247    pub tools_search_list_dir: bool,
248    /// P4c: `[capabilities.tools_web].fetch` (default `true`).
249    pub tools_web_fetch: bool,
250    /// P4c: `[capabilities.tools_web].search` (default `true`).
251    pub tools_web_search: bool,
252    /// BP-13: `[capabilities.tools_apply_patch].per_model` (default
253    /// `false`) — arms the per-model capability bits that decide which
254    /// write surface a model is offered (see
255    /// [`crate::tools::ToolRegistry::from_config`]). Its other reader is
256    /// the resolver's §2.2 C1 check, which is why this flag existed at all
257    /// before the bits did.
258    pub tools_apply_patch_per_model: bool,
259}
260
261impl ModuleActivation {
262    /// Compute the activation set from a resolved `HarnessConfig` (the
263    /// output of [`crate::configfile::resolve`]'s folding, before `Config`
264    /// materialization).
265    pub fn from_harness(hc: &HarnessConfig) -> Self {
266        let mut active = BTreeSet::new();
267        for &m in ModuleId::ALL {
268            if m.is_active(hc) {
269                active.insert(m);
270            }
271        }
272        ModuleActivation {
273            active,
274            tools_search_glob: tools_search_subflag(hc, "glob"),
275            tools_search_content_search: tools_search_subflag(hc, "content_search"),
276            tools_search_list_dir: tools_search_subflag(hc, "list_dir"),
277            tools_web_fetch: tools_web_subflag(hc, "fetch"),
278            tools_web_search: tools_web_subflag(hc, "search"),
279            tools_apply_patch_per_model: hc
280                .capabilities
281                .get("tools_apply_patch")
282                .and_then(|c| c.settings.get("per_model"))
283                .and_then(|v| v.as_bool())
284                .unwrap_or(false),
285        }
286    }
287
288    /// Whether `id` is active.
289    pub fn is_active(&self, id: ModuleId) -> bool {
290        self.active.contains(&id)
291    }
292
293    /// Every active module, in [`ModuleId::ALL`] order.
294    pub fn iter(&self) -> impl Iterator<Item = &ModuleId> {
295        self.active.iter()
296    }
297
298    /// Count of active modules.
299    pub fn len(&self) -> usize {
300        self.active.len()
301    }
302
303    /// Whether no module is active at all.
304    pub fn is_empty(&self) -> bool {
305        self.active.is_empty()
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::configfile::HarnessConfig;
313
314    #[test]
315    fn all_has_exactly_35_modules_matching_the_design_table() {
316        assert_eq!(ModuleId::ALL.len(), 35);
317        // No duplicates.
318        let set: BTreeSet<ModuleId> = ModuleId::ALL.iter().copied().collect();
319        assert_eq!(set.len(), 35);
320    }
321
322    #[test]
323    fn default_harness_config_activates_nothing() {
324        let hc = HarnessConfig::default();
325        let act = ModuleActivation::from_harness(&hc);
326        assert!(act.is_empty(), "expected no modules active by default");
327        for &m in ModuleId::ALL {
328            assert!(!act.is_active(m), "{m} unexpectedly active by default");
329        }
330    }
331
332    #[test]
333    fn mcp_server_reads_the_serve_bool_not_a_nested_table() {
334        let hc = HarnessConfig::from_toml_str(
335            r#"
336[capabilities.mcp]
337enabled = true
338serve = true
339"#,
340        )
341        .expect("parses");
342        let act = ModuleActivation::from_harness(&hc);
343        assert!(act.is_active(ModuleId::McpClient));
344        assert!(act.is_active(ModuleId::McpServer));
345    }
346
347    #[test]
348    fn permissions_approvals_reads_the_parent_tables_own_enabled() {
349        let hc = HarnessConfig::from_toml_str(
350            r#"
351[capabilities.permissions]
352enabled = true
353approval = "untrusted"
354"#,
355        )
356        .expect("parses");
357        let act = ModuleActivation::from_harness(&hc);
358        assert!(act.is_active(ModuleId::PermissionsApprovals));
359        // The nested sub-modules stay off — a separate `enabled` each.
360        assert!(!act.is_active(ModuleId::PermissionsRules));
361        assert!(!act.is_active(ModuleId::PermissionsSandbox));
362    }
363
364    #[test]
365    fn tools_search_subflags_default_true_when_module_active() {
366        let hc = HarnessConfig::from_toml_str(
367            r#"
368[capabilities.tools_search]
369enabled = true
370"#,
371        )
372        .expect("parses");
373        let act = ModuleActivation::from_harness(&hc);
374        assert!(act.is_active(ModuleId::ToolsSearch));
375        assert!(act.tools_search_glob);
376        assert!(act.tools_search_content_search);
377        assert!(act.tools_search_list_dir);
378    }
379
380    #[test]
381    fn tools_search_subflags_honor_explicit_false() {
382        let hc = HarnessConfig::from_toml_str(
383            r#"
384[capabilities.tools_search]
385enabled = true
386list_dir = false
387"#,
388        )
389        .expect("parses");
390        let act = ModuleActivation::from_harness(&hc);
391        assert!(act.tools_search_glob);
392        assert!(!act.tools_search_list_dir);
393    }
394
395    /// P4c: `tools_web`'s `fetch`/`search` sub-flags default `true` when the
396    /// module is active — same shape as `tools_search`'s three.
397    #[test]
398    fn tools_web_subflags_default_true_when_module_active() {
399        let hc = HarnessConfig::from_toml_str(
400            r#"
401[capabilities.tools_web]
402enabled = true
403"#,
404        )
405        .expect("parses");
406        let act = ModuleActivation::from_harness(&hc);
407        assert!(act.is_active(ModuleId::ToolsWeb));
408        assert!(act.tools_web_fetch);
409        assert!(act.tools_web_search);
410    }
411
412    /// P4c: `tools_web`'s sub-flags honor an explicit `false`.
413    #[test]
414    fn tools_web_subflags_honor_explicit_false() {
415        let hc = HarnessConfig::from_toml_str(
416            r#"
417[capabilities.tools_web]
418enabled = true
419search = false
420"#,
421        )
422        .expect("parses");
423        let act = ModuleActivation::from_harness(&hc);
424        assert!(act.tools_web_fetch);
425        assert!(!act.tools_web_search);
426    }
427
428    /// Default-off: `tools_web` inactive by default (no `[capabilities.tools_web]`
429    /// table at all) leaves both sub-flags at their struct default (`false`)
430    /// — never consulted anyway since `ToolRegistry::from_config` gates on
431    /// `act.is_active(ModuleId::ToolsWeb)` first.
432    #[test]
433    fn tools_web_inactive_by_default() {
434        let hc = HarnessConfig::default();
435        let act = ModuleActivation::from_harness(&hc);
436        assert!(!act.is_active(ModuleId::ToolsWeb));
437    }
438}