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}
253
254impl ModuleActivation {
255    /// Compute the activation set from a resolved `HarnessConfig` (the
256    /// output of [`crate::configfile::resolve`]'s folding, before `Config`
257    /// materialization).
258    pub fn from_harness(hc: &HarnessConfig) -> Self {
259        let mut active = BTreeSet::new();
260        for &m in ModuleId::ALL {
261            if m.is_active(hc) {
262                active.insert(m);
263            }
264        }
265        ModuleActivation {
266            active,
267            tools_search_glob: tools_search_subflag(hc, "glob"),
268            tools_search_content_search: tools_search_subflag(hc, "content_search"),
269            tools_search_list_dir: tools_search_subflag(hc, "list_dir"),
270            tools_web_fetch: tools_web_subflag(hc, "fetch"),
271            tools_web_search: tools_web_subflag(hc, "search"),
272        }
273    }
274
275    /// Whether `id` is active.
276    pub fn is_active(&self, id: ModuleId) -> bool {
277        self.active.contains(&id)
278    }
279
280    /// Every active module, in [`ModuleId::ALL`] order.
281    pub fn iter(&self) -> impl Iterator<Item = &ModuleId> {
282        self.active.iter()
283    }
284
285    /// Count of active modules.
286    pub fn len(&self) -> usize {
287        self.active.len()
288    }
289
290    /// Whether no module is active at all.
291    pub fn is_empty(&self) -> bool {
292        self.active.is_empty()
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::configfile::HarnessConfig;
300
301    #[test]
302    fn all_has_exactly_35_modules_matching_the_design_table() {
303        assert_eq!(ModuleId::ALL.len(), 35);
304        // No duplicates.
305        let set: BTreeSet<ModuleId> = ModuleId::ALL.iter().copied().collect();
306        assert_eq!(set.len(), 35);
307    }
308
309    #[test]
310    fn default_harness_config_activates_nothing() {
311        let hc = HarnessConfig::default();
312        let act = ModuleActivation::from_harness(&hc);
313        assert!(act.is_empty(), "expected no modules active by default");
314        for &m in ModuleId::ALL {
315            assert!(!act.is_active(m), "{m} unexpectedly active by default");
316        }
317    }
318
319    #[test]
320    fn mcp_server_reads_the_serve_bool_not_a_nested_table() {
321        let hc = HarnessConfig::from_toml_str(
322            r#"
323[capabilities.mcp]
324enabled = true
325serve = true
326"#,
327        )
328        .expect("parses");
329        let act = ModuleActivation::from_harness(&hc);
330        assert!(act.is_active(ModuleId::McpClient));
331        assert!(act.is_active(ModuleId::McpServer));
332    }
333
334    #[test]
335    fn permissions_approvals_reads_the_parent_tables_own_enabled() {
336        let hc = HarnessConfig::from_toml_str(
337            r#"
338[capabilities.permissions]
339enabled = true
340approval = "untrusted"
341"#,
342        )
343        .expect("parses");
344        let act = ModuleActivation::from_harness(&hc);
345        assert!(act.is_active(ModuleId::PermissionsApprovals));
346        // The nested sub-modules stay off — a separate `enabled` each.
347        assert!(!act.is_active(ModuleId::PermissionsRules));
348        assert!(!act.is_active(ModuleId::PermissionsSandbox));
349    }
350
351    #[test]
352    fn tools_search_subflags_default_true_when_module_active() {
353        let hc = HarnessConfig::from_toml_str(
354            r#"
355[capabilities.tools_search]
356enabled = true
357"#,
358        )
359        .expect("parses");
360        let act = ModuleActivation::from_harness(&hc);
361        assert!(act.is_active(ModuleId::ToolsSearch));
362        assert!(act.tools_search_glob);
363        assert!(act.tools_search_content_search);
364        assert!(act.tools_search_list_dir);
365    }
366
367    #[test]
368    fn tools_search_subflags_honor_explicit_false() {
369        let hc = HarnessConfig::from_toml_str(
370            r#"
371[capabilities.tools_search]
372enabled = true
373list_dir = false
374"#,
375        )
376        .expect("parses");
377        let act = ModuleActivation::from_harness(&hc);
378        assert!(act.tools_search_glob);
379        assert!(!act.tools_search_list_dir);
380    }
381
382    /// P4c: `tools_web`'s `fetch`/`search` sub-flags default `true` when the
383    /// module is active — same shape as `tools_search`'s three.
384    #[test]
385    fn tools_web_subflags_default_true_when_module_active() {
386        let hc = HarnessConfig::from_toml_str(
387            r#"
388[capabilities.tools_web]
389enabled = true
390"#,
391        )
392        .expect("parses");
393        let act = ModuleActivation::from_harness(&hc);
394        assert!(act.is_active(ModuleId::ToolsWeb));
395        assert!(act.tools_web_fetch);
396        assert!(act.tools_web_search);
397    }
398
399    /// P4c: `tools_web`'s sub-flags honor an explicit `false`.
400    #[test]
401    fn tools_web_subflags_honor_explicit_false() {
402        let hc = HarnessConfig::from_toml_str(
403            r#"
404[capabilities.tools_web]
405enabled = true
406search = false
407"#,
408        )
409        .expect("parses");
410        let act = ModuleActivation::from_harness(&hc);
411        assert!(act.tools_web_fetch);
412        assert!(!act.tools_web_search);
413    }
414
415    /// Default-off: `tools_web` inactive by default (no `[capabilities.tools_web]`
416    /// table at all) leaves both sub-flags at their struct default (`false`)
417    /// — never consulted anyway since `ToolRegistry::from_config` gates on
418    /// `act.is_active(ModuleId::ToolsWeb)` first.
419    #[test]
420    fn tools_web_inactive_by_default() {
421        let hc = HarnessConfig::default();
422        let act = ModuleActivation::from_harness(&hc);
423        assert!(!act.is_active(ModuleId::ToolsWeb));
424    }
425}