aitable-workflow-cli 0.1.28 → 0.1.29-beta.1790736949347
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/dist/assistant/assets/index-BA6il19d.css +1 -0
- package/dist/assistant/assets/{index-B3LvYfRW.js → index-BMzAOp70.js} +31 -31
- package/dist/assistant/index.html +2 -2
- package/dist/chunk-BBP5RVFU.js +15 -0
- package/dist/cli.js +180 -73
- package/dist/{config-ui-server-4SVD42Z3.js → config-ui-server-UWELHUH4.js} +1 -1
- package/dist/web/assets/index-Bnmnydwk.css +1 -0
- package/dist/web/assets/index-Dbs1xWB9.js +331 -0
- package/dist/web/index.html +2 -2
- package/dist/web/install.ps1 +173 -0
- package/dist/web/install.sh +188 -0
- package/package.json +3 -3
- package/templates/ai-native-sdlc/.env.example +11 -0
- package/templates/ai-native-sdlc/aitable-workflow.config.yml +47 -0
- package/templates/ai-native-sdlc/coding-agents.config.json +17 -0
- package/templates/ai-native-sdlc/docs/plans/2026-09-15-ai-native-sdlc-dual-staff-implementation-plan.md +150 -0
- package/templates/ai-native-sdlc/docs/plans/2026-09-15-ai-native-sdlc-dual-staff.md +213 -0
- package/templates/ai-native-sdlc/docs/plans/2026-09-16-domain-role-notification-routing.md +36 -0
- package/templates/ai-native-sdlc/docs/plans/2026-09-16-execution-resume-and-cr-loop.md +29 -0
- package/templates/ai-native-sdlc/docs/plans/2026-09-16-project-handoff.md +148 -0
- package/templates/ai-native-sdlc/docs/plans/2026-09-17-prd-code-context-and-readable-prompts.md +37 -0
- package/templates/ai-native-sdlc/docs/plans/2026-09-21-plan-merge-sync-and-agent-source.md +19 -0
- package/templates/ai-native-sdlc/docs/plans/2026-09-21-sdlc-base-ref-and-review-notification.md +18 -0
- package/templates/ai-native-sdlc/docs/plans/2026-09-29-borrow-from-joe-design.md +943 -0
- package/templates/ai-native-sdlc/lib/artifacts.ts +126 -0
- package/templates/ai-native-sdlc/lib/recipe-config.ts +34 -0
- package/templates/ai-native-sdlc/lib/recipe-helpers.ts +595 -0
- package/templates/ai-native-sdlc/npmrc +1 -0
- package/templates/ai-native-sdlc/package.json +19 -0
- package/templates/ai-native-sdlc/profile/profile.yml +115 -0
- package/templates/ai-native-sdlc/setup/module-config.seed.json +37 -0
- package/templates/ai-native-sdlc/test/artifacts.test.ts +72 -0
- package/templates/ai-native-sdlc/test/recipe-helpers.test.ts +931 -0
- package/templates/ai-native-sdlc/tsconfig.json +17 -0
- package/templates/ai-native-sdlc/workflows/.gitkeep +3 -0
- package/templates/ai-native-sdlc/workflows/execution/automations/blocked-questions-dev.json +91 -0
- package/templates/ai-native-sdlc/workflows/execution/automations/blocked-questions.json +91 -0
- package/templates/ai-native-sdlc/workflows/execution/automations/cr-review-dev.json +80 -0
- package/templates/ai-native-sdlc/workflows/execution/automations/cr-review.json +81 -0
- package/templates/ai-native-sdlc/workflows/execution/automations/resume-answers.json +42 -0
- package/templates/ai-native-sdlc/workflows/execution/automations/spec-review-dev.json +138 -0
- package/templates/ai-native-sdlc/workflows/execution/recipes/coding.recipe.ts +161 -0
- package/templates/ai-native-sdlc/workflows/execution/recipes/prd-review.recipe.ts +200 -0
- package/templates/ai-native-sdlc/workflows/execution/recipes/prd.recipe.ts +163 -0
- package/templates/ai-native-sdlc/workflows/execution/recipes/review.recipe.ts +257 -0
- package/templates/ai-native-sdlc/workflows/execution/recipes/spec-review.recipe.ts +242 -0
- package/templates/ai-native-sdlc/workflows/execution/recipes/spec.recipe.ts +173 -0
- package/templates/ai-native-sdlc/workflows/execution/workflow.yml +79 -0
- package/templates/ai-native-sdlc/workflows/intake/automations/accept-intake.json +101 -0
- package/templates/ai-native-sdlc/workflows/intake/recipes/classify-module.recipe.ts +133 -0
- package/templates/ai-native-sdlc/workflows/intake/recipes/create-change.recipe.ts +94 -0
- package/templates/ai-native-sdlc/workflows/intake/recipes/mechanical-deduplicate.recipe.ts +33 -0
- package/templates/ai-native-sdlc/workflows/intake/workflow.yml +62 -0
- package/templates/ai-native-sdlc/workspaces/dev/AGENTS.md +69 -0
- package/templates/ai-native-sdlc/workspaces/pm/AGENTS.md +48 -0
- package/templates/ai-native-sdlc/workspaces/reviewer/AGENTS.md +79 -0
- package/dist/assistant/assets/index-CCEHOkkJ.css +0 -1
- package/dist/chunk-YDJTCQW4.js +0 -15
- package/dist/web/assets/index-DPc2WOdp.css +0 -1
- package/dist/web/assets/index-HTw8K38o.js +0 -332
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CodingRunResult,
|
|
3
|
+
MrComment,
|
|
4
|
+
RecipeContext,
|
|
5
|
+
RecipeResult,
|
|
6
|
+
} from "aitable-workflow-core";
|
|
7
|
+
import { repositoryConfig, type RepositoryConfig } from "./recipe-config.js";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export function text(value: unknown): string {
|
|
11
|
+
return typeof value === "string" ? value.trim() : "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* singleSelect 单元格读回是 `{id, name}` 对象而非字符串,
|
|
16
|
+
* 直接与字面量比较会永远不相等(受理门禁因此永久阻塞)。比较前必须取 name。
|
|
17
|
+
*/
|
|
18
|
+
export function selectName(value: unknown): string {
|
|
19
|
+
if (typeof value === "string") return value.trim();
|
|
20
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
21
|
+
const name = (value as Record<string, unknown>).name;
|
|
22
|
+
if (typeof name === "string") return name.trim();
|
|
23
|
+
}
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function recordValue(
|
|
28
|
+
row: Record<string, unknown>,
|
|
29
|
+
name: string,
|
|
30
|
+
): string {
|
|
31
|
+
return text(row[name]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 测试需求仍必须走真实 Agent、Git 与 CR;只收窄 Agent 的调研范围,避免一次端到端验证遍历业务代码库。
|
|
36
|
+
* 标记写在标题而非运行字段,保证所有阶段与恢复会话都能从同一持久化记录复现这个边界。
|
|
37
|
+
*/
|
|
38
|
+
export function isTestDemand(row: Record<string, unknown>): boolean {
|
|
39
|
+
const title = recordValue(row, "标题") || recordValue(row, "需求");
|
|
40
|
+
return title.startsWith("【测试】");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function testDemandInstruction(row: Record<string, unknown>): string {
|
|
44
|
+
if (!isTestDemand(row)) return "";
|
|
45
|
+
return "## 测试需求模式\n这是测试需求。立即遵循工作目录 AGENTS.md 的测试模式,以最短步骤完成本阶段,不得调研业务代码库。";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function repositoryUrl(repository: RepositoryConfig): string {
|
|
49
|
+
return repository.url;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function requireRepository(
|
|
53
|
+
ctx: RecipeContext,
|
|
54
|
+
key: string,
|
|
55
|
+
): RepositoryConfig | undefined {
|
|
56
|
+
const parsed = repositoryConfig.safeParse(ctx.config[key]);
|
|
57
|
+
return parsed.success ? parsed.data : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function requireRepositories(
|
|
61
|
+
ctx: RecipeContext,
|
|
62
|
+
key: string,
|
|
63
|
+
): RepositoryConfig[] {
|
|
64
|
+
const parsed = repositoryConfig.array().min(1).safeParse(ctx.config[key]);
|
|
65
|
+
return parsed.success ? parsed.data : [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 仓库默认分支由其声明决定,所有运行模式均执行配置的初始化命令。 */
|
|
69
|
+
export function worktreeOptions(repository: RepositoryConfig, baseRef?: string): { baseRef?: string } {
|
|
70
|
+
return {
|
|
71
|
+
...(baseRef ? { baseRef } : repository.default_branch ? { baseRef: repository.default_branch } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 正常变更沿用仓库 hook;测试 smoke 工件已有独立 node:test 证据,才跳过 hook。 */
|
|
76
|
+
export function commitOptions(row: Record<string, unknown>): { noVerify: true } | undefined {
|
|
77
|
+
return isTestDemand(row) ? { noVerify: true } : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function appendArtifact(previous: unknown, line: string): string {
|
|
81
|
+
const current = text(previous);
|
|
82
|
+
return current.includes(line)
|
|
83
|
+
? current
|
|
84
|
+
: [current, line].filter(Boolean).join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function hasStageTransition(
|
|
88
|
+
previous: unknown,
|
|
89
|
+
itemId: string,
|
|
90
|
+
stage: string,
|
|
91
|
+
): boolean {
|
|
92
|
+
return text(previous).split("\n").includes(`${itemId}+${stage}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function appendStageTransition(
|
|
96
|
+
previous: unknown,
|
|
97
|
+
itemId: string,
|
|
98
|
+
stage: string,
|
|
99
|
+
): string {
|
|
100
|
+
const transition = `${itemId}+${stage}`;
|
|
101
|
+
const audit = text(previous);
|
|
102
|
+
return audit.split("\n").includes(transition)
|
|
103
|
+
? audit
|
|
104
|
+
: [audit, transition].filter(Boolean).join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 「当前步骤」是引擎持久化的唯一 recipe 指针,必须与本轮 ctx.stepId 一致。 */
|
|
108
|
+
export function isCurrentRecipe(
|
|
109
|
+
row: Record<string, unknown>,
|
|
110
|
+
stepId: string,
|
|
111
|
+
): boolean {
|
|
112
|
+
return recordValue(row, "当前步骤") === stepId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function sessionMap(value: unknown): Record<string, string> {
|
|
116
|
+
if (typeof value !== "string" || !value.trim()) return {};
|
|
117
|
+
try {
|
|
118
|
+
const parsed: unknown = JSON.parse(value);
|
|
119
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
120
|
+
return {};
|
|
121
|
+
return Object.fromEntries(
|
|
122
|
+
Object.entries(parsed).filter(
|
|
123
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
} catch {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function saveSession(
|
|
132
|
+
previous: unknown,
|
|
133
|
+
key: string,
|
|
134
|
+
result: CodingRunResult,
|
|
135
|
+
): string {
|
|
136
|
+
const sessions = sessionMap(previous);
|
|
137
|
+
if (result.sessionId) sessions[key] = result.sessionId;
|
|
138
|
+
return JSON.stringify(sessions);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Agent 成功返回后先写入 session,再执行可能失败的 Git/CR 后置操作。
|
|
143
|
+
* 这样重试至少能复用 Agent 上下文,不会因后置操作失败丢失本轮 session。
|
|
144
|
+
*/
|
|
145
|
+
export async function persistCodingSession(
|
|
146
|
+
ctx: RecipeContext,
|
|
147
|
+
row: Record<string, unknown>,
|
|
148
|
+
sessionKey: string,
|
|
149
|
+
result: CodingRunResult,
|
|
150
|
+
): Promise<string> {
|
|
151
|
+
if (!result.sessionId) return text(row["Agent会话ID"]);
|
|
152
|
+
const next = saveSession(row["Agent会话ID"], sessionKey, result);
|
|
153
|
+
if (next !== text(row["Agent会话ID"])) {
|
|
154
|
+
await ctx.toolkit.table.updateCurrentRow({ Agent会话ID: next });
|
|
155
|
+
}
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 截断长文本,避免失败详情把表单元格 / trace 撑爆(参考事件日志写爆的教训)。 */
|
|
160
|
+
function clip(value: string, max: number): string {
|
|
161
|
+
const s = value.trim();
|
|
162
|
+
return s.length <= max ? s : `${s.slice(0, max)}…(+${s.length - max}字)`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 把失败的 CodingRunResult 展开成可读诊断行,写进 trace logs 与阻塞原因。
|
|
167
|
+
* 引擎超时/异常时 `result.response` 才是真正的失败文本(如「coding agent 执行超时 (600000ms)」),
|
|
168
|
+
* 只记一句「Agent 未完成」会让 trace 无法定位根因。
|
|
169
|
+
*/
|
|
170
|
+
export function describeCodingFailure(result: CodingRunResult): string[] {
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
if (result.failure) {
|
|
173
|
+
lines.push(`failure.kind=${result.failure.kind}`);
|
|
174
|
+
const msg = text(result.failure.message);
|
|
175
|
+
if (msg) lines.push(`failure.message=${clip(msg, 400)}`);
|
|
176
|
+
}
|
|
177
|
+
const resp = text(result.response);
|
|
178
|
+
if (resp) lines.push(`agent.response=${clip(resp, 600)}`);
|
|
179
|
+
lines.push(
|
|
180
|
+
result.sessionId
|
|
181
|
+
? `sessionId=${result.sessionId}(可续跑)`
|
|
182
|
+
: "sessionId=none(引擎未回传,重试将冷启动而非续跑)",
|
|
183
|
+
);
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Agent 主动提出的问题与运行故障必须区分:前者等待真人答复,后者交回引擎重试。
|
|
189
|
+
* 否则会产生既无问题、又不在候选集内的永久 blocked 记录。
|
|
190
|
+
*/
|
|
191
|
+
export async function handleCodingFailure(
|
|
192
|
+
ctx: RecipeContext,
|
|
193
|
+
row: Record<string, unknown>,
|
|
194
|
+
stage: string,
|
|
195
|
+
sessionKey: string,
|
|
196
|
+
result: CodingRunResult,
|
|
197
|
+
reason: string,
|
|
198
|
+
): Promise<RecipeResult> {
|
|
199
|
+
const persisted = {
|
|
200
|
+
Agent会话ID: saveSession(row["Agent会话ID"], sessionKey, result),
|
|
201
|
+
};
|
|
202
|
+
const nativeQuestion = text(result.response).match(
|
|
203
|
+
/^ASK_HUMAN_DETECTED:\s*([\s\S]+)$/,
|
|
204
|
+
)?.[1];
|
|
205
|
+
const questions = agentQuestions(result.response, stage);
|
|
206
|
+
if (nativeQuestion && !questions.length) {
|
|
207
|
+
questions.push({ id: `${stage}-Q1`, question: nativeQuestion.trim() });
|
|
208
|
+
}
|
|
209
|
+
if (questions.length) {
|
|
210
|
+
return blockExecution(
|
|
211
|
+
ctx,
|
|
212
|
+
`${stage} 阶段需要人工答复`,
|
|
213
|
+
"在人工答复中按问题编号逐项回答后自动续跑",
|
|
214
|
+
questionsText(questions),
|
|
215
|
+
persisted,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const detail = describeCodingFailure(result);
|
|
219
|
+
const headline = result.failure
|
|
220
|
+
? `${result.failure.kind}: ${clip(text(result.failure.message), 160)}`
|
|
221
|
+
: clip(text(result.response), 160);
|
|
222
|
+
const executionTimedOut = [text(result.failure?.message), text(result.response)].some(
|
|
223
|
+
(message) => /^coding agent 执行超时 \(\d+ms\)$/.test(message)
|
|
224
|
+
|| /^错误:等待超时(\d+s),任务可能仍在运行。$/.test(message),
|
|
225
|
+
);
|
|
226
|
+
if (!result.sessionId && executionTimedOut) {
|
|
227
|
+
return blockExecution(
|
|
228
|
+
ctx,
|
|
229
|
+
headline ? `${reason}|${headline}` : reason,
|
|
230
|
+
"执行超时且未保存 Agent session;请调整 timeoutMs 或人工确认后重试",
|
|
231
|
+
undefined,
|
|
232
|
+
persisted,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const resumeCondition = result.sessionId
|
|
236
|
+
? "引擎将使用已保存的 Agent session 自动重试"
|
|
237
|
+
: "引擎未回传 session,重试将冷启动;若为执行超时,需在 coding.run 传入更大的 timeoutMs";
|
|
238
|
+
await ctx.toolkit.table.updateCurrentRow({
|
|
239
|
+
状态: "active",
|
|
240
|
+
阻塞原因: headline ? `${reason}|${headline}` : reason,
|
|
241
|
+
解除条件: resumeCondition,
|
|
242
|
+
...persisted,
|
|
243
|
+
});
|
|
244
|
+
return failed(reason, detail);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function blocked(
|
|
248
|
+
blockReason: string,
|
|
249
|
+
output: Record<string, unknown> = {},
|
|
250
|
+
): RecipeResult {
|
|
251
|
+
return { status: "blocked", blockReason, output, logs: [blockReason] };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 阻塞字段与引擎 blocked 结果必须同时写出,避免状态和人工可读原因脱节。 */
|
|
255
|
+
export async function blockExecution(
|
|
256
|
+
ctx: RecipeContext,
|
|
257
|
+
reason: string,
|
|
258
|
+
condition: string,
|
|
259
|
+
questions?: string,
|
|
260
|
+
extra: Record<string, unknown> = {},
|
|
261
|
+
): Promise<RecipeResult> {
|
|
262
|
+
await ctx.toolkit.table.updateCurrentRow({
|
|
263
|
+
状态: "blocked",
|
|
264
|
+
阻塞原因: reason,
|
|
265
|
+
解除条件: condition,
|
|
266
|
+
...(questions ? { AI问题: questions } : {}),
|
|
267
|
+
...extra,
|
|
268
|
+
});
|
|
269
|
+
return blocked(reason);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* CR 和外部评审进展仍需由当前 recipe 轮询,所以不能把记录转为 blocked。
|
|
274
|
+
* 仅回写发生变化的可读等待事实,避免每轮检查反复触发表内自动化。
|
|
275
|
+
*/
|
|
276
|
+
export async function waitForExternalProgress(
|
|
277
|
+
ctx: RecipeContext,
|
|
278
|
+
row: Record<string, unknown>,
|
|
279
|
+
reason: string,
|
|
280
|
+
condition: string,
|
|
281
|
+
extra: Record<string, unknown> = {},
|
|
282
|
+
): Promise<RecipeResult> {
|
|
283
|
+
const desired = {
|
|
284
|
+
状态: "active",
|
|
285
|
+
阻塞原因: reason,
|
|
286
|
+
解除条件: condition,
|
|
287
|
+
...extra,
|
|
288
|
+
};
|
|
289
|
+
const changed = Object.fromEntries(
|
|
290
|
+
Object.entries(desired).filter(([field, value]) => row[field] !== value),
|
|
291
|
+
);
|
|
292
|
+
if (Object.keys(changed).length) {
|
|
293
|
+
await ctx.toolkit.table.updateCurrentRow(changed);
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
status: "needs_human",
|
|
297
|
+
blockReason: reason,
|
|
298
|
+
output: {},
|
|
299
|
+
logs: [reason],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export type SdlcQuestion = { id: string; question: string };
|
|
304
|
+
|
|
305
|
+
export type SdlcAnswer = { id: string; answer: string };
|
|
306
|
+
|
|
307
|
+
/** 按括号配平提取 `SDLC_QUESTIONS=[...]` 的完整 JSON 数组。
|
|
308
|
+
* 问题文本里可能出现 `]`(如 "[+ viewId]"),非贪婪正则会在第一个 `]` 截断,
|
|
309
|
+
* 导致 JSON.parse 失败并静默丢弃全部问题;这里在字符串内忽略括号、跨字符串配平。 */
|
|
310
|
+
function extractQuestionsArray(response: string): string | undefined {
|
|
311
|
+
const start = response.indexOf("SDLC_QUESTIONS=");
|
|
312
|
+
if (start < 0) return undefined;
|
|
313
|
+
const open = response.indexOf("[", start);
|
|
314
|
+
if (open < 0) return undefined;
|
|
315
|
+
let depth = 0;
|
|
316
|
+
let inString = false;
|
|
317
|
+
let escaped = false;
|
|
318
|
+
for (let i = open; i < response.length; i++) {
|
|
319
|
+
const ch = response[i];
|
|
320
|
+
if (inString) {
|
|
321
|
+
if (escaped) escaped = false;
|
|
322
|
+
else if (ch === "\\") escaped = true;
|
|
323
|
+
else if (ch === '"') inString = false;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (ch === '"') inString = true;
|
|
327
|
+
else if (ch === "[") depth++;
|
|
328
|
+
else if (ch === "]") {
|
|
329
|
+
depth--;
|
|
330
|
+
if (depth === 0) return response.slice(open, i + 1);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Agent 最终回复的机器可读问题清单;Git 工件仍是人类审阅真源。 */
|
|
337
|
+
export function agentQuestions(response: string, stage: string): SdlcQuestion[] {
|
|
338
|
+
const marker = extractQuestionsArray(response);
|
|
339
|
+
if (!marker) return [];
|
|
340
|
+
try {
|
|
341
|
+
const parsed: unknown = JSON.parse(marker);
|
|
342
|
+
if (!Array.isArray(parsed)) return [];
|
|
343
|
+
return parsed
|
|
344
|
+
.map((value, index): SdlcQuestion | undefined => {
|
|
345
|
+
if (typeof value === "string" && value.trim()) {
|
|
346
|
+
return { id: `${stage}-Q${index + 1}`, question: value.trim() };
|
|
347
|
+
}
|
|
348
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
349
|
+
const item = value as Record<string, unknown>;
|
|
350
|
+
const id = text(item.id) || `${stage}-Q${index + 1}`;
|
|
351
|
+
const question = text(item.question);
|
|
352
|
+
return question ? { id, question } : undefined;
|
|
353
|
+
})
|
|
354
|
+
.filter((question): question is SdlcQuestion => Boolean(question));
|
|
355
|
+
} catch {
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 「AI问题」是人工填写入口,不暴露内部 JSON 协议。
|
|
362
|
+
* 每次阻塞都会在消费后清空问题与答复,因此 Q1、Q2 可在不同阶段安全复用。
|
|
363
|
+
*/
|
|
364
|
+
export function questionsText(questions: SdlcQuestion[]): string {
|
|
365
|
+
return questions
|
|
366
|
+
.map((question, index) => `Q${index + 1}: ${question.question}`)
|
|
367
|
+
.join("\n");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function parseQuestions(value: unknown): SdlcQuestion[] {
|
|
371
|
+
const source = text(value);
|
|
372
|
+
if (!source) return [];
|
|
373
|
+
try {
|
|
374
|
+
const parsed: unknown = JSON.parse(source);
|
|
375
|
+
if (Array.isArray(parsed)) {
|
|
376
|
+
return parsed
|
|
377
|
+
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
378
|
+
.map((item) => ({ id: text(item.id), question: text(item.question) }))
|
|
379
|
+
.filter((item) => item.id && item.question);
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
// 兼容早期按「1. 问题」保存的记录,迁移时不丢掉在途问题。
|
|
383
|
+
}
|
|
384
|
+
return source.split("\n").flatMap((line, index) => {
|
|
385
|
+
const match = line.match(/^\s*(?:\[([^\]]+)\]|(Q?\d+)[.、::])\s*(.+)$/i);
|
|
386
|
+
return match ? [{ id: match[1] || match[2] || `legacy-Q${index + 1}`, question: match[3]!.trim() }] : [];
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function parseAnswers(value: unknown): SdlcAnswer[] {
|
|
391
|
+
const source = text(value);
|
|
392
|
+
if (!source) return [];
|
|
393
|
+
try {
|
|
394
|
+
const parsed: unknown = JSON.parse(source);
|
|
395
|
+
if (Array.isArray(parsed)) {
|
|
396
|
+
return parsed
|
|
397
|
+
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
398
|
+
.map((item) => ({ id: text(item.id), answer: text(item.answer) }))
|
|
399
|
+
.filter((item) => item.id && item.answer);
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
// 同时兼容「问题编号: 答复」的人类易读输入。
|
|
403
|
+
}
|
|
404
|
+
return source.split("\n").flatMap((line) => {
|
|
405
|
+
const match = line.match(/^\s*([^::]+)[::]\s*(.+)$/);
|
|
406
|
+
return match ? [{ id: match[1]!.trim(), answer: match[2]!.trim() }] : [];
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function unansweredQuestions(questionsValue: unknown, answersValue: unknown): SdlcQuestion[] {
|
|
411
|
+
const answered = new Set(parseAnswers(answersValue).map((answer) => answer.id));
|
|
412
|
+
return parseQuestions(questionsValue).filter((question) => !answered.has(question.id));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function answersForQuestions(questionsValue: unknown, answersValue: unknown): string {
|
|
416
|
+
const wanted = new Set(parseQuestions(questionsValue).map((question) => question.id));
|
|
417
|
+
return JSON.stringify(parseAnswers(answersValue).filter((answer) => wanted.has(answer.id)));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function consumeAnswersOrBlock(
|
|
421
|
+
ctx: RecipeContext,
|
|
422
|
+
row: Record<string, unknown>,
|
|
423
|
+
): Promise<{ answers: string; blocked?: RecipeResult }> {
|
|
424
|
+
const questions = parseQuestions(row["AI问题"]);
|
|
425
|
+
if (!questions.length) return { answers: "[]" };
|
|
426
|
+
const unanswered = unansweredQuestions(row["AI问题"], row["人工答复"]);
|
|
427
|
+
if (unanswered.length) {
|
|
428
|
+
return {
|
|
429
|
+
answers: "[]",
|
|
430
|
+
blocked: await blockExecution(
|
|
431
|
+
ctx,
|
|
432
|
+
"存在待确认问题",
|
|
433
|
+
`请在人工答复中回答:${unanswered.map((question) => question.id).join("、")}`,
|
|
434
|
+
questionsText(unanswered),
|
|
435
|
+
),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const answers = answersForQuestions(row["AI问题"], row["人工答复"]);
|
|
439
|
+
// 消费后必须连「人工答复」一起清空:问题编号会跨轮复用,
|
|
440
|
+
// 残留旧答复会让下一轮同编号问题被旧答案静默通过。
|
|
441
|
+
await ctx.toolkit.table.updateCurrentRow({
|
|
442
|
+
状态: "active",
|
|
443
|
+
阻塞原因: "",
|
|
444
|
+
解除条件: "",
|
|
445
|
+
AI问题: "",
|
|
446
|
+
人工答复: "",
|
|
447
|
+
});
|
|
448
|
+
return { answers };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function handledCommentMarker(stage: string, mrId: string, commentId: number): string {
|
|
452
|
+
return `CR评论已处理:${stage}:!${mrId}:${commentId}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function hasHandledComment(previous: unknown, stage: string, mrId: string, comment: MrComment): boolean {
|
|
456
|
+
const artifacts = text(previous);
|
|
457
|
+
return artifacts.split("\n").includes(handledCommentMarker(stage, mrId, comment.id));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** 返回本轮真正需要 Agent 处理的评论,避免已处理评论随批次变化被重复修复。 */
|
|
461
|
+
export function unhandledComments(
|
|
462
|
+
previous: unknown,
|
|
463
|
+
stage: string,
|
|
464
|
+
mrId: string,
|
|
465
|
+
comments: MrComment[],
|
|
466
|
+
): MrComment[] {
|
|
467
|
+
return comments.filter((comment) => !hasHandledComment(previous, stage, mrId, comment));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function hasHandledComments(previous: unknown, stage: string, mrId: string, comments: MrComment[]): boolean {
|
|
471
|
+
return unhandledComments(previous, stage, mrId, comments).length === 0;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function appendHandledComments(previous: unknown, stage: string, mrId: string, comments: MrComment[]): string {
|
|
475
|
+
return comments.reduce(
|
|
476
|
+
(artifacts, comment) => appendArtifact(artifacts, handledCommentMarker(stage, mrId, comment.id)),
|
|
477
|
+
text(previous),
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function failed(error: string, extraLogs: string[] = []): RecipeResult {
|
|
482
|
+
return {
|
|
483
|
+
status: "failed",
|
|
484
|
+
error,
|
|
485
|
+
retryable: true,
|
|
486
|
+
output: {},
|
|
487
|
+
logs: [error, ...extraLogs.filter(Boolean)],
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function success(
|
|
492
|
+
output: Record<string, unknown> = {},
|
|
493
|
+
message = "阶段工件已提交",
|
|
494
|
+
): RecipeResult {
|
|
495
|
+
return { status: "success", output, logs: [message] };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function changeId(row: Record<string, unknown>): string {
|
|
499
|
+
return recordValue(row, "变更编号");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* a1 codeReview 的 `--repo` 需要 `group/project` 形式的仓库路径,
|
|
504
|
+
* 而 recipe 手里只有 clone URL;传本地 worktree 目录会让 a1 报 NOT_FOUND「代码库不存在」。
|
|
505
|
+
* 这里从常见 git URL 形态解析出 group/project,已是路径形态时原样返回。
|
|
506
|
+
*/
|
|
507
|
+
export function repoPathFromUrl(url: string): string {
|
|
508
|
+
const value = text(url).replace(/\.git$/i, "");
|
|
509
|
+
if (!value) return "";
|
|
510
|
+
const scp = value.match(/^[^@/]+@[^:]+:(.+)$/);
|
|
511
|
+
if (scp) return scp[1]!;
|
|
512
|
+
const http = value.match(/^[a-z]+:\/\/[^/]+\/(.+)$/i);
|
|
513
|
+
if (http) return http[1]!;
|
|
514
|
+
return value;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** MR 标题应是人能读懂的需求标题,而不是变更编号;编号已由分支名承载。 */
|
|
518
|
+
export function changeTitle(row: Record<string, unknown>, fallback: string): string {
|
|
519
|
+
return recordValue(row, "标题") || recordValue(row, "需求") || fallback;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function crId(link: unknown): string | undefined {
|
|
523
|
+
const value = text(link);
|
|
524
|
+
|
|
525
|
+
// a1 的详情页路径携带可供 status/comment 等子命令使用的全局 MR ID。
|
|
526
|
+
// 人工将「当前CR链接」改成纯 URL 以供自动化按钮直达时,不能再依赖 !123 文本标记。
|
|
527
|
+
const detailUrl = value.match(/\/codereview\/(\d+)(?:[/?#]|$)/i);
|
|
528
|
+
if (detailUrl) return detailUrl[1];
|
|
529
|
+
|
|
530
|
+
const legacy = value.match(/(?:!|mrId=)(\d+)/i);
|
|
531
|
+
return legacy?.[1];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export type CrEntry = { repoUrl: string; mrId: string; mrUrl: string };
|
|
535
|
+
|
|
536
|
+
/** 当前阶段可有多个仓库 CR;字段保持人可读 Markdown,解析只接受本项目生成的格式。 */
|
|
537
|
+
export function crEntries(value: unknown): CrEntry[] {
|
|
538
|
+
return text(value)
|
|
539
|
+
.split("\n")
|
|
540
|
+
.map((line) => line.match(/^- \[[^\]]+\]\((.+)\)$/))
|
|
541
|
+
.filter((match): match is RegExpMatchArray => Boolean(match))
|
|
542
|
+
.flatMap((match) => {
|
|
543
|
+
const mrUrl = match[1]!;
|
|
544
|
+
const detail = mrUrl.match(/\/([^/]+\/[^/]+)\/codereview\/(\d+)(?:[/?#]|$)/i);
|
|
545
|
+
if (!detail) return [];
|
|
546
|
+
return [{ repoUrl: detail[1]!, mrId: detail[2]!, mrUrl }];
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function formatCrEntries(entries: CrEntry[]): string {
|
|
551
|
+
return entries
|
|
552
|
+
.map((entry) => {
|
|
553
|
+
const repo = repoPathFromUrl(entry.repoUrl).split("/").at(-1) || entry.repoUrl;
|
|
554
|
+
return `- [${repo}](${entry.mrUrl})`;
|
|
555
|
+
})
|
|
556
|
+
.join("\n");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** 评审修复后的用户通知正文:按仓库分别报告评论数,链接仍由当前 CR 字段统一展示。 */
|
|
560
|
+
export function commentRepairNotice(
|
|
561
|
+
entries: Array<Pick<CrEntry, "repoUrl" | "mrUrl"> & { commentCount: number }>,
|
|
562
|
+
): string {
|
|
563
|
+
return entries
|
|
564
|
+
.map((entry) => {
|
|
565
|
+
const repo = repoPathFromUrl(entry.repoUrl).split("/").at(-1) || entry.repoUrl;
|
|
566
|
+
return `${repo}:本轮已修复${entry.commentCount}条评论、已推送,等待复审`;
|
|
567
|
+
})
|
|
568
|
+
.join("\n");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** 提取 coding 工件中供同仓库 CR 描述复用的改动详情,不能退回 Agent 自由回复。 */
|
|
572
|
+
export function buildChangeDetails(markdown: string): string | undefined {
|
|
573
|
+
const heading = /^##\s+改动内容.*$/m.exec(markdown);
|
|
574
|
+
if (!heading || heading.index === undefined) return undefined;
|
|
575
|
+
const bodyStart = heading.index + heading[0].length;
|
|
576
|
+
const remainder = markdown.slice(bodyStart);
|
|
577
|
+
const nextHeading = /^#{1,2}\s+/m.exec(remainder);
|
|
578
|
+
const content = remainder.slice(0, nextHeading?.index).trim();
|
|
579
|
+
return content || undefined;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ─── 有界重试 ────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
/** 评审修复最大轮次,超过则升级人工处理 */
|
|
585
|
+
export const MAX_REVIEW_ROUNDS = 3;
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 安全解析审查轮次,防止 NaN 导致有界重试保护静默失效。
|
|
589
|
+
* ?? 仅处理 null/undefined,空字符串或非数字字符串会使 Number() 返回 NaN,
|
|
590
|
+
* 而 NaN > MAX_REVIEW_ROUNDS 永远为 false,守卫永远不会触发。
|
|
591
|
+
*/
|
|
592
|
+
export function parseReviewRound(row: Record<string, unknown>): number {
|
|
593
|
+
const parsed = Number(row["审查轮次"] ?? 1);
|
|
594
|
+
return Number.isFinite(parsed) ? parsed : 1;
|
|
595
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
registry=https://registry.npmjs.org/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-native-sdlc-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "AI Native SDLC 双数字员工协作台",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"run": "aitable-workflow run",
|
|
9
|
+
"setup": "aitable-workflow setup",
|
|
10
|
+
"check": "aitable-workflow validate",
|
|
11
|
+
"test": "node --import tsx --test \"test/**/*.test.ts\""
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"aitable-workflow-core": "0.1.28-beta.1789632452666",
|
|
15
|
+
"tsx": "^4.20.6",
|
|
16
|
+
"yaml": "^2.8.1",
|
|
17
|
+
"zod": "^4.1.13"
|
|
18
|
+
}
|
|
19
|
+
}
|