diff --git a/.gitignore b/.gitignore index 21b1564..c5f57a8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ llvm-cov.zip .fastembed_cache/ *.py +!scripts/*.py temp.json *.txt summary.md diff --git a/agent-rules/global_subagents.md b/agent-rules/global_subagents.md index 56941c1..4763f56 100644 --- a/agent-rules/global_subagents.md +++ b/agent-rules/global_subagents.md @@ -12,7 +12,7 @@ Invoke these dynamically using the `invoke_subagent` tool. Use `send_message` to * **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. * **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 * **Role:** Quality Gate Enforcer diff --git a/agent-rules/mcp_memory_workflow.md b/agent-rules/mcp_memory_workflow.md index 331535b..9440434 100644 --- a/agent-rules/mcp_memory_workflow.md +++ b/agent-rules/mcp_memory_workflow.md @@ -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. - **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`. -- **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: , git_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`. - **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. diff --git a/justfile b/justfile index 0309519..d5ab42f 100644 --- a/justfile +++ b/justfile @@ -190,3 +190,9 @@ all-nvim-wsl: build-nvim deploy-nvim # 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) + +# 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 } + diff --git a/scripts/git-reconcile.py b/scripts/git-reconcile.py new file mode 100644 index 0000000..12a20cf --- /dev/null +++ b/scripts/git-reconcile.py @@ -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() diff --git a/server/src/api/rest.rs b/server/src/api/rest.rs index e7b3d53..0ea52d2 100644 --- a/server/src/api/rest.rs +++ b/server/src/api/rest.rs @@ -36,6 +36,10 @@ pub async fn gate_verify_handler( State(app_state): State>, Query(q): Query, ) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); let mut found = None; let mut to_remove = None; app_state.handler.state.env.gates.modify(|gates| { @@ -44,6 +48,7 @@ pub async fn gate_verify_handler( && g.target == q.target && g.namespace == q.namespace && g.params == q.params + && g.expires_at.is_none_or(|exp| exp > now) }) { found = Some(gates[idx].clone()); if q.consume { diff --git a/server/src/api/setup.rs b/server/src/api/setup.rs index c79bf15..18e507c 100644 --- a/server/src/api/setup.rs +++ b/server/src/api/setup.rs @@ -188,11 +188,52 @@ pub fn create_router(app_state: Arc) -> Router { for t in tasks.iter_mut() { if t.id == id { t.status = "completed".to_string(); + t.updated_at = crate::handlers::utils::now_secs(); 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| 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| 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[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()); + } } diff --git a/server/src/dashboard.html b/server/src/dashboard.html index 462a6cf..66f0768 100644 --- a/server/src/dashboard.html +++ b/server/src/dashboard.html @@ -21,6 +21,7 @@ --btn-border: #dcdde1; --btn-hover: #f8f9fa; --search-hover: #f0f2f5; + --input-bg: #ffffff; --input-text: #2c3e50; --inspector-bg: rgba(255,255,255,0.98); --success-color: #27ae60; @@ -45,6 +46,7 @@ --btn-border: #3d4a57; --btn-hover: #3d4a57; --search-hover: #2c3643; + --input-bg: #1e242b; --input-text: #d1d8e0; --inspector-bg: rgba(30, 36, 43, 0.98); --success-color: #2ecc71; @@ -464,6 +466,182 @@ color: #7f8c8d; 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; + } @@ -541,7 +719,19 @@
-

Task Network (HTN)

+
+

Task Network (HTN)

+
+ + +
+
diff --git a/server/src/dashboard.js b/server/src/dashboard.js index 2cc8059..89ffa56 100644 --- a/server/src/dashboard.js +++ b/server/src/dashboard.js @@ -157,10 +157,10 @@ function toggleFilter(type) { updateGraphData(); } function renderFilters() { - const container2 = document.getElementById("graph-filters"); - if (!container2) + const container = document.getElementById("graph-filters"); + if (!container) return; - container2.innerHTML = Array.from(allTypes).sort().map((type) => { + container.innerHTML = Array.from(allTypes).sort().map((type) => { const isActive = activeFilters.has(type); const color = getColorForType(type).bg || getColorForType(type).background; return ` + `; + } + } 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 ` +
+ ${dateStr ? `
${dateStr}
` : ""} + ${state.formatter(item)} +
`; + }).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 += ``; + pagHtml += ``; + const cur = state.currentPage; + const startP = Math.max(1, cur - 2); + const endP = Math.min(totalPages, cur + 2); + if (startP > 1) { + pagHtml += ``; + if (startP > 2) + pagHtml += `…`; + } + for (let p = startP;p <= endP; p++) { + pagHtml += ``; + } + if (endP < totalPages) { + if (endP < totalPages - 1) + pagHtml += `…`; + pagHtml += ``; + } + pagHtml += ``; + pagHtml += ``; + 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 += ` + `; + } + } + 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 = ` + `; + container.innerHTML = ` +
+
+ \uD83D\uDD0D + + +
+
+ ${filtersHtml} + + ${pageSizeHtml} + +
+
+
+
+
+ `; + 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 { const res = await fetch(endpoint); if (!res.ok) { - if (container2) { - container2.innerHTML = `
⚠️ Failed to load ${endpoint} (${res.status} ${res.statusText})
`; - } + container.innerHTML = `
⚠️ Failed to load ${escapeHtml(endpoint)} (${res.status} ${escapeHtml(res.statusText)})
`; return; } const data = await res.json(); - const container2 = document.getElementById(containerId); - if (!container2) - return; - if (!data || data.length === 0) { - container2.innerHTML = '
No items recorded.
'; - return; + state.items = Array.isArray(data) ? data : []; + applyListFiltering(containerId); + const toolbarExists = document.getElementById(`${containerId}-toolbar`); + if (!toolbarExists) { + renderListStructure(containerId); + } else { + renderListCardsAndPagination(containerId); } - container2.innerHTML = data.map((item) => { - const date = item.timestamp ? new Date(item.timestamp * 1000).toLocaleString() : ""; - return `
-
${date}
- ${formatter(item)} -
`; - }).join(""); } catch (err) { console.error(`Failed to load ${endpoint}`, err); - if (container) { - container.innerHTML = `
⚠️ Failed to load ${endpoint} (${escapeHtml(String(err))})
`; - } + container.innerHTML = `
⚠️ Failed to load ${escapeHtml(endpoint)} (${escapeHtml(String(err))})
`; } } 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 vcsColor = vcs === "SVN" ? "#e67e22" : "#3498db"; const rev = item.revision || item.git_commit; @@ -848,6 +1185,48 @@ function loadLedger() { ${authorBadge} `; + }, { + 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() { @@ -880,6 +1259,38 @@ function loadTerminal() { ` : ""} `; + }, { + 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() { @@ -923,6 +1334,50 @@ function loadTechDebt() { \uD83C\uDFF7️ ${escapeHtml(item.namespace || "global")} `; + }, { + 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) => `
@@ -945,7 +1400,34 @@ function loadTechDebt() { Commit: ${item.git_commit ? escapeHtml(item.git_commit.substring(0, 8)) : "None"} ${item.git_branch ? `\uD83C\uDF3F ${escapeHtml(item.git_branch)}` : ""}
- `); + `, { + 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() { loadGenericList("/api/adrs", "adrs-container", (item) => { @@ -980,6 +1462,41 @@ function loadADRs() {
Consequence: ${escapeHtml(item.consequence || "")}
${item.supersedes ? `
Supersedes: ${escapeHtml(item.supersedes)}
` : ""} `; + }, { + 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() { @@ -1016,6 +1533,18 @@ function loadMemos() { ` : ""} `; + }, { + 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) => `
@@ -1026,7 +1555,18 @@ function loadMemos() {
${escapeHtml(item.summary || "")}
${item.tasks_completed && item.tasks_completed.length > 0 ? `
Tasks Completed:
    ${item.tasks_completed.map((t) => `
  • ${escapeHtml(t)}
  • `).join("")}
` : ""} ${item.next_steps && item.next_steps.length > 0 ? `
Next Steps:
    ${item.next_steps.map((s) => `
  • ${escapeHtml(s)}
  • `).join("")}
` : ""} - `); + `, { + 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) => { const statusLower = (item.status || "pending").toLowerCase(); let statusBg = "rgba(52, 152, 219, 0.15)"; @@ -1072,6 +1612,30 @@ function loadMemos() {
` : ""} `; + }, { + 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() { @@ -1095,6 +1659,42 @@ function loadSnippets() { ${tags.map((t) => `#${escapeHtml(t)}`).join(" ")} `; + }, { + 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() { @@ -1177,6 +1777,15 @@ document.addEventListener("keydown", function(e) { searchInput.focus(); 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() { @@ -1225,6 +1834,8 @@ Object.assign(window, { completeTask, buildTaskTreeHTML, loadTasks, + handleTaskFilter, + handleTaskStatusFilter, highlightText, handleSearch, escapeHtml, @@ -1234,6 +1845,13 @@ Object.assign(window, { handleIncomingActivity, setupWS, loadGenericList, + onListSearchInput, + onListClearSearch, + onListFilterChange, + onListToggleSort, + onListPageSizeChange, + onListPageChange, + onListResetFilters, loadTerminal, loadLedger, loadTechDebt, diff --git a/server/src/dashboard.ts b/server/src/dashboard.ts index f75099d..c3352f5 100644 --- a/server/src/dashboard.ts +++ b/server/src/dashboard.ts @@ -602,22 +602,71 @@ function buildTaskTreeHTML( return html; } +let cachedTasks: TaskItem[] = []; +let taskFilterQuery = ""; +let taskStatusFilter = ""; + +function handleTaskFilter(val: string): void { + taskFilterQuery = val.toLowerCase().trim(); + applyTaskFilters(); +} + +function handleTaskStatusFilter(val: string): void { + taskStatusFilter = val.toLowerCase().trim(); + applyTaskFilters(); +} + +function applyTaskFilters(): void { + 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 = + '
No active tasks.
'; + } else { + taskContainer.innerHTML = + '
No tasks matching current filter.
'; + } + } else { + taskContainer.innerHTML = rootHtml; + } +} + async function loadTasks(): Promise { try { const res = await fetch("/api/tasks"); - const tasks: TaskItem[] = await res.json(); - - const taskContainer = document.getElementById("task-tree-container"); - if (!taskContainer) return; - - const rootHtml = buildTaskTreeHTML(tasks, null, 0); - - if (!rootHtml) { - taskContainer.innerHTML = - '
No active tasks.
'; - } else { - taskContainer.innerHTML = rootHtml; - } + cachedTasks = await res.json(); + applyTaskFilters(); } catch (err) { console.error("Failed to load tasks", err); } @@ -1031,92 +1080,500 @@ function setupWS(): void { }; } +interface GenericFilterDef { + id: string; + label: string; + options: { label: string; value: string }[]; + predicate: (item: T, selectedValue: string) => boolean; +} + +interface GenericListOptions { + searchFields?: (item: T) => (string | null | undefined)[]; + filters?: GenericFilterDef[]; + defaultSort?: "desc" | "asc"; + sortExtractor?: (item: T) => number | string; + pageSize?: number; + itemNoun?: string; + cardBorderColor?: string; + timestampExtractor?: (item: T) => number | null | undefined; +} + +interface ListState { + endpoint: string; + containerId: string; + formatter: (item: T) => string; + options: GenericListOptions; + items: T[]; + filteredItems: T[]; + searchQuery: string; + activeFilters: Record; + sortOrder: "desc" | "asc"; + currentPage: number; + pageSize: number; + debounceTimer: any; +} + +const listControllers = new Map>(); + +function applyListFiltering(containerId: string): void { + const state = listControllers.get(containerId); + if (!state) return; + + let result = state.items.slice(); + + // Search filter + const q = (state.searchQuery || "").trim().toLowerCase(); + if (q) { + result = result.filter((item: any) => { + 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); + }); + } + + // Dropdown filters + 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)); + } + } + } + + // Sorting + const factor = state.sortOrder === "desc" ? -1 : 1; + const extractor = + state.options.sortExtractor || + ((item: any) => { + 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: string): void { + 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); + + // Update Meta Bar + 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 ${startIndex}–${endIndex} of ${totalItems} ${itemNoun}`; + } + if (totalItems !== state.items.length) { + summaryText += ` (filtered from ${state.items.length} total)`; + } + metaEl.innerHTML = `
${summaryText}
Page ${state.currentPage} of ${totalPages}
`; + } + + // Update Cards Container + const cardsEl = document.getElementById(`${containerId}-cards`); + if (cardsEl) { + if (totalItems === 0) { + const itemNoun = state.options.itemNoun || "items"; + if (state.items.length === 0) { + cardsEl.innerHTML = `
No ${itemNoun} recorded.
`; + } else { + cardsEl.innerHTML = ` +
+
🔍 No matching ${itemNoun} found for current filter.
+ +
`; + } + } else { + const pageSlice = + pageSize <= 0 + ? state.filteredItems + : state.filteredItems.slice( + (state.currentPage - 1) * pageSize, + state.currentPage * pageSize, + ); + + cardsEl.innerHTML = pageSlice + .map((item: any) => { + 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 ` +
+ ${dateStr ? `
${dateStr}
` : ""} + ${state.formatter(item)} +
`; + }) + .join(""); + } + } + + // Update Pagination Controls + 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 += ``; + pagHtml += ``; + + const cur = state.currentPage; + const startP = Math.max(1, cur - 2); + const endP = Math.min(totalPages, cur + 2); + + if (startP > 1) { + pagHtml += ``; + if (startP > 2) + pagHtml += `…`; + } + + for (let p = startP; p <= endP; p++) { + pagHtml += ``; + } + + if (endP < totalPages) { + if (endP < totalPages - 1) + pagHtml += `…`; + pagHtml += ``; + } + + pagHtml += ``; + pagHtml += ``; + + paginationEl.innerHTML = pagHtml; + } + } + + // Update clear button visibility on toolbar + const clearBtn = document.getElementById(`${containerId}-clear`); + if (clearBtn) { + clearBtn.style.display = state.searchQuery ? "block" : "none"; + } + + // Update sort button text + const sortBtn = document.getElementById(`${containerId}-sort-btn`); + if (sortBtn) { + sortBtn.innerHTML = + state.sortOrder === "desc" ? "⬇️ Newest First" : "⬆️ Oldest First"; + } +} + +function renderListStructure(containerId: string): void { + const state = listControllers.get(containerId); + if (!state) return; + const container = document.getElementById(containerId); + if (!container) return; + + const itemNoun = state.options.itemNoun || "items"; + + // Build Filter Dropdowns HTML + 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 += ` + `; + } + } + + // Page size selector + 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 = ` + `; + + container.innerHTML = ` +
+
+ 🔍 + + +
+
+ ${filtersHtml} + + ${pageSizeHtml} + +
+
+
+
+
+ `; + + renderListCardsAndPagination(containerId); +} + +function onListSearchInput(containerId: string, value: string): void { + 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: string): void { + const state = listControllers.get(containerId); + if (!state) return; + state.searchQuery = ""; + const inputEl = document.getElementById( + `${containerId}-search`, + ) as HTMLInputElement | null; + if (inputEl) { + inputEl.value = ""; + inputEl.focus(); + } + state.currentPage = 1; + applyListFiltering(containerId); + renderListCardsAndPagination(containerId); +} + +function onListFilterChange( + containerId: string, + filterId: string, + value: string, +): void { + const state = listControllers.get(containerId); + if (!state) return; + state.activeFilters[filterId] = value; + state.currentPage = 1; + applyListFiltering(containerId); + renderListCardsAndPagination(containerId); +} + +function onListToggleSort(containerId: string): void { + const state = listControllers.get(containerId); + if (!state) return; + state.sortOrder = state.sortOrder === "desc" ? "asc" : "desc"; + applyListFiltering(containerId); + renderListCardsAndPagination(containerId); +} + +function onListPageSizeChange(containerId: string, value: string): void { + const state = listControllers.get(containerId); + if (!state) return; + state.pageSize = parseInt(value, 10); + state.currentPage = 1; + renderListCardsAndPagination(containerId); +} + +function onListPageChange(containerId: string, page: number): void { + 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: string): void { + 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: string, containerId: string, formatter: (item: any) => string, + options?: GenericListOptions, ): Promise { + const container = document.getElementById(containerId); + if (!container) return; + + const resolvedOptions = options || {}; + const existingState = listControllers.get(containerId); + + const state: ListState = 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 { const res = await fetch(endpoint); if (!res.ok) { - if (container) { - container.innerHTML = `
⚠️ Failed to load ${endpoint} (${res.status} ${res.statusText})
`; - } + container.innerHTML = `
⚠️ Failed to load ${escapeHtml(endpoint)} (${res.status} ${escapeHtml(res.statusText)})
`; return; } const data = await res.json(); - const container = document.getElementById(containerId); - if (!container) return; + state.items = Array.isArray(data) ? data : []; + applyListFiltering(containerId); - if (!data || data.length === 0) { - container.innerHTML = - '
No items recorded.
'; - return; + const toolbarExists = document.getElementById(`${containerId}-toolbar`); + if (!toolbarExists) { + renderListStructure(containerId); + } else { + renderListCardsAndPagination(containerId); } - - container.innerHTML = data - .map((item: any) => { - const date = item.timestamp - ? new Date(item.timestamp * 1000).toLocaleString() - : ""; - return `
-
${date}
- ${formatter(item)} -
`; - }) - .join(""); } catch (err) { console.error(`Failed to load ${endpoint}`, err); - if (container) { - container.innerHTML = `
⚠️ Failed to load ${endpoint} (${escapeHtml(String(err))})
`; - } + container.innerHTML = `
⚠️ Failed to load ${escapeHtml(endpoint)} (${escapeHtml(String(err))})
`; } } function loadLedger(): void { - loadGenericList("/api/ledger?limit=100", "ledger-container", (item) => { - const vcs = ( - item.vcs_type || - (item.revision && item.revision.startsWith("r") ? "svn" : "git") - ).toUpperCase(); - const vcsColor = vcs === "SVN" ? "#e67e22" : "#3498db"; - const rev = item.revision || item.git_commit; - const revLabel = vcs === "SVN" ? "Rev" : "Commit"; - const branch = item.branch || item.git_branch || "None"; - const repo = item.repo_name || item.namespace || "global"; - const changeKind = (item.change_kind || "modified").toLowerCase(); + loadGenericList( + "/api/ledger?limit=500", + "ledger-container", + (item) => { + const vcs = ( + item.vcs_type || + (item.revision && item.revision.startsWith("r") ? "svn" : "git") + ).toUpperCase(); + const vcsColor = vcs === "SVN" ? "#e67e22" : "#3498db"; + const rev = item.revision || item.git_commit; + const revLabel = vcs === "SVN" ? "Rev" : "Commit"; + const branch = item.branch || item.git_branch || "None"; + const repo = item.repo_name || item.namespace || "global"; + const changeKind = (item.change_kind || "modified").toLowerCase(); - let kindColor = "var(--accent-color)"; - let kindLabel = "Modified"; - if (changeKind === "added") { - kindColor = "var(--success-color)"; - kindLabel = "+ Added"; - } else if (changeKind === "deleted") { - kindColor = "var(--error-color)"; - kindLabel = "- Deleted"; - } else if (changeKind === "renamed") { - kindColor = "#e67e22"; - kindLabel = "→ Renamed"; - } + let kindColor = "var(--accent-color)"; + let kindLabel = "Modified"; + if (changeKind === "added") { + kindColor = "var(--success-color)"; + kindLabel = "+ Added"; + } else if (changeKind === "deleted") { + kindColor = "var(--error-color)"; + kindLabel = "- Deleted"; + } else if (changeKind === "renamed") { + kindColor = "#e67e22"; + kindLabel = "→ Renamed"; + } - const symbols = - Array.isArray(item.symbols) && item.symbols.length > 0 - ? item.symbols - .map( - (s: string) => - `${escapeHtml(s)}`, - ) - .join(" ") + const symbols = + Array.isArray(item.symbols) && item.symbols.length > 0 + ? item.symbols + .map( + (s: string) => + `${escapeHtml(s)}`, + ) + .join(" ") + : ""; + + const authorBadge = item.author + ? `👤 ${escapeHtml(item.author)}` + : ""; + const lineBadge = item.line_range + ? `${escapeHtml(item.line_range)}` : ""; - const authorBadge = item.author - ? `👤 ${escapeHtml(item.author)}` - : ""; - const lineBadge = item.line_range - ? `${escapeHtml(item.line_range)}` - : ""; - - return ` + return `
${escapeHtml(item.file_path || "Unknown")}
@@ -1134,17 +1591,76 @@ function loadLedger(): void { ${authorBadge}
`; - }); + }, + { + itemNoun: "Ledger Entries", + cardBorderColor: "#3498db", + defaultSort: "desc", + timestampExtractor: (item: any) => item.timestamp, + searchFields: (item: any) => [ + 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: any, val: string) => { + 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: any, val: string) => + (item.change_kind || "modified").toLowerCase() === + val.toLowerCase(), + }, + ], + }, + ); } + function loadTerminal(): void { loadGenericList( "/api/terminal/history", "terminal-container", (item) => { const isSuccess = item.exit_code === 0; - const statusColor = isSuccess ? "var(--success-color)" : "var(--error-color)"; - const statusBg = isSuccess ? "rgba(46, 204, 113, 0.12)" : "rgba(231, 76, 60, 0.12)"; - const durationStr = item.duration_ms != null ? (item.duration_ms < 1000 ? `${item.duration_ms}ms` : `${(item.duration_ms / 1000).toFixed(2)}s`) : ""; + const statusColor = isSuccess + ? "var(--success-color)" + : "var(--error-color)"; + const statusBg = isSuccess + ? "rgba(46, 204, 113, 0.12)" + : "rgba(231, 76, 60, 0.12)"; + const durationStr = + item.duration_ms != null + ? item.duration_ms < 1000 + ? `${item.duration_ms}ms` + : `${(item.duration_ms / 1000).toFixed(2)}s` + : ""; return `
@@ -1171,6 +1687,37 @@ function loadTerminal(): void { ` : ""} `; }, + { + itemNoun: "Commands", + cardBorderColor: "#1abc9c", + defaultSort: "desc", + timestampExtractor: (item: any) => item.timestamp, + searchFields: (item: any) => [ + 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: any, val: string) => { + if (val === "0") return item.exit_code === 0; + if (val === "err") return item.exit_code !== 0; + return true; + }, + }, + ], + }, ); } @@ -1226,6 +1773,50 @@ function loadTechDebt(): void {
`; }, + { + itemNoun: "Tech Debts", + cardBorderColor: "#e67e22", + defaultSort: "desc", + timestampExtractor: (item: any) => item.timestamp, + searchFields: (item: any) => [ + 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: any, val: string) => { + 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: any, val: string) => + String(item.severity || "").toLowerCase() === val.toLowerCase(), + }, + ], + }, ); loadGenericList( @@ -1253,48 +1844,131 @@ function loadTechDebt(): void { ${item.git_branch ? `🌿 ${escapeHtml(item.git_branch)}` : ""}
`, + { + itemNoun: "Error Fixes", + cardBorderColor: "#2ecc71", + defaultSort: "desc", + timestampExtractor: (item: any) => item.timestamp, + searchFields: (item: any) => [ + 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: any, val: string) => + String(item.error_category || "").toLowerCase() === + val.toLowerCase(), + }, + ], + }, ); } + function loadADRs(): void { loadGenericList( "/api/adrs", "adrs-container", (item) => { const statusLower = (item.status || "").toLowerCase(); - let badgeStyle = "color:var(--accent-color); border-color:rgba(88, 166, 255, 0.4); background:rgba(88, 166, 255, 0.12);"; + let badgeStyle = + "color:var(--accent-color); border-color:rgba(88, 166, 255, 0.4); background:rgba(88, 166, 255, 0.12);"; let badgeText = `● ${escapeHtml(item.status || "accepted")}`; if (statusLower === "implemented" || statusLower === "resolved") { - badgeStyle = "color:var(--success-color); border-color:rgba(46, 160, 67, 0.4); background:rgba(46, 160, 67, 0.15); font-weight:600;"; + badgeStyle = + "color:var(--success-color); border-color:rgba(46, 160, 67, 0.4); background:rgba(46, 160, 67, 0.15); font-weight:600;"; badgeText = `✓ ${escapeHtml(item.status)}`; } else if (statusLower === "superseded") { - badgeStyle = "text-decoration: line-through; color:var(--error-color); border-color:rgba(248, 81, 73, 0.4); background:rgba(248, 81, 73, 0.12);"; + badgeStyle = + "text-decoration: line-through; color:var(--error-color); border-color:rgba(248, 81, 73, 0.4); background:rgba(248, 81, 73, 0.12);"; badgeText = `✕ ${escapeHtml(item.status)}`; } else if (statusLower === "proposed" || statusLower === "draft") { - badgeStyle = "color:var(--warning-color); border-color:rgba(210, 153, 34, 0.4); background:rgba(210, 153, 34, 0.12);"; + badgeStyle = + "color:var(--warning-color); border-color:rgba(210, 153, 34, 0.4); background:rgba(210, 153, 34, 0.12);"; badgeText = `○ ${escapeHtml(item.status)}`; } - const resolvedDate = item.resolved_at ? new Date(item.resolved_at * 1000).toISOString().replace("T", " ").substring(0, 19) : ""; + const resolvedDate = item.resolved_at + ? new Date(item.resolved_at * 1000) + .toISOString() + .replace("T", " ") + .substring(0, 19) + : ""; return `
${escapeHtml(item.id)} | ${escapeHtml(item.title)} ${badgeText}
- ${(item.git_commit || item.git_branch || item.task_id || item.resolved_at) ? ` + ${ + item.git_commit || + item.git_branch || + item.task_id || + item.resolved_at + ? `
${item.git_branch || item.git_commit ? `📦 ${escapeHtml(item.git_branch || "repo")} @ ${escapeHtml((item.git_commit || "").substring(0, 8))}` : ""} ${item.task_id ? `🎯 Task: ${escapeHtml(item.task_id)}` : ""} ${resolvedDate ? `✓ Resolved: ${escapeHtml(resolvedDate)}` : ""}
- ` : ""} + ` + : "" + }
Context: ${escapeHtml(item.context || "")}
Decision: ${escapeHtml(item.decision || "")}
Consequence: ${escapeHtml(item.consequence || "")}
${item.supersedes ? `
Supersedes: ${escapeHtml(item.supersedes)}
` : ""} `; }, + { + itemNoun: "ADRs", + cardBorderColor: "#3498db", + defaultSort: "desc", + sortExtractor: (item: any) => { + const match = String(item.id || "").match(/\d+/); + if (match) return parseInt(match[0], 10); + return item.timestamp || 0; + }, + searchFields: (item: any) => [ + 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: any, val: string) => + String(item.status || "").toLowerCase() === val.toLowerCase(), + }, + ], + }, ); } @@ -1303,40 +1977,71 @@ function loadMemos(): void { "/api/handoff_memos", "memos-container", (item) => { - const blockers = Array.isArray(item.blockers) && item.blockers.length > 0 ? item.blockers : []; - const actionItems = Array.isArray(item.action_items) && item.action_items.length > 0 ? item.action_items : []; + const blockers = + Array.isArray(item.blockers) && item.blockers.length > 0 + ? item.blockers + : []; + const actionItems = + Array.isArray(item.action_items) && item.action_items.length > 0 + ? item.action_items + : []; return `
👤 Memo from ${escapeHtml(item.author || "System")} ${escapeHtml(item.namespace || "global")}
- ${(item.git_branch || item.vcs_revision || item.session_id) ? ` + ${ + item.git_branch || item.vcs_revision || item.session_id + ? `
${item.git_branch ? `🌿 ${escapeHtml(item.git_branch)}` : ""} ${item.vcs_revision ? `Rev: ${escapeHtml(item.vcs_revision.substring(0, 8))}` : ""} ${item.session_id ? `Session: ${escapeHtml(item.session_id)}` : ""}
- ` : ""} + ` + : "" + }
${escapeHtml(item.content || item.summary || "")}
- ${blockers.length > 0 ? ` + ${ + blockers.length > 0 + ? `
🚫 Blockers:
    ${blockers.map((b: string) => `
  • ${escapeHtml(b)}
  • `).join("")}
- ` : ""} - ${actionItems.length > 0 ? ` + ` + : "" + } + ${ + actionItems.length > 0 + ? `
📋 Action Items:
    ${actionItems.map((a: string) => `
  • ${escapeHtml(a)}
  • `).join("")}
- ` : ""} + ` + : "" + } `; }, + { + itemNoun: "Memos", + cardBorderColor: "#9b59b6", + defaultSort: "desc", + timestampExtractor: (item: any) => item.timestamp, + searchFields: (item: any) => [ + item.author, + item.content, + item.summary, + item.namespace, + item.git_branch, + ], + }, ); loadGenericList( @@ -1352,6 +2057,18 @@ function loadMemos(): void { ${item.tasks_completed && item.tasks_completed.length > 0 ? `
Tasks Completed:
    ${item.tasks_completed.map((t: string) => `
  • ${escapeHtml(t)}
  • `).join("")}
` : ""} ${item.next_steps && item.next_steps.length > 0 ? `
Next Steps:
    ${item.next_steps.map((s: string) => `
  • ${escapeHtml(s)}
  • `).join("")}
` : ""} `, + { + itemNoun: "Summaries", + cardBorderColor: "#34495e", + defaultSort: "desc", + timestampExtractor: (item: any) => item.timestamp, + searchFields: (item: any) => [ + item.session_id, + item.summary, + item.namespace, + item.git_branch, + ], + }, ); loadGenericList( @@ -1372,39 +2089,88 @@ function loadMemos(): void { statusLabel = "● In Progress"; } - const targetDate = item.target_date ? new Date(item.target_date * 1000).toLocaleDateString() : ""; - const completedDate = item.completed_at ? new Date(item.completed_at * 1000).toLocaleDateString() : ""; - const deliverables = Array.isArray(item.deliverables) && item.deliverables.length > 0 ? item.deliverables : []; - const taskIds = Array.isArray(item.task_ids) && item.task_ids.length > 0 ? item.task_ids : []; + const targetDate = item.target_date + ? new Date(item.target_date * 1000).toLocaleDateString() + : ""; + const completedDate = item.completed_at + ? new Date(item.completed_at * 1000).toLocaleDateString() + : ""; + const deliverables = + Array.isArray(item.deliverables) && item.deliverables.length > 0 + ? item.deliverables + : []; + const taskIds = + Array.isArray(item.task_ids) && item.task_ids.length > 0 + ? item.task_ids + : []; return `
${escapeHtml(item.title || item.name || "Milestone")} ${escapeHtml(statusLabel)}
- ${targetDate || completedDate ? ` + ${ + targetDate || completedDate + ? `
${targetDate ? `🎯 Target: ${escapeHtml(targetDate)}` : ""} ${completedDate ? `✓ Finished: ${escapeHtml(completedDate)}` : ""}
- ` : ""} + ` + : "" + }
${escapeHtml(item.description || "")}
- ${deliverables.length > 0 ? ` + ${ + deliverables.length > 0 + ? `
Deliverables:
    ${deliverables.map((d: string) => `
  • ${escapeHtml(d)}
  • `).join("")}
- ` : ""} - ${taskIds.length > 0 ? ` + ` + : "" + } + ${ + taskIds.length > 0 + ? `
Linked Tasks: ${taskIds.map((tid: string) => `${escapeHtml(tid)}`).join("")}
- ` : ""} + ` + : "" + } `; }, + { + itemNoun: "Milestones", + cardBorderColor: "#f39c12", + defaultSort: "desc", + timestampExtractor: (item: any) => item.timestamp || item.target_date, + searchFields: (item: any) => [ + 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: any, val: string) => + String(item.status || "pending").toLowerCase() === + val.toLowerCase(), + }, + ], + }, ); } @@ -1415,7 +2181,9 @@ function loadSnippets(): void { (item) => { const code = item.code || item.content || ""; const tags = Array.isArray(item.tags) ? item.tags : []; - const origin = item.origin_file ? `${item.origin_file}${item.line_range ? `:${item.line_range}` : ""}` : ""; + const origin = item.origin_file + ? `${item.origin_file}${item.line_range ? `:${item.line_range}` : ""}` + : ""; return `
@@ -1434,6 +2202,53 @@ function loadSnippets(): void {
`; }, + { + itemNoun: "Snippets", + cardBorderColor: "#27ae60", + defaultSort: "desc", + timestampExtractor: (item: any) => item.timestamp, + searchFields: (item: any) => [ + 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: any, val: string) => { + 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(): void { @@ -1525,6 +2340,23 @@ document.addEventListener("keydown", function (e: KeyboardEvent) { searchInput.focus(); searchInput.select(); } + return; + } + + // Pressing '/' focuses the current active list search input if not already typing + 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", + ) as HTMLInputElement | null; + if (activeSearch) { + e.preventDefault(); + activeSearch.focus(); + activeSearch.select(); + } } }); @@ -1576,6 +2408,8 @@ Object.assign(window, { completeTask, buildTaskTreeHTML, loadTasks, + handleTaskFilter, + handleTaskStatusFilter, highlightText, handleSearch, escapeHtml, @@ -1585,6 +2419,13 @@ Object.assign(window, { handleIncomingActivity, setupWS, loadGenericList, + onListSearchInput, + onListClearSearch, + onListFilterChange, + onListToggleSort, + onListPageSizeChange, + onListPageChange, + onListResetFilters, loadTerminal, loadLedger, loadTechDebt, diff --git a/server/src/handlers/meta.rs b/server/src/handlers/meta.rs index 2683c99..0cd979a 100644 --- a/server/src/handlers/meta.rs +++ b/server/src/handlers/meta.rs @@ -287,8 +287,8 @@ impl McpTool for LogCodeChangeHandler { author: req.author, session_id: req.session_id, vcs_type: detected_vcs, - revision: effective_rev, - branch: effective_branch, + revision: effective_rev.clone(), + branch: effective_branch.clone(), repository_root: req.repository_root, }); if ledger.len() > 500 { @@ -300,9 +300,42 @@ impl McpTool for LogCodeChangeHandler { &format!("Modified {}", req.file_path), 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!( - "Logged code change for {}: {}", - req.file_path, description + "Logged code change for {}: {}{}", + req.file_path, description, recon_suffix )) } } @@ -1170,7 +1203,11 @@ impl McpTool for GetProjectHealthHandler { let active_milestones = state.project.milestones.read_with(|milestones| { milestones .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() }); let report = serde_json::json!({ diff --git a/server/src/handlers/mod.rs b/server/src/handlers/mod.rs index 3c9db63..5efcd5b 100644 --- a/server/src/handlers/mod.rs +++ b/server/src/handlers/mod.rs @@ -5,7 +5,9 @@ pub mod graph; pub mod logs; pub mod meta; pub mod notes; +pub mod reconciliation; pub mod tasks; pub mod utils; pub mod vision; pub mod workspaces; + diff --git a/server/src/handlers/reconciliation.rs b/server/src/handlers/reconciliation.rs new file mode 100644 index 0000000..9dcb117 --- /dev/null +++ b/server/src/handlers/reconciliation.rs @@ -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, + pub message: String, + #[serde(default)] + pub author: Option, + #[serde(default)] + pub files: Vec, + #[serde(default)] + pub repo_name: Option, +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ReconciliationResult { + pub implemented_adrs: Vec, + pub resolved_tech_debts: Vec, + pub completed_tasks: Vec, + pub unblocked_tasks: Vec, + pub updated_milestones: Vec, +} + +/// Extracts ADR identifiers such as "ADR-0102", "ADR-102", "adr-005", "ADR: 102" +pub fn extract_adrs(text: &str) -> Vec { + let mut adrs = Vec::new(); + let lower = text.to_lowercase(); + let chars: Vec = text.chars().collect(); + let lower_chars: Vec = 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::() { + 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 { + let mut debts = Vec::new(); + let lower = text.to_lowercase(); + let chars: Vec = text.chars().collect(); + let lower_chars: Vec = 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 { + let mut tasks = Vec::new(); + let lower = text.to_lowercase(); + let chars: Vec = text.chars().collect(); + let lower_chars: Vec = 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 { + let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect(); + digits.parse::().ok() +} + +/// Automatically cascades unblocking to tasks whose dependencies have all completed. +pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec { + 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 { + 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, + 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 = 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()); + } +} diff --git a/server/src/handlers/tasks.rs b/server/src/handlers/tasks.rs index c22d252..5ebacdc 100644 --- a/server/src/handlers/tasks.rs +++ b/server/src/handlers/tasks.rs @@ -192,6 +192,10 @@ impl McpTool for TasksHandler { session_id: None, ..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()) } else { Err(crate::error::AppError::Internal( @@ -452,11 +456,13 @@ impl McpTool for MilestonesHandler { .as_deref() .and_then(|s| s.parse::().ok()), description: req.description, + task_ids: req.task_ids.unwrap_or_default(), deliverables: req.deliverables.unwrap_or_default(), repo_name: req.repo_name, ..Default::default() }) }); + crate::handlers::reconciliation::recalculate_milestones(&state); Ok("Milestone added".to_string()) } MilestoneAction::Update => { @@ -471,12 +477,21 @@ impl McpTool for MilestonesHandler { for m in ms.iter_mut() { if m.id == id { 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; break; } } }); if found { + crate::handlers::reconciliation::recalculate_milestones(&state); Ok("Milestone updated".to_string()) } else { Err(crate::error::AppError::Internal( diff --git a/server/src/lib.rs b/server/src/lib.rs index 536ff5a..f1a0571 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -102,10 +102,37 @@ pub async fn ttl_sweeper_worker(state: Arc) { .unwrap_or_default() .as_secs(); + let mut expired_tasks = Vec::new(); 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| { memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now)); }); diff --git a/server/src/tools.rs b/server/src/tools.rs index 951e416..436a9fb 100644 --- a/server/src/tools.rs +++ b/server/src/tools.rs @@ -739,6 +739,9 @@ pub struct MilestonesTool { pub description: Option, /// Optional deliverables or task checklist. pub deliverables: Option>, + /// Optional list of task IDs associated with this milestone. + #[serde(default)] + pub task_ids: Option>, /// Optional repository name. pub repo_name: Option, }