feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé

- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:24:51 +02:00
parent 32398827fb
commit 785e9935fd
118 changed files with 5793 additions and 866 deletions

View File

@ -106,6 +106,25 @@ pub enum DomainEvent {
/// The project whose index was rebuilt.
project_id: ProjectId,
},
/// A project's memory grew past the recall budget while no embedder is
/// configured (strategy `none`): the first moment a semantic embedder would
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per
/// project** (and never again once the user chose "ne plus demander"); the
/// frontend surfaces a one-time, dismissible suggestion. Carries the detected
/// local environment and the compiled-in capabilities so the UI never proposes
/// a strategy this binary cannot run.
EmbedderSuggested {
/// The project whose memory crossed the budget.
project_id: ProjectId,
/// Whether an Ollama-style local embedding server was detected (best-effort).
ollama_detected: bool,
/// Ids of the recommended ONNX models already present in the local cache.
onnx_cached: Vec<String>,
/// Whether the HTTP capability (`localServer`/`api`) is compiled in.
vector_http_enabled: bool,
/// Whether the in-process ONNX capability (`localOnnx`) is compiled in.
vector_onnx_enabled: bool,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput {
/// The session.

View File

@ -429,6 +429,57 @@ impl LayoutTree {
Ok(tree)
}
/// Attaches an existing live [`SessionId`] to `target`, moving it away from
/// any other visible leaf first.
///
/// This models the IdeA-first visible/background contract: a live agent
/// session may keep running while detached from the grid, and opening a cell
/// on that already-running session simply re-attaches its view. The session is
/// visible in at most one cell because any previous host is cleared before the
/// target is set.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree,
/// - [`LayoutError::CrossContainer`] if `target` hosts a different session.
pub fn attach_session(&self, target: NodeId, session: SessionId) -> Result<Self, LayoutError> {
let target_session = self.session_in_leaf(target)?;
if let Some(existing) = target_session {
if existing == session {
return Ok(self.clone());
}
return Err(LayoutError::CrossContainer);
}
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.session == Some(session) && leaf.id != target {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: None,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
if leaf.id == target {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: Some(session),
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
}
node.clone()
});
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Attaches (or, with `None`, detaches) an [`AgentId`] to the leaf `target`.
///
/// Records which agent should be auto-launched in the cell (feature #3).
@ -513,11 +564,7 @@ impl LayoutTree {
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
pub fn set_agent_running(
&self,
target: NodeId,
running: bool,
) -> Result<Self, LayoutError> {
pub fn set_agent_running(&self, target: NodeId, running: bool) -> Result<Self, LayoutError> {
let mut found = false;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
@ -580,9 +627,7 @@ impl LayoutTree {
match node {
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
LayoutNode::Leaf(_) => None,
LayoutNode::Split(split) => {
split.children.iter().find_map(|c| find(&c.node, id))
}
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
}
}

View File

@ -74,9 +74,7 @@ pub use profile::{
pub use markdown::MarkdownDoc;
pub use memory::{
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
};
pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};
pub use remote::{RemoteKind, RemoteRef, SshAuth};
@ -91,14 +89,16 @@ pub use layout::{
pub use events::DomainEvent;
pub use orchestrator::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
pub use orchestrator::{
OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility,
};
pub use ports::{
AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, Embedder,
EmbedderError, EventBus, EventStream,
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
IdGenerator, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream,
PreparedContext, ProcessError,
ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError,
RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PreparedContext,
ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
};

View File

@ -36,9 +36,7 @@ impl MemorySlug {
/// outside `[a-z0-9-]`.
pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
let raw = raw.into();
let invalid = || DomainError::InvalidSlug {
value: raw.clone(),
};
let invalid = || DomainError::InvalidSlug { value: raw.clone() };
if raw.is_empty() {
return Err(invalid());
}

View File

@ -13,12 +13,13 @@
use serde::{Deserialize, Serialize};
use crate::ids::NodeId;
use crate::skill::SkillScope;
/// Errors raised while validating a raw [`OrchestratorRequest`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OrchestratorError {
/// The `action` field is not one of the supported v1 actions.
/// The requested action/type is not one of the supported actions.
#[error("unknown orchestrator action: {0}")]
UnknownAction(String),
/// A field required by the chosen action is missing or empty.
@ -37,6 +38,14 @@ pub enum OrchestratorError {
/// The offending scope value.
scope: String,
},
/// The `visibility` field is present but not recognised.
#[error("unknown visibility `{visibility}` for action `{action}`")]
UnknownVisibility {
/// The action being validated.
action: String,
/// The offending visibility value.
visibility: String,
},
}
/// The raw, wire-level orchestrator request as deserialised from a request file.
@ -48,11 +57,22 @@ pub enum OrchestratorError {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrchestratorRequest {
/// The requested action (`spawn_agent`, `stop_agent`, `update_agent_context`).
pub action: String,
/// Legacy v1 action (`spawn_agent`, `stop_agent`, `update_agent_context`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
/// V2 action type (`agent.run`, `agent.stop`, `agent.update_context`,
/// `skill.create`). `type` is reserved in Rust, so the field is renamed.
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
pub request_type: Option<String>,
/// Optional requester id/name, informational at this layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_by: Option<String>,
/// Target agent display name (required by every v1 action).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// V2 target agent display name (`agent.run`/`agent.stop`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_agent: Option<String>,
/// Runtime profile slug/name (required by `spawn_agent`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
@ -61,6 +81,21 @@ pub struct OrchestratorRequest {
/// `create_skill` this carries the **new Markdown body** to write.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
/// Optional task/message carried by `agent.run` / future `agent.message`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<String>,
/// Whether a spawned agent should stay in the background or attach to a cell.
///
/// Accepted values are `"background"` (default) and `"visible"`. A visible
/// spawn must also provide [`Self::node_id`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<String>,
/// Target layout leaf for `visibility: "visible"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<NodeId>,
/// V2 visible-cell target (`attachToCell`), equivalent to `nodeId`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attach_to_cell: Option<NodeId>,
/// Skill scope for `create_skill` (`"global"` or `"project"`, case-insensitive).
/// Optional — absent/empty defaults to [`SkillScope::Project`]. Ignored by the
/// other actions.
@ -79,10 +114,14 @@ pub enum OrchestratorCommand {
SpawnAgent {
/// Target agent display name.
name: String,
/// Profile slug/name to resolve against the configured profiles.
profile: String,
/// Profile slug/name to resolve against the configured profiles. Required
/// for legacy `spawn_agent`; optional for `agent.run` when the agent
/// already exists and its manifest owns the profile id.
profile: Option<String>,
/// Optional initial `.md` body for a freshly-created agent.
context: Option<String>,
/// Desired visibility for the launched session.
visibility: OrchestratorVisibility,
},
/// Stop a running agent by killing its terminal session.
StopAgent {
@ -107,6 +146,18 @@ pub enum OrchestratorCommand {
},
}
/// Where IdeA should place a launched agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrchestratorVisibility {
/// Launch or keep running without attaching to a layout cell.
Background,
/// Launch or re-attach visibly in one layout leaf.
Visible {
/// Target layout leaf.
node_id: NodeId,
},
}
impl OrchestratorRequest {
/// Validates the raw request into a well-formed [`OrchestratorCommand`].
///
@ -120,25 +171,44 @@ impl OrchestratorRequest {
/// [`OrchestratorError::UnknownAction`] for an unsupported action;
/// [`OrchestratorError::MissingField`] when a required field is absent/empty.
pub fn validate(&self) -> Result<OrchestratorCommand, OrchestratorError> {
let action = self.action.trim();
let action = self.action_name()?;
match action {
"spawn_agent" => Ok(OrchestratorCommand::SpawnAgent {
name: self.require_name(action)?,
profile: self.require("profile", action, self.profile.as_deref())?,
profile: Some(self.require("profile", action, self.profile.as_deref())?),
context: self.context.as_ref().filter(|c| !c.is_empty()).cloned(),
visibility: self.parse_visibility(action)?,
}),
"agent.run" => Ok(OrchestratorCommand::SpawnAgent {
name: self.require_target_agent(action)?,
profile: self
.profile
.as_ref()
.filter(|p| !p.trim().is_empty())
.cloned(),
context: self
.context
.as_ref()
.or(self.task.as_ref())
.filter(|c| !c.is_empty())
.cloned(),
visibility: self.parse_visibility(action)?,
}),
"stop_agent" => Ok(OrchestratorCommand::StopAgent {
name: self.require_name(action)?,
}),
"agent.stop" => Ok(OrchestratorCommand::StopAgent {
name: self.require_target_agent(action)?,
}),
"update_agent_context" => Ok(OrchestratorCommand::UpdateAgentContext {
name: self.require_name(action)?,
context: self.require("context", action, self.context.as_deref())?,
}),
"create_skill" => Ok(OrchestratorCommand::CreateSkill {
"agent.update_context" => Ok(OrchestratorCommand::UpdateAgentContext {
name: self.require_target_agent(action)?,
context: self.require("context", action, self.context.as_deref())?,
}),
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
name: self.require_name(action)?,
content: self.require("context", action, self.context.as_deref())?,
scope: self.parse_scope(action)?,
@ -165,11 +235,51 @@ impl OrchestratorRequest {
}
}
/// Parses `visibility` for `spawn_agent`, defaulting to background.
fn parse_visibility(&self, action: &str) -> Result<OrchestratorVisibility, OrchestratorError> {
match self.visibility.as_deref().map(str::trim) {
None | Some("") | Some("background") => Ok(OrchestratorVisibility::Background),
Some("visible") => {
let node_id = self.node_id.or(self.attach_to_cell).ok_or_else(|| {
OrchestratorError::MissingField {
action: action.to_owned(),
field: "nodeId".to_owned(),
}
})?;
Ok(OrchestratorVisibility::Visible { node_id })
}
Some(other) => Err(OrchestratorError::UnknownVisibility {
action: action.to_owned(),
visibility: other.to_owned(),
}),
}
}
/// Requires a non-empty `name`, shared by all actions.
fn require_name(&self, action: &str) -> Result<String, OrchestratorError> {
self.require("name", action, self.name.as_deref())
}
fn require_target_agent(&self, action: &str) -> Result<String, OrchestratorError> {
self.require(
"targetAgent",
action,
self.target_agent.as_deref().or(self.name.as_deref()),
)
}
fn action_name(&self) -> Result<&str, OrchestratorError> {
self.request_type
.as_deref()
.or(self.action.as_deref())
.map(str::trim)
.filter(|a| !a.is_empty())
.ok_or_else(|| OrchestratorError::MissingField {
action: "<request>".to_owned(),
field: "type".to_owned(),
})
}
/// Requires `value` to be present and non-empty (after trimming), else a
/// [`OrchestratorError::MissingField`] naming `field`/`action`.
fn require(
@ -205,8 +315,9 @@ mod tests {
r.validate().unwrap(),
OrchestratorCommand::SpawnAgent {
name: "dev-backend".to_owned(),
profile: "claude-code".to_owned(),
profile: Some("claude-code".to_owned()),
context: Some("agents/dev-backend.md".to_owned()),
visibility: OrchestratorVisibility::Background,
}
);
}
@ -218,12 +329,43 @@ mod tests {
r.validate().unwrap(),
OrchestratorCommand::SpawnAgent {
name: "a".to_owned(),
profile: "claude-code".to_owned(),
profile: Some("claude-code".to_owned()),
context: None,
visibility: OrchestratorVisibility::Background,
}
);
}
#[test]
fn spawn_agent_visible_requires_and_carries_node_id() {
let node = uuid::Uuid::from_u128(42);
let r = req(&format!(
r#"{{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible", "nodeId": "{node}" }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::SpawnAgent {
name: "a".to_owned(),
profile: Some("claude-code".to_owned()),
context: None,
visibility: OrchestratorVisibility::Visible {
node_id: NodeId::from_uuid(node)
},
}
);
let missing = req(
r#"{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible" }"#,
);
assert_eq!(
missing.validate(),
Err(OrchestratorError::MissingField {
action: "spawn_agent".to_owned(),
field: "nodeId".to_owned(),
})
);
}
#[test]
fn spawn_agent_missing_profile_is_rejected() {
let r = req(r#"{ "action": "spawn_agent", "name": "a" }"#);
@ -236,6 +378,41 @@ mod tests {
);
}
#[test]
fn agent_run_accepts_type_target_agent_and_optional_profile() {
let r = req(
r#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "Architect", "task": "Analyse", "visibility": "background" }"#,
);
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::SpawnAgent {
name: "Architect".to_owned(),
profile: None,
context: Some("Analyse".to_owned()),
visibility: OrchestratorVisibility::Background,
}
);
}
#[test]
fn agent_run_visible_accepts_attach_to_cell() {
let node = uuid::Uuid::from_u128(77);
let r = req(&format!(
r#"{{ "type": "agent.run", "targetAgent": "Architect", "profile": "claude-code", "visibility": "visible", "attachToCell": "{node}" }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::SpawnAgent {
name: "Architect".to_owned(),
profile: Some("claude-code".to_owned()),
context: None,
visibility: OrchestratorVisibility::Visible {
node_id: NodeId::from_uuid(node)
},
}
);
}
#[test]
fn stop_agent_validates() {
let r = req(r#"{ "action": "stop_agent", "name": "dev-backend" }"#);
@ -261,9 +438,8 @@ mod tests {
#[test]
fn update_context_requires_a_body() {
let ok = req(
r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##,
);
let ok =
req(r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##);
assert_eq!(
ok.validate().unwrap(),
OrchestratorCommand::UpdateAgentContext {
@ -284,9 +460,8 @@ mod tests {
#[test]
fn create_skill_defaults_to_project_scope() {
let r = req(
r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##,
);
let r =
req(r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##);
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::CreateSkill {
@ -343,7 +518,9 @@ mod tests {
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);
assert_eq!(
r.validate(),
Err(OrchestratorError::UnknownAction("delete_everything".to_owned()))
Err(OrchestratorError::UnknownAction(
"delete_everything".to_owned()
))
);
}

View File

@ -32,7 +32,7 @@ use crate::events::DomainEvent;
use crate::ids::{AgentId, SessionId};
use crate::markdown::MarkdownDoc;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::profile::AgentProfile;
use crate::profile::{AgentProfile, EmbedderProfile};
use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind;
use crate::skill::{Skill, SkillScope};
@ -798,6 +798,94 @@ pub trait ProfileStore: Send + Sync {
async fn mark_configured(&self) -> Result<(), StoreError>;
}
/// CRUD for the declarative [`EmbedderProfile`]s in the global IDE store
/// (`embedder.json`, LOT C, §14.5.3). Mirrors [`ProfileStore`] for the embedding
/// engines: adding an engine is *data* the user configures, not code.
///
/// There is **no** `is_configured`/`mark_configured`: the default embedder posture
/// is `none` **by absence of the file** (an empty/missing `embedder.json` ⇒ recall
/// stays the dependency-free naïve étage 1). A configured embedder takes effect at
/// the **next IDE start** (the composition root freezes the recall for the session).
#[async_trait]
pub trait EmbedderProfileStore: Send + Sync {
/// Lists all configured embedder profiles (empty when none configured).
///
/// # Errors
/// [`StoreError`] on failure.
async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError>;
/// Saves (creates or replaces by id) an embedder profile.
///
/// # Errors
/// [`StoreError`] on failure.
async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError>;
/// Deletes an embedder profile by id.
///
/// # Errors
/// [`StoreError::NotFound`] if no profile carries that id.
async fn delete(&self, id: &str) -> Result<(), StoreError>;
}
/// Best-effort snapshot of the embedding *environment*: which local engines are
/// already installed/cached on this machine. Drives the "configure an embedder?"
/// UI (C2/C3) so it can detect an existing engine before suggesting a download.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EmbedderEnvReport {
/// Whether an Ollama-style local embedding server was detected (best-effort;
/// always `false` in a build without the HTTP capability).
pub ollama_detected: bool,
/// Ids of the recommended ONNX models already present in the local cache.
pub onnx_cached_models: Vec<String>,
}
/// Probes the local embedding environment (installed/cached engines). **Best-effort
/// by contract**: [`inspect`](Self::inspect) never errors — an unreachable host or
/// an unreadable cache simply yields a report with the corresponding fields unset.
#[async_trait]
pub trait EmbedderEnvInspector: Send + Sync {
/// Returns a best-effort snapshot of the local embedding environment.
async fn inspect(&self) -> EmbedderEnvReport;
}
/// The persisted user response to the "configure an embedder?" suggestion
/// (ARCHITECTURE §14.5.5, LOT C3), stored per project under
/// `.ideai/memory/.embedder-prompt.json`. Absence of the file ⇒ never answered.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmbedderPromptDismissal {
/// "Plus tard" — re-proposable on a future session (not this one).
Later,
/// "Ne plus demander" — never publish the suggestion again (persistent).
Never,
}
/// Persistence of the per-project embedder-suggestion state
/// (`.ideai/memory/.embedder-prompt.json`, LOT C3). A fine-grained port (Interface
/// Segregation) so the suggestion use cases depend on this alone, not on a wider
/// store. **Best-effort reads**: a missing/malformed file is *not* an error — it
/// reads as "never answered" ([`None`]).
#[async_trait]
pub trait EmbedderPromptStore: Send + Sync {
/// Reads the recorded dismissal for `root`'s project, or `None` when the user
/// has never answered (no file / unreadable / malformed).
///
/// # Errors
/// [`StoreError`] only on an unexpected I/O failure the adapter chooses to
/// surface; a plain missing file degrades to `Ok(None)`.
async fn read(&self, root: &ProjectPath)
-> Result<Option<EmbedderPromptDismissal>, StoreError>;
/// Records the user's dismissal choice for `root`'s project.
///
/// # Errors
/// [`StoreError`] on a persistence failure.
async fn write(
&self,
root: &ProjectPath,
dismissal: EmbedderPromptDismissal,
) -> Result<(), StoreError>;
}
/// Reads/writes agent `.md` contexts and the project manifest, within a project.
#[async_trait]
pub trait AgentContextStore: Send + Sync {
@ -895,8 +983,7 @@ pub trait GitPort: Send + Sync {
///
/// # Errors
/// [`GitError`] on failure.
async fn log(&self, root: &ProjectPath, limit: usize)
-> Result<Vec<GitCommitInfo>, GitError>;
async fn log(&self, root: &ProjectPath, limit: usize) -> Result<Vec<GitCommitInfo>, GitError>;
/// Returns the commit graph (all local branches, topological + time sort).
///