paseo-req-kit 1.7.0-beta.3 → 1.8.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/CHANGELOG.md +94 -0
- package/README.md +15 -10
- package/bin/req.mjs +4 -0
- package/config.example.json +32 -1
- package/contracts/feature-files.md +30 -0
- package/contracts/notifier-event.md +3 -2
- package/contracts/result-file.md +8 -4
- package/contracts/spec.md +3 -1
- package/contracts/state.md +7 -2
- package/contracts/verify.md +2 -0
- package/docs/adr/0023-types-gate-slices.md +54 -0
- package/docs/adr/0024-confirm-early-review-axes.md +27 -0
- package/docs/adr/0025-joint-requirements.md +27 -0
- package/docs/adr/README.md +3 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +18 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +16 -7
- package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +1 -1
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +105 -0
- package/engine/prompts/00-shared-context.md +4 -0
- package/engine/prompts/02-spec-review.md +1 -1
- package/engine/prompts/03-design.md +31 -8
- package/engine/prompts/04-design-review.md +3 -2
- package/engine/prompts/04b-design-revise.md +8 -1
- package/engine/prompts/05-implement.md +11 -5
- package/engine/prompts/06-code-review.md +13 -9
- package/engine/prompts/07-review-fix.md +1 -1
- package/engine/prompts/09-verify.md +1 -1
- package/engine/prompts/10-defect-fix.md +1 -1
- package/engine/prompts/11-report.md +20 -1
- package/engine/prompts/incremental.md +1 -0
- package/engine/prompts/joint.md +55 -0
- package/engine/prompts/related-repos.md +7 -0
- package/engine/prompts/review-axis-spec.md +10 -0
- package/engine/prompts/review-axis-standards.md +11 -0
- package/engine/prompts/types/assessment.md +41 -0
- package/engine/prompts/types/defect.md +55 -0
- package/engine/prompts/types/refactor.md +39 -0
- package/engine/src/cli-args.ts +14 -1
- package/engine/src/config.ts +140 -0
- package/engine/src/control.ts +27 -11
- package/engine/src/digest.ts +12 -3
- package/engine/src/doctor.ts +28 -1
- package/engine/src/eval.ts +1 -1
- package/engine/src/features.ts +2 -0
- package/engine/src/gate.ts +157 -0
- package/engine/src/joint.ts +173 -0
- package/engine/src/launch.ts +1 -1
- package/engine/src/lessons.ts +72 -0
- package/engine/src/main.ts +38 -5
- package/engine/src/messages.ts +87 -1
- package/engine/src/notifiers.ts +1 -1
- package/engine/src/notify-test.ts +4 -0
- package/engine/src/pipeline.ts +777 -63
- package/engine/src/playbooks.ts +46 -0
- package/engine/src/preflight.ts +41 -2
- package/engine/src/state.ts +58 -3
- package/engine/src/steps.ts +29 -5
- package/notifiers/im-template.mjs +3 -1
- package/package.json +2 -2
- package/skills/acceptance-criteria/SKILL.md +10 -1
- package/skills/req/SKILL.md +5 -3
- package/skills/req/playbooks/new.md +16 -5
- package/skills/req/playbooks/small.md +1 -1
- package/skills/req/playbooks/status.md +2 -0
- package/skills/req-retro/SKILL.md +2 -0
- package/skills/req-setup/SKILL.md +22 -5
- package/skills/requirement-interview/SKILL.md +3 -0
- package/skills/spec-writing/SKILL.md +18 -4
- package/skills/spec-writing/spec-template.md +4 -1
package/engine/src/pipeline.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { type AgentProfile, PHASE_MODE_LABELS, PROFILE_LABELS, type PipelineConfig, type ReviewPanelMember } from "./config.js";
|
|
5
4
|
import {
|
|
5
|
+
type AgentProfile,
|
|
6
|
+
PHASE_MODE_LABELS,
|
|
7
|
+
PROFILE_LABELS,
|
|
8
|
+
type PipelineConfig,
|
|
9
|
+
REQUIREMENT_TYPE_LABELS,
|
|
10
|
+
resolveRelatedRepo,
|
|
11
|
+
type ReviewPanelMember,
|
|
12
|
+
} from "./config.js";
|
|
13
|
+
import { changedFiles, changedSince, checkApplies, headCommit, logName, matchesPath, runCheck, tail } from "./gate.js";
|
|
14
|
+
import { activeJoint, ensureJointRepos, loadProjectChecks, type StepTarget, syncJointDocs } from "./joint.js";
|
|
15
|
+
import { playbookSection, promptSections, stepTitle } from "./playbooks.js";
|
|
16
|
+
import {
|
|
17
|
+
approvalContinuePrompt,
|
|
18
|
+
approvalMessage,
|
|
19
|
+
approvalReminderMessage,
|
|
6
20
|
autoResumePrompt,
|
|
7
21
|
blockedMessage,
|
|
22
|
+
gateFollowUp,
|
|
8
23
|
waitingReminderMessage,
|
|
9
24
|
completedMessage,
|
|
10
25
|
failedMessage,
|
|
@@ -20,7 +35,7 @@ import { type AgentUsage, describeTotals, totals } from "./usage.js";
|
|
|
20
35
|
import { dispatchNotifiers, type NotifierEventType } from "./notifiers.js";
|
|
21
36
|
import { principlesSection, projectAdditions, type PromptLocations, readToolPrompt } from "./prompts.js";
|
|
22
37
|
import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent } from "./runtime.js";
|
|
23
|
-
import { type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
|
|
38
|
+
import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
|
|
24
39
|
import { type PanelStep, STEPS, type StepDef, type StepId, stepKey, validateResult, VERIFY_PHASE_STEPS } from "./steps.js";
|
|
25
40
|
|
|
26
41
|
export interface PipelineContext {
|
|
@@ -37,15 +52,25 @@ export interface PipelineContext {
|
|
|
37
52
|
statusCommand?: string;
|
|
38
53
|
/** Command that starts the verify phase (gated runs). */
|
|
39
54
|
verifyCommand?: string;
|
|
55
|
+
/** Command that confirms a design waiting at the confirmation point. */
|
|
56
|
+
approveCommand?: string;
|
|
40
57
|
log: (message: string) => void;
|
|
41
58
|
sleep: (ms: number) => Promise<void>;
|
|
42
59
|
/** Paseo-managed services of this run (filled in by the pipeline). */
|
|
43
60
|
services?: { names: string[] | null | undefined; started: Set<string> };
|
|
44
61
|
/** Paseo tools the step agents get; looked up once (undefined: not yet). */
|
|
45
62
|
capabilities?: { paseoTools: boolean; browser: boolean } | null;
|
|
63
|
+
/** Related repositories found on this machine, looked up once (undefined: not yet). */
|
|
64
|
+
related?: Array<{ name: string; dir: string; role: string | null; ref: string | null }>;
|
|
65
|
+
/** Joint requirement: the related repositories the spec says this requirement also changes. */
|
|
66
|
+
jointNames?: string[];
|
|
67
|
+
/** Paseo-managed services started in the joint repositories' worktrees, by workspace. */
|
|
68
|
+
jointServices?: Map<string, string[]>;
|
|
69
|
+
/** Quality-gate checks of the joint repositories, read once. */
|
|
70
|
+
jointChecksCache?: Map<string, PipelineConfig["checks"]>;
|
|
46
71
|
}
|
|
47
72
|
|
|
48
|
-
export type PipelineOutcome = "completed" | "waiting_for_business" | "failed" | "phase_done";
|
|
73
|
+
export type PipelineOutcome = "completed" | "waiting_for_business" | "failed" | "phase_done" | "waiting_for_approval";
|
|
49
74
|
|
|
50
75
|
/** The develop phase is done and the run stops at the gate (phases.mode "gated"). */
|
|
51
76
|
class PhaseGate extends Error {
|
|
@@ -55,6 +80,14 @@ class PhaseGate extends Error {
|
|
|
55
80
|
}
|
|
56
81
|
}
|
|
57
82
|
|
|
83
|
+
/** The design waits for the requester's confirmation before any code is written (designApproval). */
|
|
84
|
+
class ApprovalNeeded extends Error {
|
|
85
|
+
constructor() {
|
|
86
|
+
super("方案等待需求方确认");
|
|
87
|
+
this.name = "ApprovalNeeded";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
58
91
|
const DEFAULT_PHASES: RunPhases = { mode: "continuous", environment: "local", defects: "fix" };
|
|
59
92
|
|
|
60
93
|
export function runPhases(state: Pick<PipelineState, "phases">): RunPhases {
|
|
@@ -72,6 +105,10 @@ function verifyCommandOf(ctx: PipelineContext): string {
|
|
|
72
105
|
return ctx.verifyCommand ?? `${ctx.resumeCommand} --phase verify`;
|
|
73
106
|
}
|
|
74
107
|
|
|
108
|
+
function approveCommandOf(ctx: PipelineContext): string {
|
|
109
|
+
return ctx.approveCommand ?? ctx.resumeCommand.replace(/ --resume$/, " --approve");
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
/** Ends the run early; the message is what the user-facing notification explains. */
|
|
76
113
|
class StepFailure extends Error {
|
|
77
114
|
constructor(
|
|
@@ -96,7 +133,11 @@ export function isTransientError(message: string): boolean {
|
|
|
96
133
|
}
|
|
97
134
|
|
|
98
135
|
class BusinessDecisionNeeded extends Error {
|
|
99
|
-
constructor(
|
|
136
|
+
constructor(
|
|
137
|
+
public readonly questions: string[],
|
|
138
|
+
/** "reproduce": a defect that could not be reproduced. */
|
|
139
|
+
public readonly source: "design_review" | "reproduce" = "design_review",
|
|
140
|
+
) {
|
|
100
141
|
super("需要需求方决定");
|
|
101
142
|
this.name = "BusinessDecisionNeeded";
|
|
102
143
|
}
|
|
@@ -138,6 +179,61 @@ interface StepExtras {
|
|
|
138
179
|
criteriaFile?: string;
|
|
139
180
|
/** Step whose project additions (steps/<id>.md) apply; defaults to the step itself. */
|
|
140
181
|
additionsFor?: StepId;
|
|
182
|
+
/** Run the quality gate after the step, sending new failures back to its agent (code-changing steps). */
|
|
183
|
+
gate?: boolean;
|
|
184
|
+
/** Run in one of the joint repositories instead of this one. */
|
|
185
|
+
target?: StepTarget;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The joint repositories this run works in (empty: an ordinary requirement). */
|
|
189
|
+
function jointTargets(ctx: PipelineContext): StepTarget[] {
|
|
190
|
+
return activeJoint(ctx.state, ctx.jointNames ?? []).map((j) => ({
|
|
191
|
+
name: j.name,
|
|
192
|
+
slug: j.slug,
|
|
193
|
+
repo: j.repo,
|
|
194
|
+
featureDir: ctx.featureDir,
|
|
195
|
+
workspaceId: j.workspaceId,
|
|
196
|
+
branch: j.branch,
|
|
197
|
+
joint: j,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A step key for a joint repository: "implement@backend", "code_review-r2@backend". */
|
|
202
|
+
function keyFor(base: string, target: StepTarget | undefined): string {
|
|
203
|
+
return target ? `${base}@${target.slug}` : base;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The step as this requirement sees it: a playbook may rename it (e.g. 方案设计 → 复现与定位). */
|
|
207
|
+
function stepDef(ctx: PipelineContext, id: StepId): StepDef {
|
|
208
|
+
return { ...STEPS[id], title: stepTitle(ctx.state.type, id) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Related repositories that exist on this machine, with the branch and commit they are at. */
|
|
212
|
+
function relatedRepos(ctx: PipelineContext): NonNullable<PipelineContext["related"]> {
|
|
213
|
+
if (ctx.related) return ctx.related;
|
|
214
|
+
const projectDir = ctx.state.worktree?.sourceRepo ?? ctx.repo;
|
|
215
|
+
ctx.related = [];
|
|
216
|
+
const joint = ctx.jointNames ?? [];
|
|
217
|
+
for (const r of ctx.config.relatedRepos) {
|
|
218
|
+
// A repository this requirement changes is not a read-only reference: it has its own worktree.
|
|
219
|
+
if (joint.includes(r.name)) continue;
|
|
220
|
+
const dir = resolveRelatedRepo(r, projectDir);
|
|
221
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
222
|
+
ctx.log(`关联仓库「${r.name}」不在这台机器上(${dir}),步骤看不到它`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
let ref: string | null = null;
|
|
226
|
+
try {
|
|
227
|
+
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
228
|
+
const commit = execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
229
|
+
ref = `分支 ${branch},提交 ${commit}`;
|
|
230
|
+
} catch {
|
|
231
|
+
// Not a git repository: still readable.
|
|
232
|
+
}
|
|
233
|
+
ctx.related.push({ name: r.name, dir, role: r.role?.trim() || null, ref });
|
|
234
|
+
}
|
|
235
|
+
if (ctx.related.length > 0) ctx.log(`关联仓库(只读):${ctx.related.map((r) => `${r.name} ${r.dir}${r.ref ? `(${r.ref})` : ""}`).join(";")}`);
|
|
236
|
+
return ctx.related;
|
|
141
237
|
}
|
|
142
238
|
|
|
143
239
|
function buildPrompt(
|
|
@@ -148,17 +244,55 @@ function buildPrompt(
|
|
|
148
244
|
resultFile: string,
|
|
149
245
|
extras: StepExtras = {},
|
|
150
246
|
): string {
|
|
151
|
-
const
|
|
247
|
+
const target = extras.target;
|
|
248
|
+
const stepRepo = target?.repo ?? ctx.repo;
|
|
249
|
+
// A joint repository's own project notes, verify.md and step additions apply to its steps.
|
|
250
|
+
const sources = { ...ctx.prompts, repo: stepRepo };
|
|
152
251
|
const profile = ctx.state.profile;
|
|
252
|
+
const branch = target ? target.branch : ctx.state.branch;
|
|
153
253
|
const lines = [
|
|
154
254
|
"<pipeline-context>",
|
|
155
|
-
`仓库目录:${
|
|
255
|
+
`仓库目录:${stepRepo}`,
|
|
156
256
|
`功能目录:${ctx.featureDir}`,
|
|
157
|
-
def.id === "spec_review" ? "工作分支:(本步骤不提交)" : `工作分支:${
|
|
257
|
+
def.id === "spec_review" ? "工作分支:(本步骤不提交)" : `工作分支:${branch ?? "(不是 git 仓库,不需要提交)"}`,
|
|
158
258
|
`档位:${PROFILE_LABELS[profile]}${profile === "quick" ? "(小改动:各产物只写必要内容,保持简短)" : ""}`,
|
|
159
259
|
`本步骤:${def.title}(流水线第 ${def.number} 步)`,
|
|
160
260
|
];
|
|
161
261
|
if (round !== null && maxRounds !== null) lines.push(`轮次:第 ${round} 轮,最多 ${maxRounds} 轮`);
|
|
262
|
+
const type = ctx.state.type ?? null;
|
|
263
|
+
if (type) lines.push(`需求类型:${REQUIREMENT_TYPE_LABELS[type]}(按下文"本需求类型的做法"进行)`);
|
|
264
|
+
const joint = jointTargets(ctx);
|
|
265
|
+
if (joint.length > 0 && target) {
|
|
266
|
+
lines.push(
|
|
267
|
+
`联合需求:本步骤在「${target.name}」仓库中进行(${target.repo},分支 ${target.branch})`,
|
|
268
|
+
`联合需求的主仓库:${ctx.repo}(需求说明、方案、接口约定的原件在 ${path.join(ctx.repo, ctx.featureDir)},本仓库功能目录中的是同步过来的副本)`,
|
|
269
|
+
);
|
|
270
|
+
} else if (joint.length > 0) {
|
|
271
|
+
lines.push(`联合需求:本需求同时改 ${joint.length + 1} 个仓库,本仓库是主仓库(${ctx.repo},分支 ${ctx.state.branch ?? "无"})`);
|
|
272
|
+
for (const t of joint) lines.push(`联合需求的仓库:${t.name} → ${t.repo}(分支 ${t.branch})`);
|
|
273
|
+
}
|
|
274
|
+
const planning: readonly StepId[] = ["design", "design_proposal", "design_review", "design_revise"];
|
|
275
|
+
if (planning.includes(def.id) && type !== "assessment") {
|
|
276
|
+
lines.push(
|
|
277
|
+
joint.length > 0
|
|
278
|
+
? "切片:联合需求每个仓库由一个 agent 实现,不按切片;实施计划按仓库分开列出任务"
|
|
279
|
+
: ctx.config.slices.profiles.includes(profile) && ctx.config.slices.max >= 2
|
|
280
|
+
? `切片上限:${ctx.config.slices.max} 片(实施计划分成多片时,按片逐个实现)`
|
|
281
|
+
: "切片:本次不按切片实现,实施计划按任务列出即可",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const approval = approvalMode(ctx);
|
|
285
|
+
if (approval && (def.id === "design" || def.id === "design_revise" || def.id === "design_review")) {
|
|
286
|
+
lines.push(
|
|
287
|
+
approval === "always"
|
|
288
|
+
? "方案确认点:开启。方案审核通过后,需求方先看 preview.md 再决定是否开始写代码"
|
|
289
|
+
: "方案确认点:界面改动时开启。结果文件写 ui_change;为 true 时写 preview.md 和界面原型,方案审核通过后需求方先确认再开始写代码",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const related = relatedRepos(ctx);
|
|
293
|
+
for (const r of related) {
|
|
294
|
+
lines.push(`关联仓库(只读):${r.name} → ${r.dir}${r.ref || r.role ? `(${[r.ref, r.role].filter(Boolean).join(";")})` : ""}`);
|
|
295
|
+
}
|
|
162
296
|
const phases = runPhases(ctx.state);
|
|
163
297
|
if (ctx.state.phases && phases.mode !== "continuous") {
|
|
164
298
|
lines.push(`执行方式:${PHASE_MODE_LABELS[phases.mode]}(当前阶段:${phaseOf(ctx) === "verify" ? "测试验证" : "研发"})`);
|
|
@@ -178,12 +312,21 @@ function buildPrompt(
|
|
|
178
312
|
);
|
|
179
313
|
}
|
|
180
314
|
if (extras.contextLines) lines.push(...extras.contextLines);
|
|
181
|
-
lines.push(`结果文件:${resultFile}`, `结果文件绝对路径:${path.join(
|
|
315
|
+
lines.push(`结果文件:${resultFile}`, `结果文件绝对路径:${path.join(stepRepo, resultFile)}`, "</pipeline-context>");
|
|
182
316
|
const parts = [readToolPrompt(sources, "00-shared-context.md")];
|
|
183
317
|
const principles = principlesSection(sources);
|
|
184
318
|
if (principles) parts.push(principles);
|
|
185
319
|
parts.push(readToolPrompt(sources, def.promptFile));
|
|
320
|
+
if (type) {
|
|
321
|
+
const playbook = playbookSection(sources, type, extras.additionsFor ?? def.id);
|
|
322
|
+
if (playbook) parts.push(playbook);
|
|
323
|
+
}
|
|
324
|
+
if (joint.length > 0) {
|
|
325
|
+
const jointGuide = promptSections(sources, "joint.md", extras.additionsFor ?? def.id);
|
|
326
|
+
if (jointGuide) parts.push(jointGuide);
|
|
327
|
+
}
|
|
186
328
|
if (ctx.state.incremental) parts.push(readToolPrompt(sources, "incremental.md"));
|
|
329
|
+
if (related.length > 0) parts.push(readToolPrompt(sources, "related-repos.md"));
|
|
187
330
|
if (extras.criteriaFile) {
|
|
188
331
|
parts.push(
|
|
189
332
|
[
|
|
@@ -357,8 +500,10 @@ async function runStepOnce(
|
|
|
357
500
|
maxRounds: number | null,
|
|
358
501
|
extras: StepExtras,
|
|
359
502
|
): Promise<Record<string, unknown>> {
|
|
360
|
-
const def =
|
|
361
|
-
const
|
|
503
|
+
const def = stepDef(ctx, id);
|
|
504
|
+
const target = extras.target;
|
|
505
|
+
const key = extras.key ?? keyFor(stepKey(id, round), target);
|
|
506
|
+
const stepRepo = target?.repo ?? ctx.repo;
|
|
362
507
|
const cached = ctx.state.results[key];
|
|
363
508
|
if (cached) {
|
|
364
509
|
ctx.log(`跳过 ${key}:沿用上次运行的结果 ${JSON.stringify(cached)}`);
|
|
@@ -366,10 +511,17 @@ async function runStepOnce(
|
|
|
366
511
|
}
|
|
367
512
|
|
|
368
513
|
const resultRel = `${ctx.featureDir}/.pipeline/results/${key}.json`;
|
|
369
|
-
const resultAbs = path.join(
|
|
514
|
+
const resultAbs = path.join(stepRepo, resultRel);
|
|
515
|
+
fs.mkdirSync(path.dirname(resultAbs), { recursive: true });
|
|
370
516
|
fs.rmSync(resultAbs, { force: true });
|
|
371
517
|
|
|
372
|
-
|
|
518
|
+
// Services, the browser and the deployed environment belong to the steps in this repository.
|
|
519
|
+
const managed = target
|
|
520
|
+
? null
|
|
521
|
+
: mergeExtras(
|
|
522
|
+
mergeExtras(mergeExtras(await serviceExtras(ctx, id), await jointServiceExtras(ctx, id)), await browserExtras(ctx, id)),
|
|
523
|
+
deployedExtras(ctx, id),
|
|
524
|
+
);
|
|
373
525
|
if (managed) {
|
|
374
526
|
extras = {
|
|
375
527
|
...extras,
|
|
@@ -380,7 +532,7 @@ async function runStepOnce(
|
|
|
380
532
|
|
|
381
533
|
const agentTitle =
|
|
382
534
|
`[${featureName(ctx.featureDir)}] ` +
|
|
383
|
-
(extras.title ?? `${def.number}-${def.title}${round !== null && round > 1 ? ` 第${round}轮` : ""}`);
|
|
535
|
+
(extras.title ?? `${def.number}-${def.title}${round !== null && round > 1 ? ` 第${round}轮` : ""}${target ? `(${target.name})` : ""}`);
|
|
384
536
|
const record: StepRecord = {
|
|
385
537
|
key,
|
|
386
538
|
stepId: id,
|
|
@@ -402,15 +554,16 @@ async function runStepOnce(
|
|
|
402
554
|
role: def.role,
|
|
403
555
|
title: agentTitle,
|
|
404
556
|
prompt: buildPrompt(ctx, def, round, maxRounds, resultRel, extras),
|
|
405
|
-
cwd:
|
|
557
|
+
cwd: stepRepo,
|
|
406
558
|
labels: {
|
|
407
559
|
"requirement-pipeline": featureName(ctx.featureDir),
|
|
408
560
|
"pipeline-step": key,
|
|
561
|
+
...(target ? { "joint-repo": target.slug } : {}),
|
|
409
562
|
},
|
|
410
563
|
parentAgentId: ctx.config.attachToOriginChat ? ctx.state.originAgentId : null,
|
|
411
564
|
// Always passed: the run's config has Paseo profile names resolved; the runtime's may not.
|
|
412
565
|
profile: extras.profile ?? phaseAgent(ctx, id) ?? ctx.config.agents[def.role],
|
|
413
|
-
workspaceId: ctx.state.worktree?.workspaceId ?? null,
|
|
566
|
+
workspaceId: target ? target.workspaceId : (ctx.state.worktree?.workspaceId ?? null),
|
|
414
567
|
});
|
|
415
568
|
record.agentId = agent.id;
|
|
416
569
|
ctx.state.history.push(record);
|
|
@@ -431,6 +584,16 @@ async function runStepOnce(
|
|
|
431
584
|
throw new StepFailure(def.title, `提醒 ${record.followUps} 次后仍没有得到有效的结果文件:${validation.error}`);
|
|
432
585
|
}
|
|
433
586
|
|
|
587
|
+
if (extras.gate) {
|
|
588
|
+
// The same agent fixes what its own change broke: it still has the context.
|
|
589
|
+
await qualityGate(ctx, key, target, async (text) => {
|
|
590
|
+
await agent.followUp(text);
|
|
591
|
+
record.usage = (await waitForTurn(ctx, agent, def, agentTitle, deadline)) ?? record.usage ?? null;
|
|
592
|
+
const again = readResultFile(resultAbs, def);
|
|
593
|
+
if (again.ok) validation = again;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
434
597
|
record.outcome = "completed";
|
|
435
598
|
record.endedAt = new Date().toISOString();
|
|
436
599
|
record.result = validation.value;
|
|
@@ -562,7 +725,38 @@ async function serviceExtras(ctx: PipelineContext, id: StepId): Promise<{ contex
|
|
|
562
725
|
return { contextLines: lines, sections };
|
|
563
726
|
}
|
|
564
727
|
|
|
728
|
+
/** Joint requirement: the other repositories' paseo.json services, started with their current code too. */
|
|
729
|
+
async function jointServiceExtras(ctx: PipelineContext, id: StepId): Promise<{ contextLines: string[]; sections: string[] } | null> {
|
|
730
|
+
if (!SERVICE_STEPS.includes(id) || deployed(ctx) || ctx.config.paseoServices === "off") return null;
|
|
731
|
+
const joint = jointTargets(ctx);
|
|
732
|
+
if (joint.length === 0) return null;
|
|
733
|
+
ctx.jointServices ??= new Map();
|
|
734
|
+
const lines: string[] = [];
|
|
735
|
+
for (const t of joint) {
|
|
736
|
+
if (!t.workspaceId) continue;
|
|
737
|
+
const names = await ctx.runtime.listServices({ workspaceId: t.workspaceId }).catch(() => null);
|
|
738
|
+
if (!names || names.length === 0) {
|
|
739
|
+
lines.push(`联合仓库「${t.name}」:paseo.json 中没有定义服务,需要它的服务时按它的验证方法自己启动(在 ${t.repo}),用完关掉`);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
const services = await ctx.runtime.startServices({ workspaceId: t.workspaceId }, names, ctx.config.serviceReadySeconds * 1000);
|
|
744
|
+
ctx.jointServices.set(t.workspaceId, names);
|
|
745
|
+
for (const service of services) lines.push(`联合仓库「${t.name}」的${describeService(service, null)}`);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
lines.push(`联合仓库「${t.name}」的服务这次没能通过 Paseo 启动(${(error as Error).message}),需要时按它的验证方法自己启动,用完关掉`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (lines.length > 0) ctx.log(lines.join(";"));
|
|
751
|
+
return lines.length > 0 ? { contextLines: lines, sections: [] } : null;
|
|
752
|
+
}
|
|
753
|
+
|
|
565
754
|
async function stopManagedServices(ctx: PipelineContext): Promise<void> {
|
|
755
|
+
for (const [workspaceId, names] of ctx.jointServices ?? []) {
|
|
756
|
+
await ctx.runtime.stopServices({ workspaceId }, names).catch((error: Error) => ctx.log(`停止联合仓库的服务失败:${error.message}`));
|
|
757
|
+
ctx.log(`已停止联合仓库中由 Paseo 管理的服务:${names.join("、")}`);
|
|
758
|
+
}
|
|
759
|
+
ctx.jointServices?.clear();
|
|
566
760
|
const started = [...(ctx.services?.started ?? [])];
|
|
567
761
|
const target = serviceTarget(ctx);
|
|
568
762
|
if (started.length === 0 || !target) return;
|
|
@@ -572,24 +766,23 @@ async function stopManagedServices(ctx: PipelineContext): Promise<void> {
|
|
|
572
766
|
}
|
|
573
767
|
|
|
574
768
|
/** A Paseo schedule that reminds the chat about unanswered questions; returns the hours, or null. */
|
|
575
|
-
async function scheduleReminder(ctx: PipelineContext,
|
|
769
|
+
async function scheduleReminder(ctx: PipelineContext, what: string[] | "phase" | "approval"): Promise<number | null> {
|
|
576
770
|
const hours = ctx.config.waitingReminderHours;
|
|
577
771
|
const origin = ctx.state.originAgentId;
|
|
578
772
|
if (hours <= 0 || !origin) return null;
|
|
579
773
|
const at = new Date(Date.now() + hours * 3600_000);
|
|
774
|
+
const shown = shownFeatureDir(ctx);
|
|
580
775
|
try {
|
|
581
776
|
const id = await ctx.runtime.scheduleMessage({
|
|
582
777
|
agentId: origin,
|
|
583
778
|
at,
|
|
584
|
-
name: `req-kit 提醒:${featureName(ctx.featureDir)} ${
|
|
585
|
-
prompt:
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
hours,
|
|
590
|
-
|
|
591
|
-
verifyCommand: verifyCommandOf(ctx),
|
|
592
|
-
}),
|
|
779
|
+
name: `req-kit 提醒:${featureName(ctx.featureDir)} ${what === "phase" ? "待测试验证" : what === "approval" ? "等待确认方案" : "等待回答"}`,
|
|
780
|
+
prompt:
|
|
781
|
+
what === "phase"
|
|
782
|
+
? phaseWaitingReminderMessage({ featureDir: shown, hours, handoffPath: `${shown}/handoff.md`, verifyCommand: verifyCommandOf(ctx) })
|
|
783
|
+
: what === "approval"
|
|
784
|
+
? approvalReminderMessage({ featureDir: shown, hours, previewPath: `${shown}/preview.md`, approveCommand: approveCommandOf(ctx) })
|
|
785
|
+
: waitingReminderMessage({ featureDir: shown, hours, questions: what, resumeCommand: ctx.resumeCommand }),
|
|
593
786
|
});
|
|
594
787
|
if (!id) return null;
|
|
595
788
|
ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "reminder", at: at.toISOString() }];
|
|
@@ -634,6 +827,35 @@ async function scheduleVerifyStart(ctx: PipelineContext): Promise<string | null>
|
|
|
634
827
|
}
|
|
635
828
|
}
|
|
636
829
|
|
|
830
|
+
/** designApproval.autoContinueHours: a Paseo schedule that has the chat approve the design if nobody did; returns the time, or null. */
|
|
831
|
+
async function scheduleApprovalContinue(ctx: PipelineContext): Promise<string | null> {
|
|
832
|
+
const hours = ctx.config.designApproval.autoContinueHours;
|
|
833
|
+
const origin = ctx.state.originAgentId;
|
|
834
|
+
if (hours <= 0 || !origin) return null;
|
|
835
|
+
const at = new Date(Date.now() + hours * 3600_000);
|
|
836
|
+
try {
|
|
837
|
+
const id = await ctx.runtime.scheduleMessage({
|
|
838
|
+
agentId: origin,
|
|
839
|
+
at,
|
|
840
|
+
name: `req-kit 方案确认超时:${featureName(ctx.featureDir)}`,
|
|
841
|
+
prompt: approvalContinuePrompt({
|
|
842
|
+
featureDir: shownFeatureDir(ctx),
|
|
843
|
+
hours,
|
|
844
|
+
statusCommand: ctx.statusCommand ?? ctx.resumeCommand.replace(/ --resume$/, " --status"),
|
|
845
|
+
approveCommand: approveCommandOf(ctx),
|
|
846
|
+
}),
|
|
847
|
+
});
|
|
848
|
+
if (!id) return null;
|
|
849
|
+
ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "approval-continue", at: at.toISOString() }];
|
|
850
|
+
save(ctx);
|
|
851
|
+
ctx.log(`已预约 ${hours} 小时后仍未确认时按方案继续(Paseo 定时任务 ${id})`);
|
|
852
|
+
return shortTime(at);
|
|
853
|
+
} catch (error) {
|
|
854
|
+
ctx.log(`预约按方案继续失败:${(error as Error).message}`);
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
637
859
|
/** A Paseo schedule that has the chat continue the run later; returns the time as text, or null. */
|
|
638
860
|
async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise<string | null> {
|
|
639
861
|
const minutes = ctx.config.autoResumeMinutes;
|
|
@@ -713,24 +935,37 @@ function memberName(member: ReviewPanelMember, index: number): string {
|
|
|
713
935
|
* (relative to the repository) of the members that finished; a member that fails
|
|
714
936
|
* is logged and left out. Fails only when no member finishes.
|
|
715
937
|
*/
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
938
|
+
/** A reviewer of a panel; `section` is extra guidance for this member only (review axes). */
|
|
939
|
+
type PanelMember = ReviewPanelMember & { section?: string };
|
|
940
|
+
|
|
941
|
+
async function runPanel(
|
|
942
|
+
ctx: PipelineContext,
|
|
943
|
+
step: PanelStep,
|
|
944
|
+
round: number,
|
|
945
|
+
shared: string[] = [],
|
|
946
|
+
members: PanelMember[] = ctx.config.reviewPanel.members,
|
|
947
|
+
target?: StepTarget,
|
|
948
|
+
): Promise<string[]> {
|
|
949
|
+
const title = stepDef(ctx, step).title;
|
|
719
950
|
const runs = members.map((member, i) => {
|
|
720
|
-
const key = `${step}-r${round}-p${i + 1}
|
|
721
|
-
const findings = `${ctx.featureDir}/review-panel/${key}.md`;
|
|
951
|
+
const key = keyFor(`${step}-r${round}-p${i + 1}`, target);
|
|
952
|
+
const findings = `${ctx.featureDir}/review-panel/${key.replace("@", "-")}.md`;
|
|
722
953
|
const name = memberName(member, i);
|
|
723
954
|
const contextLines = [
|
|
724
|
-
`评审小组:${
|
|
955
|
+
`评审小组:${title},第 ${round} 轮,${name}(共 ${members.length} 位)`,
|
|
725
956
|
`你的意见文件:${findings}`,
|
|
726
|
-
`你的意见文件绝对路径:${path.join(ctx.repo, findings)}`,
|
|
957
|
+
`你的意见文件绝对路径:${path.join(target?.repo ?? ctx.repo, findings)}`,
|
|
958
|
+
...shared,
|
|
727
959
|
];
|
|
728
960
|
if (member.focus) contextLines.push(`你的关注重点:${member.focus}`);
|
|
961
|
+
const { section, ...profile } = member;
|
|
729
962
|
return runStep(ctx, "panel_review", round, null, {
|
|
730
963
|
key,
|
|
731
|
-
title: `${STEPS[step].number}-${
|
|
732
|
-
|
|
964
|
+
title: `${STEPS[step].number}-${title}${target ? `(${target.name})` : ""} 评审小组·${name}`,
|
|
965
|
+
target,
|
|
966
|
+
profile,
|
|
733
967
|
contextLines,
|
|
968
|
+
sections: section ? [section] : undefined,
|
|
734
969
|
criteriaFile: STEPS[step].promptFile,
|
|
735
970
|
additionsFor: step,
|
|
736
971
|
}).then(() => findings);
|
|
@@ -743,7 +978,7 @@ async function runPanel(ctx: PipelineContext, step: PanelStep, round: number): P
|
|
|
743
978
|
});
|
|
744
979
|
if (done.length === 0) {
|
|
745
980
|
const first = settled.find((s): s is PromiseRejectedResult => s.status === "rejected");
|
|
746
|
-
throw first?.reason instanceof StepFailure ? first.reason : new StepFailure(
|
|
981
|
+
throw first?.reason instanceof StepFailure ? first.reason : new StepFailure(title, "评审小组的成员都没有完成");
|
|
747
982
|
}
|
|
748
983
|
return done;
|
|
749
984
|
}
|
|
@@ -771,7 +1006,9 @@ function consolidationSection(step: PanelStep, findings: string[], total: number
|
|
|
771
1006
|
function proposalsApply(ctx: PipelineContext): boolean {
|
|
772
1007
|
const p = ctx.config.designProposals;
|
|
773
1008
|
// A continuation after a change only touches what changed; one designer is enough.
|
|
774
|
-
|
|
1009
|
+
// A defect is located, not designed; an assessment compares options itself.
|
|
1010
|
+
const type = ctx.state.type;
|
|
1011
|
+
return p.enabled && p.profiles.includes(ctx.state.profile) && !ctx.state.incremental && type !== "defect" && type !== "assessment";
|
|
775
1012
|
}
|
|
776
1013
|
|
|
777
1014
|
function proposalMembers(ctx: PipelineContext): ReviewPanelMember[] {
|
|
@@ -833,14 +1070,39 @@ function proposalsSection(files: string[], total: number): string {
|
|
|
833
1070
|
}
|
|
834
1071
|
|
|
835
1072
|
/** One review round; with a panel in the first round, members review first and this step consolidates. */
|
|
836
|
-
async function runReview(
|
|
837
|
-
|
|
838
|
-
|
|
1073
|
+
async function runReview(
|
|
1074
|
+
ctx: PipelineContext,
|
|
1075
|
+
step: PanelStep,
|
|
1076
|
+
round: number,
|
|
1077
|
+
maxRounds: number,
|
|
1078
|
+
contextLines: string[] = [],
|
|
1079
|
+
target?: StepTarget,
|
|
1080
|
+
): Promise<Record<string, unknown>> {
|
|
1081
|
+
const members = panelApplies(ctx, step, round) ? ctx.config.reviewPanel.members : axesApply(ctx, step, round) ? axisMembers(ctx) : null;
|
|
1082
|
+
if (!members) return runStep(ctx, step, round, maxRounds, { contextLines, target });
|
|
1083
|
+
const findings = await runPanel(ctx, step, round, contextLines, members, target);
|
|
839
1084
|
return runStep(ctx, step, round, maxRounds, {
|
|
840
|
-
|
|
1085
|
+
contextLines,
|
|
1086
|
+
target,
|
|
1087
|
+
sections: [consolidationSection(step, findings, members.length)],
|
|
841
1088
|
});
|
|
842
1089
|
}
|
|
843
1090
|
|
|
1091
|
+
/** Code review in two axes: the first round of a standard run, unless a review panel is configured. */
|
|
1092
|
+
function axesApply(ctx: PipelineContext, step: PanelStep, round: number): boolean {
|
|
1093
|
+
const axes = ctx.config.reviewAxes;
|
|
1094
|
+
return step === "code_review" && round === 1 && axes.enabled && axes.profiles.includes(ctx.state.profile);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** The spec reviewer and the standards reviewer, both with the configured reviewer agent. */
|
|
1098
|
+
function axisMembers(ctx: PipelineContext): PanelMember[] {
|
|
1099
|
+
const sources = { ...ctx.prompts, repo: ctx.repo };
|
|
1100
|
+
return [
|
|
1101
|
+
{ ...ctx.config.agents.reviewer, name: "规格评审", section: readToolPrompt(sources, "review-axis-spec.md") },
|
|
1102
|
+
{ ...ctx.config.agents.reviewer, name: "规范评审", section: readToolPrompt(sources, "review-axis-standards.md") },
|
|
1103
|
+
];
|
|
1104
|
+
}
|
|
1105
|
+
|
|
844
1106
|
/** Runs one step outside the pipeline's flow (the spec review started from the chat). */
|
|
845
1107
|
export async function runSingleStep(ctx: PipelineContext, id: StepId, extras: StepExtras = {}): Promise<Record<string, unknown>> {
|
|
846
1108
|
try {
|
|
@@ -850,9 +1112,312 @@ export async function runSingleStep(ctx: PipelineContext, id: StepId, extras: St
|
|
|
850
1112
|
}
|
|
851
1113
|
}
|
|
852
1114
|
|
|
1115
|
+
// ---------------------------------------------------------------------------
|
|
1116
|
+
// Quality gate (ADR 0023): the engine runs the project's checks itself after the
|
|
1117
|
+
// steps that change code. Before the first code change of an attempt it records a
|
|
1118
|
+
// baseline; afterwards only checks that passed then and fail now block, and the
|
|
1119
|
+
// step's own agent is asked to fix them, up to qualityGate.fixAttempts times.
|
|
1120
|
+
// ---------------------------------------------------------------------------
|
|
1121
|
+
|
|
1122
|
+
/** Where the code base and the quality gate of a repository are kept: the state, or its joint entry. */
|
|
1123
|
+
type GateHolder = Pick<JointRepo, "codeBase" | "gate">;
|
|
1124
|
+
|
|
1125
|
+
function holderOf(ctx: PipelineContext, target: StepTarget | undefined): GateHolder {
|
|
1126
|
+
return target ? target.joint : ctx.state;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** The quality-gate checks of this repository (configuration) or of a joint one (its own project file). */
|
|
1130
|
+
function checksOf(ctx: PipelineContext, target: StepTarget | undefined): PipelineConfig["checks"] {
|
|
1131
|
+
if (!target) return ctx.config.checks;
|
|
1132
|
+
ctx.jointChecksCache ??= new Map();
|
|
1133
|
+
let checks = ctx.jointChecksCache.get(target.slug);
|
|
1134
|
+
if (!checks) {
|
|
1135
|
+
checks = loadProjectChecks(target.repo, ctx.log);
|
|
1136
|
+
ctx.jointChecksCache.set(target.slug, checks);
|
|
1137
|
+
}
|
|
1138
|
+
return checks;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function implementedThisAttempt(ctx: PipelineContext, target?: StepTarget): boolean {
|
|
1142
|
+
const suffix = target ? `@${target.slug}` : "";
|
|
1143
|
+
return Object.keys(ctx.state.results).some((k) => {
|
|
1144
|
+
if (!k.endsWith(suffix) || (!target && k.includes("@"))) return false;
|
|
1145
|
+
const base = target ? k.slice(0, -suffix.length) : k;
|
|
1146
|
+
return base === "implement" || base.startsWith("implement-s");
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** Records the commit before the attempt's first change (before design); set once per attempt. */
|
|
1151
|
+
function recordCodeBase(ctx: PipelineContext, target?: StepTarget): void {
|
|
1152
|
+
const holder = holderOf(ctx, target);
|
|
1153
|
+
if (holder.codeBase?.attempt === ctx.state.attempt) return;
|
|
1154
|
+
// An older run that already implemented without recording it: unknown, rather than wrong.
|
|
1155
|
+
holder.codeBase = { attempt: ctx.state.attempt, commit: implementedThisAttempt(ctx, target) ? null : headCommit(target?.repo ?? ctx.repo) };
|
|
1156
|
+
save(ctx);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function codeBaseCommit(ctx: PipelineContext, target?: StepTarget): string | null {
|
|
1160
|
+
const holder = holderOf(ctx, target);
|
|
1161
|
+
return holder.codeBase?.attempt === ctx.state.attempt ? (holder.codeBase.commit ?? null) : null;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function gateState(ctx: PipelineContext, target?: StepTarget): NonNullable<PipelineState["gate"]> {
|
|
1165
|
+
const holder = holderOf(ctx, target);
|
|
1166
|
+
holder.gate ??= { baseline: null, runs: [] };
|
|
1167
|
+
return holder.gate;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function checksDir(ctx: PipelineContext, key: string, target?: StepTarget): string {
|
|
1171
|
+
return path.join(target?.repo ?? ctx.repo, ctx.featureDir, ".pipeline", "checks", key);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** Runs every check on the code as it is before the change; skipped when one exists for this attempt. */
|
|
1175
|
+
async function gateBaseline(ctx: PipelineContext, target?: StepTarget): Promise<void> {
|
|
1176
|
+
const checks = checksOf(ctx, target);
|
|
1177
|
+
if (checks.length === 0) return;
|
|
1178
|
+
const gate = gateState(ctx, target);
|
|
1179
|
+
if (gate.baseline?.attempt === ctx.state.attempt) return;
|
|
1180
|
+
const where = target ? `「${target.name}」` : "";
|
|
1181
|
+
if (implementedThisAttempt(ctx, target)) {
|
|
1182
|
+
ctx.log(`质量门${where}:这次尝试已经开始写代码,没有改动前的基线,检查失败一律按新问题处理`);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const dir = checksDir(ctx, "baseline", target);
|
|
1186
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1187
|
+
const results: Record<string, CheckOutcome> = {};
|
|
1188
|
+
for (const check of checks) {
|
|
1189
|
+
const run = await runCheck(check, target?.repo ?? ctx.repo, ctx.config.qualityGate.timeoutMinutes);
|
|
1190
|
+
fs.writeFileSync(path.join(dir, logName(check.name)), run.output);
|
|
1191
|
+
results[check.name] = run.outcome;
|
|
1192
|
+
}
|
|
1193
|
+
gate.baseline = { attempt: ctx.state.attempt, commit: codeBaseCommit(ctx, target), at: new Date().toISOString(), results };
|
|
1194
|
+
save(ctx);
|
|
1195
|
+
const failing = Object.entries(results).filter(([, r]) => r === "fail").map(([n]) => n);
|
|
1196
|
+
ctx.log(`质量门基线${where}(改动前):${checks.length} 项检查,${failing.length > 0 ? `原本就失败的:${failing.join("、")}` : "全部通过"}`);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Runs the checks that apply to the change after a step. New failures go back to the
|
|
1201
|
+
* step's agent through `fix` (a follow-up turn) until they pass or the attempts run out;
|
|
1202
|
+
* either way the run goes on, and the outcome is recorded for the review and the report.
|
|
1203
|
+
*/
|
|
1204
|
+
async function qualityGate(ctx: PipelineContext, key: string, target: StepTarget | undefined, fix: (text: string) => Promise<void>): Promise<void> {
|
|
1205
|
+
const checks = checksOf(ctx, target);
|
|
1206
|
+
if (checks.length === 0) return;
|
|
1207
|
+
const repo = target?.repo ?? ctx.repo;
|
|
1208
|
+
const gate = gateState(ctx, target);
|
|
1209
|
+
const baseline = gate.baseline?.attempt === ctx.state.attempt ? gate.baseline : null;
|
|
1210
|
+
const max = ctx.config.qualityGate.fixAttempts;
|
|
1211
|
+
const dir = checksDir(ctx, key, target);
|
|
1212
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1213
|
+
for (let fixRounds = 0; ; fixRounds += 1) {
|
|
1214
|
+
const files = changedFiles(repo, codeBaseCommit(ctx, target));
|
|
1215
|
+
const runs: GateRun["checks"] = [];
|
|
1216
|
+
const failures: Parameters<typeof gateFollowUp>[0]["failures"] = [];
|
|
1217
|
+
for (const check of checks) {
|
|
1218
|
+
const failedBefore = baseline?.results[check.name] === "fail";
|
|
1219
|
+
if (!checkApplies(check, files)) {
|
|
1220
|
+
runs.push({ name: check.name, result: "skipped", failedBefore });
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
const run = await runCheck(check, repo, ctx.config.qualityGate.timeoutMinutes);
|
|
1224
|
+
const logFile = path.join(dir, logName(check.name));
|
|
1225
|
+
fs.writeFileSync(logFile, run.output);
|
|
1226
|
+
runs.push({ name: check.name, result: run.outcome, failedBefore });
|
|
1227
|
+
if (run.outcome === "fail" && !failedBefore) {
|
|
1228
|
+
failures.push({ name: check.name, command: check.command, cwd: check.cwd ?? null, logFile, tail: tail(run.output) });
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (failures.length === 0 || fixRounds >= max) {
|
|
1232
|
+
const record: GateRun = {
|
|
1233
|
+
key,
|
|
1234
|
+
attempt: ctx.state.attempt,
|
|
1235
|
+
at: new Date().toISOString(),
|
|
1236
|
+
checks: runs,
|
|
1237
|
+
newFailures: failures.map((f) => f.name),
|
|
1238
|
+
fixRounds,
|
|
1239
|
+
passed: failures.length === 0,
|
|
1240
|
+
};
|
|
1241
|
+
gate.runs.push(record);
|
|
1242
|
+
save(ctx);
|
|
1243
|
+
ctx.log(`质量门(${key} 之后):${describeGateRun(record)}`);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
ctx.log(`质量门(${key} 之后):${failures.map((f) => f.name).join("、")} 改动前通过、现在失败,第 ${fixRounds + 1} 次要求修复`);
|
|
1247
|
+
const preexisting = runs.filter((r) => r.result === "fail" && r.failedBefore).map((r) => r.name);
|
|
1248
|
+
await fix(gateFollowUp({ failures, round: fixRounds + 1, max, preexisting }));
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function describeGateRun(run: GateRun): string {
|
|
1253
|
+
const ran = run.checks.filter((c) => c.result !== "skipped");
|
|
1254
|
+
const skipped = run.checks.length - ran.length;
|
|
1255
|
+
const old = ran.filter((c) => c.result === "fail" && c.failedBefore).map((c) => c.name);
|
|
1256
|
+
const parts = [
|
|
1257
|
+
run.passed
|
|
1258
|
+
? `通过(运行 ${ran.length} 项${run.fixRounds > 0 ? `,要求修复 ${run.fixRounds} 次后通过` : ""})`
|
|
1259
|
+
: `${run.newFailures.join("、")} 改动前通过、现在失败,要求修复 ${run.fixRounds} 次后仍失败`,
|
|
1260
|
+
];
|
|
1261
|
+
if (old.length > 0) parts.push(`${old.join("、")} 改动前就失败`);
|
|
1262
|
+
if (skipped > 0) parts.push(`${skipped} 项与改动的模块无关,没有运行`);
|
|
1263
|
+
return parts.join(";");
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** What the gate found in this attempt, for the code review and the report. */
|
|
1267
|
+
function gateLines(ctx: PipelineContext, target?: StepTarget): string[] {
|
|
1268
|
+
if (checksOf(ctx, target).length === 0) return [];
|
|
1269
|
+
const gate = holderOf(ctx, target).gate;
|
|
1270
|
+
const where = target ? `「${target.name}」` : "";
|
|
1271
|
+
const lines: string[] = [];
|
|
1272
|
+
const baseline = gate?.baseline?.attempt === ctx.state.attempt ? gate.baseline : null;
|
|
1273
|
+
if (baseline) {
|
|
1274
|
+
const failing = Object.entries(baseline.results).filter(([, r]) => r === "fail").map(([n]) => n);
|
|
1275
|
+
lines.push(`质量门基线${where}(改动前,流水线运行):${Object.keys(baseline.results).length} 项检查,${failing.length > 0 ? `原本就失败:${failing.join("、")}` : "全部通过"}`);
|
|
1276
|
+
}
|
|
1277
|
+
const latest = new Map<string, GateRun>();
|
|
1278
|
+
for (const run of gate?.runs ?? []) if (run.attempt === ctx.state.attempt) latest.set(run.key, run);
|
|
1279
|
+
for (const run of latest.values()) lines.push(`质量门(${run.key} 之后,流水线运行):${describeGateRun(run)}`);
|
|
1280
|
+
if (lines.length > 0) lines.push(`质量门${where}的完整输出:${path.join(target?.repo ?? "", ctx.featureDir, ".pipeline", "checks").split(path.sep).join("/")}/`);
|
|
1281
|
+
return lines;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/** The latest plan of this attempt: the design's slices and files, as revised. */
|
|
1285
|
+
function currentPlan(ctx: PipelineContext): { slices: string[]; files: string[]; uiChange: boolean | null } {
|
|
1286
|
+
const plan = { slices: [] as string[], files: [] as string[], uiChange: null as boolean | null };
|
|
1287
|
+
const take = (r: Record<string, unknown> | undefined) => {
|
|
1288
|
+
if (!r) return;
|
|
1289
|
+
if (typeof r.ui_change === "boolean") plan.uiChange = r.ui_change;
|
|
1290
|
+
if (Array.isArray(r.slices)) plan.slices = (r.slices as string[]).map((s) => s.trim()).filter(Boolean);
|
|
1291
|
+
if (Array.isArray(r.files)) plan.files = (r.files as string[]).map((s) => s.trim()).filter(Boolean);
|
|
1292
|
+
};
|
|
1293
|
+
take(ctx.state.results.design);
|
|
1294
|
+
const revisions = Object.keys(ctx.state.results)
|
|
1295
|
+
.filter((k) => /^design_revise-r\d+$/.test(k))
|
|
1296
|
+
.sort((a, b) => Number(a.slice(15)) - Number(b.slice(15)));
|
|
1297
|
+
for (const k of revisions) take(ctx.state.results[k]);
|
|
1298
|
+
return plan;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** The slices to implement one by one, or [] for a single implementation step. */
|
|
1302
|
+
function implementationSlices(ctx: PipelineContext): string[] {
|
|
1303
|
+
// A joint requirement runs one implementation per repository instead.
|
|
1304
|
+
if (jointTargets(ctx).length > 0) return [];
|
|
1305
|
+
const { slices } = currentPlan(ctx);
|
|
1306
|
+
if (slices.length < 2 || !ctx.config.slices.profiles.includes(ctx.state.profile)) return [];
|
|
1307
|
+
const max = ctx.config.slices.max;
|
|
1308
|
+
if (max < 2) return [];
|
|
1309
|
+
if (slices.length <= max) return slices;
|
|
1310
|
+
ctx.log(`方案分了 ${slices.length} 个切片,超过上限 ${max},最后几片合并为一片`);
|
|
1311
|
+
return [...slices.slice(0, max - 1), slices.slice(max - 1).join(";")];
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function sliceSection(index: number, total: number, title: string): string {
|
|
1315
|
+
return [
|
|
1316
|
+
"# 按切片实现",
|
|
1317
|
+
"",
|
|
1318
|
+
`方案把实施计划分成了 ${total} 个纵向切片(见 \`design.md\` 的实施计划)。每个切片由一个新的 agent 完成,你负责第 ${index} 片:**${title}**。`,
|
|
1319
|
+
"",
|
|
1320
|
+
index > 1
|
|
1321
|
+
? `- 前面 ${index - 1} 片已经完成并提交:先读 \`implementation.md\` 中它们的记录,用 \`git log\` 看提交,在它们的基础上继续。`
|
|
1322
|
+
: "- 你是第一片:按上文的\"开始前\"记录基线提交,`implementation.md` 按完整结构写。",
|
|
1323
|
+
index < total ? "- 后面的切片由之后的 agent 完成,不要提前做它们的任务。" : "- 这是最后一片:完成后运行完整的测试、代码检查、类型检查和构建。",
|
|
1324
|
+
index > 1
|
|
1325
|
+
? `- \`implementation.md\`:不改前面切片的记录,在末尾追加"## 切片 ${index}:${title}"一节,写本片的任务完成情况、改动的文件、测试结果、与方案的偏离。`
|
|
1326
|
+
: `- \`implementation.md\` 的任务完成情况表只填本片的任务,并注明"切片 1";后面的切片会追加各自的一节。`,
|
|
1327
|
+
"- 本片做完的标志:本片对应的验收标准有测试或验证覆盖并通过,前面切片的测试仍然通过。提交信息中注明切片编号。",
|
|
1328
|
+
"- 结果文件:本片的任务全部完成写 `done`,否则写 `partial` 并在本片的记录里说明。",
|
|
1329
|
+
].join("\n");
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** Files changed outside the feature directory since the attempt's code base. */
|
|
1333
|
+
function changedOutsideFeature(ctx: PipelineContext): string[] | null {
|
|
1334
|
+
const files = changedFiles(ctx.repo, codeBaseCommit(ctx));
|
|
1335
|
+
return files === null ? null : files.filter((f) => !f.startsWith(`${ctx.featureDir}/`));
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** The scope check: changed files the plan did not list, for the code review and the report. */
|
|
1339
|
+
function scopeLines(ctx: PipelineContext): string[] {
|
|
1340
|
+
const { files: planned } = currentPlan(ctx);
|
|
1341
|
+
if (planned.length === 0) return [];
|
|
1342
|
+
const changed = changedOutsideFeature(ctx);
|
|
1343
|
+
if (changed === null) return [];
|
|
1344
|
+
const extra = changed.filter((f) => !planned.some((p) => matchesPath(f, p)));
|
|
1345
|
+
if (extra.length === 0) return [`范围检查(流水线比对):改动的 ${changed.length} 个文件都在方案列出的范围内`];
|
|
1346
|
+
const shown = extra.slice(0, 30).join("、");
|
|
1347
|
+
return [`范围检查(流水线比对):共改动 ${changed.length} 个文件,其中 ${extra.length} 个不在方案列出的范围内:${shown}${extra.length > 30 ? " 等" : ""}`];
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/** The confirmation point setting that applies to this requirement, or null. */
|
|
1351
|
+
function approvalMode(ctx: PipelineContext): "ui" | "always" | null {
|
|
1352
|
+
const a = ctx.config.designApproval;
|
|
1353
|
+
if (a.mode === "off" || !a.profiles.includes(ctx.state.profile) || ctx.state.type === "assessment") return null;
|
|
1354
|
+
return a.mode;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Design confirmation point: after the design review, the requester sees what they will get
|
|
1359
|
+
* (preview.md, a prototype for UI changes) and says "yes" before any code is written. The run
|
|
1360
|
+
* stops here once per attempt; --approve continues, a changed spec starts over from design.
|
|
1361
|
+
*/
|
|
1362
|
+
function confirmDesign(ctx: PipelineContext): void {
|
|
1363
|
+
const mode = approvalMode(ctx);
|
|
1364
|
+
if (!mode) return;
|
|
1365
|
+
const uiChange = currentPlan(ctx).uiChange;
|
|
1366
|
+
if (mode === "ui" && uiChange !== true) return;
|
|
1367
|
+
const approval = ctx.state.approval;
|
|
1368
|
+
if (approval?.attempt === ctx.state.attempt && approval.status === "approved") return;
|
|
1369
|
+
ctx.state.approval = { attempt: ctx.state.attempt, status: "waiting", at: new Date().toISOString() };
|
|
1370
|
+
save(ctx);
|
|
1371
|
+
throw new ApprovalNeeded();
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** Acceptance tests committed before the code (implement results): were they changed afterwards? */
|
|
1375
|
+
function acceptanceLines(ctx: PipelineContext, target?: StepTarget): string[] {
|
|
1376
|
+
const lines: string[] = [];
|
|
1377
|
+
const suffix = target ? `@${target.slug}` : "";
|
|
1378
|
+
const keys = Object.keys(ctx.state.results)
|
|
1379
|
+
.filter((k) => k.endsWith(suffix) && (target || !k.includes("@")))
|
|
1380
|
+
.filter((k) => /^implement(-s\d+)?$/.test(target ? k.slice(0, -suffix.length) : k))
|
|
1381
|
+
.sort((a, b) => Number(a.replace(/@.*/, "").slice(11) || 0) - Number(b.replace(/@.*/, "").slice(11) || 0));
|
|
1382
|
+
for (const key of keys) {
|
|
1383
|
+
const r = ctx.state.results[key];
|
|
1384
|
+
const files = Array.isArray(r.acceptance_tests) ? (r.acceptance_tests as string[]).map((f) => f.trim()).filter(Boolean) : [];
|
|
1385
|
+
const commit = typeof r.acceptance_tests_commit === "string" ? r.acceptance_tests_commit.trim() : "";
|
|
1386
|
+
if (files.length === 0 || !commit) continue;
|
|
1387
|
+
const changed = changedSince(target?.repo ?? ctx.repo, commit, files);
|
|
1388
|
+
const where = key === "implement" ? "" : `(${key})`;
|
|
1389
|
+
if (changed === null) lines.push(`验收测试锁定(流水线比对)${where}:记录的提交 ${commit} 不在仓库中,没能核对 ${files.length} 个验收测试是否被改过`);
|
|
1390
|
+
else if (changed.length === 0) lines.push(`验收测试锁定(流水线比对)${where}:${files.length} 个验收测试文件自提交 ${commit} 之后没有被修改`);
|
|
1391
|
+
else lines.push(`验收测试锁定(流水线比对)${where}:以下验收测试在提交 ${commit} 之后被修改:${changed.join("、")}`);
|
|
1392
|
+
}
|
|
1393
|
+
return lines;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** Assessment: read-only research → assessment.md → independent review ⇄ revise. */
|
|
1397
|
+
async function assessmentPhase(ctx: PipelineContext): Promise<void> {
|
|
1398
|
+
const rounds = ctx.config.profiles[ctx.state.profile].rounds;
|
|
1399
|
+
recordCodeBase(ctx);
|
|
1400
|
+
await runStep(ctx, "design");
|
|
1401
|
+
for (let r = 1; r <= rounds.designReview; r++) {
|
|
1402
|
+
const review = await runReview(ctx, "design_review", r, rounds.designReview);
|
|
1403
|
+
if (review.verdict === "approved") break;
|
|
1404
|
+
if (review.verdict === "blocked") throw new BusinessDecisionNeeded(review.questions as string[]);
|
|
1405
|
+
await runStep(ctx, "design_revise", r, rounds.designReview);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
853
1409
|
/** Develop phase: design → design review ⇄ revise → implement → code review ⇄ fix → handoff. Returns the design result. */
|
|
854
1410
|
async function developPhase(ctx: PipelineContext): Promise<Record<string, unknown>> {
|
|
855
1411
|
const rounds = ctx.config.profiles[ctx.state.profile].rounds;
|
|
1412
|
+
// Before anything is committed: a defect's reproduction test, committed while locating
|
|
1413
|
+
// the cause, must not make the whole test check count as failing before the change.
|
|
1414
|
+
const joint = jointTargets(ctx);
|
|
1415
|
+
recordCodeBase(ctx);
|
|
1416
|
+
await gateBaseline(ctx);
|
|
1417
|
+
for (const t of joint) {
|
|
1418
|
+
recordCodeBase(ctx, t);
|
|
1419
|
+
await gateBaseline(ctx, t);
|
|
1420
|
+
}
|
|
856
1421
|
|
|
857
1422
|
let designExtras: StepExtras = {};
|
|
858
1423
|
if (proposalsApply(ctx) && !ctx.state.results.design) {
|
|
@@ -861,6 +1426,10 @@ async function developPhase(ctx: PipelineContext): Promise<Record<string, unknow
|
|
|
861
1426
|
else ctx.log("方案提案都没有完成,按原来的方式由一个 agent 做方案设计");
|
|
862
1427
|
}
|
|
863
1428
|
const design = await runStep(ctx, "design", null, null, designExtras);
|
|
1429
|
+
if (ctx.state.type === "defect" && design.reproduced === false) {
|
|
1430
|
+
// Fixing what could not be reproduced is guessing: ask for what is missing.
|
|
1431
|
+
throw new BusinessDecisionNeeded(design.questions as string[], "reproduce");
|
|
1432
|
+
}
|
|
864
1433
|
|
|
865
1434
|
let designRounds = 0;
|
|
866
1435
|
for (let r = 1; r <= rounds.designReview; r++) {
|
|
@@ -872,23 +1441,74 @@ async function developPhase(ctx: PipelineContext): Promise<Record<string, unknow
|
|
|
872
1441
|
// the implementer reads both the review and the revision record.
|
|
873
1442
|
await runStep(ctx, "design_revise", r, rounds.designReview);
|
|
874
1443
|
}
|
|
1444
|
+
confirmDesign(ctx);
|
|
1445
|
+
const slices = implementationSlices(ctx);
|
|
875
1446
|
await milestone(
|
|
876
1447
|
ctx,
|
|
877
1448
|
"design-settled",
|
|
878
|
-
`技术方案已经确定(审核 ${designRounds}
|
|
1449
|
+
`技术方案已经确定(审核 ${designRounds} 轮),开始写代码${slices.length > 0 ? `,分 ${slices.length} 个切片逐个完成` : ""}。写代码和代码检查通常是整个过程中最耗时的部分。`,
|
|
879
1450
|
);
|
|
880
1451
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
if (review.verdict === "pass") break;
|
|
886
|
-
await runStep(ctx, "review_fix", r, rounds.codeReview);
|
|
1452
|
+
if (joint.length > 0) {
|
|
1453
|
+
// The other repositories get the settled design and the interface contract next to their code.
|
|
1454
|
+
syncJointDocs(activeJoint(ctx.state, ctx.jointNames ?? []), ctx.repo, ctx.featureDir, JOINT_DOCS, specTitle(ctx), ctx.log);
|
|
1455
|
+
save(ctx);
|
|
887
1456
|
}
|
|
888
|
-
|
|
1457
|
+
|
|
1458
|
+
const implementHere = async () => {
|
|
1459
|
+
if (slices.length === 0) {
|
|
1460
|
+
await runStep(ctx, "implement", null, null, { gate: true });
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
for (const [i, title] of slices.entries()) {
|
|
1464
|
+
await runStep(ctx, "implement", null, null, {
|
|
1465
|
+
key: `implement-s${i + 1}`,
|
|
1466
|
+
title: `5-代码实现 第${i + 1}/${slices.length}片`,
|
|
1467
|
+
contextLines: [`切片:第 ${i + 1} 片,共 ${slices.length} 片(${title})`],
|
|
1468
|
+
sections: [sliceSection(i + 1, slices.length, title)],
|
|
1469
|
+
gate: true,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
// Joint requirement: every repository is implemented at the same time, against the contract.
|
|
1474
|
+
await allOrFirstError([implementHere(), ...joint.map((t) => runStep(ctx, "implement", null, null, { gate: true, target: t }))]);
|
|
1475
|
+
|
|
1476
|
+
const reviewLoop = async (target?: StepTarget) => {
|
|
1477
|
+
for (let r = 1; r <= rounds.codeReview; r++) {
|
|
1478
|
+
const lines = [...gateLines(ctx, target), ...(target ? [] : scopeLines(ctx)), ...acceptanceLines(ctx, target)];
|
|
1479
|
+
const review = await runReview(ctx, "code_review", r, rounds.codeReview, lines, target);
|
|
1480
|
+
if (review.verdict === "pass") break;
|
|
1481
|
+
await runStep(ctx, "review_fix", r, rounds.codeReview, { gate: true, target });
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
await allOrFirstError([reviewLoop(), ...joint.map((t) => reviewLoop(t))]);
|
|
1485
|
+
await runStep(ctx, "handoff", null, null, { contextLines: jointLines(ctx) });
|
|
889
1486
|
return design;
|
|
890
1487
|
}
|
|
891
1488
|
|
|
1489
|
+
/** Files of the feature directory a joint repository gets a copy of, before its implementation. */
|
|
1490
|
+
const JOINT_DOCS = ["spec.md", "alignment.md", "design.md", "contract.md", "preview.md", "prototype"];
|
|
1491
|
+
|
|
1492
|
+
/**
|
|
1493
|
+
* Waits for all of them, so no agent keeps working unseen after one failed; then fails with
|
|
1494
|
+
* the first failure, if any.
|
|
1495
|
+
*/
|
|
1496
|
+
async function allOrFirstError(runs: Array<Promise<unknown>>): Promise<void> {
|
|
1497
|
+
const settled = await Promise.allSettled(runs);
|
|
1498
|
+
const failed = settled.find((s): s is PromiseRejectedResult => s.status === "rejected");
|
|
1499
|
+
if (failed) throw failed.reason;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** Each joint repository's worktree, branch and what the gate found there, for the handoff and the report. */
|
|
1503
|
+
function jointLines(ctx: PipelineContext): string[] {
|
|
1504
|
+
const lines: string[] = [];
|
|
1505
|
+
for (const t of jointTargets(ctx)) {
|
|
1506
|
+
lines.push(`联合需求的仓库「${t.name}」:工作区 ${t.repo},分支 ${t.branch},改动前的提交 ${codeBaseCommit(ctx, t)?.slice(0, 12) ?? "未知"};实现、评审记录在那里的 ${ctx.featureDir}/`);
|
|
1507
|
+
lines.push(...gateLines(ctx, t), ...acceptanceLines(ctx, t));
|
|
1508
|
+
}
|
|
1509
|
+
return lines;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
892
1512
|
/**
|
|
893
1513
|
* Verify phase: service start → verification → defects as configured.
|
|
894
1514
|
* Returns true when the defects go back to development (defects "handback").
|
|
@@ -909,7 +1529,9 @@ async function verifyPhase(ctx: PipelineContext, design: Record<string, unknown>
|
|
|
909
1529
|
ctx.log("方案设计判断项目没有常驻服务,跳过服务启动验证。");
|
|
910
1530
|
}
|
|
911
1531
|
|
|
912
|
-
const
|
|
1532
|
+
const joint = jointTargets(ctx);
|
|
1533
|
+
const jointContext = joint.length > 0 ? { contextLines: jointLines(ctx) } : {};
|
|
1534
|
+
const verification = await runStep(ctx, "verify", null, null, jointContext);
|
|
913
1535
|
if (verification.result !== "defects_found") return false;
|
|
914
1536
|
if (phases.defects === "report") {
|
|
915
1537
|
ctx.log("验证发现了问题,按设置只记录、不修复,直接写报告。");
|
|
@@ -922,20 +1544,32 @@ async function verifyPhase(ctx: PipelineContext, design: Record<string, unknown>
|
|
|
922
1544
|
return false;
|
|
923
1545
|
}
|
|
924
1546
|
await milestone(ctx, "fixing-defects", `验证中发现了一些问题,正在修复并重新验证(最多 ${rounds.defectFix} 轮)。`);
|
|
1547
|
+
let found = verification;
|
|
925
1548
|
for (let r = 1; r <= rounds.defectFix; r++) {
|
|
926
|
-
|
|
927
|
-
const
|
|
1549
|
+
// Joint requirement: each defect is fixed in the repository it belongs to.
|
|
1550
|
+
const where = defectTargets(ctx, found);
|
|
1551
|
+
await allOrFirstError(where.map((t) => runStep(ctx, "defect_fix", r, rounds.defectFix, { gate: true, target: t ?? undefined, ...(t ? {} : jointContext) })));
|
|
1552
|
+
const recheck = await runStep(ctx, "reverify", r, rounds.defectFix, jointContext);
|
|
928
1553
|
if (recheck.result === "pass") break;
|
|
1554
|
+
found = recheck;
|
|
929
1555
|
}
|
|
930
1556
|
return false;
|
|
931
1557
|
}
|
|
932
1558
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1559
|
+
/**
|
|
1560
|
+
* Where the defects of a verification (or re-verification) are: null stands for this
|
|
1561
|
+
* repository. Results name the repositories in `defect_repos` ("本仓库" or a joint name);
|
|
1562
|
+
* without it, every repository of a joint requirement is asked to look.
|
|
1563
|
+
*/
|
|
1564
|
+
function defectTargets(ctx: PipelineContext, result: Record<string, unknown>): Array<StepTarget | null> {
|
|
1565
|
+
const joint = jointTargets(ctx);
|
|
1566
|
+
if (joint.length === 0) return [null];
|
|
1567
|
+
const named = Array.isArray(result.defect_repos) ? (result.defect_repos as string[]).map((n) => n.trim()) : [];
|
|
1568
|
+
if (named.length === 0) return [null, ...joint];
|
|
1569
|
+
const here = named.some((n) => n === "本仓库" || n === "主仓库");
|
|
1570
|
+
const there = joint.filter((t) => named.includes(t.name));
|
|
1571
|
+
const out: Array<StepTarget | null> = [...(here ? [null] : []), ...there];
|
|
1572
|
+
return out.length > 0 ? out : [null, ...joint];
|
|
939
1573
|
}
|
|
940
1574
|
|
|
941
1575
|
/**
|
|
@@ -967,6 +1601,8 @@ async function handBack(ctx: PipelineContext): Promise<void> {
|
|
|
967
1601
|
ctx.state.results = {};
|
|
968
1602
|
ctx.state.milestones = [];
|
|
969
1603
|
ctx.state.phase = "develop";
|
|
1604
|
+
// Fixing defects does not change what the requester confirmed: no second confirmation.
|
|
1605
|
+
if (ctx.state.approval?.status === "approved") ctx.state.approval = { ...ctx.state.approval, attempt: ctx.state.attempt };
|
|
970
1606
|
save(ctx);
|
|
971
1607
|
ctx.log(`测试验证发现缺陷,第 ${n} 次退回研发阶段(第 ${ctx.state.attempt} 次尝试)`);
|
|
972
1608
|
const text = `测试验证发现了缺陷,按设置退回研发阶段修复(第 ${n} 次):从方案设计开始只处理这些缺陷,修完经过代码评审后${runPhases(ctx.state).mode === "gated" ? "重新提测" : "再次验证"}。`;
|
|
@@ -987,8 +1623,56 @@ function usageLines(ctx: PipelineContext): string[] {
|
|
|
987
1623
|
return lines;
|
|
988
1624
|
}
|
|
989
1625
|
|
|
1626
|
+
/** Joint requirement: the other repositories' worktrees exist and have the current spec. */
|
|
1627
|
+
async function setUpJoint(ctx: PipelineContext): Promise<void> {
|
|
1628
|
+
const names = ctx.jointNames ?? [];
|
|
1629
|
+
if (names.length === 0) return;
|
|
1630
|
+
try {
|
|
1631
|
+
const entries = await ensureJointRepos({
|
|
1632
|
+
runtime: ctx.runtime,
|
|
1633
|
+
state: ctx.state,
|
|
1634
|
+
featureDir: ctx.featureDir,
|
|
1635
|
+
names,
|
|
1636
|
+
related: ctx.config.relatedRepos,
|
|
1637
|
+
projectDir: ctx.state.worktree?.sourceRepo ?? ctx.repo,
|
|
1638
|
+
title: specTitle(ctx),
|
|
1639
|
+
log: ctx.log,
|
|
1640
|
+
});
|
|
1641
|
+
save(ctx);
|
|
1642
|
+
syncJointDocs(entries, ctx.repo, ctx.featureDir, ["spec.md", "alignment.md"], specTitle(ctx), ctx.log);
|
|
1643
|
+
save(ctx);
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
save(ctx);
|
|
1646
|
+
throw new StepFailure("联合开发准备", (error as Error).message);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/** What the report is told about this attempt: usage, the quality gate, the scope check. */
|
|
1651
|
+
function reportLines(ctx: PipelineContext): string[] {
|
|
1652
|
+
const lines = [...usageLines(ctx), ...gateLines(ctx), ...scopeLines(ctx), ...acceptanceLines(ctx), ...jointLines(ctx)];
|
|
1653
|
+
if (ctx.config.checks.length === 0 && ctx.state.type !== "assessment") {
|
|
1654
|
+
lines.push("质量门:项目没有配置检查(checks),流水线没有自己运行检查,以各步骤记录的检查结果为准");
|
|
1655
|
+
}
|
|
1656
|
+
return lines;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
990
1659
|
/** Steps 3–11. Loop rounds and optional steps come from the run's profile; the gate from the execution mode. */
|
|
991
1660
|
async function flow(ctx: PipelineContext): Promise<Record<string, unknown>> {
|
|
1661
|
+
await setUpJoint(ctx);
|
|
1662
|
+
if (ctx.state.type === "assessment") {
|
|
1663
|
+
// Read-only: no code, so no implementation, review of code, handoff or verification.
|
|
1664
|
+
await assessmentPhase(ctx);
|
|
1665
|
+
const changed = changedOutsideFeature(ctx) ?? [];
|
|
1666
|
+
if (changed.length > 0) ctx.log(`评估期间功能目录之外有文件被改动:${changed.join("、")}`);
|
|
1667
|
+
return runStep(ctx, "report", null, null, {
|
|
1668
|
+
contextLines: [
|
|
1669
|
+
...usageLines(ctx),
|
|
1670
|
+
...(changed.length > 0
|
|
1671
|
+
? [`评估期间改动了功能目录之外的文件(评估不应改动代码,请在报告中如实说明):${changed.slice(0, 30).join("、")}`]
|
|
1672
|
+
: []),
|
|
1673
|
+
],
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
992
1676
|
for (;;) {
|
|
993
1677
|
const design = await developPhase(ctx);
|
|
994
1678
|
const phases = runPhases(ctx.state);
|
|
@@ -996,7 +1680,7 @@ async function flow(ctx: PipelineContext): Promise<Record<string, unknown>> {
|
|
|
996
1680
|
if (phases.mode === "develop-only") {
|
|
997
1681
|
await stopManagedServices(ctx);
|
|
998
1682
|
return runStep(ctx, "report", null, null, {
|
|
999
|
-
contextLines: [...
|
|
1683
|
+
contextLines: [...reportLines(ctx), "只做研发:本次不做测试验证,完成报告按本步骤说明中“只做研发时”一节写成研发报告"],
|
|
1000
1684
|
});
|
|
1001
1685
|
}
|
|
1002
1686
|
if (phases.mode === "gated") {
|
|
@@ -1014,7 +1698,7 @@ async function flow(ctx: PipelineContext): Promise<Record<string, unknown>> {
|
|
|
1014
1698
|
await handBack(ctx);
|
|
1015
1699
|
}
|
|
1016
1700
|
await stopManagedServices(ctx);
|
|
1017
|
-
return runStep(ctx, "report", null, null, { contextLines:
|
|
1701
|
+
return runStep(ctx, "report", null, null, { contextLines: reportLines(ctx) });
|
|
1018
1702
|
}
|
|
1019
1703
|
|
|
1020
1704
|
export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome> {
|
|
@@ -1060,7 +1744,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1060
1744
|
save(ctx);
|
|
1061
1745
|
await stopManagedServices(ctx);
|
|
1062
1746
|
const verifyAt = await scheduleVerifyStart(ctx);
|
|
1063
|
-
const reminderHours = verifyAt ? null : await scheduleReminder(ctx,
|
|
1747
|
+
const reminderHours = verifyAt ? null : await scheduleReminder(ctx, "phase");
|
|
1064
1748
|
await notifyOrigin(
|
|
1065
1749
|
ctx,
|
|
1066
1750
|
phaseDoneMessage({
|
|
@@ -1082,16 +1766,46 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1082
1766
|
});
|
|
1083
1767
|
return "phase_done";
|
|
1084
1768
|
}
|
|
1769
|
+
if (error instanceof ApprovalNeeded) {
|
|
1770
|
+
ctx.state.status = "waiting_for_approval";
|
|
1771
|
+
ctx.state.stopReason = null;
|
|
1772
|
+
save(ctx);
|
|
1773
|
+
const autoContinueAt = await scheduleApprovalContinue(ctx);
|
|
1774
|
+
const reminderHours = await scheduleReminder(ctx, "approval");
|
|
1775
|
+
const prototype = path.join(ctx.repo, ctx.featureDir, "prototype");
|
|
1776
|
+
await notifyOrigin(
|
|
1777
|
+
ctx,
|
|
1778
|
+
approvalMessage({
|
|
1779
|
+
featureDir: shownFeatureDir(ctx),
|
|
1780
|
+
previewPath: `${shownFeatureDir(ctx)}/preview.md`,
|
|
1781
|
+
prototypePath: fs.existsSync(prototype) ? `${shownFeatureDir(ctx)}/prototype/` : null,
|
|
1782
|
+
approveCommand: approveCommandOf(ctx),
|
|
1783
|
+
resumeCommand: ctx.resumeCommand,
|
|
1784
|
+
reminderHours,
|
|
1785
|
+
autoContinueAt,
|
|
1786
|
+
}),
|
|
1787
|
+
);
|
|
1788
|
+
await notifyExternal(ctx, "approval", `方案已通过审核,请需求方确认"做完后会看到什么"再开始写代码:${ctx.featureDir}/preview.md`, {
|
|
1789
|
+
previewPath: `${ctx.featureDir}/preview.md`,
|
|
1790
|
+
approveCommand: approveCommandOf(ctx),
|
|
1791
|
+
resumeCommand: ctx.resumeCommand,
|
|
1792
|
+
});
|
|
1793
|
+
return "waiting_for_approval";
|
|
1794
|
+
}
|
|
1085
1795
|
if (error instanceof BusinessDecisionNeeded) {
|
|
1796
|
+
const reproduce = error.source === "reproduce";
|
|
1086
1797
|
ctx.state.status = "waiting_for_business";
|
|
1087
|
-
ctx.state.stopReason =
|
|
1798
|
+
ctx.state.stopReason = `${reproduce ? "没能复现缺陷,需要需求方补充信息" : "方案审核需要需求方决定"}:${error.questions.join(";")}`;
|
|
1088
1799
|
save(ctx);
|
|
1089
1800
|
const reminderHours = await scheduleReminder(ctx, error.questions);
|
|
1090
1801
|
await notifyOrigin(
|
|
1091
1802
|
ctx,
|
|
1092
|
-
blockedMessage({ featureDir: shownFeatureDir(ctx), questions: error.questions, resumeCommand: ctx.resumeCommand, reminderHours }),
|
|
1803
|
+
blockedMessage({ featureDir: shownFeatureDir(ctx), questions: error.questions, resumeCommand: ctx.resumeCommand, reminderHours, source: error.source }),
|
|
1093
1804
|
);
|
|
1094
|
-
|
|
1805
|
+
const external = reproduce
|
|
1806
|
+
? `没能复现这个缺陷,需要需求方补充 ${error.questions.length} 项信息,流水线已暂停,请到发起的对话中回答。`
|
|
1807
|
+
: `方案审核发现 ${error.questions.length} 个需要需求方决定的问题,流水线已暂停,请到发起的对话中回答。`;
|
|
1808
|
+
await notifyExternal(ctx, "blocked", external, {
|
|
1095
1809
|
questions: error.questions,
|
|
1096
1810
|
resumeCommand: ctx.resumeCommand,
|
|
1097
1811
|
});
|