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: 2 additions & 0 deletions docs/architecture/rfcs/harness-selection-dsh-pi-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,8 @@ Subsequent #4572 (`0aa6179de`) appends a channel-authored confirmation-location

The manager conversation that produced a plan now also lists the card its channel stored, instead of only the Goal workspace the pointer names. This is a presentation change over the same validated proposal: it adds no Lark card, creates nothing before confirmation, and a proposal fetched for a selected Goal stays in that Goal's workspace because it belongs to that context.

**Reuse boundary for a Lark card.** LoopX already ships the Lark half of a card confirmation: `loopx/extensions/lark/goal_channel_operation.py` delivers a non-forwardable Card 2.0 with confirm/reject buttons for a typed `operation.execute` proposal, `event_collector_runtime` consumes `card.action.trigger`, and the transport owns operator membership and tenant verification, replay protection, card readback and result-card patching. The reusable part is that shell plus `presentation.action_review_plan.compile`; the operation-specific parts are the operation envelope identity, the claim/execute effect and the Goal-channel binding the delivery resolves. `compileReviewCardFrame` now also returns a provider-neutral `review_card_frame_v0` for a validated `team.plan` proposal -- identity is the proposal plus the state fingerprint the apply re-validates, fields are `{key, value}` pairs whose fixed labels stay keys -- so a plan card can use the same shell and callback consumer. What it still needs is a delivery route for the audience that asked (a manager group is not a Goal channel binding) and a callback effect that applies the proposal through the Chat action service instead of claiming an operation envelope. No plan card is posted yet, and confirming from Lark would also need an explicit answer for whether an external manager audience may perform this durable write.

### Relationship to multi-agent and shared authority

The [alignment RFC](shared-goal-alignment-and-governed-amendment-v0.md) owns
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,8 @@ journal 与配额语义;B 作为上游接口出现时的低成本替代;只

产生计划的管家会话现在也会列出它自己通道存入的那张卡片,而不只是提示里点名的 Goal 工作区。这是同一份已校验提案上的展示改动:不新增 Lark 卡片,确认前不创建任何东西,而为已选 Goal 拉取的提案仍留在该 Goal 工作区,因为它属于那个上下文。

**Lark 卡片的复用边界。** LoopX 已经交付了卡片确认的 Lark 半边:`loopx/extensions/lark/goal_channel_operation.py` 会把 `operation.execute` 类型提案投递成不可转发的 Card 2.0(确认/拒绝按钮),`event_collector_runtime` 消费 `card.action.trigger`,传输层负责操作者成员与租户校验、重放保护、卡片读回与结果卡片修补。可复用的是这套外壳加 `presentation.action_review_plan.compile`;操作特有的部分是 operation envelope 身份、claim/execute 效果,以及投递所解析的 Goal channel 绑定。现在 `compileReviewCardFrame` 也会为已校验的 `team.plan` 返回语言中立的 `review_card_frame_v0`——身份是提案加 apply 会重新校验的 state fingerprint,字段是 `{key, value}` 对、固定标签保持为 key——因此团队计划卡片可以复用同一套外壳与回调消费者。仍缺的是**提问受众的投递路由**(管家群不是 Goal channel 绑定)以及一个**走 Chat action service 应用提案、而非 claim operation envelope 的回调效果**。目前还不会投递任何计划卡片;要从 Lark 确认,还需要明确回答"外部管家受众是否可以做这次持久写入"。

### 与 multi-agent / shared authority 契约的关系

[对齐 RFC](shared-goal-alignment-and-governed-amendment-v0.zh-CN.md) 拥有共享意图及受治理修订;[共享权威 RFC](shared-goal-authority-state-provider-v0.zh-CN.md) 拥有持久化/晋升。团队计划只提议已接受 intent 内的工作,不能靠 envelope 散文修改权限、共享验收或终止条件。Stage 3 amendment commit 仍未交付。回执可选 `intent_basis` 是既有 `source_basis_digest`,不是尚未实现的完整 intent envelope 版本,不能通过改名赋予历史回执更强语义。
Expand Down
125 changes: 125 additions & 0 deletions loopx/control_plane/presentation/action_review_plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,36 @@ type ActionReviewState =

