@devcxl/opencode-cabbage 0.3.2 → 1.1.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 +34 -19
- package/README.zh.md +34 -19
- package/assets/agents/dev-lifecycle.md +60 -49
- package/assets/agents/team/architect.md +8 -1
- package/assets/agents/team/developer.md +89 -0
- package/assets/agents/team/goal-verify.md +5 -1
- package/assets/agents/team/reviewer.md +6 -0
- package/assets/commands/code.md +3 -2
- package/assets/commands/design.md +3 -2
- package/assets/commands/release.md +3 -2
- package/assets/commands/requirements.md +3 -2
- package/assets/commands/review.md +3 -2
- package/assets/commands/setup.md +3 -2
- package/assets/commands/tasks.md +2 -1
- package/assets/prompts/bootstrap.md +9 -8
- package/assets/prompts/stage-contract.md +55 -0
- package/assets/skills/flow-code/SKILL.md +38 -140
- package/assets/skills/flow-design/SKILL.md +28 -79
- package/assets/skills/flow-release/SKILL.md +31 -51
- package/assets/skills/flow-requirements/SKILL.md +31 -196
- package/assets/skills/flow-review/SKILL.md +26 -123
- package/assets/skills/flow-setup/SKILL.md +40 -43
- package/assets/skills/flow-tasks/SKILL.md +23 -153
- package/assets/skills/flow-tdd/SKILL.md +30 -31
- package/dist/kernel/caller.d.ts +48 -0
- package/dist/kernel/caller.js +88 -0
- package/dist/kernel/caller.js.map +1 -0
- package/dist/kernel/context.d.ts +27 -0
- package/dist/kernel/context.js +84 -0
- package/dist/kernel/context.js.map +1 -0
- package/dist/kernel/flow.d.ts +175 -0
- package/dist/kernel/flow.js +467 -0
- package/dist/kernel/flow.js.map +1 -0
- package/dist/kernel/legacy.d.ts +19 -0
- package/dist/kernel/legacy.js +34 -0
- package/dist/kernel/legacy.js.map +1 -0
- package/dist/kernel/mutex.d.ts +8 -0
- package/dist/kernel/mutex.js +23 -0
- package/dist/kernel/mutex.js.map +1 -0
- package/dist/kernel/permission.d.ts +21 -0
- package/dist/kernel/permission.js +45 -0
- package/dist/kernel/permission.js.map +1 -0
- package/dist/kernel/profile.d.ts +35 -0
- package/dist/kernel/profile.js +147 -0
- package/dist/kernel/profile.js.map +1 -0
- package/dist/kernel/records.d.ts +103 -0
- package/dist/kernel/records.js +240 -0
- package/dist/kernel/records.js.map +1 -0
- package/dist/kernel/release.d.ts +106 -0
- package/dist/kernel/release.js +241 -0
- package/dist/kernel/release.js.map +1 -0
- package/dist/kernel/review.d.ts +34 -0
- package/dist/kernel/review.js +82 -0
- package/dist/kernel/review.js.map +1 -0
- package/dist/kernel/session-index.d.ts +45 -0
- package/dist/kernel/session-index.js +136 -0
- package/dist/kernel/session-index.js.map +1 -0
- package/dist/kernel/setup.d.ts +95 -0
- package/dist/kernel/setup.js +401 -0
- package/dist/kernel/setup.js.map +1 -0
- package/dist/kernel/slug.d.ts +23 -0
- package/dist/kernel/slug.js +67 -0
- package/dist/kernel/slug.js.map +1 -0
- package/dist/kernel/task.d.ts +213 -0
- package/dist/kernel/task.js +873 -0
- package/dist/kernel/task.js.map +1 -0
- package/dist/{flowrun → kernel/tdd}/adapter.d.ts +1 -1
- package/dist/{flowrun → kernel/tdd}/adapter.js +27 -26
- package/dist/kernel/tdd/adapter.js.map +1 -0
- package/dist/{flowrun → kernel/tdd}/digest.d.ts +2 -1
- package/dist/{flowrun → kernel/tdd}/digest.js +6 -4
- package/dist/kernel/tdd/digest.js.map +1 -0
- package/dist/{flowrun → kernel/tdd}/evaluator.d.ts +1 -1
- package/dist/{flowrun → kernel/tdd}/evaluator.js +1 -1
- package/dist/kernel/tdd/evaluator.js.map +1 -0
- package/dist/kernel/tdd/evidence.d.ts +42 -0
- package/dist/kernel/tdd/evidence.js +48 -0
- package/dist/kernel/tdd/evidence.js.map +1 -0
- package/dist/{flowrun → kernel/tdd}/state.d.ts +2 -2
- package/dist/{flowrun → kernel/tdd}/state.js +1 -1
- package/dist/kernel/tdd/state.js.map +1 -0
- package/dist/kernel/types.d.ts +180 -0
- package/dist/kernel/types.js +2 -0
- package/dist/kernel/types.js.map +1 -0
- package/dist/kernel/worktree.d.ts +45 -0
- package/dist/kernel/worktree.js +156 -0
- package/dist/kernel/worktree.js.map +1 -0
- package/dist/plugin/agents.d.ts +5 -17
- package/dist/plugin/agents.js +0 -23
- package/dist/plugin/agents.js.map +1 -1
- package/dist/plugin/flow-control.d.ts +91 -0
- package/dist/plugin/flow-control.js +287 -0
- package/dist/plugin/flow-control.js.map +1 -0
- package/dist/plugin/goal.d.ts +17 -46
- package/dist/plugin/goal.js +47 -117
- package/dist/plugin/goal.js.map +1 -1
- package/dist/plugin/prompt-lint.js +78 -6
- package/dist/plugin/prompt-lint.js.map +1 -1
- package/dist/plugin/release-control.d.ts +39 -0
- package/dist/plugin/release-control.js +343 -0
- package/dist/plugin/release-control.js.map +1 -0
- package/dist/plugin/server.d.ts +19 -0
- package/dist/plugin/server.js +198 -320
- package/dist/plugin/server.js.map +1 -1
- package/dist/plugin/setup-control.d.ts +59 -0
- package/dist/plugin/setup-control.js +111 -0
- package/dist/plugin/setup-control.js.map +1 -0
- package/dist/plugin/shell.d.ts +7 -34
- package/dist/plugin/shell.js +12 -137
- package/dist/plugin/shell.js.map +1 -1
- package/dist/plugin/skills.d.ts +1 -1
- package/dist/plugin/skills.js +1 -7
- package/dist/plugin/skills.js.map +1 -1
- package/dist/plugin/task-control.d.ts +90 -0
- package/dist/plugin/task-control.js +208 -0
- package/dist/plugin/task-control.js.map +1 -0
- package/dist/plugin/tdd-checkpoint.d.ts +128 -0
- package/dist/plugin/tdd-checkpoint.js +664 -0
- package/dist/plugin/tdd-checkpoint.js.map +1 -0
- package/dist/util/gh.d.ts +5 -0
- package/dist/util/gh.js +12 -0
- package/dist/util/gh.js.map +1 -1
- package/dist/util/paths.d.ts +6 -2
- package/dist/util/paths.js +15 -7
- package/dist/util/paths.js.map +1 -1
- package/package.json +4 -4
- package/assets/agents/team/backend.md +0 -100
- package/assets/agents/team/frontend.md +0 -98
- package/assets/commands/handoff.md +0 -4
- package/assets/commands/test.md +0 -4
- package/assets/context/CONTEXT.md +0 -41
- package/assets/skills/flow-handoff/SKILL.md +0 -76
- package/assets/skills/flow-test/SKILL.md +0 -75
- package/dist/flowrun/adapter.js.map +0 -1
- package/dist/flowrun/audit.d.ts +0 -5
- package/dist/flowrun/audit.js +0 -67
- package/dist/flowrun/audit.js.map +0 -1
- package/dist/flowrun/ci.d.ts +0 -64
- package/dist/flowrun/ci.js +0 -184
- package/dist/flowrun/ci.js.map +0 -1
- package/dist/flowrun/coverage.d.ts +0 -70
- package/dist/flowrun/coverage.js +0 -207
- package/dist/flowrun/coverage.js.map +0 -1
- package/dist/flowrun/digest.js.map +0 -1
- package/dist/flowrun/evaluator.js.map +0 -1
- package/dist/flowrun/gate.d.ts +0 -28
- package/dist/flowrun/gate.js +0 -139
- package/dist/flowrun/gate.js.map +0 -1
- package/dist/flowrun/github.d.ts +0 -24
- package/dist/flowrun/github.js +0 -154
- package/dist/flowrun/github.js.map +0 -1
- package/dist/flowrun/index.d.ts +0 -14
- package/dist/flowrun/index.js +0 -15
- package/dist/flowrun/index.js.map +0 -1
- package/dist/flowrun/merge.d.ts +0 -57
- package/dist/flowrun/merge.js +0 -180
- package/dist/flowrun/merge.js.map +0 -1
- package/dist/flowrun/migration.d.ts +0 -11
- package/dist/flowrun/migration.js +0 -276
- package/dist/flowrun/migration.js.map +0 -1
- package/dist/flowrun/resilience.d.ts +0 -18
- package/dist/flowrun/resilience.js +0 -80
- package/dist/flowrun/resilience.js.map +0 -1
- package/dist/flowrun/state.js.map +0 -1
- package/dist/flowrun/transitions.d.ts +0 -66
- package/dist/flowrun/transitions.js +0 -168
- package/dist/flowrun/transitions.js.map +0 -1
- package/dist/flowrun/types.d.ts +0 -356
- package/dist/flowrun/types.js +0 -21
- package/dist/flowrun/types.js.map +0 -1
- package/dist/flowrun/validator.d.ts +0 -19
- package/dist/flowrun/validator.js +0 -209
- package/dist/flowrun/validator.js.map +0 -1
- package/dist/plugin/broker.d.ts +0 -73
- package/dist/plugin/broker.js +0 -140
- package/dist/plugin/broker.js.map +0 -1
- package/dist/plugin/tdd-tool.d.ts +0 -62
- package/dist/plugin/tdd-tool.js +0 -336
- package/dist/plugin/tdd-tool.js.map +0 -1
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 生命周期 kernel 逻辑(spec §2.3 task_control)。
|
|
3
|
+
* 八 op:create-task / start-task / submit-task / submit-review /
|
|
4
|
+
* merge-task / cancel-task / destroy-worktree / status-task。
|
|
5
|
+
* caller:primary 全量;developer 不允许调用 task_control(无门禁绕过面)。
|
|
6
|
+
* gh/git 调用走可注入 executor(测试用);records/worktree/merge 复用各自模块的 executor。
|
|
7
|
+
*/
|
|
8
|
+
import { exec } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { gh as ghCli, parsePrUrlNumber } from "../util/gh.js";
|
|
13
|
+
import { escapeShellArg } from "../util/shell.js";
|
|
14
|
+
import { pathExists } from "../util/fs.js";
|
|
15
|
+
import { PLUGIN_ID } from "../util/paths.js";
|
|
16
|
+
import { validateTitle, branchFor, worktreeFor } from "./slug.js";
|
|
17
|
+
import { readProjectProfile } from "./profile.js";
|
|
18
|
+
import { getCompletedStages } from "./flow.js";
|
|
19
|
+
import { worktreeStart, destroyWorktree } from "./worktree.js";
|
|
20
|
+
import { createTaskRecord, readFlowRecord, readTddEvidenceComment, extractEvidenceBlock } from "./records.js";
|
|
21
|
+
import { captureWorkspaceBaseline } from "./tdd/evidence.js";
|
|
22
|
+
import { evaluateTddCompliance } from "./tdd/evaluator.js";
|
|
23
|
+
// merge 门禁复用 kernel/review.ts 的既有实现(spec §4.1:mergeTaskPR / checkBranchProtection)
|
|
24
|
+
import { mergeTaskPR, checkBranchProtection } from "./review.js";
|
|
25
|
+
const execAsync = promisify(exec);
|
|
26
|
+
// ─── 常量 ───
|
|
27
|
+
/** Task Record 状态标签前缀:cabbage:task:<status> */
|
|
28
|
+
export const TASK_STATUS_LABEL_PREFIX = "cabbage:task:";
|
|
29
|
+
/** 默认最大并行 Task 数(PRD R7,Profile 可覆盖但内核按此判定) */
|
|
30
|
+
export const MAX_PARALLEL_TASKS = 5;
|
|
31
|
+
/** 内核内置风险 pattern(spec §12 #12:migrations/auth/permissions/release workflow/密钥/公共 API),与 Profile riskPatterns 合并 */
|
|
32
|
+
export const BUILTIN_RISK_PATTERNS = [
|
|
33
|
+
"**/migrations/**",
|
|
34
|
+
"**/auth/**",
|
|
35
|
+
"**/permissions/**",
|
|
36
|
+
".github/workflows/**",
|
|
37
|
+
"**/.env*",
|
|
38
|
+
"**/*.pem",
|
|
39
|
+
"**/*.key",
|
|
40
|
+
"src/**/api/**",
|
|
41
|
+
];
|
|
42
|
+
let taskGhExecutor = null;
|
|
43
|
+
let taskGitExecutor = null;
|
|
44
|
+
export function setTaskGhExecutor(fn) {
|
|
45
|
+
taskGhExecutor = fn;
|
|
46
|
+
}
|
|
47
|
+
export function setTaskGitExecutor(fn) {
|
|
48
|
+
taskGitExecutor = fn;
|
|
49
|
+
}
|
|
50
|
+
export async function taskGh(args, timeout) {
|
|
51
|
+
if (taskGhExecutor)
|
|
52
|
+
return taskGhExecutor(args);
|
|
53
|
+
return ghCli(args, timeout);
|
|
54
|
+
}
|
|
55
|
+
export async function taskGit(args) {
|
|
56
|
+
if (taskGitExecutor)
|
|
57
|
+
return taskGitExecutor(args);
|
|
58
|
+
return execAsync(`git ${args}`);
|
|
59
|
+
}
|
|
60
|
+
/** 在指定目录内执行 git(worktree 内 push/status 等) */
|
|
61
|
+
async function gitIn(dir, args) {
|
|
62
|
+
return taskGit(`-C '${escapeShellArg(dir)}' ${args}`);
|
|
63
|
+
}
|
|
64
|
+
// ─── Task Record body 模板与解析(纯函数) ───
|
|
65
|
+
/**
|
|
66
|
+
* 新建 Task Record 的 body:acceptance criteria + 依赖 + 状态 checklist + Closes 约定。
|
|
67
|
+
* criteria 渲染为 `- [ ] <id>: <description> [<verification>]`,可被 parseCriteriaFromBody 还原。
|
|
68
|
+
*/
|
|
69
|
+
export function buildTaskBody(slug, criteria, dependencies) {
|
|
70
|
+
const criteriaLines = criteria.length > 0
|
|
71
|
+
? criteria.map(c => `- [ ] ${c.id}: ${c.description} [${c.verification}]`).join("\n")
|
|
72
|
+
: "- [ ] TBD";
|
|
73
|
+
// 依赖:编号渲染 `- #12`;task_id(slug)渲染 `- @slug`(门禁时解析成编号)
|
|
74
|
+
const depLines = dependencies.length > 0
|
|
75
|
+
? dependencies.map(d => (typeof d === "number" ? `- #${d}` : `- @${d}`)).join("\n")
|
|
76
|
+
: "- none";
|
|
77
|
+
return [
|
|
78
|
+
`# Task: ${slug}`,
|
|
79
|
+
"",
|
|
80
|
+
"## Acceptance Criteria",
|
|
81
|
+
"",
|
|
82
|
+
criteriaLines,
|
|
83
|
+
"",
|
|
84
|
+
"## Dependencies",
|
|
85
|
+
"",
|
|
86
|
+
depLines,
|
|
87
|
+
"",
|
|
88
|
+
"## Status",
|
|
89
|
+
"",
|
|
90
|
+
"- [ ] running",
|
|
91
|
+
"- [ ] reviewing",
|
|
92
|
+
"- [ ] merged",
|
|
93
|
+
"",
|
|
94
|
+
"<!-- PR 约定:提交 PR 时 body 必须包含 `Closes #<issueNumber>` 以在合并时关闭本 Sub Issue -->",
|
|
95
|
+
"",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
/** TDD 跨批协议:Task Record body 内 TDD 上下文块 marker(tdd_checkpoint 工具解析;spec §2.3/§4) */
|
|
99
|
+
export const TASK_TDD_TAG = "<!-- cabbage-task-tdd -->";
|
|
100
|
+
/** 构造 TDD 上下文块 JSON(policy + criteria + worktreeDir),供 tdd_checkpoint resolveTaskContext 解析 */
|
|
101
|
+
export function buildTaskTddBlock(policy, criteria, worktreePath) {
|
|
102
|
+
return `${TASK_TDD_TAG}\n${JSON.stringify({ schema: 1, policy, criteria, worktreeDir: worktreePath }, null, 2)}`;
|
|
103
|
+
}
|
|
104
|
+
/** 更新 Sub Issue body(保留既有内容,替换/追加 TDD 块) */
|
|
105
|
+
async function updateTaskBody(issueNumber, newBody) {
|
|
106
|
+
try {
|
|
107
|
+
const bodyArg = escapeShellArg(newBody);
|
|
108
|
+
await taskGh(`api repos/{owner}/{repo}/issues/${issueNumber} -X PATCH -f body='${bodyArg}' --silent`);
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return { ok: false, error: String(err) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** 解析 create-task 传入的 acceptance_criteria JSON(字符串数组 → AcceptanceCriterion[]);非法返回 null */
|
|
116
|
+
export function parseAcceptanceCriteria(json) {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(json);
|
|
119
|
+
if (!Array.isArray(parsed))
|
|
120
|
+
return null;
|
|
121
|
+
const criteria = [];
|
|
122
|
+
for (const item of parsed) {
|
|
123
|
+
if (!item || typeof item !== "object")
|
|
124
|
+
return null;
|
|
125
|
+
const { id, description, verification } = item;
|
|
126
|
+
if (typeof id !== "string" ||
|
|
127
|
+
typeof description !== "string" ||
|
|
128
|
+
(verification !== "tdd" && verification !== "regression" && verification !== "manual")) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
criteria.push({ id, description, verification });
|
|
132
|
+
}
|
|
133
|
+
return criteria;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** 从 Task Record body 还原 acceptance criteria(buildTaskBody 的逆操作) */
|
|
140
|
+
export function parseCriteriaFromBody(body) {
|
|
141
|
+
const criteria = [];
|
|
142
|
+
for (const line of body.split("\n")) {
|
|
143
|
+
const match = /^-\s+\[\s\]\s+(\S+):\s+(.+)\s+\[(\w+)\]$/.exec(line);
|
|
144
|
+
if (match && (match[3] === "tdd" || match[3] === "regression" || match[3] === "manual")) {
|
|
145
|
+
criteria.push({ id: match[1], description: match[2].trim(), verification: match[3] });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return criteria;
|
|
149
|
+
}
|
|
150
|
+
/** 从 Task Record body 的 Dependencies 区块解析依赖:`- #12` → 编号;`- @slug` → slug(门禁时解析) */
|
|
151
|
+
export function parseDependenciesFromBody(body) {
|
|
152
|
+
const deps = [];
|
|
153
|
+
for (const line of body.split("\n")) {
|
|
154
|
+
const numMatch = /^-\s+#(\d+)$/.exec(line.trim());
|
|
155
|
+
if (numMatch) {
|
|
156
|
+
deps.push(Number(numMatch[1]));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const slugMatch = /^-\s+@([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(line.trim());
|
|
160
|
+
if (slugMatch)
|
|
161
|
+
deps.push(slugMatch[1]);
|
|
162
|
+
}
|
|
163
|
+
return deps;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 解析 create-task 的 depends_on 参数:支持 Issue 编号("12" / "#12")与
|
|
167
|
+
* task_id(slug,如 "user-auth")混合,以逗号分隔。非法项忽略。
|
|
168
|
+
* 返回 string 表示 slug(需在门禁时解析成编号)。
|
|
169
|
+
*/
|
|
170
|
+
export function parseDependenciesArg(raw) {
|
|
171
|
+
if (!raw || raw.trim() === "")
|
|
172
|
+
return [];
|
|
173
|
+
const deps = [];
|
|
174
|
+
for (const part of raw.split(",")) {
|
|
175
|
+
const trimmed = part.trim().replace(/^#/, "");
|
|
176
|
+
const n = Number(trimmed);
|
|
177
|
+
if (Number.isInteger(n) && n > 0) {
|
|
178
|
+
deps.push(n);
|
|
179
|
+
}
|
|
180
|
+
else if (trimmed !== "") {
|
|
181
|
+
deps.push(trimmed);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return deps;
|
|
185
|
+
}
|
|
186
|
+
// ─── TDD policy 冻结(start-task) ───
|
|
187
|
+
/** 从 Project Profile 冻结 TDD policy:enforcement 固定 runtime(PRD R6 行为变更强制 Runtime TDD) */
|
|
188
|
+
export function freezeTddPolicy(profile) {
|
|
189
|
+
const runner = profile.testCommand
|
|
190
|
+
? {
|
|
191
|
+
adapter: "vitest",
|
|
192
|
+
baseCommand: profile.testCommand,
|
|
193
|
+
timeoutMs: 120_000,
|
|
194
|
+
executionInputPatterns: ["vitest.config.*", "vitest.*.config.*", "jest.config.*", "package.json"],
|
|
195
|
+
}
|
|
196
|
+
: null;
|
|
197
|
+
return {
|
|
198
|
+
mode: profile.tddDefaultMode ?? "strict",
|
|
199
|
+
enforcement: "runtime",
|
|
200
|
+
runner,
|
|
201
|
+
testFilePatterns: profile.testFilePatterns,
|
|
202
|
+
implementationFilePatterns: profile.implementationFilePatterns,
|
|
203
|
+
generatedArtifactPatterns: [],
|
|
204
|
+
exception: null,
|
|
205
|
+
source: { manifestPath: "AGENTS.md", revisionSha: "frozen-at-start-task" },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// ─── 风险双层判定(merge-task) ───
|
|
209
|
+
/** 轻量 glob 匹配(支持 **、*、?),风险 pattern 匹配专用 */
|
|
210
|
+
export function matchRiskPattern(filePath, pattern) {
|
|
211
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
212
|
+
let regexStr = "";
|
|
213
|
+
let i = 0;
|
|
214
|
+
while (i < pattern.length) {
|
|
215
|
+
if (pattern[i] === "*" && pattern[i + 1] === "*") {
|
|
216
|
+
i += 2;
|
|
217
|
+
if (i < pattern.length && pattern[i] === "/") {
|
|
218
|
+
regexStr += "(.*/)?";
|
|
219
|
+
i++;
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
regexStr += ".*";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else if (pattern[i] === "*") {
|
|
226
|
+
regexStr += "[^/]*";
|
|
227
|
+
i++;
|
|
228
|
+
}
|
|
229
|
+
else if (pattern[i] === ".") {
|
|
230
|
+
regexStr += "\\.";
|
|
231
|
+
i++;
|
|
232
|
+
}
|
|
233
|
+
else if (pattern[i] === "?") {
|
|
234
|
+
regexStr += "[^/]";
|
|
235
|
+
i++;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
if ("+^$(){}[]|\\".includes(pattern[i]))
|
|
239
|
+
regexStr += "\\" + pattern[i];
|
|
240
|
+
else
|
|
241
|
+
regexStr += pattern[i];
|
|
242
|
+
i++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return new RegExp(`^${regexStr}$`).test(normalized);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 风险双层判定:模型初判 + 内核按实际 diff 升级,只升不降(PRD R8 / spec §12 #12)。
|
|
249
|
+
* modelRisk 为 high → high;否则 diff 命中任一 risk pattern → high;否则 low。
|
|
250
|
+
*/
|
|
251
|
+
export function escalateRisk(modelRisk, changedFiles, riskPatterns) {
|
|
252
|
+
if (modelRisk === "high")
|
|
253
|
+
return "high";
|
|
254
|
+
const hit = changedFiles.some(file => riskPatterns.some(pattern => matchRiskPattern(file, pattern)));
|
|
255
|
+
return hit ? "high" : "low";
|
|
256
|
+
}
|
|
257
|
+
/** 解析受控 evidence comment 为结构化 blocks;无 marker 或畸形返回 null */
|
|
258
|
+
export function parseEvidenceComment(body) {
|
|
259
|
+
const extracted = extractEvidenceBlock(body);
|
|
260
|
+
if (!extracted)
|
|
261
|
+
return null;
|
|
262
|
+
const blocks = [];
|
|
263
|
+
const sections = extracted.content.split(/^### stage: /m);
|
|
264
|
+
for (const section of sections) {
|
|
265
|
+
if (section.trim() === "")
|
|
266
|
+
continue;
|
|
267
|
+
const lines = section.split("\n");
|
|
268
|
+
const stage = lines[0].trim();
|
|
269
|
+
if (stage === "")
|
|
270
|
+
continue;
|
|
271
|
+
const fields = {};
|
|
272
|
+
for (const line of lines.slice(1)) {
|
|
273
|
+
const match = /^-\s+([a-zA-Z]+):\s*(.*)$/.exec(line.trim());
|
|
274
|
+
if (match)
|
|
275
|
+
fields[match[1]] = match[2].trim();
|
|
276
|
+
}
|
|
277
|
+
blocks.push({ stage, fields });
|
|
278
|
+
}
|
|
279
|
+
return blocks;
|
|
280
|
+
}
|
|
281
|
+
/** 把受控 comment 还原为 TddEvidence(供 evaluateTddCompliance 判定);无 marker 返回 null */
|
|
282
|
+
export function evidenceCommentToTddEvidence(comment) {
|
|
283
|
+
const blocks = parseEvidenceComment(comment.body);
|
|
284
|
+
if (!blocks)
|
|
285
|
+
return null;
|
|
286
|
+
const cycles = [];
|
|
287
|
+
let regression = { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] };
|
|
288
|
+
let verification = { status: "pending", headSha: null, treeSha: null, runs: [] };
|
|
289
|
+
let index = 0;
|
|
290
|
+
for (const block of blocks) {
|
|
291
|
+
const status = mapEvidenceStatus(block.fields.status);
|
|
292
|
+
if (block.stage === "red" || block.stage === "green") {
|
|
293
|
+
index++;
|
|
294
|
+
cycles.push({
|
|
295
|
+
cycleId: block.fields.cycle ?? `cycle-${index}`,
|
|
296
|
+
criterionId: block.fields.criterion ?? "c1",
|
|
297
|
+
reworkRevision: 0,
|
|
298
|
+
status: block.stage === "green" && status === "pass" ? "pass" : "failed",
|
|
299
|
+
startWorkspaceDigest: { algorithm: "sha256-content-v1", value: "unknown" },
|
|
300
|
+
testFiles: [],
|
|
301
|
+
redTestDigest: null,
|
|
302
|
+
redAttempts: [],
|
|
303
|
+
greenAttempts: [],
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
else if (block.stage === "final-regression") {
|
|
307
|
+
regression = { status, headSha: null, treeSha: null, reworkRevision: 0, runs: [] };
|
|
308
|
+
}
|
|
309
|
+
else if (block.stage === "final-verification") {
|
|
310
|
+
verification = { status, headSha: null, treeSha: null, runs: [] };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const overall = verification.status === "pass" && regression.status === "pass"
|
|
314
|
+
? "pass"
|
|
315
|
+
: verification.status === "fail" || regression.status === "fail"
|
|
316
|
+
? "fail"
|
|
317
|
+
: "in-progress";
|
|
318
|
+
return {
|
|
319
|
+
revision: comment.revision,
|
|
320
|
+
reworkRevision: 0,
|
|
321
|
+
status: overall,
|
|
322
|
+
taskStart: { status: "pass", headSha: null, treeSha: null, startedAt: null },
|
|
323
|
+
cycles,
|
|
324
|
+
regression,
|
|
325
|
+
verification,
|
|
326
|
+
alternativeValidation: [],
|
|
327
|
+
reworks: [],
|
|
328
|
+
warnings: [],
|
|
329
|
+
updatedAt: null,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function mapEvidenceStatus(value) {
|
|
333
|
+
if (value === "pass")
|
|
334
|
+
return "pass";
|
|
335
|
+
if (value === "fail")
|
|
336
|
+
return "fail";
|
|
337
|
+
return "pending";
|
|
338
|
+
}
|
|
339
|
+
export function taskStatePath(projectDir, slug) {
|
|
340
|
+
return join(projectDir, ".opencode", PLUGIN_ID, "task-state", `${slug}.json`);
|
|
341
|
+
}
|
|
342
|
+
export async function writeTaskState(projectDir, state) {
|
|
343
|
+
const target = taskStatePath(projectDir, state.slug);
|
|
344
|
+
const tmp = `${target}.tmp`;
|
|
345
|
+
await mkdir(join(projectDir, ".opencode", PLUGIN_ID, "task-state"), { recursive: true });
|
|
346
|
+
await writeFile(tmp, JSON.stringify(state, null, 2), "utf8");
|
|
347
|
+
await rename(tmp, target);
|
|
348
|
+
}
|
|
349
|
+
export async function readTaskState(projectDir, slug) {
|
|
350
|
+
try {
|
|
351
|
+
return JSON.parse(await readFile(taskStatePath(projectDir, slug), "utf8"));
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// ─── Task Record 解析(Sub Issue → issueNumber / body) ───
|
|
358
|
+
/** 按 title(task slug)解析 Parent 下的 Sub Issue 号(--json parent 直接过滤,无搜索索引延迟) */
|
|
359
|
+
async function resolveTaskIssue(parentIssueNumber, taskId) {
|
|
360
|
+
const { stdout } = await taskGh(`issue list --state all --limit 100 --json number,title,parent --jq '[.[] | select(.parent.number == ${parentIssueNumber} and .title == ${JSON.stringify(taskId)}) | .number] | first'`);
|
|
361
|
+
const trimmed = stdout.trim();
|
|
362
|
+
if (trimmed === "" || trimmed === "null")
|
|
363
|
+
return null;
|
|
364
|
+
const n = Number(trimmed);
|
|
365
|
+
return Number.isInteger(n) ? n : null;
|
|
366
|
+
}
|
|
367
|
+
/** 读 Task Record body(供依赖解析与 criteria 解析) */
|
|
368
|
+
async function readTaskBody(issueNumber) {
|
|
369
|
+
const { stdout } = await taskGh(`issue view ${issueNumber} --json body --jq .body`);
|
|
370
|
+
return stdout;
|
|
371
|
+
}
|
|
372
|
+
/** 设置 Task Record 状态标签(移除其它 cabbage:task:*,添加当前) */
|
|
373
|
+
/** 确保 label 存在(gh label create --force 幂等);失败不阻塞(add-label 会显式报错) */
|
|
374
|
+
async function ensureLabel(name) {
|
|
375
|
+
try {
|
|
376
|
+
await taskGh(`label create '${escapeShellArg(name)}' --force`);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// 已存在或权限不足:忽略
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
export async function setTaskStatusLabel(issueNumber, status) {
|
|
383
|
+
try {
|
|
384
|
+
const { stdout } = await taskGh(`issue view ${issueNumber} --json labels --jq '[.labels[].name] | join(" ") | tostring'`);
|
|
385
|
+
const current = stdout.trim() === "" ? [] : stdout.trim().split(" ");
|
|
386
|
+
const target = `${TASK_STATUS_LABEL_PREFIX}${status}`;
|
|
387
|
+
const stale = current.filter(l => l.startsWith(TASK_STATUS_LABEL_PREFIX) && l !== target);
|
|
388
|
+
const args = [];
|
|
389
|
+
for (const label of stale)
|
|
390
|
+
args.push(`--remove-label '${escapeShellArg(label)}'`);
|
|
391
|
+
args.push(`--add-label '${escapeShellArg(target)}'`);
|
|
392
|
+
// 目标标签不存在时自动创建(gh add-label 不自动建 label)
|
|
393
|
+
await ensureLabel(target);
|
|
394
|
+
await taskGh(`issue edit ${issueNumber} ${args.join(" ")}`);
|
|
395
|
+
return { ok: true };
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
return { ok: false, error: String(err) };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* 读仓库默认分支。
|
|
403
|
+
* 优先 GitHub API(defaultBranchRef);API 未返回(defaultBranchRef 为 null)时
|
|
404
|
+
* 回退远端 HEAD 指针(refs/remotes/origin/HEAD);仍不可得 → 抛错拒绝(fail-closed,
|
|
405
|
+
* 不猜测分支名,防 PR base 误用临时分支)。
|
|
406
|
+
*/
|
|
407
|
+
async function readDefaultBranch() {
|
|
408
|
+
try {
|
|
409
|
+
const { stdout } = await taskGh(`repo view --json defaultBranchRef --jq .defaultBranchRef.name`);
|
|
410
|
+
const branch = stdout.trim();
|
|
411
|
+
if (branch !== "")
|
|
412
|
+
return branch;
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
// 查询失败 → 走回退
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
const { stdout } = await taskGit(`symbolic-ref --short refs/remotes/origin/HEAD`);
|
|
419
|
+
const head = stdout.trim();
|
|
420
|
+
if (head !== "")
|
|
421
|
+
return head;
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// 无远端 HEAD 指针 → 走失败
|
|
425
|
+
}
|
|
426
|
+
throw new Error("cannot determine default branch (repo view defaultBranchRef empty and no origin/HEAD)");
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* create-task(spec §2.3):validateTitle → slug → 未完成同名冲突检查(branch/worktree 已存在)→
|
|
430
|
+
* createTaskRecord(Sub Issue,body 含 acceptance criteria、依赖、Closes 约定)关联 Parent。
|
|
431
|
+
*/
|
|
432
|
+
export async function createTask(input) {
|
|
433
|
+
const validated = validateTitle(input.title);
|
|
434
|
+
if (!validated.ok) {
|
|
435
|
+
return { ok: false, code: validated.code, message: validated.message };
|
|
436
|
+
}
|
|
437
|
+
const slug = validated.slug;
|
|
438
|
+
const worktreeDir = join(input.projectDir, worktreeFor(slug));
|
|
439
|
+
if (await pathExists(worktreeDir)) {
|
|
440
|
+
return { ok: false, code: "TASK_SLUG_CONFLICT", message: `worktree ${worktreeDir} already exists; choose a different title` };
|
|
441
|
+
}
|
|
442
|
+
if (await branchExists(branchFor(slug, "feat"))) {
|
|
443
|
+
return { ok: false, code: "TASK_SLUG_CONFLICT", message: `branch ${branchFor(slug, "feat")} already exists; choose a different title` };
|
|
444
|
+
}
|
|
445
|
+
let criteria = [];
|
|
446
|
+
if (input.acceptanceCriteriaJson !== undefined) {
|
|
447
|
+
const parsed = parseAcceptanceCriteria(input.acceptanceCriteriaJson);
|
|
448
|
+
if (parsed === null) {
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
code: "POLICY_INVALID",
|
|
452
|
+
message: 'acceptance_criteria must be a JSON array of {id, description, verification}, where verification is one of: "tdd" | "regression" | "manual"',
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
criteria = parsed;
|
|
456
|
+
}
|
|
457
|
+
const body = buildTaskBody(slug, criteria, input.dependencies ?? []);
|
|
458
|
+
const created = await createTaskRecord({ title: slug, body, parentIssueNumber: input.parentIssueNumber });
|
|
459
|
+
if (!created.ok) {
|
|
460
|
+
return { ok: false, code: "ISSUE_CREATE_FAILED", message: created.error };
|
|
461
|
+
}
|
|
462
|
+
return { ok: true, ref: created.ref };
|
|
463
|
+
}
|
|
464
|
+
/** 本地分支是否已存在(rev-parse 成功且有输出) */
|
|
465
|
+
async function branchExists(branch) {
|
|
466
|
+
try {
|
|
467
|
+
const { stdout } = await taskGit(`rev-parse --verify 'refs/heads/${escapeShellArg(branch)}'`);
|
|
468
|
+
return stdout.trim() !== "";
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* start-task(spec §2.3):前置门禁(Planning Baseline 已合并、依赖 Task 已 merged、并行数 <5)→
|
|
476
|
+
* worktree-start → captureWorkspaceBaseline → freezeTddPolicy → 写 Task Record 状态。
|
|
477
|
+
*/
|
|
478
|
+
export async function startTask(projectDir, parentIssueNumber, taskId) {
|
|
479
|
+
const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId);
|
|
480
|
+
if (issueNumber === null) {
|
|
481
|
+
return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` };
|
|
482
|
+
}
|
|
483
|
+
const taskBody = await readTaskBody(issueNumber);
|
|
484
|
+
const profile = await readProjectProfile(projectDir);
|
|
485
|
+
// 门禁 1:Planning Baseline 已合并(design 阶段完成 = Planning PR 已合入)
|
|
486
|
+
const record = await readFlowRecord(parentIssueNumber);
|
|
487
|
+
if (!record.ok || !getCompletedStages(record.body).includes("design")) {
|
|
488
|
+
return {
|
|
489
|
+
ok: false,
|
|
490
|
+
code: "PLANNING_BASELINE_NOT_MERGED",
|
|
491
|
+
message: "Planning Baseline (design stage) must be merged before starting tasks",
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
// 门禁 2:依赖 Task 已 merged(依赖 Issue 须已关闭且带 merged 标签——被取消的任务同样 CLOSED,不得放行)
|
|
495
|
+
const deps = parseDependenciesFromBody(taskBody);
|
|
496
|
+
for (const dep of deps) {
|
|
497
|
+
// task_id(slug)依赖:解析成实际 Issue 号;解析不到(未创建)→ 拒绝
|
|
498
|
+
const depNumber = typeof dep === "number" ? dep : await resolveTaskIssue(parentIssueNumber, dep);
|
|
499
|
+
if (depNumber === null) {
|
|
500
|
+
return {
|
|
501
|
+
ok: false,
|
|
502
|
+
code: "DEPENDENCY_NOT_FOUND",
|
|
503
|
+
message: `dependency task "${dep}" is not created under parent #${parentIssueNumber}`,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const depStatus = await readIssueStateAndLabels(depNumber);
|
|
507
|
+
if (depStatus.state !== "CLOSED" || !depStatus.labels.includes(`${TASK_STATUS_LABEL_PREFIX}merged`)) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
code: "DEPENDENCY_NOT_MERGED",
|
|
511
|
+
message: `dependency task #${depNumber} is not merged (state: ${depStatus.state}, merged label: ${depStatus.labels.includes(`${TASK_STATUS_LABEL_PREFIX}merged`)})`,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// 门禁 3:并行数 < 5(running/reviewing 标签计数)
|
|
516
|
+
const running = await countRunningTasks(parentIssueNumber);
|
|
517
|
+
if (running >= MAX_PARALLEL_TASKS) {
|
|
518
|
+
return {
|
|
519
|
+
ok: false,
|
|
520
|
+
code: "PARALLEL_LIMIT_REACHED",
|
|
521
|
+
message: `parallel task limit (${MAX_PARALLEL_TASKS}) reached (${running} running/reviewing)`,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const policy = freezeTddPolicy(profile);
|
|
525
|
+
const base = await readDefaultBranch();
|
|
526
|
+
const wt = await worktreeStart({ projectDir, slug: taskId, base });
|
|
527
|
+
if (!wt.ok) {
|
|
528
|
+
return { ok: false, code: wt.code, message: wt.message };
|
|
529
|
+
}
|
|
530
|
+
const baseline = await captureWorkspaceBaseline(wt.path, {
|
|
531
|
+
testFilePatterns: policy.testFilePatterns,
|
|
532
|
+
implementationFilePatterns: policy.implementationFilePatterns,
|
|
533
|
+
generatedArtifactPatterns: policy.generatedArtifactPatterns,
|
|
534
|
+
});
|
|
535
|
+
// 跨批协议:将 policy/criteria/worktree 写入 Task Record body 的 cabbage-task-tdd 块(tdd_checkpoint 解析)
|
|
536
|
+
const criteria = parseCriteriaFromBody(taskBody);
|
|
537
|
+
const tddBlock = buildTaskTddBlock(policy, criteria, wt.path);
|
|
538
|
+
const updatedBody = taskBody.includes(TASK_TDD_TAG)
|
|
539
|
+
? taskBody
|
|
540
|
+
: `${taskBody}\n${tddBlock}\n`;
|
|
541
|
+
const bodyUpdate = await updateTaskBody(issueNumber, updatedBody);
|
|
542
|
+
if (!bodyUpdate.ok) {
|
|
543
|
+
// TDD 块写入失败:清理刚创建的 worktree(best effort),避免失败残留
|
|
544
|
+
await gitIn(projectDir, `worktree remove '${escapeShellArg(wt.path)}'`).catch(() => null);
|
|
545
|
+
return { ok: false, code: "TASK_TDD_BLOCK_WRITE_FAILED", message: bodyUpdate.error ?? "failed to write TDD block" };
|
|
546
|
+
}
|
|
547
|
+
await writeTaskState(projectDir, {
|
|
548
|
+
slug: taskId,
|
|
549
|
+
parentIssueNumber,
|
|
550
|
+
issueNumber,
|
|
551
|
+
branch: wt.branch,
|
|
552
|
+
worktreePath: wt.path,
|
|
553
|
+
policy,
|
|
554
|
+
baseline,
|
|
555
|
+
startedAt: new Date().toISOString(),
|
|
556
|
+
});
|
|
557
|
+
const label = await setTaskStatusLabel(issueNumber, "running");
|
|
558
|
+
if (!label.ok) {
|
|
559
|
+
return { ok: false, code: "LABEL_UPDATE_FAILED", message: label.error ?? "label update failed" };
|
|
560
|
+
}
|
|
561
|
+
return { ok: true, issueNumber, branch: wt.branch, worktreePath: wt.path, policy, baseline };
|
|
562
|
+
}
|
|
563
|
+
/** 读 Issue 状态与标签(依赖门禁用:CLOSED 且 merged 标签才算已合并) */
|
|
564
|
+
async function readIssueStateAndLabels(issueNumber) {
|
|
565
|
+
const { stdout } = await taskGh(`issue view ${issueNumber} --json state,labels --jq '{state, labels: [.labels[].name]}'`);
|
|
566
|
+
return JSON.parse(stdout);
|
|
567
|
+
}
|
|
568
|
+
/** 统计 flow 下 running/reviewing 的 Task 数(并行门禁);已关闭 Issue 不计(防取消/归档任务占名额) */
|
|
569
|
+
async function countRunningTasks(parentIssueNumber) {
|
|
570
|
+
const { stdout } = await taskGh(`issue list --state open --limit 100 --json number,labels,parent --jq '[.[] | select(.parent.number == ${parentIssueNumber}) | {number, labels: [.labels[].name]}]'`);
|
|
571
|
+
const issues = JSON.parse(stdout);
|
|
572
|
+
return issues.filter(i => i.labels.some(l => l === `${TASK_STATUS_LABEL_PREFIX}running` || l === `${TASK_STATUS_LABEL_PREFIX}reviewing`)).length;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* submit-task(spec §4.4):TDD 硬门禁——evidence 不完整(无 comment、regression 未 pass、
|
|
576
|
+
* compliance 未 pass)→ 拒绝创建 PR;通过后 push 分支 → gh pr create(body 引用 Task Record +
|
|
577
|
+
* evidence comment 链接,含 Closes 约定)→ 标记 reviewing。
|
|
578
|
+
*/
|
|
579
|
+
export async function submitTask(projectDir, parentIssueNumber, taskId) {
|
|
580
|
+
const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId);
|
|
581
|
+
if (issueNumber === null) {
|
|
582
|
+
return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` };
|
|
583
|
+
}
|
|
584
|
+
const state = await readTaskState(projectDir, taskId);
|
|
585
|
+
if (!state) {
|
|
586
|
+
return { ok: false, code: "TASK_NOT_STARTED", message: `task "${taskId}" was not started (run start-task first)` };
|
|
587
|
+
}
|
|
588
|
+
const taskBody = await readTaskBody(issueNumber);
|
|
589
|
+
const criteria = parseCriteriaFromBody(taskBody);
|
|
590
|
+
// TDD 硬门禁(§4.4):evidence comment 存在且状态可解析;waived(豁免)放行
|
|
591
|
+
const comment = await readTddEvidenceComment(issueNumber);
|
|
592
|
+
if (!comment) {
|
|
593
|
+
return {
|
|
594
|
+
ok: false,
|
|
595
|
+
code: "TDD_EVIDENCE_INCOMPLETE",
|
|
596
|
+
message: "no TDD evidence comment found on the task record",
|
|
597
|
+
missing: ["evidence-comment"],
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
// 跨批协议(与 tdd_checkpoint 一致):先提取 evidence block 内容(不含 end marker),
|
|
601
|
+
// 再解析其中 JSON 状态块(cabbage-tdd-state,取最后一个);失败则回退旧 markdown 有损解析
|
|
602
|
+
const evidenceBlock = extractEvidenceBlock(comment.body);
|
|
603
|
+
const evidence = (evidenceBlock ? parseJsonStateEvidence(evidenceBlock.content) : null) ?? evidenceCommentToTddEvidence(comment);
|
|
604
|
+
if (!evidence || evidence.revision < 1) {
|
|
605
|
+
return {
|
|
606
|
+
ok: false,
|
|
607
|
+
code: "TDD_EVIDENCE_INCOMPLETE",
|
|
608
|
+
message: "evidence comment is malformed or has no revision",
|
|
609
|
+
missing: ["evidence-revision"],
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
// 豁免语义:not-applicable / exempt-request → waived → 放行
|
|
613
|
+
if (evidence.status === "waived") {
|
|
614
|
+
return submitPr(state, issueNumber, comment, evidence.revision, evidence, taskId);
|
|
615
|
+
}
|
|
616
|
+
const compliance = evaluateTddCompliance(state.policy, evidence, criteria);
|
|
617
|
+
if (compliance.status !== "pass") {
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
code: "TDD_EVIDENCE_INCOMPLETE",
|
|
621
|
+
message: `TDD compliance not met: ${compliance.warnings.join("; ")}`,
|
|
622
|
+
missing: compliance.warnings.length > 0 ? compliance.warnings : ["tdd-compliance"],
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
return submitPr(state, issueNumber, comment, evidence.revision, evidence, taskId);
|
|
626
|
+
}
|
|
627
|
+
/** 解析 evidence comment 内 JSON 状态块(TDD_STATE_TAG,取最后一个),失败返回 null;schema 必须为 1 */
|
|
628
|
+
function parseJsonStateEvidence(body) {
|
|
629
|
+
const marker = "<!-- cabbage-tdd-state -->";
|
|
630
|
+
const idx = body.lastIndexOf(marker);
|
|
631
|
+
if (idx === -1)
|
|
632
|
+
return null;
|
|
633
|
+
try {
|
|
634
|
+
const parsed = JSON.parse(body.slice(idx + marker.length).trim());
|
|
635
|
+
if (parsed.schema !== 1 || !parsed?.evidence)
|
|
636
|
+
return null;
|
|
637
|
+
return { ...parsed.evidence, revision: parsed.evidence.revision ?? 1 };
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/** 创建 PR 的公共流程(evidence 通过后) */
|
|
644
|
+
async function submitPr(state, issueNumber, comment, revision, _evidence, taskId) {
|
|
645
|
+
// push 分支(工具内以宿主凭据执行)
|
|
646
|
+
try {
|
|
647
|
+
await gitIn(state.worktreePath, `push -u origin '${escapeShellArg(state.branch)}'`);
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
return { ok: false, code: "PUSH_FAILED", message: String(err) };
|
|
651
|
+
}
|
|
652
|
+
// 创建 PR:body 引用 Task Record + evidence comment 链接 + Closes 约定
|
|
653
|
+
const base = await readDefaultBranch();
|
|
654
|
+
const { stdout: repoOut } = await taskGh("repo view --json nameWithOwner --jq .nameWithOwner");
|
|
655
|
+
const repo = repoOut.trim();
|
|
656
|
+
const evidenceLink = `https://github.com/${repo}/issues/${issueNumber}#issuecomment-${comment.id}`;
|
|
657
|
+
const prTitle = `feat: ${taskId}`;
|
|
658
|
+
const prBody = [`# ${taskId}`, "", `- Task Record: #${issueNumber}`, `- TDD evidence: ${evidenceLink}`, "", `Closes #${issueNumber}`, ""].join("\n");
|
|
659
|
+
const { stdout: prOut } = await taskGh(`pr create --title '${escapeShellArg(prTitle)}' --body '${escapeShellArg(prBody)}' --base '${escapeShellArg(base)}' --head '${escapeShellArg(state.branch)}'`);
|
|
660
|
+
// gh pr create 不支持 --json:解析 stdout 中的 PR URL
|
|
661
|
+
const prNumber = parsePrUrlNumber(prOut);
|
|
662
|
+
if (prNumber === null) {
|
|
663
|
+
return { ok: false, code: "PR_CREATE_FAILED", message: `invalid PR create output: ${prOut}` };
|
|
664
|
+
}
|
|
665
|
+
// PR 已创建成功;状态标签失败仅降级 warning(不把已建 PR 报为整体失败)
|
|
666
|
+
const label = await setTaskStatusLabel(issueNumber, "reviewing");
|
|
667
|
+
if (!label.ok) {
|
|
668
|
+
return {
|
|
669
|
+
ok: true,
|
|
670
|
+
prNumber,
|
|
671
|
+
evidenceRevision: revision,
|
|
672
|
+
warning: `reviewing label update failed: ${label.error ?? "unknown"}`,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return { ok: true, prNumber, evidenceRevision: revision };
|
|
676
|
+
}
|
|
677
|
+
/** submit-review(spec §2.3):primary 提交 reviewer 双轴审查结果(reviewer 本身只读) */
|
|
678
|
+
export async function submitReview(prNumber, verdict, comment) {
|
|
679
|
+
try {
|
|
680
|
+
if (verdict === "approve") {
|
|
681
|
+
await taskGh(`pr review ${prNumber} --approve`);
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
const body = comment ? ` --body '${escapeShellArg(comment)}'` : "";
|
|
685
|
+
await taskGh(`pr review ${prNumber} --request-changes${body}`);
|
|
686
|
+
}
|
|
687
|
+
return { ok: true, prNumber, verdict };
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
return { ok: false, code: "REVIEW_SUBMIT_FAILED", message: String(err) };
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* merge-task(spec §2.3 + PRD R8):CI checks 通过 + 分支保护 + 风险双层判定(只升不降)+
|
|
695
|
+
* 高风险需非作者人类 approval → mergeTaskPR(--match-head-commit)→ 关闭 Sub Issue →
|
|
696
|
+
* worktree 干净销毁。
|
|
697
|
+
*/
|
|
698
|
+
export async function mergeTask(projectDir, parentIssueNumber, taskId, modelRisk) {
|
|
699
|
+
const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId);
|
|
700
|
+
if (issueNumber === null) {
|
|
701
|
+
return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` };
|
|
702
|
+
}
|
|
703
|
+
const state = await readTaskState(projectDir, taskId);
|
|
704
|
+
if (!state) {
|
|
705
|
+
return { ok: false, code: "TASK_NOT_STARTED", message: `task "${taskId}" was not started (run start-task first)` };
|
|
706
|
+
}
|
|
707
|
+
// 1. 定位 open PR(head 分支)
|
|
708
|
+
const { stdout: prOut } = await taskGh(`pr list --head '${escapeShellArg(state.branch)}' --state open --json number,headRefOid,author --jq '[.[] | {number, headRefOid, author}] | first'`);
|
|
709
|
+
if (prOut.trim() === "" || prOut.trim() === "null") {
|
|
710
|
+
return { ok: false, code: "PR_NOT_FOUND", message: `no open PR for branch ${state.branch}` };
|
|
711
|
+
}
|
|
712
|
+
const pr = JSON.parse(prOut);
|
|
713
|
+
// 2. CI checks 全部 SUCCESS
|
|
714
|
+
const checks = await readPrChecks(pr.number);
|
|
715
|
+
const failing = checks.filter(c => c.state !== "SUCCESS");
|
|
716
|
+
if (failing.length > 0) {
|
|
717
|
+
return {
|
|
718
|
+
ok: false,
|
|
719
|
+
code: "CI_CHECKS_NOT_PASSED",
|
|
720
|
+
message: `CI checks not passed: ${failing.map(c => `${c.name}:${c.state}`).join(", ")}`,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
// 3. 分支保护存在
|
|
724
|
+
const { stdout: repoOut } = await taskGh("repo view --json nameWithOwner --jq .nameWithOwner");
|
|
725
|
+
const [owner, repo] = repoOut.trim().split("/");
|
|
726
|
+
const protection = await checkBranchProtection(owner, repo);
|
|
727
|
+
if (!protection.exists) {
|
|
728
|
+
return { ok: false, code: "BRANCH_PROTECTION_REQUIRED", message: "branch protection is not enabled on the default branch" };
|
|
729
|
+
}
|
|
730
|
+
// 4. 风险双层判定:模型初判 + 内核按实际 diff 升级(只升不降)
|
|
731
|
+
const profile = await readProjectProfile(projectDir);
|
|
732
|
+
const changedFiles = await readPrChangedFiles(pr.number);
|
|
733
|
+
const risk = escalateRisk(modelRisk, changedFiles, [...BUILTIN_RISK_PATTERNS, ...profile.riskPatterns]);
|
|
734
|
+
// 5. 高风险需非作者人类 approval
|
|
735
|
+
if (risk === "high") {
|
|
736
|
+
const approved = await hasNonAuthorHumanApproval(pr.number);
|
|
737
|
+
if (!approved) {
|
|
738
|
+
return {
|
|
739
|
+
ok: false,
|
|
740
|
+
code: "HIGH_RISK_APPROVAL_REQUIRED",
|
|
741
|
+
message: `high-risk PR requires a non-author human approval (author: ${pr.author.login})`,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// 6. merge(--match-head-commit 防偷换)
|
|
746
|
+
const merged = await mergeTaskPR(pr.number, pr.headRefOid);
|
|
747
|
+
if (!merged.success) {
|
|
748
|
+
return { ok: false, code: "MERGE_FAILED", message: merged.error ?? "merge failed" };
|
|
749
|
+
}
|
|
750
|
+
// 7. 关闭 Sub Issue + 状态 merged(PR 已合并,后续步骤独立容错,失败仅降级 warning)
|
|
751
|
+
const warnings = [];
|
|
752
|
+
try {
|
|
753
|
+
await taskGh(`issue close ${issueNumber}`);
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
warnings.push(`issue close failed: ${String(err)}`);
|
|
757
|
+
}
|
|
758
|
+
const mergedLabel = await setTaskStatusLabel(issueNumber, "merged");
|
|
759
|
+
if (!mergedLabel.ok) {
|
|
760
|
+
warnings.push(`merged label update failed: ${mergedLabel.error ?? "unknown"}`);
|
|
761
|
+
}
|
|
762
|
+
// 8. worktree 干净销毁(PR 已合并且干净 → 自动);失败仅警告,不阻塞合并结果
|
|
763
|
+
const cleanup = await destroyWorktree({ worktreeDir: state.worktreePath, branch: state.branch, prMerged: true, userConfirmed: false });
|
|
764
|
+
if (!cleanup.ok && cleanup.reason === "DIRTY_WORKTREE") {
|
|
765
|
+
warnings.push("PR merged but worktree has uncommitted changes; clean it manually");
|
|
766
|
+
}
|
|
767
|
+
return { ok: true, prNumber: pr.number, risk, ...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}) };
|
|
768
|
+
}
|
|
769
|
+
/** 读 PR 的 statusCheckRollup 精简列表(CheckRun 用 conclusion、StatusContext 用 state;gh go-jq 需显式字段访问) */
|
|
770
|
+
async function readPrChecks(prNumber) {
|
|
771
|
+
const { stdout } = await taskGh(`pr view ${prNumber} --json statusCheckRollup --jq '[.statusCheckRollup[]? | {name: (.name // .context // "check"), state: (.conclusion // .state // "PENDING")}]'`);
|
|
772
|
+
return JSON.parse(stdout);
|
|
773
|
+
}
|
|
774
|
+
/** 将原始 statusCheckRollup 映射为 {name, state}(纯函数,供测试直接验证 jq 语义) */
|
|
775
|
+
export function mapCheckRollup(raw) {
|
|
776
|
+
return raw.map(item => {
|
|
777
|
+
const obj = (item ?? {});
|
|
778
|
+
return {
|
|
779
|
+
name: String(obj.name ?? obj.context ?? "check"),
|
|
780
|
+
state: String(obj.state ?? obj.conclusion ?? "PENDING"),
|
|
781
|
+
};
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
/** 读 PR 变更文件路径列表(风险判定用) */
|
|
785
|
+
async function readPrChangedFiles(prNumber) {
|
|
786
|
+
const { stdout } = await taskGh(`pr view ${prNumber} --json files --jq '[.files[].path]'`);
|
|
787
|
+
return JSON.parse(stdout);
|
|
788
|
+
}
|
|
789
|
+
/** 是否存在非作者的人类 APPROVED review(高风险门禁) */
|
|
790
|
+
async function hasNonAuthorHumanApproval(prNumber) {
|
|
791
|
+
const { stdout } = await taskGh(`pr view ${prNumber} --json author,reviews --jq '{author: .author.login, reviews: [.reviews[] | {state: .state, login: .author.login, type: .author.type}]}'`);
|
|
792
|
+
const data = JSON.parse(stdout);
|
|
793
|
+
return data.reviews.some(r => r.state === "APPROVED" && r.login !== data.author && r.type === "User");
|
|
794
|
+
}
|
|
795
|
+
/** cancel-task:受控取消(user_confirmed)→ 关闭 Sub Issue + 状态标签 cancelled */
|
|
796
|
+
export async function cancelTask(_projectDir, parentIssueNumber, taskId, userConfirmed) {
|
|
797
|
+
if (!userConfirmed) {
|
|
798
|
+
return {
|
|
799
|
+
ok: false,
|
|
800
|
+
code: "USER_CONFIRMATION_REQUIRED",
|
|
801
|
+
message: "Cancelling a task requires user confirmation (user_confirmed: true)",
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId);
|
|
805
|
+
if (issueNumber === null) {
|
|
806
|
+
return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` };
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
await taskGh(`issue close ${issueNumber}`);
|
|
810
|
+
await setTaskStatusLabel(issueNumber, "cancelled");
|
|
811
|
+
return { ok: true, issueNumber };
|
|
812
|
+
}
|
|
813
|
+
catch (err) {
|
|
814
|
+
return { ok: false, code: "GITHUB_ERROR", message: String(err) };
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/** destroy-worktree:preflight 销毁(§3.3),需 user_confirmed(除非 PR 已合并且干净) */
|
|
818
|
+
export async function destroyTaskWorktree(projectDir, parentIssueNumber, taskId, userConfirmed) {
|
|
819
|
+
const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId);
|
|
820
|
+
if (issueNumber === null) {
|
|
821
|
+
return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` };
|
|
822
|
+
}
|
|
823
|
+
const state = await readTaskState(projectDir, taskId);
|
|
824
|
+
if (!state) {
|
|
825
|
+
return { ok: false, code: "TASK_NOT_STARTED", message: `task "${taskId}" was not started (run start-task first)` };
|
|
826
|
+
}
|
|
827
|
+
const prMerged = await isPrMerged(state.branch);
|
|
828
|
+
const result = await destroyWorktree({
|
|
829
|
+
worktreeDir: state.worktreePath,
|
|
830
|
+
branch: state.branch,
|
|
831
|
+
prMerged,
|
|
832
|
+
userConfirmed,
|
|
833
|
+
});
|
|
834
|
+
if (!result.ok) {
|
|
835
|
+
return { ok: false, code: result.reason, message: result.message };
|
|
836
|
+
}
|
|
837
|
+
return { ok: true, reason: result.reason };
|
|
838
|
+
}
|
|
839
|
+
/** 分支是否有已合并的 PR */
|
|
840
|
+
async function isPrMerged(branch) {
|
|
841
|
+
const { stdout } = await taskGh(`pr list --head '${escapeShellArg(branch)}' --state merged --json number --jq 'length'`);
|
|
842
|
+
return Number(stdout.trim()) > 0;
|
|
843
|
+
}
|
|
844
|
+
/** status-task:读 Task Record + PR + checks 汇总 */
|
|
845
|
+
export async function readTaskStatus(_projectDir, parentIssueNumber, taskId) {
|
|
846
|
+
const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId);
|
|
847
|
+
if (issueNumber === null) {
|
|
848
|
+
return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` };
|
|
849
|
+
}
|
|
850
|
+
try {
|
|
851
|
+
const { stdout: issueOut } = await taskGh(`issue view ${issueNumber} --json number,title,state,labels,body --jq '{number, title, state, labels: [.labels[].name], body}'`);
|
|
852
|
+
const issue = JSON.parse(issueOut);
|
|
853
|
+
const branch = `feat/${taskId}`;
|
|
854
|
+
const { stdout: prsOut } = await taskGh(`pr list --head '${escapeShellArg(branch)}' --state all --json number,state,headRefName --jq '[.[] | {number, state, headRefName}]'`);
|
|
855
|
+
const prs = JSON.parse(prsOut);
|
|
856
|
+
const pullRequests = [];
|
|
857
|
+
for (const pr of prs) {
|
|
858
|
+
const checks = await readPrChecks(pr.number);
|
|
859
|
+
pullRequests.push({ ...pr, checks });
|
|
860
|
+
}
|
|
861
|
+
return {
|
|
862
|
+
ok: true,
|
|
863
|
+
report: { task: { issueNumber, slug: taskId, state: issue.state, labels: issue.labels }, pullRequests },
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
const message = String(err);
|
|
868
|
+
return message.includes("Could not resolve") || message.includes("not found")
|
|
869
|
+
? { ok: false, code: "NOT_FOUND", message }
|
|
870
|
+
: { ok: false, code: "GITHUB_ERROR", message };
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
//# sourceMappingURL=task.js.map
|