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
1 change: 1 addition & 0 deletions apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number>) => {
const table: Record<string, string> = {
"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");
3 changes: 3 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -1722,6 +1730,14 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"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。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
}

function asText(value: unknown): string {
return typeof value === "string" ? value : "";
}

export function teamPlanFields(
parameters: Record<string, unknown>,
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<string, unknown>): number {
const plan = asRecord(parameters.plan);
return Array.isArray(plan.lanes) ? plan.lanes.length : 0;
}

export function teamPlanGoalId(parameters: Record<string, unknown>): string {
const plan = asRecord(parameters.plan);
return asText(parameters.goal_id) || asText(plan.goal_id);
}
Loading