@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.
- package/README.md +91 -0
- package/assets/agents/openspec-architect.md +61 -0
- package/assets/agents/openspec-developer.md +21 -0
- package/assets/agents/openspec-main.md +35 -0
- package/assets/agents/openspec-reviewer-architecture.md +60 -0
- package/assets/agents/openspec-reviewer-maintainability.md +59 -0
- package/assets/agents/openspec-reviewer-performance.md +49 -0
- package/assets/agents/openspec-reviewer-security.md +51 -0
- package/assets/agents/openspec-reviewer-style.md +50 -0
- package/assets/agents/openspec-reviewer-task.md +40 -0
- package/assets/agents/openspec-reviewer-tool.md +30 -0
- package/assets/dashboard/index.html +295 -0
- package/assets/skills/api-conventions/SKILL.md +60 -0
- package/assets/skills/api-test/SKILL.md +175 -0
- package/assets/skills/architecture-evolution/SKILL.md +60 -0
- package/assets/skills/db-conventions/SKILL.md +62 -0
- package/assets/skills/ddd-architecture/SKILL.md +261 -0
- package/assets/skills/environment-cleanup/SKILL.md +44 -0
- package/assets/skills/java-api-conventions/SKILL.md +30 -0
- package/assets/skills/java-code-maintainability/SKILL.md +62 -0
- package/assets/skills/java-code-performance/SKILL.md +49 -0
- package/assets/skills/java-code-style/SKILL.md +54 -0
- package/assets/skills/java-db-conventions/SKILL.md +44 -0
- package/assets/skills/java-ddd-architecture/SKILL.md +533 -0
- package/assets/skills/java-dev-practices/SKILL.md +110 -0
- package/assets/skills/java-quality-gate/SKILL.md +455 -0
- package/assets/skills/java-quality-gate/reference/pmd-rules.md +16 -0
- package/assets/skills/java-quality-tool-improve/SKILL.md +142 -0
- package/assets/skills/java-security/SKILL.md +59 -0
- package/assets/skills/java-technical-debt/SKILL.md +36 -0
- package/assets/skills/orchestrator/SKILL.md +65 -0
- package/assets/skills/quality-gate/SKILL.md +84 -0
- package/assets/skills/security-baseline/SKILL.md +101 -0
- package/assets/skills/technical-debt/SKILL.md +78 -0
- package/assets/workflows/task.yaml +177 -0
- package/package.json +79 -0
- package/src/adapters/agent-md.ts +37 -0
- package/src/adapters/claude-code/index.ts +35 -0
- package/src/adapters/codex/index.ts +161 -0
- package/src/adapters/mcp-common/cli.ts +64 -0
- package/src/adapters/mcp-common/index.ts +137 -0
- package/src/adapters/mcp-common/json-schema.ts +53 -0
- package/src/adapters/mcp-common/server.ts +157 -0
- package/src/adapters/opencode/agents.ts +80 -0
- package/src/adapters/opencode/dashboard.ts +64 -0
- package/src/adapters/opencode/index.ts +34 -0
- package/src/adapters/opencode/json-schema.ts +60 -0
- package/src/adapters/opencode/skills.ts +19 -0
- package/src/adapters/opencode/tools.ts +78 -0
- package/src/adapters/plugin-common/index.ts +173 -0
- package/src/adapters/zcode/index.ts +32 -0
- package/src/core/constants.ts +46 -0
- package/src/core/dashboard.ts +101 -0
- package/src/core/derive.ts +146 -0
- package/src/core/exemptions.ts +168 -0
- package/src/core/git.ts +373 -0
- package/src/core/index.ts +76 -0
- package/src/core/namespace.ts +12 -0
- package/src/core/paths.ts +23 -0
- package/src/core/provider.ts +74 -0
- package/src/core/state.ts +357 -0
- package/src/core/task-children.ts +59 -0
- package/src/core/tasks-md.ts +89 -0
- package/src/core/tools/gate.ts +165 -0
- package/src/core/tools/lifecycle.ts +645 -0
- package/src/core/tools/schemas.ts +363 -0
- package/src/core/tools/submit.ts +863 -0
- package/src/core/tools/types.ts +129 -0
- package/src/core/types.ts +135 -0
- package/src/core/views.ts +195 -0
- package/src/core/workflow/collector.ts +150 -0
- package/src/core/workflow/engine.ts +576 -0
- package/src/core/workflow/index.ts +56 -0
- package/src/core/workflow/loader.ts +277 -0
- package/src/core/workflow/poller.ts +159 -0
- package/src/core/workflow/reset.ts +93 -0
- package/src/core/workflow/status.ts +1010 -0
- package/src/core/workflow/submit.ts +331 -0
- package/src/core/workflow/types.ts +100 -0
- package/src/core/workflow/writeback.ts +48 -0
- package/src/dashboard/page.ts +19 -0
- package/src/index.ts +2 -0
- package/src/skills/resolve.ts +95 -0
- package/src/skills/scan.ts +29 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import { mkdirSync, rmSync, statSync, readFileSync, writeFileSync, existsSync, readdirSync, copyFileSync, cpSync } from "node:fs"
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
4
|
+
import type { OrchestrateState, TaskGroupState, Phase, IssueItem } from "./types.ts"
|
|
5
|
+
import type { WorkItem, WorkItemPhase, Severity } from "./workflow/types.ts"
|
|
6
|
+
import { createInitialWorkItem } from "./workflow/engine.ts"
|
|
7
|
+
import { EXEMPT_REQUEST_KEY } from "./workflow/submit.ts"
|
|
8
|
+
import { STATE_DIR_NAME, STATE_SUBDIR_NAME, LEGACY_STATE_DIR_NAME, LEGACY_STATE_SUBDIR_NAME } from "./constants.ts"
|
|
9
|
+
import { DIMENSION_AGENT_MAP } from "./constants.ts"
|
|
10
|
+
import { discoverRepoRoot } from "./git.ts"
|
|
11
|
+
import { generateIsolationNamespace } from "./namespace.ts"
|
|
12
|
+
|
|
13
|
+
const LOCK_POLL_INTERVAL_MS = 50
|
|
14
|
+
const LOCK_META_FILENAME = "meta.json"
|
|
15
|
+
const LOCK_STALE_THRESHOLD_MS = 10000
|
|
16
|
+
|
|
17
|
+
function isWorktreePath(worktree: string): boolean {
|
|
18
|
+
const gitPath = path.join(worktree, ".git")
|
|
19
|
+
try {
|
|
20
|
+
return statSync(gitPath).isFile()
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getStateDir(worktree: string): string {
|
|
27
|
+
return path.join(worktree, STATE_DIR_NAME, STATE_SUBDIR_NAME)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 旧布局 .opencode/.orchestrate_state(读取兼容与迁移源)。 */
|
|
31
|
+
export function getLegacyStateDir(worktree: string): string {
|
|
32
|
+
return path.join(worktree, LEGACY_STATE_DIR_NAME, LEGACY_STATE_SUBDIR_NAME)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Worktree 内用于存储 changeId/taskGroupId 指针的上下文文件(相对于 worktree 根) */
|
|
36
|
+
export const WORKTREE_CONTEXT_FILE = "openspec/states/context.json"
|
|
37
|
+
/** 旧布局 worktree 上下文指针(读取兼容)。 */
|
|
38
|
+
export const WORKTREE_CONTEXT_FILE_LEGACY = ".opencode/.orchestrate_state/context.json"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 幂等迁移:旧 .opencode/.orchestrate_state/ 数据(主状态/exemptions/锁/context.json 指针)→
|
|
42
|
+
* openspec/states/。新目录已存在(含空目录)即视为已迁移,重复调用无副作用;
|
|
43
|
+
* 迁移只拷贝不删除旧文件,旧布局在迁移期间仍可读(双读兼容)。
|
|
44
|
+
*/
|
|
45
|
+
export function migrateLegacyStateDir(root: string): void {
|
|
46
|
+
const legacyDir = getLegacyStateDir(root)
|
|
47
|
+
if (!existsSync(legacyDir)) return
|
|
48
|
+
const newDir = getStateDir(root)
|
|
49
|
+
if (existsSync(newDir)) return
|
|
50
|
+
mkdirSync(newDir, { recursive: true })
|
|
51
|
+
for (const entry of readdirSync(legacyDir, { withFileTypes: true })) {
|
|
52
|
+
const src = path.join(legacyDir, entry.name)
|
|
53
|
+
const dst = path.join(newDir, entry.name)
|
|
54
|
+
try {
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
cpSync(src, dst, { recursive: true })
|
|
57
|
+
} else {
|
|
58
|
+
copyFileSync(src, dst)
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// 单文件迁移失败不阻塞整体迁移(其余条目继续)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 从 worktree 中读取上下文指针(changeId + taskGroupId)。
|
|
68
|
+
* 新布局 openspec/states/context.json 优先,旧 .opencode/.orchestrate_state/context.json 双读兼容。
|
|
69
|
+
* 文件不存在时返回 null。
|
|
70
|
+
*/
|
|
71
|
+
export async function readContextFromWorktree(worktreePath: string): Promise<{ changeId: string; taskGroupId: string } | null> {
|
|
72
|
+
const fp = path.join(worktreePath, WORKTREE_CONTEXT_FILE)
|
|
73
|
+
const fpLegacy = path.join(worktreePath, WORKTREE_CONTEXT_FILE_LEGACY)
|
|
74
|
+
for (const p of [fp, fpLegacy]) {
|
|
75
|
+
try {
|
|
76
|
+
const raw = await readFile(p, "utf-8")
|
|
77
|
+
return JSON.parse(raw) as { changeId: string; taskGroupId: string }
|
|
78
|
+
} catch {
|
|
79
|
+
// 该布局无指针,尝试下一个
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 向 worktree 写入上下文指针(changeId + taskGroupId)。
|
|
87
|
+
* 自动创建所需目录;首次写入新布局前把旧布局指针幂等迁移到新位置。
|
|
88
|
+
*/
|
|
89
|
+
export async function writeContextToWorktree(worktreePath: string, changeId: string, taskGroupId: string): Promise<void> {
|
|
90
|
+
const fp = path.join(worktreePath, WORKTREE_CONTEXT_FILE)
|
|
91
|
+
const fpLegacy = path.join(worktreePath, WORKTREE_CONTEXT_FILE_LEGACY)
|
|
92
|
+
if (!existsSync(fp) && existsSync(fpLegacy)) {
|
|
93
|
+
migrateLegacyStateDir(worktreePath)
|
|
94
|
+
}
|
|
95
|
+
const dir = path.dirname(fp)
|
|
96
|
+
mkdirSync(dir, { recursive: true })
|
|
97
|
+
await writeFile(fp, JSON.stringify({ changeId, taskGroupId }, null, 2))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getStatePath(worktree: string, changeId: string): string {
|
|
101
|
+
return path.join(getStateDir(worktree), `${changeId}.json`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function readStateByWorktree(worktree: string, changeId?: string): Promise<OrchestrateState | null> {
|
|
105
|
+
if (isWorktreePath(worktree)) {
|
|
106
|
+
const ctx = await readContextFromWorktree(worktree)
|
|
107
|
+
if (!ctx) return null
|
|
108
|
+
const repoRoot = await discoverRepoRoot(worktree)
|
|
109
|
+
return readStateByChangeId(repoRoot, ctx.changeId)
|
|
110
|
+
}
|
|
111
|
+
if (changeId) {
|
|
112
|
+
return readStateByChangeId(worktree, changeId)
|
|
113
|
+
}
|
|
114
|
+
// 非 worktree 且未传 changeId:无可用编排上下文,与「无可用编排会话」语义一致,返回 null 兜底
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── 旧格式读兼容迁移:taskGroups → workItems(单轨化后 taskGroups 不再持久化)───
|
|
119
|
+
// 以下映射仅服务于旧格式状态文件升级为 workItems 的迁移入口,非双轨同步逻辑。
|
|
120
|
+
// 升级后 taskGroups 字段不再写回,workItems 为唯一事实源。
|
|
121
|
+
|
|
122
|
+
/** 旧 tg.status → WorkItem phase(迁移用) */
|
|
123
|
+
function taskGroupStatusToPhase(status: Phase): WorkItemPhase {
|
|
124
|
+
switch (status) {
|
|
125
|
+
case "task_analysis": return "todo"
|
|
126
|
+
case "dev_impl": return "in_progress"
|
|
127
|
+
case "review": return "review"
|
|
128
|
+
case "completed": return "done"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 旧 tg.status → WorkItem currentStep(迁移用,各阶段入口 step) */
|
|
133
|
+
function taskGroupStatusToStep(status: Phase): string | null {
|
|
134
|
+
switch (status) {
|
|
135
|
+
case "task_analysis": return "analyze"
|
|
136
|
+
case "dev_impl": return "implement"
|
|
137
|
+
case "review": return "verify_tool"
|
|
138
|
+
case "completed": return null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 旧 issue status → child phase(迁移用:verified→done、exempted→cancelled、submitted→review,其余→todo) */
|
|
143
|
+
function issueStatusToChildPhase(status: IssueItem["status"]): WorkItemPhase {
|
|
144
|
+
switch (status) {
|
|
145
|
+
case "verified": return "done"
|
|
146
|
+
case "exempted": return "cancelled"
|
|
147
|
+
case "submitted": return "review"
|
|
148
|
+
default: return "todo"
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 由旧 issue 归因(sourcePhase+dimension)反推真实报源审查 agent(迁移 metadata.source 用,兼 exempt_request.requestedBy 兜底)。 */
|
|
153
|
+
function issueReporterAgent(issue: IssueItem): string {
|
|
154
|
+
if (issue.sourcePhase === "quality") return DIMENSION_AGENT_MAP[issue.dimension] ?? "openspec-reviewer-tool"
|
|
155
|
+
if (issue.sourcePhase === "task") return "openspec-reviewer-task"
|
|
156
|
+
return "openspec-reviewer-tool"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 由 issue 构造 issue 类型 WorkItem child。 */
|
|
160
|
+
export function buildIssueWorkItem(issue: IssueItem): WorkItem {
|
|
161
|
+
const reporterAgent = issueReporterAgent(issue)
|
|
162
|
+
const child = createInitialWorkItem({
|
|
163
|
+
id: `issue:${issue.id}`,
|
|
164
|
+
source: reporterAgent,
|
|
165
|
+
externalId: issue.id,
|
|
166
|
+
type: "issue",
|
|
167
|
+
title: issue.description,
|
|
168
|
+
description: issue.description,
|
|
169
|
+
severity: issue.severity as Severity,
|
|
170
|
+
})
|
|
171
|
+
child.phase = issueStatusToChildPhase(issue.status)
|
|
172
|
+
// 迁移时写入真实报源 agent 到 metadata.source:新流报源层(tool/task/quality)由 source 反推,
|
|
173
|
+
// 无 source 的迁移 issue 无法归层/裁定(原 workflow/submit.ts 报错路径)。source_phase 保留透传作
|
|
174
|
+
// 历史兼容兜底(消费端仅认 source 反推,见 reviewLayerFromMetadata)。
|
|
175
|
+
child.metadata["source"] = reporterAgent
|
|
176
|
+
child.metadata["source_phase"] = issue.sourcePhase
|
|
177
|
+
child.metadata["dimension"] = issue.dimension
|
|
178
|
+
child.metadata["file"] = issue.file
|
|
179
|
+
child.metadata["line"] = issue.line
|
|
180
|
+
if (issue.status === "exemption_requested") {
|
|
181
|
+
// 迁移时保留豁免申请语义:exempt_request 标记使 sync 回写保持 exemption_requested,
|
|
182
|
+
// 避免 child 落 todo 后被覆写回 open 丢失豁免申请。
|
|
183
|
+
child.metadata[EXEMPT_REQUEST_KEY] = {
|
|
184
|
+
requestedBy: reporterAgent,
|
|
185
|
+
reason: issue.exemptReason,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return child
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 由 taskGroup 构造 task 类型 WorkItem(含其 issue children 与 task 清单)。 */
|
|
192
|
+
export function buildTaskWorkItemFromTaskGroup(tg: TaskGroupState): WorkItem {
|
|
193
|
+
const item = createInitialWorkItem({
|
|
194
|
+
id: `task:${tg.id}`,
|
|
195
|
+
source: "openspec",
|
|
196
|
+
externalId: tg.id,
|
|
197
|
+
type: "task",
|
|
198
|
+
title: tg.name,
|
|
199
|
+
description: tg.name,
|
|
200
|
+
labels: ["openspec-change"],
|
|
201
|
+
})
|
|
202
|
+
item.phase = taskGroupStatusToPhase(tg.status)
|
|
203
|
+
item.currentStep = taskGroupStatusToStep(tg.status)
|
|
204
|
+
item.metadata = { source: "openspec", tasks: JSON.parse(JSON.stringify(tg.tasks ?? [])) }
|
|
205
|
+
// 迁移透传 worktree 引用(worktree_path/branch_name/base_ref):旧格式升级后 worktree 就绪判定与提交门禁须保持一致
|
|
206
|
+
if (tg.worktreePath) item.metadata["worktree_path"] = tg.worktreePath
|
|
207
|
+
if (tg.branchName) item.metadata["branch_name"] = tg.branchName
|
|
208
|
+
if (tg.baseRef) item.metadata["base_ref"] = tg.baseRef
|
|
209
|
+
item.children = tg.issues.map(buildIssueWorkItem)
|
|
210
|
+
return item
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 从旧格式 taskGroups 批量构造 workItems(旧状态文件自动升级入口)。
|
|
215
|
+
* 单轨化后仅由 readStateByChangeId 在旧格式(有 taskGroups 无 workItems)时调用,
|
|
216
|
+
* 升级结果落盘后 taskGroups 字段不再写回。
|
|
217
|
+
*/
|
|
218
|
+
export function upgradeWorkItemsFromTaskGroups(groups: TaskGroupState[]): WorkItem[] {
|
|
219
|
+
return groups.map(buildTaskWorkItemFromTaskGroup)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface LegacyState extends OrchestrateState {
|
|
223
|
+
taskGroups?: TaskGroupState[]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function readStateByChangeId(worktree: string, changeId: string): Promise<OrchestrateState | null> {
|
|
227
|
+
const fp = getStatePath(worktree, changeId)
|
|
228
|
+
const fpLegacy = path.join(getLegacyStateDir(worktree), `${changeId}.json`)
|
|
229
|
+
// 双读兼容:新布局 openspec/states/ 优先,旧 .opencode/.orchestrate_state/ 兜底(迁移前仍可读)
|
|
230
|
+
const readPath = existsSync(fp) ? fp : existsSync(fpLegacy) ? fpLegacy : null
|
|
231
|
+
let raw: unknown
|
|
232
|
+
try {
|
|
233
|
+
raw = readPath ? JSON.parse(await readFile(readPath, "utf-8")) : null
|
|
234
|
+
} catch {
|
|
235
|
+
raw = null
|
|
236
|
+
}
|
|
237
|
+
if (raw === null) return null
|
|
238
|
+
const legacy = raw as LegacyState
|
|
239
|
+
const sampleGroup = legacy.taskGroups?.[0]
|
|
240
|
+
if (sampleGroup && !("tasks" in sampleGroup)) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`状态文件 "${legacy.changeId}" 是旧版本格式,不兼容当前版本。请重新初始化编排会话(opx_orch_init)。`
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
if (!legacy.isolationNamespace) {
|
|
246
|
+
legacy.isolationNamespace = generateIsolationNamespace(legacy.changeId)
|
|
247
|
+
}
|
|
248
|
+
// 旧格式归一化:blocker 状态兼容 + task_analysis 自动推进(仅在迁移前生效)
|
|
249
|
+
for (const group of legacy.taskGroups || []) {
|
|
250
|
+
group.blockers ??= []
|
|
251
|
+
for (const blocker of group.blockers) {
|
|
252
|
+
if ((blocker.status as string) === "reported" || (blocker.status as string) === "ready_for_architect") {
|
|
253
|
+
blocker.status = "awaiting_user"
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (group.status === "task_analysis" && group.phases?.architect_review?.completed && !group.blockers.some((blocker) => blocker.status !== "resolved")) {
|
|
257
|
+
group.status = "dev_impl"
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const needsUpgrade = !Array.isArray(legacy.workItems) || legacy.workItems.length === 0
|
|
262
|
+
// 单轨组装:仅保留 workItems 为唯一事实源,taskGroups 字段丢弃不再写回
|
|
263
|
+
const state: OrchestrateState = {
|
|
264
|
+
changeId: legacy.changeId,
|
|
265
|
+
isolationNamespace: legacy.isolationNamespace,
|
|
266
|
+
taskGroupId: legacy.taskGroupId,
|
|
267
|
+
baseBranch: legacy.baseBranch,
|
|
268
|
+
workItems: needsUpgrade
|
|
269
|
+
? upgradeWorkItemsFromTaskGroups(legacy.taskGroups ?? [])
|
|
270
|
+
: (legacy.workItems as WorkItem[]),
|
|
271
|
+
createdAt: legacy.createdAt,
|
|
272
|
+
updatedAt: legacy.updatedAt,
|
|
273
|
+
unattended: legacy.unattended,
|
|
274
|
+
}
|
|
275
|
+
if (needsUpgrade) {
|
|
276
|
+
// 迁移产物一次性落盘固定单轨形态,避免每次读取都重建(首次写新目录前幂等迁移旧数据)
|
|
277
|
+
migrateLegacyStateDir(worktree)
|
|
278
|
+
mkdirSync(getStateDir(worktree), { recursive: true })
|
|
279
|
+
await writeFile(fp, JSON.stringify(state, null, 2))
|
|
280
|
+
}
|
|
281
|
+
return state
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function resolveStateRoot(worktree: string): Promise<string> {
|
|
285
|
+
return isWorktreePath(worktree) ? await discoverRepoRoot(worktree) : worktree
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function writeState(worktree: string, state: OrchestrateState): Promise<void> {
|
|
289
|
+
const target = await resolveStateRoot(worktree)
|
|
290
|
+
// 首次新目录写入前幂等迁移旧 .opencode 数据(主状态/exemptions/锁),重复迁移无副作用
|
|
291
|
+
migrateLegacyStateDir(target)
|
|
292
|
+
mkdirSync(getStateDir(target), { recursive: true })
|
|
293
|
+
state.updatedAt = new Date().toISOString()
|
|
294
|
+
await writeFile(getStatePath(target, state.changeId), JSON.stringify(state, null, 2))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function getLockPath(worktree: string, changeId: string): Promise<string> {
|
|
298
|
+
const root = await resolveStateRoot(worktree)
|
|
299
|
+
return path.join(getStateDir(root), `${changeId}.review.lock`)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function readLockMeta(lockPath: string): { pid: number; acquiredAt: number } | null {
|
|
303
|
+
try {
|
|
304
|
+
const meta = JSON.parse(readFileSync(path.join(lockPath, LOCK_META_FILENAME), "utf-8"))
|
|
305
|
+
if (meta && typeof meta.acquiredAt === "number") return meta
|
|
306
|
+
return null
|
|
307
|
+
} catch {
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function acquireLock(lockPath: string, timeoutMs = 10000): Promise<void> {
|
|
313
|
+
return new Promise((resolve, reject) => {
|
|
314
|
+
const deadline = Date.now() + timeoutMs
|
|
315
|
+
const tryAcquire = () => {
|
|
316
|
+
try {
|
|
317
|
+
mkdirSync(lockPath)
|
|
318
|
+
try {
|
|
319
|
+
writeFileSync(
|
|
320
|
+
path.join(lockPath, LOCK_META_FILENAME),
|
|
321
|
+
JSON.stringify({ pid: process.pid, acquiredAt: Date.now() })
|
|
322
|
+
)
|
|
323
|
+
} catch (metaErr) {
|
|
324
|
+
rmSync(lockPath, { recursive: true, force: true })
|
|
325
|
+
reject(metaErr as Error)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
resolve()
|
|
329
|
+
} catch (err: unknown) {
|
|
330
|
+
if ((err as { code?: string }).code !== "EEXIST") {
|
|
331
|
+
reject(err as Error)
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
const meta = readLockMeta(lockPath)
|
|
335
|
+
if (meta && Date.now() - meta.acquiredAt >= LOCK_STALE_THRESHOLD_MS) {
|
|
336
|
+
rmSync(lockPath, { recursive: true, force: true })
|
|
337
|
+
setTimeout(tryAcquire, LOCK_POLL_INTERVAL_MS)
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
if (Date.now() >= deadline) {
|
|
341
|
+
reject(new Error(`获取锁超时:${lockPath}(${timeoutMs}ms 内未获得锁)`))
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
setTimeout(tryAcquire, LOCK_POLL_INTERVAL_MS)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
tryAcquire()
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function releaseLock(lockPath: string): void {
|
|
352
|
+
try {
|
|
353
|
+
rmSync(lockPath, { recursive: true, force: true })
|
|
354
|
+
} catch {
|
|
355
|
+
// 锁目录不存在时静默忽略
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { TaskItem, TaskStatus } from "./types.ts"
|
|
2
|
+
import type { WorkItem, WorkItemPhase } from "./workflow/types.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 子任务(task child)共享投影助手。
|
|
6
|
+
*
|
|
7
|
+
* 子任务建模为 type==="task" 的 WorkItem 挂入主任务 item.children(metadata.tasks 已废弃),
|
|
8
|
+
* 走统一 phase 状态机:todo(open/rejected)→ review(submitted)→ done/cancelled(终态)。
|
|
9
|
+
* task child 的 id 为短数字(如 "1"、"2"),externalId 存 taskNumber(如 "1.1")。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 过滤 item.children 中 type==="task" 的子任务。 */
|
|
13
|
+
export function taskChildrenOf(item: WorkItem): WorkItem[] {
|
|
14
|
+
return (item.children ?? []).filter((c) => c.type === "task")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 过滤 item.children 中 type==="issue" 的 issue child(task child 不参与 issue 渲染/归因)。 */
|
|
18
|
+
export function issueChildrenOf(item: WorkItem): WorkItem[] {
|
|
19
|
+
return (item.children ?? []).filter((c) => c.type === "issue")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 按 id(数字字符串或 taskNumber)查 task child,未命中返回 null。 */
|
|
23
|
+
export function taskChildById(item: WorkItem, id: string): WorkItem | null {
|
|
24
|
+
return (
|
|
25
|
+
item.children.find((c) => c.type === "task" && (c.id === id || c.externalId === id)) ?? null
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 将 taskNumber(如 "1.1")映射为数字 id(task child 的 externalId 存 taskNumber,id 存数字),未命中按原样返回。 */
|
|
30
|
+
export function normalizeTaskChildIds(rawIds: string[], item: WorkItem): string[] {
|
|
31
|
+
const byNumber = new Map<string, string>()
|
|
32
|
+
for (const c of taskChildrenOf(item)) {
|
|
33
|
+
if (c.externalId !== undefined) byNumber.set(c.externalId, c.id)
|
|
34
|
+
}
|
|
35
|
+
return rawIds.map((id) => byNumber.get(id) ?? id)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** task child phase → TaskStatus 反查(TaskItem 兼容投影)。 */
|
|
39
|
+
function phaseToTaskStatus(phase: WorkItemPhase, hasRejectReason: boolean): TaskStatus {
|
|
40
|
+
switch (phase) {
|
|
41
|
+
case "review": return "submitted"
|
|
42
|
+
case "done":
|
|
43
|
+
case "cancelled": return "verified"
|
|
44
|
+
case "todo": return hasRejectReason ? "rejected" : "open"
|
|
45
|
+
default: return "open"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 从 task child 投影 TaskItem 兼容结构(status.ts 视图渲染与旧工具读侧使用)。 */
|
|
50
|
+
export function taskListOf(item: WorkItem): TaskItem[] {
|
|
51
|
+
return taskChildrenOf(item).map((c) => ({
|
|
52
|
+
id: c.id,
|
|
53
|
+
specTrace: typeof c.metadata["specTrace"] === "string" ? c.metadata["specTrace"] : "",
|
|
54
|
+
title: c.title,
|
|
55
|
+
status: phaseToTaskStatus(c.phase, c.metadata["reject_reason"] !== undefined),
|
|
56
|
+
taskNumber: typeof c.externalId === "string" ? c.externalId : "",
|
|
57
|
+
rejectReason: typeof c.metadata["reject_reason"] === "string" ? c.metadata["reject_reason"] : null,
|
|
58
|
+
}))
|
|
59
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import { readFile } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
export interface ParsedTask {
|
|
5
|
+
title: string
|
|
6
|
+
specTrace: string
|
|
7
|
+
taskNumber: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseSpecTrace(line: string): string {
|
|
11
|
+
const m = line.match(/\[spec:([^\]]+)\]/)
|
|
12
|
+
return m ? m[1].trim() : ""
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function parseTasksMdForGroup(
|
|
16
|
+
worktree: string,
|
|
17
|
+
changeId: string,
|
|
18
|
+
taskGroupId: string
|
|
19
|
+
): Promise<ParsedTask[]> {
|
|
20
|
+
const tasksPath = path.join(worktree, "openspec", "changes", changeId, "tasks.md")
|
|
21
|
+
const tasks: ParsedTask[] = []
|
|
22
|
+
try {
|
|
23
|
+
const content = await readFile(tasksPath, "utf-8")
|
|
24
|
+
const lines = content.split("\n")
|
|
25
|
+
let currentGroup: string | null = null
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
const line = lines[i]
|
|
28
|
+
const groupMatch = line.match(/^##\s+(\d+)\./)
|
|
29
|
+
if (groupMatch) {
|
|
30
|
+
if (currentGroup === taskGroupId) break
|
|
31
|
+
currentGroup = groupMatch[1]
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
if (currentGroup !== taskGroupId) continue
|
|
35
|
+
const taskMatch = line.match(/^-\s+\[[\sx]\]\s+(.+)/)
|
|
36
|
+
if (taskMatch) {
|
|
37
|
+
const body = taskMatch[1]
|
|
38
|
+
const taskNumberMatch = body.match(/^(\d+(?:\.\d+)+)\s+/)
|
|
39
|
+
const taskNumber = taskNumberMatch ? taskNumberMatch[1] : ""
|
|
40
|
+
const cleaned = (taskNumberMatch ? body.slice(taskNumberMatch[0].length) : body)
|
|
41
|
+
.replace(/\s*\[spec:[^\]]+\]\s*$/, "")
|
|
42
|
+
.trim()
|
|
43
|
+
const specTrace = parseSpecTrace(body)
|
|
44
|
+
tasks.push({ title: cleaned, specTrace, taskNumber })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
return tasks
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function extractRelevantSpecsFromTasks(tasks: ParsedTask[]): string[] {
|
|
53
|
+
const set = new Set<string>()
|
|
54
|
+
for (const t of tasks) {
|
|
55
|
+
if (!t.specTrace) continue
|
|
56
|
+
const parts = t.specTrace.split("#")[0]
|
|
57
|
+
if (parts) set.add(parts)
|
|
58
|
+
}
|
|
59
|
+
return Array.from(set)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function parseAllTaskGroupsFromMd(
|
|
63
|
+
worktree: string,
|
|
64
|
+
changeId: string
|
|
65
|
+
): Promise<{ id: string; name: string; taskCount: number }[]> {
|
|
66
|
+
const tasksPath = path.join(worktree, "openspec", "changes", changeId, "tasks.md")
|
|
67
|
+
const groups: { id: string; name: string; taskCount: number }[] = []
|
|
68
|
+
try {
|
|
69
|
+
const content = await readFile(tasksPath, "utf-8")
|
|
70
|
+
const lines = content.split("\n")
|
|
71
|
+
let currentGroup: { id: string; name: string } | null = null
|
|
72
|
+
let currentTaskCount = 0
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
const groupMatch = line.match(/^##\s+(\d+)\./)
|
|
75
|
+
if (groupMatch) {
|
|
76
|
+
if (currentGroup) groups.push({ ...currentGroup, taskCount: currentTaskCount })
|
|
77
|
+
currentGroup = { id: groupMatch[1], name: line.replace(/^##\s+\d+\.\s*/, "").trim() }
|
|
78
|
+
currentTaskCount = 0
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (currentGroup) {
|
|
82
|
+
if (/^-\s+\[[\sx]\]\s+/.test(line)) currentTaskCount++
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (currentGroup) groups.push({ ...currentGroup, taskCount: currentTaskCount })
|
|
86
|
+
} catch {
|
|
87
|
+
}
|
|
88
|
+
return groups
|
|
89
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 质量门必做清单(must_do)覆盖度门禁与降级理由(skip_reason)结构化解析。
|
|
3
|
+
*
|
|
4
|
+
* 语义:skill 在 frontmatter 声明机器可读的 must_do 必做清单后,review 提交(verify_tool)的
|
|
5
|
+
* validation_steps 必须逐项覆盖(completed=true,或 completed=false 且 skip_reason 为合法结构化
|
|
6
|
+
* JSON)。未声明 must_do 的 skill 与解析不到质量门 skill 的 step 优雅跳过(不误伤非质量门 step)。
|
|
7
|
+
* giveup 决策复用同一套未覆盖项推导,要求对未覆盖必做项补充结构化降级理由(checkpoint_skip_reasons),
|
|
8
|
+
* 杜绝「放弃审查 → 直接推进收尾」的无痕绕过通道。
|
|
9
|
+
*
|
|
10
|
+
* skip_reason 结构化格式(JSON 字符串):
|
|
11
|
+
* {"item":"<对应必做项>","category":"<降级类别>","adjudication":"user_response|unattended_auto|env_unavailable","note":"<说明>"}
|
|
12
|
+
* adjudication 裁定方式枚举:user_response=用户答复、unattended_auto=无人值守自动降级、
|
|
13
|
+
* env_unavailable=环境不可用(note 须记录尝试)。
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
scanSkillTags,
|
|
17
|
+
resolveSkillsForCapabilities,
|
|
18
|
+
getSkillMustDo,
|
|
19
|
+
type SkillTagIndex,
|
|
20
|
+
} from "../../skills/resolve.ts"
|
|
21
|
+
|
|
22
|
+
/** 裁定方式枚举:用户答复 / 无人值守自动降级 / 环境不可用(须附尝试记录)。 */
|
|
23
|
+
export const SKIP_REASON_ADJUDICATIONS = ["user_response", "unattended_auto", "env_unavailable"] as const
|
|
24
|
+
export type SkipAdjudication = (typeof SKIP_REASON_ADJUDICATIONS)[number]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 结构化降级声明的标准格式(skip_reason / checkpoint_skip_reasons 共用)。
|
|
28
|
+
* 全链路(schema 描述 / 视图指引 / 错误提示)以本常量保证术语与格式一致。
|
|
29
|
+
*/
|
|
30
|
+
export const SKIP_REASON_FORMAT =
|
|
31
|
+
'{"item":"<对应必做项>","category":"<降级类别>","adjudication":"user_response|unattended_auto|env_unavailable","note":"<说明>"}'
|
|
32
|
+
|
|
33
|
+
/** 结构化降级声明:item 对应必做项,category 为降级类别,adjudication 为裁定方式。 */
|
|
34
|
+
export interface SkipReasonData {
|
|
35
|
+
item: string
|
|
36
|
+
category: string
|
|
37
|
+
adjudication: string
|
|
38
|
+
note?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 测试注入:覆盖门禁使用的 skill 索引。
|
|
43
|
+
* 存量测试基建(tests/helpers.ts setupWorkspace)默认注入空索引以豁免门禁(解析不到质量门 skill),
|
|
44
|
+
* 新增门禁用例显式注入构造索引或真实索引(scanSkillTags())验证。null=无注入,走真实文件扫描。
|
|
45
|
+
*/
|
|
46
|
+
let overrideIndex: SkillTagIndex | null = null
|
|
47
|
+
export function __setMustDoIndex(index: SkillTagIndex | null): void {
|
|
48
|
+
overrideIndex = index
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 空 skill 索引:任何 capability 都解析不到 skill(门禁豁免用)。 */
|
|
52
|
+
export const EMPTY_MUST_DO_INDEX: SkillTagIndex = {
|
|
53
|
+
tagMap: new Map(),
|
|
54
|
+
skillTags: new Map(),
|
|
55
|
+
skillMustDo: new Map(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function currentIndex(): SkillTagIndex {
|
|
59
|
+
return overrideIndex ?? scanSkillTags()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 判断对象是否为合法结构化降级声明(item/category/adjudication 三要素齐全)。 */
|
|
63
|
+
export function isValidSkipData(data: unknown): data is SkipReasonData {
|
|
64
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return false
|
|
65
|
+
const d = data as Record<string, unknown>
|
|
66
|
+
if (typeof d.item !== "string" || d.item.trim().length === 0) return false
|
|
67
|
+
if (typeof d.category !== "string" || d.category.trim().length === 0) return false
|
|
68
|
+
if (
|
|
69
|
+
typeof d.adjudication !== "string" ||
|
|
70
|
+
!(SKIP_REASON_ADJUDICATIONS as readonly string[]).includes(d.adjudication)
|
|
71
|
+
) return false
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 解析结构化 skip_reason 文本(JSON 字符串);格式不合法返回错误说明。 */
|
|
76
|
+
export function parseSkipReason(
|
|
77
|
+
reason: string,
|
|
78
|
+
): { ok: true; data: SkipReasonData } | { ok: false; error: string } {
|
|
79
|
+
let parsed: unknown
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(reason)
|
|
82
|
+
} catch {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error:
|
|
86
|
+
`skip_reason 必须为结构化 JSON 字符串:${SKIP_REASON_FORMAT}。当前值无法解析为 JSON:${reason}`,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!isValidSkipData(parsed)) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
error:
|
|
93
|
+
`skip_reason 结构化字段缺失或不合法:必须包含非空 item(对应必做项)、category(降级类别)、` +
|
|
94
|
+
`adjudication(裁定方式,取值 ${SKIP_REASON_ADJUDICATIONS.join(" / ")})。当前值:${reason}`,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, data: parsed }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** validation_steps 的 step 名称归一化:按冒号/括号/空白切出首段,与 must_do 枚举比对。 */
|
|
101
|
+
function stepToken(name: string): string {
|
|
102
|
+
return name.trim().split(/[::((]/)[0].trim()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 按 capability_tags 解析出所有命中 skill 声明 must_do 的合并集合(去重、保持声明顺序)。 */
|
|
106
|
+
export function resolveMustDoForCaps(caps: string[] | undefined, index?: SkillTagIndex): string[] {
|
|
107
|
+
const idx = index ?? currentIndex()
|
|
108
|
+
const { skillNames } = resolveSkillsForCapabilities(caps, idx)
|
|
109
|
+
const seen = new Set<string>()
|
|
110
|
+
const out: string[] = []
|
|
111
|
+
for (const name of skillNames) {
|
|
112
|
+
for (const item of getSkillMustDo(name, idx) ?? []) {
|
|
113
|
+
if (seen.has(item)) continue
|
|
114
|
+
seen.add(item)
|
|
115
|
+
out.push(item)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 返回 validation_steps 未覆盖的必做项(step 名称首段命中即视为覆盖;无必做清单返回空数组)。 */
|
|
122
|
+
export function uncoveredMustDo(
|
|
123
|
+
caps: string[] | undefined,
|
|
124
|
+
validationSteps: Array<{ step: string; completed: boolean; skip_reason?: string }> | undefined,
|
|
125
|
+
index?: SkillTagIndex,
|
|
126
|
+
): string[] {
|
|
127
|
+
const must = resolveMustDoForCaps(caps, index)
|
|
128
|
+
if (must.length === 0) return []
|
|
129
|
+
const steps = validationSteps ?? []
|
|
130
|
+
// 全量豁免声明:无代码/配置变更直提、或经审查确认仅注释/文档性变更免全量时,
|
|
131
|
+
// review 以一条 step 名首段为该 token 的条目声明整体豁免必做清单(completed=false +
|
|
132
|
+
// 合法结构化 skip_reason,说明判定依据);其余场景仍逐项申报。
|
|
133
|
+
if (steps.some((s) => stepToken(s.step) === NO_CHANGE_TOKEN)) return []
|
|
134
|
+
const covered = new Set<string>()
|
|
135
|
+
for (const s of steps) {
|
|
136
|
+
const token = stepToken(s.step)
|
|
137
|
+
if (must.includes(token)) covered.add(token)
|
|
138
|
+
}
|
|
139
|
+
return must.filter((m) => !covered.has(m))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 全量豁免声明 token:step 名首段为该 token 的 validation_steps 条目声明本轮整体豁免必做清单。
|
|
144
|
+
* 与 task.yaml verify_tool 无变更直提 / 注释性变更免全量分支对应(避免免全量直提被门禁误伤)。
|
|
145
|
+
*/
|
|
146
|
+
export const NO_CHANGE_TOKEN = "no_change"
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 校验 validation_steps 中每条未完成项必须携带合法结构化 skip_reason(升级既有「非空即可」校验)。
|
|
150
|
+
* 不合法即抛错(中文,指明步骤与原因)。
|
|
151
|
+
*/
|
|
152
|
+
export function assertStructuredSkipReasons(
|
|
153
|
+
validationSteps: Array<{ step: string; completed: boolean; skip_reason?: string }>,
|
|
154
|
+
): void {
|
|
155
|
+
for (const s of validationSteps) {
|
|
156
|
+
if (s.completed) continue
|
|
157
|
+
if (!s.skip_reason) {
|
|
158
|
+
throw new Error(`验证步骤 "${s.step}" 标记为未完成但未提供跳过原因(skip_reason)。`)
|
|
159
|
+
}
|
|
160
|
+
const parsed = parseSkipReason(s.skip_reason)
|
|
161
|
+
if (!parsed.ok) {
|
|
162
|
+
throw new Error(`验证步骤 "${s.step}" 的 skip_reason 格式不合法:${parsed.error}`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|