@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,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
+ }