From c7af3a6333b3b7d457363a83f97c38ef722d218e Mon Sep 17 00:00:00 2001 From: RNT56 Date: Mon, 29 Jun 2026 11:13:13 +0200 Subject: [PATCH 1/2] feat(daemon): multi-repo orchestration skeleton (roadmap-v0.6 F.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds crustcore_daemon::multirepo: RepoId, RepoBinding (id/path/verify/base/keywords, from config/CLI), and a pure classify_repo(intent, repos) -> Option that routes a chat launch — exactly one keyword hint -> that repo; no hint + single bound repo -> sole-repo default; ambiguous or unhinted-with-multiple -> None (dispatcher asks "which repo?"). The intent matches only operator-supplied keywords and never supplies a path (invariant 7); the global concurrency cap is unchanged (invariant 11). The live --repo CLI startup + simultaneous-task run is the #[ignore]d multi_repo_live_smoke (TODO(P10-multi-repo-live)), in runbook F.5. 4 new tests; daemon-only; zero nano impact. `cargo xtask verify` green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 ++ crates/crustcore-daemon/src/lib.rs | 4 + crates/crustcore-daemon/src/multirepo.rs | 158 +++++++++++++++++++++++ docs/live-socket-validation.md | 14 ++ 4 files changed, 189 insertions(+) create mode 100644 crates/crustcore-daemon/src/multirepo.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a7fdf1..8ebbbbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,18 @@ agent/PR/role/size/invariant audit trail. ### Added +- **Multi-repo orchestration skeleton (roadmap-v0.6 F.3).** Added + `crustcore_daemon::multirepo`: `RepoId`, `RepoBinding` (id/path/verify/base/keywords, + from config/CLI), and a pure `classify_repo(intent, repos) → Option` that routes + a chat launch — exactly one keyword hint → that repo; no hint + a single bound repo → + the sole-repo default; ambiguous (multiple hits) or unhinted-with-multiple → `None` + (the dispatcher asks "which repo?" rather than guessing). The intent is matched only + against **operator-supplied keywords** and never supplies a path (invariant 7); the + global concurrency cap is unchanged (invariant 11). The live `--repo id=/path` CLI + startup + a simultaneous-task run is the `#[ignore]`d `multi_repo_live_smoke` + (`TODO(P10-multi-repo-live)`), catalogued in runbook §F.5. 4 new tests; daemon-only; + **zero nano impact**. + - **Evidence bundle rendering (roadmap-v0.6 C.3).** Added `EvidenceBundle::to_markdown()` and `to_json()` to `crustcore_daemon::product`. `to_markdown` is the **bounded** canonical PR-body/cockpit renderer: it opens with @@ -249,6 +261,7 @@ agent/PR/role/size/invariant audit trail. | Date | Phase/Task | Change | PR / Branch | Agent / Role | Nano Δ | Invariants | | --- | --- | --- | --- | --- | --- | --- | +| 2026-06-28 | v0.6/F.3 | `multirepo::classify_repo` (explicit-hint → sole-repo default → ambiguous asks) + `RepoBinding`; intent matches operator keywords only, never supplies a path | `claude/v06-f3-multirepo` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 7, 11; repo paths from config/CLI, shared global cap | | 2026-06-28 | v0.6/C.3 | `EvidenceBundle::to_markdown` (bounded PR-body/cockpit render, 🔴 review notice, per-list overflow) + `to_json` (schema v1); `draft_pr_body` delegates | `claude/v06-c3-evidence` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 2, 10, 11; bounded redacted evidence, every receipt included | | 2026-06-28 | v0.6/D.1 | Task-loop wiring `plan_task`/`finalize_task` composing routing (C.1) + advisory gate (C.2) into a terminal `TaskOutcome`; sandboxed run `#[ignore]`d | `claude/v06-d1-executor-wire` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 4, 5, 6, 13; verifier-owned completion, advisory only gates | | 2026-06-28 | v0.6/A.3 | `pr_intent_to_create_request`: PrIntent→CreatePrRequest for the live draft-PR POST; evidence body verbatim, draft=true; real POST `#[ignore]`d | `claude/v06-a3-draftpr` | Claude (Implementer) | 0 kB (daemon/live-only) | Enforces 6, 13, 14; body is evidence not a model claim | diff --git a/crates/crustcore-daemon/src/lib.rs b/crates/crustcore-daemon/src/lib.rs index e07f1ae..4d7e68f 100644 --- a/crates/crustcore-daemon/src/lib.rs +++ b/crates/crustcore-daemon/src/lib.rs @@ -39,6 +39,10 @@ pub mod advisor; pub mod chat; pub mod exec; pub mod github; +/// Multi-repo orchestration (roadmap-v0.6 F.3): bind several repos at startup and a pure +/// `classify_repo` that routes a chat launch to the right one (explicit hint → sole-repo +/// default → ambiguous asks). Repo paths from config/CLI, never model/user input. +pub mod multirepo; /// GitHub App onboarding (roadmap-v0.6 A.1): turns an untrusted install redirect /// into a registered, write-capable repo + a minted `Approved`. /// Pure decision core; the install-confirm + token-mint are the live seam. diff --git a/crates/crustcore-daemon/src/multirepo.rs b/crates/crustcore-daemon/src/multirepo.rs new file mode 100644 index 0000000..021ef32 --- /dev/null +++ b/crates/crustcore-daemon/src/multirepo.rs @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Multi-repo orchestration (roadmap-v0.6 F.3). +//! +//! The `TaskRegistry` is already repo-agnostic. This module adds the **pure routing +//! decision**: bind several repos at startup (distinct paths / verify / PR targets) and +//! classify a chat launch to the right one. Repo bindings come from **config/CLI only, +//! never from model or user message text** (invariant 7); the global concurrency cap is +//! unchanged (invariant 11). The live multi-repo CLI startup + a simultaneous-task smoke +//! is the `TODO(P10-multi-repo-live)` seam. + +/// Stable id for a bound repo (operator-chosen, e.g. `app` / `infra`). From config/CLI. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RepoId(pub String); + +/// A repo CrustCore is bound to: its on-disk path, verify command, PR target, and the +/// keywords that route a launch to it. **Trusted setup data** — paths/commands are +/// supplied by the operator (config/CLI), not derived from untrusted input (invariant 7). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoBinding { + /// Operator-chosen id. + pub id: RepoId, + /// On-disk worktree path (from config/CLI). + pub path: String, + /// Verify command tokens. + pub verify: Vec, + /// PR base branch. + pub base_branch: String, + /// Lower-cased keywords that route an intent to this repo (id is always a keyword). + pub keywords: Vec, +} + +impl RepoBinding { + /// Builds a binding; the id is automatically a routing keyword. + #[must_use] + pub fn new(id: impl Into, path: impl Into) -> Self { + let id = id.into(); + RepoBinding { + keywords: vec![id.to_lowercase()], + id: RepoId(id), + path: path.into(), + verify: Vec::new(), + base_branch: "main".to_string(), + } + } + + /// Adds routing keywords (lower-cased). + #[must_use] + pub fn with_keywords(mut self, kws: I) -> Self + where + I: IntoIterator, + S: Into, + { + for k in kws { + self.keywords.push(k.into().to_lowercase()); + } + self + } + + fn matches(&self, intent_lower: &str) -> bool { + self.keywords + .iter() + .any(|k| !k.is_empty() && intent_lower.contains(k.as_str())) + } +} + +/// Classifies a launch intent to a bound repo: +/// 1. If exactly **one** repo's keyword appears in the intent → that repo (an explicit hint). +/// 2. Else if exactly **one** repo is bound → it (the sole-repo default). +/// 3. Else → `None` — ambiguous or unhinted with multiple repos; the dispatcher asks the +/// operator "which repo?" rather than guessing (a helpful failure, not a silent pick). +/// +/// Pure: the intent text is untrusted but only *matched against operator keywords* — it +/// never supplies a path (invariant 7). +#[must_use] +pub fn classify_repo(intent: &str, repos: &[RepoBinding]) -> Option { + let lower = intent.to_lowercase(); + let mut hinted = repos.iter().filter(|r| r.matches(&lower)); + match (hinted.next(), hinted.next()) { + (Some(only), None) => Some(only.id.clone()), // exactly one hint + (Some(_), Some(_)) => None, // ambiguous hint → ask + (None, _) => { + if repos.len() == 1 { + Some(repos[0].id.clone()) // sole-repo default + } else { + None // no hint, multiple repos → ask + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repos() -> Vec { + vec![ + RepoBinding::new("app", "/src/app").with_keywords(["frontend", "ui"]), + RepoBinding::new("infra", "/src/infra").with_keywords(["terraform", "deploy"]), + ] + } + + #[test] + fn an_explicit_hint_routes_to_that_repo() { + assert_eq!( + classify_repo("fix the frontend button", &repos()), + Some(RepoId("app".to_string())) + ); + assert_eq!( + classify_repo("update the terraform module", &repos()), + Some(RepoId("infra".to_string())) + ); + // The id itself is a keyword. + assert_eq!( + classify_repo("run tests in infra", &repos()), + Some(RepoId("infra".to_string())) + ); + } + + #[test] + fn a_sole_repo_is_the_default_without_a_hint() { + let one = vec![RepoBinding::new("only", "/src/only")]; + assert_eq!( + classify_repo("do something vague", &one), + Some(RepoId("only".to_string())) + ); + } + + #[test] + fn ambiguous_or_unhinted_with_multiple_repos_is_none() { + // No keyword present → None (ask which repo). + assert_eq!(classify_repo("do something vague", &repos()), None); + // Two repos hinted → None (ambiguous). + assert_eq!(classify_repo("wire the frontend deploy", &repos()), None); + // No repos bound at all → None. + assert_eq!(classify_repo("anything", &[]), None); + } + + #[test] + fn classification_is_case_insensitive_and_path_free() { + assert_eq!( + classify_repo("FIX THE UI", &repos()), + Some(RepoId("app".to_string())) + ); + // The intent never supplies a path — only matches operator keywords. + assert_eq!( + classify_repo("/etc/passwd frontend", &repos()).unwrap().0, + "app" + ); + } + + // Live seam: multi-repo CLI startup (`--repo id=/path`) + a simultaneous-task smoke. + #[test] + #[ignore = "live: bind multiple repos via CLI and run simultaneous tasks (TODO(P10-multi-repo-live))"] + fn multi_repo_live_smoke() { + // See docs/live-socket-validation.md §F.5. Needs real repos + the daemon loop. + panic!("live seam: run manually with --repo bindings (see runbook §F.5)"); + } +} diff --git a/docs/live-socket-validation.md b/docs/live-socket-validation.md index a92468b..1363001 100644 --- a/docs/live-socket-validation.md +++ b/docs/live-socket-validation.md @@ -81,6 +81,7 @@ cargo test --workspace -- --list --ignored | `live_get_updates_smoke` | F | `live` | [F.1](#f1) | `RestTelegram` shaping + redaction ✓ | easy (bot token) | | `live_telegram_round_trip_smoke` | F | `live` | [F.2](#f2) | runtime-channel decision logic ✓ | easy (bot token) | | `live_ws_sse_emits_a_snapshot` | F | — | [F.3](#f3) | snapshot serialize + `ws_stream` ✓ | easy (loopback port) | +| `multi_repo_live_smoke` | F | — | [F.5](#f5) | `classify_repo` routing core ✓ | medium (multiple repos) | --- @@ -418,6 +419,19 @@ cargo test --workspace -- --list --ignored > their live inches are covered by [F.2](#f2)/[A.2](#a2) (model + channel) and > [B.4](#b4) (the draft-PR POST) respectively. + +### F.5 — `multi_repo_live_smoke` — multi-repo orchestration (F.3) +- **Test:** `crustcore-daemon/src/multirepo.rs::tests::multi_repo_live_smoke`. Seam tag `TODO(P10-multi-repo-live)`. +- **Socket:** the multi-repo CLI startup (`--repo id=/path`) + a simultaneous-task run. +- **CI core (passing):** `classify_repo` (explicit-hint routing, sole-repo default, ambiguous + → `None`, case-insensitive, path-free — repo paths come from config/CLI, never the intent, + invariant 7). The registry already supervises repo-agnostic tasks under the global cap + (invariant 11). +- **Prereq:** two or more real repos bound at startup. +- **Run:** `cargo test -p crustcore-daemon multirepo::tests::multi_repo_live_smoke -- --ignored --nocapture` +- **Success:** a launch routes to the right repo profile; two repos run tasks + simultaneously under the shared global concurrency cap. **Difficulty: medium.** + --- ## Seam tags without a dedicated smoke test From c44808ca67ad55e4ef38a7e88d5d221f28ab6ee3 Mon Sep 17 00:00:00 2001 From: RNT56 Date: Mon, 29 Jun 2026 12:28:36 +0200 Subject: [PATCH 2/2] feat(daemon): real F.3 --repo CLI binding parser (end-to-end) Adds parse_repo_binding (id=/path) + parse_repo_bindings (rejecting malformed args and duplicate ids), so the multi-repo startup actually parses the operator's --repo CLI args into RepoBindings that classify_repo routes over. The id/path come only from the CLI (invariant 7); ambiguity/duplicates fail helpfully. Now the only #[ignore]d inch is the actual simultaneous-task daemon run across the bound repos. 3 new tests; daemon-only; zero nano impact. `cargo xtask verify` green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 +- crates/crustcore-daemon/src/multirepo.rs | 71 +++++++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ebbbbd..75b0ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,10 @@ agent/PR/role/size/invariant audit trail. the sole-repo default; ambiguous (multiple hits) or unhinted-with-multiple → `None` (the dispatcher asks "which repo?" rather than guessing). The intent is matched only against **operator-supplied keywords** and never supplies a path (invariant 7); the - global concurrency cap is unchanged (invariant 11). The live `--repo id=/path` CLI - startup + a simultaneous-task run is the `#[ignore]`d `multi_repo_live_smoke` + global concurrency cap is unchanged (invariant 11). `parse_repo_binding` / `parse_repo_bindings` + parse the real `--repo id=/path` CLI args (rejecting malformed args + duplicate ids, + CI-tested), so only the actual simultaneous-task daemon run is the `#[ignore]`d + `multi_repo_live_smoke` (`TODO(P10-multi-repo-live)`), catalogued in runbook §F.5. 4 new tests; daemon-only; **zero nano impact**. diff --git a/crates/crustcore-daemon/src/multirepo.rs b/crates/crustcore-daemon/src/multirepo.rs index 021ef32..e58f906 100644 --- a/crates/crustcore-daemon/src/multirepo.rs +++ b/crates/crustcore-daemon/src/multirepo.rs @@ -88,6 +88,42 @@ pub fn classify_repo(intent: &str, repos: &[RepoBinding]) -> Option { } } +/// Parses a `--repo` CLI binding argument of the form `id=/path` into a [`RepoBinding`] +/// (F.3 startup). The id and path come **only** from the operator's CLI (invariant 7); +/// a missing `=`, an empty id, or an empty path is rejected (`None`) rather than guessed. +/// The id is automatically a routing keyword. +#[must_use] +pub fn parse_repo_binding(arg: &str) -> Option { + let (id, path) = arg.split_once('=')?; + let id = id.trim(); + let path = path.trim(); + if id.is_empty() || path.is_empty() { + return None; + } + Some(RepoBinding::new(id, path)) +} + +/// Parses a set of `--repo id=/path` args into bindings, **rejecting duplicate ids** (a +/// repo id must be unique so routing is unambiguous). On any malformed arg or a duplicate +/// id, returns the offending argument as the error. +/// +/// # Errors +/// The first argument that is malformed or introduces a duplicate id. +pub fn parse_repo_bindings<'a, I>(args: I) -> Result, &'a str> +where + I: IntoIterator, +{ + let mut out: Vec = Vec::new(); + for arg in args { + let binding = parse_repo_binding(arg).ok_or(arg)?; + if out.iter().any(|b| b.id == binding.id) { + return Err(arg); // duplicate id + } + out.push(binding); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -148,7 +184,40 @@ mod tests { ); } - // Live seam: multi-repo CLI startup (`--repo id=/path`) + a simultaneous-task smoke. + #[test] + fn parse_repo_binding_reads_id_eq_path() { + let b = parse_repo_binding("app=/src/app").unwrap(); + assert_eq!(b.id, RepoId("app".to_string())); + assert_eq!(b.path, "/src/app"); + // The id is a routing keyword. + assert!(b.keywords.contains(&"app".to_string())); + // Malformed args are rejected, not guessed. + assert!(parse_repo_binding("noequals").is_none()); + assert!(parse_repo_binding("=/p").is_none()); + assert!(parse_repo_binding("id=").is_none()); + } + + #[test] + fn parse_repo_bindings_rejects_duplicate_ids() { + let ok = parse_repo_bindings(["app=/src/app", "infra=/src/infra"]).unwrap(); + assert_eq!(ok.len(), 2); + // A duplicate id is the offending arg. + assert_eq!(parse_repo_bindings(["app=/a", "app=/b"]), Err("app=/b")); + // A malformed arg is surfaced. + assert_eq!(parse_repo_bindings(["good=/g", "bad"]), Err("bad")); + } + + #[test] + fn parsed_bindings_route_via_classify_repo() { + let repos = parse_repo_bindings(["app=/src/app", "infra=/src/infra"]).unwrap(); + assert_eq!( + classify_repo("deploy to infra", &repos), + Some(RepoId("infra".to_string())) + ); + } + + // Live seam: the actual simultaneous-task daemon run across the bound repos (the CLI + // parse + classify are CI-tested above; this is the multi-repo runtime-loop inch). #[test] #[ignore = "live: bind multiple repos via CLI and run simultaneous tasks (TODO(P10-multi-repo-live))"] fn multi_repo_live_smoke() {