feat(reconciliation): implement deterministic state reconciliation engine and post-commit hook

- Implement ReconciliationEngine in server/src/handlers/reconciliation.rs
- Auto-transition ADRs to implemented, resolve tech debts, and cascade task unblocking
- Ingest git commit events via POST /api/git/commit and scripts/git-reconcile.py post-commit hook
- Add universal pagination, search filtering, and keyboard navigation to dashboard
- Implement non-destructive task TTL expiry sweeper and gate verification
- Implements: ADR-0102, ADR-0103
This commit is contained in:
Riz Ashraf committed 2026-10-07 19:00:44 +01:00
1 parent ec977c24dd
commit 8f924b793a
16 files changed
+2622 -163

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ llvm-cov.zip
.fastembed_cache/ .fastembed_cache/
*.py *.py
!scripts/*.py
temp.json temp.json
*.txt *.txt
summary.md summary.md
+1 -1
View File
@@ -12,7 +12,7 @@ Invoke these dynamically using the `invoke_subagent` tool. Use `send_message` to
* **Role:** The Knowledge Graph Curator * **Role:** The Knowledge Graph Curator
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true` so it can autonomously update the graph without hitting permission boundaries. * **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true` so it can autonomously update the graph without hitting permission boundaries.
* **Trigger:** After completing a coding task, refactor, or bug fix. * **Trigger:** After completing a coding task, refactor, or bug fix.
* **Action:** Send a message detailing the work (with exact git hashes and branches). The Librarian will use MCP tools (`log_code_change`, `log_error_fix`, `create_entities`, etc.) in the background to organize the knowledge graph. * **Action:** Send a message detailing the work (with exact git hashes and branches). The Librarian will use MCP tools (`log_code_change`, `log_error_fix`, `create_entities`, etc.) in the background to organize the knowledge graph. **CRITICAL:** The Librarian MUST inspect recent git commits and commit messages for any referenced ADRs (e.g. `ADR-\d+`), and automatically reconcile their lifecycle status in `decisions` to `implemented` with the exact `git_commit` and `git_branch`. No ADR whose code has been committed should ever remain in `accepted` status.
## 2. PrePushAuditor ## 2. PrePushAuditor
* **Role:** Quality Gate Enforcer * **Role:** Quality Gate Enforcer
+3 -1
View File
@@ -20,7 +20,9 @@ The MCP Memory server is the central brain. You must be PROACTIVE, not reactive,
- **Context Switching**: When switching tasks or branches, use `manage_checkpoint` (action: "create") to freeze state, and use `manage_checkpoint` (action: "restore") to restore state for the task. - **Context Switching**: When switching tasks or branches, use `manage_checkpoint` (action: "create") to freeze state, and use `manage_checkpoint` (action: "restore") to restore state for the task.
- **Error Fixes**: The moment a tricky, undocumented, or environment-specific bug is resolved (e.g., Bitbucket markdown rendering quirks, nuanced framework bugs), IMMEDIATELY call `log_error_fix`. Supply `repo_name`, `error_category`, and `stack_trace` so future searches can perform embedding-based match. - **Error Fixes**: The moment a tricky, undocumented, or environment-specific bug is resolved (e.g., Bitbucket markdown rendering quirks, nuanced framework bugs), IMMEDIATELY call `log_error_fix`. Supply `repo_name`, `error_category`, and `stack_trace` so future searches can perform embedding-based match.
- **Tech Debt**: If you notice an anti-pattern (e.g., nested `if` statements, arrow anti-pattern) but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `tech_debt` (action: "log") with `description`, `file_path`, `line_range`, `workaround`, `effort_estimate`, and `severity`. - **Tech Debt**: If you notice an anti-pattern (e.g., nested `if` statements, arrow anti-pattern) but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `tech_debt` (action: "log") with `description`, `file_path`, `line_range`, `workaround`, `effort_estimate`, and `severity`.
- **Architectural Decisions (ADR)**: When selecting design patterns, crate choices, or system structure, call `decisions` (action: "log") with `author`, `affected_components`, `alternatives_considered`, `decision`, and `consequence`. - **Architectural Decisions (ADR) & Lifecycle Closure**:
- When selecting design patterns, crate choices, or system structure, call `decisions` (action: "log") with `author`, `affected_components`, `alternatives_considered`, `decision`, and `consequence`.
- **MANDATORY Definition of Done**: When code implementing an ADR is committed, you MUST IMMEDIATELY call `decisions` (action: "update", id: "ADR-XXXX", status: "implemented", git_commit: <commit_hash>, git_branch: <branch>). NEVER leave an ADR in `accepted` once the implementing code is committed. The user should NEVER have to manually flag or remind that an implemented ADR is still marked as 'accepted'.
- **Task Management**: When creating tasks, supply `priority` ('low'|'medium'|'high'|'urgent'), `assigned_agent` (e.g. subagent role), `verification_command` (automated test command), and `acceptance_criteria`. - **Task Management**: When creating tasks, supply `priority` ('low'|'medium'|'high'|'urgent'), `assigned_agent` (e.g. subagent role), `verification_command` (automated test command), and `acceptance_criteria`.
- **VCS & SVN Agnosticism**: Supply `vcs_type` ('git'|'svn'|'hg'), `vcs_revision` (git hash or svn revision like 'r12345'), and `upstream_url` to `log_code_change` and workspace tools. - **VCS & SVN Agnosticism**: Supply `vcs_type` ('git'|'svn'|'hg'), `vcs_revision` (git hash or svn revision like 'r12345'), and `upstream_url` to `log_code_change` and workspace tools.
- **Terminal & Shell Context**: Terminal sessions and commands are automatically tracked in the server. Query `/terminal/history` or recent logs when analyzing shell execution context. - **Terminal & Shell Context**: Terminal sessions and commands are automatically tracked in the server. Query `/terminal/history` or recent logs when analyzing shell execution context.
+6
View File
@@ -190,3 +190,9 @@ all-nvim-wsl: build-nvim deploy-nvim
# Complete lifecycle: stop, build, deploy, start, and verify WSL server # Complete lifecycle: stop, build, deploy, start, and verify WSL server
all-server-wsl port="3000": (stop port) build-server deploy-server (start port) (verify port) all-server-wsl port="3000": (stop port) build-server deploy-server (start port) (verify port)
# Install git post-commit hook for automatic ADR/Task/TechDebt state reconciliation
install-git-hooks:
@Write-Host "Installing git post-commit reconciliation hook..." -ForegroundColor Cyan
-& { $hookContent = "#!/bin/sh`npython3 scripts/git-reconcile.py 2>/dev/null || python scripts/git-reconcile.py 2>/dev/null || true`n"; $hookPath = ".git\hooks\post-commit"; [System.IO.File]::WriteAllText($hookPath, $hookContent); Write-Host "✅ Git post-commit hook installed to $hookPath" -ForegroundColor Green }
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Deterministic Git Reconciliation Hook for MCP Memory
Automatically syncs git commit events to mcp-memory to transition ADRs, resolve tech debts,
complete tasks, cascade-unblock dependent tasks, and recalculate milestone statuses.
"""
import os
import sys
import json
import subprocess
import urllib.request
import urllib.error
def run_git(cmd):
try:
res = subprocess.run(["git"] + cmd, capture_output=True, text=True, check=True)
return res.stdout.strip()
except Exception:
return ""
def main():
port = os.environ.get("MCP_PORT", "3000")
# 1. Quick check if server is running
ping_url = f"http://127.0.0.1:{port}/ping"
try:
with urllib.request.urlopen(ping_url, timeout=1.0) as resp:
if resp.status != 200:
sys.exit(0)
except Exception:
# Server not running, silently exit
sys.exit(0)
# 2. Extract git metadata
commit = run_git(["rev-parse", "HEAD"])
branch = run_git(["branch", "--show-current"])
author = run_git(["log", "-1", "--pretty=format:%an"])
message = run_git(["log", "-1", "--pretty=format:%B"])
# Get changed files in this commit
files_raw = run_git(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"])
files = [f.strip() for f in files_raw.splitlines() if f.strip()]
if not commit or not message:
sys.exit(0)
payload = {
"commit": commit,
"branch": branch,
"author": author,
"message": message,
"files": files,
}
# 3. Post to reconciliation endpoint
reconcile_url = f"http://127.0.0.1:{port}/api/git/commit"
req_data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
reconcile_url,
data=req_data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=3.0) as resp:
if resp.status == 200:
body = json.loads(resp.read().decode("utf-8"))
res = body.get("reconciliation", {})
adrs = res.get("implemented_adrs", [])
debts = res.get("resolved_tech_debts", [])
tasks = res.get("completed_tasks", [])
unblocked = res.get("unblocked_tasks", [])
milestones = res.get("updated_milestones", [])
if any([adrs, debts, tasks, unblocked, milestones]):
print("\033[36m[MCP Memory Auto-Reconciled]\033[0m")
if adrs:
print(f" \033[32m✔ Implemented ADRs:\033[0m {', '.join(adrs)}")
if debts:
print(f" \033[32m✔ Resolved Tech Debts:\033[0m {', '.join(debts)}")
if tasks:
print(f" \033[32m✔ Completed Tasks:\033[0m {', '.join(tasks)}")
if unblocked:
print(f" \033[33m⚡ Unblocked Dependent Tasks:\033[0m {', '.join(unblocked)}")
if milestones:
print(f" \033[35m🎯 Updated Milestones:\033[0m {', '.join(milestones)}")
except Exception:
# Never break commit flow on failure
pass
if __name__ == "__main__":
main()
+5
View File
@@ -36,6 +36,10 @@ pub async fn gate_verify_handler(
State(app_state): State<Arc<AppState>>, State(app_state): State<Arc<AppState>>,
Query(q): Query<GateVerifyReq>, Query(q): Query<GateVerifyReq>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut found = None; let mut found = None;
let mut to_remove = None; let mut to_remove = None;
app_state.handler.state.env.gates.modify(|gates| { app_state.handler.state.env.gates.modify(|gates| {
@@ -44,6 +48,7 @@ pub async fn gate_verify_handler(
&& g.target == q.target && g.target == q.target
&& g.namespace == q.namespace && g.namespace == q.namespace
&& g.params == q.params && g.params == q.params
&& g.expires_at.is_none_or(|exp| exp > now)
}) { }) {
found = Some(gates[idx].clone()); found = Some(gates[idx].clone());
if q.consume { if q.consume {
+78 -1
View File
@@ -188,11 +188,52 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
for t in tasks.iter_mut() { for t in tasks.iter_mut() {
if t.id == id { if t.id == id {
t.status = "completed".to_string(); t.status = "completed".to_string();
t.updated_at = crate::handlers::utils::now_secs();
break; break;
} }
} }
}); });
axum::Json(serde_json::json!({"status": "success"})) let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&state_clone);
let milestones = crate::handlers::reconciliation::recalculate_milestones(&state_clone);
axum::Json(serde_json::json!({
"status": "success",
"unblocked_tasks": unblocked,
"updated_milestones": milestones
}))
}
}),
)
.route(
"/api/git/commit",
post({
let state_clone = app_state.handler.state.clone();
move |axum::Json(payload): axum::Json<crate::handlers::reconciliation::GitCommitPayload>| async move {
let result = crate::handlers::reconciliation::reconcile_commit_or_code_change(
&state_clone,
&payload.message,
payload.files.first().map(|s| s.as_str()),
Some(&payload.commit),
payload.branch.as_deref(),
)
.await;
axum::Json(result)
}
}),
)
.route(
"/api/reconcile/commit",
post({
let state_clone = app_state.handler.state.clone();
move |axum::Json(payload): axum::Json<crate::handlers::reconciliation::GitCommitPayload>| async move {
let result = crate::handlers::reconciliation::reconcile_commit_or_code_change(
&state_clone,
&payload.message,
payload.files.first().map(|s| s.as_str()),
Some(&payload.commit),
payload.branch.as_deref(),
)
.await;
axum::Json(result)
} }
}), }),
) )
@@ -909,4 +950,40 @@ mod tests {
assert_eq!(summaries.len(), 1); assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].summary, "Session 2 summary"); assert_eq!(summaries[0].summary, "Session 2 summary");
} }
#[tokio::test]
async fn test_git_commit_endpoint() {
let (app, app_state, _dir) = setup_app().await;
app_state.handler.state.code.adrs.modify(|adrs| {
adrs.push(crate::models::Adr {
id: "ADR-0102".to_string(),
title: "Neovim Dynamic Projection".to_string(),
status: "accepted".to_string(),
..Default::default()
});
});
let payload = serde_json::json!({
"commit": "git-hash-999",
"branch": "master",
"message": "feat: implement ADR-0102 and finish architecture",
"files": ["nvim-core/src/lib.rs"]
});
let request = Request::builder()
.method("POST")
.uri("/api/git/commit")
.header("Content-Type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let adr = app_state.handler.state.code.adrs.read_with(|adrs| adrs[0].clone());
assert_eq!(adr.status, "implemented");
assert_eq!(adr.git_commit.as_deref(), Some("git-hash-999"));
assert!(adr.resolved_at.is_some());
}
} }
+191 -1
View File
@@ -21,6 +21,7 @@
--btn-border: #dcdde1; --btn-border: #dcdde1;
--btn-hover: #f8f9fa; --btn-hover: #f8f9fa;
--search-hover: #f0f2f5; --search-hover: #f0f2f5;
--input-bg: #ffffff;
--input-text: #2c3e50; --input-text: #2c3e50;
--inspector-bg: rgba(255,255,255,0.98); --inspector-bg: rgba(255,255,255,0.98);
--success-color: #27ae60; --success-color: #27ae60;
@@ -45,6 +46,7 @@
--btn-border: #3d4a57; --btn-border: #3d4a57;
--btn-hover: #3d4a57; --btn-hover: #3d4a57;
--search-hover: #2c3643; --search-hover: #2c3643;
--input-bg: #1e242b;
--input-text: #d1d8e0; --input-text: #d1d8e0;
--inspector-bg: rgba(30, 36, 43, 0.98); --inspector-bg: rgba(30, 36, 43, 0.98);
--success-color: #2ecc71; --success-color: #2ecc71;
@@ -464,6 +466,182 @@
color: #7f8c8d; color: #7f8c8d;
margin-right: 8px; margin-right: 8px;
} }
/* --- Enterprise UX/HCI Styles --- */
.enterprise-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 12px;
}
.enterprise-search-wrap {
position: relative;
flex: 1;
min-width: 240px;
display: flex;
align-items: center;
}
.enterprise-search-input {
width: 100%;
padding: 8px 30px 8px 32px !important;
border-radius: 6px !important;
border: 1px solid var(--border-color) !important;
background: var(--input-bg) !important;
color: var(--input-text) !important;
font-size: 0.88em !important;
margin-bottom: 0 !important;
box-sizing: border-box !important;
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
}
.enterprise-search-input:focus {
border-color: #3498db !important;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2) !important;
}
.enterprise-search-icon {
position: absolute;
left: 10px;
color: var(--text-secondary);
pointer-events: none;
font-size: 0.85em;
}
.enterprise-clear-btn {
position: absolute;
right: 8px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 1.1em;
line-height: 1;
padding: 2px 4px;
border-radius: 3px;
display: none;
}
.enterprise-clear-btn:hover {
color: var(--error-color);
}
.enterprise-filters-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.enterprise-select {
padding: 7px 10px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--input-bg);
color: var(--input-text);
font-size: 0.85em;
cursor: pointer;
outline: none;
transition: border-color 0.15s;
}
.enterprise-select:focus {
border-color: #3498db;
}
.enterprise-btn {
padding: 7px 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--btn-bg);
color: var(--text-primary);
font-size: 0.85em;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.15s ease;
font-weight: 500;
}
.enterprise-btn:hover {
background: var(--btn-hover);
border-color: #3498db;
}
.enterprise-btn.active {
background: #3498db;
color: #fff;
border-color: #2980b9;
}
.enterprise-meta-bar {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.82em;
color: var(--text-secondary);
margin-bottom: 10px;
padding: 0 4px;
}
.enterprise-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--border-color);
}
.enterprise-pagination button {
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid var(--border-color);
background: var(--btn-bg);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
font-size: 0.85em;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.enterprise-pagination button:hover:not(:disabled) {
background: var(--btn-hover);
border-color: #3498db;
}
.enterprise-pagination button.active {
background: #3498db;
color: #fff;
border-color: #2980b9;
font-weight: bold;
}
.enterprise-pagination button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.enterprise-empty-state {
text-align: center;
padding: 36px 20px;
color: var(--text-secondary);
font-size: 0.95em;
border: 1px dashed var(--border-color);
border-radius: 8px;
background: var(--card-bg);
margin: 10px 0;
}
.enterprise-kbd {
font-size: 0.72em;
padding: 2px 5px;
border-radius: 4px;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-family: monospace;
vertical-align: middle;
}
.enterprise-cards-list {
display: flex;
flex-direction: column;
gap: 12px;
}
</style> </style>
</head> </head>
<body> <body>
@@ -541,7 +719,19 @@
<div id="task-tab" class="tab-content"> <div id="task-tab" class="tab-content">
<div class="panel kanban-panel" style="flex:1; display:flex; flex-direction:column;"> <div class="panel kanban-panel" style="flex:1; display:flex; flex-direction:column;">
<h3 style="margin-top:0;">Task Network (HTN)</h3> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; flex-wrap:wrap; gap:10px;">
<h3 style="margin:0;">Task Network (HTN)</h3>
<div style="display:flex; gap:8px; align-items:center;">
<input type="text" id="task-filter-input" placeholder="Filter tasks..." oninput="handleTaskFilter(this.value)" style="padding:6px 10px; font-size:0.85em; border-radius:6px; border:1px solid var(--border-color); background:var(--input-bg); color:var(--input-text); width:200px; margin-bottom:0;">
<select id="task-status-filter" onchange="handleTaskStatusFilter(this.value)" class="enterprise-select">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="blocked">Blocked</option>
</select>
</div>
</div>
<div class="kanban-items" id="task-tree-container" style="flex:1; border: 1px solid var(--border-color); padding:15px; border-radius:6px; background:var(--canvas-bg);"></div> <div class="kanban-items" id="task-tree-container" style="flex:1; border: 1px solid var(--border-color); padding:15px; border-radius:6px; background:var(--canvas-bg);"></div>
</div> </div>
</div> </div>
+665 -47
View File
@@ -157,10 +157,10 @@ function toggleFilter(type) {
updateGraphData(); updateGraphData();
} }
function renderFilters() { function renderFilters() {
const container2 = document.getElementById("graph-filters"); const container = document.getElementById("graph-filters");
if (!container2) if (!container)
return; return;
container2.innerHTML = Array.from(allTypes).sort().map((type) => { container.innerHTML = Array.from(allTypes).sort().map((type) => {
const isActive = activeFilters.has(type); const isActive = activeFilters.has(type);
const color = getColorForType(type).bg || getColorForType(type).background; const color = getColorForType(type).bg || getColorForType(type).background;
return `<button class="filter-chip ${isActive ? "active" : ""}" onclick="toggleFilter('${type}')"> return `<button class="filter-chip ${isActive ? "active" : ""}" onclick="toggleFilter('${type}')">
@@ -227,8 +227,8 @@ async function loadGraph() {
rawRelations = data.relations || []; rawRelations = data.relations || [];
updateGraphData(); updateGraphData();
if (!network) { if (!network) {
const container2 = document.getElementById("network-container"); const container = document.getElementById("network-container");
if (!container2) if (!container)
return; return;
const options = { const options = {
nodes: { shape: "dot", size: 16, font: { size: 12 } }, nodes: { shape: "dot", size: 16, font: { size: 12 } },
@@ -257,7 +257,7 @@ async function loadGraph() {
}, },
interaction: { hover: true, tooltipDelay: 100, zoomView: false } interaction: { hover: true, tooltipDelay: 100, zoomView: false }
}; };
network = new vis.Network(container2, { nodes: nodesData, edges: edgesData }, options); network = new vis.Network(container, { nodes: nodesData, edges: edgesData }, options);
network.once("stabilizationIterationsDone", function() { network.once("stabilizationIterationsDone", function() {
network.setOptions({ physics: { enabled: false } }); network.setOptions({ physics: { enabled: false } });
}); });
@@ -267,7 +267,7 @@ async function loadGraph() {
network.on("dragEnd", function() { network.on("dragEnd", function() {
network.setOptions({ physics: { enabled: false } }); network.setOptions({ physics: { enabled: false } });
}); });
container2.addEventListener("wheel", function(event) { container.addEventListener("wheel", function(event) {
event.preventDefault(); event.preventDefault();
const direction = event.deltaY > 0 ? -0.15 : 0.15; const direction = event.deltaY > 0 ? -0.15 : 0.15;
zoomGraph(direction); zoomGraph(direction);
@@ -283,9 +283,9 @@ async function loadGraph() {
} }
} catch (err) { } catch (err) {
console.error("Failed to load graph", err); console.error("Failed to load graph", err);
const container2 = document.getElementById("network-container"); const container = document.getElementById("network-container");
if (container2) { if (container) {
container2.innerHTML = '<div style="color:#e74c3c;padding:20px;font-weight:bold;">Error loading graph: ' + err.message + "</div>"; container.innerHTML = '<div style="color:#e74c3c;padding:20px;font-weight:bold;">Error loading graph: ' + err.message + "</div>";
} }
} }
} }
@@ -414,19 +414,54 @@ function buildTaskTreeHTML(tasks, parentId, depth = 0) {
}); });
return html; return html;
} }
var cachedTasks = [];
var taskFilterQuery = "";
var taskStatusFilter = "";
function handleTaskFilter(val) {
taskFilterQuery = val.toLowerCase().trim();
applyTaskFilters();
}
function handleTaskStatusFilter(val) {
taskStatusFilter = val.toLowerCase().trim();
applyTaskFilters();
}
function applyTaskFilters() {
const taskContainer = document.getElementById("task-tree-container");
if (!taskContainer)
return;
let filtered = cachedTasks;
if (taskFilterQuery) {
filtered = filtered.filter((t) => t.title && t.title.toLowerCase().includes(taskFilterQuery) || t.description && t.description.toLowerCase().includes(taskFilterQuery) || t.id && t.id.toLowerCase().includes(taskFilterQuery) || t.assigned_agent && t.assigned_agent.toLowerCase().includes(taskFilterQuery));
}
if (taskStatusFilter) {
if (taskStatusFilter === "blocked") {
filtered = filtered.filter((t) => {
const deps = t.dependencies || [];
return deps.some((dId) => {
const dt = cachedTasks.find((x) => x.id === dId);
return dt && dt.status !== "completed" && dt.status !== "done";
});
});
} else {
filtered = filtered.filter((t) => (t.status || "pending").toLowerCase() === taskStatusFilter);
}
}
const rootHtml = buildTaskTreeHTML(filtered, null, 0);
if (!rootHtml) {
if (cachedTasks.length === 0) {
taskContainer.innerHTML = '<div class="enterprise-empty-state">No active tasks.</div>';
} else {
taskContainer.innerHTML = '<div class="enterprise-empty-state">No tasks matching current filter.</div>';
}
} else {
taskContainer.innerHTML = rootHtml;
}
}
async function loadTasks() { async function loadTasks() {
try { try {
const res = await fetch("/api/tasks"); const res = await fetch("/api/tasks");
const tasks = await res.json(); cachedTasks = await res.json();
const taskContainer = document.getElementById("task-tree-container"); applyTaskFilters();
if (!taskContainer)
return;
const rootHtml = buildTaskTreeHTML(tasks, null, 0);
if (!rootHtml) {
taskContainer.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No active tasks.</div>';
} else {
taskContainer.innerHTML = rootHtml;
}
} catch (err) { } catch (err) {
console.error("Failed to load tasks", err); console.error("Failed to load tasks", err);
} }
@@ -462,14 +497,14 @@ async function handleSearch(e) {
signal: activeSearchAbortController.signal signal: activeSearchAbortController.signal
}); });
const data = await res.json(); const data = await res.json();
const container2 = document.getElementById("search-results"); const container = document.getElementById("search-results");
if (!container2) if (!container)
return; return;
if (!data.results || data.results.length === 0) { if (!data.results || data.results.length === 0) {
container2.innerHTML = '<div style="padding:15px;color:var(--text-secondary);">No results found.</div>'; container.innerHTML = '<div style="padding:15px;color:var(--text-secondary);">No results found.</div>';
return; return;
} }
container2.innerHTML = data.results.map((r) => ` container.innerHTML = data.results.map((r) => `
<div class="search-result-item" onclick="switchTab('graph-tab'); if(rawEntities['${r.id}']){ showInspector('${r.id}'); if(network){ network.selectNodes(['${r.id}']); network.focus('${r.id}', {scale:1.2, animation:true}); } }" style="cursor:pointer;"> <div class="search-result-item" onclick="switchTab('graph-tab'); if(rawEntities['${r.id}']){ showInspector('${r.id}'); if(network){ network.selectNodes(['${r.id}']); network.focus('${r.id}', {scale:1.2, animation:true}); } }" style="cursor:pointer;">
<div style="display:flex; justify-content:space-between; align-items:flex-start;"> <div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div> <div>
@@ -775,39 +810,341 @@ function setupWS() {
setupSSE(); setupSSE();
}; };
} }
async function loadGenericList(endpoint, containerId, formatter) { var listControllers = new Map;
function applyListFiltering(containerId) {
const state = listControllers.get(containerId);
if (!state)
return;
let result = state.items.slice();
const q = (state.searchQuery || "").trim().toLowerCase();
if (q) {
result = result.filter((item) => {
if (state.options.searchFields) {
const fields = state.options.searchFields(item);
return fields.some((f) => f && String(f).toLowerCase().includes(q));
}
return JSON.stringify(item).toLowerCase().includes(q);
});
}
if (state.options.filters) {
for (const filterDef of state.options.filters) {
const activeVal = state.activeFilters[filterDef.id];
if (activeVal !== undefined && activeVal !== "") {
result = result.filter((item) => filterDef.predicate(item, activeVal));
}
}
}
const factor = state.sortOrder === "desc" ? -1 : 1;
const extractor = state.options.sortExtractor || ((item) => {
if (state.options.timestampExtractor) {
const ts = state.options.timestampExtractor(item);
if (ts != null)
return ts;
}
return item.timestamp || item.created_at || item.id || 0;
});
result.sort((a, b) => {
const valA = extractor(a);
const valB = extractor(b);
if (typeof valA === "number" && typeof valB === "number") {
return (valA - valB) * factor;
}
return String(valA).localeCompare(String(valB)) * factor;
});
state.filteredItems = result;
}
function renderListCardsAndPagination(containerId) {
const state = listControllers.get(containerId);
if (!state)
return;
const totalItems = state.filteredItems.length;
const pageSize = state.pageSize;
const totalPages = pageSize <= 0 ? 1 : Math.max(1, Math.ceil(totalItems / pageSize));
if (state.currentPage > totalPages) {
state.currentPage = totalPages;
}
if (state.currentPage < 1) {
state.currentPage = 1;
}
const startIndex = totalItems === 0 ? 0 : (state.currentPage - 1) * (pageSize > 0 ? pageSize : totalItems) + 1;
const endIndex = pageSize <= 0 ? totalItems : Math.min(state.currentPage * pageSize, totalItems);
const metaEl = document.getElementById(`${containerId}-meta`);
if (metaEl) {
const itemNoun = state.options.itemNoun || "items";
let summaryText = "";
if (totalItems === 0) {
summaryText = `0 ${itemNoun}`;
} else {
summaryText = `Showing <strong>${startIndex}–${endIndex}</strong> of <strong>${totalItems}</strong> ${itemNoun}`;
}
if (totalItems !== state.items.length) {
summaryText += ` <span style="color:var(--text-secondary);">(filtered from ${state.items.length} total)</span>`;
}
metaEl.innerHTML = `<div>${summaryText}</div><div>Page <strong>${state.currentPage}</strong> of <strong>${totalPages}</strong></div>`;
}
const cardsEl = document.getElementById(`${containerId}-cards`);
if (cardsEl) {
if (totalItems === 0) {
const itemNoun = state.options.itemNoun || "items";
if (state.items.length === 0) {
cardsEl.innerHTML = `<div class="enterprise-empty-state">No ${itemNoun} recorded.</div>`;
} else {
cardsEl.innerHTML = `
<div class="enterprise-empty-state">
<div>\uD83D\uDD0D No matching ${itemNoun} found for current filter.</div>
<button class="enterprise-btn" style="margin-top:12px;" onclick="onListResetFilters('${containerId}')">Reset Filters</button>
</div>`;
}
} else {
const pageSlice = pageSize <= 0 ? state.filteredItems : state.filteredItems.slice((state.currentPage - 1) * pageSize, state.currentPage * pageSize);
cardsEl.innerHTML = pageSlice.map((item) => {
const ts = state.options.timestampExtractor ? state.options.timestampExtractor(item) : item.timestamp || item.created_at || null;
const dateStr = ts ? new Date(ts * 1000).toLocaleString() : "";
const borderCol = state.options.cardBorderColor || "#9b59b6";
return `
<div class="task-card" style="border-left-color: ${borderCol};">
${dateStr ? `<div style="font-size:0.75em; color:var(--text-secondary); float:right;">${dateStr}</div>` : ""}
${state.formatter(item)}
</div>`;
}).join("");
}
}
const paginationEl = document.getElementById(`${containerId}-pagination`);
if (paginationEl) {
if (totalPages <= 1) {
paginationEl.style.display = "none";
paginationEl.innerHTML = "";
} else {
paginationEl.style.display = "flex";
let pagHtml = "";
pagHtml += `<button onclick="onListPageChange('${containerId}', 1)" ${state.currentPage === 1 ? "disabled" : ""} title="First Page">⏮</button>`;
pagHtml += `<button onclick="onListPageChange('${containerId}', ${state.currentPage - 1})" ${state.currentPage === 1 ? "disabled" : ""} title="Previous Page">◀</button>`;
const cur = state.currentPage;
const startP = Math.max(1, cur - 2);
const endP = Math.min(totalPages, cur + 2);
if (startP > 1) {
pagHtml += `<button onclick="onListPageChange('${containerId}', 1)">1</button>`;
if (startP > 2)
pagHtml += `<span style="padding:0 4px; color:var(--text-secondary);">…</span>`;
}
for (let p = startP;p <= endP; p++) {
pagHtml += `<button class="${p === cur ? "active" : ""}" onclick="onListPageChange('${containerId}', ${p})">${p}</button>`;
}
if (endP < totalPages) {
if (endP < totalPages - 1)
pagHtml += `<span style="padding:0 4px; color:var(--text-secondary);">…</span>`;
pagHtml += `<button onclick="onListPageChange('${containerId}', ${totalPages})">${totalPages}</button>`;
}
pagHtml += `<button onclick="onListPageChange('${containerId}', ${state.currentPage + 1})" ${state.currentPage === totalPages ? "disabled" : ""} title="Next Page">▶</button>`;
pagHtml += `<button onclick="onListPageChange('${containerId}', ${totalPages})" ${state.currentPage === totalPages ? "disabled" : ""} title="Last Page">⏭</button>`;
paginationEl.innerHTML = pagHtml;
}
}
const clearBtn = document.getElementById(`${containerId}-clear`);
if (clearBtn) {
clearBtn.style.display = state.searchQuery ? "block" : "none";
}
const sortBtn = document.getElementById(`${containerId}-sort-btn`);
if (sortBtn) {
sortBtn.innerHTML = state.sortOrder === "desc" ? "⬇️ Newest First" : "⬆️ Oldest First";
}
}
function renderListStructure(containerId) {
const state = listControllers.get(containerId);
if (!state)
return;
const container = document.getElementById(containerId);
if (!container)
return;
const itemNoun = state.options.itemNoun || "items";
let filtersHtml = "";
if (state.options.filters && state.options.filters.length > 0) {
for (const f of state.options.filters) {
const activeVal = state.activeFilters[f.id] || "";
filtersHtml += `
<select class="enterprise-select" id="${containerId}-filter-${f.id}" onchange="onListFilterChange('${containerId}', '${f.id}', this.value)" title="Filter by ${f.label}">
${f.options.map((opt) => `<option value="${escapeHtml(opt.value)}" ${opt.value === activeVal ? "selected" : ""}>${escapeHtml(opt.label)}</option>`).join("")}
</select>`;
}
}
const curPageSize = state.pageSize;
const pageSizeOptions = [
{ label: "10 / page", value: "10" },
{ label: "25 / page", value: "25" },
{ label: "50 / page", value: "50" },
{ label: "100 / page", value: "100" },
{ label: "All items", value: "-1" }
];
const pageSizeHtml = `
<select class="enterprise-select" id="${containerId}-pagesize" onchange="onListPageSizeChange('${containerId}', this.value)" title="Items per page">
${pageSizeOptions.map((opt) => `<option value="${opt.value}" ${parseInt(opt.value, 10) === curPageSize ? "selected" : ""}>${opt.label}</option>`).join("")}
</select>`;
container.innerHTML = `
<div class="enterprise-toolbar" id="${containerId}-toolbar">
<div class="enterprise-search-wrap">
<span class="enterprise-search-icon">\uD83D\uDD0D</span>
<input
type="text"
class="enterprise-search-input"
id="${containerId}-search"
placeholder="Filter ${itemNoun} (/)..."
value="${escapeHtml(state.searchQuery)}"
oninput="onListSearchInput('${containerId}', this.value)"
/>
<button
class="enterprise-clear-btn"
id="${containerId}-clear"
onclick="onListClearSearch('${containerId}')"
title="Clear search"
style="display:${state.searchQuery ? "block" : "none"};"
>✕</button>
</div>
<div class="enterprise-filters-group">
${filtersHtml}
<button
class="enterprise-btn"
id="${containerId}-sort-btn"
onclick="onListToggleSort('${containerId}')"
title="Toggle sort direction"
>
${state.sortOrder === "desc" ? "⬇️ Newest First" : "⬆️ Oldest First"}
</button>
${pageSizeHtml}
<button
class="enterprise-btn"
onclick="onListResetFilters('${containerId}')"
title="Reset all filters and sorting"
>
\uD83D\uDD04 Reset
</button>
</div>
</div>
<div class="enterprise-meta-bar" id="${containerId}-meta"></div>
<div class="enterprise-cards-list" id="${containerId}-cards"></div>
<div class="enterprise-pagination" id="${containerId}-pagination"></div>
`;
renderListCardsAndPagination(containerId);
}
function onListSearchInput(containerId, value) {
const state = listControllers.get(containerId);
if (!state)
return;
if (state.debounceTimer)
clearTimeout(state.debounceTimer);
state.searchQuery = value;
state.debounceTimer = setTimeout(() => {
state.currentPage = 1;
applyListFiltering(containerId);
renderListCardsAndPagination(containerId);
}, 150);
}
function onListClearSearch(containerId) {
const state = listControllers.get(containerId);
if (!state)
return;
state.searchQuery = "";
const inputEl = document.getElementById(`${containerId}-search`);
if (inputEl) {
inputEl.value = "";
inputEl.focus();
}
state.currentPage = 1;
applyListFiltering(containerId);
renderListCardsAndPagination(containerId);
}
function onListFilterChange(containerId, filterId, value) {
const state = listControllers.get(containerId);
if (!state)
return;
state.activeFilters[filterId] = value;
state.currentPage = 1;
applyListFiltering(containerId);
renderListCardsAndPagination(containerId);
}
function onListToggleSort(containerId) {
const state = listControllers.get(containerId);
if (!state)
return;
state.sortOrder = state.sortOrder === "desc" ? "asc" : "desc";
applyListFiltering(containerId);
renderListCardsAndPagination(containerId);
}
function onListPageSizeChange(containerId, value) {
const state = listControllers.get(containerId);
if (!state)
return;
state.pageSize = parseInt(value, 10);
state.currentPage = 1;
renderListCardsAndPagination(containerId);
}
function onListPageChange(containerId, page) {
const state = listControllers.get(containerId);
if (!state)
return;
state.currentPage = page;
renderListCardsAndPagination(containerId);
const container = document.getElementById(containerId);
if (container) {
container.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
function onListResetFilters(containerId) {
const state = listControllers.get(containerId);
if (!state)
return;
state.searchQuery = "";
state.activeFilters = {};
state.sortOrder = state.options.defaultSort || "desc";
state.pageSize = state.options.pageSize || 25;
state.currentPage = 1;
applyListFiltering(containerId);
renderListStructure(containerId);
}
async function loadGenericList(endpoint, containerId, formatter, options) {
const container = document.getElementById(containerId);
if (!container)
return;
const resolvedOptions = options || {};
const existingState = listControllers.get(containerId);
const state = existingState || {
endpoint,
containerId,
formatter,
options: resolvedOptions,
items: [],
filteredItems: [],
searchQuery: "",
activeFilters: {},
sortOrder: resolvedOptions.defaultSort || "desc",
currentPage: 1,
pageSize: resolvedOptions.pageSize || 25,
debounceTimer: null
};
state.options = resolvedOptions;
state.formatter = formatter;
listControllers.set(containerId, state);
try { try {
const res = await fetch(endpoint); const res = await fetch(endpoint);
if (!res.ok) { if (!res.ok) {
if (container2) { container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${escapeHtml(endpoint)} (${res.status} ${escapeHtml(res.statusText)})</div>`;
container2.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${endpoint} (${res.status} ${res.statusText})</div>`;
}
return; return;
} }
const data = await res.json(); const data = await res.json();
const container2 = document.getElementById(containerId); state.items = Array.isArray(data) ? data : [];
if (!container2) applyListFiltering(containerId);
return; const toolbarExists = document.getElementById(`${containerId}-toolbar`);
if (!data || data.length === 0) { if (!toolbarExists) {
container2.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No items recorded.</div>'; renderListStructure(containerId);
return; } else {
renderListCardsAndPagination(containerId);
} }
container2.innerHTML = data.map((item) => {
const date = item.timestamp ? new Date(item.timestamp * 1000).toLocaleString() : "";
return `<div class="task-card" style="border-left-color: #9b59b6;">
<div style="font-size:0.75em; color:var(--text-secondary); float:right;">${date}</div>
${formatter(item)}
</div>`;
}).join("");
} catch (err) { } catch (err) {
console.error(`Failed to load ${endpoint}`, err); console.error(`Failed to load ${endpoint}`, err);
if (container) { container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${escapeHtml(endpoint)} (${escapeHtml(String(err))})</div>`;
container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${endpoint} (${escapeHtml(String(err))})</div>`;
}
} }
} }
function loadLedger() { function loadLedger() {
loadGenericList("/api/ledger?limit=100", "ledger-container", (item) => { loadGenericList("/api/ledger?limit=500", "ledger-container", (item) => {
const vcs = (item.vcs_type || (item.revision && item.revision.startsWith("r") ? "svn" : "git")).toUpperCase(); const vcs = (item.vcs_type || (item.revision && item.revision.startsWith("r") ? "svn" : "git")).toUpperCase();
const vcsColor = vcs === "SVN" ? "#e67e22" : "#3498db"; const vcsColor = vcs === "SVN" ? "#e67e22" : "#3498db";
const rev = item.revision || item.git_commit; const rev = item.revision || item.git_commit;
@@ -848,6 +1185,48 @@ function loadLedger() {
${authorBadge} ${authorBadge}
</div> </div>
`; `;
}, {
itemNoun: "Ledger Entries",
cardBorderColor: "#3498db",
defaultSort: "desc",
timestampExtractor: (item) => item.timestamp,
searchFields: (item) => [
item.file_path,
item.description,
item.author,
item.repo_name,
item.branch,
item.git_commit,
item.revision,
Array.isArray(item.symbols) ? item.symbols.join(" ") : ""
],
filters: [
{
id: "vcs",
label: "VCS",
options: [
{ label: "All VCS", value: "" },
{ label: "Git", value: "git" },
{ label: "SVN", value: "svn" }
],
predicate: (item, val) => {
const vcs = (item.vcs_type || (item.revision && item.revision.startsWith("r") ? "svn" : "git")).toLowerCase();
return vcs === val.toLowerCase();
}
},
{
id: "kind",
label: "Change Kind",
options: [
{ label: "All Types", value: "" },
{ label: "+ Added", value: "added" },
{ label: "Modified", value: "modified" },
{ label: "- Deleted", value: "deleted" },
{ label: "→ Renamed", value: "renamed" }
],
predicate: (item, val) => (item.change_kind || "modified").toLowerCase() === val.toLowerCase()
}
]
}); });
} }
function loadTerminal() { function loadTerminal() {
@@ -880,6 +1259,38 @@ function loadTerminal() {
</details> </details>
` : ""} ` : ""}
`; `;
}, {
itemNoun: "Commands",
cardBorderColor: "#1abc9c",
defaultSort: "desc",
timestampExtractor: (item) => item.timestamp,
searchFields: (item) => [
item.command,
item.cwd,
item.repo_name,
item.invoker,
item.shell,
item.stdout_summary,
item.error_output
],
filters: [
{
id: "status",
label: "Exit Code",
options: [
{ label: "All Results", value: "" },
{ label: "Success (0)", value: "0" },
{ label: "Failed (!= 0)", value: "err" }
],
predicate: (item, val) => {
if (val === "0")
return item.exit_code === 0;
if (val === "err")
return item.exit_code !== 0;
return true;
}
}
]
}); });
} }
function loadTechDebt() { function loadTechDebt() {
@@ -923,6 +1334,50 @@ function loadTechDebt() {
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">\uD83C\uDFF7️ ${escapeHtml(item.namespace || "global")}</span> <span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">\uD83C\uDFF7️ ${escapeHtml(item.namespace || "global")}</span>
</div> </div>
`; `;
}, {
itemNoun: "Tech Debts",
cardBorderColor: "#e67e22",
defaultSort: "desc",
timestampExtractor: (item) => item.timestamp,
searchFields: (item) => [
item.id,
item.description,
item.ideal_solution,
item.workaround,
item.file_path,
item.repo_name,
item.severity
],
filters: [
{
id: "status",
label: "Status",
options: [
{ label: "All Statuses", value: "" },
{ label: "Open", value: "open" },
{ label: "Resolved", value: "resolved" }
],
predicate: (item, val) => {
if (val === "resolved")
return !!item.is_resolved;
if (val === "open")
return !item.is_resolved;
return true;
}
},
{
id: "severity",
label: "Severity",
options: [
{ label: "All Severities", value: "" },
{ label: "Critical", value: "critical" },
{ label: "High", value: "high" },
{ label: "Medium", value: "medium" },
{ label: "Low", value: "low" }
],
predicate: (item, val) => String(item.severity || "").toLowerCase() === val.toLowerCase()
}
]
}); });
loadGenericList("/api/error_fixes", "errorfixes-container", (item) => ` loadGenericList("/api/error_fixes", "errorfixes-container", (item) => `
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px;"> <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px;">
@@ -945,7 +1400,34 @@ function loadTechDebt() {
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-family:monospace; border:1px solid var(--border-color);">Commit: ${item.git_commit ? escapeHtml(item.git_commit.substring(0, 8)) : "None"}</span> <span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-family:monospace; border:1px solid var(--border-color);">Commit: ${item.git_commit ? escapeHtml(item.git_commit.substring(0, 8)) : "None"}</span>
${item.git_branch ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83C\uDF3F ${escapeHtml(item.git_branch)}</span>` : ""} ${item.git_branch ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83C\uDF3F ${escapeHtml(item.git_branch)}</span>` : ""}
</div> </div>
`); `, {
itemNoun: "Error Fixes",
cardBorderColor: "#2ecc71",
defaultSort: "desc",
timestampExtractor: (item) => item.timestamp,
searchFields: (item) => [
item.signature,
item.solution,
item.toolchain,
item.error_category,
item.repo_name,
item.stack_trace
],
filters: [
{
id: "category",
label: "Category",
options: [
{ label: "All Categories", value: "" },
{ label: "Compilation", value: "compilation" },
{ label: "Runtime", value: "runtime" },
{ label: "Test", value: "test" },
{ label: "Configuration", value: "configuration" }
],
predicate: (item, val) => String(item.error_category || "").toLowerCase() === val.toLowerCase()
}
]
});
} }
function loadADRs() { function loadADRs() {
loadGenericList("/api/adrs", "adrs-container", (item) => { loadGenericList("/api/adrs", "adrs-container", (item) => {
@@ -980,6 +1462,41 @@ function loadADRs() {
<div style="font-size:0.9em; margin-bottom:8px;"><em>Consequence:</em> ${escapeHtml(item.consequence || "")}</div> <div style="font-size:0.9em; margin-bottom:8px;"><em>Consequence:</em> ${escapeHtml(item.consequence || "")}</div>
${item.supersedes ? `<div style="font-size:0.9em; font-weight:bold; color:var(--warning-color);">Supersedes: ${escapeHtml(item.supersedes)}</div>` : ""} ${item.supersedes ? `<div style="font-size:0.9em; font-weight:bold; color:var(--warning-color);">Supersedes: ${escapeHtml(item.supersedes)}</div>` : ""}
`; `;
}, {
itemNoun: "ADRs",
cardBorderColor: "#3498db",
defaultSort: "desc",
sortExtractor: (item) => {
const match = String(item.id || "").match(/\d+/);
if (match)
return parseInt(match[0], 10);
return item.timestamp || 0;
},
searchFields: (item) => [
item.id,
item.title,
item.context,
item.decision,
item.consequence,
item.status,
item.git_branch,
item.task_id
],
filters: [
{
id: "status",
label: "Status",
options: [
{ label: "All Statuses", value: "" },
{ label: "Accepted", value: "accepted" },
{ label: "Implemented", value: "implemented" },
{ label: "Proposed", value: "proposed" },
{ label: "Draft", value: "draft" },
{ label: "Superseded", value: "superseded" }
],
predicate: (item, val) => String(item.status || "").toLowerCase() === val.toLowerCase()
}
]
}); });
} }
function loadMemos() { function loadMemos() {
@@ -1016,6 +1533,18 @@ function loadMemos() {
</div> </div>
` : ""} ` : ""}
`; `;
}, {
itemNoun: "Memos",
cardBorderColor: "#9b59b6",
defaultSort: "desc",
timestampExtractor: (item) => item.timestamp,
searchFields: (item) => [
item.author,
item.content,
item.summary,
item.namespace,
item.git_branch
]
}); });
loadGenericList("/api/session_summaries", "summaries-container", (item) => ` loadGenericList("/api/session_summaries", "summaries-container", (item) => `
<div style="display:flex; justify-content:space-between; align-items:center;"> <div style="display:flex; justify-content:space-between; align-items:center;">
@@ -1026,7 +1555,18 @@ function loadMemos() {
<div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4; margin-top:8px;">${escapeHtml(item.summary || "")}</div> <div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4; margin-top:8px;">${escapeHtml(item.summary || "")}</div>
${item.tasks_completed && item.tasks_completed.length > 0 ? `<div style="margin-top:8px; font-size:0.85em;"><strong>Tasks Completed:</strong><ul style="margin:4px 0 0 16px; padding:0;">${item.tasks_completed.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul></div>` : ""} ${item.tasks_completed && item.tasks_completed.length > 0 ? `<div style="margin-top:8px; font-size:0.85em;"><strong>Tasks Completed:</strong><ul style="margin:4px 0 0 16px; padding:0;">${item.tasks_completed.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul></div>` : ""}
${item.next_steps && item.next_steps.length > 0 ? `<div style="margin-top:6px; font-size:0.85em;"><strong>Next Steps:</strong><ul style="margin:4px 0 0 16px; padding:0;">${item.next_steps.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}</ul></div>` : ""} ${item.next_steps && item.next_steps.length > 0 ? `<div style="margin-top:6px; font-size:0.85em;"><strong>Next Steps:</strong><ul style="margin:4px 0 0 16px; padding:0;">${item.next_steps.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}</ul></div>` : ""}
`); `, {
itemNoun: "Summaries",
cardBorderColor: "#34495e",
defaultSort: "desc",
timestampExtractor: (item) => item.timestamp,
searchFields: (item) => [
item.session_id,
item.summary,
item.namespace,
item.git_branch
]
});
loadGenericList("/api/milestones", "milestones-container", (item) => { loadGenericList("/api/milestones", "milestones-container", (item) => {
const statusLower = (item.status || "pending").toLowerCase(); const statusLower = (item.status || "pending").toLowerCase();
let statusBg = "rgba(52, 152, 219, 0.15)"; let statusBg = "rgba(52, 152, 219, 0.15)";
@@ -1072,6 +1612,30 @@ function loadMemos() {
</div> </div>
` : ""} ` : ""}
`; `;
}, {
itemNoun: "Milestones",
cardBorderColor: "#f39c12",
defaultSort: "desc",
timestampExtractor: (item) => item.timestamp || item.target_date,
searchFields: (item) => [
item.title,
item.name,
item.description,
item.status
],
filters: [
{
id: "status",
label: "Status",
options: [
{ label: "All Statuses", value: "" },
{ label: "Pending", value: "pending" },
{ label: "In Progress", value: "in_progress" },
{ label: "Completed", value: "completed" }
],
predicate: (item, val) => String(item.status || "pending").toLowerCase() === val.toLowerCase()
}
]
}); });
} }
function loadSnippets() { function loadSnippets() {
@@ -1095,6 +1659,42 @@ function loadSnippets() {
${tags.map((t) => `<span style="background:var(--card-bg); padding:1px 6px; border-radius:3px; font-size:0.75em; border:1px solid var(--border-color); color:var(--text-secondary);">#${escapeHtml(t)}</span>`).join(" ")} ${tags.map((t) => `<span style="background:var(--card-bg); padding:1px 6px; border-radius:3px; font-size:0.75em; border:1px solid var(--border-color); color:var(--text-secondary);">#${escapeHtml(t)}</span>`).join(" ")}
</div> </div>
`; `;
}, {
itemNoun: "Snippets",
cardBorderColor: "#27ae60",
defaultSort: "desc",
timestampExtractor: (item) => item.timestamp,
searchFields: (item) => [
item.name,
item.description,
item.code,
item.language,
item.origin_file,
Array.isArray(item.tags) ? item.tags.join(" ") : ""
],
filters: [
{
id: "language",
label: "Language",
options: [
{ label: "All Languages", value: "" },
{ label: "Rust", value: "rust" },
{ label: "TypeScript / JS", value: "typescript" },
{ label: "Python", value: "python" },
{ label: "SQL", value: "sql" },
{ label: "Shell / Bash", value: "bash" },
{ label: "Markdown / Text", value: "markdown" }
],
predicate: (item, val) => {
const lang = String(item.language || "").toLowerCase();
if (val === "typescript")
return lang === "typescript" || lang === "javascript" || lang === "ts" || lang === "js";
if (val === "bash")
return lang === "bash" || lang === "sh" || lang === "pwsh" || lang === "powershell";
return lang === val.toLowerCase();
}
}
]
}); });
} }
function loadAllExtras() { function loadAllExtras() {
@@ -1177,6 +1777,15 @@ document.addEventListener("keydown", function(e) {
searchInput.focus(); searchInput.focus();
searchInput.select(); searchInput.select();
} }
return;
}
if (e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") {
const activeSearch = document.querySelector(".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input");
if (activeSearch) {
e.preventDefault();
activeSearch.focus();
activeSearch.select();
}
} }
}); });
function refreshActiveTab() { function refreshActiveTab() {
@@ -1225,6 +1834,8 @@ Object.assign(window, {
completeTask, completeTask,
buildTaskTreeHTML, buildTaskTreeHTML,
loadTasks, loadTasks,
handleTaskFilter,
handleTaskStatusFilter,
highlightText, highlightText,
handleSearch, handleSearch,
escapeHtml, escapeHtml,
@@ -1234,6 +1845,13 @@ Object.assign(window, {
handleIncomingActivity, handleIncomingActivity,
setupWS, setupWS,
loadGenericList, loadGenericList,
onListSearchInput,
onListClearSearch,
onListFilterChange,
onListToggleSort,
onListPageSizeChange,
onListPageChange,
onListResetFilters,
loadTerminal, loadTerminal,
loadLedger, loadLedger,
loadTechDebt, loadTechDebt,
+947 -106
View File
File diff suppressed because it is too large. Load diff
+42 -5
View File
@@ -287,8 +287,8 @@ impl McpTool for LogCodeChangeHandler {
author: req.author, author: req.author,
session_id: req.session_id, session_id: req.session_id,
vcs_type: detected_vcs, vcs_type: detected_vcs,
revision: effective_rev, revision: effective_rev.clone(),
branch: effective_branch, branch: effective_branch.clone(),
repository_root: req.repository_root, repository_root: req.repository_root,
}); });
if ledger.len() > 500 { if ledger.len() > 500 {
@@ -300,9 +300,42 @@ impl McpTool for LogCodeChangeHandler {
&format!("Modified {}", req.file_path), &format!("Modified {}", req.file_path),
Some(&description), Some(&description),
); );
let recon = crate::handlers::reconciliation::reconcile_commit_or_code_change(
&state,
&description,
Some(&req.file_path),
effective_rev.as_deref(),
effective_branch.as_deref(),
)
.await;
let mut recon_notes = Vec::new();
if !recon.implemented_adrs.is_empty() {
recon_notes.push(format!("Implemented ADRs: {}", recon.implemented_adrs.join(", ")));
}
if !recon.resolved_tech_debts.is_empty() {
recon_notes.push(format!("Resolved TechDebt: {}", recon.resolved_tech_debts.join(", ")));
}
if !recon.completed_tasks.is_empty() {
recon_notes.push(format!("Completed Tasks: {}", recon.completed_tasks.join(", ")));
}
if !recon.unblocked_tasks.is_empty() {
recon_notes.push(format!("Unblocked Tasks: {}", recon.unblocked_tasks.join(", ")));
}
if !recon.updated_milestones.is_empty() {
recon_notes.push(format!("Updated Milestones: {}", recon.updated_milestones.join(", ")));
}
let recon_suffix = if recon_notes.is_empty() {
String::new()
} else {
format!(" [{}]", recon_notes.join(" | "))
};
Ok(format!( Ok(format!(
"Logged code change for {}: {}", "Logged code change for {}: {}{}",
req.file_path, description req.file_path, description, recon_suffix
)) ))
} }
} }
@@ -1170,7 +1203,11 @@ impl McpTool for GetProjectHealthHandler {
let active_milestones = state.project.milestones.read_with(|milestones| { let active_milestones = state.project.milestones.read_with(|milestones| {
milestones milestones
.iter() .iter()
.filter(|m| m.namespace == req.namespace && m.status != "done") .filter(|m| {
m.namespace == req.namespace
&& !m.status.eq_ignore_ascii_case("done")
&& !m.status.eq_ignore_ascii_case("completed")
})
.count() .count()
}); });
let report = serde_json::json!({ let report = serde_json::json!({
+2
View File
@@ -5,7 +5,9 @@ pub mod graph;
pub mod logs; pub mod logs;
pub mod meta; pub mod meta;
pub mod notes; pub mod notes;
pub mod reconciliation;
pub mod tasks; pub mod tasks;
pub mod utils; pub mod utils;
pub mod vision; pub mod vision;
pub mod workspaces; pub mod workspaces;
+541
View File
@@ -0,0 +1,541 @@
use crate::models::{Adr, TaskEvent};
use crate::state::MemoryState;
use std::sync::Arc;
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
pub struct GitCommitPayload {
pub commit: String,
#[serde(default)]
pub branch: Option<String>,
pub message: String,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub files: Vec<String>,
#[serde(default)]
pub repo_name: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct ReconciliationResult {
pub implemented_adrs: Vec<String>,
pub resolved_tech_debts: Vec<String>,
pub completed_tasks: Vec<String>,
pub unblocked_tasks: Vec<String>,
pub updated_milestones: Vec<String>,
}
/// Extracts ADR identifiers such as "ADR-0102", "ADR-102", "adr-005", "ADR: 102"
pub fn extract_adrs(text: &str) -> Vec<String> {
let mut adrs = Vec::new();
let lower = text.to_lowercase();
let chars: Vec<char> = text.chars().collect();
let lower_chars: Vec<char> = lower.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
// Look for "adr"
if i + 3 <= len && lower_chars[i..i + 3] == ['a', 'd', 'r'] {
let mut cursor = i + 3;
// Skip optional '-', ':', or whitespace
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
cursor += 1;
}
// Collect consecutive digits
let digit_start = cursor;
while cursor < len && chars[cursor].is_ascii_digit() {
cursor += 1;
}
if cursor > digit_start {
let num_str: String = chars[digit_start..cursor].iter().collect();
if let Ok(num) = num_str.parse::<u32>() {
let formatted = format!("ADR-{:04}", num);
if !adrs.contains(&formatted) {
adrs.push(formatted);
}
let raw_formatted = format!("ADR-{}", num_str);
if !adrs.contains(&raw_formatted) {
adrs.push(raw_formatted);
}
}
}
i = cursor;
} else {
i += 1;
}
}
adrs
}
/// Extracts TechDebt identifiers such as "TD-001", "td-auth-cleanup", "TD: 12"
pub fn extract_tech_debts(text: &str) -> Vec<String> {
let mut debts = Vec::new();
let lower = text.to_lowercase();
let chars: Vec<char> = text.chars().collect();
let lower_chars: Vec<char> = lower.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if i + 2 <= len && lower_chars[i..i + 2] == ['t', 'd'] {
let mut cursor = i + 2;
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
cursor += 1;
}
let start = cursor;
while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') {
cursor += 1;
}
if cursor > start {
let id_part: String = chars[start..cursor].iter().collect();
let full_id = format!("TD-{}", id_part);
if !debts.contains(&full_id) {
debts.push(full_id);
}
if !debts.contains(&id_part) {
debts.push(id_part);
}
}
i = cursor;
} else {
i += 1;
}
}
debts
}
/// Extracts Task identifiers such as "TASK-123", "task-01", or UUID strings
pub fn extract_tasks(text: &str) -> Vec<String> {
let mut tasks = Vec::new();
let lower = text.to_lowercase();
let chars: Vec<char> = text.chars().collect();
let lower_chars: Vec<char> = lower.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if i + 4 <= len && lower_chars[i..i + 4] == ['t', 'a', 's', 'k'] {
let mut cursor = i + 4;
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
cursor += 1;
}
let start = cursor;
while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') {
cursor += 1;
}
if cursor > start {
let id_part: String = chars[start..cursor].iter().collect();
let full_id = format!("TASK-{}", id_part);
if !tasks.contains(&full_id) {
tasks.push(full_id);
}
if !tasks.contains(&id_part) {
tasks.push(id_part);
}
}
i = cursor;
} else {
i += 1;
}
}
tasks
}
fn adr_numeric_id(id: &str) -> Option<u32> {
let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
digits.parse::<u32>().ok()
}
/// Automatically cascades unblocking to tasks whose dependencies have all completed.
pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec<String> {
let mut unblocked = Vec::new();
let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone());
state.project.tasks.modify(|tasks| {
for t in tasks.iter_mut() {
if t.status.eq_ignore_ascii_case("blocked") {
if t.dependencies.is_empty() {
t.status = "active".to_string();
t.updated_at = crate::handlers::utils::now_secs();
unblocked.push(t.id.clone());
} else {
let all_deps_completed = t.dependencies.iter().all(|dep_id| {
tasks_snapshot.iter().any(|other| {
(other.id == *dep_id || other.title == *dep_id)
&& !other.is_active()
})
});
if all_deps_completed {
t.status = "active".to_string();
t.updated_at = crate::handlers::utils::now_secs();
unblocked.push(t.id.clone());
}
}
}
}
});
for tid in &unblocked {
state.record_activity(
"task_update",
&format!("Auto-unblocked Task {} (all dependencies met)", tid),
Some("active"),
);
state.broadcast_task_event(TaskEvent {
task_id: tid.clone(),
status: "active".to_string(),
action: Some("cascade_unblock".to_string()),
result: Some(serde_json::json!({ "status": "active" })),
error: None,
timestamp: crate::handlers::utils::now_secs(),
session_id: None,
..Default::default()
});
}
unblocked
}
/// Automatically recalculates milestone progress and status based on task completion.
pub fn recalculate_milestones(state: &MemoryState) -> Vec<String> {
let mut updated = Vec::new();
let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone());
state.project.milestones.modify(|milestones| {
for m in milestones.iter_mut() {
if m.task_ids.is_empty() {
continue;
}
let total = m.task_ids.len();
let completed = m
.task_ids
.iter()
.filter(|tid| {
tasks_snapshot
.iter()
.any(|t| (t.id == **tid || t.title == **tid) && !t.is_active())
})
.count();
if completed == total {
if !m.status.eq_ignore_ascii_case("completed") {
m.status = "completed".to_string();
if m.completed_at.is_none() {
m.completed_at = Some(crate::handlers::utils::now_secs());
}
updated.push(m.id.clone());
}
} else if completed > 0
|| tasks_snapshot.iter().any(|t| {
m.task_ids.iter().any(|tid| tid == &t.id || tid == &t.title)
&& (t.status == "in_progress" || t.status == "active")
})
{
if !m.status.eq_ignore_ascii_case("in_progress")
&& !m.status.eq_ignore_ascii_case("completed")
{
m.status = "in_progress".to_string();
updated.push(m.id.clone());
}
}
}
});
for mid in &updated {
state.record_activity(
"milestone",
&format!("Milestone {} status updated via task progress", mid),
None,
);
}
updated
}
/// Reconciles state deterministically from git commit messages or MCP `log_code_change` calls.
pub async fn reconcile_commit_or_code_change(
state: &Arc<MemoryState>,
text: &str,
file_path: Option<&str>,
git_commit: Option<&str>,
git_branch: Option<&str>,
) -> ReconciliationResult {
let mut result = ReconciliationResult::default();
let now = crate::handlers::utils::now_secs();
let text_lower = text.to_lowercase();
// 1. Reconcile ADRs
let adr_candidates = extract_adrs(text);
if !adr_candidates.is_empty() {
let mut adrs_to_index: Vec<Adr> = Vec::new();
state.code.adrs.modify(|adrs| {
for adr in adrs.iter_mut() {
let matches = adr_candidates.iter().any(|cand| {
if adr.id.eq_ignore_ascii_case(cand) {
return true;
}
if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand)) {
if n1 == n2 {
return true;
}
}
false
});
if matches && !adr.status.eq_ignore_ascii_case("implemented") {
adr.status = "implemented".to_string();
adr.resolved_at = Some(now);
if let Some(rev) = git_commit {
adr.git_commit = Some(rev.to_string());
}
if let Some(br) = git_branch {
adr.git_branch = Some(br.to_string());
}
result.implemented_adrs.push(adr.id.clone());
adrs_to_index.push(adr.clone());
}
}
});
if !adrs_to_index.is_empty() {
let idx = state.get_search_index().await;
for a in &adrs_to_index {
drop(idx.index_adr(a));
state.record_activity(
"decision",
&format!("Auto-implemented ADR {}: {}", a.id, a.title),
Some("implemented"),
);
}
}
}
// 2. Reconcile Tech Debts
let td_candidates = extract_tech_debts(text);
let mentions_debt_resolution = text_lower.contains("fix")
|| text_lower.contains("resolve")
|| text_lower.contains("refactor")
|| text_lower.contains("clean")
|| text_lower.contains("address");
state.code.tech_debts.modify(|debts| {
for d in debts.iter_mut() {
if d.is_resolved {
continue;
}
let matches_id = td_candidates.iter().any(|cand| d.id.eq_ignore_ascii_case(cand));
let matches_file = file_path
.map(|fp| d.file_path.as_deref() == Some(fp))
.unwrap_or(false)
&& mentions_debt_resolution
&& (text_lower.contains("debt") || text_lower.contains("td-"));
if matches_id || matches_file {
d.is_resolved = true;
if let Some(rev) = git_commit {
d.git_commit = Some(rev.to_string());
}
if let Some(br) = git_branch {
d.git_branch = Some(br.to_string());
}
result.resolved_tech_debts.push(d.id.clone());
}
}
});
for tdid in &result.resolved_tech_debts {
state.record_activity(
"tech_debt",
&format!("Auto-resolved TechDebt {} via commit", tdid),
Some("resolved"),
);
}
// 3. Reconcile Tasks
let task_candidates = extract_tasks(text);
let mentions_task_close = text_lower.contains("fix")
|| text_lower.contains("close")
|| text_lower.contains("complete")
|| text_lower.contains("resolve")
|| text_lower.contains("finish")
|| text_lower.contains("implement");
if mentions_task_close && !task_candidates.is_empty() {
state.project.tasks.modify(|tasks| {
for t in tasks.iter_mut() {
if !t.is_active() {
continue;
}
let matches = task_candidates.iter().any(|cand| {
t.id.eq_ignore_ascii_case(cand)
|| cand.trim_start_matches("TASK-").eq_ignore_ascii_case(&t.id)
});
if matches {
t.status = "completed".to_string();
t.updated_at = now;
result.completed_tasks.push(t.id.clone());
}
}
});
for tid in &result.completed_tasks {
state.record_activity(
"task_update",
&format!("Auto-completed Task {} via commit", tid),
Some("completed"),
);
state.broadcast_task_event(TaskEvent {
task_id: tid.clone(),
status: "completed".to_string(),
action: Some("commit_reconciliation".to_string()),
result: Some(serde_json::json!({ "status": "completed" })),
error: None,
timestamp: now,
session_id: None,
..Default::default()
});
}
}
// 4. Cascade Unblock & Recalculate Milestones if any tasks completed
if !result.completed_tasks.is_empty() {
result.unblocked_tasks = cascade_unblock_tasks(state);
result.updated_milestones = recalculate_milestones(state);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{Milestone, Task};
use tempfile::tempdir;
#[test]
fn test_extract_adrs() {
let text = "feat(api): implement ADR-0102 and ADR-103 with pagination";
let adrs = extract_adrs(text);
assert!(adrs.contains(&"ADR-0102".to_string()));
assert!(adrs.contains(&"ADR-0103".to_string()));
}
#[test]
fn test_extract_tech_debts() {
let text = "refactor: resolve TD-cleanup_old_buffers and TD-002";
let debts = extract_tech_debts(text);
assert!(debts.contains(&"TD-cleanup_old_buffers".to_string()));
assert!(debts.contains(&"TD-002".to_string()));
}
#[test]
fn test_extract_tasks() {
let text = "fix(core): close task-551 and TASK-abc";
let tasks = extract_tasks(text);
assert!(tasks.contains(&"TASK-551".to_string()));
assert!(tasks.contains(&"TASK-abc".to_string()));
}
#[tokio::test]
async fn test_reconcile_adr_implementation() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
state.code.adrs.modify(|adrs| {
adrs.push(Adr {
id: "ADR-0102".to_string(),
title: "Dynamic Neovim Projection Buffer".to_string(),
status: "accepted".to_string(),
..Default::default()
});
});
let result = reconcile_commit_or_code_change(
&state,
"feat: implement ADR-102 projection buffer",
None,
Some("commit123"),
Some("master"),
)
.await;
assert_eq!(result.implemented_adrs, vec!["ADR-0102"]);
let adr = state.code.adrs.read_with(|adrs| adrs[0].clone());
assert_eq!(adr.status, "implemented");
assert_eq!(adr.git_commit.as_deref(), Some("commit123"));
assert_eq!(adr.git_branch.as_deref(), Some("master"));
assert!(adr.resolved_at.is_some());
}
#[tokio::test]
async fn test_cascade_unblock_and_milestone_recalculation() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let task1 = Task {
id: "task-1".to_string(),
title: "Task 1".to_string(),
status: "active".to_string(),
..Default::default()
};
let task2 = Task {
id: "task-2".to_string(),
title: "Task 2".to_string(),
status: "blocked".to_string(),
dependencies: vec!["task-1".to_string()],
..Default::default()
};
state.project.tasks.modify(|t| {
t.push(task1);
t.push(task2);
});
let ms = Milestone {
id: "ms-1".to_string(),
title: "Sprint 1".to_string(),
status: "pending".to_string(),
task_ids: vec!["task-1".to_string(), "task-2".to_string()],
..Default::default()
};
state.project.milestones.modify(|m| m.push(ms));
// Complete task-1 via commit message
let result = reconcile_commit_or_code_change(
&state,
"fix: close task-1 core functionality",
None,
Some("c1"),
Some("master"),
)
.await;
assert_eq!(result.completed_tasks, vec!["task-1"]);
assert_eq!(result.unblocked_tasks, vec!["task-2"]);
assert_eq!(result.updated_milestones, vec!["ms-1"]);
// Task 2 should now be active
let t2 = state.project.tasks.read_with(|t| t[1].clone());
assert_eq!(t2.status, "active");
// Milestone should now be in_progress
let m = state.project.milestones.read_with(|m| m[0].clone());
assert_eq!(m.status, "in_progress");
// Now complete task-2
let result2 = reconcile_commit_or_code_change(
&state,
"fix: close task-2 follow-up",
None,
Some("c2"),
Some("master"),
)
.await;
assert_eq!(result2.completed_tasks, vec!["task-2"]);
let m2 = state.project.milestones.read_with(|m| m[0].clone());
assert_eq!(m2.status, "completed");
assert!(m2.completed_at.is_some());
}
}
+15
View File
@@ -192,6 +192,10 @@ impl McpTool for TasksHandler {
session_id: None, session_id: None,
..Default::default() ..Default::default()
}); });
if target_status == "completed" {
crate::handlers::reconciliation::cascade_unblock_tasks(&state);
crate::handlers::reconciliation::recalculate_milestones(&state);
}
Ok("Task status updated.".to_string()) Ok("Task status updated.".to_string())
} else { } else {
Err(crate::error::AppError::Internal( Err(crate::error::AppError::Internal(
@@ -452,11 +456,13 @@ impl McpTool for MilestonesHandler {
.as_deref() .as_deref()
.and_then(|s| s.parse::<u64>().ok()), .and_then(|s| s.parse::<u64>().ok()),
description: req.description, description: req.description,
task_ids: req.task_ids.unwrap_or_default(),
deliverables: req.deliverables.unwrap_or_default(), deliverables: req.deliverables.unwrap_or_default(),
repo_name: req.repo_name, repo_name: req.repo_name,
..Default::default() ..Default::default()
}) })
}); });
crate::handlers::reconciliation::recalculate_milestones(&state);
Ok("Milestone added".to_string()) Ok("Milestone added".to_string())
} }
MilestoneAction::Update => { MilestoneAction::Update => {
@@ -471,12 +477,21 @@ impl McpTool for MilestonesHandler {
for m in ms.iter_mut() { for m in ms.iter_mut() {
if m.id == id { if m.id == id {
m.status = status.clone(); m.status = status.clone();
if status.eq_ignore_ascii_case("completed") || status.eq_ignore_ascii_case("done") {
if m.completed_at.is_none() {
m.completed_at = Some(crate::handlers::utils::now_secs());
}
}
if let Some(task_ids) = &req.task_ids {
m.task_ids = task_ids.clone();
}
found = true; found = true;
break; break;
} }
} }
}); });
if found { if found {
crate::handlers::reconciliation::recalculate_milestones(&state);
Ok("Milestone updated".to_string()) Ok("Milestone updated".to_string())
} else { } else {
Err(crate::error::AppError::Internal( Err(crate::error::AppError::Internal(
+28 -1
View File
@@ -102,10 +102,37 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
let mut expired_tasks = Vec::new();
state.project.tasks.modify(|tasks| { state.project.tasks.modify(|tasks| {
tasks.retain(|t| t.expires_at.is_none_or(|exp| exp > now)); for t in tasks.iter_mut() {
if let Some(exp) = t.expires_at {
if exp <= now && t.is_active() {
t.status = "expired".to_string();
t.updated_at = now;
expired_tasks.push(t.id.clone());
}
}
}
}); });
for tid in expired_tasks {
state.record_activity(
"task_expired",
&format!("Task {} expired due to TTL", tid),
Some("expired"),
);
state.broadcast_task_event(crate::models::TaskEvent {
task_id: tid,
status: "expired".to_string(),
action: Some("ttl_expire".to_string()),
result: Some(serde_json::json!({ "status": "expired" })),
error: None,
timestamp: now,
session_id: None,
..Default::default()
});
}
state.telemetry.handoff_memos.modify(|memos| { state.telemetry.handoff_memos.modify(|memos| {
memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now)); memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now));
}); });
+3
View File
@@ -739,6 +739,9 @@ pub struct MilestonesTool {
pub description: Option<String>, pub description: Option<String>,
/// Optional deliverables or task checklist. /// Optional deliverables or task checklist.
pub deliverables: Option<Vec<String>>, pub deliverables: Option<Vec<String>>,
/// Optional list of task IDs associated with this milestone.
#[serde(default)]
pub task_ids: Option<Vec<String>>,
/// Optional repository name. /// Optional repository name.
pub repo_name: Option<String>, pub repo_name: Option<String>,
} }