From a1c24af641086e0b23ec928e45ad4bc9b9d0ff06 Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 2 Oct 2026 00:30:33 +0800
Subject: [PATCH 01/10] feat(task-graph): project a read-only Goal work map
Add the work_item.task_graph.goal_topology effect so TypeScript owns
admission, relation edges and prerequisite depth for every non-archived
Todo of one Goal, with explicit omitted, missing-endpoint and cycle
counts. Python renders the public-safe goal_task_map_v0 projection and the
cold delivery-review read returns it beside the bounded chain.
Deferred Todos now render as waiting instead of done in every lens.
Signed-off-by: huangruiteng
---
.../protocols/task-graph-projection-v0.md | 57 +++++++-
loopx/chat_status_api.py | 34 ++++-
.../control_plane/effect_runtime_handlers.ts | 3 +-
loopx/control_plane/work_items/task_graph.py | 133 ++++++++++++++++--
loopx/control_plane/work_items/task_graph.ts | 94 +++++++++++--
.../control_plane/test_task_graph_topology.py | 41 ++++++
tests/control_plane_ts/task_graph.test.ts | 61 +++++++-
tests/test_delivery_review.py | 14 +-
8 files changed, 408 insertions(+), 29 deletions(-)
diff --git a/docs/reference/protocols/task-graph-projection-v0.md b/docs/reference/protocols/task-graph-projection-v0.md
index 8f94ed83f9..866855a385 100644
--- a/docs/reference/protocols/task-graph-projection-v0.md
+++ b/docs/reference/protocols/task-graph-projection-v0.md
@@ -150,8 +150,8 @@ These are intentional corrections to the old graph, which collapsed successor
lineage into dependencies, reversed unblocks discovery, and omitted Monitor
conditions. Parallel lineage and condition edges are retained. Opaque route,
capability and unknown-condition suffixes must not be interpreted as Todo IDs
-by either graph or horizon. Existing read-only node kinds, status normalization,
-claim presentation and evidence/handoff renderers are unchanged.
+by either graph or horizon. Existing read-only node kinds, claim presentation
+and evidence/handoff renderers are unchanged.
The predecessor lens expands the selected Todo and completed predecessors;
open predecessors are visible boundaries, not recursive traversal roots.
@@ -177,6 +177,59 @@ still active, stale, repaired, or safe to hand off. They must not create a graph
resume command, mutate todo status, or replace freshness checks against current
quota, gates, claims, and run history.
+A `deferred` Todo renders as `waiting` in every lens. Deferral ends scheduling,
+not delivery, so it must never appear as `done`.
+
+### Goal work map (`goal_task_map_v0`)
+
+The predecessor lens answers "what led to this item". The Goal work map answers
+"what is the shape of this Goal's work": one read-only map of the Goal's
+non-archived Todos and every typed relation above. It ships beside `graph` in
+the cold `GET /api/chat/delivery-review?goal_id=...` response and is never
+added to status hot paths.
+
+```json
+{
+ "schema_version": "goal_task_map_v0",
+ "mode": "read_only",
+ "goal_id": "community-day",
+ "limits": {
+ "node_limit": 120, "emitted_node_count": 22, "omitted_node_count": 0,
+ "source_truncated": false, "missing_endpoint_count": 0,
+ "cycle_edge_count": 0, "topology_complete": true
+ },
+ "nodes": [{ "node_id": "node_todo_...", "kind": "deliverable", "title": "Reserve the hall",
+ "state": "blocked", "depth": 1, "refs": { "todo_ids": ["todo_..."] },
+ "owner_agent": "logistics", "task_domain": "booking" }],
+ "edges": [{ "edge_id": "edge_unblocks_...", "from_node_id": "node_todo_...",
+ "to_node_id": "node_gate_...", "relation": "depends_on",
+ "enforcement": "typed_lifecycle", "reason": "..." }]
+}
+```
+
+- Node `kind` is `deliverable`, `gate` (a user Todo) or `monitor` (a
+ `continuous_monitor` Todo). Gates carry no owner. Nodes carry no note,
+ evidence or raw Todo text beyond the compact public-safe title.
+- Edges use the relation mapping in the table above. `enforcement` reuses the
+ `planning_relations` catalog (`typed_lifecycle`, `typed_condition`,
+ `lineage_only`), so a reader can tell a lifecycle link from lineage.
+- The TS effect `work_item.task_graph.goal_topology` owns admission, edges and
+ `depth` (the longest recorded prerequisite path). Admission is deterministic:
+ unfinished work first, then direct completed prerequisites of unfinished
+ work, then remaining history, up to `node_limit` (at most 200).
+- `topology_complete` is true only when nothing was omitted, the source list
+ was not truncated, no relation names an absent Todo, and no cycle exists.
+ Cycle back edges are counted, not drawn as an order. Missing endpoints never
+ create phantom nodes.
+
+Like the predecessor lens, the map has no write authority. A drawn line is a
+recorded relation, not a readiness verdict; the resume evaluator and lifecycle
+commands keep that authority.
+
+中文:工作地图只读展示一个 Goal 的全部未归档 Todo 和已记录的类型化关系;
+连线来自记录而非推断,不代表“可以执行”。截断、缺失端点和环都必须显式计入
+`limits`,不能把不完整的图画成完整顺序。
+
## Write Boundary
`task_graph_projection_v0` has no write authority. It must never expose a graph
diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py
index 3a36941a48..550f6bce3c 100644
--- a/loopx/chat_status_api.py
+++ b/loopx/chat_status_api.py
@@ -18,12 +18,43 @@
EffectRuntimeRemoteError,
EffectRuntimeStartupError,
)
-from .control_plane.runtime.public_safety import validate_public_safe_value
+from .control_plane.runtime.public_safety import (
+ public_safe_compact_text,
+ validate_public_safe_value,
+)
+from .control_plane.todos.contract import (
+ TODO_STATUS_OPEN,
+ TODO_TASK_CLASS_MONITOR,
+ normalize_todo_status,
+ todo_done_for_status,
+)
+from .control_plane.work_items.task_graph import build_goal_task_map
from .feedback import validate_goal_id
from .history import load_registry
from .registry import registry_goals
from .status import collect_status
from .status_server import parse_goal_activation_filter
+from .todos import list_goal_todos
+
+
+def _goal_task_map(server: Any, goal_id: str) -> dict[str, Any]:
+ runtime_root = server.runtime_root_override
+ listed = list_goal_todos(
+ registry_path=server.registry_path,
+ goal_id=goal_id,
+ runtime_root_arg=str(runtime_root) if runtime_root else None,
+ )
+ todos = [row for row in listed.get("todos") or [] if isinstance(row, dict)]
+ return build_goal_task_map(
+ goal_id=goal_id,
+ todos=todos,
+ source_truncated=int(listed.get("todo_count") or 0) > len(todos),
+ public_safe_compact_text=public_safe_compact_text,
+ normalize_todo_status=normalize_todo_status,
+ todo_done_for_status=todo_done_for_status,
+ todo_status_open=TODO_STATUS_OPEN,
+ monitor_task_class=TODO_TASK_CLASS_MONITOR,
+ )
def _status_access_denied(error: BaseException) -> bool:
@@ -163,6 +194,7 @@ def _status(self, *, delivery_review: bool = False) -> None:
"goal_id": goal_id,
"observed_at": datetime.now(timezone.utc).isoformat(),
"graph": item.get("task_graph_projection"),
+ "goal_map": _goal_task_map(self.server, goal_id),
"acceptance": goal.get("acceptance_observation"),
}
validate_public_safe_value(projection)
diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts
index 0936320c26..3abcce6eb6 100644
--- a/loopx/control_plane/effect_runtime_handlers.ts
+++ b/loopx/control_plane/effect_runtime_handlers.ts
@@ -159,7 +159,7 @@ import {
reconcileRetainedActionSelection,
} from "./work_items/action_portfolio.ts";
import { projectQuotaPlanningHorizon } from "./work_items/planning_horizon.ts";
-import { projectTaskGraphTopology } from "./work_items/task_graph.ts";
+import { projectGoalTaskGraphTopology, projectTaskGraphTopology } from "./work_items/task_graph.ts";
import { projectDeliveryHistory, projectDeliveryResponse } from "./work_items/delivery_history.ts";
import { validateDeliveryClaim } from "./work_items/delivery_outcome.ts";
import {
@@ -538,6 +538,7 @@ export function createEffectRuntimeHandlers(
],
["work_item.planning_horizon.project", projectQuotaPlanningHorizon],
["work_item.task_graph.topology", projectTaskGraphTopology],
+ ["work_item.task_graph.goal_topology", projectGoalTaskGraphTopology],
["work_item.planning_inventory.project", projectTodoPlanningInventory],
["work_item.planning_inventory.detail", projectTodoPlanningInventoryDetail],
["work_item.refresh_recommendation.resolve", resolveRefreshRecommendation],
diff --git a/loopx/control_plane/work_items/task_graph.py b/loopx/control_plane/work_items/task_graph.py
index 661ad01b90..95dc628f46 100644
--- a/loopx/control_plane/work_items/task_graph.py
+++ b/loopx/control_plane/work_items/task_graph.py
@@ -6,6 +6,7 @@
from ..runtime.time import now_utc_iso
from ..effect_runtime import effect_runtime_result
+from ..todos.contract import TODO_STATUS_DEFERRED
from ..todos.summary_item import todo_planning_source_items
TASK_GRAPH_PROJECTION_SCHEMA_VERSION = "task_graph_projection_v0"
@@ -90,6 +91,9 @@ def _task_graph_todo_state(
waiting_default: bool = False,
) -> str:
status = normalize_todo_status(todo.get("status")) or todo_status_open
+ # Deferred is terminal for scheduling, but the work has not been delivered.
+ if status == TODO_STATUS_DEFERRED:
+ return "waiting"
if todo.get("done") or todo_done_for_status(status):
return "done"
if status == "blocked":
@@ -529,6 +533,27 @@ def _task_graph_attach_evidence(
)
+def _task_graph_topology_row(
+ todo_id: str,
+ value: dict[str, Any],
+ *,
+ done: bool,
+ public_safe_compact_text: Callable[..., str | None],
+) -> dict[str, Any]:
+ row: dict[str, Any] = {"todo_id": todo_id, "done": done, "successor_todo_ids": []}
+ for field in ("unblocks_todo_id", "superseded_by", "resume_when"):
+ text = public_safe_compact_text(value.get(field), limit=240)
+ if text:
+ row[field] = text
+ successors = value.get("successor_todo_ids")
+ if isinstance(successors, list):
+ row["successor_todo_ids"] = [
+ text for raw in successors
+ if (text := public_safe_compact_text(raw, limit=120))
+ ]
+ return row
+
+
def _task_graph_build_predecessor_chain(
*,
selected_todo_id: str,
@@ -556,19 +581,9 @@ def _task_graph_build_predecessor_chain(
todo_done_for_status=todo_done_for_status,
todo_status_open=todo_status_open, waiting_default=tid != selected_todo_id,
)
- row = {"todo_id": tid, "done": state == "done",
- "successor_todo_ids": []}
- for field in ("unblocks_todo_id", "superseded_by", "resume_when"):
- text = public_safe_compact_text(value.get(field), limit=240)
- if text:
- row[field] = text
- successors = value.get("successor_todo_ids")
- if isinstance(successors, list):
- row["successor_todo_ids"] = [
- text for raw in successors
- if (text := public_safe_compact_text(raw, limit=120))
- ]
- rows.append(row)
+ rows.append(_task_graph_topology_row(
+ tid, value, done=state == "done", public_safe_compact_text=public_safe_compact_text,
+ ))
result = effect_runtime_result("work_item.task_graph.topology", {
"schema_version": "task_graph_topology_request_v0",
"selected_todo_id": selected_todo_id, "items": rows,
@@ -949,3 +964,95 @@ def build_task_graph_projection(
"nodes": builder.nodes,
"edges": builder.edges,
}
+
+
+GOAL_TASK_MAP_SCHEMA_VERSION = "goal_task_map_v0"
+GOAL_TASK_MAP_MAX_NODES = 120
+
+
+def build_goal_task_map(
+ *,
+ goal_id: str,
+ todos: list[dict[str, Any]],
+ source_truncated: bool,
+ public_safe_compact_text: Callable[..., str | None],
+ normalize_todo_status: Callable[[Any], str | None],
+ todo_done_for_status: Callable[[str], bool],
+ todo_status_open: str,
+ monitor_task_class: str,
+) -> dict[str, Any]:
+ """Read-only map of one Goal's active Todos and their recorded relations."""
+
+ renderable: dict[str, tuple[dict[str, Any], str, str]] = {}
+ rows = []
+ for value in todos:
+ tid = public_safe_compact_text(value.get("todo_id"), limit=120)
+ title = public_safe_compact_text(value.get("title") or value.get("text"), limit=160)
+ if not tid or not title or tid in renderable:
+ continue
+ state = _task_graph_todo_state(
+ value,
+ normalize_todo_status=normalize_todo_status,
+ todo_done_for_status=todo_done_for_status,
+ todo_status_open=todo_status_open,
+ )
+ renderable[tid] = (value, title, state)
+ rows.append(_task_graph_topology_row(
+ tid, value, done=state == "done", public_safe_compact_text=public_safe_compact_text,
+ ))
+ result = effect_runtime_result("work_item.task_graph.goal_topology", {
+ "schema_version": "task_graph_goal_topology_request_v0",
+ "items": rows,
+ "node_limit": GOAL_TASK_MAP_MAX_NODES,
+ "source_truncated": source_truncated,
+ })
+ if not isinstance(result, dict) or result.get("schema_version") != "task_graph_goal_topology_result_v0":
+ raise RuntimeError("TypeScript goal task graph topology shape mismatch")
+ nodes: list[dict[str, Any]] = []
+ node_ids: dict[str, str] = {}
+ for entry in result["nodes"]:
+ tid = entry["todo_id"]
+ value, title, state = renderable[tid]
+ gate = value.get("role") == "user"
+ node_ids[tid] = _task_graph_node_id(
+ "node_gate" if gate else "node_todo", tid,
+ public_safe_compact_text=public_safe_compact_text, durable_id=tid,
+ )
+ node: dict[str, Any] = {
+ "node_id": node_ids[tid],
+ "kind": "gate" if gate else (
+ "monitor" if value.get("task_class") == monitor_task_class else "deliverable"
+ ),
+ "title": title,
+ "state": state,
+ "depth": entry["depth"],
+ "refs": {"todo_ids": [tid]},
+ }
+ for key, field in (("owner_agent", "claimed_by"), ("task_domain", "task_domain")):
+ text = public_safe_compact_text(value.get(field), limit=80)
+ if text and not gate:
+ node[key] = text
+ nodes.append(node)
+ edges = [
+ {
+ "edge_id": _task_graph_node_id(
+ f"edge_{edge['source_relation']}",
+ f"{node_ids[edge['from_todo_id']]}:{node_ids[edge['to_todo_id']]}",
+ public_safe_compact_text=public_safe_compact_text,
+ ),
+ "from_node_id": node_ids[edge["from_todo_id"]],
+ "to_node_id": node_ids[edge["to_todo_id"]],
+ "relation": edge["relation"],
+ "enforcement": edge["enforcement"],
+ "reason": edge["reason"],
+ }
+ for edge in result["edges"]
+ ]
+ return {
+ "schema_version": GOAL_TASK_MAP_SCHEMA_VERSION,
+ "mode": "read_only",
+ "goal_id": goal_id,
+ "limits": result["completeness"],
+ "nodes": nodes,
+ "edges": edges,
+ }
diff --git a/loopx/control_plane/work_items/task_graph.ts b/loopx/control_plane/work_items/task_graph.ts
index 1cd9f124de..e12c0bce2c 100644
--- a/loopx/control_plane/work_items/task_graph.ts
+++ b/loopx/control_plane/work_items/task_graph.ts
@@ -65,6 +65,20 @@ function edgeKey(edge: GraphEdge): string {
return [edge.from_todo_id, edge.to_todo_id, edge.source_relation].join("\0");
}
+function decodeRows(request: JsonObject): { rows: GraphRow[]; byId: Map } {
+ if (!Array.isArray(request.items)) throw new EffectRuntimeRequestError("graph items must be an array");
+ const rows = request.items.map(row);
+ const byId = new Map(rows.map(item => [item.todo_id, item]));
+ if (byId.size !== rows.length) throw new EffectRuntimeRequestError("Duplicate task graph Todo id");
+ return { rows, byId };
+}
+
+/** Every displayable relation in caller-independent order. */
+function relationEdges(rows: readonly GraphRow[]): GraphEdge[] {
+ const edges = projectRelations(rows).map(graphEdge).filter((edge): edge is GraphEdge => edge !== null);
+ return edges.sort((a, b) => edgeKey(a) < edgeKey(b) ? -1 : edgeKey(a) > edgeKey(b) ? 1 : 0);
+}
+
/** Bounded read lens over one supplied snapshot. No provider reads or writes. */
export function projectTaskGraphTopology(value: unknown): JsonObject {
const request = requireJsonObject(value, "task graph topology request");
@@ -75,21 +89,13 @@ export function projectTaskGraphTopology(value: unknown): JsonObject {
const limit = requireInteger(request.predecessor_limit, "predecessor_limit");
if (limit < 0 || limit > 32) throw new EffectRuntimeRequestError("predecessor_limit must be in 0..32");
const sourceTruncated = requireBoolean(request.source_truncated, "source_truncated");
- if (!Array.isArray(request.items)) throw new EffectRuntimeRequestError("graph items must be an array");
- const rows = request.items.map(row);
- const byId = new Map(rows.map(item => [item.todo_id, item]));
- if (byId.size !== rows.length) throw new EffectRuntimeRequestError("Duplicate task graph Todo id");
+ const { byId, rows } = decodeRows(request);
const adjacency = new Map();
- for (const relation of projectRelations(rows)) {
- const edge = graphEdge(relation);
- if (!edge) continue;
+ for (const edge of relationEdges(rows)) {
const neighbors = adjacency.get(edge.from_todo_id) ?? [];
neighbors.push(edge);
adjacency.set(edge.from_todo_id, neighbors);
}
- for (const edges of adjacency.values()) {
- edges.sort((a, b) => edgeKey(a) < edgeKey(b) ? -1 : edgeKey(a) > edgeKey(b) ? 1 : 0);
- }
const emitted = new Set();
const missing = new Set();
const omitted = new Set();
@@ -126,3 +132,71 @@ export function projectTaskGraphTopology(value: unknown): JsonObject {
},
};
}
+
+export const TASK_GRAPH_GOAL_TOPOLOGY_REQUEST = "task_graph_goal_topology_request_v0";
+export const TASK_GRAPH_GOAL_TOPOLOGY_RESULT = "task_graph_goal_topology_result_v0";
+const GOAL_NODE_LIMIT_MAX = 200;
+
+/**
+ * Whole-Goal read lens over one supplied snapshot. Unfinished work is admitted
+ * first, then completed direct prerequisites of that work, then remaining
+ * history, all in caller order. `depth` is the longest recorded prerequisite or
+ * lineage path below a Todo: a layout fact, not an execution order or readiness.
+ */
+export function projectGoalTaskGraphTopology(value: unknown): JsonObject {
+ const request = requireJsonObject(value, "goal task graph topology request");
+ if (request.schema_version !== TASK_GRAPH_GOAL_TOPOLOGY_REQUEST) {
+ throw new EffectRuntimeRequestError("Goal task graph topology request schema mismatch");
+ }
+ const limit = requireInteger(request.node_limit, "node_limit");
+ if (limit < 1 || limit > GOAL_NODE_LIMIT_MAX) {
+ throw new EffectRuntimeRequestError(`node_limit must be in 1..${GOAL_NODE_LIMIT_MAX}`);
+ }
+ const sourceTruncated = requireBoolean(request.source_truncated, "source_truncated");
+ const { byId, rows } = decodeRows(request);
+ const edges = relationEdges(rows);
+ const admitted = new Set();
+ const admit = (id: string) => { if (admitted.size < limit) admitted.add(id); };
+ for (const item of rows) if (!item.done) admit(item.todo_id);
+ const unfinished = new Set(admitted);
+ for (const edge of edges) {
+ if (unfinished.has(edge.from_todo_id) && byId.get(edge.to_todo_id)?.done) admit(edge.to_todo_id);
+ }
+ for (const item of rows) admit(item.todo_id);
+ const missing = new Set([...edges.flatMap(edge => [edge.from_todo_id, edge.to_todo_id])]
+ .filter(id => !byId.has(id)));
+ const emitted = edges.filter(edge => admitted.has(edge.from_todo_id) && admitted.has(edge.to_todo_id));
+ const outgoing = new Map();
+ for (const edge of emitted) outgoing.set(edge.from_todo_id, [...outgoing.get(edge.from_todo_id) ?? [], edge]);
+ const depth = new Map();
+ const visiting = new Set();
+ const cycleEdges = new Set();
+ const visit = (id: string): number => {
+ const known = depth.get(id);
+ if (known !== undefined) return known;
+ visiting.add(id);
+ let result = 0;
+ for (const edge of outgoing.get(id) ?? []) {
+ // A back edge would make depth unbounded; it stays in `edges` for display.
+ if (visiting.has(edge.to_todo_id)) { cycleEdges.add(edgeKey(edge)); continue; }
+ result = Math.max(result, visit(edge.to_todo_id) + 1);
+ }
+ visiting.delete(id);
+ depth.set(id, result);
+ return result;
+ };
+ const order = rows.map(item => item.todo_id).filter(id => admitted.has(id));
+ for (const id of order) visit(id);
+ const omitted = rows.length - order.length;
+ return {
+ schema_version: TASK_GRAPH_GOAL_TOPOLOGY_RESULT,
+ nodes: order.map(todo_id => ({ todo_id, depth: depth.get(todo_id)! })),
+ edges: emitted,
+ completeness: {
+ node_limit: limit, emitted_node_count: order.length, omitted_node_count: omitted,
+ source_truncated: sourceTruncated, missing_endpoint_count: missing.size,
+ cycle_edge_count: cycleEdges.size,
+ topology_complete: !sourceTruncated && omitted === 0 && missing.size === 0,
+ },
+ };
+}
diff --git a/tests/control_plane/test_task_graph_topology.py b/tests/control_plane/test_task_graph_topology.py
index 186f4ba80e..9caa57061f 100644
--- a/tests/control_plane/test_task_graph_topology.py
+++ b/tests/control_plane/test_task_graph_topology.py
@@ -121,3 +121,44 @@ def test_real_status_reader_uses_provider_snapshot_without_repair_writes(tmp_pat
assert "graph-goal" in process.stdout
assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal["id"]) == before
assert (state.read_text() if state.exists() else None) == (None if display == "missing" else source)
+
+
+def goal_map(rows, total=None):
+ from loopx.control_plane.runtime.public_safety import public_safe_compact_text
+ from loopx.control_plane.todos.contract import (
+ TODO_STATUS_OPEN, TODO_TASK_CLASS_MONITOR, normalize_todo_status, todo_done_for_status)
+ from loopx.control_plane.work_items.task_graph import build_goal_task_map
+ return build_goal_task_map(
+ goal_id="graph-goal", todos=rows, source_truncated=total is not None and total > len(rows),
+ public_safe_compact_text=public_safe_compact_text, normalize_todo_status=normalize_todo_status,
+ todo_done_for_status=todo_done_for_status, todo_status_open=TODO_STATUS_OPEN,
+ monitor_task_class=TODO_TASK_CLASS_MONITOR)
+
+
+def test_deferred_work_is_waiting_not_delivered_in_chain_and_goal_map():
+ rows = [todo("todo_root", resume_when="todo_done:todo_parent"),
+ todo("todo_parent", status="deferred", resume_when="todo_done:todo_other")]
+ chain = graph(rows)
+ assert {n["state"] for n in chain["nodes"] if n["kind"] == "deliverable"} == {"open", "waiting"}
+ assert {n["refs"]["todo_ids"][0]: n["state"] for n in goal_map(rows)["nodes"]}["todo_parent"] == "waiting"
+
+
+def test_goal_map_keeps_roles_kinds_and_only_public_fields():
+ result = goal_map([
+ todo("todo_gate", role="user", task_class="user_gate", unblocks_todo_id="todo_work",
+ claimed_by="owner", note="private planning detail"),
+ todo("todo_work", role="agent", status="blocked", claimed_by="builder", task_domain="release",
+ evidence="raw evidence text"),
+ todo("todo_watch", role="agent", task_class="continuous_monitor", claimed_by="watcher"),
+ todo("todo_done_work", role="agent", status="done", done=True, successor_todo_ids=["todo_work"]),
+ ], total=5)
+ nodes = {n["refs"]["todo_ids"][0]: n for n in result["nodes"]}
+ assert {tid: (n["kind"], n["state"], n["depth"]) for tid, n in nodes.items()} == {
+ "todo_gate": ("gate", "open", 0), "todo_work": ("deliverable", "blocked", 1),
+ "todo_watch": ("monitor", "open", 0), "todo_done_work": ("deliverable", "done", 0)}
+ assert "owner_agent" not in nodes["todo_gate"]
+ assert (nodes["todo_work"]["owner_agent"], nodes["todo_work"]["task_domain"]) == ("builder", "release")
+ assert {e["relation"] for e in result["edges"]} == {"depends_on", "continues"}
+ assert {e["enforcement"] for e in result["edges"]} == {"typed_lifecycle", "lineage_only"}
+ assert "private planning" not in json.dumps(result) and "raw evidence" not in json.dumps(result)
+ assert result["limits"]["source_truncated"] is True and result["limits"]["topology_complete"] is False
diff --git a/tests/control_plane_ts/task_graph.test.ts b/tests/control_plane_ts/task_graph.test.ts
index bbc042f9aa..fc7d8f9a33 100644
--- a/tests/control_plane_ts/task_graph.test.ts
+++ b/tests/control_plane_ts/task_graph.test.ts
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { projectTaskGraphTopology, TASK_GRAPH_TOPOLOGY_REQUEST } from "../../loopx/control_plane/work_items/task_graph.ts";
+import { projectGoalTaskGraphTopology, projectTaskGraphTopology, TASK_GRAPH_GOAL_TOPOLOGY_REQUEST,
+ TASK_GRAPH_TOPOLOGY_REQUEST } from "../../loopx/control_plane/work_items/task_graph.ts";
import { projectRelations, todoRef } from "../../loopx/control_plane/work_items/planning_relations.ts";
import type { JsonObject } from "../../loopx/control_plane/effect_program.ts";
import { productionScaleCoordinationFixture } from "./production_scale_coordination_fixture.ts";
@@ -88,6 +89,64 @@ test("invalid wire input fails at the typed boundary", () => {
assert.throws(() => graph([], {schema_version: "unknown"}), /schema/);
});
+function goalGraph(items: JsonObject[], extra: JsonObject = {}): JsonObject {
+ return projectGoalTaskGraphTopology({ schema_version: TASK_GRAPH_GOAL_TOPOLOGY_REQUEST,
+ node_limit: 50, source_truncated: false, items, ...extra });
+}
+const depths = (result: JsonObject) =>
+ Object.fromEntries((result.nodes as JsonObject[]).map(n => [n.todo_id, n.depth]));
+const pairs = (result: JsonObject) =>
+ (result.edges as JsonObject[]).map(e => [e.from_todo_id, e.to_todo_id, e.relation]);
+
+test("goal scope keeps every typed edge and layers by longest prerequisite path", () => {
+ // scope -> (venues, rota); venues -> budget; deposit unblocked by reserve; gate unblocks reserve.
+ const rows = [row("todo_scope", {successor_todo_ids: ["todo_venues", "todo_rota"]}),
+ row("todo_venues", {successor_todo_ids: ["todo_budget"]}), row("todo_rota"),
+ row("todo_budget", {done: false}), row("todo_reserve", {done: false, unblocks_todo_id: "todo_deposit"}),
+ row("todo_deposit", {done: false}), row("todo_gate", {done: false, unblocks_todo_id: "todo_reserve"}),
+ row("todo_close", {done: false, resume_when: "todo_done:todo_deposit"}), row("todo_loose", {done: false})];
+ const result = goalGraph(rows);
+ assert.deepEqual(depths(result), {todo_scope: 0, todo_venues: 1, todo_rota: 1, todo_budget: 2,
+ todo_reserve: 1, todo_deposit: 2, todo_gate: 0, todo_close: 3, todo_loose: 0});
+ assert.deepEqual(new Set(pairs(result).map(String)), new Set([
+ ["todo_venues", "todo_scope", "continues"], ["todo_rota", "todo_scope", "continues"],
+ ["todo_budget", "todo_venues", "continues"], ["todo_deposit", "todo_reserve", "depends_on"],
+ ["todo_reserve", "todo_gate", "depends_on"], ["todo_close", "todo_deposit", "depends_on"],
+ ].map(String)));
+ assert.equal((result.completeness as JsonObject).topology_complete, true);
+ assert.deepEqual(goalGraph([...rows].reverse()).edges, result.edges, "edge order is caller independent");
+});
+
+test("goal scope admits unfinished work, then its direct completed prerequisites, then history", () => {
+ const rows = [row("todo_old_a"), row("todo_old_b"), row("todo_prereq"),
+ row("todo_open", {done: false}), row("todo_next", {done: false, resume_when: "todo_done:todo_prereq"})];
+ const result = goalGraph(rows, {node_limit: 3});
+ assert.deepEqual((result.nodes as JsonObject[]).map(n => n.todo_id), ["todo_prereq", "todo_open", "todo_next"]);
+ assert.deepEqual(result.completeness, {node_limit: 3, emitted_node_count: 3, omitted_node_count: 2,
+ source_truncated: false, missing_endpoint_count: 0, cycle_edge_count: 0, topology_complete: false});
+});
+
+test("goal scope reports missing endpoints and cycles without inventing depth or edges", () => {
+ const result = goalGraph([row("todo_alpha", {done: false, resume_when: "todo_done:todo_beta"}),
+ row("todo_beta", {done: false, resume_when: "todo_done:todo_alpha"}),
+ row("todo_gamma", {done: false, resume_when: "todo_done:todo_elsewhere"}),
+ row("todo_delta", {successor_todo_ids: ["todo_gone"]}),
+ row("todo_eps", {done: false, resume_when: "capacity_available:todo_alpha"})]);
+ const completeness = result.completeness as JsonObject;
+ assert.equal(completeness.cycle_edge_count, 1);
+ assert.equal(completeness.missing_endpoint_count, 2);
+ assert.equal(completeness.topology_complete, false);
+ assert.deepEqual(pairs(result), [["todo_alpha", "todo_beta", "depends_on"], ["todo_beta", "todo_alpha", "depends_on"]]);
+ assert.deepEqual(depths(result), {todo_alpha: 1, todo_beta: 0, todo_gamma: 0, todo_delta: 0, todo_eps: 0});
+ assert.equal((goalGraph([row("todo_alpha")], {source_truncated: true}).completeness as JsonObject).topology_complete, false);
+});
+
+test("goal scope rejects invalid wire input at the typed boundary", () => {
+ assert.throws(() => goalGraph([row("todo_a"), row("todo_a")]), /Duplicate/);
+ for (const node_limit of [0, 201, 1.5]) assert.throws(() => goalGraph([], {node_limit}));
+ assert.throws(() => goalGraph([], {schema_version: TASK_GRAPH_TOPOLOGY_REQUEST}), /schema/);
+});
+
test("production-scale canonical fixture supports mixed ancestry without changing authority", () => {
const fixture = productionScaleCoordinationFixture("graph-goal");
const before = JSON.stringify(fixture.projection);
diff --git a/tests/test_delivery_review.py b/tests/test_delivery_review.py
index c0b37257a4..a960a9c0a2 100644
--- a/tests/test_delivery_review.py
+++ b/tests/test_delivery_review.py
@@ -83,7 +83,18 @@ def test_real_http_review_matches_cli_sources_without_canonical_writes(workspace
before = (registry.read_bytes(), state.read_bytes())
status, review = get(server)
assert status == 200
- assert set(review) == {"ok", "goal_id", "observed_at", "graph", "acceptance"}
+ assert set(review) == {"ok", "goal_id", "observed_at", "graph", "goal_map", "acceptance"}
+ goal_map = review["goal_map"]
+ titles = {node["refs"]["todo_ids"][0]: node for node in goal_map["nodes"]}
+ # The whole-Goal map is not limited to the bounded chain's two expanded gates.
+ assert set(titles) == {"todo_integrate", "todo_design", "todo_review", "todo_support", "todo_publish"}
+ assert [titles[t]["kind"] for t in ("todo_review", "todo_integrate")] == ["gate", "deliverable"]
+ assert titles["todo_design"]["state"] == "done" and titles["todo_design"]["depth"] == 0
+ assert titles["todo_integrate"]["depth"] == 1
+ node_todo = {node["node_id"]: tid for tid, node in titles.items()}
+ assert {(node_todo[e["from_node_id"]], node_todo[e["to_node_id"]], e["relation"]) for e in goal_map["edges"]} == {
+ ("todo_integrate", "todo_design", "continues"), ("todo_integrate", "todo_design", "depends_on")}
+ assert goal_map["limits"]["topology_complete"] is True
canonical = collect_status(registry_path=registry, runtime_root_override=str(runtime), scan_roots=[], limit=20,
goal_id="release-demo", include_public_boundary_scan=False, include_task_graph=True)
item = canonical["attention_queue"]["items"][0]
@@ -110,6 +121,7 @@ def collect(**kwargs):
_, result = get(server)
assert calls[-1]["include_task_graph"] is True
assert result["graph"] is None and result["acceptance"] is None
+ assert result["goal_map"]["schema_version"] == "goal_task_map_v0"
@pytest.mark.parametrize("query, expected", [("", 400), ("?goal_id=", 400), ("?goal_id=missing", 404),
From 2aba1bb90038c5014fed7bedf4555177175dc4a7 Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 2 Oct 2026 00:30:33 +0800
Subject: [PATCH 02/10] demo(workspace): record delivery dependencies as typed
relations
Seed successors, unblocks links and resume conditions through the todo
lifecycle so the demo Goal's dependencies are readable data instead of
note prose, and let gate approval resume blocked work through the
existing typed lifecycle.
Signed-off-by: huangruiteng
---
demo/workspace/__main__.py | 70 ++++++++++++++++++++++----------------
1 file changed, 40 insertions(+), 30 deletions(-)
diff --git a/demo/workspace/__main__.py b/demo/workspace/__main__.py
index c778186d78..44939d48a3 100644
--- a/demo/workspace/__main__.py
+++ b/demo/workspace/__main__.py
@@ -68,14 +68,17 @@ def write_story_artifacts(project: Path, story: dict, notice: str) -> None:
)
-def seed_delivery_tasks(
- story: dict, gates: dict, registry: Path, runtime: Path
-) -> list:
- todos = []
- ids = {"gate:" + key: value["todo_id"] for key, value in gates.items()}
+def seed_delivery_tasks(story: dict, registry: Path, runtime: Path) -> list:
+ """Record each story dependency as the typed relation its state allows."""
+ todos, ids = [], {}
+ by_key = {task["key"]: task for task in story["tasks"]}
+ dependents: dict[str, list[str]] = {}
+ for task in story["tasks"]:
+ if task.get("after"):
+ dependents.setdefault(task["after"], []).append(task["key"])
for task in story["tasks"]:
owner, status, title = task["agent"], task["status"], task["title"]
- dependency = ids[task["after"]] if task.get("after") else None
+ after = task.get("after")
result = checked(
add_goal_todo(
registry_path=registry,
@@ -88,26 +91,44 @@ def seed_delivery_tasks(
task_domain=task["phase"].lower().replace(" ", "-"),
claimed_by=owner,
status="open" if status == "done" else status,
- resume_when="todo_done:" + dependency if status == "deferred" else None,
- note=f"Phase: {task['phase']}. Dependency: {dependency or 'none'}. See BRIEF.md and calculations.json.",
+ resume_when="todo_done:" + ids[after] if status == "deferred" else None,
+ note=f"Phase: {task['phase']}. See BRIEF.md and calculations.json.",
)
)
- todo_id = result["todo_id"]
- ids[task["key"]] = todo_id
- if status == "done":
+ ids[task["key"]] = result["todo_id"]
+ todos.append({**task, "todo_id": result["todo_id"]})
+ for task in story["tasks"]:
+ # Deferred work already waits on its condition; the rest needs a link.
+ linked = [
+ key for key in dependents.get(task["key"], [])
+ if by_key[key]["status"] != "deferred"
+ ]
+ if task["status"] == "done":
checked(
complete_goal_todo(
registry_path=registry,
runtime_root_arg=str(runtime),
goal_id=story["id"],
- todo_id=todo_id,
- agent_id=owner,
+ todo_id=ids[task["key"]],
+ agent_id=task["agent"],
evidence="Scenario replay checkpoint; BRIEF.md, working-table.csv and calculations.json retain the planning inputs. No live execution receipt claimed.",
- no_followup=True,
+ successor_todo_ids=[ids[key] for key in linked] or None,
+ no_followup=not linked,
+ )
+ )
+ elif linked:
+ if len(linked) > 1:
+ raise ValueError(f"{task['key']} can unblock only one task")
+ checked(
+ update_goal_todo(
+ registry_path=registry,
+ runtime_root_arg=str(runtime),
+ goal_id=story["id"],
+ todo_id=ids[task["key"]],
+ agent_id=task["agent"],
+ unblocks_todo_id=ids[linked[0]],
)
)
- todos.append({**task, "todo_id": todo_id})
-
return todos
@@ -153,6 +174,8 @@ def seed_story(root: Path, story: dict, notice: str) -> dict:
)
)
write_story_artifacts(project, story, notice)
+ todos = seed_delivery_tasks(story, registry, runtime)
+ gated = {t["after"]: t["todo_id"] for t in todos if (t.get("after") or "").startswith("gate:")}
gates = {}
for decision in story["gates"]:
gate = checked(
@@ -164,6 +187,7 @@ def seed_story(root: Path, story: dict, notice: str) -> dict:
task_class="user_gate",
action_kind="approve",
blocks_agent=decision["agent"],
+ unblocks_todo_id=gated["gate:" + decision["key"]],
text="[P0] " + decision["title"],
)
)
@@ -171,7 +195,6 @@ def seed_story(root: Path, story: dict, notice: str) -> dict:
"todo_id": gate["todo_id"],
"agent": decision["agent"],
}
- todos = seed_delivery_tasks(story, gates, registry, runtime)
monitors = []
for owner, title, cadence, target in story["monitors"]:
monitor = checked(
@@ -279,19 +302,6 @@ def advance(
no_followup=True,
)
)
- for todo in story["todos"]:
- if todo["status"] == "blocked" and todo.get("after") == "gate:" + decision_key:
- checked(
- update_goal_todo(
- registry_path=registry,
- runtime_root_arg=str(root / "runtime"),
- goal_id=story_id,
- todo_id=todo["todo_id"],
- status="open",
- agent_id=todo["agent"],
- reason="Decision replay resolved this local blocker.",
- )
- )
print(
json.dumps(
{
From 7c76a1246d94d78a54a564abeea17a9f5ff1f13b Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 2 Oct 2026 00:30:33 +0800
Subject: [PATCH 03/10] feat(workspace): show the whole Goal as a work map in
Overview
Draw every task, decision and monitor with recorded relations, focus on
current work or everything, trace the selected item's lineage, open task
details in place and disclose incomplete coverage. Phones get a list.
A changed workspace now re-reads the delivery snapshot automatically
instead of showing a stale alert on load; the chain defaults to list view.
Signed-off-by: huangruiteng
---
apps/presentation/dashboard/package.json | 2 +-
.../dashboard/smoke/delivery-review-smoke.mjs | 1 +
.../dashboard/smoke/goal-work-map-smoke.mjs | 68 +++++++
.../dashboard/src/data/delivery-review.ts | 41 +++-
.../dashboard/src/data/goal-work-map.ts | 83 ++++++++
.../delivery-review-copy.ts | 36 +++-
.../personal-workspace/delivery-review.tsx | 37 +++-
.../personal-workspace/goal-work-map.css | 99 ++++++++++
.../personal-workspace/goal-work-map.tsx | 180 ++++++++++++++++++
examples/personal-workspace-browser-smoke.mjs | 2 +
.../goal-work-map.mjs | 112 +++++++++++
11 files changed, 653 insertions(+), 8 deletions(-)
create mode 100644 apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs
create mode 100644 apps/presentation/dashboard/src/data/goal-work-map.ts
create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css
create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx
create mode 100644 examples/personal-workspace-browser/goal-work-map.mjs
diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json
index 50c276b8dd..bf8a546d68 100644
--- a/apps/presentation/dashboard/package.json
+++ b/apps/presentation/dashboard/package.json
@@ -55,7 +55,7 @@
"smoke:usage-progress": "rm -rf /tmp/loopx-usage-progress-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --outDir /tmp/loopx-usage-progress-smoke smoke/usage-progress-smoke.ts src/features/personal-workspace/personal-workspace-model.ts && node /tmp/loopx-usage-progress-smoke/apps/presentation/dashboard/smoke/usage-progress-smoke.js",
"smoke:action-review-plan": "rm -rf .local/action-review-plan-smoke && tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --rootDir ../../.. --outDir .local/action-review-plan-smoke smoke/action-review-plan-smoke.ts ../../../loopx/control_plane/presentation/action_review_plan.ts src/vite-env.d.ts && node .local/action-review-plan-smoke/apps/presentation/dashboard/smoke/action-review-plan-smoke.js",
"smoke:attention-details": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --outDir /tmp/loopx-attention-details-smoke smoke/attention-details-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-attention-details-smoke/apps/presentation/dashboard/smoke/attention-details-smoke.js",
- "smoke:delivery-review": "tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/delivery-review src/data/delivery-review.ts src/data/goal-acceptance-observation.ts src/features/personal-workspace/delivery-review-copy.ts && node smoke/delivery-review-smoke.mjs",
+ "smoke:delivery-review": "tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/delivery-review src/data/delivery-review.ts src/data/goal-work-map.ts src/data/goal-acceptance-observation.ts src/features/personal-workspace/delivery-review-copy.ts && node smoke/delivery-review-smoke.mjs",
"smoke:team-artifact-comparison": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/team-comparison src/features/personal-workspace/team-artifact-comparison.ts src/vite-env.d.ts && node smoke/team-artifact-comparison-smoke.mjs",
"smoke:team-report": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --jsx react-jsx --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/team-report src/features/personal-workspace/team-artifact-content.tsx src/vite-env.d.ts && node smoke/team-report-smoke.mjs",
"build:chat:vite": "tsc --noEmit && vite build --config vite.chat.config.ts",
diff --git a/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs b/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs
index 5a6c00786d..aaec8ce379 100644
--- a/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs
@@ -64,4 +64,5 @@ for (const copy of Object.values(deliveryReviewCopy)) {
assert.ok(markdown.includes(`${copy.guards}: ${copy.unavailable}`), "Unavailable decisions must not export as zero pending");
}
console.log("delivery review: identity, scope, relationships, partial coverage, filtering, export and negative contracts passed");
+await import("./goal-work-map-smoke.mjs");
await import("./goal-acceptance-contract-smoke.mjs");
diff --git a/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs b/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs
new file mode 100644
index 0000000000..67529a2a11
--- /dev/null
+++ b/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs
@@ -0,0 +1,68 @@
+import assert from "node:assert/strict";
+import { deliveryReviewMarkdown, parseDeliveryReview } from "../node_modules/.cache/delivery-review/data/delivery-review.js";
+import { goalWorkMapIncomplete, goalWorkMapLayout, goalWorkMapLineage, goalWorkMapSummary, goalWorkMapTone } from "../node_modules/.cache/delivery-review/data/goal-work-map.js";
+import { deliveryReviewCopy } from "../node_modules/.cache/delivery-review/features/personal-workspace/delivery-review-copy.js";
+
+const node = (id, kind, state, depth) => ({ node_id: id, kind, title: `Title ${id}`, state, depth, refs: { todo_ids: [`todo_${id}`] } });
+const edge = (from, to, relation = "depends_on", enforcement = "typed_lifecycle") =>
+ ({ edge_id: `${from}_${to}_${relation}`, from_node_id: from, to_node_id: to, relation, enforcement, reason: "Recorded relation" });
+// A decision gates two blocked tasks; finished history feeds open work through
+// two relations between one pair; a watch has no recorded link.
+const map = {
+ schema_version: "goal_task_map_v0", mode: "read_only", goal_id: "map-demo",
+ limits: { node_limit: 120, emitted_node_count: 7, omitted_node_count: 0, source_truncated: false, missing_endpoint_count: 0, cycle_edge_count: 0, topology_complete: true },
+ nodes: [node("gate", "gate", "open", 0), node("reserve", "deliverable", "blocked", 1), node("deposit", "deliverable", "blocked", 2),
+ node("scope", "deliverable", "done", 0), node("venues", "deliverable", "done", 1), node("budget", "deliverable", "open", 2),
+ node("watch", "monitor", "open", 0)],
+ edges: [edge("reserve", "gate"), edge("deposit", "reserve"), edge("venues", "scope", "continues", "lineage_only"),
+ edge("budget", "venues", "continues", "lineage_only"), edge("budget", "venues", "depends_on", "typed_condition")],
+};
+const snapshot = { ok: true, goal_id: "map-demo", observed_at: "2026-09-01T00:00:00Z", graph: null, goal_map: map, acceptance: null };
+const parsed = parseDeliveryReview(snapshot, "map-demo").goal_map;
+const ids = nodes => nodes.map(item => item.node_id);
+
+assert.deepEqual(goalWorkMapSummary(parsed), { work: 5, done: 2, blocked: 2, waiting: 0, decisions: 1, watches: 1 });
+assert.equal(goalWorkMapTone(node("x", "gate", "done", 0)), "done", "A decided gate no longer asks for a decision");
+assert.equal(goalWorkMapTone(node("x", "deliverable", "ready", 0)), "open");
+
+const current = goalWorkMapLayout(parsed, "current");
+assert.deepEqual(current.columns.map(ids), [["gate"], ["reserve", "venues"], ["deposit", "budget"]], "Rows follow placed prerequisites");
+assert.equal(current.hiddenCount, 1, "Older history beyond direct prerequisites is hidden, not dropped");
+assert.deepEqual(ids(current.unlinked), ["watch"]);
+assert.equal(current.edges.length, 4, "Parallel relations between one pair survive layout");
+const all = goalWorkMapLayout(parsed, "all");
+assert.deepEqual(all.columns.map(ids), [["gate", "scope"], ["reserve", "venues"], ["deposit", "budget"]], "Decisions lead finished work at equal depth");
+assert.equal(all.hiddenCount, 0);
+const gap = goalWorkMapLayout({ ...parsed, nodes: [node("a", "deliverable", "done", 0), node("b", "deliverable", "done", 1), node("c", "deliverable", "open", 2)],
+ edges: [edge("b", "a"), edge("c", "b")] }, "current");
+assert.deepEqual(gap.columns.map(ids), [["b"], ["c"]], "Hidden depths leave no empty columns");
+
+const lineage = goalWorkMapLineage(parsed.edges, "reserve");
+assert.deepEqual([...lineage.nodes].sort(), ["deposit", "gate", "reserve"], "Lineage follows only recorded links");
+assert.equal(lineage.edges.size, 2);
+assert.equal(goalWorkMapLineage(parsed.edges, "budget").edges.size, 3, "Transitive prerequisites are traced");
+assert.equal(goalWorkMapLineage(parsed.edges, null).nodes.size, 0);
+
+assert.equal(goalWorkMapIncomplete(parsed), false);
+assert.equal(goalWorkMapIncomplete({ ...parsed, limits: { ...parsed.limits, topology_complete: false } }), true);
+assert.equal(goalWorkMapIncomplete({ ...parsed, limits: { ...parsed.limits, cycle_edge_count: 1 } }), true, "A cycle is never drawn as a complete order");
+
+for (const mutation of [
+ { ...map, goal_id: "other-goal" },
+ { ...map, nodes: [...map.nodes, map.nodes[0]] },
+ { ...map, edges: [...map.edges, map.edges[0]] },
+ { ...map, edges: [{ ...map.edges[0], to_node_id: "missing" }] },
+ { ...map, edges: [{ ...map.edges[0], relation: "blocks" }] },
+ { ...map, nodes: [{ ...map.nodes[0], kind: "evidence" }] },
+ { ...map, mode: "writable" },
+]) assert.throws(() => parseDeliveryReview({ ...snapshot, goal_map: mutation }, "map-demo"));
+
+for (const copy of Object.values(deliveryReviewCopy)) {
+ const markdown = deliveryReviewMarkdown(parseDeliveryReview(snapshot, "map-demo"), copy);
+ assert.ok(markdown.includes(`## ${copy.workMap.title}`) && markdown.includes(copy.workMap.boundary));
+ assert.ok(markdown.includes('"topology_complete": true'), "Exports keep coverage limits next to the map");
+ assert.ok(markdown.includes(`Title budget → ${copy.workMap.relation.depends_on} → Title venues`)
+ && markdown.includes(`Title budget → ${copy.workMap.relation.continues} → Title venues`));
+ assert.ok(!deliveryReviewMarkdown(parseDeliveryReview({ ...snapshot, goal_map: null }, "map-demo"), copy).includes(`## ${copy.workMap.title}`));
+}
+console.log("goal work map: summary, focus layout, lineage, coverage, export and negative contracts passed");
diff --git a/apps/presentation/dashboard/src/data/delivery-review.ts b/apps/presentation/dashboard/src/data/delivery-review.ts
index 92f7d14a0f..bf886b7cd7 100644
--- a/apps/presentation/dashboard/src/data/delivery-review.ts
+++ b/apps/presentation/dashboard/src/data/delivery-review.ts
@@ -38,19 +38,49 @@ const graphSchema = z.object({
context.addIssue({ code: "custom", message: "Graph identities or endpoints are invalid" });
}
});
+const goalMapNodeSchema = z.object({
+ node_id: z.string().min(1), kind: z.enum(["deliverable", "gate", "monitor"]), title: z.string(),
+ state: nodeSchema.shape.state, depth: z.number().int().nonnegative(), refs: refsSchema,
+ owner_agent: z.string().optional(), task_domain: z.string().optional(),
+});
+const goalMapSchema = z.object({
+ schema_version: z.literal("goal_task_map_v0"), mode: z.literal("read_only"), goal_id: z.string(),
+ limits: z.object({
+ node_limit: z.number().int().positive(), emitted_node_count: z.number().int().nonnegative(),
+ omitted_node_count: z.number().int().nonnegative(), source_truncated: z.boolean(),
+ missing_endpoint_count: z.number().int().nonnegative(), cycle_edge_count: z.number().int().nonnegative(),
+ topology_complete: z.boolean(),
+ }),
+ nodes: z.array(goalMapNodeSchema),
+ edges: z.array(z.object({
+ edge_id: z.string().min(1), from_node_id: z.string(), to_node_id: z.string(),
+ relation: z.enum(["depends_on", "continues", "supersedes"]),
+ enforcement: z.enum(["typed_lifecycle", "typed_condition", "lineage_only"]), reason: z.string(),
+ })),
+}).superRefine((map, context) => {
+ const nodes = new Set(map.nodes.map(node => node.node_id));
+ if (nodes.size !== map.nodes.length || new Set(map.edges.map(edge => edge.edge_id)).size !== map.edges.length
+ || map.edges.some(edge => !nodes.has(edge.from_node_id) || !nodes.has(edge.to_node_id))) {
+ context.addIssue({ code: "custom", message: "Goal map identities or endpoints are invalid" });
+ }
+});
const snapshotSchema = z.object({
ok: z.literal(true), goal_id: z.string(), observed_at: z.string().datetime({ offset: true }),
- graph: graphSchema.nullable(), acceptance: goalAcceptanceObservationSchema.nullable(),
+ graph: graphSchema.nullable(), goal_map: goalMapSchema.nullable().optional(),
+ acceptance: goalAcceptanceObservationSchema.nullable(),
});
export type DeliveryReviewSnapshot = z.infer;
export type ReviewGraph = NonNullable;
export type ReviewNode = ReviewGraph["nodes"][number];
export type ReviewRelation = ReviewGraph["edges"][number]["relation"];
export type ReviewFocus = "all" | "conditions" | "evidence" | "related";
+type GoalMapNode = z.infer;
+type GoalMapEdge = z.infer["edges"][number];
export function parseDeliveryReview(value: unknown, goalId: string): DeliveryReviewSnapshot {
const result = snapshotSchema.parse(value);
if (result.goal_id !== goalId || (result.graph && result.graph.goal_id !== goalId)
+ || (result.goal_map && result.goal_map.goal_id !== goalId)
|| (result.acceptance && result.acceptance.goal_id !== goalId)) {
throw new Error("Review source does not match the selected Goal");
}
@@ -108,6 +138,7 @@ export type ReviewExportLabels = {
owner: string; reason: string; historical: string; checks: string; missingSources: string; observedScope: string;
kind: Record; state: Record;
relation: Record;
+ workMap: { title: string; boundary: string; kind: Record; relation: Record } & Record;
};
/** Export the entire validated snapshot, never the search-filtered screen. */
@@ -130,6 +161,14 @@ export function deliveryReviewMarkdown(snapshot: DeliveryReviewSnapshot, labels:
rows.push("", `## ${labels.relations}`, "");
for (const edge of graph.edges) rows.push(`- ${line(names.get(edge.from_node_id))} → ${labels.relation[edge.relation]} → ${line(names.get(edge.to_node_id))}: ${line(edge.reason)} (${line(edge.edge_id)})`, ` ${labels.refs}: ${line(JSON.stringify(edge.refs ?? {}))}`);
}
+ const map = snapshot.goal_map;
+ if (map) {
+ rows.push("", `## ${labels.workMap.title}`, "", labels.workMap.boundary, "", "```json", JSON.stringify(map.limits, null, 2), "```", "");
+ const names = new Map(map.nodes.map(node => [node.node_id, node.title]));
+ for (const node of map.nodes) rows.push(`- ${line(node.title)} · ${labels.workMap.kind[node.kind]} · ${labels.state[node.state]}${node.owner_agent ? ` · ${line(node.owner_agent)}` : ""} (${line(node.refs.todo_ids?.join(", "))})`);
+ if (map.edges.length) rows.push("");
+ for (const edge of map.edges) rows.push(`- ${line(names.get(edge.from_node_id))} → ${labels.workMap.relation[edge.relation]} → ${line(names.get(edge.to_node_id))}`);
+ }
rows.push("", `## ${labels.acceptance}`, "");
const acceptance = snapshot.acceptance;
if (!acceptance) rows.push(labels.unavailable);
diff --git a/apps/presentation/dashboard/src/data/goal-work-map.ts b/apps/presentation/dashboard/src/data/goal-work-map.ts
new file mode 100644
index 0000000000..70de3c7798
--- /dev/null
+++ b/apps/presentation/dashboard/src/data/goal-work-map.ts
@@ -0,0 +1,83 @@
+import type { DeliveryReviewSnapshot } from "./delivery-review.js";
+
+export type GoalWorkMap = NonNullable;
+export type GoalWorkMapNode = GoalWorkMap["nodes"][number];
+export type GoalWorkMapEdge = GoalWorkMap["edges"][number];
+export type GoalWorkMapFocus = "current" | "all";
+export type GoalWorkMapTone = "decision" | "blocked" | "open" | "waiting" | "done" | "unknown";
+
+/** Visual emphasis only. It never decides readiness, ownership or completion. */
+export function goalWorkMapTone(node: GoalWorkMapNode): GoalWorkMapTone {
+ if (node.kind === "gate" && node.state !== "done") return "decision";
+ if (node.state === "ready") return "open";
+ return node.state;
+}
+
+const toneRank: Record = { decision: 0, blocked: 1, open: 2, waiting: 3, unknown: 4, done: 5 };
+
+export function goalWorkMapSummary(map: GoalWorkMap) {
+ const work = map.nodes.filter(node => node.kind === "deliverable");
+ const count = (state: GoalWorkMapNode["state"]) => work.filter(node => node.state === state).length;
+ return {
+ work: work.length, done: count("done"), blocked: count("blocked"), waiting: count("waiting"),
+ decisions: map.nodes.filter(node => goalWorkMapTone(node) === "decision").length,
+ watches: map.nodes.filter(node => node.kind === "monitor" && node.state !== "done").length,
+ };
+}
+
+/**
+ * Columns follow the projected prerequisite depth. "current" keeps unfinished
+ * items plus their direct recorded prerequisites; it hides history, not facts.
+ */
+export function goalWorkMapLayout(map: GoalWorkMap, focus: GoalWorkMapFocus) {
+ const unfinished = new Set(map.nodes.filter(node => node.state !== "done").map(node => node.node_id));
+ const visible = new Set(focus === "all" ? map.nodes.map(node => node.node_id) : unfinished);
+ if (focus === "current") for (const edge of map.edges) if (unfinished.has(edge.from_node_id)) visible.add(edge.to_node_id);
+ const edges = map.edges.filter(edge => visible.has(edge.from_node_id) && visible.has(edge.to_node_id));
+ const linked = new Set(edges.flatMap(edge => [edge.from_node_id, edge.to_node_id]));
+ const order = new Map(map.nodes.map((node, index) => [node.node_id, index]));
+ const byTone = (a: GoalWorkMapNode, b: GoalWorkMapNode) =>
+ toneRank[goalWorkMapTone(a)] - toneRank[goalWorkMapTone(b)] || order.get(a.node_id)! - order.get(b.node_id)!;
+ const shown = map.nodes.filter(node => visible.has(node.node_id));
+ const placed = shown.filter(node => linked.has(node.node_id));
+ const depths = [...new Set(placed.map(node => node.depth))].sort((a, b) => a - b);
+ const row = new Map();
+ const columns = depths.map(depth => {
+ // Order by the mean row of placed prerequisites to keep curves short.
+ const center = (node: GoalWorkMapNode) => {
+ const rows = edges.filter(edge => edge.from_node_id === node.node_id && row.has(edge.to_node_id)).map(edge => row.get(edge.to_node_id)!);
+ return rows.length ? rows.reduce((sum, value) => sum + value, 0) / rows.length : Number.POSITIVE_INFINITY;
+ };
+ const column = placed.filter(node => node.depth === depth)
+ .map(node => ({ node, center: center(node) }))
+ .sort((a, b) => a.center - b.center || byTone(a.node, b.node)).map(entry => entry.node);
+ column.forEach((node, index) => row.set(node.node_id, index));
+ return column;
+ });
+ return { columns, edges, unlinked: shown.filter(node => !linked.has(node.node_id)).sort(byTone), hiddenCount: map.nodes.length - shown.length };
+}
+
+/** Every recorded prerequisite above and dependent below one item. */
+export function goalWorkMapLineage(edges: readonly GoalWorkMapEdge[], nodeId: string | null) {
+ const nodes = new Set(nodeId ? [nodeId] : []);
+ const related = new Set();
+ for (const [from, to] of [["from_node_id", "to_node_id"], ["to_node_id", "from_node_id"]] as const) {
+ const queue = nodeId ? [nodeId] : [];
+ const seen = new Set(queue);
+ while (queue.length) {
+ const current = queue.shift()!;
+ for (const edge of edges) {
+ if (edge[from] !== current) continue;
+ related.add(edge.edge_id);
+ nodes.add(edge[to]);
+ if (!seen.has(edge[to])) { seen.add(edge[to]); queue.push(edge[to]); }
+ }
+ }
+ }
+ return { nodes, edges: related };
+}
+
+export function goalWorkMapIncomplete(map: GoalWorkMap) {
+ const limits = map.limits;
+ return !limits.topology_complete || limits.cycle_edge_count > 0;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts
index b32baac3e2..36b0ee3e57 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts
@@ -30,7 +30,7 @@ export type GoalAcceptanceContractCopy = typeof goalAcceptanceContractCopy.en;
const en = {
contract: goalAcceptanceContractCopy.en,
- title: "Delivery & evidence", scope: "Current work and a limited set of predecessors. Use Tasks for the full task inventory.",
+ title: "Delivery & evidence", scope: "The delivery chain covers current work, a few predecessors and their evidence. The work map covers every task that is not archived.",
observed: "Snapshot read", chain: "Delivery chain", relations: "Relationships", acceptance: "Acceptance observations",
acceptanceBoundary: "Completed tasks and recorded evidence do not certify Goal acceptance.",
noGraph: "No delivery chain is available in this snapshot. This does not mean all work is complete.",
@@ -51,11 +51,27 @@ const en = {
openTask: "Open task", openGate: "Review decision", openRun: "Open execution",
sourceUnavailable: "The linked item is not in the current workspace. Use its reference in the task board or CLI.",
omittedGates: "Decisions not expanded", missing: "Missing predecessors", clipped: "Expansion limited", sourceClipped: "Source truncated", yes: "Yes", no: "No", chainOnly: "Bounded chain", exportFailed: "Download failed. Please retry.",
+ updating: "Workspace facts changed. Updating this snapshot…",
+ workMap: {
+ title: "Work map", canvas: "Work map canvas", focus: "Map focus", current: "Current work", all: "Everything",
+ zoom: "Map zoom", zoomOut: "Zoom out", zoomIn: "Zoom in", fit: "Fit",
+ boundary: "Arrows follow recorded task links. The map shows relationships; it does not decide what can run or whether the Goal is accepted.",
+ tasksDone: "tasks done", blocked: "blocked", waiting: "waiting", decisions: "need your decision", watches: "watching",
+ hidden: "completed earlier, hidden", unlinked: "Not linked to other work", empty: "This Goal has no tasks yet.",
+ unavailable: "This source does not provide a work map.",
+ kind: { deliverable: "Task", gate: "Decision", monitor: "Watch" },
+ tone: { decision: "Needs you", blocked: "Blocked", open: "Open", waiting: "Waiting", done: "Done", unknown: "Unknown" },
+ before: "Before this", after: "After this", nothingBefore: "No recorded prerequisites.", nothingAfter: "Nothing recorded depends on this yet.",
+ relation: { depends_on: "Required", continues: "Follow-up", supersedes: "Replaces" },
+ legend: "Line styles", open: "Open details", noSource: "Details for this item are not loaded in the workspace.",
+ select: "Select an item to trace what it needs and what it leads to.", selected: "Selected item",
+ incomplete: "Some work is not on this map.", omitted: "Not shown", missingEnds: "Linked outside this Goal or archived", truncated: "Source truncated", cycles: "Circular links",
+ },
} satisfies ReviewExportLabels & Record;
const zh: typeof en = {
contract: goalAcceptanceContractCopy["zh-CN"],
- title: "交付与依据", scope: "仅含当前工作及有限前序,完整任务清单见任务页。",
+ title: "交付与依据", scope: "交付链仅含当前工作、少量前序及其证据;工作地图包含全部未归档任务。",
observed: "快照读取时间", chain: "交付链", relations: "关联关系", acceptance: "验收观察",
acceptanceBoundary: "任务完成、已有证据均不等于 Goal 已通过验收。",
noGraph: "当前快照没有可展示的交付链,这不代表工作已经全部完成。",
@@ -76,5 +92,21 @@ const zh: typeof en = {
openTask: "打开任务", openGate: "查看决策", openRun: "打开执行",
sourceUnavailable: "关联事项未出现在当前工作区,可使用其引用到任务看板或 CLI 查找。",
omittedGates: "未展开决策", missing: "缺失前序", clipped: "展开受限", sourceClipped: "来源被裁剪", yes: "是", no: "否", chainOnly: "当前局部链", exportFailed: "下载失败,请重试。",
+ updating: "工作区状态已变化,正在更新此快照…",
+ workMap: {
+ title: "工作地图", canvas: "工作地图画布", focus: "地图范围", current: "当前工作", all: "全部",
+ zoom: "地图缩放", zoomOut: "缩小", zoomIn: "放大", fit: "适配",
+ boundary: "箭头来自已记录的任务关联。地图只展示关系,不决定哪些工作可以执行,也不代表 Goal 已通过验收。",
+ tasksDone: "项任务已完成", blocked: "受阻", waiting: "等待", decisions: "需你决策", watches: "持续监控",
+ hidden: "项早期已完成工作已隐藏", unlinked: "未与其他工作关联", empty: "此 Goal 还没有任务。",
+ unavailable: "此来源不提供工作地图。",
+ kind: { deliverable: "任务", gate: "决策", monitor: "监控" },
+ tone: { decision: "需要你", blocked: "受阻", open: "待处理", waiting: "等待", done: "已完成", unknown: "未知" },
+ before: "之前", after: "之后", nothingBefore: "未记录前置工作。", nothingAfter: "暂无记录依赖此项的工作。",
+ relation: { depends_on: "必需", continues: "延续", supersedes: "替代" },
+ legend: "连线样式", open: "打开详情", noSource: "此事项的详情未加载到工作区。",
+ select: "选择一项,查看它依赖什么、又会带来什么。", selected: "选中事项",
+ incomplete: "部分工作未出现在此地图中。", omitted: "未显示", missingEnds: "关联到其他 Goal 或已归档", truncated: "来源被裁剪", cycles: "循环关联",
+ },
};
export const deliveryReviewCopy = { en, "zh-CN": zh };
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx
index 7d7a70dff5..2a0bd368be 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx
@@ -4,6 +4,8 @@ import { deliveryReviewMarkdown, fetchDeliveryReview, filterReviewNodes, reviewC
import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceModel, WorkspaceTimelineItem } from "./personal-workspace-model";
import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card";
import { GoalAcceptanceContractSection } from "./goal-acceptance-contract";
+import { GoalWorkMapView } from "./goal-work-map";
+import type { GoalWorkMapNode } from "../../data/goal-work-map";
import { deliveryReviewCopy } from "./delivery-review-copy";
import { useWorkspaceI18n } from "./i18n";
import "./delivery-review.css";
@@ -59,7 +61,9 @@ export function DeliveryReview({ goal, items, userTodos, onSelect, active }: Del
const [query, setQuery] = useState("");
const [focus, setFocus] = useState("all");
const [selectedId, setSelectedId] = useState(null);
- const [mapView, setMapView] = useState(() => window.matchMedia("(min-width: 1024px)").matches);
+ // The work map is the spatial overview; the bounded chain defaults to its list.
+ const [mapView, setMapView] = useState(false);
+ const [mapSelectedId, setMapSelectedId] = useState(null);
const [feedback, setFeedback] = useState("");
const detailRef = useRef(null);
const scopedAttention = userTodos.filter(todo => todo.goalId === goal.goalId);
@@ -81,6 +85,12 @@ export function DeliveryReview({ goal, items, userTodos, onSelect, active }: Del
}, [goal.goalId, refresh, active]);
const snapshot = state.snapshot?.goal_id === goal.goalId ? state.snapshot : null;
const stale = Boolean(snapshot) && state.sourceKey !== sourceKey;
+ useEffect(() => {
+ // Re-read once workspace facts settle instead of leaving a stale snapshot.
+ if (!active || state.kind !== "ready" || !stale) return;
+ const timer = window.setTimeout(() => setRefresh(value => value + 1), 600);
+ return () => window.clearTimeout(timer);
+ }, [active, state.kind, stale, sourceKey]);
const usable = Boolean(snapshot) && state.kind === "ready" && !stale;
const graph = snapshot?.graph;
const selected = graph?.nodes.find(node => node.node_id === selectedId);
@@ -104,6 +114,21 @@ export function DeliveryReview({ goal, items, userTodos, onSelect, active }: Del
];
}
const sources = selected ? linkedSources(selected) : [];
+ function mapSource(node: GoalWorkMapNode): WorkspaceDrawerSelection | null {
+ const id = node.refs.todo_ids?.[0];
+ if (!id) return null;
+ if (node.kind === "gate") {
+ const item = scopedAttention.find(todo => todo.todoId === id);
+ return item ? { kind: "attention", item } : null;
+ }
+ // History outside the status window still opens with the projected facts only.
+ const todo = goal.agentTodos.find(item => item.todoId === id) ?? {
+ todoId: id, text: node.title, done: node.state === "done", claimedBy: node.owner_agent ?? null,
+ status: node.state === "waiting" ? "deferred" : node.state === "blocked" || node.state === "done" ? node.state : "open",
+ taskDomain: node.task_domain ?? null, taskClass: node.kind === "monitor" ? "continuous_monitor" : null,
+ };
+ return { kind: "todo", item: { ...todo, goalId: goal.goalId, goalTitle: goal.title, ownerLabel: todo.claimedBy } };
+ }
function download() {
if (!snapshot || !usable) return;
let url: string | undefined;
@@ -124,9 +149,13 @@ export function DeliveryReview({ goal, items, userTodos, onSelect, active }: Del
{feedback ? {feedback}
: null}
{!snapshot ? {state.kind === "error" ? copy.error : copy.loading}
: <>
- {state.kind !== "ready" ? {state.kind === "error" ? copy.refreshError : copy.loading}
: null}
- {copy.observed} · {new Date(snapshot.observed_at).toLocaleString(locale)}
- {stale ? {copy.changed}
: null}
+ {state.kind === "error" ? {copy.refreshError}
: null}
+ {copy.observed} · {new Date(snapshot.observed_at).toLocaleString(locale)}
+ {state.kind === "loading" || (stale && state.kind !== "error") ? · {copy.updating} : null}
+ {stale && state.kind === "error" ? {copy.changed}
: null}
+ {snapshot.goal_map ? usable && mapSource(node) !== null} onOpen={node => { const source = mapSource(node); if (source) onSelect(source); }} />
+ : snapshot.goal_map === null ? {copy.workMap.unavailable}
: null}
{copy.scope} {copy.acceptanceBoundary}
{graph ? <>
{reviewCoverageIncomplete(graph) ? {copy.incomplete}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css
new file mode 100644
index 0000000000..5fdc31ff02
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css
@@ -0,0 +1,99 @@
+.work-map { display: grid; gap: 0; min-width: 0; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); overflow: hidden; }
+.work-map h3, .work-map h4, .work-map h5, .work-map p { margin: 0; }
+.work-map button { font: inherit; color: inherit; cursor: pointer; }
+.work-map :is(button, [tabindex]):focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; }
+.work-map-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; padding: 16px 16px 12px; }
+.work-map-toolbar h3 { font-size: 16px; line-height: 24px; font-weight: 600; }
+.work-map-summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; color: var(--pw-muted); font-size: 13px; }
+.work-map-summary strong { color: var(--pw-text); font-variant-numeric: tabular-nums; font-weight: 600; margin-right: -8px; }
+.work-map-summary span { font-variant-numeric: tabular-nums; }
+.work-map-summary [data-tone="decision"] { color: var(--pw-blue-ink, var(--pw-blue)); font-weight: 500; }
+.work-map-summary [data-tone="blocked"] { color: var(--pw-amber); }
+.work-map-controls { display: flex; flex-wrap: wrap; gap: 8px; }
+.work-map-controls [role="group"][hidden] { display: none; }
+.work-map-controls [role="group"] { display: inline-flex; border: 1px solid var(--pw-line); border-radius: 8px; padding: 2px; gap: 2px; }
+.work-map .work-map-controls button { display: inline-flex; align-items: center; justify-content: center; min-width: 36px; min-height: 32px; padding: 4px 10px; border: 0; border-radius: 6px; background: transparent; font-size: 12px; color: var(--pw-muted); }
+.work-map .work-map-controls button[aria-pressed="true"] { background: var(--pw-bg); color: var(--pw-text); box-shadow: 0 0 0 1px var(--pw-line); }
+.work-map .work-map-controls button:disabled { opacity: .4; cursor: not-allowed; }
+.work-map-notice { margin: 0 16px 12px; padding: 8px 12px; border: 1px solid var(--pw-line); border-left: 3px solid var(--pw-amber); border-radius: 6px; font-size: 12px; }
+.work-map-notice summary { cursor: pointer; }
+.work-map-notice dl { display: flex; flex-wrap: wrap; gap: 4px 20px; margin: 8px 0 0; }
+.work-map-notice dl > div { display: flex; gap: 6px; }
+.work-map-notice dt { color: var(--pw-muted); }
+.work-map-notice dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; }
+.work-map-scroll { position: relative; overflow: auto; max-height: 560px; overscroll-behavior: contain; border-block: 1px solid var(--pw-line);
+ background-color: var(--pw-bg); background-image: radial-gradient(circle, var(--pw-line) 1px, transparent 1.2px); background-size: 20px 20px; cursor: grab; }
+.work-map-scroll[hidden] { display: none; }
+.work-map-scroll:active { cursor: grabbing; }
+.work-map-stage { position: relative; }
+.work-map-canvas { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
+.work-map-canvas > svg { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
+.work-map-canvas > svg path { fill: none; stroke: var(--pw-line-strong, var(--pw-muted)); stroke-width: 1.5; transition: stroke .16s ease, opacity .16s ease; }
+.work-map svg path[data-relation="continues"] { stroke-dasharray: 2 4; stroke-linecap: round; }
+.work-map svg path[data-relation="supersedes"] { stroke-dasharray: 8 4; }
+.work-map-canvas > svg path[data-related] { stroke: var(--pw-text); stroke-width: 2; }
+.work-map-canvas > svg path[data-dimmed] { opacity: .25; }
+.work-map-arrow path { fill: var(--pw-line-strong, var(--pw-muted)); stroke: none; }
+.work-map-arrow.is-hot path { fill: var(--pw-text); }
+.work-map .work-map-node { position: absolute; margin: 0; min-height: 0; color: var(--pw-text); align-items: stretch; justify-content: stretch; width: 176px; height: 88px; display: grid; grid-template-rows: auto 1fr auto; gap: 4px; padding: 8px 10px; text-align: left;
+ border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-card); box-shadow: 0 1px 2px rgb(0 0 0 / .04);
+ transition: border-color .16s ease, opacity .16s ease, box-shadow .16s ease, transform .16s ease; }
+.work-map .work-map-node:hover { border-color: var(--pw-line-strong, var(--pw-muted)); box-shadow: 0 4px 12px rgb(0 0 0 / .06); }
+.work-map .work-map-node[aria-pressed="true"] { border-color: var(--pw-text); box-shadow: 0 0 0 1px var(--pw-text), 0 6px 16px rgb(0 0 0 / .08); }
+.work-map .work-map-node[data-dimmed] { opacity: .38; }
+.work-map-node-meta svg { flex: none; }
+.work-map-node-meta { display: flex; flex: none; align-items: center; gap: 4px; font-size: 11px; line-height: 14px; color: var(--pw-muted); min-width: 0; }
+.work-map-node-meta em { margin-left: auto; font-style: normal; font-weight: 500; white-space: nowrap; }
+.work-map .work-map-node > strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; max-height: 32px; font-size: 12.5px; line-height: 16px; font-weight: 500; overflow-wrap: anywhere; }
+.work-map .work-map-node > small { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 11px; line-height: 14px; color: var(--pw-muted); white-space: nowrap; overflow: hidden; }
+.work-map .work-map-node > small i { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; border-radius: 50%; background: var(--pw-bg); border: 1px solid var(--pw-line); font-style: normal; font-size: 9px; font-weight: 600; color: var(--pw-text); }
+.work-map .work-map-node > small span { overflow: hidden; text-overflow: ellipsis; }
+.work-map .work-map-node > small span:not(:first-child)::before { content: "· "; }
+.work-map .work-map-node[data-tone="decision"] { border-color: var(--pw-blue); background: var(--pw-blue-soft, var(--pw-card)); }
+.work-map .work-map-node[data-tone="decision"] .work-map-node-meta { color: var(--pw-blue-ink, var(--pw-blue)); }
+.work-map .work-map-node[data-tone="blocked"] { box-shadow: inset 3px 0 0 var(--pw-amber), 0 1px 2px rgb(0 0 0 / .04); }
+.work-map .work-map-node[data-tone="blocked"] .work-map-node-meta em { color: var(--pw-amber); }
+.work-map .work-map-node[data-tone="waiting"] { border-style: dashed; }
+.work-map .work-map-node[data-tone="done"] { background: var(--pw-bg); }
+.work-map .work-map-node[data-tone="done"] > strong { color: var(--pw-muted); font-weight: 400; }
+.work-map .work-map-node[data-tone="done"] .work-map-node-meta em { color: var(--pw-green, var(--pw-muted)); }
+.work-map-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; padding: 10px 16px; font-size: 12px; color: var(--pw-muted); }
+.work-map-footer p { flex: 1 1 320px; line-height: 1.5; }
+.work-map-legend { display: inline-flex; gap: 12px; }
+.work-map-legend > span { display: inline-flex; align-items: center; gap: 6px; }
+.work-map-legend path { stroke: var(--pw-muted); stroke-width: 1.5; fill: none; }
+.work-map .work-map-hidden { border: 1px solid var(--pw-line); border-radius: 999px; background: var(--pw-card); padding: 2px 10px; font-size: 12px; color: var(--pw-muted); min-height: 28px; }
+.work-map-unlinked { display: grid; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--pw-line); }
+.work-map-unlinked h4 { font-size: 12px; font-weight: 500; color: var(--pw-muted); }
+.work-map-unlinked > div { display: flex; flex-wrap: wrap; gap: 8px; }
+.work-map .work-map-unlinked .work-map-node { position: static; }
+.work-map-inspector { display: grid; gap: 12px; padding: 16px; border-top: 1px solid var(--pw-line); font-size: 13px; }
+.work-map-inspector > p, .work-map-inspector header p, .work-map-relations p { color: var(--pw-muted); }
+.work-map-inspector header { display: grid; gap: 4px; }
+.work-map-inspector header span { font-size: 12px; color: var(--pw-muted); }
+.work-map-inspector header span[data-tone="decision"] { color: var(--pw-blue-ink, var(--pw-blue)); }
+.work-map-inspector header span[data-tone="blocked"] { color: var(--pw-amber); }
+.work-map-inspector h4 { font-size: 15px; line-height: 22px; font-weight: 600; overflow-wrap: anywhere; }
+.work-map-relations { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+.work-map-relations h5 { font-size: 12px; font-weight: 500; color: var(--pw-muted); margin-bottom: 6px; }
+.work-map-relations ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
+.work-map .work-map-relations li button { width: 100%; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 36px; padding: 6px 10px; text-align: left; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); }
+.work-map .work-map-relations li button:hover { border-color: var(--pw-line-strong, var(--pw-muted)); }
+.work-map-relations li span { overflow-wrap: anywhere; }
+.work-map-relations li em, .work-map-relations li small { font-size: 11px; font-style: normal; color: var(--pw-muted); white-space: nowrap; }
+.work-map-relations li em[data-relation="depends_on"] { color: var(--pw-text); font-weight: 500; }
+.work-map .work-map-open { justify-self: start; display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); }
+.work-map-empty { padding: 24px 16px; color: var(--pw-muted); }
+.work-map-list { list-style: none; margin: 0; padding: 12px 16px; display: grid; gap: 10px; border-top: 1px solid var(--pw-line); }
+.work-map-list li { display: grid; gap: 4px; }
+.work-map .work-map-list .work-map-node { position: static; width: 100%; height: auto; min-height: 72px; }
+.work-map-list p { font-size: 12px; color: var(--pw-muted); padding-left: 12px; border-left: 2px solid var(--pw-line); overflow-wrap: anywhere; }
+.work-map-list p span { color: var(--pw-text); font-weight: 500; }
+@media (max-width: 720px) {
+ .work-map-toolbar { padding: 12px; }
+ .work-map-relations { grid-template-columns: 1fr; }
+ .work-map .work-map-unlinked .work-map-node { width: 100%; height: auto; min-height: 72px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .work-map .work-map-node, .work-map-canvas > svg path { transition: none; }
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx
new file mode 100644
index 0000000000..b544707b9b
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx
@@ -0,0 +1,180 @@
+import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
+import { Circle, CircleCheck, CircleDashed, ExternalLink, Eye, Hand, OctagonAlert, ZoomIn, ZoomOut } from "lucide-react";
+import { goalWorkMapLayout, goalWorkMapLineage, goalWorkMapIncomplete, goalWorkMapSummary, goalWorkMapTone,
+ type GoalWorkMap, type GoalWorkMapEdge, type GoalWorkMapFocus, type GoalWorkMapNode, type GoalWorkMapTone } from "../../data/goal-work-map";
+import type { deliveryReviewCopy } from "./delivery-review-copy";
+import "./goal-work-map.css";
+
+type Copy = (typeof deliveryReviewCopy)["en"]["workMap"];
+const CARD_W = 176, CARD_H = 88, COLUMN = 212, ROW = 102, PAD = 20;
+const relationRank: Record = { depends_on: 0, supersedes: 1, continues: 2 };
+const toneIcon: Record = {
+ decision: Hand, blocked: OctagonAlert, open: Circle, waiting: CircleDashed, done: CircleCheck, unknown: Circle,
+};
+
+function NodeCard({ node, copy, selected, dimmed, style, onSelect }: {
+ node: GoalWorkMapNode; copy: Copy; selected: boolean; dimmed: boolean; style?: CSSProperties; onSelect: (id: string) => void;
+}) {
+ const tone = goalWorkMapTone(node);
+ const Icon = node.kind === "monitor" && tone !== "done" ? Eye : toneIcon[tone];
+ const owner = node.owner_agent;
+ return onSelect(node.node_id)}>
+ {copy.kind[node.kind]}{copy.tone[tone]}
+ {node.title}
+ {owner ? <>{owner.slice(0, 1).toUpperCase()} {owner}> : null}{node.task_domain ? {node.task_domain} : null}
+ ;
+}
+
+export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOpen }: {
+ map: GoalWorkMap; copy: Copy; selectedId: string | null; onSelect: (id: string | null) => void;
+ onOpen: (node: GoalWorkMapNode) => void; canOpen: (node: GoalWorkMapNode) => boolean;
+}) {
+ const marker = useId().replace(/:/g, "");
+ const [focus, setFocus] = useState("current");
+ const [scale, setScale] = useState(null);
+ const scrollRef = useRef(null);
+ const inspectorRef = useRef(null);
+ const [available, setAvailable] = useState(0);
+ const [compact, setCompact] = useState(() => window.matchMedia("(max-width: 720px)").matches);
+ useEffect(() => {
+ const query = window.matchMedia("(max-width: 720px)");
+ const update = () => setCompact(query.matches);
+ query.addEventListener("change", update);
+ return () => query.removeEventListener("change", update);
+ }, []);
+ const layout = useMemo(() => goalWorkMapLayout(map, focus), [map, focus]);
+ const summary = goalWorkMapSummary(map);
+ const nodeById = new Map(map.nodes.map(node => [node.node_id, node]));
+ const position = new Map(layout.columns.flatMap((column, x) => column.map((node, y) => [node.node_id, { x: PAD + x * COLUMN, y: PAD + y * ROW }] as const)));
+ const width = PAD * 2 + Math.max(0, layout.columns.length * COLUMN - (COLUMN - CARD_W));
+ const height = PAD * 2 + Math.max(1, ...layout.columns.map(column => column.length)) * ROW - (ROW - CARD_H);
+ const fit = available ? Math.min(1, available / width) : 1;
+ // Fit when the whole map stays legible; otherwise keep full size and scroll.
+ const zoom = scale ?? (fit >= 0.86 ? fit : 1);
+ const selected = selectedId ? nodeById.get(selectedId) ?? null : null;
+ const lineage = goalWorkMapLineage(layout.edges, selected && position.has(selected.node_id) ? selected.node_id : null);
+ const strokes = useMemo(() => {
+ const pairs = new Map();
+ for (const edge of layout.edges) pairs.set(`${edge.from_node_id}\0${edge.to_node_id}`, [...pairs.get(`${edge.from_node_id}\0${edge.to_node_id}`) ?? [], edge]);
+ return [...pairs.values()].map(group => group.sort((a, b) => relationRank[a.relation] - relationRank[b.relation]));
+ }, [layout.edges]);
+ useLayoutEffect(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+ const observer = new ResizeObserver(() => setAvailable(element.clientWidth));
+ observer.observe(element);
+ setAvailable(element.clientWidth);
+ return () => observer.disconnect();
+ }, []);
+ useEffect(() => setScale(null), [focus]);
+ useEffect(() => {
+ if (selectedId) window.requestAnimationFrame(() => inspectorRef.current?.scrollIntoView({ block: "nearest" }));
+ }, [selectedId]);
+ const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null);
+ const startPan = (event: ReactPointerEvent) => {
+ const element = scrollRef.current;
+ if (!element || event.pointerType !== "mouse" || event.button !== 0 || (event.target as HTMLElement).closest("button")) return;
+ drag.current = { x: event.clientX, y: event.clientY, left: element.scrollLeft, top: element.scrollTop };
+ element.setPointerCapture(event.pointerId);
+ };
+ const pan = (event: ReactPointerEvent) => {
+ const element = scrollRef.current, origin = drag.current;
+ if (!element || !origin) return;
+ element.scrollLeft = origin.left - (event.clientX - origin.x);
+ element.scrollTop = origin.top - (event.clientY - origin.y);
+ };
+ const before = selected ? map.edges.filter(edge => edge.from_node_id === selected.node_id) : [];
+ const after = selected ? map.edges.filter(edge => edge.to_node_id === selected.node_id) : [];
+ const relationList = (edges: GoalWorkMapEdge[], end: "from_node_id" | "to_node_id", empty: string) => edges.length
+ ? {edges.map(edge => { const target = nodeById.get(edge[end])!; return
+ onSelect(target.node_id)}>{copy.relation[edge.relation]} {target.title} {copy.tone[goalWorkMapTone(target)]}
+ ; })}
+ : {empty}
;
+ const limits = map.limits;
+ return
+
+
+
{copy.title}
+
+ {summary.done}/{summary.work} {copy.tasksDone}
+ {summary.decisions ? {summary.decisions} {copy.decisions} : null}
+ {summary.blocked ? {summary.blocked} {copy.blocked} : null}
+ {summary.waiting ? {summary.waiting} {copy.waiting} : null}
+ {summary.watches ? {summary.watches} {copy.watches} : null}
+
+
+
+
+ {(["current", "all"] as const).map(value => setFocus(value)}>{copy[value]} )}
+
+
+ setScale(Math.max(0.5, Math.round((zoom - 0.1) * 10) / 10))}>
+ setScale(fit)}>{copy.fit}
+ = 1.25} onClick={() => setScale(Math.min(1.25, Math.round((zoom + 0.1) * 10) / 10))}>
+
+
+
+ {goalWorkMapIncomplete(map) ? {copy.incomplete}
+
{copy.omitted} {limits.omitted_node_count}
+
{copy.missingEnds} {limits.missing_endpoint_count}
+
{copy.truncated} {limits.source_truncated ? "✓" : "–"}
+
{copy.cycles} {limits.cycle_edge_count}
+ : null}
+ {!map.nodes.length ? {copy.empty}
: <>
+ {compact ? {layout.columns.flat().map(node => {
+ const needs = layout.edges.filter(edge => edge.from_node_id === node.node_id).map(edge => nodeById.get(edge.to_node_id)!.title);
+ return
+ {needs.length ? {copy.before} {[...new Set(needs)].join(" · ")}
: null} ;
+ })} : null}
+ { drag.current = null; }} onPointerCancel={() => { drag.current = null; }}>
+ {layout.columns.length ?
+
+
+ {["base", "hot"].map(kind => )}
+ {strokes.map(group => {
+ const edge = group[0];
+ const from = position.get(edge.to_node_id)!, to = position.get(edge.from_node_id)!;
+ const sx = from.x + CARD_W, sy = from.y + CARD_H / 2, ex = to.x, ey = to.y + CARD_H / 2;
+ const bend = Math.max(28, (ex - sx) / 2);
+ const d = ex > sx ? `M ${sx} ${sy} C ${sx + bend} ${sy}, ${ex - bend} ${ey}, ${ex} ${ey}`
+ : `M ${from.x + CARD_W / 2} ${from.y + CARD_H} C ${from.x + CARD_W / 2} ${from.y + CARD_H + 48}, ${to.x + CARD_W / 2} ${to.y + CARD_H + 48}, ${to.x + CARD_W / 2} ${to.y + CARD_H}`;
+ const related = group.some(item => lineage.edges.has(item.edge_id));
+ return ;
+ })}
+
+ {layout.columns.flat().map(node =>
)}
+
+
: null}
+
+
+ {!selected ? {copy.select}
: <>
+
+
+
{copy.before} {relationList(before, "to_node_id", copy.nothingBefore)}
+
{copy.after} {relationList(after, "from_node_id", copy.nothingAfter)}
+
+ {canOpen(selected) ? onOpen(selected)}> {copy.open} : {copy.noSource}
}
+ >}
+
+
+
+ {(["depends_on", "continues"] as const).map(relation => {copy.relation[relation]} )}
+
+ {layout.hiddenCount ? setFocus("all")}>{layout.hiddenCount} {copy.hidden} : null}
+ {copy.boundary}
+
+ {layout.unlinked.length ?
+ {copy.unlinked}
+ {layout.unlinked.map(node => )}
+ : null}
+ >}
+ ;
+}
diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs
index 1d140ffe65..153e1b826c 100644
--- a/examples/personal-workspace-browser-smoke.mjs
+++ b/examples/personal-workspace-browser-smoke.mjs
@@ -46,6 +46,7 @@ import { newestDraftScenario } from "./personal-workspace-browser/newest-draft.m
import { conversationInputScenario } from "./personal-workspace-browser/conversation-input.mjs";
import { goalActivityScenario } from "./personal-workspace-browser/goal-activity.mjs";
+import { goalWorkMapScenario } from "./personal-workspace-browser/goal-work-map.mjs";
import { stewardGroupTriggerScenario } from "./personal-workspace-browser/steward-group-trigger.mjs";
@@ -60,6 +61,7 @@ const scenarioCatalog = [conversationStartupScenario,goalDraftScenario, capabili
scenarioCatalog.push(confirmedOperationsScenario);
scenarioCatalog.push(workspaceViewRecoveryScenario);
scenarioCatalog.push(monitorReadbackScenario);
+scenarioCatalog.push(goalWorkMapScenario);
const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO;
const scenarios = requestedScenario
? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario)
diff --git a/examples/personal-workspace-browser/goal-work-map.mjs b/examples/personal-workspace-browser/goal-work-map.mjs
new file mode 100644
index 0000000000..88e0ecf8ed
--- /dev/null
+++ b/examples/personal-workspace-browser/goal-work-map.mjs
@@ -0,0 +1,112 @@
+import assert from "node:assert/strict";
+import { resolve } from "node:path";
+import { outputDir } from "./fixture.mjs";
+import { openWorkspacePage } from "./scenario-context.mjs";
+
+const node = (id, kind, title, state, depth, extra = {}) =>
+ ({ node_id: `node_${id}`, kind, title, state, depth, refs: { todo_ids: [`todo_map_${id}`] }, ...extra });
+const edge = (from, to, relation, enforcement) =>
+ ({ edge_id: `edge_${from}_${to}_${relation}`, from_node_id: `node_${from}`, to_node_id: `node_${to}`, relation, enforcement, reason: "Recorded task relation." });
+
+// A decision gates booking work, completed history feeds an open task, and a
+// watch has no recorded link. Only these typed edges may appear as lines.
+function goalMap(goalId, limits = {}) {
+ return {
+ schema_version: "goal_task_map_v0", mode: "read_only", goal_id: goalId,
+ limits: { node_limit: 120, emitted_node_count: 7, omitted_node_count: 0, source_truncated: false,
+ missing_endpoint_count: 0, cycle_edge_count: 0, topology_complete: true, ...limits },
+ nodes: [
+ node("gate", "gate", "Approve the venue hold", "open", 0),
+ node("reserve", "deliverable", "Reserve the hall", "blocked", 1, { owner_agent: "logistics", task_domain: "booking" }),
+ node("deposit", "deliverable", "Release the deposit", "blocked", 2, { owner_agent: "finance" }),
+ node("scope", "deliverable", "Agree the event scope", "done", 0, { owner_agent: "producer" }),
+ node("venues", "deliverable", "Compare three venues", "done", 1, { owner_agent: "logistics" }),
+ node("budget", "deliverable", "Reprice catering", "open", 2, { owner_agent: "finance" }),
+ node("watch", "monitor", "Check registration totals", "open", 0, { owner_agent: "logistics" }),
+ ],
+ edges: [
+ edge("reserve", "gate", "depends_on", "typed_lifecycle"),
+ edge("deposit", "reserve", "depends_on", "typed_lifecycle"),
+ edge("venues", "scope", "continues", "lineage_only"),
+ edge("budget", "venues", "continues", "lineage_only"),
+ edge("budget", "venues", "depends_on", "typed_condition"),
+ ],
+ };
+}
+
+export const goalWorkMapScenario = {
+ id: "goal-work-map",
+ async run({ browser, collectCoverage, url }) {
+ let limits = {};
+ const reads = [];
+ const routeReview = (_api, page) => page.route("**/api/chat/delivery-review?*", route => {
+ const goalId = new URL(route.request().url()).searchParams.get("goal_id");
+ reads.push(goalId);
+ return route.fulfill({ json: { ok: true, goal_id: goalId, observed_at: new Date().toISOString(), graph: null, goal_map: goalMap(goalId, limits), acceptance: null } });
+ });
+ const desktop = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto: routeReview });
+ const { page } = desktop;
+ await page.locator(".personal-goal-link").first().click();
+ await page.getByRole("button", { name: "概览", exact: true }).click();
+ const map = page.locator(".work-map");
+ await map.getByRole("heading", { name: "工作地图" }).waitFor();
+ const canvas = map.getByRole("region", { name: "工作地图画布" });
+ const titles = locator => locator.locator(".work-map-node strong").allInnerTexts();
+
+ assert.deepEqual(new Set(await titles(canvas)), new Set(["Approve the venue hold", "Reserve the hall", "Release the deposit",
+ "Compare three venues", "Reprice catering"]), "Current work keeps unfinished items and their direct prerequisites");
+ assert.equal(await canvas.locator("path[marker-end]").count(), 3, "Two relations between one pair draw one line; none are invented");
+ assert.deepEqual(await titles(map.getByRole("region", { name: "未与其他工作关联" })), ["Check registration totals"]);
+ assert.match(await map.locator(".work-map-summary").innerText(), /2\/5\s+项任务已完成.*1 需你决策.*2 受阻.*1 持续监控/s);
+ await map.getByRole("button", { name: "1 项早期已完成工作已隐藏" }).click();
+ assert.equal(await canvas.locator(".work-map-node").count(), 6);
+ assert.equal(await canvas.locator("path[marker-end]").count(), 4);
+ await map.getByRole("button", { name: "当前工作", exact: true }).click();
+
+ await canvas.focus();
+ await page.keyboard.press("Tab");
+ await page.keyboard.press("Enter");
+ assert.equal(await canvas.locator('.work-map-node[aria-pressed="true"] strong').innerText(), "Approve the venue hold", "Keyboard reaches and selects the first node");
+ const inspector = map.getByRole("region", { name: "选中事项" });
+ await inspector.getByText("此事项的详情未加载到工作区。").waitFor();
+ assert.equal(await inspector.getByRole("button", { name: "打开详情" }).count(), 0, "An unloaded decision is never opened as an agent task");
+
+ await canvas.locator(".work-map-node", { hasText: "Reserve the hall" }).click();
+ assert.deepEqual(await inspector.locator(".work-map-relations > div").evaluateAll(columns => columns.map(column =>
+ [...column.querySelectorAll("li span")].map(span => span.textContent))), [["Approve the venue hold"], ["Release the deposit"]]);
+ const dimmed = await canvas.locator(".work-map-node[data-dimmed] strong").allInnerTexts();
+ assert.deepEqual(new Set(dimmed), new Set(["Compare three venues", "Reprice catering"]), "Selection traces only recorded lineage");
+ await page.screenshot({ path: resolve(outputDir, "goal-work-map.png"), animations: "disabled" });
+ await inspector.getByRole("button", { name: "打开详情" }).click();
+ const drawer = page.getByRole("dialog", { name: "Todo 详情" });
+ await drawer.getByRole("heading", { name: "Reserve the hall" }).waitFor();
+ assert.match(await drawer.innerText(), /logistics/);
+ await page.getByRole("button", { name: /关闭详情/ }).click();
+ assert.equal(await canvas.locator('.work-map-node[aria-pressed="true"] strong').innerText(), "Reserve the hall", "Closing details returns to the same selection");
+
+ limits = { omitted_node_count: 3, topology_complete: false };
+ await page.getByRole("button", { name: "刷新快照", exact: true }).click();
+ await map.getByText("部分工作未出现在此地图中。").waitFor();
+ assert.equal(await page.locator("[role=alert]", { hasText: "工作区状态已在此快照之后变化" }).count(), 0);
+ assert.ok(reads.length <= 4, `Delivery review re-read ${reads.length} times`);
+ assert.deepEqual(desktop.errors, []);
+ const coverageEntries = await desktop.close();
+
+ const phone = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto: routeReview, viewport: { width: 390, height: 844 }, isMobile: true });
+ const navigation = phone.page.getByRole("button", { name: "打开 Goal 导航" });
+ if (await navigation.isVisible()) await navigation.click();
+ await phone.page.locator(".personal-goal-link").first().click();
+ await phone.page.getByRole("button", { name: "概览", exact: true }).click();
+ const list = phone.page.locator(".work-map-list");
+ await list.waitFor();
+ assert.equal(await list.locator("li").count(), 5);
+ assert.match(await list.locator("li", { hasText: "Release the deposit" }).innerText(), /之前 Reserve the hall/);
+ assert.equal(await phone.page.locator(".work-map-scroll").isVisible(), false);
+ assert.equal(await phone.page.getByRole("group", { name: "地图缩放" }).isVisible(), false);
+ assert.equal(await phone.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, "No page-level horizontal overflow");
+ await list.scrollIntoViewIfNeeded();
+ await phone.page.screenshot({ path: resolve(outputDir, "goal-work-map-mobile.png"), animations: "disabled" });
+ coverageEntries.push(...await phone.close());
+ return { coverageEntries, note: "Work map shows typed links only, traces lineage, opens details, discloses incomplete coverage and collapses to a list on phones." };
+ },
+};
From 2345fc33135dc57bc3d3d16b148f98e13c7bfa15 Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 2 Oct 2026 00:30:33 +0800
Subject: [PATCH 04/10] docs: plan the Work-scale map track and document the
work map
Record W1 (Goal work map) with W2 live state and W3 outputs in the Live
Team Workspace RFC, point roadmap S5 at the track, and teach the Overview
work map, refresh behavior and CLI readback in the user guide.
Signed-off-by: huangruiteng
---
.../rfcs/live-team-workspace-v0.md | 22 ++++++++++++-
.../rfcs/live-team-workspace-v0.zh-CN.md | 16 +++++++++-
.../rfcs/loopx-overall-roadmap-v0.md | 2 +-
.../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 2 +-
docs/guides/personal-workspace-user-guide.md | 31 ++++++++++++++++---
5 files changed, 64 insertions(+), 9 deletions(-)
diff --git a/docs/architecture/rfcs/live-team-workspace-v0.md b/docs/architecture/rfcs/live-team-workspace-v0.md
index fa32b5961f..af9fe27308 100644
--- a/docs/architecture/rfcs/live-team-workspace-v0.md
+++ b/docs/architecture/rfcs/live-team-workspace-v0.md
@@ -450,6 +450,25 @@ The [near-term local-agent launch](loopx-overall-roadmap-v0.md#near-term-local-a
| L2 Continuous research | Original research Agent completes V4 and the user replays correction→adoption→next cycle | Runtime credential/admission repairs and usable L1; qualify each host profile, then V4 | Existing R2/R3 owners; stop new admission and retain results |
| L3 Semantic zoom and scale | Subteams, source/conclusion version comparison and focused replay at measured 10/30/100 display load | Typed lineage producers and L2; V5/V7 and approved first-screen composition | Presentation + evidence owner; reduce detail/revert renderer |
+**Work-scale map track.** Semantic zoom needs a whole-Goal Work view to zoom
+from. This track renders existing typed Todo relations only. It runs beside L1
+and never advances L1, V4 or G1:
+
+- **W1 Goal work map** (checkpoint 2026-10-01): the Overview draws every
+ non-archived Todo of one Goal with its recorded relations
+ ([`goal_task_map_v0`](../../reference/protocols/task-graph-projection-v0.md#goal-work-map-goal_task_map_v0)).
+ It switches between current work and everything, traces selected lineage
+ (interaction 5), opens Todo details in place, discloses omitted/missing/cyclic
+ coverage and becomes a list on phones. A packaged synthetic scenario covers the
+ desktop, 390px and keyboard paths of V3. A disposable demo Goal exercises the
+ real cold delivery-review read; live team Goals remain unqualified.
+- **W2 Live state on the map:** show running turns, owed returns and stale
+ observations on the same nodes from the existing execution observation owner,
+ without a new polling loop; disconnection shows age, not activity.
+- **W3 Outputs on the map:** attach produced artifacts, acceptance and adoption
+ from the L1 evidence owners to their Todo, with version comparison. L3 then
+ collapses W-maps by subteam.
+
The next executable implementation is **L1**, a complete observation-to-user
vertical with the complete Section 9 correction episode. Include missing
version/adoption producers, source collection, typed projection, reconnect
@@ -493,7 +512,8 @@ It launches no Agents and reads no private research data. It is not shipped in
the product or used as live runtime evidence.
**Current checkpoint:** L0 design is merged; Section 9 records the open L1
-implementation candidate. Full L1 and L2–L3 remain unqualified. V1/V3 may be
+implementation candidate. W1 of the Work-scale map track is proposed in Section
+11; W2–W3 are planned. Full L1 and L2–L3 remain unqualified. V1/V3 may be
explored with the synthetic study; V2/V4/V5/V6/V7 remain unqualified until their
required production or measured evidence exists. No G1/G3/G4 promotion follows.
The exact delivery PR carries validation and review; roadmap pointers retain
diff --git a/docs/architecture/rfcs/live-team-workspace-v0.zh-CN.md b/docs/architecture/rfcs/live-team-workspace-v0.zh-CN.md
index bc73f53415..b9ca10ae7f 100644
--- a/docs/architecture/rfcs/live-team-workspace-v0.zh-CN.md
+++ b/docs/architecture/rfcs/live-team-workspace-v0.zh-CN.md
@@ -348,6 +348,20 @@ V5 候选目标在实验前定稿:前台动态目标 60fps / 优雅降级底
| L2 持续研究 | 原投研 Agent 完成 V4,用户回放纠偏→采用→下一轮 | runtime 凭证 / 准入修复及可用 L1;逐 profile 资格化后 V4 | 既有 R2/R3 owner;停新准入、保留结果 |
| L3 语义缩放与规模 | 子团队、来源 / 结论版本对比、实测 10/30/100 展示负载下的聚焦回放 | typed 谱系 producer 与 L2;V5/V7、批准首屏编排 | Presentation + evidence owner;降细节 / 回退 renderer |
+**工作尺度地图分线。** 语义缩放需要先有一个可缩放的整 Goal 工作视图。本分线只
+渲染既有的类型化 Todo 关系,与 L1 并行,不推进 L1、V4 或 G1:
+
+- **W1 Goal 工作地图**(检查点 2026-10-01):概览页画出一个 Goal 全部未归档
+ Todo 及已记录关系([`goal_task_map_v0`](../../reference/protocols/task-graph-projection-v0.md#goal-work-map-goal_task_map_v0)),
+ 可在“当前工作”与“全部”间切换、追踪选中事项的谱系(交互 5)、原地打开 Todo
+ 详情、显式披露遗漏 / 缺失 / 成环,并在手机上改为列表。打包前端合成场景覆盖
+ V3 的桌面、390px 与键盘路径;一次性 demo Goal 走通真实冷路径 delivery-review
+ 读取;真实团队 Goal 仍未验收。
+- **W2 地图上的实时状态:** 由既有执行观测 owner 在同一节点显示运行中 turn、
+ 欠回报和陈旧观测,不新增轮询;失联只显示时长,不伪装活动。
+- **W3 地图上的产出:** 由 L1 证据 owner 把产物、验收和采用挂到对应 Todo,
+ 并支持版本对比。之后 L3 按子团队折叠工作地图。
+
下一个可执行实现是 **L1**,覆盖第 9 节完整纠偏过程的观测到用户 vertical。
同一交付计划包含缺失的版本 / 采用 producer、源收集、typed 投影、重连语义、
打包前端、证据导航、干预 / 停止反馈、独立 CLI 回读和失败用例。选择新 transport
@@ -378,6 +392,6 @@ SSE。第 6 节一手资料支撑设计选项,不构成产品验收。一个
空间成员、单次交接动态、纠偏回放、证据深入与降级状态;不启动 Agent,不读取
私人投研数据,未作为产品发布,也不充当真实 runtime 证据。
-**当前检查点:** L0 设计已合并;第 9 节记录开放中的 L1 实现候选。完整 L1 及 L2–L3 仍未验收。合成研究可探索 V1/V3;
+**当前检查点:** L0 设计已合并;第 9 节记录开放中的 L1 实现候选。第 11 节工作尺度地图分线的 W1 已提出,W2–W3 已规划。完整 L1 及 L2–L3 仍未验收。合成研究可探索 V1/V3;
V2/V4/V5/V6/V7 在获得其要求的生产或测量证据前都未资格化。不晋升 G1/G3/G4。
准确交付 PR 承载验证与 review;总路线只投影此边界,不再追加运行任务账本。
diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md
index 9e8755b9c1..3a6fb3747b 100644
--- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md
+++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md
@@ -74,7 +74,7 @@ P0 blocks correctness or continuity in the current user journey. P1 enables repe
| **S2 Typed kernel and durable authority · P0/P1** | Effect/Todo/quota/recovery owners, TS migration and store candidates exist; writer cutover/provider promotion remain incomplete | Migrate one real transaction/recovery lifecycle at a time, with semantic counterexamples before cutover/deletion. R1 correctness precedes migration volume. Require real backend, concurrency/fence, ambiguous commit, retention/export recovery, bridge costs and D1–D3 evidence |
| **S3 Goal planning and multi-Agent collaboration · P0/P1** | Vision/replan, peer frontiers, claim/lease, directory, manager_context and explicit continuation exist; general handoff/shared amendment remain incomplete | R2 proves peer dependency; R3 closes parallel joins, pipelines, help/review, continuation and automatic return; R4 delivers one intent-preserving amendment class. Cover cycles, invalidated inputs, rejection/deferral, lease transfer, competing bases and aggregate acceptance |
| **S4 Runtime/host/daemon · P0/P1** | Attached/managed, Turn, broker, runtime connectors and Desktop repairs exist; registration does not establish executable capacity | Qualify multi-Turn supervision for one real supported combination; restart/cancel/drain/stop retain work and fence old executors. Then expand host parity, unique service-profile ownership, clean installation and upgrades; show unsupported adapter capabilities |
-| **S5 Frontend, Lark and human interaction · P0/P1** | Local chat, settings, proposals and partial Goal Channel verticals exist; shared audience/session/work readback needs qualification | One journey spans settings, work graph, handoff, blockers, cost, corrections, artifacts and return. Shared typed projections; reconnect/repeated-click/stale/original-route cases. [Live team workspace](live-team-workspace-v0.md) makes exchange, revision and original-coordinator continuation visible. Then intelligent review, keyboard accessibility, bilingual terminology, actionable errors and offline degradation; interrupt only for actual decisions |
+| **S5 Frontend, Lark and human interaction · P0/P1** | Local chat, settings, proposals and partial Goal Channel verticals exist; shared audience/session/work readback needs qualification | One journey spans settings, work graph, handoff, blockers, cost, corrections, artifacts and return. Shared typed projections; reconnect/repeated-click/stale/original-route cases. [Live team workspace](live-team-workspace-v0.md) makes exchange, revision and original-coordinator continuation visible. Its [Work-scale map track](live-team-workspace-v0.md#11-delivery-order-and-relationship-to-aggressive-r2-progress) draws each Goal's typed Todo relations first (W1), then live state and outputs on the same nodes. Then intelligent review, keyboard accessibility, bilingual terminology, actionable errors and offline degradation; interrupt only for actual decisions |
| **S6 Materials, evidence, memory and learning · P1** | Authority registry, material lifecycle/frontier, decision context, reward memory and turn recall exist; direction baseline and parts of attribution remain proposed | Connect material revision→same-Agent read→decision reference→artifact/outcome. Expose expiry/revocation/source loss and forgetting policy. Handoff preserves decision-relevant summaries and authorized artifacts; qualify OpenViking/Obelisk as optional providers. Prove causal utility with controls, not relevance alone |
| **S7 Budget, scheduling and fleet scale · P0 observation/P1–P2 expansion** | Quota/scheduler and partial usage aggregates exist; full provider cost, distributed reservations and hundred-Agent concurrency need evidence | Separate configured budget, admission, consumption and estimates; unknown is not zero and replay cannot double-charge. R7 pagination/bounded summaries and [complete-history transport](typescript-control-plane-migration-v0.md), including refresh/replay/single-debit evidence beyond the RPC limit; provider/host limits, fairness, backpressure, event wake and isolation; report registration/activity/throughput and cost per accepted outcome separately |
| **S8 Capabilities, extensions and domain integration · P1/P2** | Capability catalog, extension lifecycle, hooks, engineering/research/content/office capabilities and computer-use contracts exist | First exercise the shared control plane with existing issue-fix/PR-review and material/research callers. Every provider has readiness/version/permissions/default-off/uninstall/rollback/isolation and real-entry evidence. New domain effects start with one simulated operation, not a marketplace or workflow DSL |
diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
index 0cce3949f3..740bceb1c9 100644
--- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
+++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
@@ -61,7 +61,7 @@ managed 与 attached 的工作对话都应能持续在 LoopX 中进行:沿用
| **S2 typed 内核与 durable authority · P0/P1** | Effect/Todo/quota/recovery owner、TS 事务迁移与 store 候选已存在;writer 和 provider 晋升仍未全闭合 | 每次迁移一个真实事务/恢复生命周期,先语义反例再切换/删除旧 owner;R1 正确性先于迁移数量。真实 backend、并发/fence、ambiguous commit、保留/导出恢复、bridge 成本及 D1–D3 资格 |
| **S3 目标规划与 multi-Agent 协作 · P0/P1** | Vision/replan、peer frontier、claim/lease、directory、manager_context 和显式接续有基础;通用 handoff/共享修订未闭环 | R2 必须证明 peer 依赖;R3 完成并行汇合、流水线、求助/复核、接续、自动回报;R4 做一个保持 intent 的 amendment class。检查依赖环、输入失效、拒绝/延期、lease 转移、同基线竞争及 aggregate acceptance |
| **S4 runtime/host/daemon · P0/P1** | attached/managed、Turn、broker、runtime connector 和 Desktop 修复存在;“registered”不等于可执行 | 选择一个真实合格组合完成多 Turn supervision;restart/cancel/drain/stop 不丢工作且旧 executor 被 fence。之后扩 host parity、service-profile 唯一 owner、干净安装与版本升级;按 adapter 能力显示不支持项 |
-| **S5 前端、Lark 与人机交互 · P0/P1** | 本地对话、settings、proposal 和部分 Goal Channel vertical 已有;统一受众/会话/工作回读仍需资格 | 用一个团队旅程贯穿设置、工作图、handoff、阻塞、成本、修订、产物和回报;共享 typed projection,验证重连/重复点击/stale/原路反馈。再做 intelligent review、无障碍键盘流程、中英术语、错误可恢复和离线降级;只在真实决策处打断人;[团队实时工作区](live-team-workspace-v0.zh-CN.md)让交换、修订与原协调员继续推进可见 |
+| **S5 前端、Lark 与人机交互 · P0/P1** | 本地对话、settings、proposal 和部分 Goal Channel vertical 已有;统一受众/会话/工作回读仍需资格 | 用一个团队旅程贯穿设置、工作图、handoff、阻塞、成本、修订、产物和回报;共享 typed projection,验证重连/重复点击/stale/原路反馈。再做 intelligent review、无障碍键盘流程、中英术语、错误可恢复和离线降级;只在真实决策处打断人;[团队实时工作区](live-team-workspace-v0.zh-CN.md)让交换、修订与原协调员继续推进可见;其[工作尺度地图分线](live-team-workspace-v0.zh-CN.md#11-交付顺序与激进推进-r2-的关系)先画出每个 Goal 的类型化 Todo 关系(W1),再在同一节点叠加实时状态与产出 |
| **S6 材料、证据、记忆与学习 · P1** | authority registry、material lifecycle/frontier、decision context、reward memory、turn recall 已有;方向基线和部分归因仍是提案 | 先打通“材料 revision→同 Agent 阅读→决策引用→产物/结果”;失效、撤销、来源消失与遗忘策略可回读。handoff 保存影响决策的摘要与授权 artifact;OpenViking/Obelisk 按可选 provider 资格化。utility 的因果收益另以对照证明,不把相关性当提升 |
| **S7 预算、调度与 fleet 规模 · P0 观测/P1–P2 扩展** | quota/scheduler 与部分 usage aggregate 存在;全 provider 成本、分布式资源预留及百 Agent 并发尚需证据 | 先区分配置预算、准入、消耗与估算;未知成本不记零、重复事件不双记。R7 分页/有界摘要及[完整历史传输](typescript-control-plane-migration-v0.zh-CN.md),验收超出 RPC 上限后的写回/重放/单次扣记;provider/host 限流、公平性、背压、事件唤醒与失败隔离;分别报告注册数/活跃数/吞吐量和每个验收成果成本 |
| **S8 能力、扩展与领域集成 · P1/P2** | 已有 capability catalog、extension 生命周期、hook、工程/研究/content/office 能力及 computer-use 合同 | 优先用现有 issue-fix/PR-review 和材料/研究 caller 检验共享控制面;每个 provider 带 readiness、版本、权限、默认关闭、卸载/回滚、失败隔离与真实入口证据。新 domain effect 从模拟单操作闭环开始,不先建市场或通用工作流 DSL |
diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md
index ce7b77f077..496cbb0521 100644
--- a/docs/guides/personal-workspace-user-guide.md
+++ b/docs/guides/personal-workspace-user-guide.md
@@ -188,15 +188,22 @@ Goal 顶部直接提供 **概览、任务、对话、成果**,分别用于判
**交付与依据:**概览直接展示当前交付链、责任、关联关系和验收观察,
不需要额外打开复盘弹窗。可按标题、负责人或引用搜索,选择节点沿关系追溯,
-并打开当前工作区中的任务、决定或执行记录。桌面支持关系图,手机默认列表。
+并打开当前工作区中的任务、决定或执行记录。交付链默认列表,桌面可切换关系图。
-**范围与刷新:**交付链覆盖当前选中工作及有限前序,不是完整 Goal 依赖图。
+**工作地图:**交付链上方展示整个 Goal 的工作形状:全部未归档任务、决定和持续监控,
+以及已记录的依赖、延续与替代关系。默认「当前工作」只显示未完成事项及其直接前序,
+「全部」包含早期已完成工作;没有关联的事项单独列出。选中事项会高亮全部前序与后续,
+检查器列出「之前 / 之后」并可打开任务详情。连线只来自已记录关系,不代表可以执行;
+遗漏、缺失端点或成环时显示「部分工作未出现在此地图中」。手机改为列表。
+
+**范围与刷新:**交付链覆盖当前选中工作及有限前序;完整 Goal 关系请看工作地图。
缺失前序、来源裁剪与未展开决定可展开查看;任务完成或缺口列表为空都不代表通过验收。
仅进入概览或点击「刷新快照」时读取交付链;离开概览取消未完成请求,
-不增加普通状态读取的图计算。状态变化后旧快照的来源跳转和导出暂停,刷新后继续。
+不增加普通状态读取的图计算。状态变化后自动重读快照,重读完成前暂停来源跳转和导出;
+重读失败才提示快照已过期。
读取失败保留其他概览内容并显示重试提示,不将失败视为工作已完成。
-**导出:**「导出交付快照」下载包含读取时间、完整当前链、关系、证据引用和验收观察的
+**导出:**「导出交付快照」下载包含读取时间、完整当前链、工作地图、关系、证据引用和验收观察的
Markdown。搜索筛选不会裁剪导出;不包含原始日志、文件正文或对话正文。
成果与报告继续由成果页统一展示,不在概览建立第二份成果清单。
@@ -221,7 +228,18 @@ view session. Settings opens the existing capability editor directly.
Overview brings progress, pending decisions, execution and usage into one page.
Its delivery section reads the bounded current chain and acceptance observations
on entry or explicit refresh, with search, map/list layouts, source navigation
-and Markdown export. Leaving Overview aborts pending reads. Export retains the
+and Markdown export. Leaving Overview aborts pending reads. A changed workspace
+re-reads the snapshot automatically; source navigation and export pause until it
+returns, and only a failed re-read reports the snapshot as stale.
+
+The **Work map** above the chain shows the whole Goal: every non-archived task,
+decision and monitor, with recorded requires/follow-up/replaces relations.
+**Current work** keeps unfinished items and their direct prerequisites;
+**Everything** adds earlier finished work, and unlinked items sit in their own
+strip. Selecting an item highlights its full lineage and lists what comes before
+and after it, with **Open details** for tasks. A line is a recorded relation,
+not permission to run. Omitted items, missing endpoints and cycles show
+"Some work is not on this map." Phones get a list instead of a canvas. Export retains the
entire validated delivery snapshot regardless of filtering, excluding raw logs
and conversation/file bodies. Outputs remain in Files. Missing observations
never certify acceptance. Remote sources show their synchronized observations
@@ -244,8 +262,11 @@ CLI readback uses the same existing owners:
```bash
loopx --format json status --goal-id example-goal --include-task-graph
loopx --format json review-packet --goal-id example-goal
+loopx --format json todo list --goal-id example-goal
```
+The last command returns the typed relation fields the Work map draws.
+
The local Chat HTTP read is `GET /api/chat/delivery-review?goal_id=example-goal`.
Lark continues using its existing Goal Channel projection; this slice adds no
Lark card or notification and does not qualify cross-channel presentation parity.
From f97632de7a56edcd3b74dcdc958586b0d721bd79 Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 2 Oct 2026 01:29:05 +0800
Subject: [PATCH 05/10] fix(task-graph): order the work map by dependencies,
keep reviews readable
Lineage no longer creates false cycles: a follow-up recorded opposite to a
dependency adds no ordering, and cycle_edge_count counts only dependency
loops, which now also clear topology_complete. An unreadable Todo source
returns goal_map: null instead of failing the whole delivery review.
Signed-off-by: huangruiteng
---
.../protocols/task-graph-projection-v0.md | 20 +++++++---
loopx/chat_status_api.py | 16 +++++---
loopx/control_plane/work_items/task_graph.ts | 40 ++++++++++++-------
tests/control_plane_ts/task_graph.test.ts | 18 +++++++++
tests/test_delivery_review.py | 8 ++++
5 files changed, 76 insertions(+), 26 deletions(-)
diff --git a/docs/reference/protocols/task-graph-projection-v0.md b/docs/reference/protocols/task-graph-projection-v0.md
index 866855a385..fbe0692118 100644
--- a/docs/reference/protocols/task-graph-projection-v0.md
+++ b/docs/reference/protocols/task-graph-projection-v0.md
@@ -217,18 +217,26 @@ added to status hot paths.
`depth` (the longest recorded prerequisite path). Admission is deterministic:
unfinished work first, then direct completed prerequisites of unfinished
work, then remaining history, up to `node_limit` (at most 200).
-- `topology_complete` is true only when nothing was omitted, the source list
- was not truncated, no relation names an absent Todo, and no cycle exists.
- Cycle back edges are counted, not drawn as an order. Missing endpoints never
- create phantom nodes.
+- `depends_on` edges order the map. Lineage (`continues`, `supersedes`)
+ carries no ordering obligation: it adds depth only where it agrees with the
+ dependency order. A follow-up recorded opposite to a dependency, such as a
+ gate spawned by the work it later unblocks, is drawn but is not a cycle.
+- `cycle_edge_count` counts dependency loops only; their back edges are
+ counted, not drawn as an order. `topology_complete` is true only when nothing
+ was omitted, the source list was not truncated, no relation names an absent
+ Todo, and no dependency loop exists. Missing endpoints never create phantom
+ nodes.
+- An unreadable Todo source returns `goal_map: null` and leaves the rest of the
+ delivery review available.
Like the predecessor lens, the map has no write authority. A drawn line is a
recorded relation, not a readiness verdict; the resume evaluator and lifecycle
commands keep that authority.
中文:工作地图只读展示一个 Goal 的全部未归档 Todo 和已记录的类型化关系;
-连线来自记录而非推断,不代表“可以执行”。截断、缺失端点和环都必须显式计入
-`limits`,不能把不完整的图画成完整顺序。
+连线来自记录而非推断,不代表“可以执行”。顺序只由依赖决定,延续/替代仅在不与依赖
+冲突时加深层级,因此反向记录的后续不算成环;`cycle_edge_count` 只统计依赖环。截断、
+缺失端点和依赖环都必须显式计入 `limits`,不能把不完整的图画成完整顺序。
## Write Boundary
diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py
index 550f6bce3c..fdd1428a5a 100644
--- a/loopx/chat_status_api.py
+++ b/loopx/chat_status_api.py
@@ -37,13 +37,17 @@
from .todos import list_goal_todos
-def _goal_task_map(server: Any, goal_id: str) -> dict[str, Any]:
+def _goal_task_map(server: Any, goal_id: str) -> dict[str, Any] | None:
runtime_root = server.runtime_root_override
- listed = list_goal_todos(
- registry_path=server.registry_path,
- goal_id=goal_id,
- runtime_root_arg=str(runtime_root) if runtime_root else None,
- )
+ try:
+ listed = list_goal_todos(
+ registry_path=server.registry_path,
+ goal_id=goal_id,
+ runtime_root_arg=str(runtime_root) if runtime_root else None,
+ )
+ except (OSError, ValueError):
+ # An unreadable Todo source leaves the rest of the review usable.
+ return None
todos = [row for row in listed.get("todos") or [] if isinstance(row, dict)]
return build_goal_task_map(
goal_id=goal_id,
diff --git a/loopx/control_plane/work_items/task_graph.ts b/loopx/control_plane/work_items/task_graph.ts
index e12c0bce2c..aee36f09c5 100644
--- a/loopx/control_plane/work_items/task_graph.ts
+++ b/loopx/control_plane/work_items/task_graph.ts
@@ -166,26 +166,38 @@ export function projectGoalTaskGraphTopology(value: unknown): JsonObject {
const missing = new Set([...edges.flatMap(edge => [edge.from_todo_id, edge.to_todo_id])]
.filter(id => !byId.has(id)));
const emitted = edges.filter(edge => admitted.has(edge.from_todo_id) && admitted.has(edge.to_todo_id));
- const outgoing = new Map();
- for (const edge of emitted) outgoing.set(edge.from_todo_id, [...outgoing.get(edge.from_todo_id) ?? [], edge]);
- const depth = new Map();
- const visiting = new Set();
+ const order = rows.map(item => item.todo_id).filter(id => admitted.has(id));
+ // Dependencies order the map. Lineage carries no ordering obligation, so it
+ // only adds depth where it agrees with that order; a follow-up recorded in
+ // the opposite direction of a dependency is not a cycle.
+ const ordering = new Map();
+ const link = (edge: GraphEdge) => ordering.set(edge.from_todo_id, [...ordering.get(edge.from_todo_id) ?? [], edge.to_todo_id]);
+ const reaches = (from: string, to: string) => {
+ const seen = new Set([from]);
+ const queue = [from];
+ while (queue.length) {
+ const current = queue.shift()!;
+ if (current === to) return true;
+ for (const next of ordering.get(current) ?? []) if (!seen.has(next)) { seen.add(next); queue.push(next); }
+ }
+ return false;
+ };
const cycleEdges = new Set();
+ for (const edge of emitted.filter(edge => edge.relation === "depends_on")) {
+ if (reaches(edge.to_todo_id, edge.from_todo_id)) cycleEdges.add(edgeKey(edge));
+ else link(edge);
+ }
+ for (const edge of emitted.filter(edge => edge.relation !== "depends_on")) {
+ if (!reaches(edge.to_todo_id, edge.from_todo_id)) link(edge);
+ }
+ const depth = new Map();
const visit = (id: string): number => {
const known = depth.get(id);
if (known !== undefined) return known;
- visiting.add(id);
- let result = 0;
- for (const edge of outgoing.get(id) ?? []) {
- // A back edge would make depth unbounded; it stays in `edges` for display.
- if (visiting.has(edge.to_todo_id)) { cycleEdges.add(edgeKey(edge)); continue; }
- result = Math.max(result, visit(edge.to_todo_id) + 1);
- }
- visiting.delete(id);
+ const result = Math.max(0, ...(ordering.get(id) ?? []).map(next => visit(next) + 1));
depth.set(id, result);
return result;
};
- const order = rows.map(item => item.todo_id).filter(id => admitted.has(id));
for (const id of order) visit(id);
const omitted = rows.length - order.length;
return {
@@ -196,7 +208,7 @@ export function projectGoalTaskGraphTopology(value: unknown): JsonObject {
node_limit: limit, emitted_node_count: order.length, omitted_node_count: omitted,
source_truncated: sourceTruncated, missing_endpoint_count: missing.size,
cycle_edge_count: cycleEdges.size,
- topology_complete: !sourceTruncated && omitted === 0 && missing.size === 0,
+ topology_complete: !sourceTruncated && omitted === 0 && missing.size === 0 && cycleEdges.size === 0,
},
};
}
diff --git a/tests/control_plane_ts/task_graph.test.ts b/tests/control_plane_ts/task_graph.test.ts
index fc7d8f9a33..95efa22a2f 100644
--- a/tests/control_plane_ts/task_graph.test.ts
+++ b/tests/control_plane_ts/task_graph.test.ts
@@ -141,6 +141,24 @@ test("goal scope reports missing endpoints and cycles without inventing depth or
assert.equal((goalGraph([row("todo_alpha")], {source_truncated: true}).completeness as JsonObject).topology_complete, false);
});
+test("goal scope orders by dependencies and never reports opposing lineage as a cycle", () => {
+ // Work spawns the decision it waits for: lineage says the gate came from the
+ // work, the dependency says the work waits on the gate.
+ const result = goalGraph([
+ row("todo_work", {done: false, successor_todo_ids: ["todo_gate", "todo_after"]}),
+ row("todo_gate", {done: false, unblocks_todo_id: "todo_work"}),
+ row("todo_after", {done: false}),
+ row("todo_loop_a", {successor_todo_ids: ["todo_loop_b"]}), row("todo_loop_b", {successor_todo_ids: ["todo_loop_a"]})]);
+ const completeness = result.completeness as JsonObject;
+ assert.equal(completeness.cycle_edge_count, 0, "Lineage carries no order, so it cannot contradict one");
+ assert.equal(completeness.topology_complete, true);
+ assert.equal(depths(result).todo_gate, 0);
+ assert.equal(depths(result).todo_work, 1, "The dependency wins over opposing lineage");
+ assert.equal(depths(result).todo_after, 2, "Agreeing lineage still adds depth");
+ assert.equal(Math.abs((depths(result).todo_loop_a as number) - (depths(result).todo_loop_b as number)), 1);
+ assert.equal(pairs(result).length, 5, "Every recorded relation is still drawn");
+});
+
test("goal scope rejects invalid wire input at the typed boundary", () => {
assert.throws(() => goalGraph([row("todo_a"), row("todo_a")]), /Duplicate/);
for (const node_limit of [0, 201, 1.5]) assert.throws(() => goalGraph([], {node_limit}));
diff --git a/tests/test_delivery_review.py b/tests/test_delivery_review.py
index a960a9c0a2..ce7323f571 100644
--- a/tests/test_delivery_review.py
+++ b/tests/test_delivery_review.py
@@ -124,6 +124,14 @@ def collect(**kwargs):
assert result["goal_map"]["schema_version"] == "goal_task_map_v0"
+def test_unreadable_todo_source_keeps_review_and_marks_map_unavailable(workspace):
+ server, registry, _ = workspace
+ (registry.parent / "ACTIVE_GOAL_STATE.md").unlink()
+ status, result = get(server)
+ assert status == 200
+ assert result["goal_map"] is None
+
+
@pytest.mark.parametrize("query, expected", [("", 400), ("?goal_id=", 400), ("?goal_id=missing", 404),
("?goal_id=release-demo&goal_id=release-demo", 400), ("?goal_id=release-demo&view=workspace-directory", 400)])
def test_review_rejects_ambiguous_or_absent_scope(workspace, query, expected):
From 8c767e4475561603a0cafb649af530ae6ab01287 Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 2 Oct 2026 01:29:05 +0800
Subject: [PATCH 06/10] feat(workspace): keep large Goal work maps readable
Current work now shows active work, its direct prerequisites and what open
decisions unblock; long finished histories collapse into a count. Unconnected
chains get their own framed grid, decision chains first. A shared owner is
shown once, links into archived or other-Goal work become a footnote, and the
unlinked strip previews eight items.
Signed-off-by: huangruiteng
---
.../dashboard/smoke/goal-work-map-smoke.mjs | 45 ++++++++--
.../dashboard/src/data/goal-work-map.ts | 86 ++++++++++++++-----
.../delivery-review-copy.ts | 8 +-
.../personal-workspace/goal-work-map.css | 8 +-
.../personal-workspace/goal-work-map.tsx | 63 ++++++++++----
docs/guides/personal-workspace-user-guide.md | 26 +++---
.../goal-work-map.mjs | 2 +-
7 files changed, 175 insertions(+), 63 deletions(-)
diff --git a/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs b/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs
index 67529a2a11..16a01927a6 100644
--- a/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { deliveryReviewMarkdown, parseDeliveryReview } from "../node_modules/.cache/delivery-review/data/delivery-review.js";
-import { goalWorkMapIncomplete, goalWorkMapLayout, goalWorkMapLineage, goalWorkMapSummary, goalWorkMapTone } from "../node_modules/.cache/delivery-review/data/goal-work-map.js";
+import { goalWorkMapCoverage, goalWorkMapLayout, goalWorkMapLineage, goalWorkMapSharedOwner, goalWorkMapSummary, goalWorkMapTone } from "../node_modules/.cache/delivery-review/data/goal-work-map.js";
import { deliveryReviewCopy } from "../node_modules/.cache/delivery-review/features/personal-workspace/delivery-review-copy.js";
const node = (id, kind, state, depth) => ({ node_id: id, kind, title: `Title ${id}`, state, depth, refs: { todo_ids: [`todo_${id}`] } });
@@ -20,22 +20,27 @@ const map = {
const snapshot = { ok: true, goal_id: "map-demo", observed_at: "2026-09-01T00:00:00Z", graph: null, goal_map: map, acceptance: null };
const parsed = parseDeliveryReview(snapshot, "map-demo").goal_map;
const ids = nodes => nodes.map(item => item.node_id);
+const grids = layout => layout.groups.map(group => group.columns.map(ids));
assert.deepEqual(goalWorkMapSummary(parsed), { work: 5, done: 2, blocked: 2, waiting: 0, decisions: 1, watches: 1 });
assert.equal(goalWorkMapTone(node("x", "gate", "done", 0)), "done", "A decided gate no longer asks for a decision");
assert.equal(goalWorkMapTone(node("x", "deliverable", "ready", 0)), "open");
const current = goalWorkMapLayout(parsed, "current");
-assert.deepEqual(current.columns.map(ids), [["gate"], ["reserve", "venues"], ["deposit", "budget"]], "Rows follow placed prerequisites");
+assert.deepEqual(grids(current), [[["gate"], ["reserve"], ["deposit"]], [["venues"], ["budget"]]],
+ "Each chain gets its own grid, the one needing a decision first, with hidden depths compacted");
assert.equal(current.hiddenCount, 1, "Older history beyond direct prerequisites is hidden, not dropped");
assert.deepEqual(ids(current.unlinked), ["watch"]);
assert.equal(current.edges.length, 4, "Parallel relations between one pair survive layout");
const all = goalWorkMapLayout(parsed, "all");
-assert.deepEqual(all.columns.map(ids), [["gate", "scope"], ["reserve", "venues"], ["deposit", "budget"]], "Decisions lead finished work at equal depth");
+assert.deepEqual(grids(all), [[["gate"], ["reserve"], ["deposit"]], [["scope"], ["venues"], ["budget"]]]);
assert.equal(all.hiddenCount, 0);
const gap = goalWorkMapLayout({ ...parsed, nodes: [node("a", "deliverable", "done", 0), node("b", "deliverable", "done", 1), node("c", "deliverable", "open", 2)],
edges: [edge("b", "a"), edge("c", "b")] }, "current");
-assert.deepEqual(gap.columns.map(ids), [["b"], ["c"]], "Hidden depths leave no empty columns");
+assert.deepEqual(grids(gap), [[["b"], ["c"]]], "Hidden depths leave no empty columns");
+const joined = goalWorkMapLayout({ ...parsed, nodes: [node("p", "deliverable", "open", 0), node("q", "deliverable", "open", 0), node("r", "deliverable", "open", 1)],
+ edges: [edge("r", "p"), edge("r", "q")] }, "all");
+assert.deepEqual(grids(joined), [[["p", "q"], ["r"]]], "A shared dependent keeps its prerequisites in one chain");
const lineage = goalWorkMapLineage(parsed.edges, "reserve");
assert.deepEqual([...lineage.nodes].sort(), ["deposit", "gate", "reserve"], "Lineage follows only recorded links");
@@ -43,9 +48,35 @@ assert.equal(lineage.edges.size, 2);
assert.equal(goalWorkMapLineage(parsed.edges, "budget").edges.size, 3, "Transitive prerequisites are traced");
assert.equal(goalWorkMapLineage(parsed.edges, null).nodes.size, 0);
-assert.equal(goalWorkMapIncomplete(parsed), false);
-assert.equal(goalWorkMapIncomplete({ ...parsed, limits: { ...parsed.limits, topology_complete: false } }), true);
-assert.equal(goalWorkMapIncomplete({ ...parsed, limits: { ...parsed.limits, cycle_edge_count: 1 } }), true, "A cycle is never drawn as a complete order");
+// Deferred work leaves the current view unless active work needs it or an open decision unblocks it.
+const parked = goalWorkMapLayout({ ...parsed, nodes: [node("ask", "gate", "open", 0), node("later", "deliverable", "waiting", 1),
+ node("run", "deliverable", "open", 1), node("input", "deliverable", "waiting", 0), node("shelf", "deliverable", "waiting", 0)],
+ edges: [edge("later", "ask", "depends_on", "typed_condition"), edge("run", "input", "depends_on", "typed_condition")] }, "current");
+assert.deepEqual(new Set(parked.groups.flatMap(group => group.columns.flat()).map(item => item.node_id)), new Set(["ask", "later", "run", "input"]));
+assert.equal(parked.hiddenCount, 1, "Unrelated deferred work is hidden, not dropped");
+
+// A long finished history behind one step collapses into a count instead of a column.
+const history = (count) => goalWorkMapLayout({ ...parsed, nodes: [node("step", "deliverable", "open", 1), node("next", "deliverable", "open", 2),
+ ...Array.from({ length: count }, (_, index) => node(`done${index}`, "deliverable", "done", 0))],
+ edges: [edge("next", "step", "depends_on", "typed_condition"), ...Array.from({ length: count }, (_, index) => edge("step", `done${index}`))] }, "current");
+assert.deepEqual(grids(history(2)), [[["done0", "done1"], ["step"], ["next"]]], "Two finished prerequisites stay as context");
+assert.deepEqual(grids(history(3)), [[["step"], ["next"]]]);
+assert.equal(history(3).collapsed.get("step"), 3);
+assert.equal(history(3).hiddenCount, 3, "Collapsed history is counted, not dropped");
+assert.equal(goalWorkMapLayout({ ...parsed, nodes: history(3).groups[0].columns.flat() }, "all").collapsed.size, 0, "The full map collapses nothing");
+
+const limits = parsed.limits;
+assert.equal(goalWorkMapCoverage(parsed), "complete");
+assert.equal(goalWorkMapCoverage({ ...parsed, limits: { ...limits, missing_endpoint_count: 2, topology_complete: false } }), "outside_links",
+ "Links into archived or other-Goal work do not make this Goal's own map partial");
+for (const change of [{ omitted_node_count: 1 }, { source_truncated: true }, { cycle_edge_count: 1 }]) {
+ assert.equal(goalWorkMapCoverage({ ...parsed, limits: { ...limits, missing_endpoint_count: 2, topology_complete: false, ...change } }), "partial");
+}
+assert.equal(goalWorkMapCoverage({ ...parsed, limits: { ...limits, topology_complete: false } }), "partial", "Unexplained incompleteness stays partial");
+const owned = (...owners) => ({ ...parsed, nodes: owners.map((owner, index) => ({ ...node(`n${index}`, "deliverable", "open", 0), ...(owner ? { owner_agent: owner } : {}) })) });
+assert.equal(goalWorkMapSharedOwner(owned("solo", "solo", null)), "solo");
+assert.equal(goalWorkMapSharedOwner(owned("solo", "pair")), null);
+assert.equal(goalWorkMapSharedOwner(owned("solo")), null, "A single owned item keeps its owner on the card");
for (const mutation of [
{ ...map, goal_id: "other-goal" },
diff --git a/apps/presentation/dashboard/src/data/goal-work-map.ts b/apps/presentation/dashboard/src/data/goal-work-map.ts
index 70de3c7798..df8e2ab17b 100644
--- a/apps/presentation/dashboard/src/data/goal-work-map.ts
+++ b/apps/presentation/dashboard/src/data/goal-work-map.ts
@@ -13,6 +13,7 @@ export function goalWorkMapTone(node: GoalWorkMapNode): GoalWorkMapTone {
return node.state;
}
+const DONE_CONTEXT = 2;
const toneRank: Record = { decision: 0, blocked: 1, open: 2, waiting: 3, unknown: 4, done: 5 };
export function goalWorkMapSummary(map: GoalWorkMap) {
@@ -26,35 +27,62 @@ export function goalWorkMapSummary(map: GoalWorkMap) {
}
/**
- * Columns follow the projected prerequisite depth. "current" keeps unfinished
- * items plus their direct recorded prerequisites; it hides history, not facts.
+ * Columns follow the projected prerequisite depth. "current" keeps active
+ * items, their direct recorded prerequisites and the work an open decision
+ * unblocks; it hides finished and deferred history, not facts.
*/
export function goalWorkMapLayout(map: GoalWorkMap, focus: GoalWorkMapFocus) {
- const unfinished = new Set(map.nodes.filter(node => node.state !== "done").map(node => node.node_id));
- const visible = new Set(focus === "all" ? map.nodes.map(node => node.node_id) : unfinished);
- if (focus === "current") for (const edge of map.edges) if (unfinished.has(edge.from_node_id)) visible.add(edge.to_node_id);
+ const active = new Set(map.nodes.filter(node => node.state !== "done" && node.state !== "waiting").map(node => node.node_id));
+ const decisions = new Set(map.nodes.filter(node => goalWorkMapTone(node) === "decision").map(node => node.node_id));
+ const visible = new Set(focus === "all" ? map.nodes.map(node => node.node_id) : active);
+ const doneBefore = new Map();
+ const state = new Map(map.nodes.map(node => [node.node_id, node.state]));
+ if (focus === "current") for (const edge of map.edges) {
+ if (active.has(edge.from_node_id)) {
+ if (state.get(edge.to_node_id) === "done") doneBefore.set(edge.from_node_id, [...new Set([...doneBefore.get(edge.from_node_id) ?? [], edge.to_node_id])]);
+ else visible.add(edge.to_node_id);
+ }
+ if (decisions.has(edge.to_node_id)) visible.add(edge.from_node_id);
+ }
+ // A little finished context explains a step; a long finished history buries it.
+ const collapsed = new Map();
+ for (const [id, done] of doneBefore) {
+ if (done.length <= DONE_CONTEXT) done.forEach(item => visible.add(item));
+ else collapsed.set(id, done.length);
+ }
const edges = map.edges.filter(edge => visible.has(edge.from_node_id) && visible.has(edge.to_node_id));
const linked = new Set(edges.flatMap(edge => [edge.from_node_id, edge.to_node_id]));
const order = new Map(map.nodes.map((node, index) => [node.node_id, index]));
const byTone = (a: GoalWorkMapNode, b: GoalWorkMapNode) =>
toneRank[goalWorkMapTone(a)] - toneRank[goalWorkMapTone(b)] || order.get(a.node_id)! - order.get(b.node_id)!;
const shown = map.nodes.filter(node => visible.has(node.node_id));
- const placed = shown.filter(node => linked.has(node.node_id));
- const depths = [...new Set(placed.map(node => node.depth))].sort((a, b) => a - b);
- const row = new Map();
- const columns = depths.map(depth => {
- // Order by the mean row of placed prerequisites to keep curves short.
- const center = (node: GoalWorkMapNode) => {
- const rows = edges.filter(edge => edge.from_node_id === node.node_id && row.has(edge.to_node_id)).map(edge => row.get(edge.to_node_id)!);
- return rows.length ? rows.reduce((sum, value) => sum + value, 0) / rows.length : Number.POSITIVE_INFINITY;
- };
- const column = placed.filter(node => node.depth === depth)
- .map(node => ({ node, center: center(node) }))
- .sort((a, b) => a.center - b.center || byTone(a.node, b.node)).map(entry => entry.node);
- column.forEach((node, index) => row.set(node.node_id, index));
- return column;
- });
- return { columns, edges, unlinked: shown.filter(node => !linked.has(node.node_id)).sort(byTone), hiddenCount: map.nodes.length - shown.length };
+ // Each connected chain gets its own grid so unrelated work never shares
+ // columns and every line stays inside its chain.
+ const parent = new Map([...linked].map(id => [id, id]));
+ const root = (id: string): string => { const up = parent.get(id)!; if (up === id) return id; const top = root(up); parent.set(id, top); return top; };
+ for (const edge of edges) parent.set(root(edge.from_node_id), root(edge.to_node_id));
+ const members = new Map();
+ for (const node of shown) if (linked.has(node.node_id)) members.set(root(node.node_id), [...members.get(root(node.node_id)) ?? [], node]);
+ const urgency = (nodes: GoalWorkMapNode[]) => Math.min(...nodes.map(node => toneRank[goalWorkMapTone(node)]));
+ const groups = [...members.values()]
+ .sort((a, b) => urgency(a) - urgency(b) || b.length - a.length || order.get(a[0].node_id)! - order.get(b[0].node_id)!)
+ .map(placed => {
+ const depths = [...new Set(placed.map(node => node.depth))].sort((a, b) => a - b);
+ const row = new Map();
+ return { columns: depths.map(depth => {
+ // Order by the mean row of placed prerequisites to keep curves short.
+ const center = (node: GoalWorkMapNode) => {
+ const rows = edges.filter(edge => edge.from_node_id === node.node_id && row.has(edge.to_node_id)).map(edge => row.get(edge.to_node_id)!);
+ return rows.length ? rows.reduce((sum, value) => sum + value, 0) / rows.length : Number.POSITIVE_INFINITY;
+ };
+ const column = placed.filter(node => node.depth === depth)
+ .map(node => ({ node, center: center(node) }))
+ .sort((a, b) => a.center - b.center || byTone(a.node, b.node)).map(entry => entry.node);
+ column.forEach((node, index) => row.set(node.node_id, index));
+ return column;
+ }) };
+ });
+ return { groups, edges, collapsed, unlinked: shown.filter(node => !linked.has(node.node_id)).sort(byTone), hiddenCount: map.nodes.length - shown.length };
}
/** Every recorded prerequisite above and dependent below one item. */
@@ -77,7 +105,19 @@ export function goalWorkMapLineage(edges: readonly GoalWorkMapEdge[], nodeId: st
return { nodes, edges: related };
}
-export function goalWorkMapIncomplete(map: GoalWorkMap) {
+/**
+ * "partial" means this Goal's own work is not fully drawn. Links into archived
+ * or other-Goal work are reported separately: the map scope excludes them.
+ */
+export function goalWorkMapCoverage(map: GoalWorkMap): "complete" | "outside_links" | "partial" {
const limits = map.limits;
- return !limits.topology_complete || limits.cycle_edge_count > 0;
+ if (limits.omitted_node_count > 0 || limits.source_truncated || limits.cycle_edge_count > 0) return "partial";
+ if (limits.missing_endpoint_count > 0) return "outside_links";
+ return limits.topology_complete ? "complete" : "partial";
+}
+
+/** One owner shared by every owned item is shown once instead of on each card. */
+export function goalWorkMapSharedOwner(map: GoalWorkMap) {
+ const owners = new Set(map.nodes.flatMap(node => node.owner_agent ? [node.owner_agent] : []));
+ return owners.size === 1 && map.nodes.filter(node => node.owner_agent).length > 1 ? [...owners][0] : null;
}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts
index 36b0ee3e57..c00053d4e9 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts
@@ -57,7 +57,9 @@ const en = {
zoom: "Map zoom", zoomOut: "Zoom out", zoomIn: "Zoom in", fit: "Fit",
boundary: "Arrows follow recorded task links. The map shows relationships; it does not decide what can run or whether the Goal is accepted.",
tasksDone: "tasks done", blocked: "blocked", waiting: "waiting", decisions: "need your decision", watches: "watching",
- hidden: "completed earlier, hidden", unlinked: "Not linked to other work", empty: "This Goal has no tasks yet.",
+ hidden: "finished or deferred, hidden", unlinked: "Not linked to other work", empty: "This Goal has no tasks yet.",
+ owner: "Owner", showMore: "Show {count} more", showFewer: "Show fewer", doneBefore: "+{count} done before", noLinks: "No task links are recorded between these items yet.",
+ outsideLinks: "{count} links point to archived or other-Goal work and are not drawn.",
unavailable: "This source does not provide a work map.",
kind: { deliverable: "Task", gate: "Decision", monitor: "Watch" },
tone: { decision: "Needs you", blocked: "Blocked", open: "Open", waiting: "Waiting", done: "Done", unknown: "Unknown" },
@@ -98,7 +100,9 @@ const zh: typeof en = {
zoom: "地图缩放", zoomOut: "缩小", zoomIn: "放大", fit: "适配",
boundary: "箭头来自已记录的任务关联。地图只展示关系,不决定哪些工作可以执行,也不代表 Goal 已通过验收。",
tasksDone: "项任务已完成", blocked: "受阻", waiting: "等待", decisions: "需你决策", watches: "持续监控",
- hidden: "项早期已完成工作已隐藏", unlinked: "未与其他工作关联", empty: "此 Goal 还没有任务。",
+ hidden: "项已完成或延后的工作已隐藏", unlinked: "未与其他工作关联", empty: "此 Goal 还没有任务。",
+ owner: "负责人", showMore: "显示其余 {count} 项", showFewer: "收起", doneBefore: "另有 {count} 项已完成前序", noLinks: "这些事项之间还没有记录的任务关联。",
+ outsideLinks: "{count} 条关联指向已归档或其他 Goal 的工作,未画出。",
unavailable: "此来源不提供工作地图。",
kind: { deliverable: "任务", gate: "决策", monitor: "监控" },
tone: { decision: "需要你", blocked: "受阻", open: "待处理", waiting: "等待", done: "已完成", unknown: "未知" },
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css
index 5fdc31ff02..e8a82d7a71 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css
@@ -21,7 +21,7 @@
.work-map-notice dl > div { display: flex; gap: 6px; }
.work-map-notice dt { color: var(--pw-muted); }
.work-map-notice dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; }
-.work-map-scroll { position: relative; overflow: auto; max-height: 560px; overscroll-behavior: contain; border-block: 1px solid var(--pw-line);
+.work-map-scroll { position: relative; overflow: auto; max-height: max(420px, min(720px, 72vh)); overscroll-behavior: contain; border-block: 1px solid var(--pw-line);
background-color: var(--pw-bg); background-image: radial-gradient(circle, var(--pw-line) 1px, transparent 1.2px); background-size: 20px 20px; cursor: grab; }
.work-map-scroll[hidden] { display: none; }
.work-map-scroll:active { cursor: grabbing; }
@@ -33,6 +33,7 @@
.work-map svg path[data-relation="supersedes"] { stroke-dasharray: 8 4; }
.work-map-canvas > svg path[data-related] { stroke: var(--pw-text); stroke-width: 2; }
.work-map-canvas > svg path[data-dimmed] { opacity: .25; }
+.work-map-canvas > svg rect.work-map-group { fill: var(--pw-card); fill-opacity: .55; stroke: var(--pw-line); stroke-width: 1; }
.work-map-arrow path { fill: var(--pw-line-strong, var(--pw-muted)); stroke: none; }
.work-map-arrow.is-hot path { fill: var(--pw-text); }
.work-map .work-map-node { position: absolute; margin: 0; min-height: 0; color: var(--pw-text); align-items: stretch; justify-content: stretch; width: 176px; height: 88px; display: grid; grid-template-rows: auto 1fr auto; gap: 4px; padding: 8px 10px; text-align: left;
@@ -45,10 +46,13 @@
.work-map-node-meta { display: flex; flex: none; align-items: center; gap: 4px; font-size: 11px; line-height: 14px; color: var(--pw-muted); min-width: 0; }
.work-map-node-meta em { margin-left: auto; font-style: normal; font-weight: 500; white-space: nowrap; }
.work-map .work-map-node > strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; max-height: 32px; font-size: 12.5px; line-height: 16px; font-weight: 500; overflow-wrap: anywhere; }
+.work-map .work-map-node[data-bare] { grid-template-rows: auto 1fr; }
+.work-map .work-map-node[data-bare] > strong { -webkit-line-clamp: 3; max-height: 48px; }
.work-map .work-map-node > small { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 11px; line-height: 14px; color: var(--pw-muted); white-space: nowrap; overflow: hidden; }
.work-map .work-map-node > small i { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; border-radius: 50%; background: var(--pw-bg); border: 1px solid var(--pw-line); font-style: normal; font-size: 9px; font-weight: 600; color: var(--pw-text); }
.work-map .work-map-node > small span { overflow: hidden; text-overflow: ellipsis; }
.work-map .work-map-node > small span:not(:first-child)::before { content: "· "; }
+.work-map .work-map-node > small .work-map-done-before { color: var(--pw-green, var(--pw-muted)); }
.work-map .work-map-node[data-tone="decision"] { border-color: var(--pw-blue); background: var(--pw-blue-soft, var(--pw-card)); }
.work-map .work-map-node[data-tone="decision"] .work-map-node-meta { color: var(--pw-blue-ink, var(--pw-blue)); }
.work-map .work-map-node[data-tone="blocked"] { box-shadow: inset 3px 0 0 var(--pw-amber), 0 1px 2px rgb(0 0 0 / .04); }
@@ -66,6 +70,8 @@
.work-map-unlinked { display: grid; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--pw-line); }
.work-map-unlinked h4 { font-size: 12px; font-weight: 500; color: var(--pw-muted); }
.work-map-unlinked > div { display: flex; flex-wrap: wrap; gap: 8px; }
+.work-map-unlinked > button { justify-self: start; }
+.work-map-summary .work-map-owner { color: var(--pw-muted); }
.work-map .work-map-unlinked .work-map-node { position: static; }
.work-map-inspector { display: grid; gap: 12px; padding: 16px; border-top: 1px solid var(--pw-line); font-size: 13px; }
.work-map-inspector > p, .work-map-inspector header p, .work-map-relations p { color: var(--pw-muted); }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx
index b544707b9b..3c5a91e609 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx
@@ -1,28 +1,29 @@
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
import { Circle, CircleCheck, CircleDashed, ExternalLink, Eye, Hand, OctagonAlert, ZoomIn, ZoomOut } from "lucide-react";
-import { goalWorkMapLayout, goalWorkMapLineage, goalWorkMapIncomplete, goalWorkMapSummary, goalWorkMapTone,
+import { goalWorkMapCoverage, goalWorkMapLayout, goalWorkMapLineage, goalWorkMapSharedOwner, goalWorkMapSummary, goalWorkMapTone,
type GoalWorkMap, type GoalWorkMapEdge, type GoalWorkMapFocus, type GoalWorkMapNode, type GoalWorkMapTone } from "../../data/goal-work-map";
import type { deliveryReviewCopy } from "./delivery-review-copy";
import "./goal-work-map.css";
type Copy = (typeof deliveryReviewCopy)["en"]["workMap"];
-const CARD_W = 176, CARD_H = 88, COLUMN = 212, ROW = 102, PAD = 20;
+const CARD_W = 176, CARD_H = 88, COLUMN = 212, ROW = 102, PAD = 20, GAP_X = 44, GAP_Y = 36, UNLINKED_PREVIEW = 8;
const relationRank: Record = { depends_on: 0, supersedes: 1, continues: 2 };
const toneIcon: Record = {
decision: Hand, blocked: OctagonAlert, open: Circle, waiting: CircleDashed, done: CircleCheck, unknown: Circle,
};
-function NodeCard({ node, copy, selected, dimmed, style, onSelect }: {
- node: GoalWorkMapNode; copy: Copy; selected: boolean; dimmed: boolean; style?: CSSProperties; onSelect: (id: string) => void;
+function NodeCard({ node, copy, selected, dimmed, style, sharedOwner, doneBefore = 0, onSelect }: {
+ node: GoalWorkMapNode; copy: Copy; selected: boolean; dimmed: boolean; style?: CSSProperties; sharedOwner: string | null; doneBefore?: number; onSelect: (id: string) => void;
}) {
const tone = goalWorkMapTone(node);
const Icon = node.kind === "monitor" && tone !== "done" ? Eye : toneIcon[tone];
- const owner = node.owner_agent;
+ const owner = node.owner_agent === sharedOwner ? undefined : node.owner_agent;
+ const footer = Boolean(owner || node.task_domain || doneBefore);
return onSelect(node.node_id)}>
+ data-bare={footer ? undefined : true} aria-pressed={selected} style={style} title={node.title} onClick={() => onSelect(node.node_id)}>
{copy.kind[node.kind]}{copy.tone[tone]}
{node.title}
- {owner ? <>{owner.slice(0, 1).toUpperCase()} {owner}> : null}{node.task_domain ? {node.task_domain} : null}
+ {footer ? {owner ? <>{owner.slice(0, 1).toUpperCase()} {owner}> : null}{node.task_domain ? {node.task_domain} : null}{doneBefore ? {copy.doneBefore.replace("{count}", String(doneBefore))} : null} : null}
;
}
@@ -32,6 +33,7 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp
}) {
const marker = useId().replace(/:/g, "");
const [focus, setFocus] = useState("current");
+ const [allUnlinked, setAllUnlinked] = useState(false);
const [scale, setScale] = useState(null);
const scrollRef = useRef(null);
const inspectorRef = useRef(null);
@@ -45,10 +47,29 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp
}, []);
const layout = useMemo(() => goalWorkMapLayout(map, focus), [map, focus]);
const summary = goalWorkMapSummary(map);
+ const sharedOwner = goalWorkMapSharedOwner(map);
+ const coverage = goalWorkMapCoverage(map);
+ const unlinked = allUnlinked ? layout.unlinked : layout.unlinked.slice(0, UNLINKED_PREVIEW);
const nodeById = new Map(map.nodes.map(node => [node.node_id, node]));
- const position = new Map(layout.columns.flatMap((column, x) => column.map((node, y) => [node.node_id, { x: PAD + x * COLUMN, y: PAD + y * ROW }] as const)));
- const width = PAD * 2 + Math.max(0, layout.columns.length * COLUMN - (COLUMN - CARD_W));
- const height = PAD * 2 + Math.max(1, ...layout.columns.map(column => column.length)) * ROW - (ROW - CARD_H);
+ const placedNodes = layout.groups.flatMap(group => group.columns.flat());
+ // Shelf-pack chains left to right within the visible width, most urgent first.
+ const { position, frames, width, height } = useMemo(() => {
+ const limit = available ? available - PAD * 2 : 1040;
+ const position = new Map();
+ const frames: { x: number; y: number; w: number; h: number }[] = [];
+ let x = 0, y = 0, shelf = 0, right = 0;
+ for (const group of layout.groups) {
+ const w = group.columns.length * COLUMN - (COLUMN - CARD_W);
+ const h = Math.max(...group.columns.map(column => column.length)) * ROW - (ROW - CARD_H);
+ if (x > 0 && x + w > limit) { y += shelf + GAP_Y; x = 0; shelf = 0; }
+ group.columns.forEach((column, c) => column.forEach((node, r) => position.set(node.node_id, { x: PAD + x + c * COLUMN, y: PAD + y + r * ROW })));
+ frames.push({ x: PAD + x - 10, y: PAD + y - 10, w: w + 20, h: h + 20 });
+ right = Math.max(right, x + w);
+ x += w + GAP_X;
+ shelf = Math.max(shelf, h);
+ }
+ return { position, frames, width: PAD * 2 + right, height: PAD * 2 + y + shelf };
+ }, [layout, available]);
const fit = available ? Math.min(1, available / width) : 1;
// Fit when the whole map stays legible; otherwise keep full size and scroll.
const zoom = scale ?? (fit >= 0.86 ? fit : 1);
@@ -102,6 +123,7 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp
{summary.blocked ? {summary.blocked} {copy.blocked} : null}
{summary.waiting ? {summary.waiting} {copy.waiting} : null}
{summary.watches ? {summary.watches} {copy.watches} : null}
+ {sharedOwner ? {copy.owner} {sharedOwner} : null}
@@ -115,23 +137,24 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp
- {goalWorkMapIncomplete(map) ? {copy.incomplete}
+ {coverage === "partial" ? {copy.incomplete}
{copy.omitted} {limits.omitted_node_count}
{copy.missingEnds} {limits.missing_endpoint_count}
{copy.truncated} {limits.source_truncated ? "✓" : "–"}
{copy.cycles} {limits.cycle_edge_count}
: null}
{!map.nodes.length ? {copy.empty}
: <>
- {compact ? {layout.columns.flat().map(node => {
+ {compact ? {placedNodes.map(node => {
const needs = layout.edges.filter(edge => edge.from_node_id === node.node_id).map(edge => nodeById.get(edge.to_node_id)!.title);
- return
+ return
{needs.length ? {copy.before} {[...new Set(needs)].join(" · ")}
: null} ;
})} : null}
{ drag.current = null; }} onPointerCancel={() => { drag.current = null; }}>
- {layout.columns.length ?
+ {placedNodes.length ?
+ {frames.length > 1 ? frames.map(frame => ) : null}
{["base", "hot"].map(kind => )}
{strokes.map(group => {
const edge = group[0];
@@ -145,10 +168,10 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp
data-dimmed={(selected && !related) || undefined} markerEnd={`url(#${marker}-${related ? "hot" : "base"})`} />;
})}
- {layout.columns.flat().map(node =>
)}
+ {placedNodes.map(node =>
)}
-
: null}
+
:
{copy.noLinks}
}
{!selected ? {copy.select}
: <>
@@ -169,11 +192,13 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp
{(["depends_on", "continues"] as const).map(relation => {copy.relation[relation]} )}
{layout.hiddenCount ? setFocus("all")}>{layout.hiddenCount} {copy.hidden} : null}
- {copy.boundary}
+ {copy.boundary}{coverage === "outside_links" ? <> {copy.outsideLinks.replace("{count}", String(limits.missing_endpoint_count))}> : null}
{layout.unlinked.length ?
{copy.unlinked}
- {layout.unlinked.map(node => )}
+ {unlinked.map(node => )}
+ {layout.unlinked.length > UNLINKED_PREVIEW ? setAllUnlinked(value => !value)}>
+ {allUnlinked ? copy.showFewer : copy.showMore.replace("{count}", String(layout.unlinked.length - UNLINKED_PREVIEW))} : null}
: null}
>}
;
diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md
index 496cbb0521..9836a85b1b 100644
--- a/docs/guides/personal-workspace-user-guide.md
+++ b/docs/guides/personal-workspace-user-guide.md
@@ -191,10 +191,12 @@ Goal 顶部直接提供 **概览、任务、对话、成果**,分别用于判
并打开当前工作区中的任务、决定或执行记录。交付链默认列表,桌面可切换关系图。
**工作地图:**交付链上方展示整个 Goal 的工作形状:全部未归档任务、决定和持续监控,
-以及已记录的依赖、延续与替代关系。默认「当前工作」只显示未完成事项及其直接前序,
-「全部」包含早期已完成工作;没有关联的事项单独列出。选中事项会高亮全部前序与后续,
-检查器列出「之前 / 之后」并可打开任务详情。连线只来自已记录关系,不代表可以执行;
-遗漏、缺失端点或成环时显示「部分工作未出现在此地图中」。手机改为列表。
+以及已记录的依赖、延续与替代关系。默认「当前工作」显示进行中和受阻的事项、它们的直接前序
+(单项超过 2 个已完成前序时折叠为计数),以及待决定事项会解锁的工作;已完成或延后的其余工作
+隐藏并计数。「全部」包含早期已完成工作。互不相连的工作链分框排列,需要决定的链在前;
+没有关联的事项单独列出。选中事项会高亮全部前序与后续,检查器列出「之前 / 之后」并可打开任务详情。
+连线只来自已记录关系,不代表可以执行;遗漏、来源裁剪或依赖成环时显示「部分工作未出现在此地图中」,
+仅指向已归档或其他 Goal 的关联只在底部注明。手机改为列表。
**范围与刷新:**交付链覆盖当前选中工作及有限前序;完整 Goal 关系请看工作地图。
缺失前序、来源裁剪与未展开决定可展开查看;任务完成或缺口列表为空都不代表通过验收。
@@ -234,12 +236,16 @@ returns, and only a failed re-read reports the snapshot as stale.
The **Work map** above the chain shows the whole Goal: every non-archived task,
decision and monitor, with recorded requires/follow-up/replaces relations.
-**Current work** keeps unfinished items and their direct prerequisites;
-**Everything** adds earlier finished work, and unlinked items sit in their own
-strip. Selecting an item highlights its full lineage and lists what comes before
-and after it, with **Open details** for tasks. A line is a recorded relation,
-not permission to run. Omitted items, missing endpoints and cycles show
-"Some work is not on this map." Phones get a list instead of a canvas. Export retains the
+**Current work** keeps in-progress and blocked items, their direct
+prerequisites (more than two finished ones collapse into a count on the card)
+and the work an open decision unblocks; other finished or deferred work is
+hidden and counted. **Everything** adds earlier finished work. Unconnected
+chains sit in separate frames, chains awaiting a decision first, and unlinked
+items sit in their own strip. Selecting an item highlights its full lineage and
+lists what comes before and after it, with **Open details** for tasks. A line is
+a recorded relation, not permission to run. Omitted items, a truncated source
+or a dependency loop show "Some work is not on this map"; links that only point
+to archived or other-Goal work get a quiet footnote. Phones get a list instead of a canvas. Export retains the
entire validated delivery snapshot regardless of filtering, excluding raw logs
and conversation/file bodies. Outputs remain in Files. Missing observations
never certify acceptance. Remote sources show their synchronized observations
diff --git a/examples/personal-workspace-browser/goal-work-map.mjs b/examples/personal-workspace-browser/goal-work-map.mjs
index 88e0ecf8ed..e3e74e9911 100644
--- a/examples/personal-workspace-browser/goal-work-map.mjs
+++ b/examples/personal-workspace-browser/goal-work-map.mjs
@@ -58,7 +58,7 @@ export const goalWorkMapScenario = {
assert.equal(await canvas.locator("path[marker-end]").count(), 3, "Two relations between one pair draw one line; none are invented");
assert.deepEqual(await titles(map.getByRole("region", { name: "未与其他工作关联" })), ["Check registration totals"]);
assert.match(await map.locator(".work-map-summary").innerText(), /2\/5\s+项任务已完成.*1 需你决策.*2 受阻.*1 持续监控/s);
- await map.getByRole("button", { name: "1 项早期已完成工作已隐藏" }).click();
+ await map.getByRole("button", { name: "1 项已完成或延后的工作已隐藏" }).click();
assert.equal(await canvas.locator(".work-map-node").count(), 6);
assert.equal(await canvas.locator("path[marker-end]").count(), 4);
await map.getByRole("button", { name: "当前工作", exact: true }).click();
From c02617905aa2e1a8bb8f6f9757a422bad679c9d2 Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 2 Oct 2026 03:18:14 +0800
Subject: [PATCH 07/10] fix(workspace): keep missing work links visibly
incomplete
Signed-off-by: huangruiteng
---
apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs | 4 ++--
apps/presentation/dashboard/src/data/goal-work-map.ts | 8 ++++----
.../features/personal-workspace/delivery-review-copy.ts | 6 ++----
.../src/features/personal-workspace/goal-work-map.tsx | 2 +-
docs/guides/personal-workspace-user-guide.md | 7 ++++---
5 files changed, 13 insertions(+), 14 deletions(-)
diff --git a/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs b/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs
index 16a01927a6..b30a167e09 100644
--- a/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/goal-work-map-smoke.mjs
@@ -67,8 +67,8 @@ assert.equal(goalWorkMapLayout({ ...parsed, nodes: history(3).groups[0].columns.
const limits = parsed.limits;
assert.equal(goalWorkMapCoverage(parsed), "complete");
-assert.equal(goalWorkMapCoverage({ ...parsed, limits: { ...limits, missing_endpoint_count: 2, topology_complete: false } }), "outside_links",
- "Links into archived or other-Goal work do not make this Goal's own map partial");
+assert.equal(goalWorkMapCoverage({ ...parsed, limits: { ...limits, missing_endpoint_count: 2, topology_complete: false } }), "partial",
+ "Missing endpoints do not prove the target was archived or belongs to another Goal");
for (const change of [{ omitted_node_count: 1 }, { source_truncated: true }, { cycle_edge_count: 1 }]) {
assert.equal(goalWorkMapCoverage({ ...parsed, limits: { ...limits, missing_endpoint_count: 2, topology_complete: false, ...change } }), "partial");
}
diff --git a/apps/presentation/dashboard/src/data/goal-work-map.ts b/apps/presentation/dashboard/src/data/goal-work-map.ts
index df8e2ab17b..65027646da 100644
--- a/apps/presentation/dashboard/src/data/goal-work-map.ts
+++ b/apps/presentation/dashboard/src/data/goal-work-map.ts
@@ -106,13 +106,13 @@ export function goalWorkMapLineage(edges: readonly GoalWorkMapEdge[], nodeId: st
}
/**
- * "partial" means this Goal's own work is not fully drawn. Links into archived
- * or other-Goal work are reported separately: the map scope excludes them.
+ * Missing endpoints do not identify why an item is absent. Keep the map partial
+ * until the owning projection can prove that a link is outside this Goal.
*/
-export function goalWorkMapCoverage(map: GoalWorkMap): "complete" | "outside_links" | "partial" {
+export function goalWorkMapCoverage(map: GoalWorkMap): "complete" | "partial" {
const limits = map.limits;
if (limits.omitted_node_count > 0 || limits.source_truncated || limits.cycle_edge_count > 0) return "partial";
- if (limits.missing_endpoint_count > 0) return "outside_links";
+ if (limits.missing_endpoint_count > 0) return "partial";
return limits.topology_complete ? "complete" : "partial";
}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts
index c00053d4e9..8cbc02599b 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts
@@ -59,7 +59,6 @@ const en = {
tasksDone: "tasks done", blocked: "blocked", waiting: "waiting", decisions: "need your decision", watches: "watching",
hidden: "finished or deferred, hidden", unlinked: "Not linked to other work", empty: "This Goal has no tasks yet.",
owner: "Owner", showMore: "Show {count} more", showFewer: "Show fewer", doneBefore: "+{count} done before", noLinks: "No task links are recorded between these items yet.",
- outsideLinks: "{count} links point to archived or other-Goal work and are not drawn.",
unavailable: "This source does not provide a work map.",
kind: { deliverable: "Task", gate: "Decision", monitor: "Watch" },
tone: { decision: "Needs you", blocked: "Blocked", open: "Open", waiting: "Waiting", done: "Done", unknown: "Unknown" },
@@ -67,7 +66,7 @@ const en = {
relation: { depends_on: "Required", continues: "Follow-up", supersedes: "Replaces" },
legend: "Line styles", open: "Open details", noSource: "Details for this item are not loaded in the workspace.",
select: "Select an item to trace what it needs and what it leads to.", selected: "Selected item",
- incomplete: "Some work is not on this map.", omitted: "Not shown", missingEnds: "Linked outside this Goal or archived", truncated: "Source truncated", cycles: "Circular links",
+ incomplete: "Some work is not on this map.", omitted: "Not shown", missingEnds: "Missing linked items", truncated: "Source truncated", cycles: "Circular links",
},
} satisfies ReviewExportLabels & Record;
@@ -102,7 +101,6 @@ const zh: typeof en = {
tasksDone: "项任务已完成", blocked: "受阻", waiting: "等待", decisions: "需你决策", watches: "持续监控",
hidden: "项已完成或延后的工作已隐藏", unlinked: "未与其他工作关联", empty: "此 Goal 还没有任务。",
owner: "负责人", showMore: "显示其余 {count} 项", showFewer: "收起", doneBefore: "另有 {count} 项已完成前序", noLinks: "这些事项之间还没有记录的任务关联。",
- outsideLinks: "{count} 条关联指向已归档或其他 Goal 的工作,未画出。",
unavailable: "此来源不提供工作地图。",
kind: { deliverable: "任务", gate: "决策", monitor: "监控" },
tone: { decision: "需要你", blocked: "受阻", open: "待处理", waiting: "等待", done: "已完成", unknown: "未知" },
@@ -110,7 +108,7 @@ const zh: typeof en = {
relation: { depends_on: "必需", continues: "延续", supersedes: "替代" },
legend: "连线样式", open: "打开详情", noSource: "此事项的详情未加载到工作区。",
select: "选择一项,查看它依赖什么、又会带来什么。", selected: "选中事项",
- incomplete: "部分工作未出现在此地图中。", omitted: "未显示", missingEnds: "关联到其他 Goal 或已归档", truncated: "来源被裁剪", cycles: "循环关联",
+ incomplete: "部分工作未出现在此地图中。", omitted: "未显示", missingEnds: "关联事项未找到", truncated: "来源被裁剪", cycles: "循环关联",
},
};
export const deliveryReviewCopy = { en, "zh-CN": zh };
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx
index 3c5a91e609..5e22301c47 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx
@@ -192,7 +192,7 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp
{(["depends_on", "continues"] as const).map(relation => {copy.relation[relation]} )}
{layout.hiddenCount ? setFocus("all")}>{layout.hiddenCount} {copy.hidden} : null}
- {copy.boundary}{coverage === "outside_links" ? <> {copy.outsideLinks.replace("{count}", String(limits.missing_endpoint_count))}> : null}
+ {copy.boundary}
{layout.unlinked.length ?
{copy.unlinked}
diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md
index 9836a85b1b..bdaac4ffd9 100644
--- a/docs/guides/personal-workspace-user-guide.md
+++ b/docs/guides/personal-workspace-user-guide.md
@@ -196,7 +196,7 @@ Goal 顶部直接提供 **概览、任务、对话、成果**,分别用于判
隐藏并计数。「全部」包含早期已完成工作。互不相连的工作链分框排列,需要决定的链在前;
没有关联的事项单独列出。选中事项会高亮全部前序与后续,检查器列出「之前 / 之后」并可打开任务详情。
连线只来自已记录关系,不代表可以执行;遗漏、来源裁剪或依赖成环时显示「部分工作未出现在此地图中」,
-仅指向已归档或其他 Goal 的关联只在底部注明。手机改为列表。
+关联端点未找到时同样保留不完整提示,不能推断它已归档或属于其他 Goal。手机改为列表。
**范围与刷新:**交付链覆盖当前选中工作及有限前序;完整 Goal 关系请看工作地图。
缺失前序、来源裁剪与未展开决定可展开查看;任务完成或缺口列表为空都不代表通过验收。
@@ -244,8 +244,9 @@ chains sit in separate frames, chains awaiting a decision first, and unlinked
items sit in their own strip. Selecting an item highlights its full lineage and
lists what comes before and after it, with **Open details** for tasks. A line is
a recorded relation, not permission to run. Omitted items, a truncated source
-or a dependency loop show "Some work is not on this map"; links that only point
-to archived or other-Goal work get a quiet footnote. Phones get a list instead of a canvas. Export retains the
+or a dependency loop show "Some work is not on this map". Missing linked items
+also keep that notice: absence alone does not prove archival or another Goal.
+Phones get a list instead of a canvas. Export retains the
entire validated delivery snapshot regardless of filtering, excluding raw logs
and conversation/file bodies. Outputs remain in Files. Missing observations
never certify acceptance. Remote sources show their synchronized observations
From 4c1e16425615fe1c7b8c7a19c1baaa3be1a8cdcf Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 2 Oct 2026 03:18:14 +0800
Subject: [PATCH 08/10] chore(semantics): refresh registry reader source
locations
Signed-off-by: huangruiteng
---
loopx/semantics/project_registry_io_manifest_v1.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json
index ba3300a8d1..c9d2408874 100644
--- a/loopx/semantics/project_registry_io_manifest_v1.json
+++ b/loopx/semantics/project_registry_io_manifest_v1.json
@@ -471,7 +471,7 @@
},
{
"site": "loopx/chat_status_api.py::.ChatStatusRequestMixin._status::codec_read:load_registry#1",
- "line": 112,
+ "line": 147,
"column": 28,
"kind": "codec_read",
"api": "load_registry",
@@ -479,7 +479,7 @@
},
{
"site": "loopx/chat_status_api.py::.ChatStatusRequestMixin._status::codec_read:load_registry#2",
- "line": 139,
+ "line": 174,
"column": 21,
"kind": "codec_read",
"api": "load_registry",
From f88ac3182209c22aaf3d39e8dd68b74448e389fb Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 2 Oct 2026 03:55:12 +0800
Subject: [PATCH 09/10] fix(demo): isolate preparation and replay from personal
state
Signed-off-by: huangruiteng
---
demo/workspace/README.md | 2 +-
demo/workspace/__main__.py | 30 +++++++++++++-----------------
tests/test_workspace_story_demo.py | 16 ++++++++++++++--
3 files changed, 28 insertions(+), 20 deletions(-)
diff --git a/demo/workspace/README.md b/demo/workspace/README.md
index 5500161526..5f515323ce 100644
--- a/demo/workspace/README.md
+++ b/demo/workspace/README.md
@@ -42,7 +42,7 @@ Refresh the UI afterward. Only that decision and its direct blocked successor ar
These are authored scenario replays using real LoopX APIs and state transitions, not customer case studies or receipts of live Agent execution. Natural project titles keep the interface readable; the manifest and completion evidence retain provenance. Source-card inventories and website checklists are planning inputs, not claims of external research or executed website tests. Event money is a project budget, not model spending.
-The demo does not import personal registries, session history or credentials, and does not sync into the global registry. The loopback server uses a separate HOME/CODEX_HOME, minimal environment and unavailable Agent/Lark binaries. Chat and Lark connection errors are intentional isolation and do not qualify live IM behavior. Stop with Ctrl-C.
+The demo does not import personal registries, session history or credentials, and does not sync into the global registry. Prepare, advance and serve run in a separate HOME/CODEX_HOME with a minimal environment; even preparation never discovers personal default registries. The loopback server uses unavailable Agent/Lark binaries. Chat and Lark connection errors are intentional isolation and do not qualify live IM behavior. Stop with Ctrl-C.
This remains a source-checkout demo under `demo/`, outside the installed wheel and capability catalog. Screenshots and recordings belong in ignored `output/playwright/`. Keep real operating statistics separately timestamped with their counting scope.
diff --git a/demo/workspace/__main__.py b/demo/workspace/__main__.py
index 0b44e04f18..1869a42554 100644
--- a/demo/workspace/__main__.py
+++ b/demo/workspace/__main__.py
@@ -334,30 +334,21 @@ def advance(
)
-def serve_isolated(root: Path, port: int) -> None:
- # Isolate machine settings, host discovery and credentials from the user's home.
+def run_isolated(args: argparse.Namespace, root: Path) -> None:
+ # Prepare and replay must be isolated too: even read-only default discovery
+ # can consult personal registries before the server starts.
home = root / "home"
- manifest = prepare(root)
- home.mkdir(exist_ok=True)
env = {
k: v
for k, v in os.environ.items()
if k in {"PATH", "LANG", "LC_ALL", "TMPDIR", "SYSTEMROOT"}
}
env.update(HOME=str(home), CODEX_HOME=str(home / ".codex"), PYTHONPATH=str(REPO))
- print(
- json.dumps(
- {
- "url": f"http://127.0.0.1:{port}/chat/",
- "notice": manifest["notice"],
- }
- ),
- flush=True,
- )
# Paths and ports are data, never arguments to an interpreter invocation.
result = subprocess.run(
- [sys.executable, "-m", "demo.workspace", "serve", "--_isolated"],
- input=json.dumps({"root": str(root), "port": port}),
+ [sys.executable, "-m", "demo.workspace", args.command, "--_isolated"],
+ input=json.dumps({"root": str(root), "port": args.port,
+ "story": args.story, "decision": args.decision}),
text=True,
cwd=REPO,
env=env,
@@ -387,6 +378,8 @@ def main() -> None:
config = json.load(sys.stdin)
args.root = Path(config["root"])
args.port = int(config["port"])
+ args.story = config.get("story")
+ args.decision = config.get("decision")
if not 1 <= args.port <= 65535:
parser.error("port must be between 1 and 65535")
root = (
@@ -395,9 +388,10 @@ def main() -> None:
if root.is_symlink():
parser.error("Demo root must not be a symlink")
root = root.resolve()
- if args.command == "serve" and not args._isolated:
- serve_isolated(root, args.port)
+ if not args._isolated:
+ run_isolated(args, root)
manifest = prepare(root)
+ (root / "home").mkdir(exist_ok=True)
if args.command == "advance":
if not args.story:
parser.error("advance requires --story")
@@ -406,6 +400,8 @@ def main() -> None:
if args.command == "prepare":
print(json.dumps(manifest, ensure_ascii=False, indent=2))
return
+ print(json.dumps({"url": f"http://127.0.0.1:{args.port}/chat/",
+ "notice": manifest["notice"]}), flush=True)
from loopx.chat_server import serve_chat
serve_chat(
diff --git a/tests/test_workspace_story_demo.py b/tests/test_workspace_story_demo.py
index 9ad7fdd7ef..6173b7a8c5 100644
--- a/tests/test_workspace_story_demo.py
+++ b/tests/test_workspace_story_demo.py
@@ -30,11 +30,22 @@ def test_real_state_replay_is_local_and_repeatable(tmp_path, monkeypatch):
registry = home / ".codex" / "loopx" / "registry.global.json"
registry.parent.mkdir(parents=True)
registry.write_text('{"sentinel": "personal registry"}')
+ current_registry = home / ".loopx" / "registry.global.json"
+ current_registry.parent.mkdir(parents=True)
+ current_registry.write_text('{"sentinel": "other personal registry"}')
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("CODEX_HOME", str(home / ".codex"))
root = tmp_path / "demo"
- manifest = prepare(root)
- assert prepare(root) == manifest
+ def prepare_from_cli():
+ result = subprocess.run(
+ [sys.executable, "-m", "demo.workspace", "prepare", "--root", str(root)],
+ cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ return json.loads(result.stdout)
+
+ manifest = prepare_from_cli()
+ assert prepare_from_cli() == manifest
assert len(manifest["goals"]) == 3
def todos(goal_id):
@@ -97,3 +108,4 @@ def todos(goal_id):
assert todos("community-day") == before["community-day"]
assert todos("neighborhood-site") == before["neighborhood-site"]
assert registry.read_text() == '{"sentinel": "personal registry"}'
+ assert current_registry.read_text() == '{"sentinel": "other personal registry"}'
From 1e4ff548dfb651afc3dccba82d2df8541c80975a Mon Sep 17 00:00:00 2001
From: huangruiteng
Date: Fri, 2 Oct 2026 03:57:00 +0800
Subject: [PATCH 10/10] refactor(demo): type the existing replay records
Signed-off-by: huangruiteng
---
demo/workspace/__main__.py | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/demo/workspace/__main__.py b/demo/workspace/__main__.py
index 1869a42554..00a488e1f8 100644
--- a/demo/workspace/__main__.py
+++ b/demo/workspace/__main__.py
@@ -11,6 +11,7 @@
import subprocess
import sys
import tempfile
+from typing import Any
from loopx.bootstrap import bootstrap_project
from loopx.configure_goal import configure_goal
@@ -23,13 +24,13 @@
REGISTRY_NAME = "registry.json"
-def checked(result: dict) -> dict:
+def checked(result: dict[str, Any]) -> dict[str, Any]:
if not result.get("ok"):
raise RuntimeError(json.dumps(result, ensure_ascii=False))
return result
-def write_story_artifacts(project: Path, story: dict, notice: str) -> None:
+def write_story_artifacts(project: Path, story: dict[str, Any], notice: str) -> None:
(project / "BRIEF.md").write_text(f"# {story['title']}\n\n{story['brief']}\n")
with (project / "working-table.csv").open("w", newline="") as table:
writer = csv.writer(table)
@@ -68,9 +69,10 @@ def write_story_artifacts(project: Path, story: dict, notice: str) -> None:
)
-def seed_delivery_tasks(story: dict, registry: Path, runtime: Path) -> list:
+def seed_delivery_tasks(story: dict[str, Any], registry: Path, runtime: Path) -> list[dict[str, Any]]:
"""Record each story dependency as the typed relation its state allows."""
- todos, ids = [], {}
+ todos: list[dict[str, Any]] = []
+ ids: dict[str, str] = {}
by_key = {task["key"]: task for task in story["tasks"]}
dependents: dict[str, list[str]] = {}
for task in story["tasks"]:
@@ -132,7 +134,7 @@ def seed_delivery_tasks(story: dict, registry: Path, runtime: Path) -> list:
return todos
-def seed_story(root: Path, story: dict, notice: str) -> dict:
+def seed_story(root: Path, story: dict[str, Any], notice: str) -> dict[str, Any]:
runtime = root / "runtime"
registry = root / REGISTRY_NAME
project = root / "projects" / story["id"]
@@ -263,14 +265,14 @@ def seed_story(root: Path, story: dict, notice: str) -> dict:
}
-def prepare(root: Path) -> dict:
+def prepare(root: Path) -> dict[str, Any]:
root = root.expanduser()
if root.is_symlink():
raise ValueError("Demo root must not be a symlink")
root = root.resolve()
marker = root / MARKER
if marker.exists():
- manifest = json.loads(marker.read_text())
+ manifest: dict[str, Any] = json.loads(marker.read_text())
if manifest.get("schema_version") != "workspace_story_demo_v3":
raise ValueError("Unrecognized demo manifest")
if manifest.get("root") != str(root) or manifest.get("registry") != str(
@@ -302,7 +304,7 @@ def prepare(root: Path) -> dict:
def advance(
- root: Path, manifest: dict, story_id: str, decision_key: str | None
+ root: Path, manifest: dict[str, Any], story_id: str, decision_key: str | None
) -> None:
story = next(s for s in manifest["goals"] if s["id"] == story_id)
registry = root / REGISTRY_NAME