From 89833943e8d2a8964a7e91b82c9afe1336e3ca2e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:29:33 +0800 Subject: [PATCH] feat(dashboard): let the owner confirm a validated steward team plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The steward's team intake produces one validated multi-lane preview, and the Chat action surface already owns `team.plan` with a preview and an apply. The dashboard could not render it: its transport schema accepted a fixed action-kind enum without `team.plan`, so a validated preview failed to parse instead of becoming a card an owner could confirm. That left the one-sentence team flow with no confirmation surface at all. - `src/data/chat.ts`: the transport kind enum accepts `team.plan`. - `personal-workspace-model.ts`: the workspace preview kind matches. - `team-plan-preview.ts` (new): a presentation-only reducer that turns the preview into what an owner reads before confirming — the Goal the plan staffs, its objective, one row per lane (the Agent, that lane's first bounded Todo with its priority and action kind, and its acceptance signal), the declared staffing gaps with the work they did not staff, the quota envelope and the stop condition. It owns no authority and claims no lane already exists. - `personal-workspace-page.tsx`: the proposal card uses that reducer, with a summary, an impact statement that says what confirming does, and a primary label for confirmation. - `i18n.tsx`: the new strings in English and Chinese. Verification: `tsc --noEmit -p tsconfig.json` clean; `npm run smoke:team-plan-proposal` (new) prints `team plan proposal smoke ok`; the existing `smoke:chat-route` and `smoke:action-review-plan` stay green, so the kind addition did not disturb the other proposal surfaces. The card only appears when a validated team plan exists, so the dashboard's first viewport, hero and navigation are unchanged. The user-visible entry point covered here is the dashboard chat/workspace proposal card; the Lark goal channel and the CLI are unchanged by this diff. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/package.json | 1 + .../smoke/team-plan-proposal-smoke.ts | 133 ++++++++++++++++++ apps/presentation/dashboard/src/data/chat.ts | 3 + .../src/features/personal-workspace/i18n.tsx | 16 +++ .../personal-workspace-model.ts | 3 +- .../personal-workspace-page.tsx | 11 ++ .../personal-workspace/team-plan-preview.ts | 115 +++++++++++++++ 7 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 49aa4a1607..dc4c21385b 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -38,6 +38,7 @@ "smoke:goal-acceptance-browser": "node ../../../examples/dashboard-goal-acceptance-browser-smoke.mjs", "smoke:goal-acceptance-packaged": "LOOPX_GOAL_ACCEPTANCE_PACKAGED=1 LOOPX_GOAL_ACCEPTANCE_PORT=5292 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/dashboard-goal-acceptance-browser-smoke.mjs", "smoke:status-projection-contract": "rm -rf /tmp/loopx-status-projection-contract-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-projection-contract-smoke smoke/status-projection-contract-smoke.ts src/data/status.ts src/data/status-merge.ts src/data/status-request-fence.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-projection-contract-smoke/apps/presentation/dashboard/smoke/status-projection-contract-smoke.js", + "smoke:team-plan-proposal": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --jsx react-jsx --types node --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-team-plan-smoke smoke/team-plan-proposal-smoke.ts src/data/chat.ts src/features/personal-workspace/team-plan-preview.ts src/vite-env.d.ts && node node_modules/.cache/loopx-team-plan-smoke/smoke/team-plan-proposal-smoke.js", "smoke:status-source-switch-browser": "node ../../../examples/status-source-switch-browser-smoke.mjs", "smoke:status-source-switch-packaged": "LOOPX_STATUS_SOURCE_SWITCH_PACKAGED=1 LOOPX_STATUS_SOURCE_SWITCH_PORT=5198 node ../../../examples/status-source-switch-browser-smoke.mjs", "smoke:status-sources": "rm -rf /tmp/loopx-status-source-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-source-smoke smoke/status-source-catalog-smoke.ts src/data/ssh-host-catalog.ts src/data/status-source-catalog.ts src/data/local-status-query.ts src/data/status.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-source-smoke/apps/presentation/dashboard/smoke/status-source-catalog-smoke.js", diff --git a/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts b/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts new file mode 100644 index 0000000000..3007e04f20 --- /dev/null +++ b/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts @@ -0,0 +1,133 @@ +/** + * A validated steward team plan must survive the transport and read as lanes. + * + * Before the `team.plan` kind existed in the dashboard schema, the transport + * rejected the proposal outright, so an owner could never confirm the preview + * the steward had already validated. + */ + +import { typedActionKindSchema, typedActionProposalSchema } from "../src/data/chat.js"; +import { teamPlanFields, teamPlanGoalId, teamPlanLaneCount } from "../src/features/personal-workspace/team-plan-preview.js"; + +const GOAL_ID = "team-plan-smoke-goal"; + +function check(condition: boolean, message: string) { + if (!condition) { + console.error(`FAIL ${message}`); + process.exitCode = 1; + } +} + +check(typedActionKindSchema.safeParse("team.plan").success, "transport accepts the steward team plan kind"); +check(!typedActionKindSchema.safeParse("team.plans").success, "the kind stays exact"); + +const plan = { + schema_version: "steward_team_plan_preview_v0", + kind: "steward_team_plan_preview", + goal_id: GOAL_ID, + objective: "Ship the steward team intake", + lanes: [ + { + lane_id: "lane_backend", + agent_id: "agent-backend", + acceptance: "the bounded Todo is created through the canonical owner", + staffing: "ready", + first_todo: { + text: "Implement the bounded intake", + priority: "P1", + task_class: "advancement_task", + action_kind: "implement", + }, + }, + { + lane_id: "lane_review", + agent_id: "agent-reviewer", + acceptance: "the review receipt is recorded", + staffing: "gap", + gap_reason_code: "agent_not_registered", + declined_first_todo: { + text: "Independently review the intake", + priority: "P1", + task_class: "advancement_task", + action_kind: "validate", + }, + }, + ], + gaps: [{ lane_id: "lane_review", reason_code: "agent_not_registered" }], + quota_envelope: { slots: 4, window: "1d" }, + stop_condition: "every lane reports a typed outcome or a stated gap", + applies: false, +}; + +const proposal = typedActionProposalSchema.parse({ + schema_version: "loopx_chat_action_proposal_v1", + proposal_id: "proposal_team_plan_smoke", + action_kind: "team.plan", + summary: "Staff a two-lane team", + normalized_parameters: { goal_id: GOAL_ID, plan, requested_by: "owner" }, + context: { goal_id: GOAL_ID }, + expected_state_fingerprint: "sha256:team-plan-smoke", + permission_classification: "durable_write", + validation_evidence: [ + "every ready lane names an Agent this Goal registers", + "the plan names the Goal it staffs", + ], + available_transitions: ["apply", "cancel"], + status: "preview_ready", + receipt: null, + stale: null, + created_at: "2026-09-16T12:00:00Z", + updated_at: "2026-09-16T12:00:00Z", +}); + +check(proposal.action_kind === "team.plan", "the proposal keeps the team plan kind"); +check(teamPlanGoalId(proposal.normalized_parameters) === GOAL_ID, "the card names the Goal the plan staffs"); +check(teamPlanLaneCount(proposal.normalized_parameters) === 2, "the card counts the previewed lanes"); + +const translate = (key: string, values?: Record) => { + const table: Record = { + "proposal.field.goalId": "Goal", + "proposal.field.objective": "Objective", + "proposal.field.laneGaps": "Unstaffed lanes", + "proposal.field.quotaEnvelope": "Quota envelope", + "proposal.field.stopCondition": "Stop condition", + "proposal.teamPlan.acceptanceShort": "acceptance", + "proposal.teamPlan.gapLane": "unstaffed", + "proposal.teamPlan.laneUnstaffed": "staffing gap, no first Todo", + }; + const template = table[key] ?? key; + return Object.entries(values ?? {}).reduce( + (text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), + template, + ); +}; + +const fields = teamPlanFields(proposal.normalized_parameters, translate as never); +const byKey = new Map(fields.map((field) => [field.key, field])); +const readyLane = byKey.get("lane_lane_backend"); +const gapLane = byKey.get("lane_lane_review"); + +check(readyLane?.label === "agent-backend", "a ready lane names the Agent that runs it"); +check( + readyLane?.value.includes("P1") === true + && readyLane?.value.includes("Implement the bounded intake") === true + && readyLane?.value.includes("acceptance: the bounded Todo is created through the canonical owner") === true, + "a ready lane shows its first bounded Todo, its priority and its acceptance signal", +); +check( + gapLane?.value.startsWith("unstaffed · agent_not_registered") === true + && gapLane?.value.includes("Independently review the intake") === true, + "a gap lane says it is unstaffed, names the reason and keeps the work it did not staff", +); +check(byKey.get("lane_gaps")?.value === "lane_review: agent_not_registered", "the gap summary joins lane and reason"); +check(byKey.get("quota_envelope")?.value === "slots: 4 · window: 1d", "the quota envelope is shown as data"); +check( + byKey.get("stop_condition")?.value === "every lane reports a typed outcome or a stated gap", + "the stop condition is shown", +); +check( + fields.some((field) => field.value.includes("agent-") === true && field.value.includes("ready") === true) === false, + "the card never renders a lane as already created", +); + +if (process.exitCode !== 1) console.log("team plan proposal smoke ok"); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 6afd9ac015..5c7589ae18 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -332,6 +332,9 @@ export const typedActionKindSchema = z.enum([ "gate.resolve", "run.correct", "operation.execute", + // The steward's team intake: one validated multi-lane preview that the owner + // confirms. The apply re-validates the same payload before creating work. + "team.plan", ]); const typedOperationEnvelopeSchema = z.object({ diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 0d712b3568..ecaa63b998 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -694,6 +694,14 @@ const en = { "proposal.summary.lifecycleResume": "Resume Goal: {title}", "proposal.summary.lifecycleStop": "Stop Goal: {title}", "proposal.summary.monitor": "Create a scheduled check for the current Goal: {target}", + "proposal.summary.teamPlan": "Staff a {count}-lane team for {goal}", + "proposal.impact.teamPlan": "Confirming creates each ready lane's first bounded Todo through the canonical owner; a lane with a staffing gap creates nothing, no lane exists before the apply receipt returns, and a Goal or Agent change between preview and apply makes this proposal stale instead of applying drifted staffing.", + "proposal.primary.teamPlan": "Confirm and staff the lanes", + "proposal.field.laneGaps": "Unstaffed lanes", + "proposal.field.quotaEnvelope": "Quota envelope", + "proposal.teamPlan.acceptanceShort": "acceptance", + "proposal.teamPlan.gapLane": "unstaffed", + "proposal.teamPlan.laneUnstaffed": "staffing gap, no first Todo", "proposal.workspace.current": "Current local workspace (no Repository bound)", "proposal.workspace.named": "{workspace} (execution environment only; does not bind a repository)", "proposal.workspaceGate.agentImpact": "Bind an Agent identity, then recheck the original action. No Goal has been written.", @@ -1722,6 +1730,14 @@ const zhCN: Record = { "proposal.summary.lifecycleResume": "恢复 Goal:{title}", "proposal.summary.lifecycleStop": "停止 Goal:{title}", "proposal.summary.monitor": "为当前 Goal 创建定时检查:{target}", + "proposal.summary.teamPlan": "为 {goal} 配出 {count} 条 lane 的团队", + "proposal.impact.teamPlan": "确认后会通过既有 owner 为每条就绪 lane 创建它的首个有界 Todo;声明了编制缺口的 lane 不创建任何东西;apply 回执返回前不存在任何 lane;预览与 apply 之间 Goal 或 Agent 发生变化会让这条提案变为 stale,而不是按漂移后的编制执行。", + "proposal.primary.teamPlan": "确认并组建各 lane", + "proposal.field.laneGaps": "未配齐的 lane", + "proposal.field.quotaEnvelope": "配额包络", + "proposal.teamPlan.acceptanceShort": "验收", + "proposal.teamPlan.gapLane": "未配齐", + "proposal.teamPlan.laneUnstaffed": "编制缺口,无首个 Todo", "proposal.workspace.current": "当前本地工作区(未绑定 Repository)", "proposal.workspace.named": "{workspace}(仅提供执行环境,不会自动关联仓库)", "proposal.workspaceGate.agentImpact": "先完成 Agent 身份绑定,再重新检查原操作;当前没有写入 Goal。", 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 7fd7452ed9..da085993a8 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 @@ -216,7 +216,8 @@ export type WorkspaceActionPreview = { | "monitor.update" | "gate.resolve" | "run.correct" - | "operation.execute"; + | "operation.execute" + | "team.plan"; agentLabel?: string; fields: Array<{ key: string; label: string; value: string }>; goalId?: string; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 992ba0946e..c9d25141a2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -3,6 +3,7 @@ import { isStaleActionFailure, } from "../../../../../../loopx/control_plane/presentation/action_review_plan.js"; import { refreshAttention } from "./attention-details"; +import { teamPlanFields, teamPlanGoalId, teamPlanLaneCount } from "./team-plan-preview"; import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent } from "react"; import { AlertCircle, Bot, CalendarClock, FileText, ListPlus, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, X } from "lucide-react"; @@ -561,6 +562,11 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) const operationTitle = operationFrame?.content.title ?? proposal.summary; const localizedSummary = proposal.action_kind === "operation.execute" ? operationTitle + : proposal.action_kind === "team.plan" + ? t("proposal.summary.teamPlan", { + goal: teamPlanGoalId(proposal.normalized_parameters), + count: teamPlanLaneCount(proposal.normalized_parameters), + }) : proposal.action_kind === "goal.create" ? t("proposal.summary.goalCreate", { title }) : proposal.action_kind === "heartbeat.bind" @@ -579,10 +585,14 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) reviewPlan, fields: proposal.action_kind === "operation.execute" ? operationProposalFields(proposal, reviewPlan, t) + : proposal.action_kind === "team.plan" + ? teamPlanFields(proposal.normalized_parameters, t) : proposalFields(proposal.normalized_parameters, t), goalId: typeof proposal.normalized_parameters.goal_id === "string" ? proposal.normalized_parameters.goal_id : undefined, impact: proposal.action_kind === "operation.execute" ? t("proposal.impact.operation") + : proposal.action_kind === "team.plan" + ? t("proposal.impact.teamPlan") : proposal.action_kind === "goal.create" ? t("proposal.impact.goalCreate") : proposal.action_kind === "goal.lifecycle" && lifecycleOperation === "stop" @@ -607,6 +617,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) ? t("proposal.primary.operationResultVerified") : t("proposal.primary.operationResultPending") : t("proposal.primary.operationGroup") + : proposal.action_kind === "team.plan" ? t("proposal.primary.teamPlan") : proposal.action_kind === "goal.create" ? t("proposal.primary.goalCreate") : proposal.action_kind === "goal.lifecycle" && lifecycleOperation === "stop" ? t("proposal.primary.lifecycleStop") diff --git a/apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts b/apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts new file mode 100644 index 0000000000..94297dc69d --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts @@ -0,0 +1,115 @@ +/** + * Present a validated steward team plan for owner confirmation. + * + * The reducer owns no action authority: it reads the preview the host already + * validated and shows, per lane, the Agent that runs it, that lane's first + * bounded Todo, its acceptance signal, and whether the lane is a declared + * staffing gap. Confirming the card is what asks the canonical owners to create + * the lanes, so the card states what confirming does and never claims a lane + * already exists. + */ + +import type { WorkspaceTranslate } from "./i18n.js"; + +export type TeamPlanPreviewField = { key: string; label: string; value: string }; + +function asRecord(value: unknown): Record { + return value !== null && typeof value === "object" ? value as Record : {}; +} + +function asText(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +export function teamPlanFields( + parameters: Record, + t: WorkspaceTranslate, +): TeamPlanPreviewField[] { + const plan = asRecord(parameters.plan); + const fields: TeamPlanPreviewField[] = []; + const goalId = asText(parameters.goal_id) || asText(plan.goal_id); + if (goalId) { + fields.push({ key: "goal_id", label: t("proposal.field.goalId"), value: goalId }); + } + const objective = asText(plan.objective); + if (objective) { + fields.push({ key: "objective", label: t("proposal.field.objective"), value: objective }); + } + const lanes = Array.isArray(plan.lanes) ? plan.lanes : []; + lanes.forEach((rawLane, index) => { + const lane = asRecord(rawLane); + const laneId = asText(lane.lane_id) || `lane-${index + 1}`; + const agentId = asText(lane.agent_id); + const acceptance = asText(lane.acceptance); + if (asText(lane.staffing) === "gap") { + // A gap lane keeps the work it did not staff and creates nothing. + const declined = asRecord(lane.declined_first_todo); + fields.push({ + key: `lane_${laneId}`, + label: agentId || laneId, + value: [ + t("proposal.teamPlan.gapLane"), + asText(lane.gap_reason_code), + asText(declined.text), + ].filter(Boolean).join(" · "), + }); + return; + } + const todo = asRecord(lane.first_todo); + const laneValue = [ + asText(todo.priority), + asText(todo.action_kind), + asText(todo.text), + ].filter(Boolean).join(" · "); + fields.push({ + key: `lane_${laneId}`, + label: agentId || laneId, + value: [ + laneValue || t("proposal.teamPlan.laneUnstaffed"), + acceptance ? `${t("proposal.teamPlan.acceptanceShort")}: ${acceptance}` : "", + ].filter(Boolean).join(" · "), + }); + }); + const gaps = Array.isArray(plan.gaps) ? plan.gaps : []; + if (gaps.length > 0) { + fields.push({ + key: "lane_gaps", + label: t("proposal.field.laneGaps"), + value: gaps + .map((rawGap) => { + const gap = asRecord(rawGap); + return [asText(gap.lane_id), asText(gap.reason_code)].filter(Boolean).join(": "); + }) + .filter(Boolean) + .join(" · "), + }); + } + const envelope = asRecord(plan.quota_envelope); + const envelopeEntries = Object.entries(envelope); + if (envelopeEntries.length > 0) { + fields.push({ + key: "quota_envelope", + label: t("proposal.field.quotaEnvelope"), + value: envelopeEntries.map(([key, value]) => `${key}: ${String(value ?? "")}`).join(" · "), + }); + } + const stopCondition = asText(plan.stop_condition); + if (stopCondition) { + fields.push({ + key: "stop_condition", + label: t("proposal.field.stopCondition"), + value: stopCondition, + }); + } + return fields; +} + +export function teamPlanLaneCount(parameters: Record): number { + const plan = asRecord(parameters.plan); + return Array.isArray(plan.lanes) ? plan.lanes.length : 0; +} + +export function teamPlanGoalId(parameters: Record): string { + const plan = asRecord(parameters.plan); + return asText(parameters.goal_id) || asText(plan.goal_id); +}