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}