Merge pull request #2161 from idling11/feat/slop-ledger

Feat/Add a durable `SlopLedger` that makes invisible architectural residue visible and queryable across agent sessions
This commit is contained in:
Hunter Bown
2026-05-30 23:17:27 -07:00
committed by GitHub
9 changed files with 1441 additions and 2 deletions
+41
View File
@@ -744,6 +744,47 @@ pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult {
}
}
/// `/slop [query|export]` — inspect or export the slop ledger (#2127).
/// With no arguments, prints a summary. `query` shows filtered results;
/// `export` outputs the full ledger as Markdown.
pub fn slop(_app: &mut App, arg: Option<&str>) -> CommandResult {
let arg = arg.map(str::trim).unwrap_or("");
let ledger = match crate::slop_ledger::SlopLedger::load() {
Ok(l) => l,
Err(e) => return CommandResult::error(format!("Failed to load slop ledger: {e}")),
};
match arg {
"" => CommandResult::message(ledger.summary()),
"query" | "q" => {
if ledger.is_empty() {
return CommandResult::message("Slop ledger is empty.");
}
let mut out = String::new();
for entry in &ledger.query(&Default::default()) {
use std::fmt::Write;
let _ = writeln!(
out,
"[{}] {} ({:?} | {:?}) — {}",
crate::slop_ledger::short_id(&entry.id),
entry.bucket.as_str(),
entry.severity,
entry.status,
entry.title
);
}
CommandResult::message(out)
}
"export" | "e" => {
let md = ledger.export_markdown(None, None);
CommandResult::message(md)
}
_ => CommandResult::error(format!(
"Unknown /slop action '{arg}'. Use /slop, /slop query, or /slop export."
)),
}
}
/// Manage workspace-level trust and the per-path allowlist.
///
/// Subcommands:
+10
View File
@@ -546,6 +546,13 @@ pub const COMMANDS: &[CommandInfo] = &[
usage: "/cache [count|inspect|stats|warmup]",
description_id: MessageId::CmdCacheDescription,
},
// Slop Ledger (#2127)
CommandInfo {
name: "slop",
aliases: &["canzha"],
usage: "/slop [query|export]",
description_id: MessageId::CmdSlopDescription,
},
];
/// Execute a slash command
@@ -621,6 +628,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"balance" => balance::balance(app),
"cache" => debug::cache(app, arg),
// Slop ledger (#2127)
"slop" | "canzha" => config::slop(app, arg),
// ChangeLog command
"change" => change::change(app, arg),
"system" | "xitong" => debug::system_prompt(app),
+44 -2
View File
@@ -12,7 +12,7 @@ use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime};
use anyhow::Result;
use futures_util::StreamExt;
@@ -358,6 +358,10 @@ pub struct Engine {
/// Diagnostics collected during the current step's tool calls. Drained
/// and forwarded as a synthetic user message before the next API call.
pending_lsp_blocks: Vec<crate::lsp::DiagnosticBlock>,
/// Cached SlopLedger gate block keyed by the ledger file's modified time.
/// This keeps prompt refreshes cheap while still noticing append/update
/// writes from slop ledger tools during the same session.
slop_ledger_gate_cache: Option<(Option<SystemTime>, Option<String>)>,
}
// === Internal tool helpers ===
@@ -599,6 +603,7 @@ impl Engine {
turn_counter: 0,
lsp_manager,
pending_lsp_blocks: Vec::new(),
slop_ledger_gate_cache: None,
workshop_vars,
sandbox_backend,
};
@@ -1907,8 +1912,20 @@ impl Engine {
},
self.session.approval_mode,
);
let stable_prompt =
let mut stable_prompt =
merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone());
// SlopLedger completion-gate: inject unresolved slop entries into the
// system prompt so the agent can autonomously review them before
// claiming the task is done (#2127).
let gate_block = self.slop_ledger_gate_block();
if let Some(ref block) = gate_block {
if let Some(SystemPrompt::Text(prompt_text)) = &mut stable_prompt {
prompt_text.push_str("\n\n");
prompt_text.push_str(block);
}
}
let stable_hash = system_prompt_hash(stable_prompt.as_ref());
if self.session.system_prompt_override {
self.session.last_system_prompt_hash = Some(stable_hash);
@@ -1920,6 +1937,31 @@ impl Engine {
}
}
fn slop_ledger_gate_block(&mut self) -> Option<String> {
let modified = crate::slop_ledger::SlopLedger::default_path()
.ok()
.and_then(|path| std::fs::metadata(path).ok())
.and_then(|metadata| metadata.modified().ok());
if let Some((cached_modified, cached_block)) = &self.slop_ledger_gate_cache
&& *cached_modified == modified
{
return cached_block.clone();
}
let loaded = crate::slop_ledger::SlopLedger::load()
.ok()
.and_then(|ledger| {
if ledger.has_open_entries() {
ledger.completion_gate_summary()
} else {
None
}
});
self.slop_ledger_gate_cache = Some((modified, loaded.clone()));
loaded
}
fn merge_compaction_summary(&mut self, summary_prompt: Option<SystemPrompt>) {
if summary_prompt.is_none() {
return;
+8
View File
@@ -67,6 +67,14 @@ impl Engine {
.with_parallel_tool()
.with_recall_archive_tool();
// SlopLedger: plan mode only gets read-only query + export,
// agent/yolo get the full set including append + update.
builder = if mode == AppMode::Plan {
builder.with_slop_ledger_read_only_tools()
} else {
builder.with_slop_ledger_tools()
};
if mode != AppMode::Plan {
builder = builder
.with_rlm_tool(self.deepseek_client.clone(), self.session.model.clone())
+8
View File
@@ -303,6 +303,7 @@ pub enum MessageId {
CmdSettingsDescription,
CmdSkillDescription,
CmdSkillsDescription,
CmdSlopDescription,
CmdStashDescription,
CmdStatusDescription,
CmdStatuslineDescription,
@@ -563,6 +564,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdSettingsDescription,
MessageId::CmdSkillDescription,
MessageId::CmdSkillsDescription,
MessageId::CmdSlopDescription,
MessageId::CmdStashDescription,
MessageId::CmdStatusDescription,
MessageId::CmdStatuslineDescription,
@@ -1046,6 +1048,7 @@ fn english(id: MessageId) -> &'static str {
MessageId::CmdSkillsDescription => {
"List local skills (filter by `/skills <prefix>`; --remote browses the curated registry)"
}
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
MessageId::CmdStashDescription => {
"Park or restore a composer draft (Ctrl+S to push, /stash list/pop)"
}
@@ -1457,6 +1460,7 @@ fn vietnamese(id: MessageId) -> Option<&'static str> {
MessageId::CmdSkillsDescription => {
"Liệt kê các kỹ năng cục bộ (lọc bằng `/skills <tiền_tố>`; --remote để duyệt kho lưu trữ được kiểm duyệt)"
}
MessageId::CmdSlopDescription => "Kiểm tra hoặc xuất SlopLedger",
MessageId::CmdStashDescription => {
"Tạm cất hoặc khôi phục bản nháp (Ctrl+S để cất, /stash list/pop để xem/lấy ra)"
}
@@ -1880,6 +1884,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::CmdSkillsDescription => {
"ローカルスキルを一覧表示(`/skills <prefix>` で絞り込み、--remote で精選レジストリを参照)"
}
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
MessageId::CmdStashDescription => {
"コンポーザーの下書きを退避/復元(Ctrl+S で退避、/stash list|pop"
}
@@ -2248,6 +2253,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::CmdSkillsDescription => {
"列出本地技能(用 `/skills <prefix>` 按名称前缀过滤,--remote 浏览精选注册表)"
}
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
MessageId::CmdStashDescription => "暂存或恢复输入草稿(Ctrl+S 暂存,/stash list|pop",
MessageId::CmdStatusDescription => "显示当前运行状态",
MessageId::CmdStatuslineDescription => "配置底栏要显示哪些条目",
@@ -2612,6 +2618,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::CmdSkillsDescription => {
"Listar skills locais (filtre com `/skills <prefixo>`; --remote navega pelo registro curado)"
}
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
MessageId::CmdStashDescription => {
"Estacionar ou restaurar rascunho do compositor (Ctrl+S estaciona, /stash list|pop)"
}
@@ -3030,6 +3037,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
MessageId::CmdSkillsDescription => {
"Listar skills locales (filtra con `/skills <prefijo>`; --remote navega el registro curado)"
}
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
MessageId::CmdStashDescription => {
"Estacionar o restaurar borrador del compositor (Ctrl+S estaciona, /stash list|pop)"
}
+1
View File
@@ -68,6 +68,7 @@ mod settings;
mod shell_dispatcher;
mod skill_state;
mod skills;
mod slop_ledger;
mod snapshot;
mod task_manager;
#[cfg(test)]
File diff suppressed because it is too large Load Diff
+24
View File
@@ -733,6 +733,30 @@ impl ToolRegistryBuilder {
self.with_tool(Arc::new(RememberTool))
}
/// Include the slop ledger tools (#2127) — durable tracking of
/// unresolved architectural residue: append, query, update, export.
/// Registered unconditionally; the ledger JSON file is auto-created
/// on first append.
#[must_use]
pub fn with_slop_ledger_tools(self) -> Self {
use crate::slop_ledger::{
SlopLedgerAppendTool, SlopLedgerExportTool, SlopLedgerQueryTool, SlopLedgerUpdateTool,
};
self.with_tool(Arc::new(SlopLedgerAppendTool))
.with_tool(Arc::new(SlopLedgerQueryTool))
.with_tool(Arc::new(SlopLedgerUpdateTool))
.with_tool(Arc::new(SlopLedgerExportTool))
}
/// Read-only subset of slop ledger tools (#2127) for plan mode:
/// only query and export — no append or update.
#[must_use]
pub fn with_slop_ledger_read_only_tools(self) -> Self {
use crate::slop_ledger::{SlopLedgerExportTool, SlopLedgerQueryTool};
self.with_tool(Arc::new(SlopLedgerQueryTool))
.with_tool(Arc::new(SlopLedgerExportTool))
}
/// Include the `notify` tool — model-callable desktop notification
/// (#1322). Routes through the existing `tui::notifications` OSC 9 /
/// BEL pipeline so the user's `[notifications].method` config is
+19
View File
@@ -1605,6 +1605,25 @@ async fn run_event_loop(
// composer receipt), regardless of notification method
// or platform.
if status == crate::core::events::TurnOutcomeStatus::Completed {
// SlopLedger completion-gate: after every completed
// turn, check whether there are unresolved slop entries
// the agent should address before claiming the task is
// done (#2127). This runs autonomously — no tool call
// required — so the agent can't forget to check.
if let Ok(ledger) = crate::slop_ledger::SlopLedger::load()
&& ledger.has_open_entries()
{
if let Some(gate_msg) = ledger.completion_gate_summary() {
let short =
gate_msg.lines().nth(4).unwrap_or("review before done");
app.push_status_toast(
format!("⚠️ SlopLedger: {short}"),
crate::tui::app::StatusToastLevel::Warning,
Some(12_000),
);
}
}
let tool_count = app.tool_evidence.len();
let mut receipt = "✓ turn completed".to_string();
if tool_count > 0 {