From 14735b5bca411d4abc13dd8efb2fc332b6f8efa1 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Mon, 26 Oct 2026 15:09:34 +0800 Subject: [PATCH 1/2] fix(explore): require explicit finding judgments in writeback templates Signed-off-by: huangruiteng --- loopx/capabilities/explore/README.md | 10 ++++++++ loopx/capabilities/explore/README.zh-CN.md | 10 ++++++++ .../capabilities/explore_result_writeback.ts | 8 ++++--- .../test_explore_result_writeback.py | 4 +++- .../explore_result_writeback.test.ts | 23 ++++++++++++++++++- 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index af0cce92f8..8a7a83ed3b 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -411,6 +411,16 @@ or block valid writeback. Successful replay needs no second graph event or Todo mutation. This changes enabled evidence capture and explicit Todo-link edits; ordinary work and feature-off hooks retain their existing behavior. +Writeback templates leave `status` blank so the caller explicitly selects +`tentative`, `confirmed`, or `refuted`. `tentative` means the interpretation is +unresolved, not that a verification job is queued. Choose `confirmed` or +`refuted` only for the stated conclusion within its recorded applicability; +evidence references or a successful check alone do not settle broader claims. +Keep established observations, remaining uncertainty and the next useful check +in the interpretation. Use the existing optional `question_update` to close an +answered question independently of finding status or overall Goal completion. +Existing recorded statuses and the CLI finding default are unchanged. + Use `tentative` for inconclusive observations or prerequisite failures. Neither compilation failure nor a score alone supplies a scientific interpretation. Routine work without reusable evidence needs no attachment. Omitting the option diff --git a/loopx/capabilities/explore/README.zh-CN.md b/loopx/capabilities/explore/README.zh-CN.md index 97e0e95aa6..150badad63 100644 --- a/loopx/capabilities/explore/README.zh-CN.md +++ b/loopx/capabilities/explore/README.zh-CN.md @@ -629,3 +629,13 @@ python3 -m pytest -q \ smoke 证明投影契约(折叠、blocked reasons、树、Mermaid)、记录时路径拒绝、dry-run 默认、分页发现、零写幂等 resync、单行漂移修复、嵌套 create-receipt 处理、shared 可见性脱敏、免传输卡片内容、opt-in todo branch-plan packet、adaptive resilient worker harness 配置档,以及针对临时 registry 的 CLI 表面,无需 live Lark credentials。它还证明 worker-lane router 契约:请求宽度不再被静默压到 worker 上限以下、独立 lane 准入下空闲 lanes 是队列耗尽(不是上限)、routing bias 在不动 value bookkeeping 的情况下重排 lanes、confident-prefix bundles 在校准阈值截断并对 reject-heavy families 坍缩、router-state novelty ledger 跨 epochs 去重而 coverage debt 累积 bias、观测负载配置档通过 CLI flags 校准准入。 runtime smoke 与聚焦 pytest 模块还覆盖:可恢复 prefix 复用、prefix/suffix novelty 与双复用记账、按 suffix 数加权的 router acceptance、显式 legacy fallback、重启兼容、recorded 与 fatal 失败下的清理、无可变状态别名的并发组、fatal 结构预检、record-mode 畸形 item 隔离与 resume 连续性、聚合指标投影,以及无 legacy `execute` 方法的 episode-only adapters。 + +### 结论状态与问题收束 + +写回模板的 `status` 留空,由调用方明确选择 `tentative`、`confirmed` 或 +`refuted`。`tentative` 在研究界面显示为“暂定结论”,表示尚未确认或反驳, +不表示有检查任务排队。确认与反驳仅适用于记录中声明的结论和范围;证据引用、 +检查通过或某个分数不能自动证明更广泛的收益。 +在解读中区分已建立的观察、仍未知的结论和下一步有用的验证。问题已回答时, +使用已有可选 `question_update` 收束,不因整体 Goal 未完成而一直保持开放, +也不因结论被确认就自动关闭问题。历史状态与 CLI finding 默认值保持兼容。 diff --git a/loopx/control_plane/capabilities/explore_result_writeback.ts b/loopx/control_plane/capabilities/explore_result_writeback.ts index bae30993e8..99154c8ff8 100644 --- a/loopx/control_plane/capabilities/explore_result_writeback.ts +++ b/loopx/control_plane/capabilities/explore_result_writeback.ts @@ -45,11 +45,11 @@ export function exploreResultWritebackAffordance(): JsonObject { path_delta_attachment_schema: EXPLORE_PATH_DELTA_ATTACHMENT_SCHEMA, path_delta_attachment_template: { schema_version: EXPLORE_PATH_DELTA_ATTACHMENT_SCHEMA, - question: "", applicability: "", input_revision: "", status: "tentative", + question: "", applicability: "", input_revision: "", status: "", }, linked_question_attachment_template: { schema_version: EXPLORE_PATH_DELTA_ATTACHMENT_SCHEMA, - node_id: "", input_revision: "", status: "tentative", + node_id: "", input_revision: "", status: "", }, optional_question_evolution_example: { question_update: {status: "resolved"}, @@ -77,6 +77,8 @@ export function exploreResultWritebackAffordance(): JsonObject { "The hook reuses the complete observation and route decision.", "For an existing question linked to this Todo, use linked_question_attachment_template: omit both question and applicability to reuse its canonical scope.", "New or unlinked questions still require explicit scope; input_revision and status are never inferred.", + "Choose status explicitly: tentative for unresolved interpretation, insufficient evidence or failed prerequisites; confirmed when evidence supports the stated conclusion within its applicability; refuted when evidence contradicts that conclusion within its applicability. Evidence references, a successful check, a score or a route decision alone do not choose the status.", + "Separate established observations from unresolved broader claims in interpretation; state what remains unknown and the next discriminating check when useful. Revisit the linked question at a meaningful conclusion using question_update; do not keep it open solely because the overall Goal is unfinished, or close it merely because a finding is confirmed.", "If path_delta also references local files, explicitly select its opaque identifiers with optional evidence_refs; selected refs must occur in this same path_delta.", "Without a selection, all refs must be opaque identifiers.", "Otherwise fill attachment_template, inline or via --explore-result-json.", @@ -98,7 +100,7 @@ export function exploreResultWritebackAffordance(): JsonObject { attachment_template: { schema_version: EXPLORE_RESULT_ATTACHMENT_SCHEMA, question: "", applicability: "", input_revision: "", - observation: "", interpretation: "", status: "tentative", evidence_refs: [], + observation: "", interpretation: "", status: "", evidence_refs: [], }, }; } diff --git a/tests/control_plane/test_explore_result_writeback.py b/tests/control_plane/test_explore_result_writeback.py index 063f022c8f..c9f383fb34 100644 --- a/tests/control_plane/test_explore_result_writeback.py +++ b/tests/control_plane/test_explore_result_writeback.py @@ -185,7 +185,7 @@ def interrupt(**kwargs): assert second["ok"] and second["graph"]["appended_event_count"] == 0 -@pytest.mark.parametrize("case", ["disabled", "null", "invalid", "conflict", "off-without-result", +@pytest.mark.parametrize("case", ["disabled", "null", "invalid", "blank-status", "conflict", "off-without-result", "path-disabled", "path-missing", "path-no-evidence", "path-overflow"]) def test_real_cli_inline_validation_precedes_primary_commit(tmp_path, case): from canonical_authority_fixture import initialize_canonical_authority @@ -228,6 +228,8 @@ def test_real_cli_inline_validation_precedes_primary_commit(tmp_path, case): packet["explore_result"] = None if case == "invalid": packet["explore_result"]["evidence_refs"] = [] + if case == "blank-status": + packet["explore_result"]["status"] = "" if case.startswith("path-"): packet["explore_result"] = { key: ATTACHMENT[key] for key in diff --git a/tests/control_plane_ts/explore_result_writeback.test.ts b/tests/control_plane_ts/explore_result_writeback.test.ts index c8db7a8658..3228ec6560 100644 --- a/tests/control_plane_ts/explore_result_writeback.test.ts +++ b/tests/control_plane_ts/explore_result_writeback.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import {normalizeExploreResultAttachment, planExploreQuestionEvolution} from "../../loopx/control_plane/capabilities/explore_result_writeback.ts"; +import {exploreResultWritebackAffordance, normalizeExploreResultAttachment, planExploreQuestionEvolution} from "../../loopx/control_plane/capabilities/explore_result_writeback.ts"; const attachment = { schema_version: "explore_result_attachment_v0", node_id: "tail-bound", @@ -292,3 +292,24 @@ test("scope reuse rejects unknown, unlinked, non-question and incomplete scopes" assert.deepEqual(normalizeExploreResultAttachment({attachment: scope, vision_packet: {path_delta: delta}}), resolved); }); + +// Filling factual fields must not silently choose an epistemic judgment. +test("writeback templates require a deliberate finding status independently of question closure", () => { + const affordance = exploreResultWritebackAffordance(); + for (const key of ["attachment_template", "path_delta_attachment_template", "linked_question_attachment_template"]) { + const template = affordance[key] as Record; + const path = key !== "attachment_template"; + const input = path ? {...scope, status: template.status} : {...attachment, status: template.status}; + const normalize = (row: Record) => normalizeExploreResultAttachment({ + attachment: row, ...(path ? {vision_packet: {path_delta: delta}} : {}), + }); + assert.throws(() => normalize(input), /finding status/); + for (const status of ["tentative", "confirmed", "refuted"]) { + const chosen = normalize({...input, status}); + assert.equal(chosen.status, status); + assert.equal(Object.hasOwn(chosen, "question_update"), false); + assert.deepEqual(normalize({...input, status, question_update: {status: "resolved"}}).question_update, + {status: "resolved"}); + } + } +}); From 1e14056be60d771ff3645d0bd72c58831b91d802 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Mon, 26 Oct 2026 15:09:59 +0800 Subject: [PATCH 2/2] fix(research): distinguish tentative findings from queued checks Signed-off-by: huangruiteng --- .../explore-finding-copy.ts | 13 +++++++++++ .../goal-explore-results.tsx | 7 ++++-- .../goal-research-history.tsx | 6 +++-- .../goal-research-timeline.tsx | 8 ++++--- .../research-exploration-control-plane-v0.md | 6 +++++ ...arch-exploration-control-plane-v0.zh-CN.md | 4 ++++ docs/development/design.md | 2 +- .../research-finding-status/README.md | 22 ++++++++++++++++++ .../research-finding-status/after-desktop.png | Bin 0 -> 110202 bytes .../after-help-desktop.png | Bin 0 -> 132664 bytes .../after-reader-phone.png | Bin 0 -> 53704 bytes .../before-desktop.png | Bin 0 -> 117526 bytes .../research-route-reading.mjs | 10 ++++++++ 13 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/explore-finding-copy.ts create mode 100644 examples/personal-workspace-browser/evidence/research-finding-status/README.md create mode 100644 examples/personal-workspace-browser/evidence/research-finding-status/after-desktop.png create mode 100644 examples/personal-workspace-browser/evidence/research-finding-status/after-help-desktop.png create mode 100644 examples/personal-workspace-browser/evidence/research-finding-status/after-reader-phone.png create mode 100644 examples/personal-workspace-browser/evidence/research-finding-status/before-desktop.png diff --git a/apps/presentation/dashboard/src/features/personal-workspace/explore-finding-copy.ts b/apps/presentation/dashboard/src/features/personal-workspace/explore-finding-copy.ts new file mode 100644 index 0000000000..1c15bd7e53 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/explore-finding-copy.ts @@ -0,0 +1,13 @@ +/** Source-reported finding meaning, shared by history and saved-result readers. + * These labels do not classify evidence or schedule verification. */ +export function exploreFindingCopy(zh: boolean) { + return zh ? { + labels: {tentative: "暂定结论", confirmed: "来源记为确认", refuted: "已反驳"}, + help: "结论状态说明", + meaning: "暂定结论表示来源尚未确认或反驳该结论,可能已有观测与证据;不表示有检查任务排队。确认与反驳也仅表示来源在所述适用范围内的判断,不等于独立验证或目标完成。研究问题是否收束、下一步继续或转向,分别记录。", + } : { + labels: {tentative: "Tentative", confirmed: "Recorded confirmation", refuted: "Refuted"}, + help: "About finding states", + meaning: "Tentative means the source has not confirmed or refuted the finding; observations and evidence may already exist. It does not mean a check is queued. Confirmation and refutation are source judgments within the stated scope, not independent verification or Goal completion. Question closure and the decision to continue or replan are recorded separately.", + }; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx index caf263094c..33691654da 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx @@ -1,3 +1,4 @@ +import {exploreFindingCopy} from "./explore-finding-copy"; import {useEffect, useRef, useState} from "react"; import {ChevronRight, RefreshCw} from "lucide-react"; import {fetchExploreResults, readTodoRequest, type ExploreResultPage} from "../../data/chat"; @@ -59,9 +60,10 @@ export function GoalExploreResults({goalId, zh, active = true, readSourceUrl}: { }, [goalId, cursor, revision, active, readSourceUrl]); const selected = page?.items.find(item => item.finding_id === selectedId); const sections = selected ? parseExploreFindingSections(selected.summary) : null; + const findingCopy = exploreFindingCopy(zh); const statuses: Record = zh - ? {tentative: "待检查", confirmed: "来源记为确认", refuted: "已反驳", open: "未完成", done: "已完成", blocked: "受阻", deferred: "已延后"} - : {tentative: "Tentative", confirmed: "Recorded confirmation", refuted: "Refuted", open: "Open", done: "Done", blocked: "Blocked", deferred: "Deferred"}; + ? {...findingCopy.labels, open: "未完成", done: "已完成", blocked: "受阻", deferred: "已延后"} + : {...findingCopy.labels, open: "Open", done: "Done", blocked: "Blocked", deferred: "Deferred"}; const outcomes: Record = zh ? {replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定"} : {replan: "Replan", continue: "Continue", stop: "Stop", wait: "Wait", no_change: "No change", ask_human: "Ask human"}; @@ -81,6 +83,7 @@ export function GoalExploreResults({goalId, zh, active = true, readSourceUrl}: {

