@ifrankwang/openspec-agents 0.130.0 → 0.132.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/git.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import path from "path"
2
2
  import { execFile } from "node:child_process"
3
- import { readFile, writeFile, stat } from "node:fs/promises"
3
+ import { readFile, writeFile, stat, rm } from "node:fs/promises"
4
+
5
+ /** 判断路径是否存在于磁盘(fs 层判断,git 命令结果不作为存在性依据)。 */
6
+ async function pathExists(p: string): Promise<boolean> {
7
+ try {
8
+ await stat(p)
9
+ return true
10
+ } catch {
11
+ return false
12
+ }
13
+ }
4
14
 
5
15
  export interface GitRunner {
6
16
  run(worktree: string, args: string[]): Promise<string>
@@ -54,6 +64,85 @@ export async function runGitChecked(
54
64
  return gitRunner.runChecked(worktree, args)
55
65
  }
56
66
 
67
+ /** worktree 清理结果:成功判定以目录从磁盘真实消失为准,不信任单条 git 命令的退出码。 */
68
+ export interface WorktreeRemovalResult {
69
+ /** 目录最终是否已从磁盘消失。 */
70
+ dirResolved: boolean
71
+ /** 目录是否经文件系统兜底删除后才消失(true 表示主路径未直接达成,发生了补救)。 */
72
+ dirResolvedByFallback: boolean
73
+ /** worktree prune 是否执行成功(幽灵管理记录清理)。 */
74
+ pruned: boolean
75
+ /** 分支删除结果:null=未指定分支或因目录残留被跳过;true/false=删除断言结果。 */
76
+ branchDeleted: boolean | null
77
+ /** 各环节失败原因聚合(人类可读),全部成功时为空数组。 */
78
+ errors: string[]
79
+ }
80
+
81
+ /**
82
+ * 删除任务组 worktree 并清理关联管理记录(收尾清理与 set_worktree 复用失败重建共用)。
83
+ *
84
+ * 序列:
85
+ * 1. 目录不存在 → 直接 prune(清理只残留管理记录的幽灵 worktree);
86
+ * 2. 目录存在 → `git worktree remove --force`;命令失败或目录仍在磁盘时走补救链:
87
+ * fs 递归删除 + prune(prune 失败仅记入 errors,不阻断);
88
+ * 3. 目录确认消失后才 `git branch -D`;仍残留则跳过删分支并记入 errors,
89
+ * 防止孤儿分支掩盖问题、以及向不完整状态发出被 git 拒绝的命令。
90
+ *
91
+ * @param repoRoot 主仓库根路径(worktree 管理命令的 -C 目标)
92
+ * @param wtPath 待删除的 worktree 路径
93
+ * @param opts.branchName 关联分支名;缺省/null 时只做 worktree 侧清理
94
+ */
95
+ export async function removeTaskGroupWorktree(
96
+ repoRoot: string,
97
+ wtPath: string,
98
+ opts: { branchName?: string | null } = {},
99
+ ): Promise<WorktreeRemovalResult> {
100
+ const result: WorktreeRemovalResult = { dirResolved: false, dirResolvedByFallback: false, pruned: false, branchDeleted: null, errors: [] }
101
+
102
+ if (!(await pathExists(wtPath))) {
103
+ const pruneRes = await runGitChecked(repoRoot, ["worktree", "prune"])
104
+ if (pruneRes.success) result.pruned = true
105
+ else result.errors.push(`worktree prune 失败:${pruneRes.stderr}`)
106
+ result.dirResolved = true
107
+ } else {
108
+ const rmRes = await runGitChecked(repoRoot, ["worktree", "remove", wtPath, "--force"])
109
+ if (!rmRes.success) result.errors.push(`git worktree remove 失败:${rmRes.stderr}`)
110
+ if (await pathExists(wtPath)) {
111
+ // 补救链:fs 物理删除目录 + prune 清理残留管理记录
112
+ try {
113
+ await rm(wtPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
114
+ } catch (e) {
115
+ result.errors.push(`文件系统兜底删除 "${wtPath}" 失败:${e instanceof Error ? e.message : String(e)}`)
116
+ }
117
+ const pruneRes = await runGitChecked(repoRoot, ["worktree", "prune"])
118
+ if (pruneRes.success) result.pruned = true
119
+ else result.errors.push(`worktree prune 失败:${pruneRes.stderr}`)
120
+ if (!(await pathExists(wtPath))) {
121
+ result.dirResolved = true
122
+ result.dirResolvedByFallback = true
123
+ }
124
+ } else {
125
+ result.dirResolved = true
126
+ }
127
+ }
128
+
129
+ if (!result.dirResolved) {
130
+ if (opts.branchName) {
131
+ result.errors.push(`目录 "${wtPath}" 未能删除,跳过分支 "${opts.branchName}" 的删除以保留排查线索`)
132
+ }
133
+ return result
134
+ }
135
+ if (opts.branchName) {
136
+ const branchRes = await runGitChecked(repoRoot, ["branch", "-D", opts.branchName])
137
+ if (branchRes.success) result.branchDeleted = true
138
+ else {
139
+ result.branchDeleted = false
140
+ result.errors.push(`删除分支 "${opts.branchName}" 失败:${branchRes.stderr}`)
141
+ }
142
+ }
143
+ return result
144
+ }
145
+
57
146
  export async function getCurrentHead(worktree: string): Promise<string> {
58
147
  return runGit(worktree, ["rev-parse", "HEAD"])
59
148
  }
@@ -1,8 +1,9 @@
1
1
  import path from "path"
2
+ import { rmdir } from "node:fs/promises"
2
3
  import type { OrchestrateState, TaskItem, TaskStatus, WorkflowMode } from "../types.ts"
3
- import { BUILD_PHASE_TARGETS, REVIEW_LAYERS, REVIEW_VERIFY_STEPS } from "../types.ts"
4
+ import { BUILD_PHASE_TARGETS, REVIEW_LAYERS, REVIEW_VERIFY_STEPS, SIMPLE_REVIEW_STEPS } from "../types.ts"
4
5
  import { agentToReviewLayer } from "../constants.ts"
5
- import { runGit, runGitChecked, getCurrentBranch, getMergeBase, isWorktreeClean, markTaskGroupCheckboxesComplete, mergeBranchToTarget, discoverDiskWorktrees, detectMainRepoPollution, detectChanges, type DetectChangesResult } from "../git.ts"
6
+ import { runGit, runGitChecked, getCurrentBranch, getMergeBase, isWorktreeClean, markTaskGroupCheckboxesComplete, mergeBranchToTarget, discoverDiskWorktrees, detectMainRepoPollution, detectChanges, removeTaskGroupWorktree, type DetectChangesResult } from "../git.ts"
6
7
  import { readStateByWorktree, readStateByChangeId, writeState, writeContextToWorktree } from "../state.ts"
7
8
  import { generateIsolationNamespace } from "../namespace.ts"
8
9
  import { readExemptions } from "../exemptions.ts"
@@ -134,8 +135,9 @@ function firstUnpassedReviewStep(item: WorkItem, workflow: LoadedWorkflow): stri
134
135
  * simple 分支(mode === "simple",3.2):无 analyze / verify_* step,task_analysis / dev_impl 均落
135
136
  * in_progress/implement(task_analysis 重置 task children 全 todo,dev_impl 保留既有进度);review 落
136
137
  * review/quality_review——implement 置 passed、quality_review 的 failed tag 删除回 pending(passed 保留)、
137
- * task children 缺省 done;reset_steps / review_layer 参数在 simple 下接受但空操作(值域校验不变,
138
- * simple 无对应 verify step 可操作,文档标注无效)。
138
+ * task children 缺省 done;reset_steps 按模式生效(simple 仅接受 quality_review,含该值时清空
139
+ * quality_review 全部 tags——passed 也清,强制重审正是该参数的目的,清空后引擎重派该 step 审查者);
140
+ * review_layer 在 simple 下无对应子层(仅 quality_review 单层),接受但不生效,init 返回体输出警告。
139
141
  */
140
142
  function applyRecoveryState(
141
143
  item: WorkItem,
@@ -175,7 +177,12 @@ function applyRecoveryState(
175
177
  if (item.tags[key] !== "passed") delete item.tags[key]
176
178
  }
177
179
  }
178
- // reset_steps / review_layer 在 simple 下接受但空操作(值域校验在 assertValidRecovery 不变)
180
+ // reset_steps(simple 模式仅 quality_review):含该值时清空该 step 全部 tags(passed 也清——
181
+ // 强制重审正是该参数的目的,含「已通过但任务验证/裁定遗漏」场景),tag 清空后引擎重派该 step 审查者
182
+ if (((recovery?.reset_steps ?? []) as string[]).includes("quality_review")) {
183
+ clearStepTags(item, "quality_review")
184
+ }
185
+ // review_layer 在 simple 下无对应子层,接受但不生效(init 返回体对该组合输出警告)
179
186
  item.currentStep = "quality_review"
180
187
  syncTaskChildren(item, parsedTasks, { defaultStatus: "done" })
181
188
  return
@@ -240,8 +247,10 @@ function applyRecoveryState(
240
247
  * - phase 必须为合法恢复阶段(task_analysis/dev_impl/review),缺失/非法即抛错并列出合法值;
241
248
  * - review_layer 必须为合法子层(tool/task/quality),非法即抛错;
242
249
  * - review_layer 仅当 phase=review 时允许存在,其余 phase 组合复用既有组合错误消息;
243
- * - reset_steps 必须为合法 verify step(verify_tool/verify_task/verify_quality),非空数组,
244
- * 仅当 phase=review 时允许存在,且与 review_layer 互斥(二者都操纵哪些 review step 通过)。
250
+ * - reset_steps 必须为非空数组,仅当 phase=review 时允许存在,且与 review_layer 互斥
251
+ * (二者都操纵哪些 review step 通过)。reset_steps 的 step 值域按模式生效(full 仅
252
+ * verify_tool/verify_task/verify_quality,simple 仅 quality_review),由
253
+ * assertValidResetStepValues 在 state 读取后做模式感知校验——本函数仅做与模式无关的组合校验。
245
254
  */
246
255
  function assertValidRecovery(recovery: InitParams["recovery"]): void {
247
256
  if (recovery === undefined) return
@@ -266,14 +275,28 @@ function assertValidRecovery(recovery: InitParams["recovery"]): void {
266
275
  throw new Error("reset_steps 与 review_layer 互斥,不可同时使用。")
267
276
  }
268
277
  if (recovery.reset_steps.length === 0) {
269
- throw new Error("reset_steps 不能为空数组,请至少指定一个 verify step。")
278
+ throw new Error("reset_steps 不能为空数组,请至少指定一个审查 step。")
270
279
  }
271
- for (const stepId of recovery.reset_steps) {
272
- if (!(REVIEW_VERIFY_STEPS as readonly string[]).includes(stepId)) {
273
- throw new Error(
274
- `reset_steps 中的 step "${stepId}" 不合法,合法值:${REVIEW_VERIFY_STEPS.join("、")}。传入值:"${stepId}"。`
275
- )
276
- }
280
+ }
281
+ }
282
+
283
+ /**
284
+ * reset_steps 值域的模式感知校验(state 读取/固化后调用——有效模式 = state.mode ?? "full"):
285
+ * - full 模式仅接受 verify_tool/verify_task/verify_quality;
286
+ * - simple 模式仅接受 quality_review;
287
+ * 跨模式值抛错并列出当前模式的合法值。phase/review_layer/组合校验在 assertValidRecovery 前置
288
+ * (错误早于任何状态变更且不落盘),值域校验依赖 state.mode 故后置到 state 读取之后——
289
+ * state 读取不是状态变更,该原则不破坏。
290
+ */
291
+ function assertValidResetStepValues(recovery: InitParams["recovery"], mode: WorkflowMode): void {
292
+ if (!recovery?.reset_steps?.length) return
293
+ const valid = mode === "simple" ? SIMPLE_REVIEW_STEPS : REVIEW_VERIFY_STEPS
294
+ for (const stepId of recovery.reset_steps) {
295
+ if (!(valid as readonly string[]).includes(stepId)) {
296
+ throw new Error(
297
+ `reset_steps 中的 step "${stepId}" 不属于当前模式(${mode})的审查 step,` +
298
+ `合法值:${valid.join("、")}。传入值:"${stepId}"。`
299
+ )
277
300
  }
278
301
  }
279
302
  }
@@ -388,6 +411,10 @@ export async function initExecute(params: InitParams, ctx: ToolContext): Promise
388
411
  state.isolationNamespace = state.isolationNamespace || generateIsolationNamespace(state.changeId)
389
412
  }
390
413
 
414
+ // reset_steps 值域的模式感知校验:位于 state 读取/固化之后(有效模式 = state.mode ?? "full",
415
+ // 与 applyRecoveryState 消费的生效模式一致)、一切状态变更与落盘之前,跨模式值报错不落盘
416
+ assertValidResetStepValues(args.recovery, state.mode ?? "full")
417
+
391
418
  // 按 tasks.md 构造全部任务组的 task WorkItem(单轨:workItems 为唯一事实源)
392
419
  for (const group of parsedGroups) {
393
420
  const isCurrent = group.id === args.task_group_id
@@ -490,6 +517,11 @@ export async function initExecute(params: InitParams, ctx: ToolContext): Promise
490
517
  const parts = ["编排会话已初始化。"]
491
518
  if (args.recovery) parts.push(`已恢复到 ${args.recovery.phase} 阶段。`)
492
519
  if (modeSwitchNote) parts.push(modeSwitchNote)
520
+ // review_layer 在 simple 模式下无对应子层(仅 quality_review 单层审查),该参数接受但不生效:
521
+ // 返回体显式警告,避免调用方误以为已按子层恢复
522
+ if (args.recovery?.review_layer && (state.mode ?? "full") === "simple") {
523
+ parts.push("\n\n⚠️ simple 模式无 review 子层(仅 quality_review 单层审查),recovery.review_layer 参数未生效。")
524
+ }
493
525
  return parts.join("")
494
526
  }
495
527
 
@@ -576,13 +608,13 @@ export async function setWorktreeExecute(params: SetWorktreeParams, ctx: ToolCon
576
608
  await bindWorktreeRefs(item, existingPath, branch, state.baseBranch)
577
609
  reused = true
578
610
  } else {
579
- const rmResult = await runGitChecked(repoRoot, ["worktree", "remove", existingPath, "--force"])
580
- if (!rmResult.success) {
581
- throw new Error(`无法清理已有 worktree "${existingPath}":${rmResult.stderr}`)
582
- }
583
- const branchRmResult = await runGitChecked(repoRoot, ["branch", "-D", branch])
584
- if (!branchRmResult.success) {
585
- throw new Error(`无法清理已有分支 "${branch}":${branchRmResult.stderr}`)
611
+ // 复用失败的清理走共享清理函数:目录侧(git remove + fs 兜底 + prune)与分支删除统一语义
612
+ const cleanup = await removeTaskGroupWorktree(repoRoot, existingPath, { branchName: branch })
613
+ if (!cleanup.dirResolved || cleanup.branchDeleted === false) {
614
+ const problems: string[] = []
615
+ if (!cleanup.dirResolved) problems.push(`无法清理已有 worktree "${existingPath}"`)
616
+ if (cleanup.branchDeleted === false) problems.push(`无法清理已有分支 "${branch}"`)
617
+ throw new Error(`${problems.join(";")}:${cleanup.errors.join(";") || "原因未知"}\n请手动处理后重试。`)
586
618
  }
587
619
  }
588
620
  }
@@ -667,6 +699,22 @@ export async function statusExecute(params: StatusParams, ctx: ToolContext): Pro
667
699
  return renderWorkflowStatusView(item, workflow, rec, { agent, orchestrator: ctx.orchestrator, identityDeclared: ctx.identityDeclared }, { state, tg, mainPollution, toolChanges, exemptedHits, exemptionItems })
668
700
  }
669
701
 
702
+ /**
703
+ * 收尾后清扫默认布局的空父目录(`.worktree/<changeId>` 与 `.worktree`):
704
+ * 仅对这两级字面路径做非递归 rmdir(空目录才会成功),任一失败静默忽略。
705
+ * 自定义 worktree_path 时以「被删目录的实际 dirname === `.worktree/<changeId>`」判定是否可扫第一级,
706
+ * 第二级固定只试 `.worktree` 本身;其余任意父路径一律不做处理。
707
+ */
708
+ async function sweepEmptyWorktreeParents(repoRoot: string, removedDir: string, changeId: string): Promise<void> {
709
+ const repoRootAbs = path.resolve(repoRoot)
710
+ const groupLevelDir = path.join(repoRootAbs, ".worktree", changeId)
711
+ const worktreeLevelDir = path.join(repoRootAbs, ".worktree")
712
+ if (path.dirname(path.resolve(removedDir)) === groupLevelDir) {
713
+ try { await rmdir(groupLevelDir) } catch {}
714
+ }
715
+ try { await rmdir(worktreeLevelDir) } catch {}
716
+ }
717
+
670
718
  export async function completeTaskGroupExecute(params: { change_id: string }, ctx: ToolContext): Promise<string> {
671
719
  assertOrchestrator(ctx, "opx_orch_complete_task_group")
672
720
  const state = await readStateByWorktree(ctx.worktree, params.change_id)
@@ -731,17 +779,40 @@ export async function completeTaskGroupExecute(params: { change_id: string }, ct
731
779
  ].join("\n")
732
780
  }
733
781
  }
