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); +}