@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,277 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs"
|
|
2
|
+
import { join, dirname, resolve as pathResolve } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import yaml from "js-yaml"
|
|
5
|
+
import type {
|
|
6
|
+
WorkflowConfig,
|
|
7
|
+
PhaseConfig,
|
|
8
|
+
StepConfig,
|
|
9
|
+
StepAgent,
|
|
10
|
+
StepTransitions,
|
|
11
|
+
WorkItemPhase,
|
|
12
|
+
WorkflowCommon,
|
|
13
|
+
} from "./types.ts"
|
|
14
|
+
import { WORK_ITEM_PHASES, stepAgentIds } from "./types.ts"
|
|
15
|
+
import { DIMENSION_AGENT_MAP } from "../constants.ts"
|
|
16
|
+
|
|
17
|
+
const SPECIAL_TRANSITIONS = ["done", "halt"] as const
|
|
18
|
+
const PHASE_NAMES = new Set<string>(WORK_ITEM_PHASES)
|
|
19
|
+
const QUALITY_REVIEW_STEP_ID = "verify_quality"
|
|
20
|
+
|
|
21
|
+
export interface LoadedWorkflow extends WorkflowConfig {
|
|
22
|
+
stepMap: Map<string, { step: StepConfig; phase: PhaseConfig }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 定位 task.yaml:源码(src/core/workflow/ 上溯 3 级=仓库根)、插件包 dist 形态
|
|
27
|
+
* (dist/zcode-plugin/.mcp-server/ 上溯 1 级=插件根)与 ZCode 缓存嵌套形态
|
|
28
|
+
* (cache/<marketplace>/<plugin>/<version>/.mcp-server/ 上溯 1 级=版本目录)部署深度不同,
|
|
29
|
+
* 故从模块所在目录逐级上溯探测 assets/workflows/task.yaml,首个命中即采用(首中即用)。
|
|
30
|
+
* bundle 单文件合并后 import.meta.url 指向部署位置(cli.mjs),源码形态指向本文件自身,两者自然成立。
|
|
31
|
+
*/
|
|
32
|
+
export function resolveTaskWorkflowPath(moduleUrl: string): string {
|
|
33
|
+
const startDir = dirname(fileURLToPath(moduleUrl))
|
|
34
|
+
let dir = startDir
|
|
35
|
+
let probed = 0
|
|
36
|
+
for (;;) {
|
|
37
|
+
const candidate = pathResolve(dir, "assets", "workflows", "task.yaml")
|
|
38
|
+
if (existsSync(candidate)) return candidate
|
|
39
|
+
probed++
|
|
40
|
+
const parent = dirname(dir)
|
|
41
|
+
if (parent === dir) break // 到达文件系统根
|
|
42
|
+
dir = parent
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`workflow 文件缺失:从 ${startDir} 上溯 ${probed} 级未找到 assets/workflows/task.yaml`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 模块加载期求值,workflow 缺失即启动抛错(fail-fast):opx_* 工具全部依赖 workflow,缺失时
|
|
48
|
+
// server 无可用性,启动即抛比首次调用才报错更早且可读(可读错误替代原生 ENOENT)。
|
|
49
|
+
export const TASK_WORKFLOW_PATH = resolveTaskWorkflowPath(import.meta.url)
|
|
50
|
+
|
|
51
|
+
const workflowFileCache = new Map<string, LoadedWorkflow>()
|
|
52
|
+
|
|
53
|
+
/** 读取 workflow YAML 文件并解析,进程内按路径缓存(submit / status 共用)。 */
|
|
54
|
+
export function loadWorkflowFile(filePath: string): LoadedWorkflow {
|
|
55
|
+
let wf = workflowFileCache.get(filePath)
|
|
56
|
+
if (!wf) {
|
|
57
|
+
wf = loadWorkflow(readFileSync(filePath, "utf-8"))
|
|
58
|
+
workflowFileCache.set(filePath, wf)
|
|
59
|
+
}
|
|
60
|
+
return wf
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface RawStep {
|
|
64
|
+
id?: unknown
|
|
65
|
+
agents?: unknown
|
|
66
|
+
always_run?: unknown
|
|
67
|
+
max_retries?: unknown
|
|
68
|
+
instructions?: unknown
|
|
69
|
+
constraints?: unknown
|
|
70
|
+
transitions?: unknown
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface RawPhase {
|
|
74
|
+
name?: unknown
|
|
75
|
+
steps?: unknown
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface RawWorkflow {
|
|
79
|
+
id?: unknown
|
|
80
|
+
name?: unknown
|
|
81
|
+
max_retries?: unknown
|
|
82
|
+
phases?: unknown
|
|
83
|
+
common?: unknown
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fail(message: string): never {
|
|
87
|
+
throw new Error(message)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function expectString(value: unknown, path: string): string {
|
|
91
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
92
|
+
fail(`workflow 配置非法:${path} 必须是非空字符串,收到 ${JSON.stringify(value)}`)
|
|
93
|
+
}
|
|
94
|
+
return value
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function expectPositiveInt(value: unknown, path: string): number {
|
|
98
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
99
|
+
fail(`workflow 配置非法:${path} 必须是正整数,收到 ${JSON.stringify(value)}`)
|
|
100
|
+
}
|
|
101
|
+
return value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function expectStringArray(value: unknown, path: string): string[] {
|
|
105
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== "string")) {
|
|
106
|
+
fail(`workflow 配置非法:${path} 必须是非空字符串数组,收到 ${JSON.stringify(value)}`)
|
|
107
|
+
}
|
|
108
|
+
return value as string[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** agents 对象数组解析:每个元素须含非空 id 与非空字符串数组 capability_tags(agent 级 capability_tags 必填)。 */
|
|
112
|
+
function expectAgentList(value: unknown, path: string): StepAgent[] {
|
|
113
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
114
|
+
fail(`workflow 配置非法:${path} 必须是非空对象数组(每个元素含 id 与 capability_tags),收到 ${JSON.stringify(value)}`)
|
|
115
|
+
}
|
|
116
|
+
return value.map((v, i) => {
|
|
117
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) {
|
|
118
|
+
fail(`workflow 配置非法:${path}[${i}] 必须是对象(含 id 与 capability_tags),收到 ${JSON.stringify(v)}`)
|
|
119
|
+
}
|
|
120
|
+
const a = v as { id?: unknown; capability_tags?: unknown }
|
|
121
|
+
return {
|
|
122
|
+
id: expectString(a.id, `${path}[${i}].id`),
|
|
123
|
+
capability_tags: expectStringArray(a.capability_tags, `${path}[${i}].capability_tags`),
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function expectOptionalPositiveInt(value: unknown, path: string): number | undefined {
|
|
129
|
+
if (value === undefined) return undefined
|
|
130
|
+
return expectPositiveInt(value, path)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function expectOptionalNonEmptyStringArray(value: unknown, path: string): string[] | undefined {
|
|
134
|
+
if (value === undefined) return undefined
|
|
135
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== "string" || v.trim() === "")) {
|
|
136
|
+
fail(`workflow 配置非法:${path} 必须是非空字符串数组,收到 ${JSON.stringify(value)}`)
|
|
137
|
+
}
|
|
138
|
+
return value as string[]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** common 块解析:instructions / constraints 可选非空字符串数组(全缺省时降级 undefined)。 */
|
|
142
|
+
function parseCommon(raw: unknown, workflowId: string): WorkflowCommon | undefined {
|
|
143
|
+
if (raw === undefined) return undefined
|
|
144
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
145
|
+
fail(`workflow "${workflowId}" 配置非法:common 必须是对象`)
|
|
146
|
+
}
|
|
147
|
+
const c = raw as { instructions?: unknown; constraints?: unknown }
|
|
148
|
+
const instructions = expectOptionalNonEmptyStringArray(c.instructions, "common.instructions")
|
|
149
|
+
const constraints = expectOptionalNonEmptyStringArray(c.constraints, "common.constraints")
|
|
150
|
+
if (instructions === undefined && constraints === undefined) return undefined
|
|
151
|
+
return { instructions, constraints }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** verify_quality 双源一致性校验:存在该 step 时,agents 的 id 集合须等于质量维度 agent 映射值集合(改一处不同步即不一致)。 */
|
|
155
|
+
function assertQualityAgentsConsistent(workflowId: string, stepMap: Map<string, { step: StepConfig; phase: PhaseConfig }>): void {
|
|
156
|
+
const qualityStep = stepMap.get(QUALITY_REVIEW_STEP_ID)
|
|
157
|
+
if (!qualityStep) return
|
|
158
|
+
const declared = new Set(stepAgentIds(qualityStep.step))
|
|
159
|
+
const expected = new Set(Object.values(DIMENSION_AGENT_MAP))
|
|
160
|
+
if (declared.size !== expected.size || ![...expected].every((a) => declared.has(a))) {
|
|
161
|
+
fail(
|
|
162
|
+
`workflow "${workflowId}" 配置非法:${QUALITY_REVIEW_STEP_ID}.agents(${JSON.stringify([...declared])})与质量维度 agent 映射(${JSON.stringify([...expected])})不一致,两处须保持同一集合`
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseTransitions(raw: unknown, stepId: string, validStepIds: Set<string>): StepTransitions {
|
|
168
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
169
|
+
fail(`workflow 配置非法:step "${stepId}" 的 transitions 必须是对象`)
|
|
170
|
+
}
|
|
171
|
+
const t = raw as Record<string, unknown>
|
|
172
|
+
const out: Partial<StepTransitions> = {}
|
|
173
|
+
for (const key of ["on_pass", "on_fail"] as const) {
|
|
174
|
+
const target = t[key]
|
|
175
|
+
if (typeof target !== "string" || target === "") {
|
|
176
|
+
fail(`workflow 配置非法:step "${stepId}" 的 transitions.${key} 必须是非空字符串`)
|
|
177
|
+
}
|
|
178
|
+
if (!SPECIAL_TRANSITIONS.includes(target as (typeof SPECIAL_TRANSITIONS)[number]) && !validStepIds.has(target)) {
|
|
179
|
+
fail(
|
|
180
|
+
`workflow 配置非法:step "${stepId}" 的 transitions.${key} 目标 "${target}" 不存在(既非已声明 step id,也非 done/halt 特殊值)`
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
out[key] = target as StepTransitions[typeof key]
|
|
184
|
+
}
|
|
185
|
+
return out as StepTransitions
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function loadWorkflow(yamlText: string): LoadedWorkflow {
|
|
189
|
+
let raw: unknown
|
|
190
|
+
try {
|
|
191
|
+
raw = yaml.load(yamlText)
|
|
192
|
+
} catch (err) {
|
|
193
|
+
fail(`workflow YAML 解析失败:${(err as Error).message}`)
|
|
194
|
+
}
|
|
195
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
196
|
+
fail("workflow 配置非法:顶层必须是 YAML 映射对象")
|
|
197
|
+
}
|
|
198
|
+
const wf = raw as RawWorkflow
|
|
199
|
+
const id = expectString(wf.id, "id")
|
|
200
|
+
const maxRetries = expectPositiveInt(wf.max_retries, "max_retries")
|
|
201
|
+
const name = typeof wf.name === "string" && wf.name.trim() !== "" ? wf.name : undefined
|
|
202
|
+
|
|
203
|
+
if (!Array.isArray(wf.phases) || wf.phases.length === 0) {
|
|
204
|
+
fail(`workflow "${id}" 配置非法:phases 必须是非空数组`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const phases: PhaseConfig[] = []
|
|
208
|
+
const stepMap = new Map<string, { step: StepConfig; phase: PhaseConfig }>()
|
|
209
|
+
const rawTransitions = new Map<string, unknown>()
|
|
210
|
+
const seenPhaseNames = new Set<string>()
|
|
211
|
+
const allStepIds = new Set<string>()
|
|
212
|
+
|
|
213
|
+
for (const [phaseIdx, rawPhase] of wf.phases.entries()) {
|
|
214
|
+
if (typeof rawPhase !== "object" || rawPhase === null || Array.isArray(rawPhase)) {
|
|
215
|
+
fail(`workflow "${id}" 配置非法:phases[${phaseIdx}] 必须是对象`)
|
|
216
|
+
}
|
|
217
|
+
const rp = rawPhase as RawPhase
|
|
218
|
+
const phaseName = expectString(rp.name, `phases[${phaseIdx}].name`)
|
|
219
|
+
if (!PHASE_NAMES.has(phaseName)) {
|
|
220
|
+
fail(
|
|
221
|
+
`workflow "${id}" 配置非法:phases[${phaseIdx}].name "${phaseName}" 不是合法 phase(合法值:${WORK_ITEM_PHASES.join(", ")})`
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
if (seenPhaseNames.has(phaseName)) {
|
|
225
|
+
fail(`workflow "${id}" 配置非法:phase "${phaseName}" 重复声明`)
|
|
226
|
+
}
|
|
227
|
+
seenPhaseNames.add(phaseName)
|
|
228
|
+
const phase: PhaseConfig = { name: phaseName as WorkItemPhase, steps: [] }
|
|
229
|
+
|
|
230
|
+
if (!Array.isArray(rp.steps) || rp.steps.length === 0) {
|
|
231
|
+
fail(`workflow "${id}" 配置非法:phase "${phaseName}" 的 steps 必须是非空数组`)
|
|
232
|
+
}
|
|
233
|
+
for (const [stepIdx, rawStep] of rp.steps.entries()) {
|
|
234
|
+
if (typeof rawStep !== "object" || rawStep === null || Array.isArray(rawStep)) {
|
|
235
|
+
fail(`workflow "${id}" 配置非法:phase "${phaseName}" steps[${stepIdx}] 必须是对象`)
|
|
236
|
+
}
|
|
237
|
+
const rs = rawStep as RawStep
|
|
238
|
+
const stepId = expectString(rs.id, `phase "${phaseName}" steps[${stepIdx}].id`)
|
|
239
|
+
if (allStepIds.has(stepId)) {
|
|
240
|
+
fail(`workflow "${id}" 配置非法:step id "${stepId}" 重复声明`)
|
|
241
|
+
}
|
|
242
|
+
allStepIds.add(stepId)
|
|
243
|
+
rawTransitions.set(stepId, rs.transitions)
|
|
244
|
+
const agents = expectAgentList(rs.agents, `step "${stepId}" 的 agents`)
|
|
245
|
+
const step: StepConfig = {
|
|
246
|
+
id: stepId,
|
|
247
|
+
agents,
|
|
248
|
+
always_run: typeof rs.always_run === "boolean" ? rs.always_run : undefined,
|
|
249
|
+
max_retries: expectOptionalPositiveInt(rs.max_retries, `step "${stepId}" 的 max_retries`),
|
|
250
|
+
instructions: expectOptionalNonEmptyStringArray(rs.instructions, `step "${stepId}" 的 instructions`),
|
|
251
|
+
constraints: expectOptionalNonEmptyStringArray(rs.constraints, `step "${stepId}" 的 constraints`),
|
|
252
|
+
transitions: { on_pass: "done", on_fail: "halt" },
|
|
253
|
+
}
|
|
254
|
+
phase.steps.push(step)
|
|
255
|
+
stepMap.set(stepId, { step, phase })
|
|
256
|
+
}
|
|
257
|
+
phases.push(phase)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const { step } of stepMap.values()) {
|
|
261
|
+
step.transitions = parseTransitions(rawTransitions.get(step.id), step.id, allStepIds)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
assertQualityAgentsConsistent(id, stepMap)
|
|
265
|
+
|
|
266
|
+
return { id, name, max_retries: maxRetries, phases, stepMap, common: parseCommon(wf.common, id) }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function findStepByWorkItemPhase(
|
|
270
|
+
workflow: LoadedWorkflow,
|
|
271
|
+
phase: WorkItemPhase,
|
|
272
|
+
): { step: StepConfig; phase: PhaseConfig } | null {
|
|
273
|
+
for (const entry of workflow.stepMap.values()) {
|
|
274
|
+
if (entry.phase.name === phase) return entry
|
|
275
|
+
}
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readStateByWorktree, writeState } from "../state.ts"
|
|
2
|
+
import { getCollectors, type CollectorAdapter } from "./collector.ts"
|
|
3
|
+
import type { WorkItem } from "./types.ts"
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TICK_MS = 1_000
|
|
6
|
+
|
|
7
|
+
export interface PollOnceResult {
|
|
8
|
+
added: string[]
|
|
9
|
+
skipped: string[]
|
|
10
|
+
errors: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PollAdapterResult {
|
|
14
|
+
added: string[]
|
|
15
|
+
skipped: string[]
|
|
16
|
+
error?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 去重键:source + externalId,跨 adapter 来源隔离。 */
|
|
20
|
+
function dedupeKey(item: WorkItem): string {
|
|
21
|
+
return `${item.source}:${item.externalId ?? item.id}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 拉取单个 adapter:pull → transform → 按 source+externalId 去重写入 state.workItems,
|
|
26
|
+
* 并对新增项执行 writeback(失败在 item.writeback 记录 lastAttempt/error,不阻塞调度)。
|
|
27
|
+
*/
|
|
28
|
+
export async function pollAdapter(
|
|
29
|
+
worktree: string,
|
|
30
|
+
adapter: CollectorAdapter,
|
|
31
|
+
changeId?: string,
|
|
32
|
+
): Promise<PollAdapterResult> {
|
|
33
|
+
let raw: unknown[]
|
|
34
|
+
try {
|
|
35
|
+
raw = await adapter.pull()
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { added: [], skipped: [], error: `[poller] adapter "${adapter.name}" pull 失败:${(err as Error).message}` }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let items: WorkItem[]
|
|
41
|
+
try {
|
|
42
|
+
items = adapter.transform(raw)
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return { added: [], skipped: [], error: `[poller] adapter "${adapter.name}" transform 失败:${(err as Error).message}` }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let state
|
|
48
|
+
try {
|
|
49
|
+
state = await readStateByWorktree(worktree, changeId)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return { added: [], skipped: [], error: `[poller] adapter "${adapter.name}" 读取编排状态失败:${(err as Error).message}` }
|
|
52
|
+
}
|
|
53
|
+
if (!state) {
|
|
54
|
+
// 无可用编排会话:静默跳过,不产生 error(避免 poller 每 tick 刷 console.error 噪音)
|
|
55
|
+
return { added: [], skipped: [] }
|
|
56
|
+
}
|
|
57
|
+
state.workItems ??= []
|
|
58
|
+
const existing = new Set<string>(state.workItems.map(dedupeKey))
|
|
59
|
+
|
|
60
|
+
const added: string[] = []
|
|
61
|
+
const skipped: string[] = []
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
const key = dedupeKey(item)
|
|
64
|
+
if (existing.has(key)) {
|
|
65
|
+
skipped.push(item.id)
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
state.workItems.push(item)
|
|
69
|
+
existing.add(key)
|
|
70
|
+
added.push(item.id)
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const result = await adapter.writeback(item, item)
|
|
74
|
+
if (result.success) {
|
|
75
|
+
item.writeback = { ...item.writeback, lastAttempt: new Date().toISOString(), lastSuccess: new Date().toISOString() }
|
|
76
|
+
} else {
|
|
77
|
+
item.writeback = { ...item.writeback, lastAttempt: new Date().toISOString(), error: result.error ?? "writeback 失败" }
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
item.writeback = { ...item.writeback, lastAttempt: new Date().toISOString(), error: (err as Error).message }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 仅在本次有新增项(added 会触发 writeback 状态变更)时才写盘,全量 skipped 时跳过
|
|
85
|
+
// 避免无意义 I/O。read-modify-write 的无锁并发问题本阶段保持现状(单实例部署,
|
|
86
|
+
// 多实例并发场景见后续设计,此处不引入锁)。
|
|
87
|
+
if (added.length > 0) {
|
|
88
|
+
await writeState(worktree, state)
|
|
89
|
+
}
|
|
90
|
+
return { added, skipped }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 单次全量拉取:遍历所有已注册 adapter 执行 pollAdapter,聚合结果。
|
|
95
|
+
* 单个 adapter 失败不阻塞其余 adapter。
|
|
96
|
+
*/
|
|
97
|
+
export async function pollOnce(worktree: string, changeId?: string): Promise<PollOnceResult> {
|
|
98
|
+
const result: PollOnceResult = { added: [], skipped: [], errors: [] }
|
|
99
|
+
for (const adapter of getCollectors()) {
|
|
100
|
+
const r = await pollAdapter(worktree, adapter, changeId)
|
|
101
|
+
result.added.push(...r.added)
|
|
102
|
+
result.skipped.push(...r.skipped)
|
|
103
|
+
if (r.error) {
|
|
104
|
+
result.errors.push(r.error)
|
|
105
|
+
console.error(r.error)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 活跃轮询器注册表(按 worktree 去重,插件热重载不累积定时器)。 */
|
|
112
|
+
const activePollers = new Map<string, { stop: () => void }>()
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 启动定时拉取:按全局 tick(intervalMs)轮询各已注册 collector,每个 adapter
|
|
116
|
+
* 按自身 pollIntervalMs 到期后执行单次拉取(拉取失败仅记录不阻塞后续)。
|
|
117
|
+
* worktree 为动态上下文,由插件启动时注入。
|
|
118
|
+
* 同 worktree 重复调用幂等:复用已注册的轮询器,不新建定时器。
|
|
119
|
+
*/
|
|
120
|
+
export function startPolling(
|
|
121
|
+
worktree: string,
|
|
122
|
+
options?: { intervalMs?: number; changeId?: string },
|
|
123
|
+
): { stop(): void } {
|
|
124
|
+
const existing = activePollers.get(worktree)
|
|
125
|
+
if (existing) return existing
|
|
126
|
+
|
|
127
|
+
const tickMs = options?.intervalMs ?? DEFAULT_TICK_MS
|
|
128
|
+
const changeId = options?.changeId
|
|
129
|
+
const lastRun = new Map<string, number>()
|
|
130
|
+
|
|
131
|
+
const timer = setInterval(() => {
|
|
132
|
+
const now = Date.now()
|
|
133
|
+
for (const adapter of getCollectors()) {
|
|
134
|
+
const last = lastRun.get(adapter.name) ?? 0
|
|
135
|
+
if (now - last < adapter.pollIntervalMs) continue
|
|
136
|
+
lastRun.set(adapter.name, now)
|
|
137
|
+
// pollAdapter 不 reject(错误以 {error} 返回),此处消费并记录,避免调度错误静默。
|
|
138
|
+
pollAdapter(worktree, adapter, changeId).then((r) => {
|
|
139
|
+
if (r.error) console.error(r.error)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}, tickMs)
|
|
143
|
+
timer.unref?.()
|
|
144
|
+
|
|
145
|
+
const handle: { stop(): void } = {
|
|
146
|
+
stop: () => {
|
|
147
|
+
clearInterval(timer)
|
|
148
|
+
if (activePollers.get(worktree) === handle) activePollers.delete(worktree)
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
activePollers.set(worktree, handle)
|
|
152
|
+
return handle
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 清理全部活跃轮询器(测试隔离用,非编排业务接口)。 */
|
|
156
|
+
export function __resetPoller(): void {
|
|
157
|
+
for (const { stop } of activePollers.values()) stop()
|
|
158
|
+
activePollers.clear()
|
|
159
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { WorkItem } from "./types.ts"
|
|
2
|
+
import { tagKey } from "./types.ts"
|
|
3
|
+
import { clearStepTags, isTerminalPhase } from "./engine.ts"
|
|
4
|
+
import { DIMENSION_AGENT_MAP, reviewLayerFromMetadata } from "../constants.ts"
|
|
5
|
+
import { REVIEW_DIMENSIONS } from "../types.ts"
|
|
6
|
+
import type { Dimension, ReviewLayer } from "../types.ts"
|
|
7
|
+
import { issueChildrenOf } from "../task-children.ts"
|
|
8
|
+
|
|
9
|
+
export interface ResetReviewTagsInput {
|
|
10
|
+
fixedSourcePhases: string[]
|
|
11
|
+
exemptSourcePhases: string[]
|
|
12
|
+
touchedQualityDims: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 解析 child 的 issue 归因字段(报源层/dimension/file/line),缺省 tool/style/""/0。
|
|
16
|
+
* 报源层由 metadata.source 经 agentToReviewLayer 反推,source_phase 仅作历史 state 兜底。 */
|
|
17
|
+
export function resolveChildIssueFields(child: WorkItem): {
|
|
18
|
+
sourcePhase: ReviewLayer
|
|
19
|
+
dimension: Dimension
|
|
20
|
+
file: string
|
|
21
|
+
line: number
|
|
22
|
+
} {
|
|
23
|
+
const sourcePhase = reviewLayerFromMetadata(child)
|
|
24
|
+
const dimension = (REVIEW_DIMENSIONS as readonly string[]).includes(child.metadata["dimension"] as string)
|
|
25
|
+
? (child.metadata["dimension"] as Dimension)
|
|
26
|
+
: "style"
|
|
27
|
+
return {
|
|
28
|
+
sourcePhase,
|
|
29
|
+
dimension,
|
|
30
|
+
file: typeof child.metadata["file"] === "string" ? child.metadata["file"] : "",
|
|
31
|
+
line: typeof child.metadata["line"] === "number" ? child.metadata["line"] : 0,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* dev 提交后按 issue 归因分层重置 review 验证标记(旧 review.ts devSubmitExecute:298-318 分层重置的精确复刻)。
|
|
37
|
+
* - 任一 fixed(代码变更)→ 清 verify_tool(tool 层确定性检查须基于最新代码重跑)
|
|
38
|
+
* - fixed 中属 task 层 → 额外清 verify_task
|
|
39
|
+
* - exempt 属 task 层 → 清 verify_tool + verify_task
|
|
40
|
+
* - exempt 属 tool 层 → 清 verify_tool
|
|
41
|
+
* - fixed 中属 quality 层且维度 dim → 只清 verify_quality 中 DIMENSION_AGENT_MAP[dim] 对应 agent 的 tag
|
|
42
|
+
* - exempt 不清 quality 维度 tag:豁免=接受现状、不修改代码,已 passed 维度无需重审
|
|
43
|
+
* - 豁免不改代码(exempt 无 tool/task 层)→ 不清 verify_tool/verify_task
|
|
44
|
+
*/
|
|
45
|
+
export function resetReviewTagsOnFix(item: WorkItem, input: ResetReviewTagsInput): void {
|
|
46
|
+
const fixed = new Set(input.fixedSourcePhases)
|
|
47
|
+
const exempt = new Set(input.exemptSourcePhases)
|
|
48
|
+
|
|
49
|
+
if (fixed.size > 0) {
|
|
50
|
+
clearStepTags(item, "verify_tool")
|
|
51
|
+
if (fixed.has("task")) clearStepTags(item, "verify_task")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (exempt.has("task")) {
|
|
55
|
+
clearStepTags(item, "verify_tool")
|
|
56
|
+
clearStepTags(item, "verify_task")
|
|
57
|
+
} else if (exempt.has("tool")) {
|
|
58
|
+
clearStepTags(item, "verify_tool")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const d of input.touchedQualityDims) {
|
|
62
|
+
const agent = DIMENSION_AGENT_MAP[d as Dimension]
|
|
63
|
+
if (agent) {
|
|
64
|
+
delete item.tags[tagKey("verify_quality", agent)]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* new_children 入库前去重(旧 deduplicateAndAddIssues 语义):
|
|
71
|
+
* 与「未终结态(非 done/cancelled)」的既有 issue child 按 (报源层/dimension/file/line/description) 比对,
|
|
72
|
+
* 命中即丢弃并计数;已终态 child 不参与判重(允许 reviewer 重报已关闭问题)。
|
|
73
|
+
* 比对集仅限 issue child——task child 的 description 可能与新 issue 恰好相同,混入会误吞新报。
|
|
74
|
+
*/
|
|
75
|
+
export function dedupeNewChildren(item: WorkItem, newChildren: WorkItem[]): { accepted: WorkItem[]; dedupedCount: number } {
|
|
76
|
+
const keyOf = (child: WorkItem): string => {
|
|
77
|
+
const f = resolveChildIssueFields(child)
|
|
78
|
+
return [f.sourcePhase, f.dimension, f.file, f.line, child.description].join("\u0000")
|
|
79
|
+
}
|
|
80
|
+
const accepted: WorkItem[] = []
|
|
81
|
+
let dedupedCount = 0
|
|
82
|
+
const existing = issueChildrenOf(item)
|
|
83
|
+
for (const nc of newChildren) {
|
|
84
|
+
const key = keyOf(nc)
|
|
85
|
+
const duplicate = existing.some((child) => !isTerminalPhase(child.phase) && keyOf(child) === key)
|
|
86
|
+
if (duplicate) {
|
|
87
|
+
dedupedCount++
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
accepted.push(nc)
|
|
91
|
+
}
|
|
92
|
+
return { accepted, dedupedCount }
|
|
93
|
+
}
|