diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 92a4b8d1be..2c933d0640 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -52,7 +52,7 @@ "smoke:task-board-scroll": "node smoke/task-board-scroll-smoke.mjs", "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 --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: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: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", diff --git a/apps/presentation/dashboard/smoke/attention-details-smoke.ts b/apps/presentation/dashboard/smoke/attention-details-smoke.ts index 4cf3058fdc..f1fa503631 100644 --- a/apps/presentation/dashboard/smoke/attention-details-smoke.ts +++ b/apps/presentation/dashboard/smoke/attention-details-smoke.ts @@ -1,3 +1,7 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AttentionDetailCard } from "../src/features/personal-workspace/attention-detail-card"; +import { WorkspaceI18nProvider } from "../src/features/personal-workspace/i18n"; import { todoItemSchema } from "../src/data/status"; import { attentionDetails, attentionSuccessor, canReviewAttention, refreshAttention, sourceAttention } from "../src/features/personal-workspace/attention-details"; import { normalizePersonalHomeModel, type WorkspaceAttention } from "../src/features/personal-workspace/personal-workspace-model"; @@ -55,3 +59,14 @@ const healthySource = sourceAttention(row, "source-b", true, "Healthy Goal"); assert(canReviewAttention(refreshAttention(healthySource, [failedSource, healthySource])), "another source failure cannot fence healthy source"); const healthyGoal = sourceAttention({ ...row, goalId: "healthy-goal" }, "source-a", true); assert(canReviewAttention(refreshAttention(healthyGoal, [failedSource, healthyGoal])), "another Goal read failure cannot fence healthy Goal"); + +const longRequest = "Review the public release evidence. ".repeat(12) + "Publish version 2.0 to stable only after acceptance."; +const longSource = todoItemSchema.parse({ ...source, text: longRequest }); +const longRow = { ...row, text: "Release review", details: attentionDetails(longSource) }; +const longModel = normalizePersonalHomeModel({ blockingTodoCount: 1, goals: [], openUserTodoCount: 1, userTodos: [longRow], attentionHistory: [longRow] }); +assert(longModel.userTodos[0]?.details?.requestText === longRequest, "App decision detail retains the object after the short card label"); +assert(longModel.userTodos[0]?.details?.evidence === source.evidence, "App decision evidence retained with the full request"); + +const markup = renderToStaticMarkup(createElement(WorkspaceI18nProvider, null, createElement(AttentionDetailCard, { item: longModel.userTodos[0] }))); +assert(markup.includes("Publish version 2.0 to stable only after acceptance."), "rendered App detail contains object beyond compact label"); +assert(markup.includes(source.evidence), "rendered App detail contains evidence"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-detail-card.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attention-detail-card.tsx index be3a399833..cf6119efe7 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attention-detail-card.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-detail-card.tsx @@ -10,6 +10,7 @@ export function AttentionDetailCard({ item, successor, onSelect }: { const detail = item.details; return

{t("attentionDetail.title")}

+ {detail?.requestText && detail.requestText !== item.text ?

{detail.requestText}

: null}
{t("attentionDetail.request")}
{t(detail?.interaction === "decision" ? "attentionDetail.decision" : "attentionDetail.unknownRequest")}
{t("common.status")}
{t(`attentionDetail.${detail?.lifecycle ?? "unknown"}`)}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts b/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts index a7ebd56cd1..0091fc848f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts @@ -4,6 +4,7 @@ import type { WorkspaceAttention } from "./personal-workspace-model"; export type AttentionDetails = { interaction: "decision" | "unknown"; lifecycle: "open" | "closed" | "deferred" | "superseded" | "unknown" | "unavailable"; + requestText?: string | null; reason: string | null; evidence: string | null; blocksAgent: string | null; @@ -28,6 +29,7 @@ export function attentionDetails(todo: Record): AttentionDetail : todo.status === "deferred" ? "deferred" : todo.done === true || ["done", "completed", "closed", "archived"].includes(String(todo.status)) ? "closed" : todo.status === "open" || todo.status === "blocked" ? "open" : "unknown", + requestText: text(todo.text), reason: text(todo.note), evidence: text(todo.evidence), blocksAgent: text(todo.blocks_agent), diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 9114ad33a1..a08fbe6bcd 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -72,6 +72,14 @@ Transfer the hierarchy, not the artwork or untested claims: conversation for a requested answer. - Put one useful next action beside the relevant failure or decision. Fold routine activity; preserve missing authority, stale information and failures. + Decision notices use the request body, object and evidence rather than a short + scheduling label. Keep distinct request identities and label bounded previews; + users inspect the current request before deciding. App detail drawers retain + the request separately from the compact card label. Provider notices use the + same content distinction: missing request bodies are explicitly unavailable, + never reconstructed from legacy action labels or free-form gate prompts. + Retire obsolete presentation branches rather than preserving old data shapes + without an active caller or a documented migration requirement. - Use typography, spacing and restrained state accents from the existing design system. Motion explains verified transitions, never invents busy workers. - Recent-completion previews sort all loaded Goals by recorded completion time diff --git a/docs/product/use-cases/steward/golden-queries.md b/docs/product/use-cases/steward/golden-queries.md index fba17e9b2d..1a4393ec52 100644 --- a/docs/product/use-cases/steward/golden-queries.md +++ b/docs/product/use-cases/steward/golden-queries.md @@ -57,6 +57,16 @@ must finish before work on the next layer can start. GQ17 repeats the ordinary parallel-work intent at larger fixture sizes; basic parallel work is already P0 in GQ11, and the steward should not overstaff a small task. +### Readable decisions / 看得懂再决定 + +“要我决定什么?把对象和依据说清楚。” / “What do you need me to decide?” +For GQ10, prepare a public release request whose concrete channel/version appears +past the compact scheduling label, with a public evidence link. The App detail +and provider notice must retain the request object and available evidence. Two +requests with the same title remain distinguishable; a replay sends no second +notice. Bound oversized content explicitly and preserve redaction. A notification +is a preview, not proof of approval, execution or a fresh authorization grant. + ### Direct group conversation / 群里直接说话 For the Lark variant of P0 reliable entry, configure the steward connection to diff --git a/examples/dashboard-attention-details-browser-smoke.mjs b/examples/dashboard-attention-details-browser-smoke.mjs index 896dba52ea..2ef2978d9d 100644 --- a/examples/dashboard-attention-details-browser-smoke.mjs +++ b/examples/dashboard-attention-details-browser-smoke.mjs @@ -18,6 +18,7 @@ const server = packaged ? spawn(resolveTestPython(), ["-m", "http.server", String(port), "--bind", "127.0.0.1", "--directory", resolve(root, "loopx/web")], { stdio: "ignore" }) : startViteDashboardServer({ dashboardDir, port }); const url = `http://127.0.0.1:${port}/${packaged ? "chat/" : ""}?statusUrl=/status.json`; +const requestBody = "Review the public release evidence and the selected publication channel. ".repeat(5) + "Publish version 2.0 to stable only after acceptance."; let browser; try { await waitForHttp(url); @@ -55,12 +56,12 @@ try { const original = { index: 1, todo_id: "todo_original", role: "user", task_class: "user_gate", done: state === "superseded", status: state === "superseded" ? "done" : "open", - text: "Review the bounded direction", note: "A direction choice is needed before todo_target can continue.", + title: "Release review", text: requestBody, note: "A direction choice is needed before todo_target can continue.", evidence: "review:bounded-validation", blocks_agent: "worker-one", unblocks_todo_id: "todo_target", decision_scope: { schema_version: "decision_scope_v0", kind: "direction", granularity: "action", scope_key: "route-one" }, ...(state === "superseded" ? { superseded_by: "todo_replacement" } : {}), }; - const replacement = { ...original, index: 2, todo_id: "todo_replacement", text: "Review the replacement direction", done: false, status: "open", superseded_by: undefined }; + const replacement = { ...original, index: 2, todo_id: "todo_replacement", title: "Review the replacement direction", text: "Review the replacement direction", done: false, status: "open", superseded_by: undefined }; queue.user_todos = { items: state === "missing" ? [replacement] : state === "superseded" ? [original, replacement] : [original], total_count: 2, open_count: 1 }; if (locale === "en") queue.project_asset = { owner: "fixture-owner", gate: "pending", next_action: "Review direction", stop_condition: "Await decision", ...(queue.project_asset ?? {}), user_todos: { items: queue.user_todos.items, total: 2, open: 1 } }; return route.fulfill({ json: fixture }); @@ -70,7 +71,7 @@ try { await page.getByTestId("personal-home-lane-needs_you").locator(".personal-home-goal-card").first().click(); await page.locator(".personal-object-list").first().getByRole("button").first().click(); const drawer = page.locator(".personal-drawer-body"); - for (const value of ["worker-one", "todo_target", "direction · action · route-one", "review:bounded-validation", "A direction choice is needed before todo_target can continue."]) { + for (const value of [requestBody, "worker-one", "todo_target", "direction · action · route-one", "review:bounded-validation", "A direction choice is needed before todo_target can continue."]) { await drawer.getByText(value, { exact: true }).waitFor({ state: "visible" }); } await page.screenshot({ path: resolve(output, `${packaged ? "packaged" : "dev"}-${locale}${readOnly ? "-readonly" : ""}.png`), fullPage: false }); diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 5002d0c379..ef844d79c6 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -1,3 +1,4 @@ +import {projectDecisionNotice} from "./presentation/decision_notice.ts"; import {projectTodoSummary} from "./todos/summary_projection.ts"; import {admitAutomationStart, confirmAutomationStart, manageAutomationCadence, projectCadenceSchedule} from "./quota/automation_cadence.ts"; import {deliverShadowEntry} from "./coordination/shadow_entry_delivery.ts"; @@ -610,6 +611,7 @@ export function createEffectRuntimeHandlers( ], ["task_lease.write_scopes.overlap", evaluateTaskLeaseWriteScopesOverlap], ["quota.monitor_poll.commit", evaluateQuotaMonitorPollCommit], + ["presentation.decision_notice.project", projectDecisionNotice], ["presentation.action_review_plan.compile", (params) => compileActionReviewPlan(params.proposal)], ["scheduler.monitor_successor.plan", planMonitorSuccessor], diff --git a/loopx/control_plane/presentation/decision_notice.ts b/loopx/control_plane/presentation/decision_notice.ts new file mode 100644 index 0000000000..e94b2d3f07 --- /dev/null +++ b/loopx/control_plane/presentation/decision_notice.ts @@ -0,0 +1,21 @@ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; + +/** Read-only content selection. Callers supply public-safe, bounded projection + * fields; this neither resolves a gate nor authorizes an operation. */ +export function projectDecisionNotice(input: JsonObject): JsonObject { + const items: JsonObject[] = []; + const text = (value: unknown): string => typeof value === "string" ? value.trim() : ""; + const requests = Array.isArray(input.requests) ? input.requests : []; + for (const raw of requests) { + const request = requireJsonObject(raw, "decision_notice.requests[]"); + const body = text(request.text); + if (!body) continue; + items.push({ + request_id: text(request.request_id), text: body, + reason: text(request.reason), evidence: text(request.evidence), + }); + if (items.length === 3) break; + } + return {source: items.length ? "request_items" : "unavailable", items}; +} diff --git a/loopx/extensions/lark/goal_channel_contracts.py b/loopx/extensions/lark/goal_channel_contracts.py index 5955a898d2..bd15a0822b 100644 --- a/loopx/extensions/lark/goal_channel_contracts.py +++ b/loopx/extensions/lark/goal_channel_contracts.py @@ -715,29 +715,20 @@ def gate_message( or "A human decision is required.", limit=900, ) - interaction = quota_packet.get("interaction_contract") - interaction = interaction if isinstance(interaction, Mapping) else {} - user_channel = interaction.get("user_channel") - user_channel = user_channel if isinstance(user_channel, Mapping) else {} - raw_actions = user_channel.get("actions") - action_lines = ( - [public_safe_compact_text(action, limit=300) for action in raw_actions[:3]] - if isinstance(raw_actions, list) - else [] - ) - if not any(action_lines): - action_lines = [ - public_safe_compact_text( - item.get("text") or item.get("title"), - limit=300, - ) + # Scheduling labels cannot substitute for a decision request body. + from ...control_plane.effect_runtime import effect_runtime_result + + notice = effect_runtime_result("presentation.decision_notice.project", { + "requests": [ + { + "request_id": public_safe_compact_text(item.get("todo_id") or item.get("gate_id"), limit=120), + "text": public_safe_compact_text(item.get("text"), limit=900), + "reason": public_safe_compact_text(item.get("note") or item.get("reason"), limit=450), + "evidence": public_safe_compact_text(item.get("evidence"), limit=450), + } for item in _quota_human_gate_items(quota_packet) - ] - unique_actions: list[str] = [] - for action in action_lines or [question]: - cleaned = GATE_ACTION_PREFIX.sub("", action.strip()).strip() - if cleaned and cleaned not in unique_actions: - unique_actions.append(cleaned) + ], + }) lines = [ "LoopX · Action required", "", @@ -745,17 +736,26 @@ def gate_message( ] if objective and objective != goal_id: lines.append(f"Objective: {objective}") - lines.extend(["", "Please confirm:"]) - lines.extend( - f"{index}. {action}" for index, action in enumerate(unique_actions, start=1) - ) - lines.extend( - [ - "", - "Reply: approve / reject / done / still pending, plus a one-sentence reason.", - "Unchanged gate state will stay quiet until an explicit reminder window.", - ] - ) + lines.extend(["", "Decision requests:"]) + if notice["source"] == "unavailable": + lines.append("Request details are unavailable. Open the current request in LoopX; a scheduling summary is not a decision body.") + for index, item in enumerate(notice["items"], start=1): + body = GATE_ACTION_PREFIX.sub("", item["text"]).strip() + lines.append(f"{index}. {body}") + if item["request_id"]: + lines.append(f" Request: {item['request_id']}") + if item["reason"]: + lines.append(f" Context: {item['reason']}") + if item["evidence"]: + lines.append(f" Evidence: {item['evidence']}") + lines.extend([ + "", + "Review the current request in LoopX before deciding; this notification is a bounded preview.", + + "Unchanged gate state will stay quiet until an explicit reminder window.", + ]) + if notice["items"]: + lines.append("Reply with the request ID (or number), your decision and a one-sentence reason.") if kanban_url: lines.extend(["", f"Kanban: {kanban_url}"]) return "\n".join(lines), question diff --git a/tests/control_plane_ts/decision_notice.test.ts b/tests/control_plane_ts/decision_notice.test.ts new file mode 100644 index 0000000000..08c0e02ecb --- /dev/null +++ b/tests/control_plane_ts/decision_notice.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {projectDecisionNotice} from "../../loopx/control_plane/presentation/decision_notice.ts"; + +test("decision bodies outrank lossy scheduler labels and preserve distinct requests", () => { + const body = "Review the public release candidate. ".repeat(9) + "Only publish after the signed build passes."; + const result = projectDecisionNotice({requests: [ + {request_id: "todo_a", text: body, reason: "Choose the release channel", evidence: "https://example.org/release"}, + {request_id: "todo_b", text: body}, + ], actions: ["[P0] Release review"], question: "Approve?"}); + assert.deepEqual(result, {source: "request_items", items: [ + {request_id: "todo_a", text: body, reason: "Choose the release channel", evidence: "https://example.org/release"}, + {request_id: "todo_b", text: body, reason: "", evidence: ""}, + ]}); +}); + +test("summary-only packets expose missing request content instead of a compatibility decision", () => { + for (const input of [ + {requests: [{text: " "}], actions: ["Approve deployment"], question: "Approve?"}, + {question: "Approve?"}, + ]) assert.deepEqual(projectDecisionNotice(input), {source: "unavailable", items: []}); +}); diff --git a/tests/extensions/test_lark_goal_channel.py b/tests/extensions/test_lark_goal_channel.py index 2368132177..517a652ffc 100644 --- a/tests/extensions/test_lark_goal_channel.py +++ b/tests/extensions/test_lark_goal_channel.py @@ -1635,7 +1635,7 @@ def packet( assert sum("+messages-send" in args for args in calls) == 3 -def test_notify_gate_honors_interaction_contract_and_renders_one_clear_action_list( +def test_notify_gate_honors_admission_and_does_not_present_labels_as_decisions( tmp_path: Path, ) -> None: binding_path = _gate_test_binding(tmp_path) @@ -1686,8 +1686,10 @@ def test_notify_gate_honors_interaction_contract_and_renders_one_clear_action_li assert rejected["blocker"] == "state_transition_rejected" assert calls == [] assert message.startswith("LoopX · Action required\n\nGoal:") - assert "\n1. Approve the bounded change." in message - assert "\n2. Revoke the test key." in message + assert "Request details are unavailable" in message + assert "Approve the bounded change" not in message + assert "Revoke the test key" not in message + assert "Reply with" not in message assert "Current recommendation" not in message assert "Next safe action" not in message assert "\n- " not in message @@ -2645,3 +2647,54 @@ def deliver_unknown(**kwargs: object) -> object: assert printed["details"]["external_write_outcome"] == "unknown" assert "extension_activation" not in printed _assert_public_packet(printed) + + +def test_gate_notice_delivers_request_body_instead_of_compact_label(tmp_path: Path) -> None: + """The same long request survives preview, send, readback and duplicate retry.""" + binding_path = _gate_test_binding(tmp_path) + body = "Review the public release candidate and its validation evidence. " * 6 + body += "The decision is whether to publish version 2.0 to the stable channel." + quota = { + "state": "operator_gate", "notify_user_on_gate": True, + "interaction_contract": {"user_channel": { + "action_required": True, "notify": "NOTIFY", "actions": ["[P0] Release review"], + }}, + "user_todo_summary": {"gate_open_items": [{ + "todo_id": "todo_release_review", "task_class": "user_gate", "status": "open", + "text": body, "note": "Only the stable-channel publication needs a decision.", + "evidence": "https://example.org/release/2.0", + }]}, + } + message, _ = goal_channel_contracts.gate_message( + goal_id=GOAL_ID, objective="Public release", quota_packet=quota, kanban_url="", + ) + assert body in message + assert "todo_release_review" in message + assert "Only the stable-channel publication needs a decision." in message + assert "https://example.org/release/2.0" in message + assert "bounded preview" in message + calls: list[list[str]] = [] + runner = _fake_runner(calls) + first = _notify_test_gate(tmp_path=tmp_path, binding_path=binding_path, quota_packet=quota, runner=runner) + again = _notify_test_gate(tmp_path=tmp_path, binding_path=binding_path, quota_packet=quota, runner=runner) + assert first["status"] == "sent_verified" + assert first["readback_verified"] is True + assert again["status"] == "already_sent" + sends = [args for args in calls if "+messages-send" in args] + assert len(sends) == 1 + assert body in sends[0][sends[0].index("--text") + 1] + + +def test_gate_notice_redacts_and_bounds_additional_context() -> None: + message, _ = goal_channel_contracts.gate_message( + goal_id=GOAL_ID, objective="Public release", kanban_url="", + quota_packet={"user_todo_summary": {"gate_open_items": [{ + "todo_id": "todo_release_review", "text": "Review " + "public facts " * 200, + "note": "See /tmp/private-review.txt and api_key=synthetic_fixture_secret_123456789", + "evidence": "https://example.org/release/2.0", + }]}}, + ) + assert "/tmp/private-review.txt" not in message + assert "synthetic_fixture_secret_123456789" not in message + assert len(message) < 2000 + assert "Review the current request in LoopX" in message