export type ActionReviewPlan = ActionReviewIdentity & ActionReviewState & {
operationFrame?: OperationReviewFrame;
reviewCardFrame?: ReviewCardFrame;
};

/**
* Provider-neutral content for a confirmation card on a surface that is not the
* Dashboard, such as a Lark Card 2.0.
*
* The operation frame above can only describe an `operation.execute` proposal,
* because its identity is the operation envelope. A plan has no envelope: what
* makes its confirmation exact is the action proposal and the state fingerprint
* the apply re-validates against, so that pair is the frame's identity. Labels
* stay keys, not sentences, because this boundary is language-neutral; the
* surface owns the words and renders the data below.
*/
export type ReviewCardFrame = {
schemaVersion: "review_card_frame_v0";
actionKind: string;
proposalId: string;
stateFingerprint: string;
kind: "confirmation";
attentionKind: "authority";
interactionMode: "confirm_reject";
decisions: readonly ["confirm", "reject"];
titleKey: string;
subtitleKey: string;
confirmLabelKey: string;
rejectLabelKey: string;
warningKey: string;
focus: string;
fields: Array<{ key: string; value: string }>;
};

type JsonRecord = Record<string, unknown>;
Expand All @@ -82,6 +112,99 @@ function textValue(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}

function compactValue(value: unknown, limit = 240): string {
const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
}

function laneFieldValue(laneValue: unknown): string {
const lane = objectValue(laneValue) ?? {};
const agent = compactValue(lane.agent_id, 80) || "unknown-agent";
const acceptance = compactValue(lane.acceptance, 200);
if (lane.staffing === "gap") {
const declined = objectValue(lane.declined_first_todo) ?? {};
return [
`${agent} · gap`,
compactValue(lane.gap_reason_code, 80),
compactValue(declined.text, 200),
].filter(Boolean).join(" · ");
}
const todo = objectValue(lane.first_todo) ?? {};
return [
`${agent} · ready`,
compactValue(todo.priority, 8),
compactValue(todo.action_kind, 40),
compactValue(todo.text, 240),
acceptance ? `acceptance: ${acceptance}` : "",
].filter(Boolean).join(" · ");
}

function envelopeFieldValue(value: unknown): string {
const envelope = objectValue(value) ?? {};
return Object.entries(envelope)
.map(([key, item]) => `${key}: ${typeof item === "object" && item !== null ? JSON.stringify(item) : String(item)}`)
.join(" · ");
}

/**
* Compile the confirmation frame for a validated steward team plan.
*
* Returns `undefined` for anything else, so a surface asks for a plan card only
* when the proposal is one, and gets the same silence for a proposal whose plan
* is not a preview. The plan below is data the model wrote; the frame copies it
* as values and never as instructions.
*/
export function compileReviewCardFrame(proposalValue: unknown): ReviewCardFrame | undefined {
const proposal = objectValue(proposalValue);
if (proposal?.action_kind !== "team.plan") return undefined;
if (proposal.status !== "preview_ready" && proposal.status !== "deferred") return undefined;
const parameters = objectValue(proposal.normalized_parameters);
const plan = objectValue(parameters?.plan);
if (!plan || plan.kind !== "steward_team_plan_preview" || plan.applies !== false) return undefined;
const proposalId = textValue(proposal.proposal_id);
const stateFingerprint = textValue(proposal.expected_state_fingerprint);
const goalId = textValue(plan.goal_id);
if (!proposalId || !stateFingerprint || !goalId) return undefined;
const lanes = Array.isArray(plan.lanes) ? plan.lanes : [];
const gaps = Array.isArray(plan.gaps) ? plan.gaps : [];
const fields = [
{ key: "goal", value: goalId },
{ key: "objective", value: compactValue(plan.objective) },
...lanes.map((lane, index) => ({ key: `lane_${index + 1}`, value: laneFieldValue(lane) })),
...(gaps.length > 0
? [{
key: "lane_gaps",
value: gaps
.map((gapValue) => {
const gap = objectValue(gapValue) ?? {};
return [compactValue(gap.lane_id, 80), compactValue(gap.reason_code, 80)].filter(Boolean).join(": ");
})
.filter(Boolean)
.join(" · "),
}]
: []),
{ key: "quota_envelope", value: envelopeFieldValue(plan.quota_envelope) },
{ key: "stop_condition", value: compactValue(plan.stop_condition) },
].filter((field) => field.value.length > 0);
return {
schemaVersion: "review_card_frame_v0",
actionKind: "team.plan",
proposalId,
stateFingerprint,
kind: "confirmation",
attentionKind: "authority",
interactionMode: "confirm_reject",
decisions: ["confirm", "reject"],
titleKey: "team_plan_preview",
subtitleKey: "preview_only_no_lane_exists",
confirmLabelKey: "confirm_team_plan",
rejectLabelKey: "reject_team_plan",
warningKey: "confirming_creates_each_ready_lane_first_todo",
focus: `${goalId} · ${lanes.length} lane${lanes.length === 1 ? "" : "s"}`,
fields,
};
}

