diff --git a/.graphlemon/runs/bfd3ee82-26e5-44d6-8acc-fdbeb3d78185/code_diff.patch b/.graphlemon/runs/bfd3ee82-26e5-44d6-8acc-fdbeb3d78185/code_diff.patch
new file mode 100644
index 0000000..7b8be65
--- /dev/null
+++ b/.graphlemon/runs/bfd3ee82-26e5-44d6-8acc-fdbeb3d78185/code_diff.patch
@@ -0,0 +1,497 @@
+diff --git a/frontend/app.js b/frontend/app.js
+index 1a5b3ec..f026060 100644
+--- a/frontend/app.js
++++ b/frontend/app.js
+@@ -115,13 +115,36 @@ function registerRouteCleanup(fn) {
+ currentRouteCleanup = fn;
+ }
+
++// U-047: `#/tasks?status=done`-style query strings on hash routes -- a new
++// capability, not a bug fix (no route previously carried a query string).
++// Hand-rolled instead of `new URLSearchParams(...)` (which mountTaskBoard's
++// own outgoing-request building already uses) because this one also has to
++// run inside frontend/test_*_smoke.js's bare `vm.createContext` sandbox,
++// which has no WHATWG URL globals -- decodeURIComponent is a plain
++// ECMAScript global and works there unmodified.
++function parseHashQuery(hash) {
++ const qIndex = hash.indexOf("?");
++ if (qIndex === -1) return { path: hash, params: {} };
++ const path = hash.slice(0, qIndex);
++ const params = {};
++ for (const pair of hash.slice(qIndex + 1).split("&")) {
++ if (!pair) continue;
++ const eqIndex = pair.indexOf("=");
++ const key = eqIndex === -1 ? pair : pair.slice(0, eqIndex);
++ const value = eqIndex === -1 ? "" : pair.slice(eqIndex + 1);
++ params[decodeURIComponent(key)] = decodeURIComponent(value);
++ }
++ return { path, params };
++}
++
+ function render() {
+ if (currentRouteCleanup) {
+ currentRouteCleanup();
+ currentRouteCleanup = null;
+ }
+
+- const hash = window.location.hash || "#/";
++ const rawHash = window.location.hash || "#/";
++ const { path: hash, params: queryParams } = parseHashQuery(rawHash);
+ let route = routes[hash];
+ let routeArgs = [];
+ if (!route) {
+@@ -154,16 +177,115 @@ function render() {
+ if (!route) route = { render: () => "
Not found
" + hash + "
" };
+
+ document.getElementById("app").innerHTML = route.render(...routeArgs);
+- if (route.mount) route.mount(...routeArgs);
+-
+- for (const a of document.querySelectorAll("#nav a")) {
+- a.classList.toggle("active", a.getAttribute("href") === hash);
++ // queryParams always appended after any route-specific captures -- every
++ // mount() so far only declares the params it actually reads (e.g.
++ // mountTaskBoard(queryParams)), so this trailing arg is silently ignored
++ // by the rest (plain JS behavior, not a special case per route).
++ if (route.mount) route.mount(...routeArgs, queryParams);
++
++ // `#nav > a`, not `#nav a`: nav_project_context (U-048) nests its own
++ // links (project name, status-bucket counts) directly inside #nav --
++ // those must never get the same "active" bolding as the fixed route
++ // links above them just because their target route happens to match.
++ for (const a of document.querySelectorAll("#nav > a")) {
++ a.classList.toggle("active", parseHashQuery(a.getAttribute("href")).path === hash);
+ }
++ refreshNavProjectContext();
+ }
+
+ window.addEventListener("hashchange", render);
+ window.addEventListener("DOMContentLoaded", render);
+
++// ---- nav bar "current project" context (U-048) ----
++// All pages share #nav (index.html), so this is wired into render() itself
++// (called once per hashchange/initial load, see above) rather than into any
++// one page's mount() -- "piggybacks on the existing hashchange listener, no
++// new event plumbing needed" per the approved SDD. Reuses
++// `GET /api/tasks?project_id=...` and reduces status counts client-side
++// (one fetch per refresh, not one per bucket) -- a dedicated aggregation
++// endpoint is a documented revisit if a single project ever reaches
++// ~1000-2000 tasks.
++
++// 10 raw task.status values (TASK_STATUSES below, v_task_board) collapsed
++// into the 4 buckets the nav bar shows. `statuses.length === 1` buckets
++// (pending/done) link to "#/tasks?status=" -- v_task_board's status
++// filter is single-select, so multi-status buckets (running/attention) link
++// to the unfiltered "#/tasks" instead of picking one arbitrary status.
++const NAV_STATUS_BUCKETS = [
++ { key: "pending", label: "pending", statuses: ["queued"] },
++ { key: "running", label: "running", statuses: [
++ "planning_brd_prd_sdd", "coding", "validating", "routed_fast_lane", "routed_full_replan",
++ ] },
++ { key: "attention", label: "attention", statuses: [
++ "planning_failed", "coding_failed", "blocked_pending_human",
++ ] },
++ { key: "done", label: "done", statuses: ["done"] },
++];
++
++function navBucketHref(bucket) {
++ return bucket.statuses.length === 1 ? "#/tasks?status=" + bucket.statuses[0] : "#/tasks";
++}
++
++function computeNavStatusBucketCounts(tasks) {
++ const statusToBucketKey = {};
++ for (const bucket of NAV_STATUS_BUCKETS) {
++ for (const status of bucket.statuses) statusToBucketKey[status] = bucket.key;
++ }
++ const counts = {};
++ for (const bucket of NAV_STATUS_BUCKETS) counts[bucket.key] = 0;
++ for (const task of tasks) {
++ const bucketKey = statusToBucketKey[task.status];
++ if (bucketKey) counts[bucketKey]++;
++ }
++ return counts;
++}
++
++function renderNavProjectContextHtml(project, counts) {
++ if (!project) return "";
++ const countsHtml = NAV_STATUS_BUCKETS.map((bucket) =>
++ ''
++ + escapeHtml(bucket.label) + " " + (counts[bucket.key] || 0) + ""
++ ).join("");
++ return (
++ '' + escapeHtml(project.name) + ""
++ + '' + countsHtml + ""
++ );
++}
++
++// Guards against out-of-order responses: if the current project is switched
++// (or this fires again before an earlier fetch resolves), a stale response
++// landing after a newer one must not overwrite the nav with old data.
++let navProjectContextRequestId = 0;
++
++async function refreshNavProjectContext() {
++ const contextEl = document.getElementById("nav_project_context");
++ if (!contextEl) return;
++ const projectId = getCurrentProjectId();
++ const requestId = ++navProjectContextRequestId;
++ if (!projectId) {
++ // Requirement 1: no project selected -> render nothing, not a hint.
++ contextEl.innerHTML = "";
++ return;
++ }
++ try {
++ const [projectResp, tasksResp] = await Promise.all([
++ fetch("/api/projects/" + encodeURIComponent(projectId)),
++ fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)),
++ ]);
++ if (requestId !== navProjectContextRequestId) return; // superseded by a newer refresh
++ if (projectResp.status !== 200) {
++ contextEl.innerHTML = "";
++ return;
++ }
++ const project = await projectResp.json();
++ const tasks = tasksResp.status === 200 ? await tasksResp.json() : [];
++ if (requestId !== navProjectContextRequestId) return;
++ contextEl.innerHTML = renderNavProjectContextHtml(project, computeNavStatusBucketCounts(tasks));
++ } catch (e) {
++ if (requestId === navProjectContextRequestId) contextEl.innerHTML = "";
++ }
++}
++
+ // ---- v_project_list (2026-08-03): project list + CRUD ----
+ // 02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an
+ // MVP non-goal -- this page is a deliberate, explicit scope expansion
+@@ -885,7 +1007,21 @@ function renderTaskBoard() {
+ `;
+ }
+
+-function mountTaskBoard() {
++// U-047/U-048: nav-bar status-bucket links land on #/tasks as
++// "?status=" -- this picks the status to pre-select, on initial
++// mount only (same reasoning as v_task_board's other filters: it's a
++// starting value the user can then change, not re-applied on every route
++// re-render). An unrecognized/stale status value is ignored rather than
++// left in a select the