@ifrankwang/openspec-agents 0.118.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.
Files changed (84) hide show
  1. package/README.md +91 -0
  2. package/assets/agents/openspec-architect.md +61 -0
  3. package/assets/agents/openspec-developer.md +21 -0
  4. package/assets/agents/openspec-main.md +35 -0
  5. package/assets/agents/openspec-reviewer-architecture.md +60 -0
  6. package/assets/agents/openspec-reviewer-maintainability.md +59 -0
  7. package/assets/agents/openspec-reviewer-performance.md +49 -0
  8. package/assets/agents/openspec-reviewer-security.md +51 -0
  9. package/assets/agents/openspec-reviewer-style.md +50 -0
  10. package/assets/agents/openspec-reviewer-task.md +40 -0
  11. package/assets/agents/openspec-reviewer-tool.md +30 -0
  12. package/assets/dashboard/index.html +295 -0
  13. package/assets/skills/api-conventions/SKILL.md +60 -0
  14. package/assets/skills/api-test/SKILL.md +175 -0
  15. package/assets/skills/architecture-evolution/SKILL.md +60 -0
  16. package/assets/skills/db-conventions/SKILL.md +62 -0
  17. package/assets/skills/ddd-architecture/SKILL.md +261 -0
  18. package/assets/skills/environment-cleanup/SKILL.md +44 -0
  19. package/assets/skills/java-api-conventions/SKILL.md +30 -0
  20. package/assets/skills/java-code-maintainability/SKILL.md +62 -0
  21. package/assets/skills/java-code-performance/SKILL.md +49 -0
  22. package/assets/skills/java-code-style/SKILL.md +54 -0
  23. package/assets/skills/java-db-conventions/SKILL.md +44 -0
  24. package/assets/skills/java-ddd-architecture/SKILL.md +533 -0
  25. package/assets/skills/java-dev-practices/SKILL.md +110 -0
  26. package/assets/skills/java-quality-gate/SKILL.md +455 -0
  27. package/assets/skills/java-quality-gate/reference/pmd-rules.md +16 -0
  28. package/assets/skills/java-quality-tool-improve/SKILL.md +142 -0
  29. package/assets/skills/java-security/SKILL.md +59 -0
  30. package/assets/skills/java-technical-debt/SKILL.md +36 -0
  31. package/assets/skills/orchestrator/SKILL.md +65 -0
  32. package/assets/skills/quality-gate/SKILL.md +84 -0
  33. package/assets/skills/security-baseline/SKILL.md +101 -0
  34. package/assets/skills/technical-debt/SKILL.md +78 -0
  35. package/assets/workflows/task.yaml +177 -0
  36. package/package.json +79 -0
  37. package/src/adapters/agent-md.ts +37 -0
  38. package/src/adapters/claude-code/index.ts +35 -0
  39. package/src/adapters/codex/index.ts +161 -0
  40. package/src/adapters/mcp-common/cli.ts +64 -0
  41. package/src/adapters/mcp-common/index.ts +137 -0
  42. package/src/adapters/mcp-common/json-schema.ts +53 -0
  43. package/src/adapters/mcp-common/server.ts +157 -0
  44. package/src/adapters/opencode/agents.ts +80 -0
  45. package/src/adapters/opencode/dashboard.ts +64 -0
  46. package/src/adapters/opencode/index.ts +34 -0
  47. package/src/adapters/opencode/json-schema.ts +60 -0
  48. package/src/adapters/opencode/skills.ts +19 -0
  49. package/src/adapters/opencode/tools.ts +78 -0
  50. package/src/adapters/plugin-common/index.ts +173 -0
  51. package/src/adapters/zcode/index.ts +32 -0
  52. package/src/core/constants.ts +46 -0
  53. package/src/core/dashboard.ts +101 -0
  54. package/src/core/derive.ts +146 -0
  55. package/src/core/exemptions.ts +168 -0
  56. package/src/core/git.ts +373 -0
  57. package/src/core/index.ts +76 -0
  58. package/src/core/namespace.ts +12 -0
  59. package/src/core/paths.ts +23 -0
  60. package/src/core/provider.ts +74 -0
  61. package/src/core/state.ts +357 -0
  62. package/src/core/task-children.ts +59 -0
  63. package/src/core/tasks-md.ts +89 -0
  64. package/src/core/tools/gate.ts +165 -0
  65. package/src/core/tools/lifecycle.ts +645 -0
  66. package/src/core/tools/schemas.ts +363 -0
  67. package/src/core/tools/submit.ts +863 -0
  68. package/src/core/tools/types.ts +129 -0
  69. package/src/core/types.ts +135 -0
  70. package/src/core/views.ts +195 -0
  71. package/src/core/workflow/collector.ts +150 -0
  72. package/src/core/workflow/engine.ts +576 -0
  73. package/src/core/workflow/index.ts +56 -0
  74. package/src/core/workflow/loader.ts +277 -0
  75. package/src/core/workflow/poller.ts +159 -0
  76. package/src/core/workflow/reset.ts +93 -0
  77. package/src/core/workflow/status.ts +1010 -0
  78. package/src/core/workflow/submit.ts +331 -0
  79. package/src/core/workflow/types.ts +100 -0
  80. package/src/core/workflow/writeback.ts +48 -0
  81. package/src/dashboard/page.ts +19 -0
  82. package/src/index.ts +2 -0
  83. package/src/skills/resolve.ts +95 -0
  84. package/src/skills/scan.ts +29 -0
