@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,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zcode 适配器:ZCode 官方插件包生成(Plugin 机制,zcode.z.ai/en/docs/plugin)。
|
|
3
|
+
* 复用共享生成器 src/adapters/plugin-common/,差异参数:清单目录 .zcode-plugin/。
|
|
4
|
+
*
|
|
5
|
+
* 与 claude code / codex 的文件分发形态不同:zcode 不再写 ~/.zcode 与项目 .zcode/config.json,
|
|
6
|
+
* 插件安装/启用/禁用/卸载均为 GUI 动作,子代理为插件只读形态。
|
|
7
|
+
*
|
|
8
|
+
* 已知限制(接入文档注明):
|
|
9
|
+
* - zcode 自定义子代理为 Beta,子代理提问能力未经官方确认——默认无人值守规避;
|
|
10
|
+
* - GUI 安装动作为人工步骤,本适配器仅保证生成结构与官方 schema 一致。
|
|
11
|
+
*/
|
|
12
|
+
import { resolve } from "../agent-md.ts"
|
|
13
|
+
import {
|
|
14
|
+
buildPluginPackage,
|
|
15
|
+
type PluginPackageResult,
|
|
16
|
+
} from "../plugin-common/index.ts"
|
|
17
|
+
|
|
18
|
+
/** 默认插件包输出目录(dist/ 已 gitignore,生成物不入库)。 */
|
|
19
|
+
export const ZCODE_PLUGIN_DIR = resolve("dist", "zcode-plugin")
|
|
20
|
+
const MANIFEST_DIR_NAME = ".zcode-plugin"
|
|
21
|
+
|
|
22
|
+
export type ZcodePluginBuildResult = PluginPackageResult
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 生成 ZCode 官方插件包到 outDir(默认 dist/zcode-plugin/):
|
|
26
|
+
* .zcode-plugin/plugin.json 清单、agents/*.md、skills/<名>/、assets/workflows/、.mcp.json、
|
|
27
|
+
* .mcp-server/cli.mjs bundle。
|
|
28
|
+
* 返回生成的组件清单供调用方/测试断言。
|
|
29
|
+
*/
|
|
30
|
+
export function buildZcodePlugin(outDir: string = ZCODE_PLUGIN_DIR): ZcodePluginBuildResult {
|
|
31
|
+
return buildPluginPackage({ outDir, manifestDirName: MANIFEST_DIR_NAME })
|
|
32
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReviewDimension, ReviewLayer } from "./types.ts"
|
|
2
|
+
import type { WorkItem } from "./workflow/types.ts"
|
|
3
|
+
|
|
4
|
+
export const STATE_DIR_NAME = "openspec"
|
|
5
|
+
export const STATE_SUBDIR_NAME = "states"
|
|
6
|
+
/** 旧布局 .opencode/.orchestrate_state(读取兼容 + 首次新目录写入时幂等迁移)。 */
|
|
7
|
+
export const LEGACY_STATE_DIR_NAME = ".opencode"
|
|
8
|
+
export const LEGACY_STATE_SUBDIR_NAME = ".orchestrate_state"
|
|
9
|
+
export const SEVERITY_LEVELS = ["Critical", "High", "Medium", "Low", "Info"] as const
|
|
10
|
+
export const BLOCKING_SEVERITIES = ["Critical", "High", "Medium", "Low"] as const
|
|
11
|
+
|
|
12
|
+
export const DIMENSION_AGENT_MAP: Record<ReviewDimension, string> = {
|
|
13
|
+
style: "openspec-reviewer-style",
|
|
14
|
+
architecture: "openspec-reviewer-architecture",
|
|
15
|
+
performance: "openspec-reviewer-performance",
|
|
16
|
+
security: "openspec-reviewer-security",
|
|
17
|
+
maintainability: "openspec-reviewer-maintainability",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** agent → quality 维度反查(DIMENSION_AGENT_MAP 值反向映射),未命中返回 undefined。 */
|
|
21
|
+
export function agentToReviewDimension(agent: string): ReviewDimension | undefined {
|
|
22
|
+
return (Object.keys(DIMENSION_AGENT_MAP) as ReviewDimension[]).find((d) => DIMENSION_AGENT_MAP[d] === agent)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** agent → issue 报源层(tool/task/quality):tool/task reviewer 直接映射,quality reviewer 经维度反查命中即 quality。其余 agent 返回 undefined。 */
|
|
26
|
+
export function agentToReviewLayer(agent: string | undefined): ReviewLayer | undefined {
|
|
27
|
+
if (!agent) return undefined
|
|
28
|
+
if (agent === "openspec-reviewer-tool") return "tool"
|
|
29
|
+
if (agent === "openspec-reviewer-task") return "task"
|
|
30
|
+
if (agentToReviewDimension(agent)) return "quality"
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 安全读取 issue child 的报源 agent(metadata.source),非 string 返回 undefined。 */
|
|
35
|
+
export function readIssueSource(child: WorkItem): string | undefined {
|
|
36
|
+
return typeof child.metadata["source"] === "string" ? child.metadata["source"] : undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 从 child.metadata 解析 issue 报源层:source 反推 → 旧 source_phase 兼容历史 state → 缺省 tool。 */
|
|
40
|
+
export function reviewLayerFromMetadata(child: WorkItem): ReviewLayer {
|
|
41
|
+
const source = readIssueSource(child)
|
|
42
|
+
const bySource = agentToReviewLayer(source)
|
|
43
|
+
if (bySource) return bySource
|
|
44
|
+
const rawPhase = child.metadata["source_phase"]
|
|
45
|
+
return rawPhase === "tool" || rawPhase === "task" || rawPhase === "quality" ? rawPhase : "tool"
|
|
46
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { OrchestrateState } from "./types.ts"
|
|
2
|
+
import { readStateByWorktree, readStateByChangeId, getStateDir } from "./state.ts"
|
|
3
|
+
import { readdirSync, existsSync } from "node:fs"
|
|
4
|
+
import { readFile } from "node:fs/promises"
|
|
5
|
+
import path from "path"
|
|
6
|
+
import type { WorkItem, WorkItemType, WorkItemPhase, Verdict } from "./workflow/types.ts"
|
|
7
|
+
|
|
8
|
+
/** 看板卡片投影:WorkItem → 前端可渲染结构。 */
|
|
9
|
+
export interface WorkItemCard {
|
|
10
|
+
id: string
|
|
11
|
+
type: WorkItemType
|
|
12
|
+
title: string
|
|
13
|
+
description: string
|
|
14
|
+
phase: WorkItemPhase
|
|
15
|
+
suspended: boolean
|
|
16
|
+
suspendReason?: string
|
|
17
|
+
currentStep: string | null
|
|
18
|
+
labels: string[]
|
|
19
|
+
source: string
|
|
20
|
+
severity?: string
|
|
21
|
+
agentVerdicts: Array<{ stepId: string; agentKey: string; verdict: Verdict }>
|
|
22
|
+
children: Array<{ id: string; type: WorkItemType; title: string; phase: WorkItemPhase; severity?: string }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** taskGroup → 看板投影(旧三面板视图已随单轨化移除)。 */
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* WorkItem → 看板卡片。
|
|
29
|
+
* tags 键为 `${stepId}:${agentKey}`,拆解为可渲染的 agent 裁决数组;
|
|
30
|
+
* metadata 仅暴露无下划线前缀的业务字段(suspend_reason),内部字段(_ 前缀)不泄露。
|
|
31
|
+
*/
|
|
32
|
+
function toWorkItemCard(item: WorkItem): WorkItemCard {
|
|
33
|
+
const agentVerdicts = Object.entries(item.tags).map(([key, verdict]) => {
|
|
34
|
+
const sep = key.indexOf(":")
|
|
35
|
+
return {
|
|
36
|
+
stepId: sep === -1 ? key : key.slice(0, sep),
|
|
37
|
+
agentKey: sep === -1 ? key : key.slice(sep + 1),
|
|
38
|
+
verdict,
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
const suspendReason =
|
|
42
|
+
typeof item.metadata["suspend_reason"] === "string" ? (item.metadata["suspend_reason"] as string) : undefined
|
|
43
|
+
return {
|
|
44
|
+
id: item.id,
|
|
45
|
+
type: item.type,
|
|
46
|
+
title: item.title,
|
|
47
|
+
description: item.description,
|
|
48
|
+
phase: item.phase,
|
|
49
|
+
suspended: item.suspended,
|
|
50
|
+
suspendReason,
|
|
51
|
+
currentStep: item.currentStep,
|
|
52
|
+
labels: item.labels,
|
|
53
|
+
source: item.source,
|
|
54
|
+
severity: item.severity,
|
|
55
|
+
agentVerdicts,
|
|
56
|
+
children: item.children.map((child) => ({
|
|
57
|
+
id: child.id,
|
|
58
|
+
type: child.type,
|
|
59
|
+
title: child.title,
|
|
60
|
+
phase: child.phase,
|
|
61
|
+
severity: child.severity,
|
|
62
|
+
})),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** OrchestrateState → 看板投影(workItems 单轨 + workItemCards 5 列视图)。 */
|
|
67
|
+
function projectState(state: OrchestrateState) {
|
|
68
|
+
return {
|
|
69
|
+
active: true,
|
|
70
|
+
changeId: state.changeId,
|
|
71
|
+
currentTaskGroupId: state.taskGroupId,
|
|
72
|
+
baseBranch: state.baseBranch,
|
|
73
|
+
createdAt: state.createdAt,
|
|
74
|
+
updatedAt: state.updatedAt,
|
|
75
|
+
workItems: state.workItems,
|
|
76
|
+
workItemCards: state.workItems.map(toWorkItemCard),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function readDashboardState(worktree: string, changeId?: string) {
|
|
81
|
+
if (changeId) {
|
|
82
|
+
const state = await readStateByWorktree(worktree, changeId)
|
|
83
|
+
if (!state) return null
|
|
84
|
+
return projectState(state)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const stateDir = getStateDir(worktree)
|
|
88
|
+
if (!existsSync(stateDir)) return null
|
|
89
|
+
const files = readdirSync(stateDir).filter(f => f.endsWith(".json"))
|
|
90
|
+
const results: ReturnType<typeof projectState>[] = []
|
|
91
|
+
for (const f of files) {
|
|
92
|
+
try {
|
|
93
|
+
const raw = await readFile(path.join(stateDir, f), "utf-8")
|
|
94
|
+
const state = JSON.parse(raw) as OrchestrateState
|
|
95
|
+
if (!state.changeId) continue
|
|
96
|
+
results.push(projectState(state))
|
|
97
|
+
} catch { /* skip unreadable */ }
|
|
98
|
+
}
|
|
99
|
+
if (results.length === 1) return results[0]
|
|
100
|
+
return results.length > 0 ? results : null
|
|
101
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { TaskGroupState, IssueItem, OrchestrateState, Phase, QualityLayerProgress, ExecutionBoundary, BlockerItem } from "./types.ts"
|
|
2
|
+
import { BLOCKING_SEVERITIES } from "./constants.ts"
|
|
3
|
+
import type { WorkItem, WorkItemPhase } from "./workflow/types.ts"
|
|
4
|
+
import type { ToolContext } from "./tools/types.ts"
|
|
5
|
+
import { EXEMPT_REQUEST_KEY } from "./workflow/types.ts"
|
|
6
|
+
import { resolveChildIssueFields } from "./workflow/reset.ts"
|
|
7
|
+
import { taskListOf, issueChildrenOf } from "./task-children.ts"
|
|
8
|
+
|
|
9
|
+
export function createEmptyQualityProgress(): QualityLayerProgress {
|
|
10
|
+
return {
|
|
11
|
+
style: "pending",
|
|
12
|
+
architecture: "pending",
|
|
13
|
+
performance: "pending",
|
|
14
|
+
security: "pending",
|
|
15
|
+
maintainability: "pending",
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function blockingIssues<T extends { severity: string; status?: string; sourcePhase?: string; dimension?: string }>(
|
|
20
|
+
issues: T[],
|
|
21
|
+
sourcePhase?: string,
|
|
22
|
+
dimension?: string,
|
|
23
|
+
): T[] {
|
|
24
|
+
return issues.filter(
|
|
25
|
+
(i) =>
|
|
26
|
+
(!sourcePhase || i.sourcePhase === sourcePhase) &&
|
|
27
|
+
(!dimension || i.dimension === dimension) &&
|
|
28
|
+
isStatusUnresolved(i.status) &&
|
|
29
|
+
isBlockingIssue(i)
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function hasBlockingIssues(issues: Array<{ severity: string; status?: string; sourcePhase?: string; dimension?: string }>, sourcePhase?: string, dimension?: string): boolean {
|
|
34
|
+
return blockingIssues(issues, sourcePhase, dimension).length > 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isBlockingIssue(i: { severity: string }): boolean {
|
|
38
|
+
return (BLOCKING_SEVERITIES as readonly string[]).includes(i.severity)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const ISSUE_UNRESOLVED_STATUSES = ["open", "rejected", "submitted", "exemption_requested"] as const
|
|
42
|
+
|
|
43
|
+
export function isStatusUnresolved(status?: string): boolean {
|
|
44
|
+
return !status || (ISSUE_UNRESOLVED_STATUSES as readonly string[]).includes(status)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 独占工具权限校验:仅「编排视角」调用者(各 agent 主代理)可调用。 */
|
|
48
|
+
export function assertOrchestrator(ctx: ToolContext, toolName: string): void {
|
|
49
|
+
if (!ctx.orchestrator) {
|
|
50
|
+
throw new Error(`工具 "${toolName}" 仅限编排者(主代理)调用,当前调用者为 "${ctx.agent}"。`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function assertAgent(agent: string, toolName: string, allowedAgents: string[]): void {
|
|
55
|
+
if (!allowedAgents.includes(agent)) {
|
|
56
|
+
throw new Error(`工具 "${toolName}" 仅限 [${allowedAgents.join(", ")}] 调用,当前调用者为 "${agent}"。`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function findTaskGroup(state: OrchestrateState, id: string): TaskGroupState {
|
|
61
|
+
const item = state.workItems.find((w) => w.id === `task:${id}`)
|
|
62
|
+
if (!item) throw new Error(`任务组 "${id}" 不在任务清单中。`)
|
|
63
|
+
return taskGroupFromWorkItem(item)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── 单轨只读投影:workItems(事实源)→ TaskGroupState(旧流工具/视图读侧兼容)───
|
|
67
|
+
|
|
68
|
+
function childPhaseToIssueStatus(phase: WorkItemPhase): IssueItem["status"] {
|
|
69
|
+
switch (phase) {
|
|
70
|
+
case "done": return "verified"
|
|
71
|
+
case "cancelled": return "exempted"
|
|
72
|
+
case "review": return "submitted"
|
|
73
|
+
default: return "open"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** child → IssueItem 投影(旧流工具/视图读侧兼容,children 为事实源)。 */
|
|
78
|
+
function projectIssueFromChild(child: WorkItem): IssueItem {
|
|
79
|
+
const f = resolveChildIssueFields(child)
|
|
80
|
+
const baseStatus = childPhaseToIssueStatus(child.phase)
|
|
81
|
+
const hasExemptRequest = child.metadata[EXEMPT_REQUEST_KEY] !== undefined
|
|
82
|
+
return {
|
|
83
|
+
id: child.externalId ?? child.id.replace(/^issue:/, ""),
|
|
84
|
+
dimension: f.dimension,
|
|
85
|
+
sourcePhase: f.sourcePhase,
|
|
86
|
+
severity: child.severity ?? "Info",
|
|
87
|
+
file: f.file,
|
|
88
|
+
line: f.line,
|
|
89
|
+
description: child.description,
|
|
90
|
+
suggestion: typeof child.metadata["suggestion"] === "string" ? child.metadata["suggestion"] : "",
|
|
91
|
+
// 待裁定豁免项(exempt_request 标记)无条件投影 exemption_requested:申请豁免的 issue 已进入
|
|
92
|
+
// review(待裁定)态,baseStatus 为 submitted,若限定 open 态会丢失豁免申请语义(被误标为待复核)。
|
|
93
|
+
status: hasExemptRequest ? "exemption_requested" : baseStatus,
|
|
94
|
+
refixCount: typeof child.metadata["refix_count"] === "number" ? child.metadata["refix_count"] : 0,
|
|
95
|
+
rootCauseGuess: typeof child.metadata["root_cause_guess"] === "string" ? child.metadata["root_cause_guess"] : null,
|
|
96
|
+
exemptReason: typeof child.metadata["exempt_reason"] === "string" ? child.metadata["exempt_reason"] : null,
|
|
97
|
+
rejectReason: typeof child.metadata["reject_reason"] === "string" ? child.metadata["reject_reason"] : null,
|
|
98
|
+
rule: typeof child.metadata["rule"] === "string" ? child.metadata["rule"] : undefined,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** WorkItem phase → 旧 tg.status(投影用;done 归 review 待收尾,cancelled 归 completed)。 */
|
|
103
|
+
function workItemPhaseToTaskGroupStatus(phase: WorkItemPhase): Phase {
|
|
104
|
+
switch (phase) {
|
|
105
|
+
case "todo": return "task_analysis"
|
|
106
|
+
case "in_progress": return "dev_impl"
|
|
107
|
+
case "review":
|
|
108
|
+
case "done": return "review"
|
|
109
|
+
default: return "completed"
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* workItems(单轨事实源)→ TaskGroupState 只读投影。
|
|
115
|
+
* 供旧流工具(arch_submit 等)与旧视图读侧兼容:字段全部由 WorkItem.metadata/children 派生,
|
|
116
|
+
* 变更不写回(单轨下 workItems 为唯一事实源)。reviews/phases 等旧结构无对应新流数据时给空值。
|
|
117
|
+
*/
|
|
118
|
+
export function taskGroupFromWorkItem(item: WorkItem): TaskGroupState {
|
|
119
|
+
const m = item.metadata
|
|
120
|
+
const tasks = taskListOf(item)
|
|
121
|
+
return {
|
|
122
|
+
id: item.externalId ?? item.id.replace(/^task:/, ""),
|
|
123
|
+
name: typeof m["name"] === "string" ? m["name"] : item.title,
|
|
124
|
+
taskCount: typeof m["task_count"] === "number" ? m["task_count"] : tasks.length,
|
|
125
|
+
worktreePath: typeof m["worktree_path"] === "string" ? m["worktree_path"] : null,
|
|
126
|
+
branchName: typeof m["branch_name"] === "string" ? m["branch_name"] : null,
|
|
127
|
+
baseRef: typeof m["base_ref"] === "string" ? m["base_ref"] : null,
|
|
128
|
+
executionBoundary: (m["execution_boundary"] as ExecutionBoundary) ?? null,
|
|
129
|
+
relevantSpecs: Array.isArray(m["relevant_specs"]) ? (m["relevant_specs"] as string[]) : [],
|
|
130
|
+
status: workItemPhaseToTaskGroupStatus(item.phase),
|
|
131
|
+
phases: {
|
|
132
|
+
architect_review: { completed: false },
|
|
133
|
+
review: {
|
|
134
|
+
retryCount: 0,
|
|
135
|
+
lastResolvedRetryCount: 0,
|
|
136
|
+
tool: { completed: false },
|
|
137
|
+
task: { completed: false },
|
|
138
|
+
quality: { progress: createEmptyQualityProgress() },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
tasks,
|
|
142
|
+
issues: issueChildrenOf(item).map(projectIssueFromChild),
|
|
143
|
+
blockers: Array.isArray(m["blockers"]) ? (m["blockers"] as BlockerItem[]) : [],
|
|
144
|
+
agentSummaries: typeof m["agent_summaries"] === "object" ? (m["agent_summaries"] as Record<string, string>) : undefined,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import { mkdirSync, writeFileSync, renameSync } from "node:fs"
|
|
3
|
+
import { readFile } from "node:fs/promises"
|
|
4
|
+
import type { WorkItem } from "./workflow/types.ts"
|
|
5
|
+
import { resolveChildIssueFields } from "./workflow/reset.ts"
|
|
6
|
+
import { getStateDir, resolveStateRoot, acquireLock, releaseLock } from "./state.ts"
|
|
7
|
+
import type { Dimension, ReviewLayer } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 项目级跨 change 豁免清单(跨 change 共享,写主仓库根的状态目录)。
|
|
11
|
+
*
|
|
12
|
+
* 痛点背景:worktree 模式下每个新 change 是全新状态账本 + 全新 Sonar 项目,tool review 全量扫描
|
|
13
|
+
* 会把存量已裁定豁免的安全问题重新报为阻塞 issue,dev 每轮重新豁免。此模块把「裁定认可(dismissed)」
|
|
14
|
+
* 的豁免结论落为 (rule+file+line) 清单,后续 change 的 tool review 提报命中清单时降为 Info 级,
|
|
15
|
+
* 不阻塞、无需重复豁免。
|
|
16
|
+
*
|
|
17
|
+
* 设计约束(AGENTS.md):
|
|
18
|
+
* - 持久化归属工具/状态层,匹配语义归属 skill 层,展示归属视图层;
|
|
19
|
+
* - 并发安全:专用锁(exemptions.lock 目录锁)包住读-改-写,不能用 per-change 的 review 锁;
|
|
20
|
+
* - 原子写:临时文件 + rename 覆盖,防崩溃损坏共享清单;
|
|
21
|
+
* - key 构造只在此定义(DRY),rule 缺失时宁漏勿误(不匹配、不写清单)。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const EXEMPTIONS_FILE = "exemptions.json"
|
|
25
|
+
export const EXEMPTIONS_LOCK = "exemptions.lock"
|
|
26
|
+
export const EXEMPTIONS_SCHEMA_VERSION = 1
|
|
27
|
+
/** 命中项目级豁免清单的标记键(写入 child.metadata,供视图统计与标注;不参与匹配)。 */
|
|
28
|
+
export const EXEMPTED_HIT_KEY = "exempted_hit"
|
|
29
|
+
|
|
30
|
+
export interface ExemptionRecord {
|
|
31
|
+
key: string
|
|
32
|
+
rule?: string
|
|
33
|
+
file?: string
|
|
34
|
+
line?: number
|
|
35
|
+
sourcePhase: ReviewLayer
|
|
36
|
+
dimension: Dimension
|
|
37
|
+
severity: string
|
|
38
|
+
description: string
|
|
39
|
+
exemptedAt: string
|
|
40
|
+
exemptedBy: string
|
|
41
|
+
changeId: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ExemptionStore {
|
|
45
|
+
version: number
|
|
46
|
+
items: ExemptionRecord[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 豁免 key 单一事实源:rule+file+line 拼接。rule 缺失时返回 null(宁漏勿误:不匹配、不写清单)。 */
|
|
50
|
+
export function exemptionKeyOf(rule: string | undefined, file: string, line: number): string | null {
|
|
51
|
+
if (rule === undefined || rule === "") return null
|
|
52
|
+
return [rule, file, line].join("\u0000")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function emptyStore(): ExemptionStore {
|
|
56
|
+
return { version: EXEMPTIONS_SCHEMA_VERSION, items: [] }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function exemptionsDir(worktree: string): Promise<string> {
|
|
60
|
+
const root = await resolveStateRoot(worktree)
|
|
61
|
+
return getStateDir(root)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 读取跨 change 豁免清单。文件缺失/损坏返回空清单;写侧原子写保证读到完整文件,读不持锁。 */
|
|
65
|
+
export async function readExemptions(worktree: string): Promise<ExemptionStore> {
|
|
66
|
+
const fp = path.join(await exemptionsDir(worktree), EXEMPTIONS_FILE)
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(await readFile(fp, "utf-8")) as Partial<ExemptionStore>
|
|
69
|
+
if (parsed && Array.isArray(parsed.items)) {
|
|
70
|
+
return { version: parsed.version ?? EXEMPTIONS_SCHEMA_VERSION, items: parsed.items }
|
|
71
|
+
}
|
|
72
|
+
return emptyStore()
|
|
73
|
+
} catch {
|
|
74
|
+
return emptyStore()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 原子写豁免清单:临时文件 + rename 覆盖(不能裸 writeFile 整文件覆盖,防崩溃损坏共享清单)。 */
|
|
79
|
+
export async function writeExemptions(worktree: string, store: ExemptionStore): Promise<void> {
|
|
80
|
+
const dir = await exemptionsDir(worktree)
|
|
81
|
+
mkdirSync(dir, { recursive: true })
|
|
82
|
+
const target = path.join(dir, EXEMPTIONS_FILE)
|
|
83
|
+
const tmp = path.join(dir, `${EXEMPTIONS_FILE}.tmp`)
|
|
84
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2))
|
|
85
|
+
renameSync(tmp, target)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 幂等 upsert:同 key 命中即整体刷新(不重复累积);专用锁包住读-改-写,多 change 并行写不丢更新。 */
|
|
89
|
+
export async function upsertExemption(worktree: string, record: ExemptionRecord): Promise<void> {
|
|
90
|
+
const dir = await exemptionsDir(worktree)
|
|
91
|
+
mkdirSync(dir, { recursive: true })
|
|
92
|
+
const lockPath = path.join(dir, EXEMPTIONS_LOCK)
|
|
93
|
+
await acquireLock(lockPath)
|
|
94
|
+
try {
|
|
95
|
+
const store = await readExemptions(worktree)
|
|
96
|
+
const idx = store.items.findIndex((i) => i.key === record.key)
|
|
97
|
+
if (idx >= 0) {
|
|
98
|
+
store.items[idx] = record
|
|
99
|
+
} else {
|
|
100
|
+
store.items.push(record)
|
|
101
|
+
}
|
|
102
|
+
await writeExemptions(worktree, store)
|
|
103
|
+
} finally {
|
|
104
|
+
releaseLock(lockPath)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 由被裁定(dismissed)的 issue child 组装豁免记录;rule 缺失时返回 null(不参与匹配)。 */
|
|
109
|
+
export function buildExemptionRecord(
|
|
110
|
+
child: WorkItem,
|
|
111
|
+
opts: { changeId: string; exemptedBy: string },
|
|
112
|
+
): ExemptionRecord | null {
|
|
113
|
+
const rule = typeof child.metadata["rule"] === "string" ? child.metadata["rule"] : undefined
|
|
114
|
+
const f = resolveChildIssueFields(child)
|
|
115
|
+
const key = exemptionKeyOf(rule, f.file, f.line)
|
|
116
|
+
if (!key) return null
|
|
117
|
+
return {
|
|
118
|
+
key,
|
|
119
|
+
rule,
|
|
120
|
+
file: f.file,
|
|
121
|
+
line: f.line,
|
|
122
|
+
sourcePhase: f.sourcePhase,
|
|
123
|
+
dimension: f.dimension,
|
|
124
|
+
severity: child.severity ?? "",
|
|
125
|
+
description: child.description,
|
|
126
|
+
exemptedAt: new Date().toISOString(),
|
|
127
|
+
exemptedBy: opts.exemptedBy,
|
|
128
|
+
changeId: opts.changeId,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** dismissed 裁定落库容错入口:清单写入失败仅告警,不阻断裁定主流程。 */
|
|
133
|
+
export async function writeDismissedExemption(
|
|
134
|
+
worktree: string,
|
|
135
|
+
child: WorkItem,
|
|
136
|
+
opts: { changeId: string; exemptedBy: string },
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
const record = buildExemptionRecord(child, opts)
|
|
139
|
+
if (!record) return
|
|
140
|
+
try {
|
|
141
|
+
await upsertExemption(worktree, record)
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.warn(`[exemptions] 项目级豁免清单写入失败(不影响裁定主流程):${(err as Error).message}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 命中降级:new_children 中 (rule+file+line) 命中清单的 issue 降为 Info 级并追加中性描述,
|
|
149
|
+
* 返回降级条数。必须在 assertFailedHasReason 之前调用——否则仅含存量豁免问题的 change 里
|
|
150
|
+
* reviewer 能 failed 却无真实待办,形成死锁。
|
|
151
|
+
*/
|
|
152
|
+
export function applyExemptionDowngrade(children: WorkItem[], store: ExemptionStore): number {
|
|
153
|
+
const keys = new Set(store.items.map((i) => i.key))
|
|
154
|
+
let downgraded = 0
|
|
155
|
+
for (const c of children) {
|
|
156
|
+
const rule = typeof c.metadata["rule"] === "string" ? c.metadata["rule"] : undefined
|
|
157
|
+
const file = typeof c.metadata["file"] === "string" ? c.metadata["file"] : ""
|
|
158
|
+
const line = typeof c.metadata["line"] === "number" ? c.metadata["line"] : 0
|
|
159
|
+
const key = exemptionKeyOf(rule, file, line)
|
|
160
|
+
if (key && keys.has(key)) {
|
|
161
|
+
c.severity = "Info"
|
|
162
|
+
c.metadata[EXEMPTED_HIT_KEY] = rule ?? true
|
|
163
|
+
c.description = `${c.description}\n\n> 命中项目级豁免清单${rule ? `(rule=${rule})` : ""},保持现状。`
|
|
164
|
+
downgraded++
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return downgraded
|
|
168
|
+
}
|