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}