734
- if (worktreePath && branchName) {
735
- try {
736
- await runGit(ctx.worktree, ["worktree", "remove", worktreePath, "--force"])
737
- await runGit(ctx.worktree, ["branch", "-D", branchName])
738
- } catch {
782
+ // 收尾清理:只要记录了 worktree_path 就执行;branch_name 缺省时仅做 worktree 侧清理。
783
+ // 残留不阻断收尾:completed_at 照写,残留信息写入 metadata.cleanup_residual 并在返回体给出人工处理命令。
784
+ let cleanupNote = ""
785
+ let cleanupResidualWarning = ""
786
+ if (worktreePath) {
787
+ const cleanup = await removeTaskGroupWorktree(ctx.worktree, worktreePath, { branchName })
788
+ if (cleanup.dirResolved) {
789
+ await sweepEmptyWorktreeParents(ctx.worktree, worktreePath, params.change_id)
790
+ const successNotes: string[] = []
791
+ if (cleanup.dirResolvedByFallback) {
792
+ successNotes.push("- **cleanup**: git worktree remove 未直接移除目录,已按文件系统兜底删除并 prune 管理记录。")
793
+ }
794
+ if (cleanup.errors.length > 0) {
795
+ successNotes.push(`- **cleanup 部分告警**: ${cleanup.errors.join(";")}`)
796
+ }
797
+ cleanupNote = successNotes.join("\n")
798
+ } else {
799
+ item.metadata["cleanup_residual"] = { worktree_path: worktreePath, errors: cleanup.errors }
800
+ cleanupResidualWarning = [
801
+ "",
802
+ "## ⚠️ worktree 清理残留(不影响收尾)",
803
+ `- **残留路径**: \`${worktreePath}\``,
804
+ "- **原因**:",
805
+ ...cleanup.errors.map((e) => ` - ${e}`),
806
+ `- **处理**: 请人工执行 \`rm -rf '${worktreePath}' && git worktree prune\` 清理残留目录与 git 管理记录。`,
807
+ branchName ? `- **分支**: 分支 "${branchName}" 未删除。` : "",
808
+ ].filter(Boolean).join("\n")
739
809
  }
740
810
  }
741
811
  item.metadata["completed_at"] = new Date().toISOString()
742
812
  await writeState(ctx.worktree, state)
743
813
  const doneMessage = `任务组已完成并合并到 "${mergeTarget}"。`
744
- return checkboxWarning ? `${doneMessage}\n${checkboxWarning}` : doneMessage
814
+ const notes = [cleanupNote, checkboxWarning, cleanupResidualWarning].filter(Boolean)
815
+ return notes.length > 0 ? `${doneMessage}\n${notes.join("\n")}` : doneMessage
745
816
  }
746
817
 
747
818
  export async function setUnattendedExecute(params: UnattendedParams, ctx: ToolContext): Promise<string> {
@@ -191,8 +191,8 @@ export const recoverySchema: JSONSchema = {
191
191
  reset_steps: {
192
192
  type: "array",
193
193
  description:
194
- "重置指定 verify step 的通过标记为 pending(仅 phase=review 时有效,与 review_layer 互斥)。用于已 passed 但被本层遗漏复核/裁定阻塞的 review step 强制重新审查;恢复后 currentStep 落在第一个未全部通过的 verify step,可能早于被重置的 step。",
195
- items: { type: "string", enum: ["verify_tool", "verify_task", "verify_quality"] },
194
+ "重置指定审查 step 的通过标记为 pending(仅 phase=review 时有效,与 review_layer 互斥)。用于已 passed 但被本层遗漏复核/裁定/任务验证阻塞的 review step 强制重新审查;恢复后 currentStep 落在第一个未全部通过的审查 step,可能早于被重置的 step。值按模式生效:full 模式合法值为 verify_tool/verify_task/verify_quality,simple 模式合法值为 quality_review;传入不属于当前模式的值会在运行时报错。",
195
+ items: { type: "string", enum: ["verify_tool", "verify_task", "verify_quality", "quality_review"] },
196
196
  },
197
197
  },
198
198
  required: ["phase"],
@@ -312,7 +312,7 @@ export const agentSubmitSchema: JSONSchema = {
312
312
  blockers: { type: "array", description: "analyze step:新增 blocker 列表", items: blockerItem },
313
313
  blocker_updates: {
314
314
  type: "array",
315
- description: "analyze step:按 blocker_id 置 resolved 并记录用户答复",
315
+ description: "analyze / implement step:按 blocker_id 置 resolved 并记录用户答复(implement 下人工执行任务凭留痕重新申报的依据)",
316
316
  items: {
317
317
  type: "object",
318
318
  properties: {
@@ -349,12 +349,13 @@ export const agentSubmitSchema: JSONSchema = {
349
349
  },
350
350
  verified_tasks: {
351
351
  type: "array",
352
- description: "verify_task step:验证通过的 task id",
352
+ description:
353
+ "verify_task / quality_review step(任务验证归属层):逐项确认验证通过的 task id;passed 提交时 submitted task 必须被 verified_tasks/failed_tasks 全覆盖(工具强制门禁),漏带会被拒绝",
353
354
  items: { type: "string" },
354
355
  },
355
356
  failed_tasks: {
356
357
  type: "array",
357
- description: "verify_task step:验证失败的 task 列表(含原因)",
358
+ description: "verify_task / quality_review step:验证失败的 task 列表(含原因)",
358
359
  items: taskVerifyItem,
359
360
  },
360
361
  },
@@ -57,6 +57,49 @@ function isSupplementOnly(params: AgentSubmitParams): boolean {
57
57
  )
58
58
  }
59
59
 
60
+ /**
61
+ * 判定本次提交是否「仅携带任务验证补交」(verified_tasks 非空,其余实质参数全空)。
62
+ * 供重复提交守卫的补交豁免:任务验证归属 step(verify_task/quality_review)上次 passed 提交
63
+ * 漏带 verified_tasks 导致 task children 卡 review 态(done 收口被拦截,见 engine
64
+ * recommendForItem 的 gateBlock 补交推导)时,允许仅补带 verified_tasks 重提解除阻塞;
65
+ * 空提交或携带其他实质参数的重复提交仍被守卫拒绝。放行须叠加「存在 review 态 task child」
66
+ * 条件(与引擎推导条件同源),防止放行面过宽。
67
+ */
68
+ function isTaskVerificationSupplement(params: AgentSubmitParams): boolean {
69
+ if ((params.verified_tasks?.length ?? 0) === 0) return false
70
+ return (
71
+ (params.failed_tasks?.length ?? 0) === 0 &&
72
+ (params.new_children?.length ?? 0) === 0 &&
73
+ (params.fixed_issue_ids?.length ?? 0) === 0 &&
74
+ (params.exempt_issue_ids?.length ?? 0) === 0 &&
75
+ (params.exempt_adjudications?.length ?? 0) === 0 &&
76
+ (params.recheck_adjudications?.length ?? 0) === 0 &&
77
+ !params.boundary_expansion &&
78
+ (params.validation_steps?.length ?? 0) === 0 &&
79
+ params.test_results === undefined
80
+ )
81
+ }
82
+
83
+ /**
84
+ * 任务验证补交放行判定(重复提交守卫的豁免与必做清单门禁的跳过共用同源条件):仅携带
85
+ * verified_tasks 的严格补交形态,且该 agent 在该 step 已 passed、item 存在 review 态
86
+ * (submitted)task child——与守卫放宽、引擎 gateBlock 补交推导条件同源。
87
+ * 必做门禁的跳过绑定本条件(而非仅形态判定):首次提交(step 尚未 passed)不得借补交形态
88
+ * 跳过必做清单覆盖度校验——补交跳过的理由是完整审查在首次 passed 时已做过。
89
+ */
90
+ function isTaskVerifySupplementAllowed(
91
+ item: WorkItem,
92
+ params: AgentSubmitParams,
93
+ stepId: string,
94
+ agent: string,
95
+ ): boolean {
96
+ return (
97
+ isTaskVerificationSupplement(params) &&
98
+ getStepVerdict(item, stepId, agent) === "passed" &&
99
+ taskChildrenOf(item).some((c) => c.phase === "review")
100
+ )
101
+ }
102
+
60
103
  /**
61
104
  * 统一裁定一致性守卫:任一裁定参数含「驳回(rejected)」时,提交级 verdict 必须为 failed。
62
105
  * - exempt_adjudications 含 action==="rejected"(issue 回 todo 继续修复)→ verdict 必须为 failed
@@ -232,9 +275,11 @@ function clearReviewVerificationTags(item: WorkItem): void {
232
275
  clearStepTags(item, "verify_quality")
233
276
  }
234
277
 
235
- /** blocker 提交后的 reset 助手:task children 全 todo + 清 review 层验证标记(对齐旧 resetForBlocker)。 */
278
+ /** blocker 提交后的 reset 助手:task children 仅 todo/review 态回 todo(done 已验证不得降级,
279
+ * 对齐 handleImplementParams 提交时对 done 态的跳过语义)+ 清 review 层验证标记(对齐旧 resetForBlocker)。 */
236
280
  function resetTasksForBlocker(item: WorkItem): void {
237
281
  for (const child of taskChildrenOf(item)) {
282
+ if (child.phase === "done") continue
238
283
  child.phase = "todo"
239
284
  delete child.metadata["reject_reason"]
240
285
  }
@@ -264,10 +309,19 @@ function handleAnalyzeParams(item: WorkItem, params: AgentSubmitParams): void {
264
309
  }
265
310
  }
266
311
 
267
- /** implement step 参数处理:blocker(on_fail)、completed_task_ids + 覆盖门禁、self_check_results /
268
- * test_results 申报存档。 */
312
+ /** implement step 参数处理:blocker(on_fail)、blocker_updates 消费闭环 + 无未解决 blocker 门禁、
313
+ * completed_task_ids + 覆盖门禁、self_check_results / test_results 申报存档。 */
269
314
  function handleImplementParams(item: WorkItem, params: AgentSubmitParams): void {
270
315
  if (params.blocker) {
316
+ // 同轮组合显式拒绝(置于分支最前,先于一切写入):blocker 分支在下方 return,同轮携带的
317
+ // blocker_updates 会被静默丢弃(丢失申报)。校验放行面只有分两轮:先 failed+blocker 上报新
318
+ // 阻塞,用户确认后下一轮再 blocker_updates + 重新提交。
319
+ if (params.blocker_updates?.length) {
320
+ throw new Error(
321
+ "不可在同一轮提交中同时上报新 blocker(blocker 参数)与更新旧 blocker(blocker_updates)。\n" +
322
+ "请分两轮处理:先以 verdict=failed + blocker 上报新阻塞;用户确认后,下一轮再携带 blocker_updates 记录答复并重新提交。"
323
+ )
324
+ }
271
325
  if (params.verdict !== "failed") {
272
326
  throw new Error("blocker 参数仅支持 verdict=failed 提交(on_fail 回退 analyze)。")
273
327
  }
@@ -276,6 +330,23 @@ function handleImplementParams(item: WorkItem, params: AgentSubmitParams): void
276
330
  return
277
331
  }
278
332
 
333
+ // blocker_updates 消费闭环(校验语义与 handleAnalyzeParams 一致):按 blocker_id 置 resolved 并记录
334
+ // 用户答复。task 指向型 blocker(人工执行/环境不可验证任务)经用户确认留痕后,开发者凭该记录将对应
335
+ // 任务列入 completed_task_ids 重新申报,审查者核验留痕后方可在任务验证层确认。
336
+ // 位于 blocker 分支之外:blocker 是本轮上报参数(上方分支 return),blocker_updates 是后续轮次参数。
337
+ if (params.blocker_updates?.length) {
338
+ for (const u of params.blocker_updates) {
339
+ const blocker = itemBlockers(item).find((b) => b.id === u.blocker_id)
340
+ if (!blocker) throw new Error(`blocker "${u.blocker_id}" 不存在于 metadata.blockers 中。`)
341
+ if (blocker.status !== "awaiting_user") throw new Error(`blocker "${u.blocker_id}" 状态不是 awaiting_user,无法更新。`)
342
+ blocker.userResponse = u.user_response
343
+ blocker.status = "resolved"
344
+ }
345
+ }
346
+ if (params.verdict === "passed" && itemBlockers(item).some((b) => b.status !== "resolved")) {
347
+ throw new Error("存在未解决的 blocker,无法以 passed 提交 implement step。请先通过 blocker_updates 处理全部 blocker。")
348
+ }
349
+
279
350
  const tasks = taskListOf(item)
280
351
  const completed = normalizeTaskChildIds(params.completed_task_ids ?? [], item)
281
352
  const validIds = new Set(tasks.map((t) => t.id))
@@ -305,7 +376,8 @@ function handleImplementParams(item: WorkItem, params: AgentSubmitParams): void
305
376
  throw new Error(
306
377
  `以下 task 处于 open/rejected 状态且未在 completed_task_ids 中:\n` +
307
378
  remaining.map((t) => `- #${t.id}(${t.status}) ${t.title}`).join("\n") +
308
- `\n请将未完成的 task 列在 completed_task_ids 中,或改用 blocker 上报阻塞。`
379
+ `\ncompleted_task_ids 仅申报已完成可供验证的任务;无法完成或人工执行的任务不得虚报,` +
380
+ `应以 verdict=failed + blocker 上报(task_id 指向该任务),经用户确认后凭 blocker 留痕重新申报。`
309
381
  )
310
382
  }
311
383
 
@@ -342,9 +414,10 @@ function mergeBoundaryInto(item: WorkItem, expansion: NonNullable<AgentSubmitPar
342
414
  * - verify_tool:本次 new_children 含 Low+,或存在未终态的 Low+ tool 报源层阻塞 child
343
415
  * - verify_quality:本次 new_children 含 Low+ 且维度属于当前提交 agent,或存在未终态的 Low+ quality 报源层
344
416
  * 阻塞 child 且 dimension 属于当前提交 agent 维度(新报与遗留理由均按维度过滤,F3;报源层由 source 反推)
345
- * - quality_review(simple 合并审查):本次 new_children 含 Low+,或存在未终态的 Low+ quality 报源层阻塞
346
- * child(不按维度过滤——openspec-reviewer 无固定维度,对全部质量层负责;按 agentToReviewDimension 过滤
347
- * 会对该身份恒为 undefined 导致 failed 提交死锁)
417
+ * - quality_review(simple 合并审查):本次 failed_tasks 非空,或 new_children 含 Low+,或存在未终态的
418
+ * Low+ quality 报源层阻塞 child(不按维度过滤——openspec-reviewer 无固定维度,对全部质量层负责;按
419
+ * agentToReviewDimension 过滤会对该身份恒为 undefined 导致 failed 提交死锁。simple 审查者合并承担任务
420
+ * 验证语义,failed_tasks 驳回任务与 Low+ issue 同为合法 failed 理由,与 verify_task 分支对齐)
348
421
  * - verify_cleanup:本次 new_children 含 Low+,或存在未终态的 Low+ 阻塞 child(不按维度/报源层过滤——
349
422
  * 收尾层须对全部残留阻塞负责,任何层的遗留阻塞都构成不通过理由)
350
423
  * 理由判定在 dedupeNewChildren 之后调用(F4):传入的 newChildren 为已去重的 accepted,重复新报不构成理由。
@@ -376,11 +449,14 @@ function assertFailedHasReason(
376
449
  } else if (stepId === "quality_review") {
377
450
  // simple 合并审查 step:审查者(openspec-reviewer)不归属固定质量维度,理由判定不按
378
451
  // agentToReviewDimension 过滤(该值对 openspec-reviewer 为 undefined,按维度过滤会导致任何
379
- // failed 提交都报「不存在未解决的阻塞 issue」死锁)。本次新报含 Low+ issue,或存在 quality
380
- // 报源(sourcePhase === "quality")未终态阻塞 issue,均构成不通过理由——simple 审查者对
381
- // 全部质量层负责,与 full 模式 verify_quality 各维度分支互不影响。
452
+ // failed 提交都报「不存在未解决的阻塞 issue」死锁)。simple 审查者承担任务验证(verify_task
453
+ // 语义),failed_tasks 与 Low+ issue 同为合法 failed 理由:本次 failed_tasks 非空(驳回任务回退
454
+ // implement),或本次新报含 Low+ issue,或存在 quality 报源(sourcePhase === "quality")未终态
455
+ // 阻塞 issue,均构成不通过理由——simple 审查者对全部质量层负责,与 full 模式 verify_quality
456
+ // 各维度分支互不影响。
382
457
  layerName = "AI 审查层"
383
458
  hasReason =
459
+ (params.failed_tasks?.length ?? 0) > 0 ||
384
460
  hasNewBlocking ||
385
461
  existingBlocking.some((c) => resolveChildIssueFields(c).sourcePhase === "quality")
386
462
  } else {
@@ -453,7 +529,17 @@ function handleReviewParams(
453
529
  }
454
530
  }
455
531
 
456
- if (params.verified_tasks?.length || params.failed_tasks?.length) {
532
+ // 任务验证覆盖门禁(兜底):verify_task / quality_review(任务验证归属 step)以 passed 提交时,
533
+ // 「submitted task 必须被 verified/failed 全覆盖」为无条件必查——reviewer 不得 passed 而不对任何
534
+ // 已申报任务表态(漏带 verified_tasks 会让 task children 卡 review 态,done 收口被拦截形成死锁)。
535
+ // 其余 review step 维持既有条件式(携带任一数组才进入校验);verdict=failed 不做此强制
536
+ // (报 issue 回退时无须给全部任务表态)。
537
+ const isTaskVerifyStep = stepId === "verify_task" || stepId === "quality_review"
538
+ if (
539
+ params.verified_tasks?.length ||
540
+ params.failed_tasks?.length ||
541
+ (params.verdict === "passed" && isTaskVerifyStep)
542
+ ) {
457
543
  const tasks = taskListOf(item)
458
544
  const validIds = new Set(tasks.map((t) => t.id))
459
545
  const verified = normalizeTaskChildIds(params.verified_tasks ?? [], item)
@@ -476,7 +562,10 @@ function handleReviewParams(
476
562
  if (uncovered.length > 0) {
477
563
  throw new Error(
478
564
  `以下 submitted task 未被 verified_tasks 或 failed_tasks 覆盖:\n` +
479
- uncovered.map((t) => `- #${t.id} ${t.title}`).join("\n")
565
+ uncovered.map((t) => `- #${t.id} ${t.title}`).join("\n") +
566
+ `\nverified_tasks 用于逐项确认已完成的任务;验证未通过的任务须以 failed_tasks 逐项驳回并设 verdict=failed 回退;` +
567
+ `人工执行或环境不可验证的任务不得虚报确认,应以 verdict=failed + failed_tasks 驳回,` +
568
+ `由开发者经 verdict=failed + blocker(task_id 指向该任务)上报、用户确认留痕后凭记录重新申报完成。`
480
569
  )
481
570
  }
482
571
  for (const id of verified) {
@@ -572,17 +661,22 @@ export async function agentSubmitExecute(params: AgentSubmitParams, ctx: ToolCon
572
661
 
573
662
  // 重复提交守卫(仅 review step):同 step 同 agent 已以 passed 通过后不允许重复提交;failed 允许重提
574
663
  // (回退重审期 failed tag 未被归因清空时须可重提,如 verify_task 仅 failed_tasks 驳回)。
575
- // 补交豁免:仅携带裁定类参数(recheck_adjudications / exempt_adjudications,无其他实质参数)的补交放行——
576
- // 上次提交漏带复核/豁免裁定导致本层 review 态 issue 阻塞门禁时(engine.blockedSupplementAgents 已推导
577
- // 应补交的报源 reviewer),reviewer 可补带裁定重提解除阻塞;空提交/携带其他实质参数(含 fixed_issue_ids /
578
- // exempt_issue_ids)的重复提交仍被拒绝。
664
+ // 补交豁免(两条对称通道,均要求严格形态——仅携带对应补交参数,无其他实质提交内容):
665
+ // - 裁定补交:仅携带 recheck_adjudications / exempt_adjudications——上次提交漏带复核/豁免裁定导致
666
+ // 本层 review 态 issue 阻塞门禁时(engine.blockedSupplementAgents 已推导应补交的报源 reviewer),
667
+ // reviewer 可补带裁定重提解除阻塞;
668
+ // - 任务验证补交:仅携带 verified_tasks 且存在 review 态 task child(与引擎 gateBlock 补交推导
669
+ // 条件同源)——任务验证归属 step 上次 passed 漏带 verified_tasks 导致 task children 卡 review
670
+ // 态、done 收口被拦截时,审查者可补交 verified_tasks 覆盖待验证任务后推进。
671
+ // 空提交或携带其他实质参数(含 fixed_issue_ids / exempt_issue_ids / new_children)的重复提交仍被拒绝。
579
672
  // 守卫在一切裁定(豁免/复核)之前执行,保证守卫拒绝时零副作用。step 归属按 stepMap 直接判定
580
673
  // (不依赖 assertSubmitRouting,保持越权 step 提交的裁定拦截语义不变)。
581
674
  const guardStepPhase = workflow.stepMap.get(params.step_id)?.phase.name
582
675
  if (
583
676
  guardStepPhase === "review" &&
584
677
  getStepVerdict(item, params.step_id, ctx.agent) === "passed" &&
585
- !isSupplementOnly(params)
678
+ !isSupplementOnly(params) &&
679
+ !isTaskVerifySupplementAllowed(item, params, params.step_id, ctx.agent)
586
680
  ) {
587
681
  throw new Error(`重复提交守卫:agent "${ctx.agent}" 已在 step "${params.step_id}" 以 passed 通过,不允许重复提交。`)
588
682
  }
@@ -693,8 +787,13 @@ export async function agentSubmitExecute(params: AgentSubmitParams, ctx: ToolCon
693
787
  const agentCfg = stepCfg.agents.find((a) => a.id === ctx.agent)
694
788
  handleReviewParams(item, params, newChildren, params.step_id, {
695
789
  capabilityTags: agentCfg?.capability_tags ?? [],
696
- // 补交豁免(仅裁定参数,无实质提交)不重复校验必做清单覆盖度
697
- skipMustDoGate: isSupplementOnly(params),
790
+ // 补交豁免(仅裁定参数 / 仅 verified_tasks 的任务验证补交,均无实质审查内容)不重复校验
791
+ // 必做清单覆盖度——完整审查在首次 passed 时已做过。任务验证补交的跳过与守卫放宽同源
792
+ //(isTaskVerifySupplementAllowed:已 passed 且存在 review 态 task child),首次提交
793
+ //(step 尚未 passed)不借补交形态跳过必做门禁。
794
+ skipMustDoGate:
795
+ isSupplementOnly(params) ||
796
+ isTaskVerifySupplementAllowed(item, params, params.step_id, ctx.agent),
698
797
  })
699
798
  } else if (stepPhase === "todo") {
700
799
  handleAnalyzeParams(item, params)
@@ -1,4 +1,4 @@
1
- import type { BuildPhaseTarget, ReviewLayer, ReviewVerifyStep, WorkflowMode } from "../types.ts"
1
+ import type { BuildPhaseTarget, ReviewLayer, ReviewVerifyStep, SimpleReviewStep, WorkflowMode } from "../types.ts"
2
2
 
3
3
  export interface ToolContext {
4
4
  worktree: string
@@ -24,9 +24,11 @@ export interface InitParams {
24
24
  phase: BuildPhaseTarget
25
25
  review_layer?: ReviewLayer
26
26
  reopenIssues?: boolean
27
- /** 重置指定 verify step 的审查 tags 为 pending(仅 phase=review 时有效,与 review_layer 互斥),
28
- * 恢复后 currentStep 落在第一个未全部通过的 verify step,可能早于被重置的 step。 */
29
- reset_steps?: ReviewVerifyStep[]
27
+ /** 重置指定审查 step 的审查 tags 为 pending(仅 phase=review 时有效,与 review_layer 互斥),
28
+ * 恢复后 currentStep 落在第一个未全部通过的审查 step,可能早于被重置的 step。
29
+ * 值按模式生效:full 模式为 verify_tool/verify_task/verify_quality,simple 模式为 quality_review
30
+ * (跨模式值在运行时报错)。 */
31
+ reset_steps?: ReviewVerifyStep[] | SimpleReviewStep[]
30
32
  }
31
33
  /** 流程模式(full/simple):首次新建编排状态时固化;已开始的变更仅在切组(其他任务组均已完成
32
34
  * 或从未激活)或 recovery.phase=task_analysis 重制当前组(其他任务组同样须已完成或从未激活)
package/src/core/types.ts CHANGED
@@ -20,9 +20,13 @@ export type BuildPhaseTarget = typeof BUILD_PHASE_TARGETS[number]
20
20
  export const REVIEW_LAYERS = ["tool", "task", "quality"] as const
21
21
  export type ReviewLayer = typeof REVIEW_LAYERS[number]
22
22
 
23
- /** review 三个验证 step 合法值(recovery.reset_steps 值域)。 */
23
+ /** review 三个验证 step 合法值(recovery.reset_steps 在 full 模式的值域)。 */
24
24
  export const REVIEW_VERIFY_STEPS = ["verify_tool", "verify_task", "verify_quality"] as const
25
25
  export type ReviewVerifyStep = typeof REVIEW_VERIFY_STEPS[number]
26
+
27
+ /** simple 模式 review 合并审查 step 合法值(recovery.reset_steps 在 simple 模式的值域)。 */
28
+ export const SIMPLE_REVIEW_STEPS = ["quality_review"] as const
29
+ export type SimpleReviewStep = typeof SIMPLE_REVIEW_STEPS[number]
26
30
  export type OrchestrateStatus = "not_started" | "in_progress" | "completed"
27
31
  export type DimensionVerdict = "pending" | "passed" | "failed"
28
32
  export type QualityLayerProgress = Record<ReviewDimension, DimensionVerdict>
@@ -9,6 +9,7 @@ import { WORK_ITEM_PHASES, BLOCKING_SEVERITIES, stepAgentIds, EXEMPT_REQUEST_KEY
9
9
  import type { LoadedWorkflow } from "./loader.ts"
10
10
  import { tagKey } from "./types.ts"
11
11
  import { reviewLayerFromMetadata, readIssueSource } from "../constants.ts"
12
+ import { taskChildrenOf } from "../task-children.ts"
12
13
 
13
14
  const TERMINAL_PHASES: WorkItemPhase[] = ["done", "cancelled"]
14
15
 
@@ -555,6 +556,19 @@ export function recommendForItem(item: WorkItem, workflow: LoadedWorkflow): Engi
555
556
  // 避免"step 已通过"误导造成无人再触发迁移的静默死锁。
556
557
  const gateBlock = forwardAdvanceBlockReason(item, workflow, current.step)
557
558
  if (gateBlock) {
559
+ // 任务验证补交推导:review 阶段 step 全 passed 但门禁被 review 态 task child 拦截(任务验证
560
+ // 归属 step——verify_task/quality_review——passed 提交漏带 verified_tasks,或 full 模式下游
561
+ // 收尾 step 推进到 done 时暴露)时,返回该 step 全部 agent 由其经 opx_agent_submit 补交
562
+ // verified_tasks 覆盖全部待验证任务后推进(与 submit 重复提交守卫的补交放行条件同源:
563
+ // 仅携带 verified_tasks + 存在 review 态 task child),不再恒返回 agents=[] 形成静默死锁。
564
+ if (current.phaseName === "review" && taskChildrenOf(item).some((c) => c.phase === "review")) {
565
+ return {
566
+ status: "blocked",
567
+ stepId: current.step.id,
568
+ agents: stepAgentIds(current.step),
569
+ blockedReason: `${gateBlock}需该 step 审查者经 opx_agent_submit 补交 verified_tasks(覆盖全部待验证任务)后推进。`,
570
+ }
571
+ }
558
572
  return {
559
573
  status: "blocked",
560
574
  stepId: current.step.id,