做方案链路补无头审批入口,不再只能靠 GUI 拍板

decide_game_creator_plan_gdd 此前只有 Tauri command 一个入口,无头跑
--plan 到审批卡就永远收不了尾。补两条 CLI:

- --plan-gdd-status <项目路径>:只读投影
- --plan-gdd-decide <项目路径> <approve|revise|reject> [--response-id] [--stdin]

审批卡的 identity 由 CLI 自己 hydrate,不让调用方手拼 gddId/fingerprint——
拼错的后果是 PLAN_STALE_APPROVAL,而不是一条能读懂的用法错误。hydrate 抽成
hydrate_game_creator_plan_gdd_state_for_path,GUI 与 CLI 共用同一道权限闸。

swarm 测试脚本的审批必须和 CLI 并发做:Run 停在审批位时状态是
waiting-for-user-input,而 swarm CLI 恰好把这个状态算作「本轮还在跑」,turn
永远不 settle、CLI 也就永远不退出。等 CLI 退出再审批那一步根本到不了,实测
是干等到超时。改成认「等待 Fast GDD 审批决定」这条投影文案触发。并发子进程
单独跟踪,不占 activeChild 槽位,否则 Ctrl-C 杀不到 CLI。

decide 回执带 recoveryPending 时补一次 --agent-resume:审批回执落盘和唤醒
后台任务是两件事,唤醒失败被降级成 recoveryPending,审批已生效而 Run 仍停在
等待位,实测只有补这一次恢复才会重新起 turn。

