@ifrankwang/openspec-agents 0.132.1 → 0.133.1

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.
@@ -1,9 +1,9 @@
1
1
  import path from "path"
2
2
  import { rmdir } from "node:fs/promises"
3
- import type { OrchestrateState, TaskItem, TaskStatus, WorkflowMode } from "../types.ts"
4
- import { BUILD_PHASE_TARGETS, REVIEW_LAYERS, REVIEW_VERIFY_STEPS, SIMPLE_REVIEW_STEPS } from "../types.ts"
3
+ import type { OrchestrateState, TaskItem, TaskStatus, WorkflowMode, ReviewScope } from "../types.ts"
4
+ import { BUILD_PHASE_TARGETS, REVIEW_LAYERS, REVIEW_VERIFY_STEPS, SIMPLE_REVIEW_STEPS, REVIEW_TASK_GROUP_ID } from "../types.ts"
5
5
  import { agentToReviewLayer } from "../constants.ts"
6
- import { runGit, runGitChecked, getCurrentBranch, getMergeBase, isWorktreeClean, markTaskGroupCheckboxesComplete, mergeBranchToTarget, discoverDiskWorktrees, detectMainRepoPollution, detectChanges, removeTaskGroupWorktree, type DetectChangesResult } from "../git.ts"
6
+ import { runGit, runGitChecked, getCurrentBranch, getMergeBase, isWorktreeClean, markTaskGroupCheckboxesComplete, mergeBranchToTarget, discoverDiskWorktrees, detectMainRepoPollution, detectChanges, removeTaskGroupWorktree, isLocalBranch, listLocalBranches, type DetectChangesResult } from "../git.ts"
7
7
  import { readStateByWorktree, readStateByChangeId, writeState, writeContextToWorktree } from "../state.ts"
8
8
  import { generateIsolationNamespace } from "../namespace.ts"
9
9
  import { readExemptions } from "../exemptions.ts"
@@ -12,7 +12,7 @@ import type { ParsedTask } from "../tasks-md.ts"
12
12
  import { assertOrchestrator, findTaskGroup } from "../derive.ts"
13
13
  import { assertPathWithin } from "../paths.ts"
14
14
  import { loadWorkflowFile, resolveWorkflowPath, type LoadedWorkflow } from "../workflow/loader.ts"
15
- import { createInitialWorkItem, isBlockingSeverity, isTerminalPhase, recommendForItem, resetInternalRetryCount, adjudicateStep, clearStepTags } from "../workflow/engine.ts"
15
+ import { createInitialWorkItem, isBlockingSeverity, isTerminalPhase, recommendForItem, resetInternalRetryCount, adjudicateStep, clearStepTags, REVIEW_FIX_POLICY_KEY } from "../workflow/engine.ts"
16
16
  import { renderWorkflowStatusView } from "../workflow/status.ts"
17
17
  import { taskChildrenOf } from "../task-children.ts"
18
18
  import type { WorkItem, WorkItemPhase } from "../workflow/types.ts"
