修复M1D审查发现的审批决定失败路径

决定失败也重灌权威状态,顺序钉死为先hydrate后写错误

responseId复用键纳入comment,改写修改意见时换新ID

恢复期门控扩到已打开的评论弹层,保留已输入内容

补appSurface harness的策划command分发与三条变异验证过的回归

审查基线为 14c00017c..bf2185fba(技术方案第 13、18 节)。其后分支已前进到
c6a08ef98,4624fd795 与 c6a08ef98 都不动 src/**,审查结论不受影响。

三条缺陷:

1. decidePlanGdd 只在成功分支 hydrate。后端 decide_plan_gdd_at 有多条真实
   PLAN_STALE_APPROVAL 分支(GDD 不在当前 lineage、identity 不符、版本被取代、
   pending 丢失),命中后卡片停在已失效的 pending 身份上、三个决定按钮仍可点,
   且 recoveryPending 永不翻真导致「重试恢复」入口不渲染,卡内没有恢复路径。
   现按第 18.3 节在失败分支同样 hydrate——该句不区分成功与失败。两句顺序不能反:
   hydratePlanGddState 入口会 setPlanGddError(null),先写错误再 hydrate 会把错误
   擦掉;回归钉死了这个顺序。

2. responseId 复用键为 approvalRequestId:action,不含 comment,违反第 13.2 节
   「改变 action/comment 必须生成新 responseId」。在第 14 节承认的「receipt 已提交
   但 response 丢失」构造下,改写修改意见后重提会带旧 ID,命中后端「同 responseId
   的审批意图不一致」硬拒,改写后的原因永远落不了盘。现按 approvalRequestId 存
   {action, comment, responseId} 全量意图。判据方向为宁可多换不可少换:多换的最坏
   后果是 replayed 降级成 already-decided(都是 Ok,且 already-decided 正是第 18.2
   节要求的刷新态),少换是硬错误。

3. 第 18.2 节「recoveryPending 时不允许提交决定」原来只作用于三个触发按钮,而弹层
   是打开之后才可能被后台 hydrate 翻掉资格的,其提交按钮只看 busy 与非空。现在
   submitComment 与该按钮都判 canDecide。刻意不自动关弹层,否则会丢掉用户已经写好
   的修改意见。

测试:harness 新增 hydrate_game_creator_plan_gdd_state 与
decide_game_creator_plan_gdd 分发分支及 createPlanGddStateView fixture;未配置策划
状态时 hydrate 与接入前一样抛出,既有 378 条行为不变。新增 plan-gdd.suite.ts 三条
回归并逐条变异验证——逆转对应修复后三条各自以自己的断言变红;修复二的变异是部分
逆转(保留新 Map 结构、只删 comment 比对),因此钉住的是 comment 这一维本身。

验证:appSurface.test.ts 381 passed / 0 failed;agentTraceSummary 与 rememberCommand
(另两个 import src/App 的用例文件)13 passed;agc:typecheck 通过;6 个改动文件
ESLint --max-warnings 0 通过;check:encoding 5409 文件通过;git diff --check 干净。
不改 Rust——三条全在前端,后端语义已经正确。

文档:更正第 23.8 节 M1D-1 行误引的合入提交(5b11a0530 是 ESLint 修正,落地是
0052a80da),并补 M1D 审查修复快照。同时更正既有记录里「Shell typecheck / appSurface
受仓库依赖缺失阻断」的说法——在原分支主工作树上两道门都干净,实际是 bf2185fba 改名
taskGroupLabels.design 后自己把 8 个用例文件断言改红,由 c6a08ef98 补修。

未并入的四条审查发现:hydrate 身份校验排在落盘投影修复之后(第 18.3 节固定顺序,
session.previous.json 提升+删除不可逆);锁竞争错误回传项目绝对路径(第 18.3 节);
design 组展示名剩两处字典未改(第 18.2 节);阶段进度轮次差一格。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 11:11:18 +00:00
parent c6a08ef98f
commit abcc393fde
8 changed files with 386 additions and 8 deletions
+34 -5
View File
@@ -630,7 +630,16 @@ export function App({
const [planGddHydrateBusy, setPlanGddHydrateBusy] = useState(false);
const [planGddDecisionBusy, setPlanGddDecisionBusy] = useState(false);
const [planGddError, setPlanGddError] = useState<string | null>(null);
const planGddDecisionResponseIdsRef = useRef(new Map<string, string>());
const planGddDecisionResponseIdsRef = useRef(
new Map<
string,
{
action: PlanGddDecisionAction;
comment: string | null;
responseId: string;
}
>(),
);
const hydratePlanGddState = useCallback(
async (nextProjectPath?: string) => {
@@ -681,11 +690,25 @@ export function App({
if (!pending || !current?.displayGdd || !invoke || !targetProjectPath) {
throw new Error('当前没有可提交的 GDD 审批决定');
}
const responseKey = `${pending.approvalRequestId}:${action}`;
// 方案 §13.2:busy、超时与网络重试复用同一 responseId,但用户改变 action 或
// comment 后必须换新的。旧键只含 `approvalRequestId:action`,改写修改意见时会
// 带着旧 responseId 提交,命中后端「同 responseId 的审批意图不一致」硬错误。
// 判据方向是宁可多换不可少换:多换的最坏后果是 receipt 已存在时把 replayed 降级
// 成 already-decided,两者都是 Ok;少换是硬错误。
const previousDecision = planGddDecisionResponseIdsRef.current.get(
pending.approvalRequestId,
);
const responseId =
planGddDecisionResponseIdsRef.current.get(responseKey) ??
`gdd-response-${crypto.randomUUID()}`;
planGddDecisionResponseIdsRef.current.set(responseKey, responseId);
previousDecision &&
previousDecision.action === action &&
previousDecision.comment === comment
? previousDecision.responseId
: `gdd-response-${crypto.randomUUID()}`;
planGddDecisionResponseIdsRef.current.set(pending.approvalRequestId, {
action,
comment,
responseId,
});
setPlanGddDecisionBusy(true);
setPlanGddError(null);
try {
@@ -702,6 +725,12 @@ export function App({
});
await hydratePlanGddState(targetProjectPath);
} catch (error) {
// 方案 §18.3 要求 decision 返回后以 hydrate 对权威文件的重验为准,失败分支同样
// 适用:不重灌就会让卡片停在已失效的 pending 身份上,三个决定按钮仍可点,且
// `recoveryPending` 永远翻不成真、「重试恢复」入口不渲染,卡内没有出路。
// 两句顺序不能反——`hydratePlanGddState` 入口会 `setPlanGddError(null)`
// 先写错误再 hydrate 等于把这条错误擦掉。它自身从不抛出,不需要再包一层。
await hydratePlanGddState(targetProjectPath);
setPlanGddError(String(error));
throw error;
} finally {
@@ -162,7 +162,10 @@ export function GddApprovalCard({
);
const submitComment = () => {
if (!commentAction || !comment.trim()) {
// 方案 §18.2`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮
// 已经由 `canDecide` 门住,但弹层是打开后才可能被后台 hydrate 翻掉资格的,
// 所以提交口要自己再判一次,不能只靠按钮 disabled。
if (!canDecide || !commentAction || !comment.trim()) {
return;
}
void onDecision(commentAction, comment.trim())
@@ -282,6 +285,11 @@ export function GddApprovalCard({
placeholder="请输入原因"
onChange={(event) => setComment(event.currentTarget.value)}
/>
{!canDecide ? (
<p className="gdd-approval-card__dialog-hint" role="status">
</p>
) : null}
<div className="gdd-approval-card__dialog-actions">
<button
type="button"
@@ -295,7 +303,7 @@ export function GddApprovalCard({
</button>
<button
type="button"
disabled={busy || !comment.trim()}
disabled={!canDecide || busy || !comment.trim()}
onClick={submitComment}
>
{busy ? '提交中' : '提交决定'}
@@ -3504,6 +3504,14 @@ textarea {
resize: vertical;
}
.gdd-approval-card__dialog-hint {
padding: 9px 10px;
border-radius: 7px;
color: #854d0e;
background: #fff7df;
font-size: 13px;
}
.panel-header {
margin-bottom: 10px;
}
@@ -11,6 +11,7 @@ import {
registerHomeProjectCreationTests,
registerRecentProjectsTests,
} from './appSurface/home.suite';
import { registerPlanGddApprovalTests } from './appSurface/plan-gdd.suite';
import {
registerCanvasAssetTests,
registerProjectAssetTests,
@@ -54,4 +55,5 @@ describe('AI 游戏创作 App 界面边界', () => {
registerProjectAssetTests();
registerAgentRuntimeCommandTests();
registerCanvasAssetTests();
registerPlanGddApprovalTests();
});
@@ -13,6 +13,8 @@ import {
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlanGddStateViewV1 } from '../../src/app/types';
const nativeClipboardMock = vi.hoisted(() => ({
text: '',
}));
@@ -301,6 +303,150 @@ function agentRuntimeUserInputRequest({
};
}
function createPlanGddStateView(
overrides: Partial<PlanGddStateViewV1> = {},
): PlanGddStateViewV1 {
const gddRef = {
gddId: 'gdd-plan-0001',
version: 1,
fingerprint: 'sha256-serde-json-v2:1111111111111111',
};
return {
schemaVersion: 'plan-gdd-state-view.v1',
projectId: 'local-project-draft',
gddId: gddRef.gddId,
state: 'ready_for_approval',
session: {
sessionId: 'plan-session-0001',
sessionRevision: 3,
sessionFingerprint: 'sha256-serde-json-v2:2222222222222222',
phase: 'awaiting_gdd_approval',
clarificationRound: 2,
repairDepth: 0,
accumulatedAgentMillis: 42_000,
activeRunId: null,
awaitingAnswerFor: null,
decisionStateCounts: {
confirmed: 2,
defaultPending: 1,
prototypePending: 0,
},
},
versions: [
{
gddRef,
status: 'ready_for_approval',
approvalRequestId: 'gdd-approval-0001',
createdAtUtc: '2026-08-18T00:00:00Z',
decision: null,
},
],
displayGdd: {
schemaVersion: 'plan-gdd.v1',
projectId: 'local-project-draft',
gddId: gddRef.gddId,
version: gddRef.version,
submissionId: 'action-0123456789abcdef01234567',
approvalRequestId: 'gdd-approval-0001',
actionFingerprint: 'a'.repeat(64),
agentId: 'project-planning',
source: 'agent-delegate',
runProfile: 'standard',
runProfileBindingFingerprint: 'b'.repeat(64),
rootAgentId: 'project-supervisor',
rootRunId: 'run-plan-root-0001',
delegationId: 'delegation-0001',
sessionId: 'plan-session-0001',
sourceSessionRevision: 2,
sourceSessionFingerprint: 'sha256-serde-json-v2:3333333333333333',
createdByRunId: 'run-plan-child-0001',
createdAtUtc: '2026-08-18T00:00:00Z',
fingerprint: gddRef.fingerprint,
game: {
title: '灯塔守夜人',
oneLiner: '在潮汐涨落之间调度光束,护送迷航的船只回港。',
genre: { primary: '策略', fusion: null },
artStyle: {
visualType: '像素',
keywords: ['夜色', '海雾'],
moodAndColor: '冷蓝为主,暖黄光束作为唯一高光。',
mvpArtBoundary: '只做灯塔与三类船只的静帧。',
},
pillars: [
{
name: '光束调度',
playerFeel: '在有限视野里做取舍。',
mechanism: '每回合只能照亮一个扇区。',
decisionState: 'confirmed',
basis: null,
},
],
coreLoop: ['观察潮汐', '分配光束', '结算返港'],
targetUsers: {
coreUsers: '喜欢短局策略的玩家',
preferences: '偏好可预测的规则',
sessionLength: '单局 5 分钟',
referenceGames: ['灯塔物语'],
},
platformFacts: {
runtime: 'web',
viewports: ['desktop', 'mobile'],
inputs: ['pointer'],
preview: '本地预览',
},
mvpSystems: [
{
system: '潮汐时钟',
minimalFunction: '固定三段潮汐循环。',
whyRequired: '没有它就没有节奏压力。',
verifyMethod: '观察一局内三段是否各触发一次。',
decisionState: 'confirmed',
basis: null,
},
],
outOfScope: ['多人对战'],
creatorTips: {
doFirst: '先做潮汐时钟。',
deferForNow: '暂缓天气系统。',
howToVerify: '单局跑满三段潮汐。',
expandWhen: '核心循环稳定后再加船种。',
},
},
decisions: [
{
id: 'decision-0001',
topic: '光束是否可分裂',
state: 'confirmed',
answerSource: 'user_option',
round: 1,
answerSummary: '不可分裂,保持取舍压力。',
basis: null,
},
],
prototypeValidationItems: [
{
id: 'proto-0001',
question: '单扇区照明是否足够做出取舍?',
microPrototype: '纸面推演三回合。',
observation: '玩家是否出现犹豫。',
passCriterion: '三回合内至少一次改变计划。',
},
],
},
pendingApproval: {
gddRef,
pendingActionId: 'action-0123456789abcdef01234567',
actionFingerprint: 'a'.repeat(64),
approvalRequestId: 'gdd-approval-0001',
sessionId: 'plan-session-0001',
runId: 'run-plan-root-0001',
},
approvedGddRef: null,
recoveryPending: false,
...overrides,
};
}
function createProjectSupervisorRuntimeHarness({
projectPath = '/tmp/authorized-game',
sessionId = 'supervisor-session-active',
@@ -366,6 +512,11 @@ function createProjectSupervisorRuntimeHarness({
let rejectRuntime: Record<string, unknown> | null = null;
let answerRuntime: Record<string, unknown> | null = null;
let answerFailuresRemaining = 0;
// 默认不配置策划状态:hydrate 与接入本 harness 之前一样抛出,既有用例行为不变。
let currentPlanGddState: PlanGddStateViewV1 | null = null;
let planGddDecisionError: string | null = null;
let planGddHydrateCount = 0;
const planGddDecisionCalls: Array<Record<string, unknown>> = [];
let runtimeUpdateHandler:
| ((event: {
payload: {
@@ -642,6 +793,26 @@ function createProjectSupervisorRuntimeHarness({
});
return runtimeResult();
}
if (command === 'hydrate_game_creator_plan_gdd_state') {
planGddHydrateCount += 1;
if (!currentPlanGddState) {
throw new Error('PLAN_STATE_NOT_CONFIGURED');
}
return currentPlanGddState;
}
if (command === 'decide_game_creator_plan_gdd') {
planGddDecisionCalls.push({ ...(args ?? {}) });
if (planGddDecisionError) {
const failure = planGddDecisionError;
planGddDecisionError = null;
throw new Error(failure);
}
return {
schemaVersion: 'plan-gdd-decision-result.v1',
outcome: 'decided',
recoveryPending: false,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
@@ -696,6 +867,16 @@ function createProjectSupervisorRuntimeHarness({
failNextAnswers(count = 1) {
answerFailuresRemaining = count;
},
setPlanGddState(state: PlanGddStateViewV1 | null) {
currentPlanGddState = state;
},
failNextPlanGddDecision(message: string) {
planGddDecisionError = message;
},
planGddDecisionCalls,
planGddHydrateCount() {
return planGddHydrateCount;
},
appendSupervisorMessage(message: Record<string, unknown>) {
currentSupervisorMessages.push(message);
},
@@ -822,6 +1003,7 @@ export {
cleanup,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
createPlanGddStateView,
createProjectSupervisorRuntimeHarness,
deriveAgentStatusCards,
describe,
@@ -0,0 +1,136 @@
import {
createPlanGddStateView,
createProjectSupervisorRuntimeHarness,
expect,
fireEvent,
it,
openMainProject,
renderAppAt,
screen,
waitFor,
within,
} from './harness';
async function mountApprovalCard(
harness: ReturnType<typeof createProjectSupervisorRuntimeHarness>,
) {
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
renderAppAt('/');
await openMainProject(harness.projectPath);
return await screen.findByLabelText('GDD 审批卡');
}
function openReviseDialog() {
fireEvent.click(screen.getByRole('button', { name: '修改' }));
return screen.getByRole('dialog');
}
function typeComment(dialog: HTMLElement, text: string) {
const textarea = within(dialog).getByPlaceholderText('请输入原因');
fireEvent.change(textarea, { target: { value: text } });
return textarea as HTMLTextAreaElement;
}
export function registerPlanGddApprovalTests() {
it('re-hydrates authority after a failed GDD decision and keeps the failure visible', async () => {
const harness = createProjectSupervisorRuntimeHarness();
harness.setPlanGddState(createPlanGddStateView());
await mountApprovalCard(harness);
const hydrateCallsBeforeDecision = harness.planGddHydrateCount();
// 决定失败后,hydrate 应当读到权威侧已经变成「投影恢复中」的事实。
harness.setPlanGddState(createPlanGddStateView({ recoveryPending: true }));
harness.failNextPlanGddDecision('PLAN_STALE_APPROVAL');
fireEvent.click(screen.getByRole('button', { name: '批准 v1' }));
// 失败分支必须重灌权威状态,否则卡片会停在已失效的 pending 身份上。
await waitFor(() => {
expect(harness.planGddHydrateCount()).toBeGreaterThan(
hydrateCallsBeforeDecision,
);
});
// 重灌后「重试恢复」入口出现——这是 recoveryPending 下唯一被允许的动作。
await screen.findByRole('button', { name: '重试恢复' });
// 而且重灌不能把决定失败的原因擦掉:hydrate 入口会 setPlanGddError(null)
// 两句顺序写反这条断言就红。
expect(screen.getByRole('alert').textContent).toContain(
'PLAN_STALE_APPROVAL',
);
});
it('reuses one responseId while the decision is unchanged and mints a new one once the comment changes', async () => {
const harness = createProjectSupervisorRuntimeHarness();
harness.setPlanGddState(createPlanGddStateView());
await mountApprovalCard(harness);
const dialog = openReviseDialog();
typeComment(dialog, '把核心循环压到三步');
harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED');
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
await waitFor(() => {
expect(harness.planGddDecisionCalls).toHaveLength(1);
});
// 原样重试:属于方案 §13.2 的 busy/超时/网络重试,必须复用同一 responseId。
harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED');
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
await waitFor(() => {
expect(harness.planGddDecisionCalls).toHaveLength(2);
});
expect(harness.planGddDecisionCalls[1].responseId).toBe(
harness.planGddDecisionCalls[0].responseId,
);
// 改写修改意见:审批意图变了,必须换新的 responseId,否则后端会以
// 「同 responseId 的审批意图不一致」硬拒,用户改写后的原因永远落不了盘。
typeComment(dialog, '把核心循环压到两步,并去掉天气系统');
harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED');
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
await waitFor(() => {
expect(harness.planGddDecisionCalls).toHaveLength(3);
});
expect(harness.planGddDecisionCalls[2].responseId).not.toBe(
harness.planGddDecisionCalls[0].responseId,
);
expect(harness.planGddDecisionCalls[2].comment).toBe(
'把核心循环压到两步,并去掉天气系统',
);
});
it('blocks the already-open comment dialog once recovery starts without discarding the typed reason', async () => {
const harness = createProjectSupervisorRuntimeHarness();
harness.setPlanGddState(createPlanGddStateView());
await mountApprovalCard(harness);
const dialog = openReviseDialog();
const textarea = typeComment(dialog, '战斗节奏太慢,请压缩到三个回合');
expect(
(
within(dialog).getByRole('button', {
name: '提交决定',
}) as HTMLButtonElement
).disabled,
).toBe(false);
// 弹层是打开之后才被后台 hydrate 翻掉决定资格的:触发按钮的 disabled 管不到它。
harness.setPlanGddState(createPlanGddStateView({ recoveryPending: true }));
fireEvent.focus(window);
await waitFor(() => {
expect(
(
within(dialog).getByRole('button', {
name: '提交决定',
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
// 已经写好的原因不能被丢掉——所以是禁用加说明,不是自动关弹层。
expect(textarea.value).toBe('战斗节奏太慢,请压缩到三个回合');
expect(harness.planGddDecisionCalls).toHaveLength(0);
});
}
@@ -1,5 +1,16 @@
# 决策记录
## 2026-08-18 M1D 审查修复:GDD 审批决定失败路径与恢复期弹层门控
- **审查范围与基线**:对 `14c00017c..bf2185fba` 的 M1D-1/M1D-2 全量改动做规格对照审查(技术方案第 13、18 节)。审查完成后分支又前进了 `4624fd795`(文档同步)与 `c6a08ef98`(前端文案回归断言修复)两条,二者都不改 `src/**` 生产代码,审查结论不受影响。
- **修复一:决定失败也必须重灌权威状态**`decidePlanGdd` 原来只在成功分支 hydrate`catch` 只写错误后 rethrow。后端 `decide_plan_gdd_at` 有多条真实 `PLAN_STALE_APPROVAL` 分支(GDD 已不在当前 lineage、identity 不符、版本被更新版本取代、pending 丢失或不一致),命中后卡片停在已失效的 pending 身份上、三个决定按钮仍可点,且 `recoveryPending` 永不翻真导致「重试恢复」入口不渲染,卡内没有任何恢复路径。现在失败分支同样 hydrate,落实第 18.3 节「approval decision 返回后调用 hydrate」(该句不区分成功与失败)。**两句顺序已被回归钉死**:`hydratePlanGddState` 入口会 `setPlanGddError(null)`,必须先 hydrate 再写决定错误,写反会把这条错误擦掉。
- **修复二:responseId 复用键纳入 comment**。原键为 `approvalRequestId:action`,不含 comment,违反第 13.2 节「用户改变 action/comment 后必须生成新 responseId」。在第 14 节恢复矩阵承认的「receipt 已提交但 command response 丢失」构造下,用户改写修改意见后重提会带着旧 responseId,命中后端「同 responseId 的审批意图不一致」硬拒,改写后的原因永远落不了盘。现在键挂在 `approvalRequestId` 上并比对 `{action, comment}` 完整意图。**判据方向为宁可多换不可少换**:receipt 已存在时多换的最坏后果是 `replayed` 降级成 `already-decided`(两者都是 Ok,且 already-decided 正是第 18.2 节要求的刷新态),少换则是硬错误。
- **修复三:`recoveryPending` 必须挡住已经打开的评论弹层**。第 18.2 节要求恢复期只允许重试同一 ID、不允许提交决定;原实现只把 `canDecide` 接到三个触发按钮上,而弹层是打开之后才可能被后台 hydrate 翻掉决定资格的,其「提交决定」按钮只看 `busy || !comment.trim()`,仍可提交。现在 `submitComment` 与该按钮都判 `canDecide`,并在弹层内说明原因。**刻意不自动关弹层**,否则会丢掉用户已经写好的修改意见。
- **测试**appSurface harness 新增 `hydrate_game_creator_plan_gdd_state` / `decide_game_creator_plan_gdd` 两个分发分支与 `createPlanGddStateView` fixture;未配置策划状态时 hydrate 与接入前一样抛出,既有用例行为不变。新增 `tests/appSurface/plan-gdd.suite.ts` 三条回归,并逐条做过变异验证——把对应修复单独逆转后三条各自以自己的断言变红(修复二的变异是**部分逆转**:保留新 Map 结构、只删掉 comment 比对,因此该用例钉住的是 comment 这一维本身而非那次重构)。
- **验证**`appSurface.test.ts` **381 passed / 0 failed**378 既有 + 3 新增);`agentTraceSummary``rememberCommand`(另两个 import `src/App` 的用例文件)13 passed`agc:typecheck` 通过;6 个改动/新增文件 ESLint `--max-warnings 0` 通过;`check:encoding` 5409 文件通过。不改 Rust——三条全在前端,后端语义已经正确。
- **对既有记录的更正**M1D-1 与 M1D-2 两条记录分别称「Shell TypeScript typecheck 仍被仓库既有依赖缺失阻断」「appSurface UI suite 受仓库现有缺失 Tauri plugin 依赖阻断,未把该基线失败归因于本包」,在原分支主工作树上都不成立:`agc:typecheck` 干净退出,appSurface 378 条全绿;两道门分别位于 CI 的 `check:native-shells`(且 typecheck 排在 cargo test 之前)与 Frontend tests 内,一直是活的。实际情况与记录相反——`bf2185fba` 改名 `taskGroupLabels.design` 后,appSurface 有 8 个用例文件的断言变红,随后由 `c6a08ef98` 修复;把该套件记为「基线阻断、不归因本包」正是让这条自带回归合入的原因。**隔离工作树的依赖缺失不能作为跳过门禁的依据,须回原工作树复跑后再下结论。**
- **未修的审查发现(本次不并入,单列后续)**:① hydrate 在校验 GDD/session 的 projectId 与 manifest 一致之前,已执行 `reconcile_plan_gdd_approval_projections_at`、session previous 提升与 index 重建等落盘修复,违反第 18.3 节固定顺序,其中 `session.previous.json` 的提升+删除不可逆(触发需外部篡改 `.agent/`,App 自身流程造不出该分歧);② 项目写锁竞争时 `acquire_project_write_lock` 的错误原文内嵌项目绝对路径,被原样回传前端,违反第 18.3 节「返回值不包含绝对路径」,常态可达;③ design 组展示名只改了 `taskGroupLabels` 一本字典,`agentPresentation.ts``groupConfigs``view/project-development/index.tsx``summarizeAgent` 仍硬编码「策划 Agent」,与新阶段「立项策划」同屏共存,违反第 18.2 节;④ 阶段进度「轮次 X/3」直接透传 0-indexed 的 `clarificationRound` 未 +1(后端自己用的是 `current_round + 1`),最后一轮显示「轮次 2/3」,字面暗示还剩一轮。
## 2026-08-18 M1D-2 隔离工作树实现:入口分流与阶段进度
- **隔离范围**:在 `codex/genarrative-isolated`、基线 `5b11a0530` 上开工;只接入口分流、阶段进度和实际项目总控页面的现有审批卡挂载,不接 M2 `approvedGddRef` 构建绑定、完整构建按钮或 M1E 端到端故障注入。
@@ -2023,10 +2023,12 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1``M1A-4`、`M1B-1`
| `M1C-2a` | Goal Contract 接线:turn 1 冻结、固定验收图、审批前置门取证 | `M1C-1``M1A-3` | **当前隔离 worktree 已完成并通过本包门禁,尚未合回**turn 1 的 request-scoped schema 与格式修复都只允许一个固定 `agent.goal_contract`;按项目变化的四项之外,`preferences=[]`、唯一验收节点及证据工具均冻结。Fast GDD evidence 只接受当前 Supervisor 根 run 对 `game/fast_gdd.md` 从第 1 行到 EOF 的同 hash 完整分页;无/旧证据先继续读取,显式 failed 才给原 delivery 的 `repairOfDelegationId`passed 且 delivery 已认领才建 pending。pending/recovery/completion/finalization 均按同 identity 幂等,审批后 Markdown 改写不损坏 Graph。格式、Provider 强判据、M1C-2a、Acceptance Graph、planning submit/approval、finalization、all-targets、编码与 diff 门禁均通过;扩展 autonomous completion 整组的无关 game-chat 并行超时及精确复跑结果见 decision-log 同日条,不改该路径。不包含 `M1C-2b`、UI 或构建准入 |
| `M1C-2b` | 澄清中转接线、轮次派生、预算注入 | `M1C-2a` | **实现与本包门禁已完成并已快进合回 `feat/five_min_design`**:首 child 的 revision 1 session、`NeedsUserInput → awaiting_user_input`、回答绑定后 continuation 的确定性 session 投影、审批后 `revise/reject` 用户修订谱系及 Provider 活跃时间 usage fact/fold 已接线;末次 `plan.submit_gdd` usage 在 receipt/session successor 落盘且 standalone/v4 anchors 精确消费后于同一项目锁内折叠,真实 receipt 回归证明累计值恰好推进一次,重复审批与 recovery reconcile 不二次推进。`planning_clarification_*` **13 passed / 0 failed**(M1C-2c 语义回归另见本包),另有 static deliveries 44、planning storage 13、planning submit 53、Provider usage 4、末次 usage receipt 1、barrier detail 3 条定向回归通过;格式、offline all-targets、编码与 diff 门禁通过。锁序承诺只适用于 **M1C-2b 新增的 planning 澄清写投影路径**`main_loop` 既有通用 completion blocker 的 execution→project 路径不在本包。第 4 轮信封在正常路径不可达:`agent.delegate` 已在工具边界按血缘上限硬拒并返回 failed observationcoordinator 的超三轮 reconciliation 仅用于损坏血缘纵深防御。审批 UI、hydrate、构建准入和下游完整构建不在本包范围 |
| `M1C-2c` | 决策卡 A/B 语义(第 23.9 节,2026-08-18 实现完成并合回):选项 → 台账映射改为 A/B 均 `confirmed/user_option`、信封 label 形状校验、planning role brief 与 Supervisor playbook/final-reply 文案(B 必须是真实岔路、第 3 项恒定且 description 须给出可执行验证方式、改口转述规则、提问纪律) | `M1C-2b` | **实现与门禁完成,已由 `6e4bd9703` 合回 `feat/five_min_design`**Runtime 已实现 A/B/固定第三项校验、B 不再生成 `default_pending``answerSummary` 逐字保真;非法 C/缺项 fail-closedA/B/自由填写回归已通过。`planning_clarification_*` 13、`project_planning` prompt 5、`planning_submit` 定向回归、prompt bundle、格式、编码、diff、offline all-targets 均通过;不含 M1D-1 前端、hydrate、构建准入或下游完整构建 |
| `M1D-1` | 前端 hydrate 与 GDD 审批卡;决策卡按第 23.9 节实现(label 动态渲染、默认焦点 A、Other 槽不变) | `M1C-2b` | **已完成并合入 `feat/five_min_design``5b11a0530`**:新增严格 `{projectPath}` hydrate command、`plan-gdd-state-view.v1` Rust read model、审批卡与独立 GDD 正文详情弹层;页面只消费 hydrate,决定 responseId 按审批请求/动作复用,`recoveryPending` 仅提供恢复重试;审批前置 pending 与错绑 session 继续 fail-closed。 |
| `M1D-1` | 前端 hydrate 与 GDD 审批卡;决策卡按第 23.9 节实现(label 动态渲染、默认焦点 A、Other 槽不变) | `M1C-2b` | **已完成并合入 `feat/five_min_design`落地 `0052a80da`,其后 ESLint 修正 `5b11a0530`**:新增严格 `{projectPath}` hydrate command、`plan-gdd-state-view.v1` Rust read model、审批卡与独立 GDD 正文详情弹层;页面只消费 hydrate,决定 responseId 按审批请求/动作复用,`recoveryPending` 仅提供恢复重试;审批前置 pending 与错绑 session 继续 fail-closed。 |
| `M1D-2` | 入口分流与阶段进度 | `M1D-1` | **已完成并以 `bf2185fba` 合入 `feat/five_min_design`**:游戏新项目默认 `standard + project-supervisor-plan`,显式“直接开建”保持 `autonomous-game-build`;阶段进度显示轮次 x/3、当前版本和状态徽章;实际项目总控页面挂载 hydrate/审批卡,并将 `project-planning` / 设计组展示名收口。未接 M2 approved-GDD 构建绑定或完整构建按钮。 |
| `M1E` | 端到端与故障注入收口 | `M1D-2` | 第 21 节测试矩阵中跨层场景 |
**2026-08-18 M1D 审查修复快照**:对 `14c00017c..bf2185fba` 做规格对照审查后,修复三条决定链路缺陷并补齐回归。① `decidePlanGdd` 的失败分支原来不 hydrate,命中后端任一 `PLAN_STALE_APPROVAL` 分支后卡片会停在已失效的 pending 身份上、`recoveryPending` 永不翻真导致「重试恢复」入口不渲染,现已按第 18.3 节在失败分支同样重灌(顺序钉死:`hydratePlanGddState` 入口会清空错误,必须先 hydrate 再写决定错误)。② responseId 复用键原为 `approvalRequestId:action`,不含 comment,违反第 13.2 节「改变 action/comment 必须换新 responseId」,现改为比对 `{action, comment}` 完整意图,判据方向为宁可多换不可少换。③ 第 18.2 节「`recoveryPending` 时不允许提交决定」原来只作用于三个触发按钮,已打开的评论弹层仍可提交,现已同门控并保留用户已输入内容。回归位于 `tests/appSurface/plan-gdd.suite.ts`,三条均经变异验证(逆转对应修复即变红);`appSurface.test.ts` 381 passed`agc:typecheck`、ESLint、编码检查通过。**本次不含 Rust 改动**;hydrate 身份校验与落盘投影修复的顺序、锁错误回传绝对路径、design 组展示名剩余两处字典、以及阶段进度轮次差一格四条审查发现单列后续,未并入。
**2026-08-14 `M1B-1` 合入验收快照**:已合入实现集中在 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs`,并由 `runtime_protocol.rs` 注册。已具备 `plan-gdd.v1``plan-gdd-index.v1``plan-session.v1``plan.submit_gdd` input 的 strict serde 形状校验、文本/ID/时间/枚举边界、typed serde fingerprint、canonical JSON(重复键、BOM、尾空白、字段顺序)解析、GDD 连续版本链、session `revision + 1` / `previousFingerprint` 链、create-only durable writer、session 原子替换与受限 recovery,以及 `project-planning / agent-delegate / standard / project-supervisor` writer identity。通用 `file.write``file.patch``file.delete``project.patchset` 与 checkpoint restore 对 `.agent/planning/**``game/fast_gdd.md` 只挡写,planning 的 `file.read` / `file.list` 仍可读;M1B-1 合入时没有注册或执行 `plan.submit_gdd`,也没有实现 approval pending、receipt、UI 或构建准入。
已验证第 9.1 节 golden vectorcanonical envelope 3857 bytes 与指纹 `sha256-serde-json-v2:a59856de7ef134cf2f49c4dedd2ba10ae4ab2340a9634d402eb792b6ee5458f0`)及 11 个定向 storage 测试;durable writer 的目标 schema 重解析与文件名/版本核对、index 与权威 GDD 对账、缺失/损坏/陈旧 index 的锁内链重建、`(requestId,responseId)` / decision/ref / active run/phase 等 session 约束、GDD 文件枚举/链读取及 primary 损坏/previous 提升/分叉 recovery 矩阵均已覆盖。M1B-1 尚无 approval receipt schema,因此多版本 `statusCache` 只表达无 receipt 的预审批投影;M1C-1 接入 receipt 后重建真实 approved/revise/reject/superseded 状态。`cargo check --offline``npm run check:encoding``git diff --check` 均通过;该包现已合入本分支,这一历史快照不把后续 `plan.submit_gdd`、审批闭环、UI 或 renderer 误报为 M1B-1 已实现。