Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions apps/presentation/dashboard/smoke/attention-details-smoke.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function AttentionDetailCard({ item, successor, onSelect }: {
const detail = item.details;
return <section className="personal-detail-card" aria-label={t("attentionDetail.title")}>
<h3>{t("attentionDetail.title")}</h3>
{detail?.requestText && detail.requestText !== item.text ? <p style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.requestText}</p> : null}
<dl>
<div><dt>{t("attentionDetail.request")}</dt><dd>{t(detail?.interaction === "decision" ? "attentionDetail.decision" : "attentionDetail.unknownRequest")}</dd></div>
<div><dt>{t("common.status")}</dt><dd>{t(`attentionDetail.${detail?.lifecycle ?? "unknown"}`)}</dd></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,7 @@ export function attentionDetails(todo: Record<string, unknown>): 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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/product/use-cases/steward/golden-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions examples/dashboard-attention-details-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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],
Expand Down
21 changes: 21 additions & 0 deletions loopx/control_plane/presentation/decision_notice.ts
Original file line number Diff line number Diff line change
@@ -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};
}
66 changes: 33 additions & 33 deletions loopx/extensions/lark/goal_channel_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,47 +715,47 @@ 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",
"",
f"Goal: {goal_id}",
]
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
Expand Down
22 changes: 22 additions & 0 deletions tests/control_plane_ts/decision_notice.test.ts
Original file line number Diff line number Diff line change
@@ -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: []});
});
Loading
Loading