diff --git a/apps/desktop/src-tauri/src/contracts/fixtures.json b/apps/desktop/src-tauri/src/contracts/fixtures.json index 9baa4579..d5c745ba 100644 --- a/apps/desktop/src-tauri/src/contracts/fixtures.json +++ b/apps/desktop/src-tauri/src/contracts/fixtures.json @@ -237,52 +237,6 @@ "cancel-issue-validation": { "type": "cancel-issue-validation", "runId": "run-iv1" - }, - "start-council": { - "type": "start-council", - "runId": "run-council1", - "presetId": "research", - "objective": "Recommend a caching strategy for the session store.", - "projectPath": "/proj" - }, - "kill-council": { - "type": "kill-council", - "runId": "run-council1" - }, - "resolve-council-converge": { - "type": "resolve-council-converge", - "runId": "run-council1", - "decision": "accept", - "seatId": "proposer-opus", - "note": "Clearest migration plan with the least dual-write risk." - }, - "set-council-routing": { - "type": "set-council-routing", - "runId": "run-council1", - "edges": [ - { - "from": "proposer-opus", - "to": "critic-opus" - }, - { - "from": "proposer-sonnet", - "to": "critic-opus" - } - ] - }, - "send-council-human-input": { - "type": "send-council-human-input", - "runId": "run-council1", - "mode": "direct", - "seatId": "critic-opus", - "message": "Weigh the dual-write rollback path before you settle." - }, - "resolve-worktree-op": { - "type": "resolve-worktree-op", - "requestId": "wt-1", - "worktreePath": "/proj/.nightcore/worktrees/council-run-1", - "gauntletPassed": true, - "gauntletSummary": "Structure-Lock gauntlet passed (2 check(s))." } }, "queries": { @@ -1181,27 +1135,6 @@ "issueNumber": 128, "taskId": "task-42" }, - "debate-entry": { - "type": "debate-entry", - "runId": "council-run-1", - "entry": { - "stage": "debate", - "seatId": "proposer-1", - "role": "critic", - "kind": "delivery", - "seq": 3, - "content": "Seat proposer-1 said: \"consider the isolated-worktree path\"", - "broadcastId": "bc-1", - "at": 1718900000000, - "injectionFlags": [] - } - }, - "worktree-op-required": { - "type": "worktree-op-required", - "requestId": "wt-1", - "op": "allocate", - "councilRunId": "council-run-1" - }, "query-result": { "type": "query-result", "requestId": "q-1", diff --git a/apps/desktop/src-tauri/src/contracts/generated.rs b/apps/desktop/src-tauri/src/contracts/generated.rs index d5eb3989..966e8f9a 100644 --- a/apps/desktop/src-tauri/src/contracts/generated.rs +++ b/apps/desktop/src-tauri/src/contracts/generated.rs @@ -55,8 +55,6 @@ pub enum SurfaceCommand { sandbox_writes: Option, #[serde(default, skip_serializing_if = "Option::is_none")] images: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - council: Option, }, #[serde(rename_all = "camelCase")] SendInput { session_id: u64, text: String }, @@ -196,50 +194,6 @@ pub enum SurfaceCommand { }, #[serde(rename_all = "camelCase")] CancelIssueValidation { run_id: String }, - #[serde(rename_all = "camelCase")] - StartCouncil { - run_id: String, - preset_id: CouncilPresetId, - objective: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - project_path: Option, - }, - #[serde(rename_all = "camelCase")] - KillCouncil { run_id: String }, - #[serde(rename_all = "camelCase")] - ResolveCouncilConverge { - run_id: String, - decision: CouncilConvergeDecision, - #[serde(default, skip_serializing_if = "Option::is_none")] - seat_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - note: Option, - }, - #[serde(rename_all = "camelCase")] - SetCouncilRouting { - run_id: String, - edges: Vec, - }, - #[serde(rename_all = "camelCase")] - SendCouncilHumanInput { - run_id: String, - mode: CouncilHumanInputMode, - #[serde(default, skip_serializing_if = "Option::is_none")] - seat_id: Option, - message: String, - }, - #[serde(rename_all = "camelCase")] - ResolveWorktreeOp { - request_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - worktree_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - gauntlet_passed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - gauntlet_summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - error: Option, - }, } // === Surface → engine queries (Rust SERIALIZES these; replies arrive as the @@ -354,8 +308,6 @@ pub enum NightcoreEvent { model: String, permission_mode: PermissionMode, #[serde(default, skip_serializing_if = "Option::is_none")] - council: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] containment: Option, }, #[serde(rename_all = "camelCase")] @@ -446,8 +398,6 @@ pub enum NightcoreEvent { message: String, #[serde(default, skip_serializing_if = "Option::is_none")] detail: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - council: Option, }, #[serde(rename_all = "camelCase")] SessionStatus { @@ -751,17 +701,6 @@ pub enum NightcoreEvent { issue_number: u64, task_id: String, }, - #[serde(rename_all = "camelCase")] - DebateEntry { - run_id: String, - entry: DebateTranscriptEntry, - }, - #[serde(rename_all = "camelCase")] - WorktreeOpRequired { - request_id: String, - op: WorktreeOpRequiredOpEnum, - council_run_id: String, - }, } // === Referenced enums and nested structs === @@ -918,37 +857,6 @@ pub enum CostTelemetry { None, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CouncilConvergeDecision { - Accept, - Reject, - Judge, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CouncilHumanInputMode { - Broadcast, - Direct, - Steer, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum CouncilPresetId { - Research, - UiBug, - Coding, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CouncilRoutingEdge { - pub from: String, - pub to: String, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum CoverageStatus { @@ -957,52 +865,6 @@ pub enum CoverageStatus { Unenforced, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum DebateEntryKind { - Broadcast, - Message, - Delivery, - Note, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum DebateSeatRole { - Proposer, - Critic, - Judge, - Conductor, - Human, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum DebateStage { - Frame, - Propose, - Debate, - Converge, - Build, - Review, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DebateTranscriptEntry { - pub stage: DebateStage, - pub seat_id: String, - pub role: DebateSeatRole, - pub kind: DebateEntryKind, - pub seq: u64, - pub content: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub broadcast_id: Option, - pub at: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub injection_flags: Option>, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeepScanConfig { @@ -1908,14 +1770,6 @@ pub enum WorkspaceTool { Unknown, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum WorktreeOpRequiredOpEnum { - Allocate, - Commit, - Gauntlet, -} - // === Event channel registry (nc:*) — single-sourced from `CHANNELS`; the // conformance test in `contracts/mod.rs` ties every `*_EVENT` const to it === @@ -1925,7 +1779,6 @@ pub enum WorktreeOpRequiredOpEnum { /// entry here, so a channel renamed/added/removed on either tier fails /// `cargo test` (and a rename in the zod source also reds `codegen-drift`). pub const NIGHTCORE_CHANNELS: &[(&str, &str)] = &[ - ("debate", "nc:debate"), ("harness", "nc:harness"), ("insight", "nc:insight"), ("issueTriage", "nc:issue-triage"), diff --git a/apps/desktop/src-tauri/src/contracts/mod.rs b/apps/desktop/src-tauri/src/contracts/mod.rs index bb55b52b..16fb2e8f 100644 --- a/apps/desktop/src-tauri/src/contracts/mod.rs +++ b/apps/desktop/src-tauri/src/contracts/mod.rs @@ -108,8 +108,8 @@ mod tests { .expect("fixtures.commands is an object"); assert_eq!( commands.len(), - 23, - "all 23 SurfaceCommand variants must have a fixture" + 17, + "all 17 SurfaceCommand variants must have a fixture" ); for (tag, wire) in commands { let cmd: SurfaceCommand = serde_json::from_value(wire.clone()) @@ -166,8 +166,8 @@ mod tests { .expect("fixtures.events is an object"); assert_eq!( events.len(), - 47, - "all 47 NightcoreEvent variants must have a fixture" + 45, + "all 45 NightcoreEvent variants must have a fixture" ); for (tag, wire) in events { let event: NightcoreEvent = serde_json::from_value(wire.clone()) @@ -205,7 +205,6 @@ mod tests { ledger_path: None, sandbox_writes: None, images: None, - council: None, }; let wire = serde_json::to_value(&cmd).expect("serializes"); let obj = wire.as_object().expect("an object"); @@ -229,7 +228,6 @@ mod tests { "ledgerPath", "sandboxWrites", "images", - "council", ] { assert!( !obj.contains_key(absent), @@ -526,7 +524,6 @@ mod tests { ("loop", crate::orchestration::coordinator::LOOP_EVENT), ("prFix", crate::workflow::pr_fix::PRFIX_EVENT), ("usage", crate::usage::USAGE_EVENT), - ("debate", crate::sidecar::DEBATE_EVENT), ] .into_iter() .collect(); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index dad4da90..b5a4787d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -256,11 +256,6 @@ pub fn run() { // forgets the entries but never the work (the auto-commit survives // on the PR branch in its checkout). app.manage(workflow::pr_fix::PrFixRegistry::default()); - // The Council run registry (issue #383): the host-trusted `councilRunId → - // project root` binding the write-capable worktree seam derives every path - // from. In-memory, populated at `start_council` — the engine can never supply a - // path, only name a run the host recorded. - app.manage(sidecar::CouncilRunRegistry::default()); // The per-provider model catalog cache (issue #80): DERIVED, in-memory — // never persisted. A restart starts cold and `list_models` re-fetches; the // `(provider, auth-state)` key self-invalidates across provider/auth changes. @@ -315,25 +310,6 @@ pub fn run() { sidecar::send_input, sidecar::respond_permission, sidecar::answer_question, - // Council debate run lifecycle (issue #350): start / kill a governed - // multi-agent debate. Both dispatch a `runId`-keyed SurfaceCommand to the - // engine's Conductor (the sole bus writer). The canvas + transcript stream - // are #352; these are the run-control surface. - sidecar::start_council, - sidecar::kill_council, - // The human Converge gavel (issue #353, safety #7): the terminal human - // verdict that resolves a parked run. Routes through the engine's Conductor - // (the sole bus writer) → append-only transcript, never a direct store write. - sidecar::resolve_council_converge, - // The editable canvas edges (issue #371): rewire a live run's routing policy. - // A CONDUCTOR DIRECTIVE, not a direct seat write — the Conductor filters which - // mediated, quoted peers inform a seat next Debate round (safety #1 intact). - sidecar::set_council_routing, - // Conductor-mediated human input (issue #361): broadcast-all / DM-one / - // steer-stage. NOT `send_input` — the message enters via the Conductor, which - // quotes + injection-scans it into the target seats' next mediated turn, so a - // surface never gains direct-to-seat write authority (safety #1/#2). - sidecar::send_council_human_input, sidecar::list_task_sessions, sidecar::get_task_session_messages, sidecar::resume_session, diff --git a/apps/desktop/src-tauri/src/provider/correlation.rs b/apps/desktop/src-tauri/src/provider/correlation.rs index fcd608e7..efed498f 100644 --- a/apps/desktop/src-tauri/src/provider/correlation.rs +++ b/apps/desktop/src-tauri/src/provider/correlation.rs @@ -48,28 +48,6 @@ impl SidecarProvider { Some(task_id) } - /// Record that `session_id` is a Council SEAT session (issue #364). Called by the - /// reader on a seat's `council: true` `session-started`. A seat is driven inside the - /// engine by the Conductor — it pushed NO pending-launch slot — so it must never - /// enter the board-task FIFO: once noted here, the reader short-circuits every event - /// for this id (skips [`correlate`](Self::correlate) entirely, so no desync warn and - /// no mis-bind of a concurrently-pending board task). Idempotent. - pub fn note_council_session(&self, session_id: u64) { - crate::sync::lock_or_recover(&self.correlation) - .council_sessions - .insert(session_id); - } - - /// Whether `session_id` is a known Council seat session (recorded by - /// [`note_council_session`](Self::note_council_session) on its `session-started`). - /// The reader consults this BEFORE [`correlate`](Self::correlate) so a seat's events - /// bypass the board FIFO. Dropped on the seat's terminal by [`forget`](Self::forget). - pub fn is_council_session(&self, session_id: u64) -> bool { - crate::sync::lock_or_recover(&self.correlation) - .council_sessions - .contains(&session_id) - } - /// The wall-clock duration since a session first correlated, in milliseconds, if /// it is still tracked. Read on a terminal event to log the run's `duration_ms` /// (observability #5). `None` once the session has been forgotten. @@ -113,15 +91,11 @@ impl SidecarProvider { } /// Forget a session↔task binding once the run reaches a terminal state, so the - /// map doesn't grow unboundedly across a long session. Also drops any Council seat - /// registration for the id (issue #364) — a harmless no-op for a normal session - /// (never in that set), and the terminal cleanup for a seat (which is never in - /// `by_session`/`started_at`). + /// map doesn't grow unboundedly across a long session. pub fn forget(&self, session_id: u64) { let mut c = crate::sync::lock_or_recover(&self.correlation); c.by_session.remove(&session_id); c.started_at.remove(&session_id); - c.council_sessions.remove(&session_id); } /// The session id currently bound to `task_id`, if any. Used to interrupt a @@ -162,10 +136,6 @@ impl SidecarProvider { c.by_session.clear(); c.pending.clear(); c.started_at.clear(); - // Clear Council seat registrations too (issue #364): after a crash the id - // counter continues, but nothing should treat a post-crash session id as a - // pre-crash seat. - c.council_sessions.clear(); orphaned } } @@ -314,87 +284,12 @@ mod tests { } #[test] - fn council_seat_is_noted_and_read_back() { - // A council seat self-identifies on its `session-started`; the reader notes it - // so every later event for that id can be short-circuited (skip correlate). - let p = provider(); - assert!( - !p.is_council_session(209), - "unknown id is not a council seat" - ); - p.note_council_session(209); - assert!( - p.is_council_session(209), - "a noted seat reads back as council" - ); - // Idempotent — a second note is a no-op. - p.note_council_session(209); - assert!(p.is_council_session(209)); - } - - #[test] - fn council_seat_never_pops_a_pending_board_task_slot() { - // The mis-binding regression (issue #364): a board task launch is PENDING (its - // `session-started` has not arrived) when a council convene spawns seat sessions. - // Because a seat pushed no FIFO slot, the OLD reader called `correlate` on the - // seat's `session-started`, which POPPED the board task's slot and mis-bound the - // seat to it — poisoning the board task's correlation (its real session then found - // an empty FIFO and its stream/terminal were dropped, leaving it stuck `running`). - // - // The fix records the seat via `note_council_session` and the reader skips - // `correlate` entirely for it, so the board task's slot is UNTOUCHED and its real - // session still correlates. This test pins the provider contract the reader relies - // on: a noted seat is recognizable, and correlating the board task's real session - // still binds it correctly with a seat id already in flight. - let p = provider(); - p.push_pending("board-task"); // a board launch is queued, awaiting its session - - // A council seat's `session-started` arrives FIRST. The reader notes it and, seeing - // `is_council_session`, returns WITHOUT calling `correlate` — so the FIFO is intact. - p.note_council_session(209); - assert!(p.is_council_session(209)); - assert!( - p.task_for(209).is_none(), - "the seat is never bound to any task (it skips correlation entirely)" - ); - - // The board task's REAL session-started now correlates and binds correctly — its - // slot was never stolen by the seat. - assert_eq!( - p.correlate(300).as_deref(), - Some("board-task"), - "the pending board task still correlates — the seat never popped its slot" - ); - assert_eq!(p.task_for(300).as_deref(), Some("board-task")); - } - - #[test] - fn forget_clears_a_council_seat_registration() { - // On the seat's terminal the reader calls `forget`, which must drop the council - // registration so the id set can't grow unbounded across many councils. - let p = provider(); - p.note_council_session(209); - assert!(p.is_council_session(209)); - p.forget(209); - assert!( - !p.is_council_session(209), - "forget deregisters the seat on its terminal" - ); - } - - #[test] - fn forget_of_a_normal_session_is_unaffected_by_the_council_set() { - // The council carve-out must not perturb normal terminal cleanup: forgetting a - // correlated board session still drops its binding, and touching the (empty) - // council set is a harmless no-op. + fn forget_clears_a_session_binding() { + // Forgetting a correlated session on its terminal drops its binding. let p = provider(); p.push_pending("t"); p.correlate(5); assert_eq!(p.task_for(5).as_deref(), Some("t")); - assert!( - !p.is_council_session(5), - "a board session is not a council seat" - ); p.forget(5); assert!(p.task_for(5).is_none(), "binding cleared on terminal"); } @@ -466,8 +361,6 @@ mod tests { assert_eq!(p.correlate(0).as_deref(), Some("task-a")); assert_eq!(p.correlate(1).as_deref(), Some("task-b")); // task-c never correlated (still pending). - // A council seat was in flight too (issue #364); reset must clear it as well. - p.note_council_session(42); let mut orphaned = p.reset_after_crash().await; orphaned.sort(); @@ -488,11 +381,5 @@ mod tests { p.correlate(9).is_none(), "pending launches cleared — no stale mis-bind after a crash" ); - // Council seat registrations are cleared, so a reused post-crash id is never - // mistaken for a pre-crash seat. - assert!( - !p.is_council_session(42), - "council seat registrations cleared on crash reset" - ); } } diff --git a/apps/desktop/src-tauri/src/provider/imp.rs b/apps/desktop/src-tauri/src/provider/imp.rs index d2c2e993..2f6e752e 100644 --- a/apps/desktop/src-tauri/src/provider/imp.rs +++ b/apps/desktop/src-tauri/src/provider/imp.rs @@ -173,12 +173,6 @@ impl Provider for SidecarProvider { // serializes as an OMITTED field — byte-identical to the pre-feature // `start-session` (a text-only user message). images: (!images.is_empty()).then_some(images), - // Council seat marker (issue #364): ALWAYS `None` here. This is the BOARD - // task launch path — the Rust core never launches council seats (those are - // driven inside the engine by the Conductor). Omitted on the wire, so a - // board `start-session` stays byte-identical and the reader runs normal - // FIFO correlation for it. - council: None, }; let command = serde_json::to_value(&command).map_err(|e| e.to_string())?; diff --git a/apps/desktop/src-tauri/src/provider/types.rs b/apps/desktop/src-tauri/src/provider/types.rs index 9fe17ffb..a78ba28a 100644 --- a/apps/desktop/src-tauri/src/provider/types.rs +++ b/apps/desktop/src-tauri/src/provider/types.rs @@ -7,7 +7,7 @@ //! via `use super::*`; `SidecarProvider`/`Correlation` fields are `pub(super)` so //! those descendants keep accessing them. -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; use std::sync::Mutex; @@ -294,14 +294,4 @@ pub(super) struct Correlation { pub(super) by_session: HashMap, pub(super) pending: VecDeque, pub(super) started_at: HashMap, - /// Council SEAT session ids (issue #364). A debate seat is driven INSIDE the - /// engine by the Conductor — not launched via the board's `start_session` command - /// — so it pushed no `pending` slot. The reader records a seat here on its - /// `council: true` `session-started` and then SHORT-CIRCUITS every event for that - /// id: no [`correlate`](SidecarProvider::correlate) call (which would warn on the - /// empty FIFO or, worse, pop a concurrently-pending board task's slot and mis-bind - /// the seat to it), and no board `nc:session` forward (the canvas renders seat - /// output from the moderated `nc:debate` stream). Deregistered on the seat's - /// terminal via [`forget`](SidecarProvider::forget) so it can't grow unbounded. - pub(super) council_sessions: HashSet, } diff --git a/apps/desktop/src-tauri/src/sidecar/channels.rs b/apps/desktop/src-tauri/src/sidecar/channels.rs index e24394d8..df60749e 100644 --- a/apps/desktop/src-tauri/src/sidecar/channels.rs +++ b/apps/desktop/src-tauri/src/sidecar/channels.rs @@ -51,15 +51,3 @@ pub(crate) const PRREVIEW_EVENT: &str = "nc:pr-review"; /// completion. `convert_issue_validation_to_task` also emits an /// `issue-validation-converted` notice on this channel. pub(crate) const ISSUE_TRIAGE_EVENT: &str = "nc:issue-triage"; - -/// The Tauri event carrying one append-only Council debate-transcript entry -/// (`DebateTranscriptEntry`, scoped by its council-run id) — the moderated `nc:debate` -/// bus (issue #348). Payload SHAPE is the `@nightcore/contracts` `DebateTranscriptEntry`. -/// -/// Registered in the single-source channel registry, the scattered runtime consts, and -/// the web bridge (the `channel_consts_match_generated_registry` parity guard covers it -/// below). The Council P1 foundation — the bus + append-only transcript store — lives in -/// the engine (`packages/engine/src/debate/`); the canvas slice (#352) wired the emit -/// seam, so `sidecar/reader.rs` now forwards every `debate-entry` event onto this channel -/// (the const is live — no longer dead). -pub(crate) const DEBATE_EVENT: &str = "nc:debate"; diff --git a/apps/desktop/src-tauri/src/sidecar/commands.rs b/apps/desktop/src-tauri/src/sidecar/commands.rs index d9f99ae6..05e5bebd 100644 --- a/apps/desktop/src-tauri/src/sidecar/commands.rs +++ b/apps/desktop/src-tauri/src/sidecar/commands.rs @@ -313,183 +313,6 @@ pub async fn send_input( provider.stream_input(session_id, text).await } -/// Start a governed Council debate run (issue #350). Ensures the sidecar is up, then -/// dispatches a `start-council` SurfaceCommand to the engine, whose Conductor drives -/// the `Frame → Propose(blind) → Debate(≤2) → Converge(human)` state machine over the -/// preset's seats — the sole bus writer, so seats have zero agent-to-agent authority -/// (safety #1). Fire-and-forget: the run + its append-only transcript live in the -/// engine; the `nc:debate` transcript stream is the canvas slice (#352), so this -/// command only STARTS the run. -/// -/// Async like its `send_input`/`respond_permission` siblings — the write is fully -/// async tokio I/O, so it never blocks the WKWebView. `objective` is user content and -/// is never logged. -#[tauri::command] -pub async fn start_council( - app: AppHandle, - provider: State<'_, Arc>, - council_runs: State<'_, crate::sidecar::CouncilRunRegistry>, - run_id: String, - preset_id: crate::contracts::CouncilPresetId, - objective: String, - project_path: Option, -) -> Result<(), String> { - crate::sidecar::ensure_reader(&app).await?; - // Record the run's TRUSTED project root for the write-capable worktree seam (issue - // #383): when the engine later asks to allocate/commit/gate this run's worktree, the - // host maps THIS run id → this project root (never a path the engine sends). Only - // build-capable presets (ui-bug/coding) may reach a worktree op; only recorded when a - // project path is present (a build council without one simply cannot build). - if let Some(ref path) = project_path { - council_runs.register( - &run_id, - std::path::PathBuf::from(path), - crate::sidecar::council_worktree::preset_is_build_capable(&preset_id), - ); - } - tracing::debug!(target: "nightcore", run_id, "start-council dispatched to engine"); - let command = crate::contracts::SurfaceCommand::StartCouncil { - run_id, - preset_id, - objective, - project_path, - }; - provider.dispatch_command(command).await -} - -/// Kill a running Council debate run immediately (safety non-negotiable #4 — the kill -/// switch; never "run until they agree"). Best-effort: when the sidecar is up, dispatch -/// a `kill-council` SurfaceCommand — the engine's Conductor throws the run's kill switch, -/// halting turn-taking at the next checkpoint and aborting the in-flight seat turn. When -/// the sidecar isn't running there is no live run to kill, so this is a no-op. Async like -/// its siblings. -#[tauri::command] -pub async fn kill_council( - provider: State<'_, Arc>, - council_runs: State<'_, crate::sidecar::CouncilRunRegistry>, - run_id: String, -) -> Result<(), String> { - // The run is closing — drop its worktree-seam binding (issue #383) so a later worktree - // op for this id is refused. Done unconditionally (even when the sidecar is down). - council_runs.forget(&run_id); - if !provider.is_running().await { - return Ok(()); - } - tracing::debug!(target: "nightcore", run_id, "kill-council dispatched to engine"); - let command = crate::contracts::SurfaceCommand::KillCouncil { run_id }; - provider.dispatch_command(command).await -} - -/// Resolve a Council run's PARKED Converge decision with the human judge's verdict -/// (issue #353, safety non-negotiable #7 — the human is the terminal authority in P1's -/// HUMAN-only Converge). Dispatches a `resolve-council-converge` SurfaceCommand: the -/// engine's Conductor — the SOLE bus writer — records the verdict onto the append-only -/// transcript (never a direct store write from the surface, safety #1) and closes the -/// run. The verdict streams back over `nc:debate`, so this is fire-and-forget like its -/// `start_council`/`kill_council` siblings — the transcript entry is the confirmation. -/// -/// `seat_id` names the adopted seat for an `accept` verdict; `note` is the ruling for a -/// `judge` (or an optional reason for `accept`/`reject`) and is user content, never -/// logged. When the sidecar isn't running there is no parked run to resolve, so this is -/// a no-op. Async like its siblings — fully async tokio I/O, never blocks the WKWebView. -#[tauri::command] -pub async fn resolve_council_converge( - provider: State<'_, Arc>, - council_runs: State<'_, crate::sidecar::CouncilRunRegistry>, - run_id: String, - decision: crate::contracts::CouncilConvergeDecision, - seat_id: Option, - note: Option, -) -> Result<(), String> { - // The human verdict closes the run — drop its worktree-seam binding (issue #383) so no - // further worktree op is honored for it. The writer's worktree/branch persist on disk; - // the human's merge/discard go through the board's own paths, not this registry. - council_runs.forget(&run_id); - if !provider.is_running().await { - return Ok(()); - } - // Decision KIND is debug-only (the verdict category); the ruling `note` is user - // content and is never logged. - tracing::debug!(target: "nightcore", run_id, ?decision, "resolve-council-converge dispatched to engine"); - let command = crate::contracts::SurfaceCommand::ResolveCouncilConverge { - run_id, - decision, - seat_id, - note, - }; - provider.dispatch_command(command).await -} - -/// Rewire a live Council run's routing policy — the editable canvas edges (issue #371). -/// A routing edge is "A informs B": which seats' outputs reach a recipient seat as its -/// MEDIATED, quoted, injection-scanned peer context in the Debate stage. `edges` REPLACES -/// the run's current edge set (an empty list restores the open default — every seat -/// informs every other). -/// -/// This is a CONDUCTOR DIRECTIVE, not a direct seat write (safety non-negotiable #1 — the -/// injection firewall): dispatch a `set-council-routing` SurfaceCommand, and the engine's -/// Conductor — the sole bus writer — applies the new policy to the next Debate round and -/// records the change onto the append-only transcript, which streams back over -/// `nc:debate`. Fire-and-forget like its `resolve_council_converge` sibling — the recorded -/// routing note is the confirmation. When the sidecar isn't running there is no live run -/// to route, so this is a no-op. Async — fully async tokio I/O, never blocks the WKWebView. -#[tauri::command] -pub async fn set_council_routing( - provider: State<'_, Arc>, - run_id: String, - edges: Vec, -) -> Result<(), String> { - if !provider.is_running().await { - return Ok(()); - } - tracing::debug!(target: "nightcore", run_id, edges = edges.len(), "set-council-routing dispatched to engine"); - let command = crate::contracts::SurfaceCommand::SetCouncilRouting { run_id, edges }; - provider.dispatch_command(command).await -} - -/// Relay a HUMAN's mid-debate message into a live Council run (issue #361) — the -/// broadcast-all / DM-one / steer-stage surface the #352 canvas shipped disabled. -/// -/// Deliberately NOT `send_input`. That command hands text to a running session's provider -/// runner as a raw user turn (`streamInput`) — correct for non-Council user↔agent chat -/// (#335/#347), and a violation of BOTH Council safety non-negotiables here: it would give -/// the surface direct-to-seat write authority (#1) and deliver human prose to a coding -/// agent as a bare instruction (#2). Instead this dispatches a `send-council-human-input` -/// SurfaceCommand, and the engine's Conductor — the SOLE bus writer — runs the message -/// through the SAME mediated relay every cross-seat text takes (injection scan + quoted -/// untrusted fence), records the scanned delivery onto the append-only transcript (#7), and -/// stages the QUOTED rendering for the target seat(s)' next mediated turn. -/// -/// `mode` selects the audience (`broadcast` = every live seat, `direct` = the seat `seat_id` -/// names, `steer` = every live seat plus a conductor directive to end the Debate stage at -/// its next checkpoint — a strict shortener, safety #4). `message` is user content and is -/// NEVER logged. Fire-and-forget like its `set_council_routing` sibling: the recorded, -/// quoted delivery streaming back over `nc:debate` is the confirmation. When the sidecar -/// isn't running there is no live run to address, so this is a no-op. Async — fully async -/// tokio I/O, never blocks the WKWebView. -#[tauri::command] -pub async fn send_council_human_input( - provider: State<'_, Arc>, - run_id: String, - mode: crate::contracts::CouncilHumanInputMode, - seat_id: Option, - message: String, -) -> Result<(), String> { - if !provider.is_running().await { - return Ok(()); - } - // The directive SHAPE is debug-only; the human's `message` is user content and is never - // logged (mirroring `resolve_council_converge`'s handling of the ruling `note`). - tracing::debug!(target: "nightcore", run_id, ?mode, "send-council-human-input dispatched to engine"); - let command = crate::contracts::SurfaceCommand::SendCouncilHumanInput { - run_id, - mode, - seat_id, - message, - }; - provider.dispatch_command(command).await -} - /// Best-effort interrupt of a task's run. Aborts the slot's driver (if the loop /// spawned one) and sends an `interrupt` for the task's session; the terminal /// transition still arrives via the sidecar's `session-failed (aborted)` event, diff --git a/apps/desktop/src-tauri/src/sidecar/council_worktree.rs b/apps/desktop/src-tauri/src/sidecar/council_worktree.rs deleted file mode 100644 index e29fcb4f..00000000 --- a/apps/desktop/src-tauri/src/sidecar/council_worktree.rs +++ /dev/null @@ -1,506 +0,0 @@ -//! The Rust HOST end of the path-less, `councilRunId`-keyed engine↔host worktree seam -//! (issue #383) — the security-critical half of the write-capable Council. -//! -//! The in-engine Council drives its debate + its single writer in the sidecar PROCESS; the -//! isolated worktrees, the commit single-flight, and the Structure-Lock gauntlet all live -//! HERE, in the Rust host, already audited by the board. The engine reaches them across the -//! process boundary by emitting a `worktree-op-required { requestId, op, councilRunId }` -//! event; [`handle_worktree_op`] performs the op and replies with a `resolve-worktree-op` -//! command, modeled on the parked-permission seam. -//! -//! **SECURITY INVARIANT — the host DERIVES every path; the engine sends NONE.** The request -//! carries only a closed `op` verb + the `councilRunId`. The host maps that id — via the -//! [`CouncilRunRegistry`] the WEBVIEW populated at `start-council` — to the TRUSTED project -//! root it recorded, and derives the worktree path from it with -//! [`crate::worktree::worktree_path`]. An UNKNOWN or non-build-capable run id is refused -//! outright, and no path is ever taken from the engine, so an injection-compromised engine -//! can name a verb + a run id but can NEVER redirect an op outside -//! `.nightcore/worktrees/` (the escape guard, `worktree::path::is_under`). This is -//! what makes the seam add a MESSAGE TYPE, not a write/exec sink. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::Mutex; - -use serde_json::Value; -use tauri::{AppHandle, Manager}; - -use crate::contracts::{CouncilPresetId, SurfaceCommand}; -use crate::provider::SidecarProvider; -use crate::store::types::StructureLockResult; -use crate::workflow::merge::{commit_in_flight, TaskLease}; - -/// Whether a Council preset WRITES code (declares a `build` stage + an objective gate) — -/// the ONLY presets whose runs may reach a worktree op. `research` is pure-reasoning and is -/// refused (defence in depth: the engine also never emits a worktree op for it). Kept in -/// sync with the engine presets (`ui-bug` #367, `coding` #368). -pub(crate) fn preset_is_build_capable(preset_id: &CouncilPresetId) -> bool { - matches!(preset_id, CouncilPresetId::UiBug | CouncilPresetId::Coding) -} - -/// Whether a run id is safe to use as a SINGLE filesystem path component. Council run ids are -/// `crypto.randomUUID()` today (hex + `-`), so this always holds — but `worktree_path` joins -/// the id blindly and `is_under` compares components lexically WITHOUT collapsing `..` -/// (`path.rs`), so the traversal-refusal currently rests solely on the id staying UUID-shaped. -/// Validating the charset explicitly (issue #387) means it does NOT silently regress if run-id -/// minting ever changes. The allowlist `[A-Za-z0-9-]` refuses `/`, `\`, `.` (so `.` and `..`), -/// absolute paths, and whitespace; a length cap bounds absurd input. -fn is_filesystem_safe_run_id(run_id: &str) -> bool { - !run_id.is_empty() - && run_id.len() <= 128 - && run_id - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-') -} - -/// A host-trusted binding of `councilRunId → (project root, build-capable)`, populated at -/// `start-council` from the WEBVIEW-supplied project path. It is the SOLE authority the -/// worktree-op handler consults for the project root — the engine only NAMES a run over the -/// wire, so it can never influence WHERE a worktree op lands. In-memory (a restart forgets -/// parked runs; a re-`start-council` re-registers), like the pr-fix registry. -#[derive(Default)] -pub(crate) struct CouncilRunRegistry { - runs: Mutex>, -} - -struct CouncilRunInfo { - project_path: PathBuf, - build_capable: bool, -} - -impl CouncilRunRegistry { - /// Record a run's TRUSTED project root at `start-council`. Only called with a project - /// path the webview supplied; the value is never taken from the engine. A run id that is - /// not filesystem-safe is REFUSED registration (skipped + warned) so it can never become - /// build-capable — fail-closed, keeping the map free of ids that could form a bad path - /// (issue #387). Unreachable today (ids are UUIDs); this guards against future minting. - pub(crate) fn register(&self, run_id: &str, project_path: PathBuf, build_capable: bool) { - if !is_filesystem_safe_run_id(run_id) { - tracing::warn!(target: "nightcore::council", "refusing to register a council run with a non-filesystem-safe run id"); - return; - } - crate::sync::lock_or_recover(&self.runs).insert( - run_id.to_string(), - CouncilRunInfo { - project_path, - build_capable, - }, - ); - } - - /// The TRUSTED project root for a BUILD-CAPABLE run, or `None` for an unknown run OR a - /// non-build-capable preset. This is the security gate: a worktree op only proceeds for a - /// run the host itself registered as build-capable, so a compromised engine naming a - /// research / foreign / forged run id gets no path derived at all. - pub(crate) fn build_project_path(&self, run_id: &str) -> Option { - // Belt-and-suspenders: the hard gate right before the host derives a path. Even if a - // future insert path ever admitted an unsafe id, no worktree path is derived from one - // here — `worktree_path` is only ever joined with a validated component (issue #387). - if !is_filesystem_safe_run_id(run_id) { - return None; - } - let guard = crate::sync::lock_or_recover(&self.runs); - guard - .get(run_id) - .filter(|info| info.build_capable) - .map(|info| info.project_path.clone()) - } - - /// Drop a run's binding when it closes (a human verdict or a kill). Idempotent. - pub(crate) fn forget(&self, run_id: &str) { - crate::sync::lock_or_recover(&self.runs).remove(run_id); - } - - #[cfg(test)] - pub(crate) fn is_registered(&self, run_id: &str) -> bool { - crate::sync::lock_or_recover(&self.runs).contains_key(run_id) - } - - /// TEST-ONLY: insert a binding BYPASSING [`Self::register`]'s validation, simulating the - /// "future insert path that admits an unsafe id" the [`Self::build_project_path`] re-check - /// exists for. Without this, that re-check is unreachable from any test — `register` - /// already keeps unsafe ids out of the map, so a lookup returns `None` on absence rather - /// than on the guard, and deleting the guard would break nothing (issue #387). - #[cfg(test)] - fn insert_unchecked(&self, run_id: &str, project_path: PathBuf, build_capable: bool) { - crate::sync::lock_or_recover(&self.runs).insert( - run_id.to_string(), - CouncilRunInfo { - project_path, - build_capable, - }, - ); - } -} - -/// The host's reply payload for one worktree op — the FLAT per-op result mirrored by the -/// `resolve-worktree-op` command. Built by [`perform_worktree_op`] and dispatched back to -/// the engine, which resolves the awaiting driver / gauntlet call by `requestId`. -struct WorktreeReply { - request_id: String, - worktree_path: Option, - gauntlet_passed: Option, - gauntlet_summary: Option, - error: Option, -} - -impl WorktreeReply { - fn base(request_id: &str) -> Self { - Self { - request_id: request_id.to_string(), - worktree_path: None, - gauntlet_passed: None, - gauntlet_summary: None, - error: None, - } - } - - /// A refused / failed op — the awaiting engine call fails CLOSED on `error`. - fn error(request_id: &str, message: impl Into) -> Self { - Self { - error: Some(message.into()), - ..Self::base(request_id) - } - } - - /// `commit` success (no payload — absent `error` ⇒ committed). - fn ok(request_id: &str) -> Self { - Self::base(request_id) - } - - /// `allocate` success — the host-derived worktree dir. - fn allocated(request_id: &str, worktree_path: String) -> Self { - Self { - worktree_path: Some(worktree_path), - ..Self::base(request_id) - } - } - - /// `gauntlet` result — a pass/fail + a one-line summary recorded beside the verdict. - fn gauntlet(request_id: &str, passed: bool, summary: String) -> Self { - Self { - gauntlet_passed: Some(passed), - gauntlet_summary: Some(summary), - ..Self::base(request_id) - } - } - - fn into_command(self) -> SurfaceCommand { - SurfaceCommand::ResolveWorktreeOp { - request_id: self.request_id, - worktree_path: self.worktree_path, - gauntlet_passed: self.gauntlet_passed, - gauntlet_summary: self.gauntlet_summary, - error: self.error, - } - } -} - -/// Handle one `worktree-op-required` event: derive the TRUSTED project root from the run id, -/// perform the op off the reader thread (git/gauntlet work BLOCKS), and dispatch the -/// `resolve-worktree-op` reply back to the engine. Never panics the reader — a malformed -/// event, an unknown run, or a panicked op all resolve to a logged reply. -pub(crate) async fn handle_worktree_op(app: &AppHandle, event: Value) { - let request_id = event - .get("requestId") - .and_then(Value::as_str) - .map(str::to_string); - let op = event.get("op").and_then(Value::as_str).map(str::to_string); - let council_run_id = event - .get("councilRunId") - .and_then(Value::as_str) - .map(str::to_string); - let (Some(request_id), Some(op), Some(council_run_id)) = (request_id, op, council_run_id) - else { - tracing::warn!(target: "nightcore::council", "worktree-op-required missing requestId/op/councilRunId; dropping"); - return; - }; - - // The SOLE authority for the project root — never the engine (the escape guard). An - // unknown / non-build-capable run yields no path and is refused. - let project_path = app - .state::() - .build_project_path(&council_run_id); - - let reply = match project_path { - None => { - tracing::warn!(target: "nightcore::council", council_run_id = %council_run_id, op = %op, "refusing a worktree op for an unknown / non-build-capable council run"); - WorktreeReply::error(&request_id, "unknown or non-build-capable council run") - } - Some(project_path) => { - // The worktree path is DERIVED host-side from the trusted (project_path, - // councilRunId) inside `perform_worktree_op` — the engine sends none. Offloaded - // to the blocking pool: `git worktree add` / commit / the whole Structure-Lock - // gauntlet all block, so they must not run on the async runtime thread. - let op_for_blocking = op.clone(); - let run_for_blocking = council_run_id.clone(); - let request_for_blocking = request_id.clone(); - match tauri::async_runtime::spawn_blocking(move || { - perform_worktree_op( - &op_for_blocking, - &project_path, - &run_for_blocking, - &request_for_blocking, - ) - }) - .await - { - Ok(reply) => reply, - Err(e) => { - tracing::error!(target: "nightcore::council", council_run_id = %council_run_id, op = %op, error = %e, "worktree op panicked on the blocking pool"); - WorktreeReply::error(&request_id, "the worktree op failed unexpectedly") - } - } - } - }; - - let provider = app.state::>(); - if let Err(e) = provider.dispatch_command(reply.into_command()).await { - tracing::warn!(target: "nightcore::council", request_id = %request_id, error = %e, "failed to dispatch resolve-worktree-op back to the engine"); - } -} - -/// Perform ONE worktree op against the TRUSTED, host-derived path. Runs on the blocking -/// pool. Every op is confined to `/.nightcore/worktrees/`: -/// - `allocate` — `crate::worktree::allocate` (idempotent; branch `nc/`). -/// - `commit` — `crate::worktree::commit` under the SAME `commit_in_flight` single-flight -/// lease board commits use; NEVER a merge (merge/discard stay human-only). The message is -/// host-synthesized so no engine text reaches git. -/// - `gauntlet` — `crate::gauntlet_project::run_from(project_root, worktree)`: the board's -/// audited worktree gate — the manifest is loaded from the TRUSTED project root while the -/// checks run in the worktree, so a write-capable writer cannot redefine which checks run. -fn perform_worktree_op( - op: &str, - project_path: &Path, - council_run_id: &str, - request_id: &str, -) -> WorktreeReply { - match op { - "allocate" => match crate::worktree::allocate(project_path, council_run_id) { - Ok(dir) => WorktreeReply::allocated(request_id, dir.to_string_lossy().into_owned()), - Err(e) => WorktreeReply::error(request_id, format!("worktree allocation failed: {e}")), - }, - "commit" => { - // Single-flight on the shared commit lease, keyed on the run id — a council - // commit never races a concurrent commit for the same id (defence in depth - // behind the engine's serial build turn). - let Some(_lease) = TaskLease::acquire(commit_in_flight(), council_run_id) else { - return WorktreeReply::error( - request_id, - "another commit is already in flight for this run", - ); - }; - let message = format!("Council build (run {council_run_id})"); - match crate::worktree::commit(project_path, council_run_id, &message) { - Ok(_) => WorktreeReply::ok(request_id), - Err(e) => WorktreeReply::error(request_id, format!("worktree commit failed: {e}")), - } - } - "gauntlet" => { - // Manifest from the TRUSTED project root; checks in the run's worktree — BOTH - // host-derived from the run id (never engine-sent). Reuses the board's runner. - let run_dir = crate::worktree::worktree_path(project_path, council_run_id); - if !run_dir.exists() { - return WorktreeReply::gauntlet( - request_id, - false, - "the worktree was not allocated before the gate ran".to_string(), - ); - } - let result = crate::gauntlet_project::run_from(project_path, &run_dir); - let summary = gauntlet_summary(&result); - WorktreeReply::gauntlet(request_id, result.passed, summary) - } - other => WorktreeReply::error(request_id, format!("unsupported worktree op {other:?}")), - } -} - -/// A one-line, human-readable Structure-Lock summary recorded beside the gate verdict on the -/// transcript — mirrors the engine's `gauntletObjectiveGate` summary style. -fn gauntlet_summary(result: &StructureLockResult) -> String { - if result.passed { - format!( - "Structure-Lock gauntlet passed ({} check(s)).", - result.checks.len() - ) - } else { - match &result.failed_check { - Some(check) => format!("Structure-Lock gauntlet FAILED at \"{check}\"."), - None => "Structure-Lock gauntlet FAILED.".to_string(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::worktree::{is_under, worktree_path, worktrees_base}; - - #[test] - fn build_capable_presets_are_ui_bug_and_coding_only() { - assert!(preset_is_build_capable(&CouncilPresetId::UiBug)); - assert!(preset_is_build_capable(&CouncilPresetId::Coding)); - // `research` is pure-reasoning — it must never reach a worktree op. - assert!(!preset_is_build_capable(&CouncilPresetId::Research)); - } - - #[test] - fn registry_returns_a_project_root_only_for_a_known_build_capable_run() { - let registry = CouncilRunRegistry::default(); - registry.register("run-build", PathBuf::from("/proj"), true); - registry.register("run-research", PathBuf::from("/proj"), false); - - // A known BUILD-CAPABLE run yields its recorded project root. - assert_eq!( - registry.build_project_path("run-build"), - Some(PathBuf::from("/proj")) - ); - // A known but NON-build-capable (research) run is refused — no path derived. - assert_eq!(registry.build_project_path("run-research"), None); - // An UNKNOWN run id is refused — the engine cannot conjure a project root. - assert_eq!(registry.build_project_path("run-unknown"), None); - - registry.forget("run-build"); - assert_eq!(registry.build_project_path("run-build"), None); - assert!(!registry.is_registered("run-build")); - } - - #[test] - fn a_foreign_or_traversal_run_id_is_refused_not_resolved_to_a_path() { - // The core security property (acceptance test #4): the host NEVER derives a worktree - // path from an untrusted run id. A run id the host never registered — including one - // carrying `../` traversal — resolves to NO project root, so no path is ever computed - // for it and the op is refused. Only the ids the WEBVIEW registered are honored. - let registry = CouncilRunRegistry::default(); - registry.register("run-build", PathBuf::from("/proj"), true); - - for forged in [ - "../../etc", - "run-build/../../escape", - "/abs/evil", - "unknown", - ] { - assert_eq!( - registry.build_project_path(forged), - None, - "a foreign / traversal run id {forged:?} must be refused, never resolved" - ); - } - } - - /// A real `crypto.randomUUID()` — the shape run ids actually have today. - const MINTED_UUID: &str = "550e8400-e29b-41d4-a716-446655440000"; - - /// Every rejection case the run-id guard owes (issue #387), asserted at EACH enforcement - /// point rather than only against the predicate: traversal, separators, dots, absolute - /// paths, whitespace/control chars, and empty. Shared so a case added here is - /// automatically proven at the predicate, at `register`, AND at `build_project_path`. - const UNSAFE_RUN_IDS: &[&str] = &[ - "", - "..", - "../../etc", - "run-build/../../escape", - "/abs/evil", - "a.b", - "run build", - "run_build\n", - "run\\build", - ]; - - #[test] - fn only_filesystem_safe_run_ids_pass_validation() { - // A real minted id (UUID) and the test-style `run-*` ids are safe. - assert!(is_filesystem_safe_run_id(MINTED_UUID)); - assert!(is_filesystem_safe_run_id("run-build")); - // Anything that could form a bad path component is refused. - for bad in UNSAFE_RUN_IDS { - assert!( - !is_filesystem_safe_run_id(bad), - "an unsafe run id {bad:?} must be refused" - ); - } - assert!(!is_filesystem_safe_run_id(&"a".repeat(129))); - } - - #[test] - fn register_refuses_every_non_filesystem_safe_run_id() { - // Defense-in-depth (issue #387): even if run-id minting ever produced a traversal id, - // register skips it so it never enters the map and never becomes build-capable — the - // whole seam then refuses every op for it (fail-closed), never deriving a path. - // Asserted over the WHOLE rejection matrix, so the guard cannot pass by covering only - // the two cases someone happened to write down. - let registry = CouncilRunRegistry::default(); - for bad in UNSAFE_RUN_IDS { - registry.register(bad, PathBuf::from("/proj"), true); - assert!( - !registry.is_registered(bad), - "an unsafe run id {bad:?} must never enter the registry" - ); - assert_eq!( - registry.build_project_path(bad), - None, - "an unsafe run id {bad:?} must never resolve to a project root" - ); - } - registry.register(&"a".repeat(129), PathBuf::from("/proj"), true); - assert!(!registry.is_registered(&"a".repeat(129))); - - // A REAL minted id (the shape production actually produces) still registers and - // resolves normally — the guard rejects abuse, not the happy path. - registry.register(MINTED_UUID, PathBuf::from("/proj"), true); - assert!(registry.is_registered(MINTED_UUID)); - assert_eq!( - registry.build_project_path(MINTED_UUID), - Some(PathBuf::from("/proj")) - ); - } - - #[test] - fn build_project_path_refuses_an_unsafe_id_even_when_it_is_in_the_map() { - // The SECOND enforcement point — the re-check immediately before the host derives a - // path. `register` normally keeps unsafe ids out of the map entirely, which makes this - // guard invisible to every other test: deleting it changed NO test result (a surviving - // mutant). Inserting UNCHECKED reproduces the exact scenario its comment claims to - // cover — a future insert path admitting an unsafe id — so the guard is now the only - // thing standing between that id and `worktree_path` joining it blindly (issue #387). - let registry = CouncilRunRegistry::default(); - for bad in UNSAFE_RUN_IDS { - registry.insert_unchecked(bad, PathBuf::from("/proj"), true); - // Present in the map AND build-capable — so a `None` here can only come from the - // charset guard, never from absence. - assert!(registry.is_registered(bad)); - assert_eq!( - registry.build_project_path(bad), - None, - "an unsafe run id {bad:?} in the map must STILL be refused before a path is derived" - ); - } - - // Same unchecked path with a safe id resolves — proving the refusals above come from - // the guard and not from `insert_unchecked` simply failing to insert. - registry.insert_unchecked(MINTED_UUID, PathBuf::from("/proj"), true); - assert_eq!( - registry.build_project_path(MINTED_UUID), - Some(PathBuf::from("/proj")) - ); - } - - #[test] - fn a_registered_runs_worktree_is_derived_strictly_under_the_worktrees_base() { - // For a REGISTERED run, the worktree path the host derives is `worktree_path(project, - // runId)` — always strictly under `/.nightcore/worktrees/` (the escape guard - // `is_under` holds), so even a legitimate op is confined to the run's own worktree. - let project = Path::new("/proj"); - let derived = worktree_path(project, "run-build"); - let base = worktrees_base(project); - assert!( - is_under(&base, &derived), - "the derived worktree must be strictly under the worktrees base" - ); - assert_eq!( - derived, - PathBuf::from("/proj/.nightcore/worktrees/run-build") - ); - } -} diff --git a/apps/desktop/src-tauri/src/sidecar/mod.rs b/apps/desktop/src-tauri/src/sidecar/mod.rs index d056c152..3c1afaa5 100644 --- a/apps/desktop/src-tauri/src/sidecar/mod.rs +++ b/apps/desktop/src-tauri/src/sidecar/mod.rs @@ -20,12 +20,8 @@ mod capabilities; mod channels; mod commands; -mod convert; -// The Council write-capable worktree seam (issue #383): the host handler + run registry for -// the path-less `worktree-op-required` → `resolve-worktree-op` RPC. `pub(crate)` so `lib.rs` -// can `manage` the registry and `commands.rs` can register/forget runs. mod conformance_audit; -pub(crate) mod council_worktree; +mod convert; mod harness; mod insight; mod issue_map; @@ -109,6 +105,3 @@ pub(crate) use transport::*; // split out for issue #17 D; the glob preserves the historical `crate::sidecar::*` // paths. pub(crate) use channels::*; -// The Council run registry (issue #383): the host-trusted `councilRunId → project root` -// binding the worktree-op handler consults. Managed in `lib.rs`, written by `commands.rs`. -pub(crate) use council_worktree::CouncilRunRegistry; diff --git a/apps/desktop/src-tauri/src/sidecar/reader.rs b/apps/desktop/src-tauri/src/sidecar/reader.rs index 9e2694fd..e72fddd3 100644 --- a/apps/desktop/src-tauri/src/sidecar/reader.rs +++ b/apps/desktop/src-tauri/src/sidecar/reader.rs @@ -19,8 +19,7 @@ use super::permission::{ }; use super::verification::{handle_build_completed, handle_review_completed}; use super::{ - apply_and_emit, finish_run, notify_awaiting_input, park_for_approval, Outcome, DEBATE_EVENT, - SESSION_EVENT, + apply_and_emit, finish_run, notify_awaiting_input, park_for_approval, Outcome, SESSION_EVENT, }; /// The `nc:session` wire envelope: a streamed engine event tagged with its task. @@ -190,66 +189,8 @@ pub(crate) async fn handle_event(app: &AppHandle, event: Value) { return; } - // The Council `debate-*` family (the `debate-entry` transcript stream, issue #352) - // correlates by its wrapped `runId` (no `sessionId`) and is owned by the dedicated - // `nc:debate` channel, so it is routed BEFORE session-id correlation. There is no - // Rust-side store: the append-only transcript lives in the engine (auditable + - // replayable — safety #7), and the canvas folds the LIVE stream, so the reader just - // forwards the entry verbatim (like Insight forwards `analysis-*`). The canvas only - // READS this stream — nothing here feeds text back into a seat prompt (the mediated, - // quoted, injection-scanned bus stays the sole cross-seat path — safety #1/#2). - if event_type.starts_with("debate-") { - let _ = app.emit(DEBATE_EVENT, &event); - return; - } - - // The Council write-capable worktree seam (issue #383): a `worktree-op-required` event - // is the in-engine Council asking the host to `allocate`/`commit`/`gauntlet` on its - // behalf. It correlates by its `councilRunId` (no `sessionId`), so — like the scan - // families + `debate-*` — it is routed BEFORE the session-id correlation and consumed - // INTERNALLY (never forwarded to the web; it rides no `nc:*` channel). The host derives - // every path from the run id (never an engine-sent path — the escape guard) and replies - // with a `resolve-worktree-op` command. Offloaded off the reader (git/gauntlet work - // blocks) via the guarded async spawn, so it never head-of-line-blocks the event stream. - if event_type == "worktree-op-required" { - let app = app.clone(); - tauri::async_runtime::spawn(async move { - super::council_worktree::handle_worktree_op(&app, event).await; - }); - return; - } - let session_id = event.get("sessionId").and_then(Value::as_u64); - // Council SEAT carve-out (issue #364, extended by #374). A debate seat session is driven - // INSIDE the engine by the Conductor — NOT launched via the board's `start_session` - // command — so it pushed no pending-launch slot in the board-task FIFO. It self-identifies - // with `council: true` on its `session-started`. For any seat event we must SKIP the FIFO - // correlation below: `correlate` would otherwise find an empty FIFO and warn (the - // "correlation desync" flood) or — under concurrent board+council use — POP a - // still-pending board task's slot and mis-bind the seat to it, poisoning that task's - // correlation. The seat's output reaches the canvas over the moderated `nc:debate` - // stream (run-id-keyed, forwarded above), so the raw seat `nc:session` stream is - // intentionally dropped here. Registered on `session-started`; every later seat event - // short-circuits on the id set; the terminal deregisters so the set can't grow. - // - // #374: a PREFLIGHT-REFUSED seat (autonomy/governance) emits ONLY a `session-failed` — - // no `session-started` to note — so we ALSO skip any event that carries the `council` - // marker itself, not just ids already noted. A refused BOARD session's `session-failed` - // has no marker, so it still correlates (to fail its own task); only a marked seat - // terminal is carved out. - if let Some(sid) = session_id { - if is_council_session_start(event_type, &event) { - provider.note_council_session(sid); - } - if provider.is_council_session(sid) || event_is_council_marked(&event) { - if matches!(event_type, "session-completed" | "session-failed") { - provider.forget(sid); - } - return; - } - } - // Correlate the event to its task. The first sighting of a session id binds it // to the task at the front of the pending-launch FIFO; later events read back // the binding. An uncorrelatable event (no pending launch) is dropped. @@ -593,25 +534,6 @@ pub(crate) async fn handle_event(app: &AppHandle, event: Value) { } } -/// Whether `event` is a Council SEAT session's `session-started` (issue #364) — a -/// `session-started` carrying `council: true`. A seat is driven inside the engine by -/// the Conductor, not launched via the board's `start_session` command, so it pushed no -/// pending-launch FIFO slot; the reader records the seat on this event and thereafter -/// skips `correlate` for it (no desync warn, no mis-bind of a concurrently-pending board -/// task). Any non-`session-started` type, or a `session-started` without the flag, is a -/// normal board/scan session (false), so this leaves every non-council path unchanged. -fn is_council_session_start(event_type: &str, event: &Value) -> bool { - event_type == "session-started" && event_is_council_marked(event) -} - -/// Whether an event carries the Council SEAT marker (`council: true`). The engine stamps it on -/// a seat's `session-started` AND on a preflight-refused seat's `session-failed` (issue #374), -/// so a refused seat's terminal — which had no `session-started` to note — is still skipped -/// from board-FIFO correlation. A normal board/scan session never carries it. -fn event_is_council_marked(event: &Value) -> bool { - event.get("council").and_then(Value::as_bool) == Some(true) -} - /// A terminal event is STALE when the task is currently bound to a *different* /// session than the one the event carries — i.e. a newer run has superseded the one /// this terminal belongs to. Only fires when both ids are known and differ; an @@ -658,98 +580,6 @@ mod tests { assert!(!is_stale_terminal(None, None)); } - #[test] - fn council_session_start_only_matches_a_marked_session_started() { - // Only a `session-started` carrying `council: true` is a seat (issue #364). - let seat = json!({ - "type": "session-started", "sessionId": 209, "prompt": "debate", - "model": "claude-opus-4-8", "permissionMode": "plan", "council": true - }); - assert!(is_council_session_start("session-started", &seat)); - - // A normal board `session-started` (no marker) is NOT a seat — the field is - // absent, so every non-council launch is left byte-for-byte on the FIFO path. - let board = json!({ - "type": "session-started", "sessionId": 1, "prompt": "build", - "model": "claude-opus-4-8", "permissionMode": "default" - }); - assert!(!is_council_session_start("session-started", &board)); - - // An explicit `council: false` is also not a seat. - let board_false = json!({ - "type": "session-started", "sessionId": 2, "prompt": "build", - "model": "claude-opus-4-8", "permissionMode": "default", "council": false - }); - assert!(!is_council_session_start("session-started", &board_false)); - - // `is_council_session_start` only REGISTERS a seat on `session-started`; a later - // terminal is recognized via the tracked id set (or, for a refused seat, the marker - // — see `event_is_council_marked` below), never via this predicate. - let later = json!({ "type": "session-completed", "sessionId": 209, "council": true }); - assert!( - !is_council_session_start("session-completed", &later), - "only session-started registers a seat; later events route via the id set / marker" - ); - } - - #[test] - fn council_marker_is_recognized_on_any_event_type() { - // #374: a preflight-refused seat emits ONLY a marked `session-failed` (no - // `session-started` was ever noted), so the marker itself — on any event type — must - // carve the event out of board-FIFO correlation. - let refused = json!({ - "type": "session-failed", "sessionId": 77, "reason": "runner-crash", - "message": "autonomy not permitted", "council": true - }); - assert!(event_is_council_marked(&refused)); - - // A refused BOARD session's `session-failed` carries NO marker, so it still - // correlates (to fail its own task) — the distinction that keeps board failures working. - let board_fail = json!({ - "type": "session-failed", "sessionId": 3, "reason": "runner-crash", - "message": "boom" - }); - assert!(!event_is_council_marked(&board_fail)); - - // Explicit `council: false` is not a seat marker. - let board_false = json!({ "type": "session-failed", "sessionId": 4, "council": false }); - assert!(!event_is_council_marked(&board_false)); - } - - #[test] - fn council_seat_carve_out_precedes_and_bypasses_fifo_correlation() { - // Structure guard (issue #364): the council-seat carve-out must run BEFORE the - // board-FIFO `correlate` call, register the seat (`note_council_session`), skip - // every seat event (`is_council_session` → `return`), and deregister on the - // terminal (`forget`). This proves a seat never reaches `provider.correlate` - // (the desync-warn + mis-bind site). The arm needs a full `AppHandle` to run - // live, so this is a source-level guard like the sibling reader tests. - let src = include_str!("reader.rs"); - let carve = src - .find("if is_council_session_start(event_type, &event)") - .expect("the council carve-out exists"); - let correlate = src - .find("session_id.and_then(|sid| provider.correlate(sid))") - .expect("the board-FIFO correlation exists"); - assert!( - carve < correlate, - "the council carve-out must run BEFORE the board-FIFO correlation" - ); - let carve_block = &src[carve..correlate]; - assert!( - carve_block.contains("provider.note_council_session(sid)"), - "the carve-out registers a seat on its session-started" - ); - assert!( - carve_block.contains("provider.is_council_session(sid)"), - "the carve-out short-circuits every event for a registered seat" - ); - assert!( - carve_block.contains("provider.forget(sid)"), - "the carve-out deregisters the seat on its terminal" - ); - } - #[test] fn fatal_setup_failure_reads_structured_category() { // auth / disk-full categories are fatal-setup → trip the breaker at once. @@ -970,29 +800,4 @@ mod tests { "`harness-failed` is not a contract event — the reap must never emit it" ); } - - #[test] - fn debate_family_forwards_on_its_channel_before_session_correlation() { - // The Council `debate-*` family (`debate-entry`) correlates by its wrapped - // `runId` (no `sessionId`), so it MUST forward onto the `DEBATE_EVENT` channel - // and `return` BEFORE the session-id correlation below (which would otherwise - // drop it for lacking a `sessionId`) — mirroring the scan families' - // pre-correlation routing. The arm needs a full `AppHandle` to exercise live, so - // this is a source-level guard (like the pr-fix + offload guards above). - let src = include_str!("reader.rs"); - let arm_at = src - .find("if event_type.starts_with(\"debate-\")") - .expect("the debate routing arm exists"); - let emit = src[arm_at..] - .find("app.emit(DEBATE_EVENT") - .map(|rel| arm_at + rel) - .expect("the debate arm forwards on the DEBATE_EVENT channel"); - let correlation = src - .find("let session_id = event.get(\"sessionId\")") - .expect("the session-id correlation exists"); - assert!( - arm_at < correlation && emit < correlation, - "the debate family must forward + return BEFORE the session-id correlation" - ); - } } diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 3cac2210..f299f85a 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -95,7 +95,6 @@ export default defineConfig({ { slug: 'reference/task-kinds' }, { slug: 'reference/scans' }, { slug: 'reference/pr-review' }, - { slug: 'reference/council' }, { slug: 'reference/providers' }, { slug: 'reference/files-on-disk' }, { slug: 'reference/architecture' }, diff --git a/apps/docs/src/content/docs/lifecycle/index.mdx b/apps/docs/src/content/docs/lifecycle/index.mdx index 1ea06b38..5367fc46 100644 --- a/apps/docs/src/content/docs/lifecycle/index.mdx +++ b/apps/docs/src/content/docs/lifecycle/index.mdx @@ -54,7 +54,7 @@ reasons about your project's maturity. A brand-new project realistically starts at Understand; a repo you have already hardened lives mostly in Verify. The parts of the workspace that are *not* stages — the Kanban Board (`K`), -Worktrees (`W`), Terminal (`L`), History (`R`), Council (`C`) and Settings +Worktrees (`W`), Terminal (`L`), History (`R`) and Settings (`S`) — sit alongside them as project surfaces. The board in particular is where tasks from every stage end up. diff --git a/apps/docs/src/content/docs/reference/council.md b/apps/docs/src/content/docs/reference/council.md deleted file mode 100644 index 42eeab75..00000000 --- a/apps/docs/src/content/docs/reference/council.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -title: Council -description: A governed multi-agent debate board — what is shipped, what is reachable, and the specific things that are implemented but not wired or not yet verified. -sidebar: - order: 4 ---- - -Council (`C`) is a debate board: several agent seats argue a problem through -structured stages, a deterministic gate can override their consensus, and a -human holds the gavel. - -The honest framing matters here, because "more agents" is not a thesis: -**Council is governed reasoning, not the claim that more agents are smarter.** -Its value is that the disagreement is structured, the transcript is append-only, -and the conclusion has to survive both an objective check and a human. - -:::caution[Read the "not done" section] -Council is the least settled surface in the product. Several capabilities exist -in code but are not reachable from any shipped preset, and its most significant -capability — actually writing code — has **not been verified end to end against -a live provider**. That is stated below rather than buried. -::: - -## The vocabulary - -- **Stages**: `frame`, `propose`, `debate`, `converge`, `build`, `review` -- **Seat roles**: `proposer`, `critic`, `judge`, `conductor`, `human` -- **Routing**: `moderated-bus` only — a Conductor is the sole writer to the - transcript. Peer-to-peer routing is deliberately not declared. - -## The three presets - -All three run the same three seats — two proposers and a critic. - -| Preset | Stages | Objective gate | Convergence | -|---|---|---|---| -| `research` | frame → propose (blind) → debate → converge | none | human | -| `ui-bug` | + `build` before converge | `repro` | human | -| `coding` | + `build` before converge | `build` | human | - -"Propose (blind)" means proposers do not see each other's proposal before making -their own — the point of two proposers is independent starting positions, not an -echo. - -## The objective gate - -An **objective gate** is a deterministic check whose **RED verdict overrides -debate consensus**. Three agents agreeing does not beat a failing build. - -Two kinds: - -- **`repro`** — a failing check that must go red → green. -- **`build`** — a typecheck / lint / test gauntlet over the writer's worktree. - -The gate reuses the existing Structure-Lock gauntlet rather than introducing a -new execution path, so it inherits the same confinement as everything else. The -seam is deliberately narrow: a context goes in, a `{ passed, summary, checks }` -verdict comes out, and `passed` is the only field that decides the override. - -## The terminal judge - -Convergence in every shipped preset is **human**. You resolve a council with one -of three decisions: - -| Decision | Requires | -|---|---| -| `accept` | which seat's answer you are accepting | -| `reject` | — | -| `judge` | a note explaining your own ruling | - -The verdict flows through the Conductor onto the append-only transcript. It is -never written directly to the store, so the record of "who decided what" has the -same integrity as the debate itself. - -## Yes, it writes code - -Council has a real write-capable driver. When a preset includes the `build` -stage: - -- A worktree is allocated through a **path-less RPC**: the engine sends a run - id, the host derives every path. The engine never supplies a filesystem - location, so it cannot ask for one outside the sanctioned tree. -- A **single elected writer** runs — elected from the debating seats, **never a - judge seat** — write-capable and Seatbelt-wrapped. -- The work is committed. **It never merges.** Merge and discard stay human-only. - -## What is NOT done - -This section is the reason to read this page. - -- **The `review` stage is dormant in production.** The stage exists and the - conductor implements it, but no review driver is injected by the shipped - router. A preset that named it would get nothing. -- **`judge-agent` and `vote` convergence are unreachable.** The schema accepts - them and the conductor implements them, but all three registered presets are - `convergence: 'human'`. There is no way to select the others from the UI. -- **There is no human→seat steering.** The canvas *reads* the debate stream; - there is no command that feeds text back into a seat's prompt. Mediated - broadcast / DM / steer is tracked as - [#361](https://github.com/noctcore/nightcore/issues/361) and is a substantial - feature, not a tweak. -- **Hardening follow-ups are deferred**, including a fail-**closed** Seatbelt - posture for the writer and dependency provisioning in the writer's worktree — - tracked as [#387](https://github.com/noctcore/nightcore/issues/387). -- **The live end-to-end write is unverified.** CI exercises the driver with the - writer execution faked, which proves the wiring and not the outcome. Treat the - build stage as experimental until someone has dogfooded it against a real - provider. - -The tracking issue for the whole surface is -[#334](https://github.com/noctcore/nightcore/issues/334). - -## Should you use it? - -For **research** — arguing a design decision, stress-testing an approach before -you build it — it does what it says, and the transcript is genuinely useful -afterwards. - -For **`build`-stage work**, prefer the board. A Build task gets the full -[verification gauntlet](../../governance/gates/), a -[Trust Report](../../governance/receipts/), and a merge path that has been -exercised. Council's writer has a narrower, newer, less-verified path around it. diff --git a/apps/docs/src/content/docs/reference/limits.md b/apps/docs/src/content/docs/reference/limits.md index 0517c435..53ed2a3a 100644 --- a/apps/docs/src/content/docs/reference/limits.md +++ b/apps/docs/src/content/docs/reference/limits.md @@ -1,6 +1,6 @@ --- title: Limits and honest gaps -description: What is alpha, what is macOS-only, what needs a real provider account, and what is implemented but unverified — collected in one place rather than scattered through the docs. +description: What is alpha, what is macOS-only, and what needs a real provider account, collected in one place rather than scattered through the docs. sidebar: order: 8 --- @@ -65,9 +65,9 @@ Nightcore does not bundle credentials and does not proxy anything. It drives a provider CLI you have installed and logged into. Without one, there is nothing to run. -Model usage is billed to **your** provider account. Deep scans, multi-lens PR -review, and Council all do substantially more model work than a single task — -that is a spend decision, not a setting. +Model usage is billed to **your** provider account. Deep scans and multi-lens PR +review do substantially more model work than a single task: that is a spend +decision, not a setting. :::note[The all-$0 failure signature] A scan that "fails" with zero cost and zero input tokens is almost always a @@ -86,22 +86,6 @@ trail. → [Providers](../providers/) -## Unverified or not wired - -Stated plainly, because these are the claims most likely to be over-read: - -- **Council's build stage has never been verified end to end against a live - provider.** CI exercises the write-capable driver with the writer execution - faked. The wiring is proven; the outcome is not. -- **Council's `review` stage is dormant** — implemented, not injected in - production. -- **Council's `judge-agent` and `vote` convergence modes are unreachable** — all - three shipped presets converge on a human. -- **There is no human→seat steering in Council.** The canvas reads the debate - stream; nothing feeds text back into a seat. - -→ [Council](../council/) - ## Measurements that are approximate on purpose - **Spend in the trust summary counts the last run per task only.** Do not diff --git a/apps/docs/src/content/docs/reference/task-kinds.md b/apps/docs/src/content/docs/reference/task-kinds.md index 41eab338..3a9a01dd 100644 --- a/apps/docs/src/content/docs/reference/task-kinds.md +++ b/apps/docs/src/content/docs/reference/task-kinds.md @@ -73,5 +73,3 @@ The Kanban Board (`K`) is the control surface, but a project also has: deliberately **strip provider environment variables**, so a shell you open is not silently carrying an agent's credentials or mode flags. - **History** (`R`) — every past run and scan, with cost, duration, and outcome. -- **Council** (`C`) — the multi-agent debate board; see - [Council](../council/). diff --git a/apps/docs/src/content/docs/start/install.md b/apps/docs/src/content/docs/start/install.md index a504a119..1510a8b1 100644 --- a/apps/docs/src/content/docs/start/install.md +++ b/apps/docs/src/content/docs/start/install.md @@ -15,7 +15,7 @@ sidebar: Nightcore is **alpha**. APIs, UI, and on-disk formats can break between releases. See [Limits and honest gaps](../../reference/limits/) for the full -list of what is unfinished, unverified, or platform-specific. +list of what is unfinished or platform-specific. ## Install a release diff --git a/apps/web/src/components/app/AppShell/AppShell.types.ts b/apps/web/src/components/app/AppShell/AppShell.types.ts index 36687680..eb4d5cf3 100644 --- a/apps/web/src/components/app/AppShell/AppShell.types.ts +++ b/apps/web/src/components/app/AppShell/AppShell.types.ts @@ -12,11 +12,6 @@ export type AppView = // stage shells, it isn't a provenance target) — so `nav-render-parity` is // unaffected, but its render branch lands in the SAME commit as this member. | 'history' - // The Council debate canvas (issue #352) — a governed multi-agent debate board. A - // Project-group destination, NOT a source-ref REGISTRY view (a council run has no - // provenance token), so `nav-render-parity` is unaffected; its render branch lands - // in the SAME commit as this member. - | 'council' // The five stage destinations (Phase-1 view rethink, PR 3): Understand hosts // Insight's Find + Scorecard's Grade behind one shell; Harden / Enforce are two // view filters over the ONE HarnessView run/store; PR Review + Issue Triage keep diff --git a/apps/web/src/components/app/AppShell/AppShellViews.tsx b/apps/web/src/components/app/AppShell/AppShellViews.tsx index 798e119a..b482990b 100644 --- a/apps/web/src/components/app/AppShell/AppShellViews.tsx +++ b/apps/web/src/components/app/AppShell/AppShellViews.tsx @@ -51,12 +51,6 @@ const TerminalView = lazy(() => const HistoryView = lazy(() => import('@/components/history').then((m) => ({ default: m.HistoryView })), ); -// The Council canvas (issue #352) is a heavy, rarely-first-paint surface (its own -// seat-grid + team-chat + Markdown rendering), so it is code-split behind its own lazy -// chunk like the other Project-group destinations. -const CouncilView = lazy(() => - import('@/components/council').then((m) => ({ default: m.CouncilView })), -); /** Map a History row (family + run) to a run-level provenance target: Insight and * Scorecard both land on the Understand stage (its shell splits by `family`), @@ -260,18 +254,6 @@ export function AppShellViews({ standalone `insight` / `scorecard` routes were removed in the flip — legacy `insight:` / `scorecard:` provenance tokens now retarget here through the source-ref REGISTRY. */} - {/* Council debate canvas (issue #352): a governed multi-agent debate board — - the seat-node grid beside the team-chat projection of the nc:debate bus. - Reads the active project root off the registry (seats debate over it). */} - {view === 'council' && ( - }> - - - )} - {view === 'understand' && ( }> , group: 'project', }, - { - view: 'council', - label: 'Council', - hint: 'C', - icon: , - group: 'project', - }, { view: 'issuetriage', label: STAGE_BY_ID.intake.destination, diff --git a/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.hooks.ts b/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.hooks.ts deleted file mode 100644 index 3f9ae690..00000000 --- a/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.hooks.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Local form state for the {@link import('./ConvergeGavel').ConvergeGavel} (issue #353): - * the seat selected for an `accept`, the ruling/reason note, WHICH verdict is in flight, - * the reject confirmation, and the dispatch error. Kept out of the component body - * (no-state-in-body); the three verdicts (`accept` / `reject` / `judge`) map to one - * mediated resolve call. Tracking the in-flight decision (not a bare boolean) lets only - * the PRESSED button show busy while the others merely disable (GOV-8), and reject routes - * through a confirm dialog first (GOV-9). - */ -import { useCallback, useState } from 'react'; - -import type { CouncilConvergeDecision } from '@/lib/bridge'; - -import type { - ConvergeResolve, - ConvergeResolveOptions, -} from './ConvergeGavel.types'; - -export interface ConvergeGavelModel { - /** The seat whose position an `accept` would adopt, or `null` when none is picked. */ - selectedSeatId: string | null; - /** Pick the seat to adopt. */ - select: (seatId: string) => void; - /** The ruling (for `judge`) or an optional reason (for `accept`/`reject`). */ - note: string; - setNote: (value: string) => void; - /** The verdict currently being dispatched, or `null` when idle — the PRESSED button - * reads busy from this while the others merely disable (GOV-8). */ - pending: CouncilConvergeDecision | null; - /** True while any verdict dispatch is in flight — disables the controls. */ - busy: boolean; - /** The last dispatch failure, shown inline so the human can retry. */ - error: string | null; - /** Whether an `accept` can be submitted (a seat is selected). */ - canAccept: boolean; - /** Whether a `judge` ruling can be submitted (the note is non-empty). */ - canJudge: boolean; - /** Adopt the selected seat's position. */ - accept: () => void; - /** Whether the reject confirmation dialog is open (GOV-9). */ - rejectConfirmOpen: boolean; - /** Open the reject confirmation — rejecting closes the run, so it is guarded. */ - requestReject: () => void; - /** Dismiss the reject confirmation (no-op mid-dispatch). */ - closeRejectConfirm: () => void; - /** Reject every position — the run closes with no adopted outcome (dialog-confirmed). */ - confirmReject: () => void; - /** Record the human's own ruling (the note). */ - judge: () => void; - /** ⌘/Ctrl+↵ primary: accept the selected seat, else enter a ruling if one is typed. */ - submitPrimary: () => void; -} - -export function useConvergeGavel(onResolve: ConvergeResolve): ConvergeGavelModel { - const [selectedSeatId, setSelectedSeatId] = useState(null); - const [note, setNote] = useState(''); - const [pending, setPending] = useState(null); - const [rejectConfirmOpen, setRejectConfirmOpen] = useState(false); - const [error, setError] = useState(null); - const busy = pending !== null; - - const dispatch = useCallback( - async (decision: CouncilConvergeDecision, options?: ConvergeResolveOptions) => { - setPending(decision); - setError(null); - try { - await onResolve(decision, options); - // On success the parent flips to `resolved` and unmounts the form, so `pending` - // is intentionally left set to keep controls disabled through the transition. - } catch (err) { - setPending(null); // re-enable on failure so the human can retry - setRejectConfirmOpen(false); // surface the inline error, not the dialog - setError( - err instanceof Error ? err.message : 'Could not record your verdict.', - ); - } - }, - [onResolve], - ); - - const trimmedNote = note.trim(); - const withNote = (): ConvergeResolveOptions | undefined => - trimmedNote.length > 0 ? { note: trimmedNote } : undefined; - - const accept = useCallback(() => { - if (selectedSeatId === null || busy) return; - void dispatch('accept', { seatId: selectedSeatId, ...withNote() }); - }, [busy, dispatch, selectedSeatId, trimmedNote]); - - const requestReject = useCallback(() => { - if (busy) return; - setRejectConfirmOpen(true); - }, [busy]); - - const closeRejectConfirm = useCallback(() => { - if (pending === 'reject') return; // don't dismiss mid-dispatch - setRejectConfirmOpen(false); - }, [pending]); - - const confirmReject = useCallback(() => { - if (busy) return; - void dispatch('reject', withNote()); - }, [busy, dispatch, trimmedNote]); - - const judge = useCallback(() => { - if (trimmedNote.length === 0 || busy) return; - void dispatch('judge', { note: trimmedNote }); - }, [busy, dispatch, trimmedNote]); - - const submitPrimary = useCallback(() => { - if (selectedSeatId !== null) accept(); - else if (trimmedNote.length > 0) judge(); - }, [accept, judge, selectedSeatId, trimmedNote]); - - return { - selectedSeatId, - select: setSelectedSeatId, - note, - setNote, - pending, - busy, - error, - canAccept: selectedSeatId !== null, - canJudge: trimmedNote.length > 0, - accept, - rejectConfirmOpen, - requestReject, - closeRejectConfirm, - confirmReject, - judge, - submitPrimary, - }; -} diff --git a/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.stories.tsx b/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.stories.tsx deleted file mode 100644 index 328474c9..00000000 --- a/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import type { ConvergePosition } from '../council.types'; -import { ConvergeGavel } from './ConvergeGavel'; - -const POSITIONS: ConvergePosition[] = [ - { - seatId: 'proposer-opus', - role: 'proposer', - content: 'Feature-flag the store, dual-write with idempotency keys, then cut over.', - }, - { - seatId: 'proposer-sonnet', - role: 'proposer', - content: 'Big-bang migrate in a maintenance window — simpler, but no live rollback.', - }, - { - seatId: 'critic-opus', - role: 'critic', - content: 'Neither is safe without a rehearsed backfill + rollback. Prefer the flag.', - }, -]; - -const meta = { - title: 'Council/ConvergeGavel', - component: ConvergeGavel, - parameters: { layout: 'fullscreen' }, - args: { - positions: POSITIONS, - // The story dispatch resolves immediately; the real one routes through the Conductor. - onResolve: async () => {}, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Awaiting: Story = {}; - -export const NoPositionsYet: Story = { args: { positions: [] } }; - -export const Resolved: Story = { - args: { - resolved: true, - verdict: 'Human verdict — ACCEPT: adopted seat "proposer-opus" (proposer). Reason: safest rollback.', - }, -}; diff --git a/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.test.tsx b/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.test.tsx deleted file mode 100644 index 1ed9b260..00000000 --- a/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { composeStories } from '@storybook/react-vite'; -import { expect, test, vi } from 'vitest'; -import { render } from 'vitest-browser-react'; - -import type { ConvergePosition } from '../council.types'; -import { ConvergeGavel } from './ConvergeGavel'; -import * as stories from './ConvergeGavel.stories'; - -const { NoPositionsYet, Resolved } = composeStories(stories); - -const POSITIONS: ConvergePosition[] = [ - { seatId: 'proposer-opus', role: 'proposer', content: 'Flag the store.' }, - { seatId: 'critic-opus', role: 'critic', content: 'Rehearse rollback first.' }, -]; - -test('accept is disabled until a seat is selected, then resolves with that seat', async () => { - const onResolve = vi.fn(async () => {}); - const screen = render(); - - const accept = screen.getByRole('button', { name: /Accept selected/ }); - await expect.element(accept).toBeDisabled(); - - await screen.getByRole('radio', { name: "Adopt critic-opus's position" }).click(); - await expect.element(accept).toBeEnabled(); - await accept.click(); - - expect(onResolve).toHaveBeenCalledWith('accept', { seatId: 'critic-opus' }); -}); - -test('reject is confirmed through a dialog before it resolves', async () => { - const onResolve = vi.fn(async () => {}); - const screen = render(); - - // The toolbar button opens the guard dialog; nothing dispatches yet. - await screen.getByRole('button', { name: /Reject all/ }).click(); - const dialog = screen.getByRole('alertdialog'); - await expect.element(dialog).toBeInTheDocument(); - expect(onResolve).not.toHaveBeenCalled(); - - // Confirming rejects every position and closes the run. - await dialog.getByRole('button', { name: 'Reject and close' }).click(); - expect(onResolve).toHaveBeenCalledWith('reject', undefined); -}); - -test('judge is gated on a ruling, then resolves with the ruling note', async () => { - const onResolve = vi.fn(async () => {}); - const screen = render(); - - const judge = screen.getByRole('button', { name: /Enter my ruling/ }); - await expect.element(judge).toBeDisabled(); - - await screen.getByLabelText('Your ruling / reason').fill('Stage the cutover behind a flag.'); - await expect.element(judge).toBeEnabled(); - await judge.click(); - - expect(onResolve).toHaveBeenCalledWith('judge', { - note: 'Stage the cutover behind a flag.', - }); -}); - -test('a dispatch failure surfaces an inline error and re-enables the controls', async () => { - const onResolve = vi.fn(async () => { - throw new Error('no live session for this run'); - }); - const screen = render(); - - await screen.getByRole('button', { name: /Reject all/ }).click(); - await screen.getByRole('alertdialog').getByRole('button', { name: 'Reject and close' }).click(); - await expect.element(screen.getByRole('alert')).toHaveTextContent('no live session for this run'); - await expect.element(screen.getByRole('button', { name: /Reject all/ })).toBeEnabled(); -}); - -test('shows a waiting state before the seats have final positions', async () => { - const screen = render(); - await expect - .element(screen.getByText("Waiting for the seats' final positions…")) - .toBeInTheDocument(); -}); - -test('once resolved, shows the recorded verdict read-only with no actions', async () => { - const screen = render(); - await expect.element(screen.getByText('Verdict recorded')).toBeInTheDocument(); - await expect.element(screen.getByText(/adopted seat "proposer-opus"/)).toBeInTheDocument(); - await expect - .element(screen.getByRole('button', { name: /Accept selected/ })) - .not.toBeInTheDocument(); -}); diff --git a/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.tsx b/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.tsx deleted file mode 100644 index 341f31b0..00000000 --- a/apps/web/src/components/council/ConvergeGavel/ConvergeGavel.tsx +++ /dev/null @@ -1,195 +0,0 @@ -/** - * The human Converge gavel (issue #353) — P1's Converge stage is HUMAN-only, so this is - * the human's terminal authority over a council run (safety non-negotiable #7). The - * human weighs the seats' final positions side-by-side (disagreement is the product) and - * rules one of three ways: ACCEPT one seat's position, REJECT every position, or JUDGE - * with their own ruling. The verdict flows through the Conductor (the sole bus writer) - * onto the append-only transcript — never a direct store write from the surface (safety - * #1); this component only DISPATCHES the human's choice and never feeds text into a - * seat prompt. - * - * Mirrors the board's HITL dock conventions (plan approval / the verification gate): a - * bottom-docked panel, labelled controls, ⌘/Ctrl+↵ to submit the primary action, and - * sibling loading/empty/error affordances. - */ -import { - Badge, - Button, - CheckIcon, - CloseIcon, - ConfirmDialog, - ConfirmHint, - Markdown, - RefineIcon, - VerifiedIcon, -} from '@/components/ui'; - -import { SEAT_ROLE_TONE } from '../council-roles'; -import { useConvergeGavel } from './ConvergeGavel.hooks'; -import type { ConvergeGavelProps } from './ConvergeGavel.types'; - -const RULING_INPUT_ID = 'council-converge-ruling'; - -export function ConvergeGavel({ - positions, - onResolve, - resolved = false, - verdict, -}: ConvergeGavelProps) { - const gavel = useConvergeGavel(onResolve); - - // Resolved: the run is closed — show the recorded verdict read-only, no actions. - if (resolved) { - return ( -
-
- - - Verdict recorded - -
-

- {verdict ?? 'The human judge closed this council. The verdict is on the transcript.'} -

-
- ); - } - - return ( -
-
- -

- Converge — you're the judge -

- - Adopt a seat's position, write your own ruling, or reject them all. P1 Converge - is yours alone. - -
- - {positions.length === 0 ? ( -

- Waiting for the seats' final positions… -

- ) : ( - <> -
- - Adopt a position - -
- {positions.map((position) => { - const selected = gavel.selectedSeatId === position.seatId; - return ( - - ); - })} -
-
- -
- -