Skip to content

Commit c2da83d

Browse files
committed
feat(flow): R11 高风险声明门禁 — flow_control 新增 risk 参数
- stage-start/stage-complete 支持 risk: high|low 声明:声明 high 时即使 Issue 无 cabbage:risk:high 标签同样触发 R11 用户确认门禁 - 确认通过后持久化 RISK_LABEL(修复该标签无写入方、门禁形同虚设的问题) - 未声明 risk 行为不变(保持向后兼容);响应透传 highRisk - 新增 4 个测试:声明 high 无确认拒绝/确认通过并打标签/声明 low 放行/标签不误写 测试:705 passed(+4)
1 parent 1e16bb7 commit c2da83d

3 files changed

Lines changed: 122 additions & 3 deletions

File tree

src/kernel/flow.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,13 +543,16 @@ async function readIssueLabels(parentIssueNumber: number): Promise<string[]> {
543543
/**
544544
* stage-start / stage-complete:
545545
* 门禁(checkStageGate)→ stage-complete 更新 body checklist(乐观锁)→ setStageLabel。
546+
* declaredRisk:模型显式声明高风险("high")时即使 Issue 无 cabbage:risk:high 标签,
547+
* 同样触发 R11 确认门禁;确认通过后持久化 RISK_LABEL(后续环节可见)。
546548
*/
547549
export async function applyStageOp(
548550
_projectDir: string,
549551
parentIssueNumber: number,
550552
op: StageOp,
551553
stage: FlowStage,
552554
userConfirmed: boolean,
555+
declaredRisk?: "high" | "low",
553556
): Promise<ApplyStageResult> {
554557
const record = await readFlowRecord(parentIssueNumber)
555558
if (!record.ok) {
@@ -558,7 +561,7 @@ export async function applyStageOp(
558561

559562
const completed = getCompletedStages(record.body)
560563
const labels = await readIssueLabels(parentIssueNumber)
561-
const highRisk = labels.includes(RISK_LABEL)
564+
const highRisk = labels.includes(RISK_LABEL) || declaredRisk === "high"
562565

563566
const gate = checkStageGate(op, stage, completed, highRisk, userConfirmed)
564567
if (!gate.ok) {
@@ -572,6 +575,14 @@ export async function applyStageOp(
572575
}
573576
}
574577

578+
// 模型声明高风险且已确认:持久化 RISK_LABEL(R11 状态真实化,后续环节可见)
579+
if (declaredRisk === "high" && !labels.includes(RISK_LABEL)) {
580+
const riskLabel = await addRiskLabel(parentIssueNumber)
581+
if (!riskLabel.ok) {
582+
return { ok: false, code: "LABEL_UPDATE_FAILED", message: riskLabel.error ?? "risk label update failed" }
583+
}
584+
}
585+
575586
const labelResult = await setStageLabel(parentIssueNumber, stage)
576587
if (!labelResult.ok) {
577588
return { ok: false, code: "LABEL_UPDATE_FAILED", message: labelResult.error ?? "label update failed" }
@@ -580,3 +591,13 @@ export async function applyStageOp(
580591
const newCompleted = op === "stage-complete" ? [...completed, stage] : completed
581592
return { ok: true, stage, completedStages: newCompleted, highRisk }
582593
}
594+
595+
/** 给 Parent Issue 打高风险标签(模型声明 + 用户确认后持久化) */
596+
async function addRiskLabel(parentIssueNumber: number): Promise<{ ok: boolean; error?: string }> {
597+
try {
598+
await flowGh(`issue edit ${parentIssueNumber} --add-label '${escapeShellArg(RISK_LABEL)}'`)
599+
return { ok: true }
600+
} catch (err) {
601+
return { ok: false, error: String(err) }
602+
}
603+
}

src/plugin/flow-control.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ Caller: primary for all ops except complete-flow (goal-verify only).`,
7979
.enum(["requirements", "design", "tasks", "code", "review"])
8080
.optional()
8181
.describe("Stage name (for stage-start/stage-complete)"),
82+
risk: tool.schema
83+
.enum(["high", "low"])
84+
.optional()
85+
.describe("Risk declaration for stage-start/stage-complete tasks handoff: high triggers the R11 user-confirmation gate (user_confirmed) and persists the cabbage:risk:high label"),
8286
user_confirmed: tool.schema.boolean().optional().describe("User confirmation (required for cancel-flow/takeover and high-risk gates)"),
8387
},
8488
async execute(args, ctx) {
@@ -202,11 +206,20 @@ async function handleStage(
202206
return errorResponse("POLICY_INVALID", "stage is required for stage ops: requirements|design|tasks|code|review")
203207
}
204208

205-
const result = await applyStageOp(deps.projectDir, n, op as "stage-start" | "stage-complete", stage, args.user_confirmed === true)
209+
const risk = args.risk as "high" | "low" | undefined
210+
if (risk !== undefined && risk !== "high" && risk !== "low") {
211+
return errorResponse("POLICY_INVALID", "risk must be \"high\" or \"low\"")
212+
}
213+
214+
const result = await applyStageOp(deps.projectDir, n, op as "stage-start" | "stage-complete", stage, args.user_confirmed === true, risk)
206215
if (!result.ok) {
207216
return errorResponse(result.code, result.message)
208217
}
209-
return okResponse({ stage: result.stage, completedStages: result.completedStages })
218+
return okResponse({
219+
stage: result.stage,
220+
completedStages: result.completedStages,
221+
...(result.highRisk ? { highRisk: true } : {}),
222+
})
210223
}
211224

212225
async function handleCompleteFlow(

test/plugin/flow-control.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,91 @@ describe("createFlowControlTool (spec §2.3 flow_control)", () => {
727727
})
728728
})
729729

730+
it("stage-start tasks with declared risk=high requires user confirmation (R11)", async () => {
731+
await withProjectDir(async dir => {
732+
const body = "## Stages\n\n- [x] requirements\n- [x] design\n- [ ] tasks"
733+
setRecordsGhExecutor(async args => {
734+
if (args.includes("--jq .body")) return { stdout: body, stderr: "" }
735+
if (args.includes('join(" ")')) return { stdout: "", stderr: "" }
736+
if (args.includes("issue edit")) return { stdout: "", stderr: "" }
737+
throw new Error(`unexpected gh: ${args}`)
738+
})
739+
const flowCalls: string[] = []
740+
setFlowGhExecutor(async args => {
741+
flowCalls.push(args)
742+
if (args.includes("issue view")) return { stdout: '["cabbage:flow", "cabbage:stage:requirements", "cabbage:stage:design"]', stderr: "" }
743+
if (args.includes("issue edit")) return { stdout: "", stderr: "" }
744+
throw new Error(`unexpected gh: ${args}`)
745+
})
746+
747+
const denied = await executeOp(dir, makeSessionClient(), "stage-start", {
748+
parent_issue_number: 12,
749+
stage: "tasks",
750+
risk: "high",
751+
})
752+
expect(denied.ok).toBe(false)
753+
expect(denied.error.code).toBe("RISK_CONFIRMATION_REQUIRED")
754+
// 未确认 → 不得打 RISK_LABEL
755+
expect(flowCalls.some(c => c.includes("cabbage:risk:high"))).toBe(false)
756+
})
757+
})
758+
759+
it("stage-start tasks with declared risk=high and confirmation passes and persists the RISK_LABEL", async () => {
760+
await withProjectDir(async dir => {
761+
const body = "## Stages\n\n- [x] requirements\n- [x] design\n- [ ] tasks"
762+
setRecordsGhExecutor(async args => {
763+
if (args.includes("--jq .body")) return { stdout: body, stderr: "" }
764+
if (args.includes('join(" ")')) return { stdout: "", stderr: "" }
765+
if (args.includes("issue edit")) return { stdout: "", stderr: "" }
766+
throw new Error(`unexpected gh: ${args}`)
767+
})
768+
const flowCalls: string[] = []
769+
setFlowGhExecutor(async args => {
770+
flowCalls.push(args)
771+
if (args.includes("issue view")) return { stdout: '["cabbage:flow", "cabbage:stage:requirements", "cabbage:stage:design"]', stderr: "" }
772+
if (args.includes("issue edit")) return { stdout: "", stderr: "" }
773+
throw new Error(`unexpected gh: ${args}`)
774+
})
775+
776+
const resp = await executeOp(dir, makeSessionClient(), "stage-start", {
777+
parent_issue_number: 12,
778+
stage: "tasks",
779+
risk: "high",
780+
user_confirmed: true,
781+
})
782+
expect(resp.ok).toBe(true)
783+
expect(resp.highRisk).toBe(true)
784+
expect(flowCalls.some(c => c.includes("--add-label 'cabbage:risk:high'"))).toBe(true)
785+
})
786+
})
787+
788+
it("stage-start tasks with declared risk=low passes without confirmation (no label)", async () => {
789+
await withProjectDir(async dir => {
790+
const body = "## Stages\n\n- [x] requirements\n- [x] design\n- [ ] tasks"
791+
setRecordsGhExecutor(async args => {
792+
if (args.includes("--jq .body")) return { stdout: body, stderr: "" }
793+
if (args.includes('join(" ")')) return { stdout: "", stderr: "" }
794+
if (args.includes("issue edit")) return { stdout: "", stderr: "" }
795+
throw new Error(`unexpected gh: ${args}`)
796+
})
797+
const flowCalls: string[] = []
798+
setFlowGhExecutor(async args => {
799+
flowCalls.push(args)
800+
if (args.includes("issue view")) return { stdout: '["cabbage:flow", "cabbage:stage:requirements", "cabbage:stage:design"]', stderr: "" }
801+
if (args.includes("issue edit")) return { stdout: "", stderr: "" }
802+
throw new Error(`unexpected gh: ${args}`)
803+
})
804+
805+
const resp = await executeOp(dir, makeSessionClient(), "stage-start", {
806+
parent_issue_number: 12,
807+
stage: "tasks",
808+
risk: "low",
809+
})
810+
expect(resp.ok).toBe(true)
811+
expect(flowCalls.some(c => c.includes("cabbage:risk:high"))).toBe(false)
812+
})
813+
})
814+
730815
it("complete-flow rejects callers other than goal-verify", async () => {
731816
await withProjectDir(async dir => {
732817
const resp = await executeOp(dir, makeSessionClient(), "complete-flow", { parent_issue_number: 12 }, "dev-lifecycle")

0 commit comments

Comments
 (0)