真机验证:影子机器人解谜需求跑通到 state=approved、approvedGddRef.version=1、
game/fast_gdd.md 落盘,全程未开 GUI。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 09:45:34 +00:00
parent 0482d08a69
commit adfa6ed8d3
4 changed files with 587 additions and 9 deletions
@@ -960,12 +960,25 @@ export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) {
return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1;
}
// GDD 审批位不能等 CLI 退出之后再处理:Run 停在这里时状态是 waiting-for-user-input
// 而 swarm CLI 恰好把这个状态算作「本轮还在跑」,turn 永远不 settleCLI 也就永远
// 不退出。所以审批必须在 CLI 还活着的时候并发做完,让 Run 自己继续跑到收束。
// 这一句是 PlanGddCompletionBlockerKind::AwaitingApprovalDecision 专有的投影文案,
// 另外三个 blocked 子状态都不会打出它;即便认错了,真正的判据也是随后那次
// --plan-gdd-status,没有待决定审批时不会有任何写入。
const planGddApprovalWaitPattern = /等待 Fast GDD 审批决定/u;
export function swarmOutputAwaitsPlanGddApproval(line) {
return planGddApprovalWaitPattern.test(line);
}
async function runTaskCargo(
cliArguments,
task,
setActiveChild,
timeoutMs,
autoPilot = false,
onPlanGddApprovalWait = null,
) {
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
stdio: ['pipe', 'pipe', 'inherit'],
@@ -977,6 +990,23 @@ async function runTaskCargo(
let taskSubmitted = false;
let promptsSeen = 0;
let sittingAtPrompt = false;
let planGddApproval = null;
let planGddApprovalError = null;
let planGddApprovalStarted = false;
let planGddApprovalPromise = null;
const startPlanGddApproval = () => {
planGddApprovalStarted = true;
console.log('[自动审批] 检测到 Fast GDD 审批位,正在提交 approve');
planGddApprovalPromise = onPlanGddApprovalWait()
.then((value) => {
planGddApproval = value;
})
.catch((error) => {
planGddApprovalError = error;
// 审批没成的话 Run 会一直停在等待位,干等到超时只会把真正的原因埋掉。
void terminateChildTree(child).catch(() => {});
});
};
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
@@ -989,6 +1019,13 @@ async function runTaskCargo(
reportLines.push(normalizedLine);
settled = true;
}
if (
onPlanGddApprovalWait &&
!planGddApprovalStarted &&
swarmOutputAwaitsPlanGddApproval(normalizedLine)
) {
startPlanGddApproval();
}
}
if (!autoPilot || child.stdin.writableEnded) return;
const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine);
@@ -1027,7 +1064,13 @@ async function runTaskCargo(
if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) {
reportLines.push(normalizedPendingLine);
}
return { ...result, turnReportOutput: reportLines.join('\n') };
await planGddApprovalPromise;
if (planGddApprovalError) throw planGddApprovalError;
return {
...result,
turnReportOutput: reportLines.join('\n'),
planGddApproval,
};
} finally {
setActiveChild(null);
}
@@ -1752,6 +1795,137 @@ async function reportPlanningOutputs(projectPath) {
}
}
const planGddApprovalTimeoutMs = 60_000;
export const planGddStatusOutputPrefix = 'planGddStateJson=';
export const planGddDecisionOutputPrefix = 'planGddDecisionJson=';
function parsePrefixedJsonLine(output, prefix, label) {
const line = output
.split('\n')
.map((value) => (value.endsWith('\r') ? value.slice(0, -1) : value))
.find((value) => value.startsWith(prefix));
if (!line) throw new Error(`${label}缺少 ${prefix} 输出`);
try {
return JSON.parse(line.slice(prefix.length));
} catch (error) {
throw new Error(`解析${label}失败:${error.message}`);
}
}
export function parsePlanGddStatusOutput(output) {
return parsePrefixedJsonLine(
output,
planGddStatusOutputPrefix,
'Fast GDD 审批状态',
);
}
export function parsePlanGddDecisionOutput(output) {
return parsePrefixedJsonLine(
output,
planGddDecisionOutputPrefix,
'Fast GDD 审批回执',
);
}
// 审批卡是这条链路唯一的人类判据,所以自动应答只投 approve,且只在投影确实有一张
// 待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把判据
// 换成噪声;要跑那两条分支就手工调 --plan-gdd-decide。
export function planGddAutoApprovalIsPending(state) {
return Boolean(state?.pendingApproval);
}
async function settlePlanGddApproval(
projectPath,
runtimeConfigPath,
setActiveChild,
) {
const readStatus = async () => {
const result = await runCapturedCargo(
['--config-dir', runtimeConfigPath, '--plan-gdd-status', projectPath],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批状态查询',
},
);
if (result.code !== 0 || result.signal) {
throw new Error(
`读取 Fast GDD 审批状态失败:${result.stderr.trim() || result.stdout.trim()}`,
);
}
return parsePlanGddStatusOutput(result.stdout);
};
const before = await readStatus();
if (!planGddAutoApprovalIsPending(before)) {
return { decided: false, state: before };
}
const decision = await runCapturedCargo(
[
'--config-dir',
runtimeConfigPath,
'--plan-gdd-decide',
projectPath,
'approve',
],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批决定',
},
);
if (decision.code !== 0 || decision.signal) {
throw new Error(
`提交 Fast GDD 审批决定失败:${decision.stderr.trim() || decision.stdout.trim()}`,
);
}
const receipt = parsePlanGddDecisionOutput(decision.stdout);
// 回执落盘和唤醒后台任务是两件事:decide 命令把唤醒失败降级成 recoveryPending
// 于是审批已经生效、Run 却仍停在 waiting-for-user-input。实测就是这样——只有
// 补一次 --agent-resume 才会重新起 turn。这是仓库自己给这个状态定义的恢复动作。
let recovered = false;
if (receipt.recoveryPending) {
const resume = await runCapturedCargo(
['--config-dir', runtimeConfigPath, '--agent-resume', projectPath],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批后恢复后台任务',
},
);
if (resume.code !== 0 || resume.signal) {
throw new Error(
`审批已提交但恢复后台任务失败:${resume.stderr.trim() || resume.stdout.trim()}`,
);
}
recovered = true;
}
return { decided: true, receipt, recovered, state: await readStatus() };
}
async function reportPlanGddApproval(approval) {
const { state } = approval;
console.log('\nFast GDD 审批:');
if (!approval.decided) {
console.log(` [无待决定审批] 当前投影状态=${state.state}`);
return;
}
console.log(
` [已批准] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`,
);
if (approval.recovered) {
console.log(
' [已恢复] 审批回执的 recoveryPending 由一次 --agent-resume 收口',
);
}
if (state.session) {
console.log(
` 澄清轮次=${state.session.clarificationRound} 返工深度=${state.session.repairDepth} phase=${state.session.phase}`,
);
}
}
export async function hasConfiguredEditorApiKey(configDir) {
let configured = false;
for (const fileName of [configFileName, localConfigFileName]) {
@@ -1904,17 +2078,29 @@ export async function runSwarmTestChat(options) {
const setActiveChild = (child) => {
activeChild = child;
};
// GDD 审批要和 swarm CLI 并发跑,两者不能共用 activeChild 这一个槽位:审批子进程
// 结束时的 setActiveChild(null) 会把 CLI 从槽里抹掉,Ctrl-C 就杀不到它了。
const concurrentChildren = new Set();
const setConcurrentChild = (child) => {
if (child) concurrentChildren.add(child);
else concurrentChildren.clear();
};
const stopRequested = () => receivedSignal !== null;
const handleSignal = (signal) => {
const repeatedSignal = receivedSignal !== null;
receivedSignal ??= signal;
if (!activeChild) return;
void terminateChildTree(activeChild, signal, repeatedSignal).catch(
() => {},
);
const targets = [activeChild, ...concurrentChildren].filter(Boolean);
if (targets.length === 0) return;
for (const target of targets) {
void terminateChildTree(target, signal, repeatedSignal).catch(() => {});
}
if (repeatedSignal) return;
forceTerminationHandle = setTimeout(() => {
void terminateChildTree(activeChild, 'SIGKILL', true).catch(() => {});
for (const target of [activeChild, ...concurrentChildren].filter(
Boolean,
)) {
void terminateChildTree(target, 'SIGKILL', true).catch(() => {});
}
}, childTerminationGraceMs);
forceTerminationHandle.unref();
};
@@ -2005,6 +2191,14 @@ export async function runSwarmTestChat(options) {
? null
: Math.max(1, timeoutDeadline - Date.now()),
options.plan,
options.plan
? () =>
settlePlanGddApproval(
project.path,
runtimeConfig.path,
setConcurrentChild,
)
: null,
)
: await runInteractiveCargo(chatArguments, setActiveChild);
} catch (error) {
@@ -2023,10 +2217,26 @@ export async function runSwarmTestChat(options) {
if (options.plan) {
// 立项策划不出游戏产物,正式验收在 GDD 审批卡上;这里只报告落盘情况,
// 是否收束已经由 CLI 的退出码判过了。
// 自动任务档的审批已经在 CLI 运行期间并发做完了;手工档(人自己敲 Ctrl+D
// 退出)没有那次触发,退出后补一次,没有待决定审批时它是只读的。
phase = 'plan-approval';
const approval =
chat.planGddApproval ??
(await settlePlanGddApproval(
project.path,
runtimeConfig.path,
setConcurrentChild,
));
if (receivedSignal) break session;
phase = 'plan-report';
await reportPlanGddApproval(approval);
await reportPlanningOutputs(project.path);
phase = 'complete';
console.log('\n立项策划链路已收束:Run 正常结束,策划产物见上方清单。');
console.log(
approval.decided
? '\n立项策划链路已收束:Fast GDD 已批准,策划产物见上方清单。'
: '\n立项策划链路已收束:Run 正常结束但没有待决定审批,策划产物见上方清单。',
);
break session;
}
phase = 'artifact-validation';
+287 -1
View File
@@ -113,6 +113,15 @@ pub(crate) enum CliCommand {
AgentResume {
project_path: PathBuf,
},
PlanGddStatus {
project_path: PathBuf,
},
PlanGddDecide {
project_path: PathBuf,
action: String,
response_id: Option<String>,
read_comment_from_stdin: bool,
},
RunnerStatus,
RunnerShutdownIfIdle,
AgentRun {
@@ -148,6 +157,7 @@ impl CliCommand {
| Self::AgentGoalResume { .. }
| Self::AgentGoalClear { .. }
| Self::AgentResume { .. }
| Self::PlanGddDecide { .. }
| Self::RunnerShutdownIfIdle
)
}
@@ -155,7 +165,10 @@ impl CliCommand {
pub(crate) fn is_read_only_status(&self) -> bool {
matches!(
self,
Self::AgentRuntimeStatus { .. } | Self::AgentGoalStatus { .. } | Self::RunnerStatus
Self::AgentRuntimeStatus { .. }
| Self::AgentGoalStatus { .. }
| Self::PlanGddStatus { .. }
| Self::RunnerStatus
)
}
@@ -203,6 +216,8 @@ impl CliCommand {
| Self::AgentRetry { project_path, .. }
| Self::AgentSteer { project_path, .. }
| Self::AgentResume { project_path }
| Self::PlanGddStatus { project_path }
| Self::PlanGddDecide { project_path, .. }
| Self::PreviewServe { project_path }
| Self::AgentRun { project_path, .. } => Some((project_path, false)),
Self::LlmStatus | Self::RunnerStatus | Self::RunnerShutdownIfIdle => None,
@@ -416,6 +431,56 @@ fn read_cli_agent_steer_instruction(reader: &mut impl Read) -> Result<String, St
Ok(instruction.to_string())
}
fn read_cli_plan_gdd_approval_comment(reader: &mut impl Read) -> Result<String, String> {
const MAX_STDIN_BYTES: u64 = 8 * 1024;
let mut bytes = Vec::new();
reader
.take(MAX_STDIN_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("从 stdin 读取 GDD 审批意见失败:{error}"))?;
if bytes.len() as u64 > MAX_STDIN_BYTES {
return Err(format!(
"GDD 审批意见 stdin 超过 {MAX_STDIN_BYTES} 字节上限"
));
}
let comment =
String::from_utf8(bytes).map_err(|_| "GDD 审批意见 stdin 必须是 UTF-8 文本".to_string())?;
let comment = comment.trim();
if comment.is_empty() {
return Err("GDD 审批意见 stdin 不能为空".to_string());
}
// 长度上界交给 normalize_plan_gdd_approval_comment:审批意见的 11000 scalar
// 约束是写入侧权威,CLI 再抄一份就会有两个会漂移的判据。
Ok(comment.to_string())
}
fn take_cli_named_flag_value(
args: &mut Vec<String>,
flag: &str,
usage: &str,
) -> Result<Option<String>, String> {
let positions = args
.iter()
.enumerate()
.filter_map(|(index, arg)| (arg == flag).then_some(index))
.collect::<Vec<_>>();
if positions.len() > 1 {
return Err(usage.to_string());
}
let Some(index) = positions.first().copied() else {
return Ok(None);
};
let value = args
.get(index + 1)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| usage.to_string())?
.to_string();
args.drain(index..=index + 1);
Ok(Some(value))
}
fn read_cli_agent_goal_payload(reader: &mut impl Read) -> Result<CliAgentGoalPayload, String> {
const MAX_STDIN_BYTES: u64 = 64 * 1024;
let mut bytes = Vec::new();
@@ -645,6 +710,53 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, S
project_path: PathBuf::from(&args[1]),
}));
}
if args.first().map(String::as_str) == Some("--plan-gdd-status") {
const USAGE: &str = "用法:--plan-gdd-status <本地项目绝对路径>";
if args.len() != 2 || args[1].trim().is_empty() {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::PlanGddStatus {
project_path: PathBuf::from(&args[1]),
}));
}
if args.first().map(String::as_str) == Some("--plan-gdd-decide") {
const USAGE: &str = "用法:--plan-gdd-decide <本地项目绝对路径> <approve|revise|reject> [--response-id <gdd-response-…>] [--stdin]";
let mut rest = args[1..].to_vec();
let read_comment_from_stdin =
match rest.iter().filter(|arg| arg.as_str() == "--stdin").count() {
0 => false,
1 => {
let index = rest
.iter()
.position(|arg| arg == "--stdin")
.ok_or_else(|| USAGE.to_string())?;
rest.remove(index);
true
}
_ => return Err(USAGE.to_string()),
};
let response_id = take_cli_named_flag_value(&mut rest, "--response-id", USAGE)?;
if rest.len() != 2 || rest.iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
let action = rest[1].trim().to_string();
if !matches!(action.as_str(), "approve" | "revise" | "reject") {
return Err(USAGE.to_string());
}
// revise/reject 的 comment 是写入侧硬约束,缺了必然在落盘前失败。在解析期就拒绝,
// 错误才指得回命令行本身,而不是变成一条读起来像后端故障的存储错误。
if action != "approve" && !read_comment_from_stdin {
return Err(
"revise/reject 审批必须通过 --stdin 提供 11000 scalar 的修改意见".to_string(),
);
}
return Ok(Some(CliCommand::PlanGddDecide {
project_path: PathBuf::from(&rest[0]),
action,
response_id,
read_comment_from_stdin,
}));
}
if args.first().map(String::as_str) == Some("--agent-enqueue") {
let mut rest = args[1..].to_vec();
let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") {
@@ -1423,6 +1535,63 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
);
Ok(())
}
CliCommand::PlanGddStatus { project_path } => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
let state =
hydrate_game_creator_plan_gdd_state_for_path(&project_path.display().to_string())?;
println!("plan.gdd.status");
println!(
"planGddStateJson={}",
serialize_agent_runtime_cli_payload(&state)?
);
Ok(())
}
CliCommand::PlanGddDecide {
project_path,
action,
response_id,
read_comment_from_stdin,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let comment = if read_comment_from_stdin {
Some(read_cli_plan_gdd_approval_comment(
&mut std::io::stdin().lock(),
)?)
} else {
None
};
let project_path_value = project_path.display().to_string();
// 审批卡的 identity 只有投影这一个权威来源。CLI 不接受手工传 gddId/fingerprint
// 那样每个调用方都要自己拼一遍身份,拼错的后果是 PLAN_STALE_APPROVAL
// 而不是一条能读懂的用法错误。
let state = hydrate_game_creator_plan_gdd_state_for_path(&project_path_value)?;
let pending = state
.pending_approval
.ok_or_else(|| "当前没有待决定的 Fast GDD 审批".to_string())?;
// 每次调用换新 responseId 是安全方向:重复键的最坏后果是 replayed 降级成
// already-decided(两者都成功),而复用键改 action 会撞「同 responseId 的
// 审批意图不一致」硬错误。要复放同一次决定时才显式传 --response-id。
let response_id = response_id
.unwrap_or_else(|| format!("gdd-response-{}", uuid::Uuid::new_v4().hyphenated()));
let result = decide_game_creator_plan_gdd(
project_path_value,
pending.gdd_ref.gdd_id.clone(),
pending.gdd_ref.version,
pending.gdd_ref.fingerprint.clone(),
pending.pending_action_id.clone(),
pending.approval_request_id.clone(),
response_id,
action,
comment,
)?;
println!("plan.gdd.decided");
println!(
"planGddDecisionJson={}",
serialize_agent_runtime_cli_payload(&result)?
);
Ok(())
}
CliCommand::RunnerStatus => {
println!(
"runnerJson={}",
@@ -1930,6 +2099,123 @@ mod tests {
);
}
#[test]
fn parses_plan_gdd_headless_approval_entries() {
assert_eq!(
parse_cli_command(&[
"--plan-gdd-status".to_string(),
"/tmp/game-project".to_string(),
])
.expect("parse plan gdd status")
.expect("plan gdd status command"),
CliCommand::PlanGddStatus {
project_path: PathBuf::from("/tmp/game-project"),
}
);
assert_eq!(
parse_cli_command(&[
"--plan-gdd-decide".to_string(),
"/tmp/game-project".to_string(),
" approve ".to_string(),
])
.expect("parse plan gdd approve")
.expect("plan gdd approve command"),
CliCommand::PlanGddDecide {
project_path: PathBuf::from("/tmp/game-project"),
action: "approve".to_string(),
response_id: None,
read_comment_from_stdin: false,
}
);
assert_eq!(
parse_cli_command(&[
"--plan-gdd-decide".to_string(),
"/tmp/game-project".to_string(),
"revise".to_string(),
"--response-id".to_string(),
" gdd-response-1b4e28ba-2fa1-11d2-883f-0016d3cca427 ".to_string(),
"--stdin".to_string(),
])
.expect("parse plan gdd revise")
.expect("plan gdd revise command"),
CliCommand::PlanGddDecide {
project_path: PathBuf::from("/tmp/game-project"),
action: "revise".to_string(),
response_id: Some("gdd-response-1b4e28ba-2fa1-11d2-883f-0016d3cca427".to_string()),
read_comment_from_stdin: true,
}
);
// revise/reject 没有 --stdin 时必须在解析期就失败,否则错误会伪装成写入侧故障。
for action in ["revise", "reject"] {
assert!(parse_cli_command(&[
"--plan-gdd-decide".to_string(),
"/tmp/game-project".to_string(),
action.to_string(),
])
.is_err());
}
for args in [
vec!["--plan-gdd-decide"],
vec!["--plan-gdd-decide", "/tmp/game-project"],
vec!["--plan-gdd-decide", "/tmp/game-project", "confirm"],
vec!["--plan-gdd-decide", "/tmp/game-project", "approve", "extra"],
vec![
"--plan-gdd-decide",
"/tmp/game-project",
"approve",
"--response-id",
],
vec!["--plan-gdd-status", "/tmp/game-project", "extra"],
] {
assert!(
parse_cli_command(&args.into_iter().map(str::to_string).collect::<Vec<_>>())
.is_err()
);
}
}
#[test]
fn plan_gdd_decision_requires_started_external_runner_but_status_does_not() {
let decide = CliCommand::PlanGddDecide {
project_path: PathBuf::from("/tmp/game-project"),
action: "approve".to_string(),
response_id: None,
read_comment_from_stdin: false,
};
assert!(decide.requires_external_agent_runner());
assert!(decide.requires_started_external_agent_runner());
assert!(!decide.is_read_only_status());
let status = CliCommand::PlanGddStatus {
project_path: PathBuf::from("/tmp/game-project"),
};
assert!(!status.requires_external_agent_runner());
assert!(status.is_read_only_status());
}
#[test]
fn reads_plan_gdd_approval_comment_from_stdin() {
assert_eq!(
read_cli_plan_gdd_approval_comment(&mut Cursor::new(
" 把核心循环写具体
"
))
.expect("read approval comment"),
"把核心循环写具体"
);
assert!(read_cli_plan_gdd_approval_comment(&mut Cursor::new(
"
"
))
.is_err());
assert!(
read_cli_plan_gdd_approval_comment(&mut Cursor::new("a".repeat(9 * 1024))).is_err()
);
}
#[test]
fn reads_strict_structured_goal_payload_from_stdin() {
let mut stdin = Cursor::new(
@@ -1506,7 +1506,16 @@ pub(crate) fn hydrate_game_creator_plan_gdd_state(
request: tauri::ipc::Request<'_>,
) -> Result<PlanGddStateViewV1, String> {
let input = parse_hydrate_plan_gdd_state_input(&request)?;
let root = validated_local_project_directory_path(input.project_path.trim())?;
hydrate_game_creator_plan_gdd_state_for_path(&input.project_path)
}
/// Transport-independent projection read shared by the Tauri command and the
/// headless CLI entry. Both must cross the same permission gate, otherwise the
/// CLI would become a way to read a project the policy denies.
pub(crate) fn hydrate_game_creator_plan_gdd_state_for_path(
project_path: &str,
) -> Result<PlanGddStateViewV1, String> {
let root = validated_local_project_directory_path(project_path.trim())?;
enforce_project_permission_policy(&root, "conversation.read")?;
hydrate_game_creator_plan_gdd_state_at(&root).map_err(|error| error.to_string())
}
@@ -36,9 +36,12 @@ import {
inspectSwarmProjectArtifacts,
localConfigFileName,
nextSwarmAutoPilotReply,
parsePlanGddDecisionOutput,
parsePlanGddStatusOutput,
parseRunnerShutdownOutput,
parseSettledSwarmTurnReport,
parseSwarmTestArguments,
planGddAutoApprovalIsPending,
prepareSwarmTestProject,
prepareSwarmTestRuntimeConfig,
removeDirectoryWithTimeout,
@@ -48,6 +51,7 @@ import {
shouldStartPersistentPreview,
swarmAutoPilotShouldCloseInput,
swarmAutoPilotSitsAtPrompt,
swarmOutputAwaitsPlanGddApproval,
terminateChildTree,
testProjectPrefix,
testProjectSentinelName,
@@ -739,6 +743,75 @@ describe('Swarm test argument parsing', () => {
).toBe(false);
});
it('triggers the approval while the chat CLI is still running', () => {
// Run 停在审批位时状态是 waiting-for-user-input,而 swarm CLI 把这个状态算作
// 「本轮还在跑」,turn 永远不 settle。等 CLI 退出再审批那一步根本到不了,所以
// 触发必须认这条运行期状态行。
expect(
swarmOutputAwaitsPlanGddApproval(
'[状态] project-supervisor running/waiting-for-user-input run=r queue=0/1/0/0 | 等待 Fast GDD 审批决定',
),
).toBe(true);
// 另外三个 plan_gdd blocked 子状态各有自己的文案,都不该触发审批。
expect(
swarmOutputAwaitsPlanGddApproval(
'[状态] project-supervisor running/planning | 推进本根 Run 的 Fast GDD 提交',
),
).toBe(false);
expect(
swarmOutputAwaitsPlanGddApproval(
'[状态] project-supervisor running/planning | 等待 Fast GDD 审批投影收尾',
),
).toBe(false);
expect(
swarmOutputAwaitsPlanGddApproval(
'[状态] project-supervisor needs-reconciliation | Fast GDD 审批投影需要人工核对',
),
).toBe(false);
});
it('reads the headless Fast GDD approval projection off the CLI output', () => {
const state = parsePlanGddStatusOutput(
[
'plan.gdd.status',
'planGddStateJson={"state":"ready_for_approval","pendingApproval":{"gddRef":{"gddId":"gdd-1","version":1,"fingerprint":"fp"},"pendingActionId":"action-1","approvalRequestId":"approval-1"},"session":{"clarificationRound":2,"phase":"awaiting_gdd_approval"}}',
'',
].join('\n'),
);
expect(state.state).toBe('ready_for_approval');
expect(state.session.clarificationRound).toBe(2);
expect(planGddAutoApprovalIsPending(state)).toBe(true);
// CLI 在 Windows 上按行输出会带 CR,解析必须先剥掉再判前缀。
expect(
parsePlanGddStatusOutput('planGddStateJson={"state":"approved"}\r\n')
.state,
).toBe('approved');
expect(
parsePlanGddDecisionOutput(
'plan.gdd.decided\nplanGddDecisionJson={"outcome":"decided","decisionRef":{"version":1}}',
).outcome,
).toBe('decided');
});
it('auto-approves only when the projection really carries a pending card', () => {
// 审批状态是「有没有待决定」的唯一权威。没有待决定时自动批准会把一次空跑
// 报成通过,所以这里必须 false 而不是继续往下走。
expect(
planGddAutoApprovalIsPending({
state: 'approved',
pendingApproval: null,
}),
).toBe(false);
expect(planGddAutoApprovalIsPending({ state: 'draft' })).toBe(false);
expect(planGddAutoApprovalIsPending(null)).toBe(false);
// 缺前缀或 JSON 坏掉都要报错,不能静默当成「没有待决定」。
expect(() => parsePlanGddStatusOutput('plan.gdd.status')).toThrowError();
expect(() =>
parsePlanGddStatusOutput('planGddStateJson={oops'),
).toThrowError();
expect(() => parsePlanGddDecisionOutput('plan.gdd.decided')).toThrowError();
});
it('keeps persistent preview only for manual chat mode', () => {
expect(shouldStartPersistentPreview(parseSwarmTestArguments([]))).toBe(
true,