# M5d-2 task 03: `[heartbeat]` and unknown `[compact] summarise` names **Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short` is empty; otherwise stop. Never switch branch. **Commit subject:** `loopd: [heartbeat] table and summarise name check` **Log row:** the Task cell is `M5d2/03-loopd-heartbeat-config`. ## Goal `[heartbeat]` on `loopd.toml` (spec 4.1, 5): `every_s` (default `0`, disables) and `prompt` (default `heartbeat.md`, next to the config file). Unknown keys are errors. Unknown names in `[compact] summarise` are a config error (spec 4.3). `Config::check_summarise` takes `&Registry`. `loopd` calls it after `load` with `Registry::m5c()`. `parse` still succeeds with an unknown name: the given tests call `check_summarise` themselves. The starter `config/heartbeat.md` is copied like `config/system.md`. Nothing ticks yet. ## Every place that changes | Place | What happens there | |---|---| | `crates/loopd/src/config.rs` | `Heartbeat`; field; `load` joins the prompt; `heartbeat_prompt`; `check_summarise` | | `crates/loopd/src/main.rs` | after each `Config::load`, `check_summarise(&Registry::m5c())` | | `crates/loopd/tests/ask_memory_tool.rs` | `Registry::m5c()` count in `main.rs` is 3 (edit A1) | | `config/heartbeat.md` | new, given starter | | `crates/loopd/tests/config_heartbeat.rs` | new, given | | everything else | **no change** | ## Files - Copy, from `docs/plans/M5d2/files/` (the same path under it): the new test `crates/loopd/tests/config_heartbeat.rs` and the starter `config/heartbeat.md`. - Modify: `crates/loopd/src/config.rs`, `crates/loopd/src/main.rs`, `crates/loopd/tests/ask_memory_tool.rs`, `docs/implementer-log.md`. - Do not touch: any other file. ## Facts you need - **`every_s` is `u64`.** `0` means off. No range check. - **`prompt` after `load`.** Join it to the config file's parent, the same way `load` already joins `baseline.system`. `heartbeat_prompt()` returns `self.heartbeat.prompt.clone()`. After `parse` (no file), the path stays relative. - **`check_summarise`.** For each name in `self.compact.summarise`, `registry.get(name)` must be `Some`. Empty list is allowed. The error `String` contains both `summarise` and the unknown name. `Registry::get` is already `pub`. `http_fetch` and `clock` are in `m5c()`. - **`main.rs`.** Both `run_selftest` and `run_serve` load a config. After a successful `load`, if `check_summarise` fails, print `loopd: {e}` and return exit 1, as for a load error. - **`#[serde(deny_unknown_fields, default)]`** on `Heartbeat`, like `Compact`. - The items the edits use. This table is complete. | Item | What it does | |---|---| | `Registry::get` | known tool names | | `Registry::m5c` | what `loopd serve` uses | | `Path::join` | prompt beside the config file | ## The edits, exactly Each quoted "before" block occurs exactly once after M5d-1 (compact is already on `Config`). ### `crates/loopd/src/config.rs` **H1.** After the `Compact` `Default` impl (the closing `}` of that impl, then the `Config` struct's `memory` / `compact` fields). After ```rust #[serde(default)] pub compact: Compact, } ``` become ```rust #[serde(default)] pub compact: Compact, #[serde(default)] pub heartbeat: Heartbeat, } ``` **H2.** After the `Compact` `Default` impl, add `Heartbeat` (before the `Config` struct if that is where `Compact` sits; keep `Heartbeat` next to `Compact`). The new type: ```rust /// `[heartbeat]`: how often the standing `heartbeat` session runs (M5d spec, section 4.1). /// `every_s` 0 disables it. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct Heartbeat { pub every_s: u64, pub prompt: std::path::PathBuf, } impl Default for Heartbeat { fn default() -> Self { Self { every_s: 0, prompt: std::path::PathBuf::from("heartbeat.md"), } } } ``` If `Compact` sits just above `Config`, put `Heartbeat` immediately after `Compact`'s `Default` impl and **then** do H1. If a "before" block is not found exactly once, stop. **H3.** In `load`, the lines ```rust if let Some(parent) = path.parent() { config.baseline.system = parent.join(&config.baseline.system); } Ok(config) ``` become ```rust if let Some(parent) = path.parent() { config.baseline.system = parent.join(&config.baseline.system); config.heartbeat.prompt = parent.join(&config.heartbeat.prompt); } Ok(config) ``` **H4.** After `compact_prompt` (the lines) ```rust pub fn compact_prompt(&self) -> std::path::PathBuf { self.baseline.system.with_file_name("compact.md") } } ``` become ```rust pub fn compact_prompt(&self) -> std::path::PathBuf { self.baseline.system.with_file_name("compact.md") } /// The heartbeat prompt, already joined to the config file's directory by `load`. pub fn heartbeat_prompt(&self) -> std::path::PathBuf { self.heartbeat.prompt.clone() } /// Unknown `[compact] summarise` names are a config error (M5d spec, section 4.3). pub fn check_summarise(&self, registry: &crate::tools::Registry) -> Result<(), String> { for name in &self.compact.summarise { if registry.get(name).is_none() { return Err(format!("[compact] summarise: unknown tool {name}")); } } Ok(()) } } ``` ### `crates/loopd/src/main.rs` **M1.** In `run_selftest`, after a successful `Config::load` and before `Client::new`, add: ```rust if let Err(e) = cfg.check_summarise(&loopd::tools::Registry::m5c()) { eprintln!("loopd: {e}"); return ExitCode::from(1); } ``` `Registry` is already imported in `main.rs`. Use `Registry::m5c()`. **M2.** The same check in `run_serve`, after a successful `load` and before the instance lock. If `main.rs` already has `use loopd::tools::{Registry, ToolPort};`, write `Registry::m5c()`. ### `crates/loopd/tests/ask_memory_tool.rs` **A1.** `loopd_serve_uses_the_m5c_registry` counts `Registry::m5c()` in `main.rs`. Today that count is 1 (`Context::new`). M1 and M2 add two more. The lines ```rust main.matches("Registry::m5c()").count(), 1, "main.rs uses m5c()" ``` become ```rust main.matches("Registry::m5c()").count(), 3, "main.rs uses m5c()" ``` The test still forbids `m2b()`, `m3a()` and `m5a()` in `main.rs`. Do not weaken that. ## Steps - [ ] **1. Copy the given files.** ```sh cp docs/plans/M5d2/files/crates/loopd/tests/config_heartbeat.rs crates/loopd/tests/ cp docs/plans/M5d2/files/config/heartbeat.md config/heartbeat.md ``` - [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms: test target `config_heartbeat` does not compile (`E0609` no field `heartbeat`, `E0599` no method `heartbeat_prompt` / `check_summarise`). Every other target compiles. - [ ] **3. The edits.** H1 to H4, M1, M2, A1, in order. `cargo check -p loopd` prints no error and no warning. - [ ] **4. The tests.** `cargo test -p loopd --test config_heartbeat --test config --test config_compact --test ask_memory_tool`: every test passes, 8 in `config_heartbeat`. - [ ] **5. Check.** `git diff --stat` lists `crates/loopd/src/config.rs`, `crates/loopd/src/main.rs` and `crates/loopd/tests/ask_memory_tool.rs`. `git status --short` shows the new test and `config/heartbeat.md`. - [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line `gate: ok`. Test count **1526** (1518 before, plus 8). After `gate: ok`, do not add up `test result:` lines. - [ ] **7. Log and commit.** Task cell `M5d2/03-loopd-heartbeat-config`. Stage with `git add crates/loopd config/heartbeat.md docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says. ## Done when - `make gate` prints `gate: ok` with 1526 tests. - `git diff HEAD~1 --stat` lists only `crates/loopd/src/config.rs`, `crates/loopd/src/main.rs`, `crates/loopd/tests/ask_memory_tool.rs`, `crates/loopd/tests/config_heartbeat.rs`, `config/heartbeat.md` and the log. ## Stop and report if - The branch check fails; a "before" block is not found exactly once; step 2 differs; `cargo check` warns; a test fails; the gate's last line is not `gate: ok`; you leave the `m5c()` count at 1; you join `heartbeat.prompt` onto `system.md`'s directory instead of the config file's parent; you reject an empty `summarise` list; you check names inside `parse`. ## What the given tests pin A missing table is disabled and `heartbeat_prompt` is beside the config file; overrides replace defaults; unknown keys error; default `http_fetch` is known to `m5c`; an unknown name errors with `summarise` and that name; empty is allowed; `http_fetch` and `clock` together are known.