@@ -0,0 +1,645 @@
1
+ import path from "path"
2
+ import type { TaskItem, TaskStatus } from "../types.ts"
3
+ import { BUILD_PHASE_TARGETS, REVIEW_LAYERS, REVIEW_VERIFY_STEPS } from "../types.ts"
4
+ import { agentToReviewLayer } from "../constants.ts"
5
+ import { runGit, runGitChecked, getCurrentBranch, getMergeBase, isWorktreeClean, mergeBranchToTarget, discoverDiskWorktrees, detectMainRepoPollution, detectChanges, type DetectChangesResult } from "../git.ts"
6
+ import { readStateByWorktree, readStateByChangeId, writeState, writeContextToWorktree } from "../state.ts"
7
+ import { generateIsolationNamespace } from "../namespace.ts"
8
+ import { readExemptions } from "../exemptions.ts"
9
+ import { parseAllTaskGroupsFromMd, parseTasksMdForGroup, extractRelevantSpecsFromTasks } from "../tasks-md.ts"
10
+ import type { ParsedTask } from "../tasks-md.ts"
11
+ import { assertOrchestrator, findTaskGroup } from "../derive.ts"
12
+ import { assertPathWithin } from "../paths.ts"
13
+ import { loadWorkflowFile, TASK_WORKFLOW_PATH, type LoadedWorkflow } from "../workflow/loader.ts"
14
+ import { createInitialWorkItem, isBlockingSeverity, isTerminalPhase, recommendForItem, resetInternalRetryCount, adjudicateStep, clearStepTags } from "../workflow/engine.ts"
15
+ import { renderWorkflowStatusView } from "../workflow/status.ts"
16
+ import { taskChildrenOf } from "../task-children.ts"
17
+ import type { WorkItem, WorkItemPhase } from "../workflow/types.ts"
18
+ import type { InitParams, SetWorktreeParams, UnattendedParams, ToolContext, StatusParams } from "./types.ts"
19
+
20
+ /** 由 tasks.md 解析结果构造 task child WorkItem(初始 todo;externalId 存 taskNumber,id 存数字索引)。 */
21
+ function taskChildFromParsed(p: ParsedTask, index: number): WorkItem {
22
+ const child = createInitialWorkItem({
23
+ id: String(index + 1),
24
+ source: "openspec",
25
+ externalId: p.taskNumber,
26
+ type: "task",
27
+ title: p.title,
28
+ description: p.title,
29
+ })
30
+ child.metadata["specTrace"] = p.specTrace
31
+ child.metadata["taskNumber"] = p.taskNumber
32
+ return child
33
+ }
34
+
35
+ /**
36
+ * 按 tasks.md 同步 task children(整体重建):
37
+ * - 先自愈:现存 task children 重复 id 时合并去重——保留第一出现条目(含进度),丢弃重复条目,
38
+ * 不得因重复抛错(否则封死 recovery=task_analysis 等逃生路径);
39
+ * - 以 parsed 为基准整体重建 task 部分:forceOpen=false 时复用同 id 旧 child(保留 phase/tags/metadata
40
+ * 进度),forceOpen=true 或旧 child 不存在则新建,新建时按 opts.defaultStatus 赋初始 phase;
41
+ * - 移除 tasks.md 已删除的 task child,issue children 不受影响。
42
+ */
43
+ function syncTaskChildren(item: WorkItem, parsed: ParsedTask[], opts: { forceOpen?: boolean; defaultStatus?: WorkItemPhase }): void {
44
+ // 自愈:现存 task children 重复 id 时合并去重(保留第一出现条目含进度),杜绝"保留旧 + 追加新"产生的重复。
45
+ const seen = new Set<string>()
46
+ const deduped: WorkItem[] = []
47
+ for (const c of item.children) {
48
+ if (c.type === "task") {
49
+ if (seen.has(c.id)) continue
50
+ seen.add(c.id)
51
+ }
52
+ deduped.push(c)
53
+ }
54
+ item.children = deduped
55
+
56
+ const prev = new Map(taskChildrenOf(item).map((c) => [c.id, c]))
57
+ const built = parsed.map((p, i) => {
58
+ const id = String(i + 1)
59
+ const old = prev.get(id)
60
+ if (old && !opts.forceOpen) {
61
+ // 复用旧 child 保留进度(phase/tags/metadata),按 parsed 刷新标题/编号等展示字段
62
+ old.title = p.title
63
+ old.description = p.title
64
+ old.externalId = p.taskNumber
65
+ old.metadata["specTrace"] = p.specTrace
66
+ old.metadata["taskNumber"] = p.taskNumber
67
+ return old
68
+ }
69
+ const child = taskChildFromParsed(p, i)
70
+ if (!opts.forceOpen && opts.defaultStatus) child.phase = opts.defaultStatus
71
+ return child
72
+ })
73
+ // 整体替换 task 部分:旧 task child 全部移除,以 parsed 为基准重建;issue children 不受影响。
74
+ item.children = [...item.children.filter((c) => c.type !== "task"), ...built]
75
+ }
76
+
77
+ /** TaskStatus → task child phase 反查(旧 state metadata.tasks 迁移用)。 */
78
+ function taskStatusToPhase(status: TaskStatus): WorkItemPhase {
79
+ switch (status) {
80
+ case "submitted": return "review"
81
+ case "verified": return "done"
82
+ case "rejected": return "todo"
83
+ default: return "todo"
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 旧 state 迁移:metadata.tasks(TaskItem[])→ task children。
89
+ * 无 task child 时按 status 反查 phase 重建并挂入 children;随后删除 metadata.tasks(已废弃)。
90
+ */
91
+ function migrateLegacyTasks(item: WorkItem): void {
92
+ const raw = item.metadata["tasks"]
93
+ if (!Array.isArray(raw)) return
94
+ if (taskChildrenOf(item).length === 0) {
95
+ for (const t of raw as TaskItem[]) {
96
+ const child = createInitialWorkItem({
97
+ id: String(t.id),
98
+ source: "openspec",
99
+ externalId: t.taskNumber || undefined,
100
+ type: "task",
101
+ title: t.title,
102
+ description: t.title,
103
+ })
104
+ child.metadata["specTrace"] = t.specTrace
105
+ if (t.taskNumber) child.metadata["taskNumber"] = t.taskNumber
106
+ if (t.rejectReason) child.metadata["reject_reason"] = t.rejectReason
107
+ child.phase = taskStatusToPhase(t.status)
108
+ item.children.push(child)
109
+ }
110
+ }
111
+ delete item.metadata["tasks"]
112
+ }
113
+
114
+ /**
115
+ * 扫描 review 阶段第一个未全 passed 的 step(按 workflow 声明顺序 verify_tool → verify_task → verify_quality)。
116
+ * 一个 step 全 passed 由 adjudicateStep 判定(该 step 所有 agent 的 tag 均为 passed);全部通过返回 null。
117
+ */
118
+ function firstUnpassedReviewStep(item: WorkItem, workflow: LoadedWorkflow): string | null {
119
+ const review = workflow.phases.find((p) => p.name === "review")
120
+ if (!review) return null
121
+ for (const step of review.steps) {
122
+ if (adjudicateStep(item, step) !== "passed") return step.id
123
+ }
124
+ return null
125
+ }
126
+
127
+ /**
128
+ * 按 recovery 合成活跃组 WorkItem 的 phase/currentStep/tags/task children。
129
+ * - task_analysis:todo/analyze、tags 清空、task children 全 todo(全新开始)
130
+ * - dev_impl:in_progress/implement、analyze 已 passed、task children 保留既有进度(无则 todo)
131
+ * - review:review/verify_*(增量合并——已 passed 的审查标记保留、failed 重置为 pending,currentStep
132
+ * 前移到第一个未全 passed 的子层;review_layer 决定强制前置哪些子层)、analyze+implement 已 passed、
133
+ * task children 保留既有进度(无则 done——review 恢复时子任务应视为已验证,否则 G21 remaining 会让 implement 无法提交)
134
+ */
135
+ function applyRecoveryState(
136
+ item: WorkItem,
137
+ recovery: InitParams["recovery"],
138
+ parsedTasks: ParsedTask[],
139
+ ): void {
140
+ // 恢复重建为已知状态后清除残留推进阻塞原因,避免 orchestrator 视图展示过期信息
141
+ delete item.metadata["_advance_block_reason"]
142
+ // 清除内部重试计数:recovery 恢复后残留 _retryCount 会在下次回退时立即再次触发检查点(死锁)。
143
+ resetInternalRetryCount(item)
144
+ // 清除检查点标记残留:恢复重建为已知状态后 _checkpoint 已无意义(checkpoint 态属于中断中的 step)。
145
+ delete item.metadata["_checkpoint"]
146
+ const phase = recovery?.phase
147
+ if (!phase || phase === "task_analysis") {
148
+ item.phase = "todo"
149
+ item.currentStep = "analyze"
150
+ item.tags = {}
151
+ syncTaskChildren(item, parsedTasks, { forceOpen: true })
152
+ return
153
+ }
154
+ if (phase === "dev_impl") {
155
+ item.phase = "in_progress"
156
+ item.currentStep = "implement"
157
+ item.tags = { "analyze:openspec-architect": "passed" }
158
+ syncTaskChildren(item, parsedTasks, { defaultStatus: "todo" })
159
+ return
160
+ }
161
+ item.phase = "review"
162
+ // 前置层保证:恢复进 review 时 analyze/implement 必然已通过
163
+ item.tags["analyze:openspec-architect"] = "passed"
164
+ item.tags["implement:openspec-developer"] = "passed"
165
+ // 增量合并 review 审查标记:值为 passed 的保留(已审查通过层无需重跑),failed 删除回到 pending
166
+ for (const key of Object.keys(item.tags)) {
167
+ if (key.startsWith("verify_tool:") || key.startsWith("verify_task:") || key.startsWith("verify_quality:")) {
168
+ if (item.tags[key] !== "passed") delete item.tags[key]
169
+ }
170
+ }
171
+ // reset_steps:把指定 verify step 的全部 tags 重置为 pending(删除 tag 键),使 currentStep 落在
172
+ // 第一个未全部通过的 verify step,可能早于被重置的 step——用于已 passed 但被本层遗漏复核/裁定
173
+ // 阻塞的 review step 强制重新审查(与 resetReviewTagsOnFix 删除 tag 的既有语义一致)。
174
+ for (const stepId of recovery?.reset_steps ?? []) {
175
+ clearStepTags(item, stepId)
176
+ }
177
+ // review_layer 强制前置:tool→task 时 verify_tool 强制 passed;quality 时 verify_tool+verify_task 强制 passed
178
+ if (recovery?.review_layer === "task" || recovery?.review_layer === "quality") {
179
+ item.tags["verify_tool:openspec-reviewer-tool"] = "passed"
180
+ }
181
+ if (recovery?.review_layer === "quality") {
182
+ item.tags["verify_task:openspec-reviewer-task"] = "passed"
183
+ }
184
+ // currentStep 前移:显式指向第一个未全 passed 的 review step(已全 passed 的子层跳过)
185
+ const workflow = loadWorkflowFile(TASK_WORKFLOW_PATH)
186
+ item.currentStep = firstUnpassedReviewStep(item, workflow)
187
+ // 全 passed 收口 done:三个 review step 全 passed 时,仅当 task children 全部终态才收口;
188
+ // 存在未终态 task child 则停在 verify_quality(recommendForItem 会返回 blocked 而非 terminal,安全)。
189
+ if (item.currentStep === null) {
190
+ const unfinishedTasks = item.children.filter((child) => child.type === "task" && !isTerminalPhase(child.phase))
191
+ if (unfinishedTasks.length === 0) {
192
+ item.phase = "done"
193
+ } else {
194
+ item.currentStep = "verify_quality"
195
+ }
196
+ }
197
+ syncTaskChildren(item, parsedTasks, { defaultStatus: "done" })
198
+ }
199
+
200
+ /**
201
+ * recovery 参数值域校验(入口显式拒绝,早于任何状态变更):
202
+ * - phase 必须为合法恢复阶段(task_analysis/dev_impl/review),缺失/非法即抛错并列出合法值;
203
+ * - review_layer 必须为合法子层(tool/task/quality),非法即抛错;
204
+ * - review_layer 仅当 phase=review 时允许存在,其余 phase 组合复用既有组合错误消息;
205
+ * - reset_steps 必须为合法 verify step(verify_tool/verify_task/verify_quality),非空数组,
206
+ * 仅当 phase=review 时允许存在,且与 review_layer 互斥(二者都操纵哪些 review step 通过)。
207
+ */
208
+ function assertValidRecovery(recovery: InitParams["recovery"]): void {
209
+ if (recovery === undefined) return
210
+ if (!(BUILD_PHASE_TARGETS as readonly string[]).includes(recovery.phase)) {
211
+ throw new Error(
212
+ `recovery.phase 不合法,合法值:${BUILD_PHASE_TARGETS.join("、")}。传入值:"${String(recovery.phase)}"。`
213
+ )
214
+ }
215
+ if (recovery.review_layer !== undefined && !(REVIEW_LAYERS as readonly string[]).includes(recovery.review_layer)) {
216
+ throw new Error(
217
+ `recovery.review_layer 不合法,合法值:${REVIEW_LAYERS.join("、")}。传入值:"${String(recovery.review_layer)}"。`
218
+ )
219
+ }
220
+ if (recovery.review_layer && recovery.phase !== "review") {
221
+ throw new Error(`review_layer 参数仅当 recovery.phase 为 review 时有效,当前 phase 为 "${recovery.phase}"。`)
222
+ }
223
+ if (recovery.reset_steps !== undefined) {
224
+ if (recovery.phase !== "review") {
225
+ throw new Error(`reset_steps 参数仅当 recovery.phase 为 review 时有效,当前 phase 为 "${recovery.phase}"。`)
226
+ }
227
+ if (recovery.review_layer) {
228
+ throw new Error("reset_steps 与 review_layer 互斥,不可同时使用。")
229
+ }
230
+ if (recovery.reset_steps.length === 0) {
231
+ throw new Error("reset_steps 不能为空数组,请至少指定一个 verify step。")
232
+ }
233
+ for (const stepId of recovery.reset_steps) {
234
+ if (!(REVIEW_VERIFY_STEPS as readonly string[]).includes(stepId)) {
235
+ throw new Error(
236
+ `reset_steps 中的 step "${stepId}" 不合法,合法值:${REVIEW_VERIFY_STEPS.join("、")}。传入值:"${stepId}"。`
237
+ )
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ export async function initExecute(params: InitParams, ctx: ToolContext): Promise<string> {
244
+ assertOrchestrator(ctx, "opx_orch_init")
245
+
246
+ const args = { ...params }
247
+ if (typeof (args as any).recovery === "string") {
248
+ let parsed: unknown
249
+ try { parsed = JSON.parse((args as any).recovery) } catch {
250
+ throw new Error(`recovery 参数解析失败:传入的字符串无法解析为对象。传入值:${(args as any).recovery}`)
251
+ }
252
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
253
+ throw new Error(`recovery 参数解析失败:传入的字符串解析结果不是对象。传入值:${(args as any).recovery}`)
254
+ }
255
+ (args as any).recovery = parsed
256
+ }
257
+ assertValidRecovery(args.recovery)
258
+
259
+ const parsedGroups = await parseAllTaskGroupsFromMd(ctx.worktree, args.change_id)
260
+ if (parsedGroups.length === 0) {
261
+ throw new Error(`无法从 tasks.md 解析出任务组,请检查文件 openspec/changes/${args.change_id}/tasks.md。`)
262
+ }
263
+ const targetGroup = parsedGroups.find((g) => g.id === args.task_group_id)
264
+ if (!targetGroup) {
265
+ throw new Error(
266
+ `task_group_id "${args.task_group_id}" 不在 tasks.md 中。\n可用 ID:\n` +
267
+ `- ${parsedGroups.map((g) => g.id).join("\n- ")}`
268
+ )
269
+ }
270
+
271
+ // 逐任务组解析 tasks.md 子任务(构造各 task WorkItem 的 task children 用)
272
+ const tasksByGroup = new Map<string, ParsedTask[]>()
273
+ for (const g of parsedGroups) {
274
+ tasksByGroup.set(g.id, await parseTasksMdForGroup(ctx.worktree, args.change_id, g.id))
275
+ }
276
+
277
+ // base_branch 是 ref 而非严格 branch:只做非空 + 无空白字符等基本检查(完整分支名校验由 git check-ref-format 承担)
278
+ if (args.base_branch) {
279
+ if (!args.base_branch.trim() || /\s/.test(args.base_branch)) {
280
+ throw new Error(`base_branch 不合法:"${args.base_branch}"。基准分支名不能为空或包含空白字符。`)
281
+ }
282
+ }
283
+ const baseBranch = args.base_branch || await getCurrentBranch(ctx.worktree)
284
+ let state = await readStateByChangeId(ctx.worktree, args.change_id)
285
+ const wasCurrentGroup = state?.taskGroupId === args.task_group_id
286
+
287
+ if (!state) {
288
+ state = {
289
+ changeId: args.change_id,
290
+ isolationNamespace: generateIsolationNamespace(args.change_id),
291
+ taskGroupId: args.task_group_id,
292
+ baseBranch,
293
+ workItems: [],
294
+ createdAt: new Date().toISOString(),
295
+ updatedAt: new Date().toISOString(),
296
+ }
297
+ } else {
298
+ state.baseBranch = state.baseBranch || baseBranch
299
+ state.isolationNamespace = state.isolationNamespace || generateIsolationNamespace(state.changeId)
300
+ }
301
+
302
+ // 按 tasks.md 构造全部任务组的 task WorkItem(单轨:workItems 为唯一事实源)
303
+ for (const group of parsedGroups) {
304
+ const isCurrent = group.id === args.task_group_id
305
+ const groupTasks = tasksByGroup.get(group.id) ?? []
306
+ const existing = state.workItems.find((w) => w.id === `task:${group.id}`)
307
+ const refreshMeta = (item: WorkItem): void => {
308
+ item.metadata["name"] = group.name
309
+ item.metadata["task_count"] = group.taskCount
310
+ item.metadata["source"] = "openspec"
311
+ item.metadata["relevant_specs"] = extractRelevantSpecsFromTasks(groupTasks)
312
+ }
313
+
314
+ // 旧 state 迁移:metadata.tasks(TaskItem[])→ task children(挂入 children 后删除 metadata.tasks)
315
+ if (existing) migrateLegacyTasks(existing)
316
+
317
+ if (!isCurrent) {
318
+ // 非活跃组:已有则保留进度(仅刷新名称/计数),否则新建
319
+ if (existing) {
320
+ refreshMeta(existing)
321
+ } else {
322
+ const item = createInitialWorkItem({
323
+ id: `task:${group.id}`,
324
+ source: "openspec",
325
+ externalId: group.id,
326
+ type: "task",
327
+ title: group.name,
328
+ description: group.name,
329
+ labels: ["openspec-change"],
330
+ })
331
+ item.currentStep = "analyze"
332
+ syncTaskChildren(item, groupTasks, { defaultStatus: "todo" })
333
+ refreshMeta(item)
334
+ state.workItems.push(item)
335
+ }
336
+ continue
337
+ }
338
+
339
+ // 活跃组:无 recovery 重复初始化当前组 → 保留进度并刷新 task children
340
+ // (按 parsed 数量/标题做一致性重建,复用既有 children 进度,顺带自愈重复 id 的已损坏 state)
341
+ if (existing && !args.recovery && wasCurrentGroup) {
342
+ refreshMeta(existing)
343
+ syncTaskChildren(existing, groupTasks, {})
344
+ // 与 recovery 路径一致:重建为已知状态后清除残留推进阻塞原因,避免视图展示过期信息
345
+ delete existing.metadata["_advance_block_reason"]
346
+ continue
347
+ }
348
+
349
+ const item = existing ?? createInitialWorkItem({
350
+ id: `task:${group.id}`,
351
+ source: "openspec",
352
+ externalId: group.id,
353
+ type: "task",
354
+ title: group.name,
355
+ description: group.name,
356
+ labels: ["openspec-change"],
357
+ })
358
+
359
+ // reopenIssues:已完成组继续修 issue(children 未终态置 todo + reject_reason、清 verify_* tags)
360
+ if (args.recovery?.reopenIssues) {
361
+ const closed = item.metadata["completed_at"] !== undefined || item.phase === "done"
362
+ if (!closed) {
363
+ throw new Error(`reopenIssues 仅支持已完成(completed)的任务组,当前 item.phase="${item.phase}"。`)
364
+ }
365
+ if (args.recovery.phase !== "dev_impl") {
366
+ throw new Error("reopenIssues 仅支持恢复到 dev_impl 阶段。")
367
+ }
368
+ if (args.recovery.review_layer) {
369
+ throw new Error("reopenIssues 与 review_layer 互斥,不可同时使用。")
370
+ }
371
+ for (const child of item.children) {
372
+ if (isTerminalPhase(child.phase)) continue
373
+ child.phase = "todo"
374
+ child.metadata["reject_reason"] = child.metadata["reject_reason"] ?? "通过 reopenIssues 自动驳回"
375
+ }
376
+ for (const key of Object.keys(item.tags)) {
377
+ if (key.startsWith("verify_")) delete item.tags[key]
378
+ }
379
+ delete item.metadata["completed_at"]
380
+ item.metadata["worktree_path"] = null
381
+ item.metadata["branch_name"] = null
382
+ item.metadata["base_ref"] = null
383
+ }
384
+
385
+ applyRecoveryState(item, args.recovery, groupTasks)
386
+ refreshMeta(item)
387
+ if (!existing) state.workItems.push(item)
388
+ }
389
+
390
+ state.taskGroupId = args.task_group_id
391
+ await writeState(ctx.worktree, state)
392
+
393
+ return args.recovery
394
+ ? `编排会话已初始化。已恢复到 ${args.recovery.phase} 阶段。`
395
+ : "编排会话已初始化。"
396
+ }
397
+
398
+ async function bindWorktreeRefs(
399
+ item: WorkItem,
400
+ worktreePath: string,
401
+ branch: string,
402
+ baseBranch: string,
403
+ opts: { requireBaseRef?: boolean } = {},
404
+ ): Promise<void> {
405
+ item.metadata["worktree_path"] = worktreePath
406
+ item.metadata["branch_name"] = branch
407
+ const baseRef = await getMergeBase(worktreePath, baseBranch)
408
+ if (!baseRef) {
409
+ if (opts.requireBaseRef) {
410
+ throw new Error(`worktree 创建成功但无法获取与 ${baseBranch} 的 merge-base:${worktreePath}`)
411
+ }
412
+ return
413
+ }
414
+ item.metadata["base_ref"] = baseRef
415
+ }
416
+
417
+ export async function setWorktreeExecute(params: SetWorktreeParams, ctx: ToolContext): Promise<string> {
418
+ assertOrchestrator(ctx, "opx_orch_set_worktree")
419
+ const state = await readStateByWorktree(ctx.worktree, params.change_id)
420
+ if (!state) throw new Error("编排会话未初始化。请先调用 opx_orch_init。")
421
+ const item = state.workItems.find((w) => w.id === `task:${state.taskGroupId}`)
422
+ if (!item) throw new Error(`工作项 "task:${state.taskGroupId}" 缺失,请重新调用 opx_orch_init。`)
423
+
424
+ const repoRoot = ctx.worktree
425
+ // branch_name 可为空(缺省自动生成分支名),仅显式传入时用 git check-ref-format 严格校验。
426
+ // 用 --branch 形态(而非 refs/heads/<name>):前者拒绝前导 `-` 等 git branch 创建亦拒绝的非法分支名,
427
+ // 后者仅检查 refname 合法性,会放行前导 dash 的 plain ref。
428
+ const rawBranch = params.branch_name ?? ""
429
+ if (rawBranch !== "") {
430
+ const check = await runGitChecked(repoRoot, ["check-ref-format", "--branch", rawBranch])
431
+ if (!check.success) {
432
+ throw new Error(`分支名 "${rawBranch}" 不合法,请修正后重试。`)
433
+ }
434
+ }
435
+ const branch = rawBranch || `task-group/${state.changeId}/${state.taskGroupId}`
436
+ let wtPath: string
437
+ if (params.worktree_path) {
438
+ wtPath = assertPathWithin(repoRoot, params.worktree_path, "worktree_path")
439
+ } else {
440
+ wtPath = path.join(repoRoot, ".worktree", state.changeId, `task-group-${state.taskGroupId}`)
441
+ }
442
+
443
+ const changeStatus = await runGit(repoRoot, ["status", "--porcelain", `openspec/changes/${state.changeId}/`])
444
+ if (changeStatus.trim().length > 0) {
445
+ const addResult = await runGitChecked(repoRoot, ["add", `openspec/changes/${state.changeId}/`])
446
+ if (!addResult.success) throw new Error(`change 目录 git add 失败:${addResult.stderr}`)
447
+ const commitResult = await runGitChecked(repoRoot, ["commit", "-m", "docs(openspec): auto-commit before worktree setup"])
448
+ if (!commitResult.success) throw new Error(`change 目录 git commit 失败:${commitResult.stderr}`)
449
+ }
450
+
451
+ const wtList = await runGit(repoRoot, ["worktree", "list"])
452
+ const existingLine = wtList.split("\n").find((l) => {
453
+ const m = l.match(/^(\S+)\s+[0-9a-f]+\s+\[(.+?)\]/)
454
+ return m && m[2].trim() === branch
455
+ })
456
+ const existingPath = existingLine ? existingLine.match(/^(\S+)/)?.[1] : undefined
457
+
458
+ let reused = false
459
+ if (existingPath) {
460
+ const baseHead = await runGit(repoRoot, ["rev-parse", state.baseBranch])
461
+ const mergeResult = await runGitChecked(existingPath, ["merge", "--ff-only", baseHead])
462
+ if (mergeResult.success) {
463
+ await bindWorktreeRefs(item, existingPath, branch, state.baseBranch)
464
+ reused = true
465
+ } else {
466
+ const clean = await isWorktreeClean(existingPath)
467
+ if (!clean) {
468
+ throw new Error(
469
+ `已有 worktree "${existingPath}" 与 ${state.baseBranch} 分叉且有未提交变更,无法自动 fast-forward。\n` +
470
+ `请手动处理后重试。`
471
+ )
472
+ }
473
+ const localCommitCount = parseInt(
474
+ await runGit(existingPath, ["rev-list", "--count", `${state.baseBranch}..HEAD`]),
475
+ 10
476
+ )
477
+ if (localCommitCount > 0 || Number.isNaN(localCommitCount)) {
478
+ await bindWorktreeRefs(item, existingPath, branch, state.baseBranch)
479
+ reused = true
480
+ } else {
481
+ const rmResult = await runGitChecked(repoRoot, ["worktree", "remove", existingPath, "--force"])
482
+ if (!rmResult.success) {
483
+ throw new Error(`无法清理已有 worktree "${existingPath}":${rmResult.stderr}`)
484
+ }
485
+ const branchRmResult = await runGitChecked(repoRoot, ["branch", "-D", branch])
486
+ if (!branchRmResult.success) {
487
+ throw new Error(`无法清理已有分支 "${branch}":${branchRmResult.stderr}`)
488
+ }
489
+ }
490
+ }
491
+ }
492
+
493
+ if (!reused) {
494
+ const forkBranch = state.baseBranch
495
+ await runGit(repoRoot, ["worktree", "add", "-b", branch, wtPath, forkBranch])
496
+ await bindWorktreeRefs(item, wtPath, branch, forkBranch, { requireBaseRef: true })
497
+ }
498
+
499
+ await writeState(ctx.worktree, state)
500
+
501
+ // 在 worktree 中写入上下文指针,供 worktree 内 session 读取 state
502
+ const storedPath = typeof item.metadata["worktree_path"] === "string" ? item.metadata["worktree_path"] : null
503
+ if (storedPath) {
504
+ await writeContextToWorktree(storedPath, state.changeId, state.taskGroupId)
505
+ }
506
+
507
+ return [
508
+ `- **状态**: ${reused ? "复用已有 worktree" : "已创建 worktree"}`,
509
+ `- **路径**: \`${item.metadata["worktree_path"]}\``,
510
+ `- **分支**: \`${branch}\``,
511
+ ].join("\n")
512
+ }
513
+
514
+ export async function statusExecute(params: StatusParams, ctx: ToolContext): Promise<string> {
515
+ const agent = ctx.agent
516
+ const state = await readStateByWorktree(ctx.worktree, params.change_id)
517
+
518
+ if (!state) {
519
+ if (ctx.orchestrator) {
520
+ const diskWts = await discoverDiskWorktrees(ctx.worktree)
521
+ if (diskWts.length > 0) {
522
+ const lines = ["# 编排进度", "", "**状态文件**: 未初始化", "", "## 磁盘 Worktree(可恢复进度)", ""]
523
+ lines.push("| 分支 | 路径 |")
524
+ lines.push("|------|------|")
525
+ for (const w of diskWts) lines.push(`| ${w.branch} | \`${w.path}\` |`)
526
+ lines.push("")
527
+ lines.push("请用 question 工具询问用户确认恢复目标,然后调用 opx_orch_init(recovery=...)。")
528
+ return lines.join("\n")
529
+ }
530
+ }
531
+ return "编排会话尚未初始化。请先调用 opx_orch_init。"
532
+ }
533
+
534
+ const item = state.workItems.find((w) => w.id === `task:${state.taskGroupId}`)
535
+ if (!item) {
536
+ return "编排会话未就绪:找不到活跃任务组的工作项,请重新调用 opx_orch_init。"
537
+ }
538
+
539
+ // 单轨:一律由工作流引擎推荐(recommendForItem)渲染动态视图,按调用者角色分流
540
+ const workflow = loadWorkflowFile(TASK_WORKFLOW_PATH)
541
+ const rec = recommendForItem(item, workflow)
542
+ const tg = findTaskGroup(state, state.taskGroupId)
543
+ // 主仓库 openspec 污染诊断(56ddfe9 意图):编排者分派视图展示主仓库污染,供编排者人工核对
544
+ const mainPollution = ctx.orchestrator ? await detectMainRepoPollution(ctx.worktree) : null
545
+ // tool review 检查点增量检测(A4):verify_tool 的 reviewer-tool 工作视图按「检查点 → 当前 HEAD」区间
546
+ // 变更分流(直提 / 仅处理待复核项 / 全量)。渲染层为同步函数,此处预计算后经 WorkflowStatusViewOptions 传入。
547
+ // 仅在推荐分派该 agent 时计算,其余角色/step 不产生额外 git 调用。
548
+ let toolChanges: DetectChangesResult | undefined
549
+ if (
550
+ agentToReviewLayer(agent) === "tool" &&
551
+ rec.status === "recommend" &&
552
+ rec.stepId === "verify_tool" &&
553
+ rec.agents.includes(agent)
554
+ ) {
555
+ const wtPath = typeof item.metadata["worktree_path"] === "string" ? item.metadata["worktree_path"] : undefined
556
+ if (wtPath) {
557
+ const checkpoint =
558
+ typeof item.metadata["_tool_review_checkpoint"] === "string" ? item.metadata["_tool_review_checkpoint"] : undefined
559
+ const baseRef = typeof item.metadata["base_ref"] === "string" ? item.metadata["base_ref"] : undefined
560
+ toolChanges = await detectChanges(wtPath, { checkpoint, baseRef })
561
+ }
562
+ }
563
+ // 统计本 change 命中项目级跨 change 豁免清单的存量问题数(工具层降级时写入 exempted_hit 标记)
564
+ const exemptedHits = item.children.filter((c) => c.type === "issue" && c.metadata["exempted_hit"] !== undefined).length
565
+ // 渲染期豁免清单提示数据源:渲染前异步读取一次(renderChildIssue 为同步函数,清单条目以参数传入,不在循环内重复读)
566
+ const exemptionItems = (await readExemptions(ctx.worktree)).items
567
+ return renderWorkflowStatusView(item, workflow, rec, { agent, orchestrator: ctx.orchestrator, identityDeclared: ctx.identityDeclared }, { state, tg, mainPollution, toolChanges, exemptedHits, exemptionItems })
568
+ }
569
+
570
+ export async function completeTaskGroupExecute(params: { change_id: string }, ctx: ToolContext): Promise<string> {
571
+ assertOrchestrator(ctx, "opx_orch_complete_task_group")
572
+ const state = await readStateByWorktree(ctx.worktree, params.change_id)
573
+ if (!state) throw new Error("编排会话未初始化。请先调用 opx_orch_init。")
574
+ const item = state.workItems.find((w) => w.id === `task:${state.taskGroupId}`)
575
+ if (!item) throw new Error(`工作项 "task:${state.taskGroupId}" 缺失,请重新调用 opx_orch_init。`)
576
+
577
+ if (item.phase !== "done" || item.metadata["completed_at"] !== undefined) {
578
+ throw new Error(
579
+ `阶段顺序错误:opx_orch_complete_task_group 需在 review 完成后调用。\n` +
580
+ `- item.phase=${item.phase}\n` +
581
+ `- completed_at=${item.metadata["completed_at"] ?? "(未设置)"}`
582
+ )
583
+ }
584
+
585
+ const worktreePath = typeof item.metadata["worktree_path"] === "string" ? item.metadata["worktree_path"] : null
586
+ const branchName = typeof item.metadata["branch_name"] === "string" ? item.metadata["branch_name"] : null
587
+
588
+ if (worktreePath) {
589
+ const clean = await isWorktreeClean(worktreePath)
590
+ if (!clean) throw new Error(`worktree "${worktreePath}" 存在未 commit 内容,请先 commit 再完成任务组。`)
591
+ }
592
+
593
+ const openIssues = item.children.filter((c) => isBlockingSeverity(c.severity) && !isTerminalPhase(c.phase))
594
+ if (openIssues.length > 0) {
595
+ throw new Error(`存在 ${openIssues.length} 个 Low 及以上的未解决 issue 未处理,请先修复或申请豁免。`)
596
+ }
597
+
598
+ // task children 须全部终态(done/cancelled)才能收尾
599
+ const openTasks = taskChildrenOf(item).filter((c) => !isTerminalPhase(c.phase))
600
+ if (openTasks.length > 0) {
601
+ throw new Error(`存在 ${openTasks.length} 个未完成 task。`)
602
+ }
603
+
604
+ const blockers = Array.isArray(item.metadata["blockers"])
605
+ ? (item.metadata["blockers"] as { status: string }[])
606
+ : []
607
+ const unresolvedBlockers = blockers.filter((blocker) => blocker.status !== "resolved")
608
+ if (unresolvedBlockers.length > 0) {
609
+ throw new Error(`存在 ${unresolvedBlockers.length} 个未解决 blocker,无法完成任务组。`)
610
+ }
611
+
612
+ const mergeTarget = state.baseBranch
613
+ if (branchName) {
614
+ const mergeResult = await mergeBranchToTarget(ctx.worktree, branchName, mergeTarget)
615
+ if (!mergeResult.success) {
616
+ return [
617
+ `- **status**: blocked`,
618
+ `- **merge_conflict**: true`,
619
+ `- **说明**: 合并到 "${mergeTarget}" 时发生冲突,已中止合并。`,
620
+ `- **处理**: 请手动在目标分支解决冲突后完成合并 (git merge ${branchName}),完成后重新调 opx_orch_complete_task_group 完成收尾。worktree 与分支已保留。`,
621
+ ].join("\n")
622
+ }
623
+ }
624
+ if (worktreePath && branchName) {
625
+ try {
626
+ await runGit(ctx.worktree, ["worktree", "remove", worktreePath, "--force"])
627
+ await runGit(ctx.worktree, ["branch", "-D", branchName])
628
+ } catch {
629
+ }
630
+ }
631
+ item.metadata["completed_at"] = new Date().toISOString()
632
+ await writeState(ctx.worktree, state)
633
+ return `任务组已完成并合并到 "${mergeTarget}"。`
634
+ }
635
+
636
+ export async function setUnattendedExecute(params: UnattendedParams, ctx: ToolContext): Promise<string> {
637
+ assertOrchestrator(ctx, "opx_orch_set_unattended")
638
+ const state = await readStateByWorktree(ctx.worktree, params.change_id)
639
+ if (!state) throw new Error("编排会话未初始化。请先调用 opx_orch_init。")
640
+ // enabled 缺省按 schema 声明 default=true 兜底(跨形态一致:opencode 直载透传 input 不应用 zod default)
641
+ state.unattended = params.enabled ?? true
642
+ await writeState(ctx.worktree, state)
643
+ const status = state.unattended ? "开启" : "关闭"
644
+ return `无人值守模式已 **${status}**。启用后系统将自动处理决策点,不再 question 用户。`
645
+ }