diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 301fcf817f..f655b87a8d 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -539,6 +539,7 @@ export const runRecordSchema = z.object({ }); export const runGoalSchema = z.object({ + goal_objective: z.string().nullable().optional().catch(null), zcode_goal_eligible_agent_ids: z.array(z.string()).optional().default([]).catch([]), acceptance_observation: goalAcceptanceObservationSchema.optional().nullable().catch(null), id: z.string(), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx index 51c63ccc94..51043aa448 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx @@ -81,7 +81,11 @@ export function GoalAcceptanceContractSection({goalId, contract: retained, copy, } finally {if (token === generation.current) {flight.current = false; setBusy(false);}} } const unavailable = read.isError || actions.isError || !!error; - if (!management && !enabled && !plan && !unavailable) return <>{children}{notice ?

{notice}

: null}; + if (!management && !enabled && !plan && !unavailable) return <>{children} + {overview ?

{contract?.enabled === false + ? (zh ? "验收标准未配置(可选)。" : "Acceptance criteria are not configured (optional).") + : (zh ? "此来源未提供验收标准(可选)。" : "This source has not provided acceptance criteria (optional).")}

: null} + {notice ?

{notice}

: null}; const readOnly = !canManage; const criterionText = (item: typeof criteria[number]) => item.oracle ? ({current: zh ? "当前版本已核对" : "Current versions checked", recorded: zh ? "历史结果,当前版本未核对" : "Recorded; current versions not checked", stale: zh ? "来源或版本已变化,请重新检查" : "Source or versions changed; check again", failed: zh ? "检查失败;修复交付物后可再次检查" : "Checks failed; repair the delivery before checking again", unverified: zh ? "尚未检查" : "Not checked", unavailable: zh ? "证据不可用" : "Evidence unavailable"})[fresh ? item.oracle.state : "recorded"] diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx index 9759c4b859..235cee8c40 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx @@ -35,6 +35,7 @@ export function useGoalWorkspaceDigest(goal: WorkspaceGoal | null, userTodos: Wo const byTodo = new Map(map?.nodes.flatMap(node => (node.refs.todo_ids ?? []).map(id => [id, node] as const))); return compileGoalWorkspaceDigest({ observedAt: new Date().toISOString(), + objective: goal?.objective, activation: goal?.activationState ?? "active", todos: (goal?.agentTodos ?? []).map(todo => ({ todoId: todo.todoId, done: todo.done, status: todo.status, taskClass: todo.taskClass, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx index 5a06729b75..e5b32c3b13 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx @@ -1,4 +1,4 @@ -import {useEffect, useState, type ReactNode} from "react"; +import {type ReactNode} from "react"; import {type DeliveryReviewRead} from "./delivery-review-state"; import {GoalWorkspaceWork} from "./goal-workspace-work"; import { ArrowRight, Hand, Info } from "lucide-react"; @@ -40,23 +40,15 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD readOnly: boolean; onOpenDetails: () => void; onSelect: (selection: WorkspaceDrawerSelection | null) => void; onView: (view: WorkspaceGoalTab) => void; }) { const { t, locale } = useWorkspaceI18n(); - const [narrow, setNarrow] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 640px)").matches); - const [directionExpanded, setDirectionExpanded] = useState(false); - useEffect(() => { - const media = window.matchMedia("(max-width: 640px)"); - const update = () => setNarrow(media.matches); - update(); media.addEventListener("change", update); - return () => media.removeEventListener("change", update); - }, []); const copy = locale === "zh-CN" ? { direction: "方向", current: "当前推进", agentSummary: "Agent 的进展摘要", attention: "需要你", details: "Goal 信息", tasks: "查看任务", execution: "执行记录", optional: "{count} 项可选", - noTarget: "尚未声明可衡量目标。目标通过 Goal 的验收配置声明。", + objectiveUnavailable: "此来源尚未提供 Goal 的目标声明。", remote: "此来源仅提供同步的状态与验收观察,交付链需要实时本机来源。", } : { direction: "Direction", current: "Current work", agentSummary: "Agent's progress summary", attention: "Needs you", details: "Goal information", tasks: "View tasks", execution: "Execution", optional: "{count} optional", - noTarget: "No measurable target declared. Targets are declared through the Goal's acceptance configuration.", + objectiveUnavailable: "This source has not provided the Goal's declared objective.", remote: "This source provides synchronized status and acceptance observations. The delivery chain requires the live local source.", }; const attention = userTodos.filter(item => item.goalId === goal.goalId); @@ -68,7 +60,6 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD const moreAttention = needs.availability !== "ok" || needs.coverage !== "complete" || (totalAttention ?? attention.length) > 3; const run = items.find((item): item is Extract => item.kind === "run" && item.run.goalId === goal.goalId); const observation = review.snapshot?.acceptance ?? (goal.acceptanceObservation?.goal_id === goal.goalId ? goal.acceptanceObservation : null); - const contract = observation?.goal_acceptance_contract?.enabled === true ? observation.goal_acceptance_contract : null; const acceptanceCopy = goalAcceptanceContractCopy[locale === "zh-CN" ? "zh-CN" : "en"]; const sinceParts = sinceYouLooked ? [ sinceYouLooked.newlyDone ? t("since.newlyDone", { count: sinceYouLooked.newlyDone }) : null, @@ -86,19 +77,15 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD
-

{contract ? copy.direction : copy.current}

+

{copy.direction}

- - - - + + {run ? : null}
{showAttention ?
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 107b1d5a45..469c91573a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -158,6 +158,8 @@ export type WorkspaceGoalSubagentConfiguration = { }; export type WorkspaceGoal = { + /** Bounded, safe text from this Goal's declared source document. */ + objective?: string | null; acceptanceObservation?: GoalAcceptanceObservation | null; loadState?: "loading" | "error"; loadError?: WorkspaceLoadError; diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index f301bc5962..bbdcc13ee7 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1060,6 +1060,7 @@ function buildPersonalHomeModel( .find((value) => value !== "" && value !== "暂无") ?? t("projection.nextUpdatePending"); return [{ activationState: goal.activation_state, + objective: goal.goal_objective ?? null, agentId: agentRow?.agentId ?? registeredAgentIds[0] ?? "codex", agentLaneCount: goalAgentLanes.length, agentLanes: goalAgentLanes, diff --git a/docs/architecture/rfcs/goal-workspace-surface-v0.md b/docs/architecture/rfcs/goal-workspace-surface-v0.md index 064a30028e..5a299fbac9 100644 --- a/docs/architecture/rfcs/goal-workspace-surface-v0.md +++ b/docs/architecture/rfcs/goal-workspace-surface-v0.md @@ -133,7 +133,7 @@ Facts at `5c62c5c03`: | Tasks | `GoalTasksView` | Lanes: awaiting confirmation, pending/running, scheduled, completed; agent-lane filter; completed history from `/api/chat/completed-todos` | Not grouped by responsibility; text assignee; no observed elapsed time | | Usage | `usage_summary` | 24h/7d tokens, `cost_usd`, `duration_ms`; fields absent when unmeasured; `sample_run_count`, `available` | No lifetime aggregate; no explicit per-runtime coverage | | Acceptance | Goal authority via `goal-acceptance` (preview, provider CAS, exact replay); Dashboard reads only | Objective, criteria, coverage scope, bindings; observation projection is bounded and partial by design | No App declaration path; target absence is silent | -| Goal brief | Status Goal snapshot | No general objective field; the Agent's progress sentence and, when enabled, the acceptance contract objective | The page has no stable brief for Goals without a contract | +| Goal brief | Status Goal snapshot | Bounded declared Goal-document objective; progress and optional contract stay separate | Missing source content stays unavailable | | Todo identity | Status `todoItemSchema` | Native Todos are addressed by `todo_id` and intentionally carry no synthetic index; legacy Markdown Todos keep `index` | A numbered rail would invent identity | | Activity | `goal-activity.ts`, agent-lane `lastActivityAt`, host-thread activity, run timeline | Work-kind chip and per-lane observation | Not summarized as one observed last-activity fact | | Themes | `workspace-theme.ts` (`loopx`, `paper`, `brutal`) | Light-first presets | No dark inverse of the LoopX theme | @@ -154,7 +154,7 @@ owns cross-region page semantics, so this RFC owns composition only. | --- | --- | --- | | Masthead | Goal registry and lifecycle activation | `goal_lifecycle` typed action | | Vitals | Todo projection, attention queue, `usage_summary`, execution observation | None; each cell focuses its region | -| Direction | Acceptance contract and observation; progress summary (Section 12 D6) | Acceptance owner configuration (M3, Section 12 D3) | +| Direction | Recorded Goal-document objective; separate acceptance contract and progress observation (Section 12 D6) | Acceptance owner configures checks, not the Goal's recorded objective (M3, Section 12 D3) | | Needs you | Attention and gate owners; action review plan | Existing gate decision actions | | Now, rail | Todo projection, execution observation, run timeline, `goal_task_map_v0` relations | Todo create dry-run/apply; existing drawer actions | | Results | Files and Goal-results owner; acceptance owner for badges | Existing open/read actions | @@ -178,7 +178,7 @@ screen.* | 1 | **Masthead**: breadcrumb (Workspace / Goals / Goal), title, team line (Agent label or count, repository), Stop/Resume, settings | Always, on every Goal view; compacts on scroll | Identity and the one consequential control stay one glance away | | 2 | **Vitals strip**: at most six cells; each cell is a button that focuses its region | Always | Answers moving / waiting on me / how far / what cost in one line | | 3 | **Delta line**: "Since you looked at 09:12: 2 done · 1 new decision" | Only for a material delta; cost omitted until its owner qualifies the interval | The return moment the product promises | -| 4 | **Direction**: objective, acceptance criteria with verification state | Always; criteria only with a contract | What "done" means and how close it is | +| 4 | **Direction**: recorded Goal objective, separate optional acceptance criteria and verification state | Always; criteria only with a contract | What the Goal is for; what its configured checks establish | | 5 | **Needs you**: decision cards with recommendation, consequence and actions | Only when non-empty | Decisions are the owner's actual work | | 6 | **Now**: lens switch (By owner / Board / Map) and Add task | Open work exists | Who is doing what, who is blocked | | 7 | **Results**: returned outputs, with acceptance shown separately | At least one result | What came back and whether it is trusted | @@ -320,18 +320,20 @@ qualification remain required. ### 5.8 Direction and measurable target -- When an acceptance contract is enabled, show its objective and criteria. - Otherwise show the current progress sentence, labeled as the Agent's - summary. Status projects no general Goal objective today; adding one is an - owner decision (Section 12 D6), not a frontend inference from chat or Todo - text. +- Always show the Goal's recorded objective independently of acceptance. Status + transports the existing Goal document's `objective` metadata through the + typed workspace digest, with the existing public-safety filter and a 1,000 + character bound. It performs no additional source read. Missing or unreadable + content stays unavailable; a title, Todo, progress sentence, Agent Vision or + acceptance contract cannot fill it. Current progress is a separate disclosure. + An enabled acceptance contract keeps its own scoped objective and criteria. - Each criterion shows its owner state: verified (with time), failing, not yet verified, or waiting on work. A summary reads "1 of 3 verified". Observation partiality stays visible; an absence of gaps is not acceptance. -- **No contract:** a quiet line, "No measurable target declared", with a - "Declare target" action once M3 admits an App path. Until then, the line - explains plainly that targets are declared through the Goal owner's - acceptance configuration. A command-copy button is not offered as the action. +- **No contract:** a quiet "Acceptance criteria are not configured (optional)" + line when that absence is observed; an older or incomplete source says that + criteria were not provided. Existing Goal settings own configuration. This + never denies the Goal's objective, enables a contract or creates a new gate. - **M3:** declaration goes through the acceptance owner: objective, criteria descriptions, coverage scope (`selected_work` or `all_advancement`) and bindings. It follows preview, one confirmation, provider CAS and readback, and @@ -394,7 +396,7 @@ separate identity. - At 1200px and wider: sidebar, one content frame and the rail. - From 768px to 1199px: the rail becomes a drawer toggled from the masthead. - At 640px and narrower: one column in this order: masthead with an icon Stop, - vitals in a 3×2 grid, the delta line, Needs you and Now. Direction collapses, + vitals in a 3×2 grid, the delta line, Needs you and Now. Current progress collapses; the recorded Goal objective stays visible, and All work opens from the top bar. ![Phone composition](assets/goal-workspace-surface-v0/goal-workspace-phone.jpg) @@ -654,7 +656,7 @@ local packaged rendering, not cross-host latency or W2 live scale. V6 desktop/mo have been inspected; the PR's final validation record owns the full regression result. Activity covers material observations available in the existing timeline, not a complete audit or independent acceptance. Results keep their existing readback validators; run summaries cannot prove adoption. -The approved narrow-screen presentation folds Direction on phones and opens All work from the +The narrow-screen presentation keeps the Goal objective visible, folds current progress and opens All work from the masthead in a native dialog. Escape restores launcher focus; selecting a task returns to the existing inspector. History keeps its loaded state when reads are inactive, and missing remote Todo links show an unavailable notice without querying local owners. @@ -754,7 +756,7 @@ introduced; six uncalibrated process judgments retain their existing role. | D3 | App authoring scope for acceptance targets | Goal acceptance authority | Descriptions, scope and bindings with checks kept local; full check authoring; none | The first option | M3 | | D4 | Usage coverage signal | Usage RFC owner | Sample counts only; explicit measured versus observed runtimes | An explicit field before "partial" means more than sampling | M1 "partial" beyond sampling | | D5 | Dark theme default | Presentation owner | Opt-in; follow system preference | Opt-in until AA review on real data | M4 | -| D6 | Goal objective in status | Goal registry / Vision owner | Contract objective or progress summary only; an additive projected objective from the Goal's recorded creation or Vision text | The additive field, public-safe and length-bounded | A brief for Goals without a contract | +| D6 | Goal objective in status | Goal registry / Vision owner | Recorded Goal-document objective, independent of acceptance and progress; unavailable remains explicit | The additive field, public-safe and length-bounded | A brief for Goals without a contract | | D7 | Since-last-look spend | Usage RFC owner | Omit cost; owner-qualified interval aggregate or compatible monotonic counter | Omit cost until source/scope/currency/generation, interval coverage and retention are qualified; no rolling-total subtraction | Any cost in the M3 delta line | ## Appendix A: Execution ledger diff --git a/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md b/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md index 9f54f5eba9..039b9dd0aa 100644 --- a/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md +++ b/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md @@ -102,7 +102,7 @@ owner 隔了几个小时回到一个 Goal([GQ09](../../product/use-cases/stewa | 任务 | `GoalTasksView` | 分栏:待确认、待处理/运行中、已安排、已完成;按 Agent 筛选;已完成历史来自 `/api/chat/completed-todos` | 未按负责人分组;负责人为纯文本;无观察到的耗时 | | 用量 | `usage_summary` | 24h/7d token、`cost_usd`、`duration_ms`;未测量时字段缺失;`sample_run_count`、`available` | 无累计聚合;无明确的分运行时覆盖度 | | 验收 | Goal 权限经 `goal-acceptance`(预览、provider CAS、精确重放);Dashboard 只读 | 目标、标准、覆盖范围、绑定;观测投影按设计有界且不完整 | 无 App 声明路径;缺少目标时界面无提示 | -| Goal 简报 | 状态 Goal 快照 | 无通用目标字段;只有 Agent 进展句,以及启用时的验收合同目标 | 无合同的 Goal 没有稳定简报 | +| Goal 简报 | 状态 Goal 快照 | 限长的 Goal 文档目标;进展与可选验收合同独立 | 来源缺失保持未提供 | | Todo 身份 | 状态 `todoItemSchema` | 原生 Todo 以 `todo_id` 寻址,刻意不带合成序号;旧 Markdown Todo 保留 `index` | 编号侧栏会凭空制造身份 | | 活动 | `goal-activity.ts`、Agent 分道 `lastActivityAt`、宿主线程活动、运行时间线 | 工作类型标记与分道观察 | 未汇总为一个“最近观察到的活动”事实 | | 主题 | `workspace-theme.ts`(`loopx`、`paper`、`brutal`) | 浅色优先预设 | LoopX 主题没有深色反色 | @@ -121,7 +121,7 @@ owner 隔了几个小时回到一个 Goal([GQ09](../../product/use-cases/stewa | --- | --- | --- | | 页头 | Goal registry 与生命周期激活状态 | `goal_lifecycle` 类型化动作 | | 指标 | Todo 投影、attention 队列、`usage_summary`、执行观察 | 无;每格聚焦到对应区域 | -| 方向 | 验收合同与观测;进展摘要(第 12 节 D6) | 验收 owner 配置(M3,第 12 节 D3) | +| 方向 | 已记录 Goal 文档目标;独立的验收合同与进展观测(第 12 节 D6) | 验收 owner 配置检查,不改写已记录 Goal 目标(M3,第 12 节 D3) | | 等你决定 | attention 与 Gate owner;动作审阅计划 | 既有 Gate 决定动作 | | 当前工作、侧栏 | Todo 投影、执行观察、运行时间线、`goal_task_map_v0` 关系 | Todo 新建 dry-run/apply;既有抽屉动作 | | 成果 | 文件与 Goal-results owner;验收标记来自验收 owner | 既有打开/阅读动作 | @@ -142,7 +142,7 @@ owner 隔了几个小时回到一个 Goal([GQ09](../../product/use-cases/stewa | 1 | **页头**:面包屑(工作区 / 目标 / Goal)、标题、团队行(Agent 名称或数量、仓库)、停止/恢复、设置 | 始终,在每个 Goal 视图;滚动时收紧 | 身份与唯一有后果的控制始终一眼可及 | | 2 | **指标条**:最多六格,每格是聚焦对应区域的按钮 | 始终 | 一行回答在动吗/等我吗/到哪了/花多少 | | 3 | **变化行**:“自你 09:12 查看以来:完成 2 项 · 新决定 1 项” | 仅有实质变化时;成本由其 owner 核验区间后才可加入 | 产品承诺的“回来看结果”时刻 | -| 4 | **方向**:目标与带核验状态的验收标准 | 始终;有合同时显示标准 | “完成”指什么、离它多远 | +| 4 | **方向**:已记录的 Goal 目标,独立且可选的验收标准与核验状态 | 始终;有合同时显示标准 | Goal 为了什么;已配置检查能证明什么 | | 5 | **等你决定**:带建议、后果与动作的决定卡 | 仅非空时 | 决定才是 owner 真正的工作 | | 6 | **当前工作**:镜头切换(按负责人 / 看板 / 地图)与新建任务 | 有未完成工作时 | 谁在做什么、谁被阻塞 | | 7 | **成果**:已交回的产出,验收状态单独显示 | 至少一项成果时 | 交回了什么、是否可信 | @@ -249,13 +249,14 @@ attention 条目、用量、执行观察、生命周期激活、验收摘要、 ### 5.8 方向与可衡量目标 -- 启用验收合同时显示其目标与标准;否则显示当前进展句,并标明是 Agent 的摘要。状态当前 - 不投影通用 Goal 目标;新增该字段是 owner 决定(第 12 节 D6),不能由前端从对话或 Todo - 文本推断。 +- 始终独立于验收显示 Goal 已记录的目标。状态将既有 Goal 文档的 `objective` 元数据经 + 类型化工作区 digest 投影,复用公开安全过滤与 1,000 字符上限,不增加来源读取。 + 内容缺失或不可读时明确未提供,不能用标题、Todo、进展句、Agent Vision 或验收合同 + 补齐。当前进展另行折叠;启用的验收合同继续显示其自身覆盖范围内的目标与标准。 - 每条标准显示 owner 给出的状态:已核验(含时间)、未通过、尚未核验或等待工作。摘要写作 “3 项中 1 项已核验”。观测的不完整性保持可见:没有缺口不等于已验收。 -- **无合同:** 安静显示“未声明可衡量目标”;M3 提供 App 路径后附“声明目标”动作。在此之前, - 该行用平实语言说明目标经由 Goal owner 的验收配置声明,不以复制命令按钮作为动作。 +- **无合同:** 已观察到未配置时安静显示“验收标准未配置(可选)”;旧来源或读取不完整时 + 说明标准尚未提供。配置复用既有 Goal 设置;这不否定 Goal 目标、不启用合同或新增门禁。 - **M3:** 声明经由验收 owner:目标、标准描述、覆盖范围(`selected_work` 或 `all_advancement`)与绑定。流程为预览、一次确认、provider CAS 与回读,冲突时显示更新的 修订。可执行检查仍留在 owner 本地文件中(第 12 节 D3)。 @@ -303,7 +304,7 @@ attention 条目、用量、执行观察、生命周期激活、验收摘要、 - 1200px 及以上:侧边栏、一个内容框和侧栏。 - 768px 至 1199px:侧栏变为从页头切换的抽屉。 - 640px 及以下:单列,顺序为带图标停止的页头、3×2 指标、变化行、等你决定、当前工作; - 方向折叠,全部工作从顶栏打开。 + 当前推进折叠,已记录的 Goal 目标保持可见,全部工作从顶栏打开。 ![手机组合](assets/goal-workspace-surface-v0/goal-workspace-phone.jpg) @@ -460,7 +461,7 @@ Agent/执行 owner 准备并完成分析。不要求用户填写来源 id、选 | M4 实时与表现力 | 由执行观察 owner 在行与地图上显示 W2 实时状态;事件动效;快捷键与命令面板;深色反色 | M2;W2 观察可用 | V6(动效、深色);团队实时 W2 验收 | 关闭动效/主题 | **检查点 2026-10-12(M2 候选,#6255)。** -已批准的窄屏呈现在手机默认折叠方向,通过顶部「全部工作」打开原生对话框;Escape 返回入口焦点,选择任务沿用已有检查器。非活动视图暂停读取而保留已载入历史;未知远端任务链接明确显示不可用,不查询本机 owner。 +窄屏呈现在手机保持 Goal 目标可见、默认折叠当前推进,通过顶部「全部工作」打开原生对话框;Escape 返回入口焦点,选择任务沿用已有检查器。非活动视图暂停读取而保留已载入历史;未知远端任务链接明确显示不可用,不查询本机 owner。 按负责人、未分配与决策关联任务由既有 TypeScript digest 分类;页头、Now、工作侧栏和看板 共用该结果。Map 与交付区域共用既有快照读取,只有当前可用的 typed dependency 才影响等待分类; @@ -537,7 +538,7 @@ CLI 准备复用 producer 既有接口;本切片未验收新增飞书交互入 | D3 | App 编写验收目标的范围 | Goal 验收权限 | 只含描述、范围与绑定,检查留在本地;完整编写检查;不提供 | 第一项 | M3 | | D4 | 用量覆盖度信号 | 用量 RFC owner | 仅采样计数;明确区分已测量与已观察的运行时 | 在“不完整”超出采样含义之前新增明确字段 | M1 中超出采样含义的“不完整” | | D5 | 深色主题默认值 | 展示 owner | 手动开启;跟随系统 | 在真实数据上完成 AA 评审前保持手动开启 | M4 | -| D6 | 状态中的 Goal 目标 | Goal registry / Vision owner | 只用合同目标或进展摘要;由 Goal 创建时记录或 Vision 文本投影的增量目标字段 | 采用可公开、限长的增量字段 | 为无合同 Goal 提供简报 | +| D6 | 状态中的 Goal 目标 | Goal registry / Vision owner | 独立于验收和进展投影已记录 Goal 文档目标;来源缺失保持明确 | 采用可公开、限长的增量字段 | 为无合同 Goal 提供简报 | | D7 | 自上次查看的花费 | 用量 RFC owner | 省略成本;owner 核验的区间聚合或兼容单调计数 | 来源/范围/币种/代际、区间覆盖度和保留期核验前省略;不减滚动总额 | M3 变化行中的任何成本 | ## 附录 A:执行账本 diff --git a/examples/personal-workspace-browser/assets/goal-objective-closeout/after-desktop.png b/examples/personal-workspace-browser/assets/goal-objective-closeout/after-desktop.png new file mode 100644 index 0000000000..f018eccf0e Binary files /dev/null and b/examples/personal-workspace-browser/assets/goal-objective-closeout/after-desktop.png differ diff --git a/examples/personal-workspace-browser/assets/goal-objective-closeout/after-phone.png b/examples/personal-workspace-browser/assets/goal-objective-closeout/after-phone.png new file mode 100644 index 0000000000..9a3aefac45 Binary files /dev/null and b/examples/personal-workspace-browser/assets/goal-objective-closeout/after-phone.png differ diff --git a/examples/personal-workspace-browser/assets/goal-objective-closeout/before-desktop.png b/examples/personal-workspace-browser/assets/goal-objective-closeout/before-desktop.png new file mode 100644 index 0000000000..708419cb85 Binary files /dev/null and b/examples/personal-workspace-browser/assets/goal-objective-closeout/before-desktop.png differ diff --git a/examples/personal-workspace-browser/assets/goal-objective-closeout/before-phone.png b/examples/personal-workspace-browser/assets/goal-objective-closeout/before-phone.png new file mode 100644 index 0000000000..c181bb8762 Binary files /dev/null and b/examples/personal-workspace-browser/assets/goal-objective-closeout/before-phone.png differ diff --git a/examples/personal-workspace-browser/assets/goal-objective-closeout/enabled-phone.png b/examples/personal-workspace-browser/assets/goal-objective-closeout/enabled-phone.png new file mode 100644 index 0000000000..aaac49968e Binary files /dev/null and b/examples/personal-workspace-browser/assets/goal-objective-closeout/enabled-phone.png differ diff --git a/loopx/control_plane/presentation/goal_workspace_digest.ts b/loopx/control_plane/presentation/goal_workspace_digest.ts index 035bfe4980..7d788d5121 100644 --- a/loopx/control_plane/presentation/goal_workspace_digest.ts +++ b/loopx/control_plane/presentation/goal_workspace_digest.ts @@ -52,6 +52,8 @@ export type DigestActivity = { actor: string; at: string | null }; export type GoalWorkspaceDigestInput = { observedAt: string; + /** Declared Goal document content; never an acceptance-contract or progress fallback. */ + objective?: string | null; activation: "active" | "stopped"; todos: readonly DigestTodo[]; /** Owner-projected done count; the item list carries only recent completions. */ @@ -76,6 +78,7 @@ export type DigestCoverage = "complete" | "partial" | "unknown"; export type GoalWorkspaceDigest = { schema: typeof GOAL_WORKSPACE_DIGEST_SCHEMA; observedAt: string; + objective: string | null; lifecycle: { activation: "active" | "stopped"; finishingTurn: boolean }; work: { open: number; @@ -235,6 +238,7 @@ export function compileGoalWorkspaceDigest(input: GoalWorkspaceDigestInput): Goa return { schema: GOAL_WORKSPACE_DIGEST_SCHEMA, observedAt: input.observedAt, + objective: input.objective?.trim() || null, lifecycle: { activation: input.activation, finishingTurn: input.activation === "stopped" && executionObserved }, work: { open: openWork.length, diff --git a/loopx/control_plane/runtime/run_history.py b/loopx/control_plane/runtime/run_history.py index 3213a4f38b..ba441ff946 100644 --- a/loopx/control_plane/runtime/run_history.py +++ b/loopx/control_plane/runtime/run_history.py @@ -95,6 +95,7 @@ def build_run_history( "activation_state": goal.get("activation_state") or "active", "registry_goal_digest": goal.get("registry_goal_digest"), "display_name": goal.get("display_name"), + "goal_objective": goal.get("goal_objective"), "domain": goal.get("domain"), "status": goal.get("status"), "lifecycle_phase": lifecycle_fields["lifecycle_phase"], diff --git a/loopx/control_plane/todos/active_state_todos.py b/loopx/control_plane/todos/active_state_todos.py index 273b4f1df3..a23b196c8d 100644 --- a/loopx/control_plane/todos/active_state_todos.py +++ b/loopx/control_plane/todos/active_state_todos.py @@ -9,6 +9,8 @@ canonical_todo_summary_fields, read_canonical_todos_if_promoted, ) +from ..goals.active_state_metadata import parse_state_frontmatter +from ..runtime.public_safety import public_safe_compact_text from .succession_warning import public_todo_summary from .resume_condition import TodoResumeRolloutEvents @@ -145,6 +147,12 @@ def active_state_todo_fields( preferred_todo_ids=preferred_todo_ids, rollout_events=events, ) + # Transport the Goal document's declared objective, independently of the + # optional acceptance contract. This is display content, never a write basis + # or a target inferred from Todos, progress, model output or a contract. + fields["goal_objective"] = public_safe_compact_text( + parse_state_frontmatter(state_text).get("objective"), limit=1000, + ) issue_meta_surface = parse_issue_meta_surface(state_text) if issue_meta_surface: fields["issue_meta_surface"] = issue_meta_surface diff --git a/loopx/control_plane/work_items/attention_queue.py b/loopx/control_plane/work_items/attention_queue.py index e264c9048f..b28ca6ef2b 100644 --- a/loopx/control_plane/work_items/attention_queue.py +++ b/loopx/control_plane/work_items/attention_queue.py @@ -144,6 +144,18 @@ def _reconcile_active_state_item( return reconciled["item"], reconciled["warning"] +def _active_state_goal_context( + goal: dict[str, Any], + current_run: dict[str, Any] | None, + context: AttentionQueueContext, + runtime_root: Path | None, +) -> tuple[dict[str, Any], dict[str, Any] | None]: + fields = context.active_state_todo_fields(goal, runtime_root=runtime_root) + # Goal content survives even when no work creates an attention item. + goal["goal_objective"] = fields.get("goal_objective") + return fields, context.active_state_todo_attention_item(goal, fields, current_run) + + def build_attention_queue( *, contract: dict[str, Any], @@ -190,7 +202,9 @@ def build_attention_queue( goal_latest_runs = goal.get("latest_runs") if isinstance(goal.get("latest_runs"), list) else [] if goal.get("registry_member"): try: - active_state_fields = context.active_state_todo_fields(goal, runtime_root=runtime_root) + active_state_fields, active_state_item = _active_state_goal_context( + goal, current_status_run, context, runtime_root, + ) except RetiredTodoEventSourceError as error: # Refuse this source without hiding independent Goals or turning # stale Todo/run projections into executable fallback work. @@ -206,11 +220,6 @@ def build_attention_queue( item["activation_state"] = str(goal.get("activation_state") or "active") history_items.append(item) continue - active_state_item = context.active_state_todo_attention_item( - goal, - active_state_fields, - current_status_run, - ) if active_state_item and active_state_item.get("waiting_on") in {"controller", "user_or_controller"}: item = active_state_item else: @@ -292,7 +301,8 @@ def build_attention_queue( if goal.get("registry_member"): if active_state_fields is None: active_state_fields = context.active_state_todo_fields(goal, runtime_root=runtime_root) - item.update(active_state_fields) + item.update({key: value for key, value in active_state_fields.items() + if key != "goal_objective"}) context.sync_connected_attention_action_from_todos(item) external_progress_review = ( context.external_progress_review_context(goal, runtime_root) diff --git a/tests/control_plane_ts/goal_workspace_digest.test.ts b/tests/control_plane_ts/goal_workspace_digest.test.ts index 21e7e003cd..7813ef80a2 100644 --- a/tests/control_plane_ts/goal_workspace_digest.test.ts +++ b/tests/control_plane_ts/goal_workspace_digest.test.ts @@ -14,6 +14,16 @@ const base: GoalWorkspaceDigestInput = { observedAt, activation: "active", todos const running = (observed: boolean, quiet = false, lastActivityAt: string | null = "2026-10-11T09:58:00.000Z") => ({ kind: "running" as const, observed, quiet, lastActivityAt }); +test("a declared Goal objective is independent of work, progress and optional acceptance", () => { + assert.equal(compileGoalWorkspaceDigest({ ...base, objective: " Preserve the original research question. " }).objective, + "Preserve the original research question."); + for (const objective of [undefined, null, "", " "]) { + const digest = compileGoalWorkspaceDigest({ ...base, objective, + todos: [{todoId: "work", done: false}], execution: running(true) }); + assert.equal(digest.objective, null, "Missing objective content is not inferred from activity or work"); + } +}); + test("absent usage is unknown, never zero, and a measured zero stays zero", () => { for (const usage of [undefined, null, {}]) { const digest = compileGoalWorkspaceDigest({ ...base, usage }); diff --git a/tests/presentation/test_goal_objective.py b/tests/presentation/test_goal_objective.py new file mode 100644 index 0000000000..e8e7d322e2 --- /dev/null +++ b/tests/presentation/test_goal_objective.py @@ -0,0 +1,112 @@ +"""Declared Goal content survives the real status/HTTP path without a contract.""" + +from __future__ import annotations + +import http.client +import json +import runpy +import threading +from pathlib import Path + +import pytest + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, +) +from loopx.status import active_state_todo_fields + +# Reuse the provider fixture across test directories without duplicating a +# File/SQLite initializer or depending on pytest's collection/import order. +_providers = runpy.run_path( + str(Path(__file__).parents[1] / "control_plane/canonical_authority_fixture.py") +) + + +@pytest.fixture(params=["file", "sqlite"]) +def objective_source(tmp_path, monkeypatch, request): + _providers["isolate_sqlite_runtime"](tmp_path, monkeypatch) + state = tmp_path / "state.md" + state.write_text( + '---\nobjective: "Answer the original research question."\n---\n\n# Goal\n' + ) + runtime = tmp_path / "runtime" + goal = { + "id": "research", + "display_name": "A different display title", + "repo": str(tmp_path), + "state_file": "state.md", + "adapter": {"kind": "read_only_project_map_v0"}, + } + projection = build_todo_runtime_shadow_projection(goal_id="research", todos=[]) + _providers["initialize_canonical_authority"]( + runtime, "research", projection, state_path=state, provider=request.param + ) + registry = tmp_path / "registry.json" + registry.write_text( + json.dumps( + {"schema_version": 1, "common_runtime_root": str(runtime), "goals": [goal]} + ) + ) + return goal, registry, runtime, state + + +def test_declared_objective_is_not_an_acceptance_contract_or_task(objective_source): + goal, _, runtime, state = objective_source + assert ( + active_state_todo_fields(goal, runtime_root=runtime)["goal_objective"] + == "Answer the original research question." + ) + for content in [ + "# Goal\n## Next Action\nInfer nothing from this progress.\n", + '---\nobjective: ""\n---\n', + ]: + state.write_text(content) + assert ( + active_state_todo_fields(goal, runtime_root=runtime)["goal_objective"] + is None + ) + state.write_text('---\nobjective: "' + "Declared text. " * 200 + '"\n---\n') + assert ( + len(active_state_todo_fields(goal, runtime_root=runtime)["goal_objective"]) + <= 1000 + ) + state.unlink() + assert ( + active_state_todo_fields(goal, runtime_root=runtime)["goal_objective"] is None + ) + + +def test_real_status_http_keeps_an_empty_goals_declared_objective(objective_source): + _, registry, runtime, state = objective_source + before = registry.read_bytes(), state.read_bytes() + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path, server.runtime_root, server.runtime_root_override = ( + registry, + runtime, + str(runtime), + ) + server.selected_goal_id, server.scan_roots, server.limit, server.verbose = ( + None, + [], + 10, + False, + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + connection = http.client.HTTPConnection(*server.server_address, timeout=45) + connection.request("GET", "/status.json?goal_id=research") + response = connection.getresponse() + payload = json.loads(response.read()) + connection.close() + assert response.status == 200, payload + assert ( + payload["run_history"]["goals"][0]["goal_objective"] + == "Answer the original research question." + ) + assert (registry.read_bytes(), state.read_bytes()) == before + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close()