@@ -143,7 +143,7 @@ function applyRecoveryState(
143
143
  item: WorkItem,
144
144
  recovery: InitParams["recovery"],
145
145
  parsedTasks: ParsedTask[],
146
- mode?: WorkflowMode,
146
+ state: Pick<OrchestrateState, "mode" | "kind" | "reviewScope">,
147
147
  ): void {
148
148
  // 恢复重建为已知状态后清除残留推进阻塞原因,避免 orchestrator 视图展示过期信息
149
149
  delete item.metadata["_advance_block_reason"]
@@ -151,6 +151,41 @@ function applyRecoveryState(
151
151
  resetInternalRetryCount(item)
152
152
  // 清除检查点标记残留:恢复重建为已知状态后 _checkpoint 已无意义(checkpoint 态属于中断中的 step)。
153
153
  delete item.metadata["_checkpoint"]
154
+ if (state.kind === "review") {
155
+ // 独立审查会话恢复:无 analyze 前置、无 task children;phase 值域入口已收敛为 review / dev_impl。
156
+ const phase = recovery?.phase
157
+ if (!phase || phase === "dev_impl") {
158
+ // 恢复进 implement:tags 整体重置(不残留 implement passed,否则 dev 不会被重派)
159
+ item.phase = "in_progress"
160
+ item.currentStep = "implement"
161
+ item.tags = {}
162
+ return
163
+ }
164
+ // review 分支:恢复进 review 时 implement 必然已通过
165
+ item.phase = "review"
166
+ item.tags["implement:openspec-developer"] = "passed"
167
+ // failed 审查标记删除回 pending(passed 保留——已审查通过无需重跑),对 review 两种颗粒度的
168
+ // step id(verify_* / quality_review)统一处理
169
+ for (const key of Object.keys(item.tags)) {
170
+ if (
171
+ key.startsWith("verify_tool:") || key.startsWith("verify_task:") ||
172
+ key.startsWith("verify_quality:") || key.startsWith("quality_review:")
173
+ ) {
174
+ if (item.tags[key] !== "passed") delete item.tags[key]
175
+ }
176
+ }
177
+ // reset_steps:清空指定审查 step 全部 tags(passed 也清,强制重审),值域已按形态校验
178
+ for (const stepId of recovery?.reset_steps ?? []) {
179
+ clearStepTags(item, stepId)
180
+ }
181
+ // currentStep 前移到第一个未全 passed 的审查 step(review workflow 生效,按颗粒度选文件)
182
+ const workflow = loadWorkflowFile(resolveWorkflowPath(state))
183
+ item.currentStep = firstUnpassedReviewStep(item, workflow)
184
+ // 全 passed 收口 done:review 会话无 task children,终态检查天然通过
185
+ if (item.currentStep === null) item.phase = "done"
186
+ return
187
+ }
188
+ const mode = state.mode
154
189
  if (mode === "simple") {
155
190
  const phase = recovery?.phase
156
191
  if (!phase || phase === "task_analysis" || phase === "dev_impl") {
@@ -281,20 +316,39 @@ function assertValidRecovery(recovery: InitParams["recovery"]): void {
281
316
  }
282
317
 
283
318
  /**
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 读取之后——
319
+ * reset_steps 值域的形态感知校验(state 读取/固化后调用):
320
+ * - 独立审查会话按颗粒度:thorough 仅接受 verify_tool/verify_task/verify_quality,simple 仅接受 quality_review;
321
+ * - change 会话 full 模式仅接受 verify_tool/verify_task/verify_quality,simple 模式仅接受 quality_review。
322
+ * 跨形态值抛错并列出当前形态的合法值。phase/review_layer/组合校验在 assertValidRecovery 前置
323
+ * (错误早于任何状态变更且不落盘),值域校验依赖 state 故后置到 state 读取之后——
289
324
  * state 读取不是状态变更,该原则不破坏。
290
325
  */
291
- function assertValidResetStepValues(recovery: InitParams["recovery"], mode: WorkflowMode): void {
326
+ function assertValidResetStepValues(
327
+ recovery: InitParams["recovery"],
328
+ state: Pick<OrchestrateState, "mode" | "kind" | "reviewScope">,
329
+ ): void {
292
330
  if (!recovery?.reset_steps?.length) return
293
- const valid = mode === "simple" ? SIMPLE_REVIEW_STEPS : REVIEW_VERIFY_STEPS
331
+ let valid: readonly string[]
332
+ let formLabel: string
333
+ if (state.kind === "review") {
334
+ if (state.reviewScope?.granularity === "simple") {
335
+ valid = SIMPLE_REVIEW_STEPS
336
+ formLabel = "独立审查会话(simple 颗粒度)"
337
+ } else {
338
+ valid = REVIEW_VERIFY_STEPS
339
+ formLabel = "独立审查会话(thorough 颗粒度)"
340
+ }
341
+ } else if ((state.mode ?? "full") === "simple") {
342
+ valid = SIMPLE_REVIEW_STEPS
343
+ formLabel = "simple 模式"
344
+ } else {
345
+ valid = REVIEW_VERIFY_STEPS
346
+ formLabel = "full 模式"
347
+ }
294
348
  for (const stepId of recovery.reset_steps) {
295
- if (!(valid as readonly string[]).includes(stepId)) {
349
+ if (!valid.includes(stepId)) {
296
350
  throw new Error(
297
- `reset_steps 中的 step "${stepId}" 不属于当前模式(${mode})的审查 step,` +
351
+ `reset_steps 中的 step "${stepId}" 不属于当前会话形态(${formLabel})的审查 step,` +
298
352
  `合法值:${valid.join("、")}。传入值:"${stepId}"。`
299
353
  )
300
354
  }
@@ -320,6 +374,235 @@ function otherTaskGroupsSettled(state: OrchestrateState, targetGroupId: string):
320
374
  })
321
375
  }
322
376
 
377
+ // ─── 独立审查会话(kind=review,不绑定 OpenSpec change)───
378
+
379
+ /** 分支名等非法字符归一为 -(会话 id 推导用,保持确定性)。 */
380
+ function normalizeSessionToken(s: string): string {
381
+ return s.replace(/[^A-Za-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "") || "head"
382
+ }
383
+
384
+ /** 独立审查会话 id 确定性推导:pr → review-pr-<base>-<head>;full → review-full-<当前分支>-<yyyymmdd>。 */
385
+ function deriveReviewSessionId(scope: ReviewScope, currentBranch: string): string {
386
+ if (scope.scopeType === "pr") {
387
+ return `review-pr-${normalizeSessionToken(scope.baseRef ?? "")}-${normalizeSessionToken(scope.headRef ?? "")}`
388
+ }
389
+ const day = new Date().toISOString().slice(0, 10).replace(/-/g, "")
390
+ return `review-full-${normalizeSessionToken(currentBranch)}-${day}`
391
+ }
392
+
393
+ /** 审查范围等价判定(幂等复用判定:scopeType/推导后的 baseRef/headRef/granularity/fix 全等)。 */
394
+ function reviewScopeEquals(a: ReviewScope, b: ReviewScope): boolean {
395
+ return (
396
+ a.scopeType === b.scopeType && a.baseRef === b.baseRef && a.headRef === b.headRef &&
397
+ a.granularity === b.granularity && a.fix === b.fix
398
+ )
399
+ }
400
+
401
+ /** 校验独立审查入口的分支必须为存在的本地分支(收尾合并以 update-ref 推进本地分支引用,
402
+ * origin/* 等远端 ref 会静默创建错误的本地分支,故显式拒绝)。 */
403
+ async function assertLocalReviewBranch(worktree: string, branch: string, role: string): Promise<void> {
404
+ if (typeof branch !== "string" || branch.trim() === "" || /\s/.test(branch)) {
405
+ throw new Error(`独立审查会话的 ${role} 分支名不合法:"${String(branch)}"。`)
406
+ }
407
+ if (branch.startsWith("origin/") || branch.startsWith("refs/remotes/")) {
408
+ throw new Error(
409
+ `独立审查会话的 ${role} 分支 "${branch}" 是远端引用:收尾合并只推进本地分支引用,远端引用会静默创建错误的本地分支。` +
410
+ `请改用对应的本地分支名(如需审查远端内容,先在本地建分支跟踪后再传入)。`
411
+ )
412
+ }
413
+ if (!(await isLocalBranch(worktree, branch))) {
414
+ const branches = await listLocalBranches(worktree)
415
+ throw new Error(
416
+ `独立审查会话的 ${role} 分支 "${branch}" 不是本地分支。当前本地分支:${branches.join("、") || "(无)"}。`
417
+ )
418
+ }
419
+ }
420
+
421
+ /** 解析并校验 review_scope 参数(枚举值域 + 分支本地性 + base 推导),返回规范化 ReviewScope 与当前分支。 */
422
+ async function resolveReviewScope(
423
+ worktree: string,
424
+ raw: NonNullable<InitParams["review_scope"]>,
425
+ ): Promise<{ scope: ReviewScope; currentBranch: string }> {
426
+ const currentBranch = await getCurrentBranch(worktree)
427
+ if (raw.scope_type !== "pr" && raw.scope_type !== "full") {
428
+ throw new Error(`review_scope.scope_type 不合法,合法值:pr、full。传入值:"${String(raw.scope_type)}"。`)
429
+ }
430
+ if (raw.granularity !== "simple" && raw.granularity !== "thorough") {
431
+ throw new Error(`review_scope.granularity 不合法,合法值:simple、thorough。传入值:"${String(raw.granularity)}"。`)
432
+ }
433
+ if (raw.fix !== "none" && raw.fix !== "fix") {
434
+ throw new Error(`review_scope.fix 不合法,合法值:none、fix。传入值:"${String(raw.fix)}"。`)
435
+ }
436
+ if (raw.scope_type === "full") {
437
+ if (raw.base_ref || raw.head_ref) {
438
+ throw new Error("review_scope.scope_type=full(全量审查)不接受 base_ref/head_ref——全量形态不界定分支区间。")
439
+ }
440
+ return { scope: { scopeType: "full", granularity: raw.granularity, fix: raw.fix }, currentBranch }
441
+ }
442
+ // pr 形态:head 必传;base 缺省推导(优先 main、其次 master,都不存在报错列出本地分支)
443
+ const headRef = raw.head_ref
444
+ if (!headRef) throw new Error("review_scope.scope_type=pr 必须提供 head_ref(审查目标分支,须为本地分支)。")
445
+ let baseRef = raw.base_ref
446
+ if (!baseRef) {
447
+ if (await isLocalBranch(worktree, "main")) baseRef = "main"
448
+ else if (await isLocalBranch(worktree, "master")) baseRef = "master"
449
+ else {
450
+ const branches = await listLocalBranches(worktree)
451
+ throw new Error(
452
+ `review_scope 未传 base_ref 且无法自动推导(main 与 master 均不是本地分支)。请显式传入 base_ref。当前本地分支:${branches.join("、") || "(无)"}。`
453
+ )
454
+ }
455
+ }
456
+ await assertLocalReviewBranch(worktree, baseRef, "base_ref")
457
+ await assertLocalReviewBranch(worktree, headRef, "head_ref")
458
+ return { scope: { scopeType: "pr", baseRef, headRef, granularity: raw.granularity, fix: raw.fix }, currentBranch }
459
+ }
460
+
461
+ /** 构造独立审查会话的虚拟组 WorkItem(无 task children,初始即落 review 阶段首个审查 step)。 */
462
+ function createReviewWorkItem(scope: ReviewScope, sessionId: string): WorkItem {
463
+ const item = createInitialWorkItem({
464
+ id: `task:${REVIEW_TASK_GROUP_ID}`,
465
+ source: "review",
466
+ externalId: REVIEW_TASK_GROUP_ID,
467
+ type: "task",
468
+ title: "独立代码审查",
469
+ description: "独立代码审查(不绑定 OpenSpec change)",
470
+ labels: ["standalone-review"],
471
+ })
472
+ item.phase = "review"
473
+ item.currentStep = scope.granularity === "thorough" ? "verify_tool" : "quality_review"
474
+ item.metadata["name"] = "独立代码审查"
475
+ item.metadata["task_count"] = 0
476
+ item.metadata["source"] = "review"
477
+ item.metadata["review_session_id"] = sessionId
478
+ // 引擎无 state 访问:item.metadata.review_fix_policy 为引擎消费的单一事实源(fail→done 短路依据),
479
+ // 由 init 从 state.reviewScope.fix 写入,二者恒一致(state 为配置事实源,metadata 为引擎读侧投影)。
480
+ item.metadata[REVIEW_FIX_POLICY_KEY] = scope.fix
481
+ return item
482
+ }
483
+
484
+ /**
485
+ * 独立审查会话 init(review_scope 入口):
486
+ * - 同参数重复 init 幂等(复用既有会话);已有 review 会话传不同参数报错防误覆盖;
487
+ * - 会话 id 确定性推导并在返回体回传,后续所有工具以 change_id 形式传入该会话 id;
488
+ * - 不走 tasks.md 解析 / mode 校验 / mode 字段写入;recovery 仅接受 phase=review。
489
+ */
490
+ async function initReviewSession(params: InitParams, ctx: ToolContext): Promise<string> {
491
+ const args = params
492
+ if (args.change_id || args.task_group_id || args.base_branch || args.mode) {
493
+ throw new Error(
494
+ "review_scope 与 change_id/task_group_id 是两种互斥入口(同传报错、都不传报错,二选一);" +
495
+ "base_branch 与 mode 仅 change 会话入口有效,独立审查会话不使用。"
496
+ )
497
+ }
498
+ if (args.recovery) {
499
+ if (args.recovery.phase !== "review") {
500
+ throw new Error(`独立审查会话的 recovery 仅支持 phase="review"(无分析阶段),传入值:"${args.recovery.phase}"。`)
501
+ }
502
+ if (args.recovery.review_layer) {
503
+ throw new Error("独立审查会话不支持 review_layer(按颗粒度整体审查),如需重置指定层请用 reset_steps。")
504
+ }
505
+ if (args.recovery.reopenIssues) {
506
+ throw new Error("独立审查会话不支持 reopenIssues。")
507
+ }
508
+ }
509
+ const { scope, currentBranch } = await resolveReviewScope(ctx.worktree, args.review_scope!)
510
+ const sessionId = deriveReviewSessionId(scope, currentBranch)
511
+ let state = await readStateByChangeId(ctx.worktree, sessionId)
512
+ if (state) {
513
+ if (state.kind !== "review" || !state.reviewScope) {
514
+ throw new Error(`会话 id "${sessionId}" 已被非独立审查会话占用,请核对后重试(防止误覆盖既有进度)。`)
515
+ }
516
+ if (!reviewScopeEquals(state.reviewScope, scope)) {
517
+ throw new Error(
518
+ `独立审查会话 "${sessionId}" 已存在且审查范围不同(已固化:${JSON.stringify(state.reviewScope)},传入:${JSON.stringify(scope)})。` +
519
+ `会话 id 由审查范围推导,重复 init 仅支持同参数幂等复用;需不同范围请调整参数(id 随之变化)。`
520
+ )
521
+ }
522
+ if (args.recovery) {
523
+ const item = state.workItems.find((w) => w.id === `task:${REVIEW_TASK_GROUP_ID}`)
524
+ if (!item) throw new Error(`工作项 "task:${REVIEW_TASK_GROUP_ID}" 缺失,会话状态异常,请核对 state 文件。`)
525
+ assertValidResetStepValues(args.recovery, state)
526
+ applyRecoveryState(item, args.recovery, [], state)
527
+ await writeState(ctx.worktree, state)
528
+ return `独立审查会话 "${sessionId}" 已恢复到 review 阶段(当前 step:${item.currentStep ?? "(已收口)"})。后续工具以 change_id="${sessionId}" 传入。`
529
+ }
530
+ return [
531
+ "独立审查会话已初始化(复用既有会话)。",
532
+ "",
533
+ `- **会话 ID**: \`${sessionId}\``,
534
+ `- **说明**: 后续所有 opx_* 工具以 \`change_id="${sessionId}"\` 传入。`,
535
+ ].join("\n")
536
+ }
537
+ if (args.recovery) {
538
+ throw new Error(`独立审查会话 "${sessionId}" 不存在,无法按 recovery 恢复。请先不带 recovery 初始化。`)
539
+ }
540
+ // baseBranch 双语义:worktree 分支 fork 源与快进合并源(pr=head_ref、full=当前分支),
541
+ // 同时是 fix 模式收尾合回目标(merge_target);fix=none 收尾不合并不使用。
542
+ const baseBranch = scope.scopeType === "pr" ? scope.headRef! : currentBranch
543
+ state = {
544
+ changeId: sessionId,
545
+ isolationNamespace: generateIsolationNamespace(sessionId),
546
+ taskGroupId: REVIEW_TASK_GROUP_ID,
547
+ baseBranch,
548
+ workItems: [createReviewWorkItem(scope, sessionId)],
549
+ createdAt: new Date().toISOString(),
550
+ updatedAt: new Date().toISOString(),
551
+ kind: "review",
552
+ reviewScope: scope,
553
+ }
554
+ await writeState(ctx.worktree, state)
555
+ const granularityLabel = scope.granularity === "thorough" ? "三层审查(工具检查 → 回归验证 → 五维度质量审查)" : "单层合并审查"
556
+ const fixLabel = scope.fix === "fix" ? "审并修(失败回退修复后重审)" : "只审不修(issue 报告即交付物)"
557
+ const scopeLabel = scope.scopeType === "pr" ? `PR 区间 ${scope.baseRef}..${scope.headRef}` : "全量代码库"
558
+ return [
559
+ "独立审查会话已初始化。",
560
+ "",
561
+ `- **会话 ID**: \`${sessionId}\``,
562
+ `- **审查范围**: ${scopeLabel}`,
563
+ `- **颗粒度**: ${granularityLabel}`,
564
+ `- **修复策略**: ${fixLabel}`,
565
+ "",
566
+ `- **说明**: 后续所有 opx_* 工具以 \`change_id="${sessionId}"\` 传入(含 opx_orch_set_worktree / opx_status / opx_agent_submit / 收尾)。`,
567
+ ].join("\n")
568
+ }
569
+
570
+ /** 既有独立审查会话按 change_id 入口的恢复(task_group_id 可缺省,虚拟组 review)。 */
571
+ async function initReviewRecovery(params: InitParams, state: OrchestrateState, ctx: ToolContext): Promise<string> {
572
+ const args = params
573
+ if (args.mode || args.base_branch) {
574
+ throw new Error("独立审查会话不使用 mode / base_branch 参数(审查范围在首次 init 经 review_scope 固化)。")
575
+ }
576
+ if (args.task_group_id && args.task_group_id !== REVIEW_TASK_GROUP_ID) {
577
+ throw new Error(`独立审查会话的虚拟任务组固定为 "${REVIEW_TASK_GROUP_ID}",传入值:"${args.task_group_id}"。`)
578
+ }
579
+ if (args.recovery) {
580
+ if (args.recovery.phase === "task_analysis") {
581
+ throw new Error('独立审查会话无分析阶段,recovery.phase="task_analysis" 不适用(仅支持 review / dev_impl)。')
582
+ }
583
+ if (args.recovery.review_layer) {
584
+ throw new Error("独立审查会话不支持 review_layer(按颗粒度整体审查),如需重置指定层请用 reset_steps。")
585
+ }
586
+ if (args.recovery.reopenIssues) {
587
+ throw new Error("独立审查会话不支持 reopenIssues。")
588
+ }
589
+ assertValidResetStepValues(args.recovery, state)
590
+ }
591
+ const item = state.workItems.find((w) => w.id === `task:${REVIEW_TASK_GROUP_ID}`)
592
+ if (!item) throw new Error(`工作项 "task:${REVIEW_TASK_GROUP_ID}" 缺失,会话状态异常,请核对 state 文件。`)
593
+ if (args.recovery) {
594
+ applyRecoveryState(item, args.recovery, [], state)
595
+ await writeState(ctx.worktree, state)
596
+ return `独立审查会话 "${state.changeId}" 已恢复到 ${args.recovery.phase} 阶段(当前 step:${item.currentStep ?? "(已收口)"})。`
597
+ }
598
+ return [
599
+ "独立审查会话已就绪(复用既有会话)。",
600
+ "",
601
+ `- **会话 ID**: \`${state.changeId}\``,
602
+ `- **说明**: 后续所有 opx_* 工具以 \`change_id="${state.changeId}"\` 传入。`,
603
+ ].join("\n")
604
+ }
605
+
323
606
  export async function initExecute(params: InitParams, ctx: ToolContext): Promise<string> {
324
607
  assertOrchestrator(ctx, "opx_orch_init")
325
608
 
@@ -335,14 +618,33 @@ export async function initExecute(params: InitParams, ctx: ToolContext): Promise
335
618
  (args as any).recovery = parsed
336
619
  }
337
620
  assertValidRecovery(args.recovery)
621
+
622
+ // 入口分流:review_scope(独立审查会话)与 change_id/task_group_id 互斥——同传报错、都不传报错
623
+ if (args.review_scope !== undefined) {
624
+ return initReviewSession(args, ctx)
625
+ }
626
+ if (!args.change_id || !args.task_group_id) {
627
+ if (args.change_id) {
628
+ // 已存在的独立审查会话按 change_id(会话 id)恢复:虚拟组 review,task_group_id 可缺省
629
+ const existing = await readStateByChangeId(ctx.worktree, args.change_id)
630
+ if (existing?.kind === "review") {
631
+ return initReviewRecovery(args, existing, ctx)
632
+ }
633
+ }
634
+ throw new Error(
635
+ "初始化需要两种入口之一:change 会话传 change_id + task_group_id;独立审查会话传 review_scope(会话 id 由审查范围推导并在返回体回传)。两种入口互斥,不可同传或都不传。"
636
+ )
637
+ }
638
+ const changeId = args.change_id
639
+
338
640
  // mode 值域校验:值域外一律拒绝(无论 state 是否已存在),错误早于任何状态变更且不落盘
339
641
  if (args.mode !== undefined && args.mode !== "full" && args.mode !== "simple") {
340
642
  throw new Error(`mode 参数不合法,合法值:full、simple。传入值:"${String(args.mode)}"。`)
341
643
  }
342
644
 
343
- const parsedGroups = await parseAllTaskGroupsFromMd(ctx.worktree, args.change_id)
645
+ const parsedGroups = await parseAllTaskGroupsFromMd(ctx.worktree, changeId)
344
646
  if (parsedGroups.length === 0) {
345
- throw new Error(`无法从 tasks.md 解析出任务组,请检查文件 openspec/changes/${args.change_id}/tasks.md。`)
647
+ throw new Error(`无法从 tasks.md 解析出任务组,请检查文件 openspec/changes/${changeId}/tasks.md。`)
346
648
  }
347
649
  const targetGroup = parsedGroups.find((g) => g.id === args.task_group_id)
348
650
  if (!targetGroup) {
@@ -355,7 +657,7 @@ export async function initExecute(params: InitParams, ctx: ToolContext): Promise
355
657
  // 逐任务组解析 tasks.md 子任务(构造各 task WorkItem 的 task children 用)
356
658
  const tasksByGroup = new Map<string, ParsedTask[]>()
357
659
  for (const g of parsedGroups) {
358
- tasksByGroup.set(g.id, await parseTasksMdForGroup(ctx.worktree, args.change_id, g.id))
660
+ tasksByGroup.set(g.id, await parseTasksMdForGroup(ctx.worktree, changeId, g.id))
359
661
  }
360
662
 
361
663
  // base_branch 是 ref 而非严格 branch:只做非空 + 无空白字符等基本检查(完整分支名校验由 git check-ref-format 承担)
@@ -365,7 +667,7 @@ export async function initExecute(params: InitParams, ctx: ToolContext): Promise
365
667
  }
366
668
  }
367
669
  const baseBranch = args.base_branch || await getCurrentBranch(ctx.worktree)
368
- let state = await readStateByChangeId(ctx.worktree, args.change_id)
670
+ let state = await readStateByChangeId(ctx.worktree, changeId)
369
671
  const wasCurrentGroup = state?.taskGroupId === args.task_group_id
370
672
 
371
673
  // state 已存在时的 mode 变更窗口校验/更新(位于 for 循环前——循环内非活跃组新建 item 与
@@ -395,8 +697,8 @@ export async function initExecute(params: InitParams, ctx: ToolContext): Promise
395
697
 
396
698
  if (!state) {
397
699
  state = {
398
- changeId: args.change_id,
399
- isolationNamespace: generateIsolationNamespace(args.change_id),
700
+ changeId,
701
+ isolationNamespace: generateIsolationNamespace(changeId),
400
702
  taskGroupId: args.task_group_id,
401
703
  baseBranch,
402
704
  workItems: [],
@@ -413,7 +715,7 @@ export async function initExecute(params: InitParams, ctx: ToolContext): Promise
413
715
 
414
716
  // reset_steps 值域的模式感知校验:位于 state 读取/固化之后(有效模式 = state.mode ?? "full",
415
717
  // 与 applyRecoveryState 消费的生效模式一致)、一切状态变更与落盘之前,跨模式值报错不落盘
416
- assertValidResetStepValues(args.recovery, state.mode ?? "full")
718
+ assertValidResetStepValues(args.recovery, state)
417
719
 
418
720
  // 按 tasks.md 构造全部任务组的 task WorkItem(单轨:workItems 为唯一事实源)
419
721
  for (const group of parsedGroups) {
@@ -506,7 +808,7 @@ export async function initExecute(params: InitParams, ctx: ToolContext): Promise
506
808
  item.metadata["base_ref"] = null
507
809
  }
508
810
 
509
- applyRecoveryState(item, args.recovery, groupTasks, state.mode)
811
+ applyRecoveryState(item, args.recovery, groupTasks, state)
510
812
  refreshMeta(item)
511
813
  if (!existing) state.workItems.push(item)
512
814
  }
@@ -525,6 +827,23 @@ export async function initExecute(params: InitParams, ctx: ToolContext): Promise
525
827
  return parts.join("")
526
828
  }
527
829
 
830
+ /** 独立审查会话的 worktree 引用绑定(双基准拆分):
831
+ * - merge_target(合回目标)独立存 metadata:fix 模式收尾合回(pr=head_ref、full=当前分支,即 baseBranch);fix=none 收尾销毁不合并;
832
+ * - base_ref(审查锚点)仅 pr 形态设置 = merge-base(base_ref, head_ref);full 形态不设(全量语义,视图渲染「全量代码库」锚点)。 */
833
+ async function bindReviewWorktreeRefs(item: WorkItem, worktreePath: string, branch: string, state: OrchestrateState): Promise<void> {
834
+ item.metadata["worktree_path"] = worktreePath
835
+ item.metadata["branch_name"] = branch
836
+ item.metadata["merge_target"] = state.baseBranch
837
+ if (state.reviewScope?.scopeType === "pr" && state.reviewScope.baseRef) {
838
+ const baseRef = await getMergeBase(worktreePath, state.reviewScope.baseRef)
839
+ if (baseRef) {
840
+ item.metadata["base_ref"] = baseRef
841
+ return
842
+ }
843
+ }
844
+ delete item.metadata["base_ref"]
845
+ }
846
+
528
847
  async function bindWorktreeRefs(
529
848
  item: WorkItem,
530
849
  worktreePath: string,
@@ -562,12 +881,17 @@ export async function setWorktreeExecute(params: SetWorktreeParams, ctx: ToolCon
562
881
  throw new Error(`分支名 "${rawBranch}" 不合法,请修正后重试。`)
563
882
  }
564
883
  }
565
- const branch = rawBranch || `task-group/${state.changeId}/${state.taskGroupId}`
884
+ // 独立审查会话:分支命名 review/<sessionId>、worktree 布局 .worktree/<sessionId>/review
885
+ // (discoverDiskWorktrees 按 review/ 前缀识别为可恢复磁盘痕迹)
886
+ const isReview = state.kind === "review"
887
+ const branch = rawBranch || (isReview ? `review/${state.changeId}` : `task-group/${state.changeId}/${state.taskGroupId}`)
566
888
  let wtPath: string
567
889
  if (params.worktree_path) {
568
890
  wtPath = assertPathWithin(repoRoot, params.worktree_path, "worktree_path")
569
891
  } else {
570
- wtPath = path.join(repoRoot, ".worktree", state.changeId, `task-group-${state.taskGroupId}`)
892
+ wtPath = isReview
893
+ ? path.join(repoRoot, ".worktree", state.changeId, "review")
894
+ : path.join(repoRoot, ".worktree", state.changeId, `task-group-${state.taskGroupId}`)
571
895
  }
572
896
 
573
897
  const changeStatus = await runGit(repoRoot, ["status", "--porcelain", `openspec/changes/${state.changeId}/`])
@@ -590,7 +914,8 @@ export async function setWorktreeExecute(params: SetWorktreeParams, ctx: ToolCon
590
914
  const baseHead = await runGit(repoRoot, ["rev-parse", state.baseBranch])
591
915
  const mergeResult = await runGitChecked(existingPath, ["merge", "--ff-only", baseHead])
592
916
  if (mergeResult.success) {
593
- await bindWorktreeRefs(item, existingPath, branch, state.baseBranch)
917
+ if (isReview) await bindReviewWorktreeRefs(item, existingPath, branch, state)
918
+ else await bindWorktreeRefs(item, existingPath, branch, state.baseBranch)
594
919
  reused = true
595
920
  } else {
596
921
  const clean = await isWorktreeClean(existingPath)
@@ -605,7 +930,8 @@ export async function setWorktreeExecute(params: SetWorktreeParams, ctx: ToolCon
605
930
  10
606
931
  )
607
932
  if (localCommitCount > 0 || Number.isNaN(localCommitCount)) {
608
- await bindWorktreeRefs(item, existingPath, branch, state.baseBranch)
933
+ if (isReview) await bindReviewWorktreeRefs(item, existingPath, branch, state)
934
+ else await bindWorktreeRefs(item, existingPath, branch, state.baseBranch)
609
935
  reused = true
610
936
  } else {
611
937
  // 复用失败的清理走共享清理函数:目录侧(git remove + fs 兜底 + prune)与分支删除统一语义
@@ -621,9 +947,18 @@ export async function setWorktreeExecute(params: SetWorktreeParams, ctx: ToolCon
621
947
  }
622
948
 
623
949
  if (!reused) {
950
+ // 建分支 fork 源:pr 形态为 head_ref、full 形态为当前分支(两者均为 state.baseBranch)
624
951
  const forkBranch = state.baseBranch
625
952
  await runGit(repoRoot, ["worktree", "add", "-b", branch, wtPath, forkBranch])
626
- await bindWorktreeRefs(item, wtPath, branch, forkBranch, { requireBaseRef: true })
953
+ if (isReview) {
954
+ await bindReviewWorktreeRefs(item, wtPath, branch, state)
955
+ // pr 形态审查锚点(merge-base)必须可得,缺失即建引用失败(对齐 change 路径的 requireBaseRef)
956
+ if (state.reviewScope?.scopeType === "pr" && !item.metadata["base_ref"]) {
957
+ throw new Error(`worktree 创建成功但无法获取 ${state.reviewScope.baseRef} 与 ${state.baseBranch} 的 merge-base:${wtPath}`)
958
+ }
959
+ } else {
960
+ await bindWorktreeRefs(item, wtPath, branch, forkBranch, { requireBaseRef: true })
961
+ }
627
962
  }
628
963
 
629
964
  await writeState(ctx.worktree, state)
@@ -689,7 +1024,9 @@ export async function statusExecute(params: StatusParams, ctx: ToolContext): Pro
689
1024
  const checkpoint =
690
1025
  typeof item.metadata["_tool_review_checkpoint"] === "string" ? item.metadata["_tool_review_checkpoint"] : undefined
691
1026
  const baseRef = typeof item.metadata["base_ref"] === "string" ? item.metadata["base_ref"] : undefined
692
- toolChanges = await detectChanges(wtPath, { checkpoint, baseRef })
1027
+ // 独立审查 full 形态:审查锚点为全量代码库,返回全量哨兵(视图渲染「全量代码库」而非「未检出变更」)
1028
+ const scopeFull = state.kind === "review" && state.reviewScope?.scopeType === "full"
1029
+ toolChanges = await detectChanges(wtPath, { checkpoint, baseRef, scopeFull })
693
1030
  }
694
1031
  }
695
1032
  // 统计本 change 命中项目级跨 change 豁免清单的存量问题数(工具层降级时写入 exempted_hit 标记)
@@ -715,6 +1052,37 @@ async function sweepEmptyWorktreeParents(repoRoot: string, removedDir: string, c
715
1052
  try { await rmdir(worktreeLevelDir) } catch {}
716
1053
  }
717
1054
 
1055
+ /** 独立审查会话收尾的审查结果摘要:issue 按严重级别与维度归并计数(markdown)。 */
1056
+ function renderReviewIssueSummary(item: WorkItem): string {
1057
+ const issues = item.children.filter((c) => c.type === "issue")
1058
+ const lines = ["## 审查结果摘要", ""]
1059
+ if (issues.length === 0) {
1060
+ lines.push("- 未报出任何 issue。", "")
1061
+ return lines.join("\n")
1062
+ }
1063
+ const bySeverity = new Map<string, number>()
1064
+ const byDimension = new Map<string, number>()
1065
+ for (const c of issues) {
1066
+ const sev = c.severity ?? "Info"
1067
+ const dim = String(c.metadata["dimension"] ?? "style")
1068
+ bySeverity.set(sev, (bySeverity.get(sev) ?? 0) + 1)
1069
+ byDimension.set(dim, (byDimension.get(dim) ?? 0) + 1)
1070
+ }
1071
+ lines.push(`- **issue 总数**: ${issues.length}`)
1072
+ lines.push(`- **按严重级别**: ${[...bySeverity.entries()].map(([k, v]) => `${k}=${v}`).join("、")}`)
1073
+ lines.push(`- **按维度**: ${[...byDimension.entries()].map(([k, v]) => `${k}=${v}`).join("、")}`)
1074
+ lines.push("")
1075
+ lines.push("| severity | dimension | 文件位置 | 描述 |", "|----------|-----------|----------|------|")
1076
+ for (const c of issues) {
1077
+ const file = typeof c.metadata["file"] === "string" ? c.metadata["file"] : ""
1078
+ const line = typeof c.metadata["line"] === "number" ? c.metadata["line"] : 0
1079
+ const loc = file ? `${file}${line > 0 ? `:${line}` : ""}` : "(无)"
1080
+ lines.push(`| ${c.severity ?? "Info"} | ${String(c.metadata["dimension"] ?? "style")} | \`${loc}\` | ${c.description.replace(/\|/g, "\\|")} |`)
1081
+ }
1082
+ lines.push("")
1083
+ return lines.join("\n")
1084
+ }
1085
+
718
1086
  export async function completeTaskGroupExecute(params: { change_id: string }, ctx: ToolContext): Promise<string> {
719
1087
  assertOrchestrator(ctx, "opx_orch_complete_task_group")
720
1088
  const state = await readStateByWorktree(ctx.worktree, params.change_id)
@@ -738,8 +1106,12 @@ export async function completeTaskGroupExecute(params: { change_id: string }, ct
738
1106
  if (!clean) throw new Error(`worktree "${worktreePath}" 存在未 commit 内容,请先 commit 再完成任务组。`)
739
1107
  }
740
1108
 
1109
+ // 独立审查会话双维度放宽(其余门禁不动):
1110
+ // - fix=none(只审):issue 报告即交付物,openIssues 门禁放宽(issue 停留 todo 态为合法收口形态);
1111
+ // - 审并修(fix,两种颗粒度):门禁保留(阻塞 issue 未终态拒绝收尾)。
1112
+ const isReviewNone = state.kind === "review" && state.reviewScope?.fix === "none"
741
1113
  const openIssues = item.children.filter((c) => isBlockingSeverity(c.severity) && !isTerminalPhase(c.phase))
742
- if (openIssues.length > 0) {
1114
+ if (!isReviewNone && openIssues.length > 0) {
743
1115
  throw new Error(`存在 ${openIssues.length} 个 Low 及以上的未解决 issue 未处理,请先修复或申请豁免。`)
744
1116
  }
745
1117
 
@@ -767,9 +1139,14 @@ export async function completeTaskGroupExecute(params: { change_id: string }, ct
767
1139
  }
768
1140
  }
769
1141
 
1142
+ // 合并按修复策略:fix 模式合回 merge_target(pr=head_ref、full=当前分支,即 baseBranch);
1143
+ // fix=none 不合并(issue 报告即交付物),直接销毁 worktree 与分支。
770
1144
  const mergeTarget = state.baseBranch
771
- if (branchName) {
1145
+ if (branchName && !isReviewNone) {
772
1146
  const mergeResult = await mergeBranchToTarget(ctx.worktree, branchName, mergeTarget)
1147
+ if (mergeResult.blockedMessage) {
1148
+ return [`- **status**: blocked`, mergeResult.blockedMessage].join("\n")
1149
+ }
773
1150
  if (!mergeResult.success) {
774
1151
  return [
775
1152
  `- **status**: blocked`,
@@ -810,8 +1187,16 @@ export async function completeTaskGroupExecute(params: { change_id: string }, ct
810
1187
  }
811
1188
  item.metadata["completed_at"] = new Date().toISOString()
812
1189
  await writeState(ctx.worktree, state)
813
- const doneMessage = `任务组已完成并合并到 "${mergeTarget}"。`
814
1190
  const notes = [cleanupNote, checkboxWarning, cleanupResidualWarning].filter(Boolean)
1191
+ if (state.kind === "review") {
1192
+ // 独立审查会话收尾返回审查结果摘要(issue 计数按严重级别/维度归并,只审模式的交付物形态)
1193
+ const summary = renderReviewIssueSummary(item)
1194
+ const doneMessage = isReviewNone
1195
+ ? `独立审查会话已完成(只审模式,未合并,worktree 与分支已销毁)。`
1196
+ : `独立审查会话已完成并合并到 "${mergeTarget}"。`
1197
+ return notes.length > 0 ? `${doneMessage}\n${notes.join("\n")}\n${summary}` : `${doneMessage}\n${summary}`
1198
+ }
1199
+ const doneMessage = `任务组已完成并合并到 "${mergeTarget}"。`
815
1200
  return notes.length > 0 ? `${doneMessage}\n${notes.join("\n")}` : doneMessage
816
1201
  }
817
1202