@ifrankwang/openspec-agents 0.135.0 → 0.137.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/.mcp-server/cli.mjs +284 -85
- package/README.md +3 -2
- package/assets/skills/openspec-standard/templates/openspec/config.yaml +1 -1
- package/assets/skills/openspec-standard/templates/schemas/spec-driven-standard/README.md +3 -2
- package/assets/skills/openspec-standard/templates/schemas/spec-driven-standard/schema.yaml +4 -1
- package/assets/skills/openspec-standard/templates/schemas/spec-driven-standard/templates/clarify.md +4 -3
- package/assets/workflows/task-simple.yaml +23 -3
- package/assets/workflows/task.yaml +1 -1
- package/package.json +1 -1
- package/src/adapters/mcp-common/server.ts +2 -2
- package/src/core/git.ts +19 -4
- package/src/core/tools/lifecycle.ts +355 -80
- package/src/core/tools/schemas.ts +4 -4
- package/src/core/types.ts +2 -1
- package/src/core/views.ts +1 -1
- package/src/core/workflow/engine.ts +8 -0
- package/src/core/workflow/status.ts +17 -9
|
@@ -3,8 +3,8 @@ import { rmdir } from "node:fs/promises"
|
|
|
3
3
|
import type { OrchestrateState, TaskItem, TaskStatus, WorkflowMode, ReviewScope } from "../types.ts"
|
|
4
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, isLocalBranch, listLocalBranches, type DetectChangesResult } from "../git.ts"
|
|
7
|
-
import { readStateByWorktree, readStateByChangeId, writeState, writeContextToWorktree } from "../state.ts"
|
|
6
|
+
import { runGit, runGitChecked, getCurrentBranch, getMergeBase, isAncestor, isWorktreeClean, markTaskGroupCheckboxesComplete, mergeBranchToTarget, discoverDiskWorktrees, detectMainRepoPollution, detectChanges, removeTaskGroupWorktree, isLocalBranch, listLocalBranches, pathExists, type DetectChangesResult } from "../git.ts"
|
|
7
|
+
import { readStateByWorktree, readStateByChangeId, writeState, writeContextToWorktree, getLockPath, acquireLock, releaseLock } from "../state.ts"
|
|
8
8
|
import { generateIsolationNamespace } from "../namespace.ts"
|
|
9
9
|
import { readExemptions } from "../exemptions.ts"
|
|
10
10
|
import { parseAllTaskGroupsFromMd, parseTasksMdForGroup, extractRelevantSpecsFromTasks } from "../tasks-md.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, isTaskGroupSettled, isTerminalPhase, recommendForItem, resetInternalRetryCount, adjudicateStep, clearStepTags, REVIEW_FIX_POLICY_KEY } from "../workflow/engine.ts"
|
|
15
|
+
import { createInitialWorkItem, isBlockingSeverity, isTaskGroupSettled, isFinalTaskGroup, 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"
|
|
@@ -134,10 +134,11 @@ function firstUnpassedReviewStep(item: WorkItem, workflow: LoadedWorkflow): stri
|
|
|
134
134
|
* task children 保留既有进度(无则 done——review 恢复时子任务应视为已验证,否则 G21 remaining 会让 implement 无法提交)
|
|
135
135
|
* simple 分支(mode === "simple",3.2):无 analyze / verify_* step,task_analysis / dev_impl 均落
|
|
136
136
|
* in_progress/implement(task_analysis 重置 task children 全 todo,dev_impl 保留既有进度);review 落
|
|
137
|
-
* review
|
|
138
|
-
* task children 缺省 done;reset_steps
|
|
139
|
-
* quality_review 全部 tags——passed
|
|
140
|
-
* review_layer 在 simple
|
|
137
|
+
* review 第一个未全 passed 的审查 step(quality_review,或全 passed 时 verify_cleanup)——implement 置
|
|
138
|
+
* passed、quality_review 的 failed tag 删除回 pending(passed 保留)、task children 缺省 done;reset_steps
|
|
139
|
+
* 按模式生效(simple 仅接受 quality_review,含该值时清空 quality_review 全部 tags——passed 也清,
|
|
140
|
+
* 强制重审正是该参数的目的,清空后引擎重派该 step 审查者);review_layer 在 simple 下无对应子层
|
|
141
|
+
* (仅 quality_review 单层),接受但不生效,init 返回体输出警告。
|
|
141
142
|
*/
|
|
142
143
|
function applyRecoveryState(
|
|
143
144
|
item: WorkItem,
|
|
@@ -218,7 +219,20 @@ function applyRecoveryState(
|
|
|
218
219
|
clearStepTags(item, "quality_review")
|
|
219
220
|
}
|
|
220
221
|
// review_layer 在 simple 下无对应子层,接受但不生效(init 返回体对该组合输出警告)
|
|
221
|
-
|
|
222
|
+
// currentStep 前移到第一个未全 passed 的审查 step(与 full 口径一致;simple 的 review 阶段含
|
|
223
|
+
// quality_review 与 verify_cleanup 两个 step——verify_cleanup 仅作漂移/冲突回退落点,正常流转不经过)
|
|
224
|
+
const workflow = loadWorkflowFile(resolveWorkflowPath(state))
|
|
225
|
+
item.currentStep = firstUnpassedReviewStep(item, workflow)
|
|
226
|
+
// 全 passed 收口 done:存在未终态 task child 则停在 quality_review(recommendForItem 会返回
|
|
227
|
+
// blocked 而非 terminal,安全)
|
|
228
|
+
if (item.currentStep === null) {
|
|
229
|
+
const unfinishedTasks = item.children.filter((child) => child.type === "task" && !isTerminalPhase(child.phase))
|
|
230
|
+
if (unfinishedTasks.length === 0) {
|
|
231
|
+
item.phase = "done"
|
|
232
|
+
} else {
|
|
233
|
+
item.currentStep = "quality_review"
|
|
234
|
+
}
|
|
235
|
+
}
|
|
222
236
|
syncTaskChildren(item, parsedTasks, { defaultStatus: "done" })
|
|
223
237
|
return
|
|
224
238
|
}
|
|
@@ -835,23 +849,121 @@ async function bindReviewWorktreeRefs(item: WorkItem, worktreePath: string, bran
|
|
|
835
849
|
delete item.metadata["base_ref"]
|
|
836
850
|
}
|
|
837
851
|
|
|
852
|
+
/**
|
|
853
|
+
* change 会话 worktree 引用绑定(任务组范围标记):
|
|
854
|
+
* - worktree_path/branch_name 写入(常驻 worktree 与 change 分支,各任务组写相同值);
|
|
855
|
+
* - scope_start_oid:仅 item 首次绑定时记录当时 change 分支 HEAD(分支 tip)——重调/recovery 重入不重记
|
|
856
|
+
* (否则本任务组早前提交会被剔除出审查范围);已记 oid 不在当前分支历史(如分支重建)才允许重记;
|
|
857
|
+
* - base_ref 存显式 scope 端点(scope_start..HEAD),作为变更范围查询与本轮 diff 锚点
|
|
858
|
+
* (替代旧 merge-base(HEAD, baseBranch) 的 change 级口径);oid 失效由 detectChanges 既有降级兜底。
|
|
859
|
+
*/
|
|
838
860
|
async function bindWorktreeRefs(
|
|
839
861
|
item: WorkItem,
|
|
840
862
|
worktreePath: string,
|
|
841
863
|
branch: string,
|
|
842
|
-
baseBranch: string,
|
|
843
|
-
opts: { requireBaseRef?: boolean } = {},
|
|
844
864
|
): Promise<void> {
|
|
845
865
|
item.metadata["worktree_path"] = worktreePath
|
|
846
866
|
item.metadata["branch_name"] = branch
|
|
847
|
-
const
|
|
848
|
-
if (!
|
|
849
|
-
|
|
850
|
-
|
|
867
|
+
const existing = item.metadata["scope_start_oid"]
|
|
868
|
+
if (typeof existing !== "string" || !(await isAncestor(worktreePath, existing, branch))) {
|
|
869
|
+
const head = (await runGit(worktreePath, ["rev-parse", branch])).trim()
|
|
870
|
+
if (!head) throw new Error(`worktree 绑定成功但无法获取 ${branch} 的 HEAD:${worktreePath}`)
|
|
871
|
+
item.metadata["scope_start_oid"] = head
|
|
872
|
+
}
|
|
873
|
+
item.metadata["base_ref"] = item.metadata["scope_start_oid"]
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** change 会话分支确定性派生:change/{changeId}(changeId 经会话 token 归一,非法字符归一为 -)。 */
|
|
877
|
+
function deriveChangeBranch(changeId: string): string {
|
|
878
|
+
return `change/${normalizeSessionToken(changeId)}`
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* create-or-reuse:分支不存在 → 从基准分支创建(不检出,git 解析基准分支 tip);存在 → 复用。
|
|
883
|
+
* 触点两处:set_worktree 与 complete(合并目标解析前)——在途旧模型会话升级时从当前基准 tip 创建,
|
|
884
|
+
* 天然含已合入代码。返回是否为新创建。
|
|
885
|
+
*/
|
|
886
|
+
async function ensureChangeBranch(repoRoot: string, branch: string, baseBranch: string): Promise<boolean> {
|
|
887
|
+
if (await isLocalBranch(repoRoot, branch)) return false
|
|
888
|
+
const created = await runGitChecked(repoRoot, ["branch", branch, baseBranch])
|
|
889
|
+
if (!created.success) throw new Error(`创建分支 "${branch}"(fork 自 ${baseBranch})失败:${created.stderr}`)
|
|
890
|
+
return true
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* change 会话常驻 worktree 就位(create-or-reuse):
|
|
895
|
+
* - 分支由 ensureChangeBranch 保证存在(首个任务组从基准 tip 创建,后续任务组复用);
|
|
896
|
+
* - 按 git 管理记录找检出了该分支的 worktree,找到即复用(分支匹配由按 branch 检索保证);
|
|
897
|
+
* 未找到用规范路径(.worktree/{changeId}/ws 或显式 worktree_path):
|
|
898
|
+
* ①目录不存在(或仅剩幽灵管理记录)→ `git worktree prune` 后全新创建 checkout change 分支
|
|
899
|
+
* (修复旧逻辑目录已删时 rev-list 返回空 → parseInt NaN 误入复用分支的洞);
|
|
900
|
+
* ②目录存在但无本分支管理记录 → 交由 `git worktree add` 自身语义处理(空目录可建;非空非 worktree
|
|
901
|
+
* 目录由 git 拒绝,透传错误提示人工处理);
|
|
902
|
+
* ③目录存在且检出本分支 → 脏分类:仅 openspec/ 路径脏 → 自动 commit 兜底(与主仓库侧对称);
|
|
903
|
+
* 含代码文件脏 → 拒绝并提示人工处理;
|
|
904
|
+
* - 就位后 bindWorktreeRefs 绑定引用并记录任务组 scope 端点。
|
|
905
|
+
*/
|
|
906
|
+
async function ensureChangeWorktree(
|
|
907
|
+
repoRoot: string,
|
|
908
|
+
state: OrchestrateState,
|
|
909
|
+
item: WorkItem,
|
|
910
|
+
branch: string,
|
|
911
|
+
defaultPath: string,
|
|
912
|
+
): Promise<{ path: string; reused: boolean }> {
|
|
913
|
+
await ensureChangeBranch(repoRoot, branch, state.baseBranch)
|
|
914
|
+
|
|
915
|
+
const wtList = await runGit(repoRoot, ["worktree", "list"])
|
|
916
|
+
const existingLine = wtList.split("\n").find((l) => {
|
|
917
|
+
const m = l.match(/^(\S+)\s+[0-9a-f]+\s+\[(.+?)\]/)
|
|
918
|
+
return m && m[2].trim() === branch
|
|
919
|
+
})
|
|
920
|
+
const registeredPath = existingLine ? existingLine.match(/^(\S+)/)?.[1] : undefined
|
|
921
|
+
const targetPath = registeredPath ?? defaultPath
|
|
922
|
+
|
|
923
|
+
if (!(await pathExists(targetPath))) {
|
|
924
|
+
// 目录不存在:管理记录残留(幽灵 worktree)时先 prune,再全新创建 checkout change 分支
|
|
925
|
+
await runGitChecked(repoRoot, ["worktree", "prune"])
|
|
926
|
+
await runGit(repoRoot, ["worktree", "add", targetPath, branch])
|
|
927
|
+
await bindWorktreeRefs(item, targetPath, branch)
|
|
928
|
+
return { path: targetPath, reused: false }
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (!registeredPath) {
|
|
932
|
+
// 目录存在但不是检出本分支的 git 管理记录:交由 git worktree add 判定(空目录放行、非空目录拒绝)
|
|
933
|
+
const addRes = await runGitChecked(repoRoot, ["worktree", "add", targetPath, branch])
|
|
934
|
+
if (!addRes.success) {
|
|
935
|
+
throw new Error(
|
|
936
|
+
`无法在 "${targetPath}" 就位 worktree:目录已存在且不是检出分支 "${branch}" 的 git worktree(git: ${addRes.stderr})。\n` +
|
|
937
|
+
`请人工确认该目录内容并处理(删除或迁移)后重试。`
|
|
938
|
+
)
|
|
851
939
|
}
|
|
852
|
-
|
|
940
|
+
await bindWorktreeRefs(item, targetPath, branch)
|
|
941
|
+
return { path: targetPath, reused: false }
|
|
853
942
|
}
|
|
854
|
-
|
|
943
|
+
|
|
944
|
+
// 目录存在且检出本分支 → 复用候选:脏分类(openspec/ 脏自动 commit 兜底;代码文件脏拒绝)
|
|
945
|
+
const statusOut = await runGit(targetPath, ["status", "--porcelain"])
|
|
946
|
+
const dirtyPaths = statusOut.split("\n").map((l) => l.trim()).filter(Boolean)
|
|
947
|
+
.map((l) => {
|
|
948
|
+
const f = l.replace(/^\S+\s+/, "")
|
|
949
|
+
return (f.includes(" -> ") ? f.split(" -> ").pop()! : f).replace(/^"+|"+$/g, "")
|
|
950
|
+
})
|
|
951
|
+
.filter(Boolean)
|
|
952
|
+
const codeDirty = dirtyPaths.filter((p) => !p.startsWith("openspec/"))
|
|
953
|
+
if (codeDirty.length > 0) {
|
|
954
|
+
throw new Error(
|
|
955
|
+
`已有 worktree "${targetPath}" 存在未提交的代码文件变更(${codeDirty.join("、")}),无法自动处理。\n` +
|
|
956
|
+
`请先在该 worktree 内 commit 或 stash 后重试。`
|
|
957
|
+
)
|
|
958
|
+
}
|
|
959
|
+
if (dirtyPaths.length > 0) {
|
|
960
|
+
const addResult = await runGitChecked(targetPath, ["add", "--", ...dirtyPaths])
|
|
961
|
+
if (!addResult.success) throw new Error(`worktree openspec 目录 git add 失败:${addResult.stderr}`)
|
|
962
|
+
const commitResult = await runGitChecked(targetPath, ["commit", "-m", "docs(openspec): auto-commit before worktree reuse"])
|
|
963
|
+
if (!commitResult.success) throw new Error(`worktree openspec 目录 git commit 失败:${commitResult.stderr}`)
|
|
964
|
+
}
|
|
965
|
+
await bindWorktreeRefs(item, targetPath, branch)
|
|
966
|
+
return { path: targetPath, reused: true }
|
|
855
967
|
}
|
|
856
968
|
|
|
857
969
|
export async function setWorktreeExecute(params: SetWorktreeParams, ctx: ToolContext): Promise<string> {
|
|
@@ -862,7 +974,8 @@ export async function setWorktreeExecute(params: SetWorktreeParams, ctx: ToolCon
|
|
|
862
974
|
if (!item) throw new Error(`工作项 "task:${state.taskGroupId}" 缺失,请重新调用 opx_orch_init。`)
|
|
863
975
|
|
|
864
976
|
const repoRoot = ctx.worktree
|
|
865
|
-
|
|
977
|
+
const isReview = state.kind === "review"
|
|
978
|
+
// branch_name 可为空(缺省按会话形态派生),仅显式传入时用 git check-ref-format 严格校验。
|
|
866
979
|
// 用 --branch 形态(而非 refs/heads/<name>):前者拒绝前导 `-` 等 git branch 创建亦拒绝的非法分支名,
|
|
867
980
|
// 后者仅检查 refname 合法性,会放行前导 dash 的 plain ref。
|
|
868
981
|
const rawBranch = params.branch_name ?? ""
|
|
@@ -872,17 +985,23 @@ export async function setWorktreeExecute(params: SetWorktreeParams, ctx: ToolCon
|
|
|
872
985
|
throw new Error(`分支名 "${rawBranch}" 不合法,请修正后重试。`)
|
|
873
986
|
}
|
|
874
987
|
}
|
|
875
|
-
//
|
|
876
|
-
// (discoverDiskWorktrees 按 review/
|
|
877
|
-
|
|
878
|
-
const branch = rawBranch || (isReview ? `review/${state.changeId}` :
|
|
988
|
+
// 独立审查会话(旧模型完整保留):分支命名 review/<sessionId>、worktree 布局 .worktree/<sessionId>/review
|
|
989
|
+
// (discoverDiskWorktrees 按 review/ 前缀识别为可恢复磁盘痕迹);
|
|
990
|
+
// change 会话:分支确定性派生 change/{changeId}、常驻 worktree .worktree/{changeId}/ws,全部任务组串行复用
|
|
991
|
+
const branch = rawBranch || (isReview ? `review/${state.changeId}` : deriveChangeBranch(state.changeId))
|
|
992
|
+
if (!isReview) {
|
|
993
|
+
const check = await runGitChecked(repoRoot, ["check-ref-format", "--branch", branch])
|
|
994
|
+
if (!check.success) {
|
|
995
|
+
throw new Error(`派生分支名 "${branch}" 不合法(change_id="${state.changeId}"),请修正 change_id 或显式传入 branch_name。`)
|
|
996
|
+
}
|
|
997
|
+
}
|
|
879
998
|
let wtPath: string
|
|
880
999
|
if (params.worktree_path) {
|
|
881
1000
|
wtPath = assertPathWithin(repoRoot, params.worktree_path, "worktree_path")
|
|
882
1001
|
} else {
|
|
883
1002
|
wtPath = isReview
|
|
884
1003
|
? path.join(repoRoot, ".worktree", state.changeId, "review")
|
|
885
|
-
: path.join(repoRoot, ".worktree", state.changeId,
|
|
1004
|
+
: path.join(repoRoot, ".worktree", state.changeId, "ws")
|
|
886
1005
|
}
|
|
887
1006
|
|
|
888
1007
|
const changeStatus = await runGit(repoRoot, ["status", "--porcelain", `openspec/changes/${state.changeId}/`])
|
|
@@ -893,63 +1012,63 @@ export async function setWorktreeExecute(params: SetWorktreeParams, ctx: ToolCon
|
|
|
893
1012
|
if (!commitResult.success) throw new Error(`change 目录 git commit 失败:${commitResult.stderr}`)
|
|
894
1013
|
}
|
|
895
1014
|
|
|
896
|
-
const wtList = await runGit(repoRoot, ["worktree", "list"])
|
|
897
|
-
const existingLine = wtList.split("\n").find((l) => {
|
|
898
|
-
const m = l.match(/^(\S+)\s+[0-9a-f]+\s+\[(.+?)\]/)
|
|
899
|
-
return m && m[2].trim() === branch
|
|
900
|
-
})
|
|
901
|
-
const existingPath = existingLine ? existingLine.match(/^(\S+)/)?.[1] : undefined
|
|
902
|
-
|
|
903
1015
|
let reused = false
|
|
904
|
-
if (
|
|
905
|
-
const
|
|
906
|
-
const
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
)
|
|
918
|
-
}
|
|
919
|
-
const localCommitCount = parseInt(
|
|
920
|
-
await runGit(existingPath, ["rev-list", "--count", `${state.baseBranch}..HEAD`]),
|
|
921
|
-
10
|
|
922
|
-
)
|
|
923
|
-
if (localCommitCount > 0 || Number.isNaN(localCommitCount)) {
|
|
924
|
-
if (isReview) await bindReviewWorktreeRefs(item, existingPath, branch, state)
|
|
925
|
-
else await bindWorktreeRefs(item, existingPath, branch, state.baseBranch)
|
|
1016
|
+
if (isReview) {
|
|
1017
|
+
const wtList = await runGit(repoRoot, ["worktree", "list"])
|
|
1018
|
+
const existingLine = wtList.split("\n").find((l) => {
|
|
1019
|
+
const m = l.match(/^(\S+)\s+[0-9a-f]+\s+\[(.+?)\]/)
|
|
1020
|
+
return m && m[2].trim() === branch
|
|
1021
|
+
})
|
|
1022
|
+
const existingPath = existingLine ? existingLine.match(/^(\S+)/)?.[1] : undefined
|
|
1023
|
+
|
|
1024
|
+
if (existingPath) {
|
|
1025
|
+
const baseHead = await runGit(repoRoot, ["rev-parse", state.baseBranch])
|
|
1026
|
+
const mergeResult = await runGitChecked(existingPath, ["merge", "--ff-only", baseHead])
|
|
1027
|
+
if (mergeResult.success) {
|
|
1028
|
+
await bindReviewWorktreeRefs(item, existingPath, branch, state)
|
|
926
1029
|
reused = true
|
|
927
1030
|
} else {
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1031
|
+
const clean = await isWorktreeClean(existingPath)
|
|
1032
|
+
if (!clean) {
|
|
1033
|
+
throw new Error(
|
|
1034
|
+
`已有 worktree "${existingPath}" 与 ${state.baseBranch} 分叉且有未提交变更,无法自动 fast-forward。\n` +
|
|
1035
|
+
`请手动处理后重试。`
|
|
1036
|
+
)
|
|
1037
|
+
}
|
|
1038
|
+
const localCommitCount = parseInt(
|
|
1039
|
+
await runGit(existingPath, ["rev-list", "--count", `${state.baseBranch}..HEAD`]),
|
|
1040
|
+
10
|
|
1041
|
+
)
|
|
1042
|
+
if (localCommitCount > 0 || Number.isNaN(localCommitCount)) {
|
|
1043
|
+
await bindReviewWorktreeRefs(item, existingPath, branch, state)
|
|
1044
|
+
reused = true
|
|
1045
|
+
} else {
|
|
1046
|
+
// 复用失败的清理走共享清理函数:目录侧(git remove + fs 兜底 + prune)与分支删除统一语义
|
|
1047
|
+
const cleanup = await removeTaskGroupWorktree(repoRoot, existingPath, { branchName: branch })
|
|
1048
|
+
if (!cleanup.dirResolved || cleanup.branchDeleted === false) {
|
|
1049
|
+
const problems: string[] = []
|
|
1050
|
+
if (!cleanup.dirResolved) problems.push(`无法清理已有 worktree "${existingPath}"`)
|
|
1051
|
+
if (cleanup.branchDeleted === false) problems.push(`无法清理已有分支 "${branch}"`)
|
|
1052
|
+
throw new Error(`${problems.join(";")}:${cleanup.errors.join(";") || "原因未知"}\n请手动处理后重试。`)
|
|
1053
|
+
}
|
|
935
1054
|
}
|
|
936
1055
|
}
|
|
937
1056
|
}
|
|
938
|
-
}
|
|
939
1057
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
if (isReview) {
|
|
1058
|
+
if (!reused) {
|
|
1059
|
+
// 建分支 fork 源:pr 形态为 head_ref、full 形态为当前分支(两者均为 state.baseBranch)
|
|
1060
|
+
const forkBranch = state.baseBranch
|
|
1061
|
+
await runGit(repoRoot, ["worktree", "add", "-b", branch, wtPath, forkBranch])
|
|
945
1062
|
await bindReviewWorktreeRefs(item, wtPath, branch, state)
|
|
946
|
-
// pr 形态审查锚点(merge-base
|
|
1063
|
+
// pr 形态审查锚点(merge-base)必须可得,缺失即建引用失败
|
|
947
1064
|
if (state.reviewScope?.scopeType === "pr" && !item.metadata["base_ref"]) {
|
|
948
1065
|
throw new Error(`worktree 创建成功但无法获取 ${state.reviewScope.baseRef} 与 ${state.baseBranch} 的 merge-base:${wtPath}`)
|
|
949
1066
|
}
|
|
950
|
-
} else {
|
|
951
|
-
await bindWorktreeRefs(item, wtPath, branch, forkBranch, { requireBaseRef: true })
|
|
952
1067
|
}
|
|
1068
|
+
} else {
|
|
1069
|
+
const ensured = await ensureChangeWorktree(repoRoot, state, item, branch, wtPath)
|
|
1070
|
+
wtPath = ensured.path
|
|
1071
|
+
reused = ensured.reused
|
|
953
1072
|
}
|
|
954
1073
|
|
|
955
1074
|
await writeState(ctx.worktree, state)
|
|
@@ -1074,8 +1193,58 @@ function renderReviewIssueSummary(item: WorkItem): string {
|
|
|
1074
1193
|
return lines.join("\n")
|
|
1075
1194
|
}
|
|
1076
1195
|
|
|
1196
|
+
/**
|
|
1197
|
+
* 最后任务组收口被基准分支漂移/文本冲突拦截时的回退:
|
|
1198
|
+
* 该任务组回退到收尾验证(verify_cleanup)等待重新收口——phase 回退 + clearStepTags + 按 recovery
|
|
1199
|
+
* 先例清除残留推进阻塞原因/检查点标记并重置内部重试计数(防回退后检查点误触发死锁),
|
|
1200
|
+
* 同时删除本轮回写的 scope_end_oid(重新收口时重记)。worktree 与 change 分支保留,未执行合并(无半成品)。
|
|
1201
|
+
* 落点要求工作流声明 id=verify_cleanup 的 step:解析不到 → 不回退(返回 false),沿用人工处理 blocked
|
|
1202
|
+
* 文案(防工具先上、配置未同步窗口)。
|
|
1203
|
+
*/
|
|
1204
|
+
function rollbackToCleanupStep(item: WorkItem, workflow: LoadedWorkflow): boolean {
|
|
1205
|
+
if (!workflow.stepMap.has("verify_cleanup")) return false
|
|
1206
|
+
delete item.metadata["_advance_block_reason"]
|
|
1207
|
+
delete item.metadata["_checkpoint"]
|
|
1208
|
+
resetInternalRetryCount(item)
|
|
1209
|
+
clearStepTags(item, "verify_cleanup")
|
|
1210
|
+
delete item.metadata["scope_end_oid"]
|
|
1211
|
+
item.phase = "review"
|
|
1212
|
+
item.currentStep = "verify_cleanup"
|
|
1213
|
+
return true
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** 最后任务组收口 blocked 返回体(事实文案,结尾统一指引重新查询 opx_status;不含任何「请分派 X」流转指令)。 */
|
|
1217
|
+
function renderFinalizeBlocked(
|
|
1218
|
+
reasons: string[],
|
|
1219
|
+
handlings: string[],
|
|
1220
|
+
opts: { branchName: string; mergeTarget: string; rolledBack: boolean; checkboxWarning?: string },
|
|
1221
|
+
): string {
|
|
1222
|
+
const lines = [
|
|
1223
|
+
`- **status**: blocked`,
|
|
1224
|
+
...reasons.map((r) => `- **原因**: ${r}`),
|
|
1225
|
+
`- **说明**: worktree 与变更分支 \`${opts.branchName}\` 已保留;未执行合并,无半成品状态。属多 change 并行推进的预期行为。` +
|
|
1226
|
+
(opts.rolledBack ? "本任务组状态已回退到收尾验证(verify_cleanup)。" : ""),
|
|
1227
|
+
...handlings.map((h) => `- **处理**: ${h}`),
|
|
1228
|
+
"",
|
|
1229
|
+
"重新查询 opx_status 获取分派指引。",
|
|
1230
|
+
]
|
|
1231
|
+
if (opts.checkboxWarning) lines.push(opts.checkboxWarning)
|
|
1232
|
+
return lines.join("\n")
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1077
1235
|
export async function completeTaskGroupExecute(params: { change_id: string }, ctx: ToolContext): Promise<string> {
|
|
1078
1236
|
assertOrchestrator(ctx, "opx_orch_complete_task_group")
|
|
1237
|
+
// 全程文件锁(与 opx_agent_submit 同粒度):complete 是 read-modify-write 全程,防并发完成丢状态
|
|
1238
|
+
const lockPath = await getLockPath(ctx.worktree, params.change_id)
|
|
1239
|
+
await acquireLock(lockPath)
|
|
1240
|
+
try {
|
|
1241
|
+
return await completeTaskGroupLocked(params, ctx)
|
|
1242
|
+
} finally {
|
|
1243
|
+
releaseLock(lockPath)
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async function completeTaskGroupLocked(params: { change_id: string }, ctx: ToolContext): Promise<string> {
|
|
1079
1248
|
const state = await readStateByWorktree(ctx.worktree, params.change_id)
|
|
1080
1249
|
if (!state) throw new Error("编排会话未初始化。请先调用 opx_orch_init。")
|
|
1081
1250
|
const item = state.workItems.find((w) => w.id === `task:${state.taskGroupId}`)
|
|
@@ -1100,7 +1269,8 @@ export async function completeTaskGroupExecute(params: { change_id: string }, ct
|
|
|
1100
1269
|
// 独立审查会话双维度放宽(其余门禁不动):
|
|
1101
1270
|
// - fix=none(只审):issue 报告即交付物,openIssues 门禁放宽(issue 停留 todo 态为合法收口形态);
|
|
1102
1271
|
// - 审并修(fix,两种颗粒度):门禁保留(阻塞 issue 未终态拒绝收尾)。
|
|
1103
|
-
const
|
|
1272
|
+
const isReview = state.kind === "review"
|
|
1273
|
+
const isReviewNone = isReview && state.reviewScope?.fix === "none"
|
|
1104
1274
|
const openIssues = item.children.filter((c) => isBlockingSeverity(c.severity) && !isTerminalPhase(c.phase))
|
|
1105
1275
|
if (!isReviewNone && openIssues.length > 0) {
|
|
1106
1276
|
throw new Error(`存在 ${openIssues.length} 个 Low 及以上的未解决 issue 未处理,请先修复或申请豁免。`)
|
|
@@ -1129,9 +1299,118 @@ export async function completeTaskGroupExecute(params: { change_id: string }, ct
|
|
|
1129
1299
|
checkboxWarning = `- **tasks.md 复选框勾选失败**: ${e instanceof Error ? e.message : String(e)}(不影响收尾)`
|
|
1130
1300
|
}
|
|
1131
1301
|
}
|
|
1302
|
+
// scope_end 端点(change 会话):勾选本身产生新提交,端点在勾选之后记录为当时 change 分支 HEAD(分支 tip)
|
|
1303
|
+
if (!isReview && worktreePath && branchName) {
|
|
1304
|
+
const endOid = (await runGit(worktreePath, ["rev-parse", branchName])).trim()
|
|
1305
|
+
if (endOid) item.metadata["scope_end_oid"] = endOid
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
if (isReview) {
|
|
1309
|
+
return finalizeReviewSession(state, item, params, ctx, { worktreePath, branchName, isReviewNone, checkboxWarning })
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// ─── change 会话:任务组完成按「是否最后一个收口任务组」分流 ───
|
|
1313
|
+
if (!isFinalTaskGroup(state, item)) {
|
|
1314
|
+
// 非最后任务组:门禁 → 勾选 → scope_end → completed_at;无合并、无销毁
|
|
1315
|
+
item.metadata["completed_at"] = new Date().toISOString()
|
|
1316
|
+
await writeState(ctx.worktree, state)
|
|
1317
|
+
const doneMessage =
|
|
1318
|
+
"任务组已完成。本任务组变更保留在 change 分支上,待全部任务组完成后统一收口合并(本次不合并、不销毁 worktree)。"
|
|
1319
|
+
return checkboxWarning ? `${doneMessage}\n${checkboxWarning}` : doneMessage
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// 最后任务组 → change 级收口:合并 change 分支回基准分支(一次性),成功后销毁 worktree 并删分支
|
|
1323
|
+
const mergeTarget = state.baseBranch
|
|
1324
|
+
if (branchName) {
|
|
1325
|
+
// create-or-reuse(合并目标解析前):在途旧模型会话升级时从当前基准 tip 创建,天然含已合入代码
|
|
1326
|
+
await ensureChangeBranch(ctx.worktree, branchName, mergeTarget)
|
|
1327
|
+
|
|
1328
|
+
// 前置漂移检查(独立封装):基准 tip 须为 change 分支祖先;不满足 → 回退收尾验证并 blocked
|
|
1329
|
+
if (!(await isAncestor(ctx.worktree, mergeTarget, branchName))) {
|
|
1330
|
+
const workflow = loadWorkflowFile(resolveWorkflowPath(state))
|
|
1331
|
+
const rolledBack = rollbackToCleanupStep(item, workflow)
|
|
1332
|
+
if (rolledBack) await writeState(ctx.worktree, state)
|
|
1333
|
+
return renderFinalizeBlocked(
|
|
1334
|
+
[
|
|
1335
|
+
`基准分支 \`${mergeTarget}\` 已推进(与变更分支 \`${branchName}\` 存在漂移):基准分支最新提交未包含在变更分支历史中,直接合并会遗漏基准分支新内容。`,
|
|
1336
|
+
],
|
|
1337
|
+
[
|
|
1338
|
+
`在 worktree 内执行 \`git merge ${mergeTarget}\` 合入基准分支最新代码并解决冲突,完成回归验证后重新提交收尾验证(opx_agent_submit,step_id="verify_cleanup"),通过后再调用 opx_orch_complete_task_group 重新收口。`,
|
|
1339
|
+
],
|
|
1340
|
+
{ branchName, mergeTarget, rolledBack, checkboxWarning },
|
|
1341
|
+
)
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const mergeResult = await mergeBranchToTarget(ctx.worktree, branchName, mergeTarget)
|
|
1345
|
+
if (mergeResult.blockedMessage) {
|
|
1346
|
+
// 主仓库侧前置拦截(检出冲突/脏文件重合/部分暂存):未执行任何合并动作,状态不变,人工处理后重调
|
|
1347
|
+
return [`- **status**: blocked`, mergeResult.blockedMessage].join("\n")
|
|
1348
|
+
}
|
|
1349
|
+
if (!mergeResult.success) {
|
|
1350
|
+
// 文本冲突 → 与漂移同路径回退(原冲突指引文案替换为回退口径)
|
|
1351
|
+
const workflow = loadWorkflowFile(resolveWorkflowPath(state))
|
|
1352
|
+
const rolledBack = rollbackToCleanupStep(item, workflow)
|
|
1353
|
+
if (rolledBack) await writeState(ctx.worktree, state)
|
|
1354
|
+
return renderFinalizeBlocked(
|
|
1355
|
+
[
|
|
1356
|
+
`变更分支 \`${branchName}\` 合并到 \`${mergeTarget}\` 时发生冲突,未产生任何变更(分支引用未动,无半成品合并)。`,
|
|
1357
|
+
],
|
|
1358
|
+
[
|
|
1359
|
+
`在 worktree 内执行 \`git merge ${mergeTarget}\` 合入基准分支最新代码解决冲突并提交,完成回归验证后重新提交收尾验证(opx_agent_submit,step_id="verify_cleanup"),通过后再调用 opx_orch_complete_task_group 重新收口。`,
|
|
1360
|
+
],
|
|
1361
|
+
{ branchName, mergeTarget, rolledBack, checkboxWarning },
|
|
1362
|
+
)
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// 合并成功(或无分支引用):收口清理(销毁 worktree + 删分支)。
|
|
1367
|
+
// 残留不阻断收口:completed_at 照写,残留信息写入 metadata.cleanup_residual 并在返回体给出人工处理命令。
|
|
1368
|
+
let cleanupNote = ""
|
|
1369
|
+
let cleanupResidualWarning = ""
|
|
1370
|
+
if (worktreePath) {
|
|
1371
|
+
const cleanup = await removeTaskGroupWorktree(ctx.worktree, worktreePath, { branchName })
|
|
1372
|
+
if (cleanup.dirResolved) {
|
|
1373
|
+
await sweepEmptyWorktreeParents(ctx.worktree, worktreePath, params.change_id)
|
|
1374
|
+
const successNotes: string[] = []
|
|
1375
|
+
if (cleanup.dirResolvedByFallback) {
|
|
1376
|
+
successNotes.push("- **cleanup**: git worktree remove 未直接移除目录,已按文件系统兜底删除并 prune 管理记录。")
|
|
1377
|
+
}
|
|
1378
|
+
if (cleanup.errors.length > 0) {
|
|
1379
|
+
successNotes.push(`- **cleanup 部分告警**: ${cleanup.errors.join(";")}`)
|
|
1380
|
+
}
|
|
1381
|
+
cleanupNote = successNotes.join("\n")
|
|
1382
|
+
} else {
|
|
1383
|
+
item.metadata["cleanup_residual"] = { worktree_path: worktreePath, errors: cleanup.errors }
|
|
1384
|
+
cleanupResidualWarning = [
|
|
1385
|
+
"",
|
|
1386
|
+
"## ⚠️ worktree 清理残留(不影响收口)",
|
|
1387
|
+
`- **残留路径**: \`${worktreePath}\``,
|
|
1388
|
+
"- **原因**:",
|
|
1389
|
+
...cleanup.errors.map((e) => ` - ${e}`),
|
|
1390
|
+
`- **处理**: 请人工执行 \`rm -rf '${worktreePath}' && git worktree prune\` 清理残留目录与 git 管理记录。`,
|
|
1391
|
+
branchName ? `- **分支**: 分支 "${branchName}" 未删除。` : "",
|
|
1392
|
+
].filter(Boolean).join("\n")
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
item.metadata["completed_at"] = new Date().toISOString()
|
|
1396
|
+
await writeState(ctx.worktree, state)
|
|
1397
|
+
const notes = [cleanupNote, checkboxWarning, cleanupResidualWarning].filter(Boolean)
|
|
1398
|
+
const doneMessage = branchName
|
|
1399
|
+
? `任务组已完成并合并到 "${mergeTarget}"。`
|
|
1400
|
+
: "任务组已完成(无变更分支引用,跳过合并)。"
|
|
1401
|
+
return notes.length > 0 ? `${doneMessage}\n${notes.join("\n")}` : doneMessage
|
|
1402
|
+
}
|
|
1132
1403
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1404
|
+
/** 独立审查会话(kind=review)收尾:合并行为维持 baseBranch 口径(fix 模式合回 merge_target、
|
|
1405
|
+
* fix=none 不合并不合并直接销毁),change 分支模型不介入。 */
|
|
1406
|
+
async function finalizeReviewSession(
|
|
1407
|
+
state: OrchestrateState,
|
|
1408
|
+
item: WorkItem,
|
|
1409
|
+
params: { change_id: string },
|
|
1410
|
+
ctx: ToolContext,
|
|
1411
|
+
refs: { worktreePath: string | null; branchName: string | null; isReviewNone: boolean; checkboxWarning: string },
|
|
1412
|
+
): Promise<string> {
|
|
1413
|
+
const { worktreePath, branchName, isReviewNone, checkboxWarning } = refs
|
|
1135
1414
|
const mergeTarget = state.baseBranch
|
|
1136
1415
|
if (branchName && !isReviewNone) {
|
|
1137
1416
|
const mergeResult = await mergeBranchToTarget(ctx.worktree, branchName, mergeTarget)
|
|
@@ -1179,16 +1458,12 @@ export async function completeTaskGroupExecute(params: { change_id: string }, ct
|
|
|
1179
1458
|
item.metadata["completed_at"] = new Date().toISOString()
|
|
1180
1459
|
await writeState(ctx.worktree, state)
|
|
1181
1460
|
const notes = [cleanupNote, checkboxWarning, cleanupResidualWarning].filter(Boolean)
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
return notes.length > 0 ? `${doneMessage}\n${notes.join("\n")}\n${summary}` : `${doneMessage}\n${summary}`
|
|
1189
|
-
}
|
|
1190
|
-
const doneMessage = `任务组已完成并合并到 "${mergeTarget}"。`
|
|
1191
|
-
return notes.length > 0 ? `${doneMessage}\n${notes.join("\n")}` : doneMessage
|
|
1461
|
+
// 独立审查会话收尾返回审查结果摘要(issue 计数按严重级别/维度归并,只审模式的交付物形态)
|
|
1462
|
+
const summary = renderReviewIssueSummary(item)
|
|
1463
|
+
const doneMessage = isReviewNone
|
|
1464
|
+
? `独立审查会话已完成(只审模式,未合并,worktree 与分支已销毁)。`
|
|
1465
|
+
: `独立审查会话已完成并合并到 "${mergeTarget}"。`
|
|
1466
|
+
return notes.length > 0 ? `${doneMessage}\n${notes.join("\n")}\n${summary}` : `${doneMessage}\n${summary}`
|
|
1192
1467
|
}
|
|
1193
1468
|
|
|
1194
1469
|
export async function setUnattendedExecute(params: UnattendedParams, ctx: ToolContext): Promise<string> {
|
|
@@ -210,14 +210,14 @@ export const orchInitSchema: JSONSchema = {
|
|
|
210
210
|
},
|
|
211
211
|
base_branch: {
|
|
212
212
|
type: "string",
|
|
213
|
-
description: "基准分支名(如 main、develop
|
|
213
|
+
description: "基准分支名(如 main、develop),作为 change 分支(change/{changeId})的 fork 源与收口合并目标。未传则自动从当前 git 分支推导。仅 change 会话入口有效。",
|
|
214
214
|
},
|
|
215
215
|
recovery: recoverySchema,
|
|
216
216
|
mode: {
|
|
217
217
|
type: "string",
|
|
218
218
|
enum: ["full", "simple"],
|
|
219
219
|
description:
|
|
220
|
-
"流程模式选择(仅 change 会话入口有效,独立审查会话不使用):full=完整流程(analyze→implement→三重审查+收尾验证);simple=精简流程(implement→quality_review→done
|
|
220
|
+
"流程模式选择(仅 change 会话入口有效,独立审查会话不使用):full=完整流程(analyze→implement→三重审查+收尾验证);simple=精简流程(implement→quality_review→done,verify_cleanup 仅作收口漂移/冲突回退落点,正常流转不经过,缺省)。首次新建编排状态时固化;已开始的变更仅在允许窗口内可更新:切换任务组(其他任务组均已完成或从未激活)或 recovery.phase=task_analysis 重制当前组(其他任务组同样须已完成或从未激活);其余场景传不同 mode 将报错。",
|
|
221
221
|
},
|
|
222
222
|
review_scope: {
|
|
223
223
|
type: "object",
|
|
@@ -261,8 +261,8 @@ export const setWorktreeSchema: JSONSchema = {
|
|
|
261
261
|
type: "object",
|
|
262
262
|
properties: {
|
|
263
263
|
change_id: { type: "string", minLength: 1, description: "change ID" },
|
|
264
|
-
worktree_path: { type: "string", description: "git worktree
|
|
265
|
-
branch_name: { type: "string", description: "worktree
|
|
264
|
+
worktree_path: { type: "string", description: "git worktree 的绝对路径(可选,不传则按规范自动生成 .worktree/{changeId}/ws)" },
|
|
265
|
+
branch_name: { type: "string", description: "worktree 对应的分支名(可选,不传则按 change 模型确定性派生:change/{changeId},整个 change 串行复用同一分支与常驻 worktree)" },
|
|
266
266
|
},
|
|
267
267
|
required: ["change_id"],
|
|
268
268
|
additionalProperties: false,
|
package/src/core/types.ts
CHANGED
|
@@ -31,7 +31,8 @@ export type OrchestrateStatus = "not_started" | "in_progress" | "completed"
|
|
|
31
31
|
export type DimensionVerdict = "pending" | "passed" | "failed"
|
|
32
32
|
export type QualityLayerProgress = Record<ReviewDimension, DimensionVerdict>
|
|
33
33
|
|
|
34
|
-
/** 流程模式:full(默认,analyze → implement → 三重审查+收尾验证)或 simple(implement → quality_review → done
|
|
34
|
+
/** 流程模式:full(默认,analyze → implement → 三重审查+收尾验证)或 simple(implement → quality_review → done;
|
|
35
|
+
* verify_cleanup 仅作收口漂移/冲突回退落点,正常流转不经过)。 */
|
|
35
36
|
export type WorkflowMode = "full" | "simple"
|
|
36
37
|
|
|
37
38
|
/** 独立审查会话的审查范围类型:pr=按 base..head 分支区间审查,full=全量代码库审查。 */
|
package/src/core/views.ts
CHANGED
|
@@ -120,7 +120,7 @@ export function renderWorktreeSection(
|
|
|
120
120
|
lines.push("- **审查范围**: 全量代码库审查(无区间界定,工具检查与审查覆盖整个代码库)")
|
|
121
121
|
}
|
|
122
122
|
} else if (tg.baseRef) {
|
|
123
|
-
lines.push(`- **变更范围**: 用 \`git -C ${tg.worktreePath} diff --name-only ${tg.baseRef}..HEAD\`
|
|
123
|
+
lines.push(`- **变更范围**: 用 \`git -C ${tg.worktreePath} diff --name-only ${tg.baseRef}..HEAD\` 查询本任务组的已提交变更文件`)
|
|
124
124
|
}
|
|
125
125
|
lines.push("- **⚠️ 约束**: 所有读写和 git 操作均在此目录下进行;严禁直接修改主仓库/主分支路径下的文件(如 `<repo>/openspec/...`)")
|
|
126
126
|
lines.push("- **路径解析**: 推荐阅读文档均为相对 worktree 路径的引用,一律以 worktree 路径为基准解析,禁止从主仓库根目录解析")
|
|
@@ -44,6 +44,14 @@ export function isTaskGroupSettled(item: WorkItem): boolean {
|
|
|
44
44
|
return noTags && childrenSettled
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** 判断 item 是否为其 change 的最后一个收口任务组:其余全部任务组均「终态或从未激活」。
|
|
48
|
+
* complete 的 change 级收口判定与终态视图文案分流共用此判定(单一事实源)。 */
|
|
49
|
+
export function isFinalTaskGroup(state: { workItems: WorkItem[] }, item: WorkItem): boolean {
|
|
50
|
+
return state.workItems
|
|
51
|
+
.filter((w) => w.id.startsWith("task:") && w.id !== item.id)
|
|
52
|
+
.every(isTaskGroupSettled)
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
export function isBlockingSeverity(severity: string | undefined): boolean {
|
|
48
56
|
return severity !== undefined && (BLOCKING_SEVERITIES as readonly string[]).includes(severity)
|
|
49
57
|
}
|