function operationContent(parameters: JsonRecord): OperationReviewContent | null {
const projection = objectValue(parameters.projection);
if (projection?.schema_version !== "loopx_operation_projection_v0") return null;
Expand Down Expand Up @@ -175,10 +298,12 @@ export function compileActionReviewPlan(proposalValue: unknown): ActionReviewPla
: "",
};
const operationFrame = compileOperationReviewFrame(proposal);
const reviewCardFrame = compileReviewCardFrame(proposal);
const finish = (state: ActionReviewState): ActionReviewPlan => ({
...identity,
...state,
...(operationFrame ? { operationFrame } : {}),
...(reviewCardFrame ? { reviewCardFrame } : {}),
});
const held = (
interaction: "gated" | "refresh" | "repair" | "pending" | "completed" | "inactive",
Expand Down
12 changes: 12 additions & 0 deletions loopx/extensions/lark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ Confirmation and result cards consume the same internal TypeScript
card render, then owns only provider-specific Card 2.0 markup and delivery; the
frame neither grants authority nor replaces the canonical operation receipt.

That request path is not operation-only. `presentation.action_review_plan.compile`
also returns a `review_card_frame_v0` for a validated steward team plan
(`team.plan`), whose confirmation identity is the action proposal and the state
fingerprint the apply re-validates against rather than an operation envelope. Its
fields are `{key, value}` pairs and its fixed labels are keys, so this boundary
stays language-neutral and the surface owns the words. A plan card can therefore
reuse the card shell, the callback consumer, the operator membership check,
replay protection and card readback described here. What a plan card still needs
is its own delivery route for the audience that asked (the manager group is not a
Goal channel binding) and a callback effect that applies the proposal through the
Chat action service instead of claiming an operation envelope.

```bash
loopx goal-channel prepare-operation \
--goal-id <goal-id> \
Expand Down
4 changes: 2 additions & 2 deletions loopx/web/chat/asset-retention.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2",
"assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2",
"assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2",
"assets/index-CXvjZarX.js",
"assets/index-Cp9Ll6py.js",
"assets/index-DWmuPX72.css"
],
[
Expand All @@ -28,7 +28,7 @@
"assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2",
"assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2",
"assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2",
"assets/index-Cb7S1yHW.js",
"assets/index-CXvjZarX.js",
"assets/index-DWmuPX72.css"
]
]
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion loopx/web/chat/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
content="LoopX 个人 Agent 工作区:在同一个频道里查看、纠偏并推进 Goal。"
/>
<title>LoopX 个人 Agent 工作区</title>
<script type="module" crossorigin src="/chat/assets/index-CXvjZarX.js"></script>
<script type="module" crossorigin src="/chat/assets/index-Cp9Ll6py.js"></script>
<link rel="stylesheet" crossorigin href="/chat/assets/index-DWmuPX72.css">
</head>
<body>
Expand Down
126 changes: 126 additions & 0 deletions tests/control_plane_ts/action_review_plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
compileActionReviewPlan,
compileOperationReviewFrame,
compileReviewCardFrame,
isStaleActionFailure,
} from "../../loopx/control_plane/presentation/action_review_plan.ts";

Expand Down Expand Up @@ -116,3 +117,128 @@ test("generic action review keeps state precedence and stale classification", ()
assert.equal(isStaleActionFailure({ error_code: "action_conflict" }), true);
assert.equal(isStaleActionFailure({ error: "unrelated conflict text" }), false);
});

function teamPlanProposal() {
return {
schema_version: "loopx_chat_action_proposal_v1",
proposal_id: "proposal-team-plan-1",
action_kind: "team.plan",
context: { kind: "manager", goal_id: "goal-1" },
expected_state_fingerprint: "registry-revision-1",
permission_classification: "durable_write",
validation_evidence: ["every ready lane names an Agent this Goal registers"],
available_transitions: ["apply", "cancel"],
status: "preview_ready",
normalized_parameters: {
goal_id: "goal-1",
plan: {
schema_version: "steward_team_plan_preview_v0",
kind: "steward_team_plan_preview",
goal_id: "goal-1",
objective: "Ship the bounded intake",
lanes: [
{
lane_id: "lane_intake",
agent_id: "agent-backend",
acceptance: "the Todo exists 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,
},
},
};
}

test("a validated plan compiles into a confirmation card frame", () => {
const plan = compileActionReviewPlan(teamPlanProposal());
assert.equal(plan.interaction, "review");
assert.equal(plan.canApply, true);
const frame = compileReviewCardFrame(teamPlanProposal());
if (!frame) assert.fail("expected review card frame");
// The confirmation identity is the proposal and the state the apply
// re-validates against, not an operation envelope this proposal does not have.
assert.equal(frame.schemaVersion, "review_card_frame_v0");
assert.equal(frame.kind, "confirmation");
assert.equal(frame.interactionMode, "confirm_reject");
assert.deepEqual([...frame.decisions], ["confirm", "reject"]);
assert.equal(frame.proposalId, "proposal-team-plan-1");
assert.equal(frame.stateFingerprint, "registry-revision-1");
const fields = new Map(frame.fields.map((field) => [field.key, field.value]));
assert.equal(fields.get("goal"), "goal-1");
assert.equal(fields.get("objective"), "Ship the bounded intake");
assert.equal(
fields.get("lane_1"),
"agent-backend · ready · P1 · implement · Implement the bounded intake"
+ " · acceptance: the Todo exists through the canonical owner",
);
// A gap lane keeps the work it did not staff, so a card can show what the
// owner asked for next to the reason it cannot run.
assert.equal(
fields.get("lane_2"),
"agent-reviewer · gap · agent_not_registered · Independently review the intake",
);
assert.equal(fields.get("lane_gaps"), "lane_review: agent_not_registered");
assert.equal(fields.get("quota_envelope"), "slots: 4 · window: 1d");
assert.equal(
fields.get("stop_condition"),
"every lane reports a typed outcome or a stated gap",
);
assert.equal(frame.focus, "goal-1 · 2 lanes");
// Labels are keys, never sentences: this boundary stays language-neutral and
// the surface owns the words it renders.
for (const key of [
frame.titleKey,
frame.subtitleKey,
frame.confirmLabelKey,
frame.rejectLabelKey,
frame.warningKey,
...frame.fields.map((field) => field.key),
]) {
assert.match(key, /^[a-z][a-z0-9_]*$/);
}
});

test("a plan card frame is refused for anything that is not an admitted preview", () => {
const applied = teamPlanProposal();
applied.normalized_parameters.plan.applies = true;
assert.equal(compileReviewCardFrame(applied), undefined);

const moved = teamPlanProposal();
moved.status = "applied";
assert.equal(compileReviewCardFrame(moved), undefined);

const otherKind = { ...teamPlanProposal(), action_kind: "todo.create" };
assert.equal(compileReviewCardFrame(otherKind), undefined);

const withoutFingerprint = { ...teamPlanProposal() };
delete (withoutFingerprint as { expected_state_fingerprint?: string }).expected_state_fingerprint;
assert.equal(compileReviewCardFrame(withoutFingerprint), undefined);

// An operation proposal keeps the operation frame path and gains no plan card.
const operation = compileActionReviewPlan(operationProposal("awaiting_confirmation"));
assert.equal(operation.operationFrame?.kind, "confirmation");
assert.equal(operation.reviewCardFrame, undefined);
});
Loading