{zh ? "已保存的结论" : "Saved findings"}

{zh ? "来源记录的观察与结论,保存不代表已经验证或被后续决策采用。关联任务是原记录的显式关联。" : "Source-recorded observations and conclusions. Saving does not establish validation or adoption. Linked tasks are explicit source relationships."}

+

{findingCopy.meaning}

{busy ?

{zh ? "正在读取证据…" : "Reading evidence…"}

: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index 61acc09efe..4552545bf7 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -1,3 +1,4 @@ +import {exploreFindingCopy} from "./explore-finding-copy"; import {AlertCircle, ArrowLeft, ArrowRight, Circle, CircleHelp, FlaskConical, GitBranch, RefreshCw, Search} from "lucide-react"; import {Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from "react"; import {defaultParseSearch} from "@tanstack/react-router"; @@ -106,7 +107,8 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re }; // Labels reuse Explore's existing vocabulary; they confer no verification state. const kindLabels: Record = zh ? {question: "问题", hypothesis: "假设", experiment: "尝试", artifact: "产物", area: "路线"} : {question: "Question", hypothesis: "Hypothesis", experiment: "Attempt", artifact: "Artifact", area: "Route"}; - const stateLabels: Record = zh ? {open: "待探索", exploring: "探索中", blocked: "受阻", resolved: "已收束", dead_end: "已排除", tentative: "待检查", confirmed: "来源记为确认", refuted: "已反驳"} : {open: "Open", exploring: "Exploring", blocked: "Blocked", resolved: "Resolved", dead_end: "Ruled out", tentative: "Tentative", confirmed: "Recorded confirmation", refuted: "Refuted"}; + const findingCopy = exploreFindingCopy(zh); + const stateLabels: Record = zh ? {open: "待探索", exploring: "探索中", blocked: "受阻", resolved: "已收束", dead_end: "已排除", ...findingCopy.labels} : {open: "Open", exploring: "Exploring", blocked: "Blocked", resolved: "Resolved", dead_end: "Ruled out", ...findingCopy.labels}; const relationLabels: Record = zh ? {subtopic_of: "属于", depends_on: "依赖", answers: "回答", supports: "支持", refutes: "反驳", leads_to: "引向"} : {subtopic_of: "Part of", depends_on: "Depends on", answers: "Answers", supports: "Supports", refutes: "Refutes", leads_to: "Leads to"}; const kindLabel = (kind: string) => kindLabels[kind] ?? kind; const stateLabel = (state: string) => stateLabels[state] ?? state; @@ -464,7 +466,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
-

{kindLabel(selected.latest.node_kind)} · {stateLabel(selected.latest.status)}

+
{kindLabel(selected.latest.node_kind)} · {stateLabel(selected.latest.status)}

{findingCopy.meaning}

{selected.findings.length > 1 && findingExceptions ?

{findingExceptions}

: null}
{selected.findings.length ? selectedFinding?.title !== selected.latest.title ?
setQuestionExpanded(event.currentTarget.open)}> diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx index ffb4354ba0..e9c40c0cba 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx @@ -1,3 +1,4 @@ +import {exploreFindingCopy} from "./explore-finding-copy"; import {CircleHelp} from "lucide-react"; import {useLayoutEffect, useRef, useState, type CSSProperties} from "react"; import type {ExploreTimeline, TimelineFinding, TimelineNode, TimelinePhase} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js"; @@ -32,6 +33,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da observer.observe(element); return () => observer.disconnect(); }, [timeline.start, timeline.end]); + const findingCopy = exploreFindingCopy(zh); const copy: Copy = zh ? { early: "前段", middle: "中段", late: "后段", items: "研究项", findings: "结论", decisions: "显式决策", none: "未记录显式决策", changed: "变化", stopped: "停止", retained: "保留", @@ -40,7 +42,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da routes: "研究路线", routeHint: "仅由显式关联或父子分组相连", singles: "未关联研究项", trail: "决策轨迹", more: "显示更多决策", all: "全部时段", span: "记录跨度", start: "起点", end: "末端", replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定", - confirmed: "来源记为确认", refuted: "已反驳", tentative: "待检查", unlabeled: "无结论", phaseNote: "按记录时间三等分,仅为阅读分组,不是预声明的评估窗口。", + ...findingCopy.labels, unlabeled: "无结论", phaseNote: "按记录时间三等分,仅为阅读分组,不是预声明的评估窗口。", noTime: "没有可用的记录时间,无法放上时间轴;下方记录仍可完整阅读。", } : { early: "Early", middle: "Middle", late: "Late", items: "Research items", findings: "Findings", @@ -50,7 +52,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da routes: "Research routes", routeHint: "Connected only by recorded relationships or parent grouping", singles: "Unlinked research items", trail: "Decision history", more: "Show more decisions", all: "Whole history", span: "Recorded span", start: "Start", end: "End", replan: "Replan", continue: "Continue", stop: "Stop", wait: "Wait", no_change: "No change", ask_human: "Ask human", - confirmed: "Recorded confirmation", refuted: "Refuted", tentative: "Tentative", unlabeled: "No finding", phaseNote: "Equal thirds of record time, for reading only; not a pre-declared evaluation window.", + ...findingCopy.labels, unlabeled: "No finding", phaseNote: "Equal thirds of record time, for reading only; not a pre-declared evaluation window.", noTime: "No usable record time; the history cannot be placed on a time axis. All records remain readable below.", }; const {start, end} = timeline; @@ -115,7 +117,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da

{zh ? "未关联问题按首次记录 UTC 日期收纳,不推断路线或因果。决策轨迹保留各轮显式决策,最新记录在前。" : "Unlinked questions group by first-record UTC date, without inferring a route or cause. Decision history retains each explicit decision, newest record first."}

{timeline.phases.map(item =>
{phaseName(item)}
{phaseDescription(item)}
)}
{phase !== null ? : null}
- {LABELS.map(key =>