paseo-req-kit 0.0.0-stage → 1.8.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -2
  4. package/SCOPE.md +11 -0
  5. package/bin/locate.mjs +46 -0
  6. package/bin/req-doctor.mjs +21 -0
  7. package/bin/req.mjs +179 -0
  8. package/bin/start-pipeline +21 -0
  9. package/bin/start-pipeline.mjs +22 -0
  10. package/config.example.json +132 -0
  11. package/contracts/README.md +18 -0
  12. package/contracts/feature-files.md +117 -0
  13. package/contracts/notifier-event.md +72 -0
  14. package/contracts/result-file.md +21 -0
  15. package/contracts/spec.md +36 -0
  16. package/contracts/state.md +49 -0
  17. package/contracts/verify.md +41 -0
  18. package/docs/adr/0001-step-agents-under-origin-chat.md +15 -0
  19. package/docs/adr/0002-no-directory-switch-in-chat.md +15 -0
  20. package/docs/adr/0003-script-engine-not-hub.md +15 -0
  21. package/docs/adr/0004-result-files.md +15 -0
  22. package/docs/adr/0005-no-default-branch-local-commits-only.md +15 -0
  23. package/docs/adr/0006-skill-install-targets.md +15 -0
  24. package/docs/adr/0007-own-interview-skill.md +15 -0
  25. package/docs/adr/0008-entries-do-not-call-entries.md +15 -0
  26. package/docs/adr/0009-versioned-install-layout.md +15 -0
  27. package/docs/adr/0010-generated-feature-overview.md +15 -0
  28. package/docs/adr/0011-resume-after-completion.md +15 -0
  29. package/docs/adr/0012-review-panel.md +18 -0
  30. package/docs/adr/0013-no-release-tooling.md +15 -0
  31. package/docs/adr/0014-notifier-slot.md +19 -0
  32. package/docs/adr/0015-incremental-continue.md +17 -0
  33. package/docs/adr/0016-upgrade-from-tags.md +19 -0
  34. package/docs/adr/0017-use-paseo-capabilities.md +18 -0
  35. package/docs/adr/0018-own-worktree-option.md +21 -0
  36. package/docs/adr/0019-parallel-orchestration.md +21 -0
  37. package/docs/adr/0020-scheduling-through-the-chat.md +21 -0
  38. package/docs/adr/0021-npm-version-manager.md +19 -0
  39. package/docs/adr/0022-develop-and-verify-phases.md +32 -0
  40. package/docs/adr/0023-types-gate-slices.md +54 -0
  41. package/docs/adr/0024-confirm-early-review-axes.md +27 -0
  42. package/docs/adr/0025-joint-requirements.md +27 -0
  43. package/docs/adr/README.md +29 -0
  44. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +96 -0
  45. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +348 -0
  46. package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +85 -0
  47. 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 +356 -0
  48. package/engine/prompts/00-shared-context.md +104 -0
  49. package/engine/prompts/02-spec-review.md +46 -0
  50. package/engine/prompts/03-design.md +73 -0
  51. package/engine/prompts/03a-design-proposal.md +30 -0
  52. package/engine/prompts/04-design-review.md +73 -0
  53. package/engine/prompts/04b-design-revise.md +41 -0
  54. package/engine/prompts/05-implement.md +72 -0
  55. package/engine/prompts/06-code-review.md +70 -0
  56. package/engine/prompts/07-review-fix.md +41 -0
  57. package/engine/prompts/07b-handoff.md +45 -0
  58. package/engine/prompts/08-service-start.md +59 -0
  59. package/engine/prompts/09-verify.md +90 -0
  60. package/engine/prompts/10-defect-fix.md +41 -0
  61. package/engine/prompts/10b-reverify.md +43 -0
  62. package/engine/prompts/11-report.md +112 -0
  63. package/engine/prompts/browser-verify.md +13 -0
  64. package/engine/prompts/incremental.md +22 -0
  65. package/engine/prompts/joint.md +55 -0
  66. package/engine/prompts/panel-member.md +38 -0
  67. package/engine/prompts/paseo-services.md +9 -0
  68. package/engine/prompts/related-repos.md +7 -0
  69. package/engine/prompts/review-axis-spec.md +10 -0
  70. package/engine/prompts/review-axis-standards.md +11 -0
  71. package/engine/prompts/types/assessment.md +41 -0
  72. package/engine/prompts/types/defect.md +55 -0
  73. package/engine/prompts/types/refactor.md +39 -0
  74. package/engine/prompts/verify-deployed.md +14 -0
  75. package/engine/src/activity.ts +87 -0
  76. package/engine/src/cli-args.ts +217 -0
  77. package/engine/src/config.ts +619 -0
  78. package/engine/src/configure.ts +237 -0
  79. package/engine/src/control.ts +246 -0
  80. package/engine/src/daemon-check.ts +104 -0
  81. package/engine/src/daemon-version.ts +66 -0
  82. package/engine/src/digest.ts +142 -0
  83. package/engine/src/doctor.ts +462 -0
  84. package/engine/src/eval.ts +324 -0
  85. package/engine/src/features.ts +270 -0
  86. package/engine/src/gate.ts +157 -0
  87. package/engine/src/git.ts +30 -0
  88. package/engine/src/joint.ts +173 -0
  89. package/engine/src/launch.ts +405 -0
  90. package/engine/src/lessons.ts +72 -0
  91. package/engine/src/locations.ts +55 -0
  92. package/engine/src/main.ts +346 -0
  93. package/engine/src/messages.ts +394 -0
  94. package/engine/src/notifiers.ts +76 -0
  95. package/engine/src/notify-test.ts +101 -0
  96. package/engine/src/paseo-json.ts +73 -0
  97. package/engine/src/paseo-profiles.ts +133 -0
  98. package/engine/src/paseo-runtime.ts +437 -0
  99. package/engine/src/paths.ts +10 -0
  100. package/engine/src/pipeline.ts +1840 -0
  101. package/engine/src/playbooks.ts +46 -0
  102. package/engine/src/preflight.ts +337 -0
  103. package/engine/src/prompts.ts +72 -0
  104. package/engine/src/queue.ts +61 -0
  105. package/engine/src/runtime-loader.ts +14 -0
  106. package/engine/src/runtime.ts +155 -0
  107. package/engine/src/schedule-time.ts +51 -0
  108. package/engine/src/smoke-test.ts +202 -0
  109. package/engine/src/spec-review.ts +100 -0
  110. package/engine/src/state.ts +333 -0
  111. package/engine/src/steps.ts +266 -0
  112. package/engine/src/usage.ts +67 -0
  113. package/engine/src/worktree.ts +72 -0
  114. package/install.mjs +928 -0
  115. package/notifiers/im-template.mjs +60 -0
  116. package/package.json +67 -4
  117. package/principles/INDEX.md +11 -0
  118. package/skills/acceptance-criteria/SKILL.md +61 -0
  119. package/skills/principle-delete-before-add/SKILL.md +33 -0
  120. package/skills/principle-dont-block-on-humans/SKILL.md +35 -0
  121. package/skills/principle-fix-root-cause/SKILL.md +33 -0
  122. package/skills/principle-prove-it-works/SKILL.md +33 -0
  123. package/skills/principle-report-honestly/SKILL.md +33 -0
  124. package/skills/principle-speak-to-reader/SKILL.md +33 -0
  125. package/skills/principle-this-requirement-only/SKILL.md +33 -0
  126. package/skills/req/SKILL.md +101 -0
  127. package/skills/req/playbooks/accept.md +65 -0
  128. package/skills/req/playbooks/change.md +51 -0
  129. package/skills/req/playbooks/incident.md +55 -0
  130. package/skills/req/playbooks/new.md +94 -0
  131. package/skills/req/playbooks/small.md +48 -0
  132. package/skills/req/playbooks/status.md +41 -0
  133. package/skills/req/playbooks/takeover.md +51 -0
  134. package/skills/req/playbooks/verify.md +57 -0
  135. package/skills/req-doctor/SKILL.md +36 -0
  136. package/skills/req-retro/SKILL.md +81 -0
  137. package/skills/req-setup/SKILL.md +118 -0
  138. package/skills/req-setup/templates/paseo/copy-local-files.mjs +32 -0
  139. package/skills/req-setup/templates/paseo/with-port.mjs +36 -0
  140. package/skills/requirement-interview/SKILL.md +97 -0
  141. package/skills/requirement-kickoff/SKILL.md +17 -0
  142. package/skills/result-report/SKILL.md +34 -0
  143. package/skills/spec-writing/SKILL.md +55 -0
  144. package/skills/spec-writing/spec-template.md +109 -0
@@ -0,0 +1,1840 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
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,
20
+ autoResumePrompt,
21
+ blockedMessage,
22
+ gateFollowUp,
23
+ waitingReminderMessage,
24
+ completedMessage,
25
+ failedMessage,
26
+ phaseDoneMessage,
27
+ phaseWaitingReminderMessage,
28
+ verifyStartPrompt,
29
+ milestoneMessage,
30
+ permissionMessage,
31
+ resultFollowUp,
32
+ } from "./messages.js";
33
+ import { kitVersion } from "./locations.js";
34
+ import { type AgentUsage, describeTotals, totals } from "./usage.js";
35
+ import { dispatchNotifiers, type NotifierEventType } from "./notifiers.js";
36
+ import { principlesSection, projectAdditions, type PromptLocations, readToolPrompt } from "./prompts.js";
37
+ import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent } from "./runtime.js";
38
+ import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
39
+ import { type PanelStep, STEPS, type StepDef, type StepId, stepKey, validateResult, VERIFY_PHASE_STEPS } from "./steps.js";
40
+
41
+ export interface PipelineContext {
42
+ repo: string;
43
+ featureDir: string;
44
+ config: PipelineConfig;
45
+ runtime: AgentRuntime;
46
+ state: PipelineState;
47
+ statePath: string;
48
+ prompts: PromptLocations;
49
+ logPath: string;
50
+ resumeCommand: string;
51
+ /** Command to see progress; used in prompts that Paseo schedules send to the chat. */
52
+ statusCommand?: string;
53
+ /** Command that starts the verify phase (gated runs). */
54
+ verifyCommand?: string;
55
+ /** Command that confirms a design waiting at the confirmation point. */
56
+ approveCommand?: string;
57
+ log: (message: string) => void;
58
+ sleep: (ms: number) => Promise<void>;
59
+ /** Paseo-managed services of this run (filled in by the pipeline). */
60
+ services?: { names: string[] | null | undefined; started: Set<string> };
61
+ /** Paseo tools the step agents get; looked up once (undefined: not yet). */
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"]>;
71
+ }
72
+
73
+ export type PipelineOutcome = "completed" | "waiting_for_business" | "failed" | "phase_done" | "waiting_for_approval";
74
+
75
+ /** The develop phase is done and the run stops at the gate (phases.mode "gated"). */
76
+ class PhaseGate extends Error {
77
+ constructor() {
78
+ super("研发完成,待测试验证");
79
+ this.name = "PhaseGate";
80
+ }
81
+ }
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
+
91
+ const DEFAULT_PHASES: RunPhases = { mode: "continuous", environment: "local", defects: "fix" };
92
+
93
+ export function runPhases(state: Pick<PipelineState, "phases">): RunPhases {
94
+ return state.phases ?? DEFAULT_PHASES;
95
+ }
96
+
97
+ const ENVIRONMENT_TEXT = { local: "本地启动服务验证", deployed: "已部署的测试环境(见 test-env.md)" } as const;
98
+ const DEFECTS_TEXT = { fix: "当场修复并复验", handback: "只记录,退回研发阶段修复后再提测", report: "只记录,不修复" } as const;
99
+
100
+ function phaseOf(ctx: PipelineContext): PhaseName {
101
+ return ctx.state.phase ?? "develop";
102
+ }
103
+
104
+ function verifyCommandOf(ctx: PipelineContext): string {
105
+ return ctx.verifyCommand ?? `${ctx.resumeCommand} --phase verify`;
106
+ }
107
+
108
+ function approveCommandOf(ctx: PipelineContext): string {
109
+ return ctx.approveCommand ?? ctx.resumeCommand.replace(/ --resume$/, " --approve");
110
+ }
111
+
112
+ /** Ends the run early; the message is what the user-facing notification explains. */
113
+ class StepFailure extends Error {
114
+ constructor(
115
+ public readonly stepTitle: string,
116
+ message: string,
117
+ ) {
118
+ super(message);
119
+ this.name = "StepFailure";
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Errors that usually go away by themselves: provider rate limits and overload,
125
+ * dropped connections, a restarting daemon. A step that fails this way is started
126
+ * again after a pause instead of stopping the whole run.
127
+ */
128
+ const TRANSIENT_ERROR =
129
+ /rate.?limit|too many requests|\b429\b|overload|\b5(?:02|03|04|29)\b|temporarily|unavailable|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket hang up|network|disconnect|连接|无法获取 agent 状态/i;
130
+
131
+ export function isTransientError(message: string): boolean {
132
+ return TRANSIENT_ERROR.test(message);
133
+ }
134
+
135
+ class BusinessDecisionNeeded extends Error {
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
+ ) {
141
+ super("需要需求方决定");
142
+ this.name = "BusinessDecisionNeeded";
143
+ }
144
+ }
145
+
146
+ function featureName(featureDir: string): string {
147
+ return path.basename(featureDir);
148
+ }
149
+
150
+ function save(ctx: PipelineContext): void {
151
+ writeState(ctx.statePath, ctx.state);
152
+ }
153
+
154
+ async function notifyOrigin(ctx: PipelineContext, text: string): Promise<void> {
155
+ const origin = ctx.state.originAgentId;
156
+ ctx.log(`通知:\n${text}`);
157
+ if (!origin) {
158
+ ctx.log("没有发起对话的 agent ID,通知只写入日志。");
159
+ return;
160
+ }
161
+ try {
162
+ await ctx.runtime.notifyAgent(origin, text);
163
+ } catch (error) {
164
+ // The chat may have been archived. The state file and log still hold everything.
165
+ ctx.log(`通知发送失败(agent ${origin}):${(error as Error).message}`);
166
+ }
167
+ }
168
+
169
+ /** Extra material for one run of a step: panel member details, or the panel's findings to consolidate. */
170
+ interface StepExtras {
171
+ key?: string;
172
+ title?: string;
173
+ profile?: AgentProfile;
174
+ /** Appended as extra lines inside <pipeline-context>. */
175
+ contextLines?: string[];
176
+ /** Prompt sections appended after the step task (before project additions). */
177
+ sections?: string[];
178
+ /** Prompt file to quote as the review criteria (panel members). */
179
+ criteriaFile?: string;
180
+ /** Step whose project additions (steps/<id>.md) apply; defaults to the step itself. */
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;
237
+ }
238
+
239
+ function buildPrompt(
240
+ ctx: PipelineContext,
241
+ def: StepDef,
242
+ round: number | null,
243
+ maxRounds: number | null,
244
+ resultFile: string,
245
+ extras: StepExtras = {},
246
+ ): string {
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 };
251
+ const profile = ctx.state.profile;
252
+ const branch = target ? target.branch : ctx.state.branch;
253
+ const lines = [
254
+ "<pipeline-context>",
255
+ `仓库目录:${stepRepo}`,
256
+ `功能目录:${ctx.featureDir}`,
257
+ def.id === "spec_review" ? "工作分支:(本步骤不提交)" : `工作分支:${branch ?? "(不是 git 仓库,不需要提交)"}`,
258
+ `档位:${PROFILE_LABELS[profile]}${profile === "quick" ? "(小改动:各产物只写必要内容,保持简短)" : ""}`,
259
+ `本步骤:${def.title}(流水线第 ${def.number} 步)`,
260
+ ];
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
+ }
296
+ const phases = runPhases(ctx.state);
297
+ if (ctx.state.phases && phases.mode !== "continuous") {
298
+ lines.push(`执行方式:${PHASE_MODE_LABELS[phases.mode]}(当前阶段:${phaseOf(ctx) === "verify" ? "测试验证" : "研发"})`);
299
+ }
300
+ if (VERIFY_PHASE_STEPS.includes(def.id) || def.id === "report") {
301
+ if (phases.environment !== "local" || phases.defects !== "fix") {
302
+ lines.push(`测试验证:${ENVIRONMENT_TEXT[phases.environment]};发现缺陷时${DEFECTS_TEXT[phases.defects]}`);
303
+ }
304
+ lines.push(`提测说明:${ctx.featureDir}/handoff.md(研发阶段写的,先读它)`);
305
+ }
306
+ const inc = ctx.state.incremental;
307
+ if (inc) {
308
+ lines.push(
309
+ `增量继续:本次是第 ${ctx.state.attempt} 次尝试,接着第 ${inc.fromAttempt} 次的工作(${inc.reason})`,
310
+ `上一次是否已经写过代码:${inc.hadImplementation ? "是" : "否"}`,
311
+ `本次开始时的提交:${inc.baseCommit ?? "(不是 git 仓库)"}`,
312
+ );
313
+ }
314
+ if (extras.contextLines) lines.push(...extras.contextLines);
315
+ lines.push(`结果文件:${resultFile}`, `结果文件绝对路径:${path.join(stepRepo, resultFile)}`, "</pipeline-context>");
316
+ const parts = [readToolPrompt(sources, "00-shared-context.md")];
317
+ const principles = principlesSection(sources);
318
+ if (principles) parts.push(principles);
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
+ }
328
+ if (ctx.state.incremental) parts.push(readToolPrompt(sources, "incremental.md"));
329
+ if (related.length > 0) parts.push(readToolPrompt(sources, "related-repos.md"));
330
+ if (extras.criteriaFile) {
331
+ parts.push(
332
+ [
333
+ "# 评审标准(引用自该评审步骤的说明)",
334
+ "",
335
+ "下面是这一轮评审本来的完整说明,用来确定评审什么、怎么判断级别。其中关于写哪个产物文件、结果文件写什么、是否提交的要求不适用于你,以上一节\"评审小组成员\"的说明为准。",
336
+ "",
337
+ readToolPrompt(sources, extras.criteriaFile),
338
+ ].join("\n"),
339
+ );
340
+ }
341
+ if (extras.sections) parts.push(...extras.sections);
342
+ const extra = projectAdditions(sources, extras.additionsFor ?? def.id);
343
+ if (extra) parts.push(extra);
344
+ parts.push(lines.join("\n"));
345
+ return `${parts.join("\n\n")}\n`;
346
+ }
347
+
348
+ /**
349
+ * The feature directory as the chat should see it: relative to its own directory normally,
350
+ * absolute when the requirement is developed in a worktree created for it.
351
+ */
352
+ function shownFeatureDir(ctx: PipelineContext): string {
353
+ return ctx.state.worktree ? path.join(ctx.repo, ctx.featureDir).split(path.sep).join("/") : ctx.featureDir;
354
+ }
355
+
356
+ function specTitle(ctx: PipelineContext): string {
357
+ try {
358
+ const spec = fs.readFileSync(path.join(ctx.repo, ctx.featureDir, "spec.md"), "utf8");
359
+ return /^#\s+(.+)$/m.exec(spec)?.[1]?.trim() ?? featureName(ctx.featureDir);
360
+ } catch {
361
+ return featureName(ctx.featureDir);
362
+ }
363
+ }
364
+
365
+ /** Calls the configured external notifiers (e.g. an internal IM). Never throws. */
366
+ async function notifyExternal(ctx: PipelineContext, type: NotifierEventType, text: string, details: Record<string, unknown>): Promise<void> {
367
+ if (ctx.config.notifiers.length === 0) return;
368
+ await dispatchNotifiers(
369
+ ctx.config.notifiers,
370
+ {
371
+ schema: 1,
372
+ type,
373
+ at: new Date().toISOString(),
374
+ kitVersion: kitVersion(),
375
+ feature: { dir: ctx.featureDir, name: featureName(ctx.featureDir), title: specTitle(ctx) },
376
+ repo: ctx.repo,
377
+ branch: ctx.state.branch,
378
+ profile: ctx.state.profile,
379
+ originAgentId: ctx.state.originAgentId,
380
+ text,
381
+ details,
382
+ },
383
+ ctx.log,
384
+ );
385
+ }
386
+
387
+ /** Sends a progress note once per run attempt, when enabled. */
388
+ async function milestone(ctx: PipelineContext, id: string, text: string): Promise<void> {
389
+ if (!ctx.config.notifyMilestones || ctx.state.milestones.includes(id)) return;
390
+ ctx.state.milestones.push(id);
391
+ save(ctx);
392
+ await notifyOrigin(ctx, milestoneMessage(text));
393
+ await notifyExternal(ctx, "milestone", text, { milestone: id });
394
+ }
395
+
396
+ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef, agentTitle: string, deadline: number): Promise<AgentUsage | null> {
397
+ const notifiedPermissions = new Set<string>();
398
+ let consecutiveErrors = 0;
399
+ for (;;) {
400
+ const remaining = deadline - Date.now();
401
+ if (remaining <= 0) {
402
+ throw new StepFailure(
403
+ def.title,
404
+ `超过 ${ctx.config.stepTimeoutMinutes[def.id]} 分钟仍未完成。该 agent 可能仍在运行,可以在 Paseo 中查看「${agentTitle}」。`,
405
+ );
406
+ }
407
+ let outcome;
408
+ try {
409
+ outcome = await agent.wait(Math.min(ctx.config.waitChunkSeconds * 1000, remaining));
410
+ consecutiveErrors = 0;
411
+ } catch (error) {
412
+ consecutiveErrors += 1;
413
+ ctx.log(`等待 ${agentTitle} 时出错(第 ${consecutiveErrors} 次):${(error as Error).message}`);
414
+ if (consecutiveErrors >= 5) {
415
+ throw new StepFailure(def.title, `连续 ${consecutiveErrors} 次无法获取 agent 状态:${(error as Error).message}`);
416
+ }
417
+ await ctx.sleep(Math.min(60_000, 5_000 * consecutiveErrors));
418
+ continue;
419
+ }
420
+ switch (outcome.status) {
421
+ case "idle":
422
+ return outcome.usage ?? null;
423
+ case "timeout":
424
+ continue;
425
+ case "error":
426
+ throw new StepFailure(def.title, `agent 报告错误:${outcome.error ?? "未知错误"}`);
427
+ case "permission": {
428
+ const fresh = outcome.pendingPermissions.filter((p) => !notifiedPermissions.has(p.id));
429
+ if (fresh.length > 0) {
430
+ for (const p of fresh) notifiedPermissions.add(p.id);
431
+ await notifyOrigin(
432
+ ctx,
433
+ permissionMessage({ stepTitle: def.title, agentTitle, requests: fresh.map((p) => p.title) }),
434
+ );
435
+ await notifyExternal(ctx, "permission", `「${def.title}」步骤在等待权限批准,批准前会一直停在这里。`, {
436
+ stepTitle: def.title,
437
+ agentTitle,
438
+ requests: fresh.map((p) => p.title),
439
+ });
440
+ }
441
+ await ctx.sleep(ctx.config.permissionPollSeconds * 1000);
442
+ continue;
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ function readResultFile(file: string, def: StepDef) {
449
+ if (!fs.existsSync(file)) return { ok: false as const, error: "结果文件不存在" };
450
+ let raw: unknown;
451
+ try {
452
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
453
+ } catch (error) {
454
+ return { ok: false as const, error: `结果文件不是合法的 JSON(${(error as Error).message})` };
455
+ }
456
+ return validateResult(def.result, raw);
457
+ }
458
+
459
+ /**
460
+ * Runs a step; a transient failure (see isTransientError) starts it again with a
461
+ * fresh agent after a pause, up to config.retry.attempts more times.
462
+ */
463
+ async function runStep(
464
+ ctx: PipelineContext,
465
+ id: StepId,
466
+ round: number | null = null,
467
+ maxRounds: number | null = null,
468
+ extras: StepExtras = {},
469
+ ): Promise<Record<string, unknown>> {
470
+ const { attempts, backoffSeconds } = ctx.config.retry;
471
+ for (let retry = 0; ; retry += 1) {
472
+ try {
473
+ return await runStepOnce(ctx, id, round, maxRounds, extras);
474
+ } catch (error) {
475
+ const message = (error as Error).message;
476
+ if (!(error instanceof StepFailure) || !isTransientError(message) || retry >= attempts) {
477
+ if (error instanceof StepFailure && retry > 0) {
478
+ throw new StepFailure(error.stepTitle, `${message}(已自动重试 ${retry} 次)`);
479
+ }
480
+ throw error;
481
+ }
482
+ const wait = backoffSeconds.at(Math.min(retry, backoffSeconds.length - 1)) ?? 60;
483
+ ctx.log(`${error.stepTitle} 遇到临时错误:${message}。${wait} 秒后自动重试(第 ${retry + 1} 次,最多 ${attempts} 次)`);
484
+ await ctx.sleep(wait * 1000);
485
+ extras = {
486
+ ...extras,
487
+ contextLines: [
488
+ ...(extras.contextLines ?? []).filter((l) => !l.startsWith("自动重试:")),
489
+ `自动重试:本步骤上一次因临时错误中断(${message.slice(0, 200)})。工作区里可能留有上一次没做完的改动,先用 git status 和 git diff 查看,在此基础上继续,不要重复提交`,
490
+ ],
491
+ };
492
+ }
493
+ }
494
+ }
495
+
496
+ async function runStepOnce(
497
+ ctx: PipelineContext,
498
+ id: StepId,
499
+ round: number | null,
500
+ maxRounds: number | null,
501
+ extras: StepExtras,
502
+ ): Promise<Record<string, unknown>> {
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;
507
+ const cached = ctx.state.results[key];
508
+ if (cached) {
509
+ ctx.log(`跳过 ${key}:沿用上次运行的结果 ${JSON.stringify(cached)}`);
510
+ return cached;
511
+ }
512
+
513
+ const resultRel = `${ctx.featureDir}/.pipeline/results/${key}.json`;
514
+ const resultAbs = path.join(stepRepo, resultRel);
515
+ fs.mkdirSync(path.dirname(resultAbs), { recursive: true });
516
+ fs.rmSync(resultAbs, { force: true });
517
+
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
+ );
525
+ if (managed) {
526
+ extras = {
527
+ ...extras,
528
+ contextLines: [...(extras.contextLines ?? []), ...managed.contextLines],
529
+ sections: [...(extras.sections ?? []), ...managed.sections],
530
+ };
531
+ }
532
+
533
+ const agentTitle =
534
+ `[${featureName(ctx.featureDir)}] ` +
535
+ (extras.title ?? `${def.number}-${def.title}${round !== null && round > 1 ? ` 第${round}轮` : ""}${target ? `(${target.name})` : ""}`);
536
+ const record: StepRecord = {
537
+ key,
538
+ stepId: id,
539
+ round,
540
+ attempt: ctx.state.attempt,
541
+ agentId: "",
542
+ startedAt: new Date().toISOString(),
543
+ endedAt: null,
544
+ outcome: "running",
545
+ followUps: 0,
546
+ result: null,
547
+ error: null,
548
+ phase: phaseOf(ctx),
549
+ };
550
+ ctx.log(`开始 ${key}(${def.title})`);
551
+
552
+ try {
553
+ const agent = await ctx.runtime.startAgent({
554
+ role: def.role,
555
+ title: agentTitle,
556
+ prompt: buildPrompt(ctx, def, round, maxRounds, resultRel, extras),
557
+ cwd: stepRepo,
558
+ labels: {
559
+ "requirement-pipeline": featureName(ctx.featureDir),
560
+ "pipeline-step": key,
561
+ ...(target ? { "joint-repo": target.slug } : {}),
562
+ },
563
+ parentAgentId: ctx.config.attachToOriginChat ? ctx.state.originAgentId : null,
564
+ // Always passed: the run's config has Paseo profile names resolved; the runtime's may not.
565
+ profile: extras.profile ?? phaseAgent(ctx, id) ?? ctx.config.agents[def.role],
566
+ workspaceId: target ? target.workspaceId : (ctx.state.worktree?.workspaceId ?? null),
567
+ });
568
+ record.agentId = agent.id;
569
+ ctx.state.history.push(record);
570
+ save(ctx);
571
+
572
+ const deadline = Date.now() + ctx.config.stepTimeoutMinutes[id] * 60_000;
573
+ record.usage = (await waitForTurn(ctx, agent, def, agentTitle, deadline)) ?? record.usage ?? null;
574
+ let validation = readResultFile(resultAbs, def);
575
+ while (!validation.ok && record.followUps < ctx.config.resultFollowUps) {
576
+ record.followUps += 1;
577
+ save(ctx);
578
+ ctx.log(`${key} 的结果无效(${validation.error}),第 ${record.followUps} 次提醒 agent`);
579
+ await agent.followUp(resultFollowUp({ resultFile: resultAbs, problem: validation.error }));
580
+ record.usage = (await waitForTurn(ctx, agent, def, agentTitle, deadline)) ?? record.usage ?? null;
581
+ validation = readResultFile(resultAbs, def);
582
+ }
583
+ if (!validation.ok) {
584
+ throw new StepFailure(def.title, `提醒 ${record.followUps} 次后仍没有得到有效的结果文件:${validation.error}`);
585
+ }
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
+
597
+ record.outcome = "completed";
598
+ record.endedAt = new Date().toISOString();
599
+ record.result = validation.value;
600
+ ctx.state.results[key] = validation.value;
601
+ save(ctx);
602
+ ctx.log(`完成 ${key}:${JSON.stringify(validation.value)}`);
603
+ if (ctx.config.archiveStepAgents) {
604
+ await agent.archive().catch((error: Error) => ctx.log(`归档 ${agentTitle} 失败:${error.message}`));
605
+ }
606
+ return validation.value;
607
+ } catch (error) {
608
+ record.outcome = "failed";
609
+ record.endedAt = new Date().toISOString();
610
+ record.error = (error as Error).message;
611
+ if (!ctx.state.history.includes(record)) ctx.state.history.push(record);
612
+ save(ctx);
613
+ if (error instanceof StepFailure) throw error;
614
+ throw new StepFailure(def.title, (error as Error).message);
615
+ }
616
+ }
617
+
618
+ /** phases.verify.agents applies to the verify-phase steps. */
619
+ function phaseAgent(ctx: PipelineContext, id: StepId): AgentProfile | undefined {
620
+ if (!VERIFY_PHASE_STEPS.includes(id)) return undefined;
621
+ return ctx.config.phases.verify.agents[STEPS[id].role];
622
+ }
623
+
624
+ function deployed(ctx: PipelineContext): boolean {
625
+ return runPhases(ctx.state).environment === "deployed";
626
+ }
627
+
628
+ /** Verification against an environment deployed for testing, described in test-env.md. */
629
+ function deployedExtras(ctx: PipelineContext, id: StepId): { contextLines: string[]; sections: string[] } | null {
630
+ if (!deployed(ctx) || !["verify", "defect_fix", "reverify"].includes(id)) return null;
631
+ return {
632
+ contextLines: [`验证环境:已部署的测试环境,地址、账号从哪里取、可以用的测试数据见 ${ctx.featureDir}/test-env.md`],
633
+ sections: [readToolPrompt({ ...ctx.prompts, repo: ctx.repo }, "verify-deployed.md")],
634
+ };
635
+ }
636
+
637
+ function mergeExtras(
638
+ a: { contextLines: string[]; sections: string[] } | null,
639
+ b: { contextLines: string[]; sections: string[] } | null,
640
+ ): { contextLines: string[]; sections: string[] } | null {
641
+ if (!a && !b) return null;
642
+ return { contextLines: [...(a?.contextLines ?? []), ...(b?.contextLines ?? [])], sections: [...(a?.sections ?? []), ...(b?.sections ?? [])] };
643
+ }
644
+
645
+ /** Steps that check the result the way a user would. */
646
+ const BROWSER_STEPS: readonly StepId[] = ["verify", "defect_fix", "reverify"];
647
+
648
+ /** Points verification at Paseo's built-in browser when the step agents have it. */
649
+ async function browserExtras(ctx: PipelineContext, id: StepId): Promise<{ contextLines: string[]; sections: string[] } | null> {
650
+ if (!BROWSER_STEPS.includes(id) || ctx.config.browserVerify === "off") return null;
651
+ if (ctx.capabilities === undefined) ctx.capabilities = await ctx.runtime.capabilities().catch(() => null);
652
+ if (!ctx.capabilities?.browser) return null;
653
+ return {
654
+ contextLines: ["浏览器:可以用 Paseo 内置浏览器(browser_* 工具)验证网页界面"],
655
+ sections: [readToolPrompt({ ...ctx.prompts, repo: ctx.repo }, "browser-verify.md")],
656
+ };
657
+ }
658
+
659
+ /** Steps that need the project's services running. */
660
+ const SERVICE_STEPS: readonly StepId[] = ["service_start", "verify", "defect_fix", "reverify"];
661
+
662
+ function serviceState(ctx: PipelineContext): NonNullable<PipelineContext["services"]> {
663
+ ctx.services ??= { names: undefined, started: new Set() };
664
+ return ctx.services;
665
+ }
666
+
667
+ /** The workspace whose paseo.json services the steps use: the requirement's own worktree, or the chat's. */
668
+ function serviceTarget(ctx: PipelineContext): ServiceTarget | null {
669
+ if (ctx.state.worktree) return { workspaceId: ctx.state.worktree.workspaceId };
670
+ const origin = ctx.state.originAgentId;
671
+ return origin && ctx.config.attachToOriginChat ? { agentId: origin } : null;
672
+ }
673
+
674
+ /** Service scripts from paseo.json that Paseo can run for this pipeline; null when not used. */
675
+ async function serviceNames(ctx: PipelineContext): Promise<string[] | null> {
676
+ const state = serviceState(ctx);
677
+ if (state.names !== undefined) return state.names;
678
+ const target = serviceTarget(ctx);
679
+ if (ctx.config.paseoServices === "off" || !target) {
680
+ state.names = null;
681
+ return null;
682
+ }
683
+ state.names = await ctx.runtime.listServices(target).catch(() => null);
684
+ if (state.names) ctx.log(`项目在 paseo.json 中定义了服务(${state.names.join("、")}),服务启动和验证相关的步骤由流水线通过 Paseo 启动它们`);
685
+ return state.names;
686
+ }
687
+
688
+ function describeService(s: ManagedService, logFile: string | null): string {
689
+ const where = `${s.url ?? "(Paseo 没有给出地址)"}${s.port ? `(端口 ${s.port})` : ""}`;
690
+ const output = logFile ? `,最后的输出见 ${logFile}` : "";
691
+ if (!s.running) return `Paseo 管理的服务:${s.name} 没有启动成功${s.exitCode !== null ? `(退出码 ${s.exitCode})` : ""}${output}`;
692
+ if (s.health === "unhealthy") return `Paseo 管理的服务:${s.name} → ${where},已启动但健康检查没有通过${output}`;
693
+ return `Paseo 管理的服务:${s.name} → ${where},已启动${s.health === "healthy" ? ",健康检查通过" : ""}`;
694
+ }
695
+
696
+ /** Starts the services (fresh, with the current code) before a step that needs them. */
697
+ async function serviceExtras(ctx: PipelineContext, id: StepId): Promise<{ contextLines: string[]; sections: string[] } | null> {
698
+ if (!SERVICE_STEPS.includes(id) || deployed(ctx)) return null;
699
+ const names = await serviceNames(ctx);
700
+ const target = serviceTarget(ctx);
701
+ if (!names || !target) return null;
702
+ const sections = [readToolPrompt({ ...ctx.prompts, repo: ctx.repo }, "paseo-services.md")];
703
+ let services: ManagedService[];
704
+ try {
705
+ services = await ctx.runtime.startServices(target, names, ctx.config.serviceReadySeconds * 1000);
706
+ } catch (error) {
707
+ ctx.log(`通过 Paseo 启动服务失败:${(error as Error).message}`);
708
+ return {
709
+ contextLines: [`Paseo 管理的服务:这次没能通过 Paseo 启动(${(error as Error).message}),请按验证方法或 service-start.md 自己启动,用完关掉`],
710
+ sections: [],
711
+ };
712
+ }
713
+ for (const name of names) serviceState(ctx).started.add(name);
714
+ const dir = path.join(ctx.repo, ctx.featureDir, ".pipeline", "services");
715
+ const lines = services.map((s) => {
716
+ let logFile: string | null = null;
717
+ if (s.output && (!s.running || s.health === "unhealthy")) {
718
+ fs.mkdirSync(dir, { recursive: true });
719
+ logFile = `${ctx.featureDir}/.pipeline/services/${s.name}.log`;
720
+ fs.writeFileSync(path.join(ctx.repo, logFile), `${s.output}\n`);
721
+ }
722
+ return describeService(s, logFile);
723
+ });
724
+ ctx.log(lines.join(";"));
725
+ return { contextLines: lines, sections };
726
+ }
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
+
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();
760
+ const started = [...(ctx.services?.started ?? [])];
761
+ const target = serviceTarget(ctx);
762
+ if (started.length === 0 || !target) return;
763
+ await ctx.runtime.stopServices(target, started).catch((error: Error) => ctx.log(`停止服务失败:${error.message}`));
764
+ ctx.services?.started.clear();
765
+ ctx.log(`已停止 Paseo 管理的服务:${started.join("、")}`);
766
+ }
767
+
768
+ /** A Paseo schedule that reminds the chat about unanswered questions; returns the hours, or null. */
769
+ async function scheduleReminder(ctx: PipelineContext, what: string[] | "phase" | "approval"): Promise<number | null> {
770
+ const hours = ctx.config.waitingReminderHours;
771
+ const origin = ctx.state.originAgentId;
772
+ if (hours <= 0 || !origin) return null;
773
+ const at = new Date(Date.now() + hours * 3600_000);
774
+ const shown = shownFeatureDir(ctx);
775
+ try {
776
+ const id = await ctx.runtime.scheduleMessage({
777
+ agentId: origin,
778
+ at,
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 }),
786
+ });
787
+ if (!id) return null;
788
+ ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "reminder", at: at.toISOString() }];
789
+ save(ctx);
790
+ ctx.log(`已预约 ${hours} 小时后的提醒(Paseo 定时任务 ${id})`);
791
+ return hours;
792
+ } catch (error) {
793
+ ctx.log(`预约提醒失败:${(error as Error).message}`);
794
+ return null;
795
+ }
796
+ }
797
+
798
+ function shortTime(at: Date): string {
799
+ return at.toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
800
+ }
801
+
802
+ /** --verify-at: a Paseo schedule that has the chat start the verify phase; returns the time as text, or null. */
803
+ async function scheduleVerifyStart(ctx: PipelineContext): Promise<string | null> {
804
+ const origin = ctx.state.originAgentId;
805
+ if (!ctx.state.verifyAt || !origin) return null;
806
+ const at = new Date(ctx.state.verifyAt);
807
+ try {
808
+ const id = await ctx.runtime.scheduleMessage({
809
+ agentId: origin,
810
+ at,
811
+ name: `req-kit 测试验证:${featureName(ctx.featureDir)}`,
812
+ prompt: verifyStartPrompt({
813
+ featureDir: shownFeatureDir(ctx),
814
+ statusCommand: ctx.statusCommand ?? ctx.resumeCommand.replace(/ --resume$/, " --status"),
815
+ verifyCommand: verifyCommandOf(ctx),
816
+ environment: runPhases(ctx.state).environment,
817
+ }),
818
+ });
819
+ if (!id) return null;
820
+ ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "verify-start", at: at.toISOString() }];
821
+ save(ctx);
822
+ ctx.log(`已预约在 ${shortTime(at)} 开始测试验证(Paseo 定时任务 ${id})`);
823
+ return shortTime(at);
824
+ } catch (error) {
825
+ ctx.log(`预约测试验证失败:${(error as Error).message}`);
826
+ return null;
827
+ }
828
+ }
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
+
859
+ /** A Paseo schedule that has the chat continue the run later; returns the time as text, or null. */
860
+ async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise<string | null> {
861
+ const minutes = ctx.config.autoResumeMinutes;
862
+ const origin = ctx.state.originAgentId;
863
+ if (minutes <= 0 || !origin) return null;
864
+ const used = ctx.state.autoResumes?.attempt === ctx.state.attempt ? ctx.state.autoResumes.count : 0;
865
+ if (used >= ctx.config.autoResumeMax) {
866
+ ctx.log(`这次尝试已经自动继续过 ${used} 次,不再预约`);
867
+ return null;
868
+ }
869
+ const at = new Date(Date.now() + minutes * 60_000);
870
+ try {
871
+ const id = await ctx.runtime.scheduleMessage({
872
+ agentId: origin,
873
+ at,
874
+ name: `req-kit 自动继续:${featureName(ctx.featureDir)}`,
875
+ prompt: autoResumePrompt({
876
+ featureDir: shownFeatureDir(ctx),
877
+ reason: reason.slice(0, 300),
878
+ statusCommand: ctx.statusCommand ?? ctx.resumeCommand.replace(/ --resume$/, " --status"),
879
+ resumeCommand: ctx.resumeCommand,
880
+ }),
881
+ });
882
+ if (!id) return null;
883
+ ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "auto-resume", at: at.toISOString() }];
884
+ ctx.state.autoResumes = { attempt: ctx.state.attempt, count: used + 1 };
885
+ save(ctx);
886
+ ctx.log(`已预约 ${minutes} 分钟后自动继续(Paseo 定时任务 ${id})`);
887
+ return at.toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
888
+ } catch (error) {
889
+ ctx.log(`预约自动继续失败:${(error as Error).message}`);
890
+ return null;
891
+ }
892
+ }
893
+
894
+ /** Warns once at the start when a provider's subscription is nearly used up. Never blocks. */
895
+ async function checkQuota(ctx: PipelineContext): Promise<void> {
896
+ const threshold = ctx.config.quotaWarnPercent;
897
+ if (threshold <= 0) return;
898
+ const profiles = [ctx.config.agents.builder, ctx.config.agents.reviewer];
899
+ if (ctx.config.reviewPanel.enabled && ctx.config.reviewPanel.profiles.includes(ctx.state.profile)) profiles.push(...ctx.config.reviewPanel.members);
900
+ const providers = [...new Set(profiles.map((p) => (p.provider ?? "").split("/")[0]).filter(Boolean))];
901
+ const quotas = await ctx.runtime.providerQuota(providers).catch(() => null);
902
+ if (!quotas) return;
903
+ const tight = quotas.filter((q) => (q.usedPct !== null && q.usedPct >= threshold) || q.tone === "danger");
904
+ if (tight.length === 0) {
905
+ ctx.log(`额度:${quotas.map((q) => `${q.displayName} ${q.window} ${q.usedPct === null ? "?" : `${Math.round(q.usedPct)}%`}`).join(",")}`);
906
+ return;
907
+ }
908
+ const items = tight.map((q) => {
909
+ const reset = q.resetsAt ? `,${new Date(q.resetsAt).toLocaleString("zh-CN", { hour12: false })} 恢复` : "";
910
+ return `${q.displayName} 的「${q.window}」额度已用 ${q.usedPct === null ? "大部分" : `${Math.round(q.usedPct)}%`}${reset}`;
911
+ });
912
+ await notifyOrigin(
913
+ ctx,
914
+ [
915
+ "【需求开发流水线通知】这条消息来自后台流水线,不是用户发的。",
916
+ `额度提醒:${items.join(";")}。`,
917
+ "流水线照常进行;额度用完时会遇到限流,流水线会自动等待重试,重试用完会中断,之后可以继续。如果希望等额度恢复后再开发,请转告用户,用户同意的话可以先暂停。",
918
+ ].join("\n"),
919
+ );
920
+ }
921
+
922
+ function panelApplies(ctx: PipelineContext, step: PanelStep, round: number): boolean {
923
+ const panel = ctx.config.reviewPanel;
924
+ return panel.enabled && round === 1 && panel.steps.includes(step) && panel.profiles.includes(ctx.state.profile);
925
+ }
926
+
927
+ const PANEL_OUTPUT: Record<PanelStep, string> = { design_review: "design-review.md", code_review: "code-review.md" };
928
+
929
+ function memberName(member: ReviewPanelMember, index: number): string {
930
+ return member.name?.trim() || `成员 ${index + 1}`;
931
+ }
932
+
933
+ /**
934
+ * Runs the panel members of one review in parallel. Returns the findings files
935
+ * (relative to the repository) of the members that finished; a member that fails
936
+ * is logged and left out. Fails only when no member finishes.
937
+ */
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;
950
+ const runs = members.map((member, i) => {
951
+ const key = keyFor(`${step}-r${round}-p${i + 1}`, target);
952
+ const findings = `${ctx.featureDir}/review-panel/${key.replace("@", "-")}.md`;
953
+ const name = memberName(member, i);
954
+ const contextLines = [
955
+ `评审小组:${title},第 ${round} 轮,${name}(共 ${members.length} 位)`,
956
+ `你的意见文件:${findings}`,
957
+ `你的意见文件绝对路径:${path.join(target?.repo ?? ctx.repo, findings)}`,
958
+ ...shared,
959
+ ];
960
+ if (member.focus) contextLines.push(`你的关注重点:${member.focus}`);
961
+ const { section, ...profile } = member;
962
+ return runStep(ctx, "panel_review", round, null, {
963
+ key,
964
+ title: `${STEPS[step].number}-${title}${target ? `(${target.name})` : ""} 评审小组·${name}`,
965
+ target,
966
+ profile,
967
+ contextLines,
968
+ sections: section ? [section] : undefined,
969
+ criteriaFile: STEPS[step].promptFile,
970
+ additionsFor: step,
971
+ }).then(() => findings);
972
+ });
973
+ const settled = await Promise.allSettled(runs);
974
+ const done: string[] = [];
975
+ settled.forEach((outcome, i) => {
976
+ if (outcome.status === "fulfilled") done.push(outcome.value);
977
+ else ctx.log(`评审小组${memberName(members[i], i)}没有完成,汇总时不包含它的意见:${(outcome.reason as Error).message}`);
978
+ });
979
+ if (done.length === 0) {
980
+ const first = settled.find((s): s is PromiseRejectedResult => s.status === "rejected");
981
+ throw first?.reason instanceof StepFailure ? first.reason : new StepFailure(title, "评审小组的成员都没有完成");
982
+ }
983
+ return done;
984
+ }
985
+
986
+ function consolidationSection(step: PanelStep, findings: string[], total: number): string {
987
+ return [
988
+ "# 评审小组的意见",
989
+ "",
990
+ `这一轮先由 ${total} 位评审者各自独立评审${findings.length < total ? `(其中 ${total - findings.length} 位没有完成,只有下面 ${findings.length} 份)` : ""},意见文件如下。你负责核实并汇总,再按上文的要求完成本步骤:`,
991
+ "",
992
+ ...findings.map((f) => `- \`${f}\``),
993
+ "",
994
+ "做法:",
995
+ "1. 逐条读完所有意见。不同成员说的是同一个问题的,合并为一条。",
996
+ "2. 每条都亲自核实(读代码、对照 spec、能运行的就运行),然后归入四类之一:",
997
+ " - **必须修改**:成立,且属于\"阻塞\"或\"重要\"。按上文的格式写进评审意见,参与结论判断。",
998
+ " - **建议修改**:成立,但属于\"建议\"。",
999
+ " - **留给人决定**:涉及需求取舍、只有需求方或开发同事能判断的。在 `decisions.tsv` 中记一行,`needs_human` 为 `yes`。",
1000
+ " - **已驳回**:核实后不成立。写明依据,并在 `decisions.tsv` 中记一行,`kind` 为 `驳回意见`。",
1001
+ `3. 在 \`${PANEL_OUTPUT[step]}\` 中增加\"评审小组意见汇总\"一节,表格列出每条原始意见:来源成员、意见摘要、归类、理由。被驳回的也要列出,最终报告会展示它们。`,
1002
+ "4. 结论和结果文件只根据你核实后的\"必须修改\"判断,不按成员的数量投票。",
1003
+ ].join("\n");
1004
+ }
1005
+
1006
+ function proposalsApply(ctx: PipelineContext): boolean {
1007
+ const p = ctx.config.designProposals;
1008
+ // A continuation after a change only touches what changed; one designer is enough.
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";
1012
+ }
1013
+
1014
+ function proposalMembers(ctx: PipelineContext): ReviewPanelMember[] {
1015
+ const members = ctx.config.designProposals.members;
1016
+ if (members.length > 0) return members;
1017
+ return [
1018
+ { ...ctx.config.agents.builder, name: "builder" },
1019
+ { ...ctx.config.agents.reviewer, name: "reviewer" },
1020
+ ];
1021
+ }
1022
+
1023
+ /**
1024
+ * Several agents propose a design in parallel (no code changes, no commits); returns the
1025
+ * proposal files of those that finished. The design step then compares and decides.
1026
+ */
1027
+ async function runProposals(ctx: PipelineContext): Promise<string[]> {
1028
+ const members = proposalMembers(ctx);
1029
+ const runs = members.map((member, i) => {
1030
+ const key = `design_proposal-p${i + 1}`;
1031
+ const file = `${ctx.featureDir}/design-proposals/${key}.md`;
1032
+ const name = memberName(member, i);
1033
+ const contextLines = [
1034
+ `方案提案:${name}(共 ${members.length} 份,各自独立完成)`,
1035
+ `你的提案文件:${file}`,
1036
+ `你的提案文件绝对路径:${path.join(ctx.repo, file)}`,
1037
+ ];
1038
+ if (member.focus) contextLines.push(`你的关注重点:${member.focus}`);
1039
+ return runStep(ctx, "design_proposal", null, null, {
1040
+ key,
1041
+ title: `3-方案提案·${name}`,
1042
+ profile: member,
1043
+ contextLines,
1044
+ additionsFor: "design",
1045
+ }).then(() => file);
1046
+ });
1047
+ const settled = await Promise.allSettled(runs);
1048
+ const done: string[] = [];
1049
+ settled.forEach((outcome, i) => {
1050
+ if (outcome.status === "fulfilled") done.push(outcome.value);
1051
+ else ctx.log(`方案提案·${memberName(members[i], i)}没有完成,方案设计时不包含它:${(outcome.reason as Error).message}`);
1052
+ });
1053
+ return done;
1054
+ }
1055
+
1056
+ function proposalsSection(files: string[], total: number): string {
1057
+ return [
1058
+ "# 方案提案",
1059
+ "",
1060
+ `这一次先由 ${total} 个 agent 各自独立提出了方案${files.length < total ? `(其中 ${total - files.length} 份没有完成,只有下面 ${files.length} 份)` : ""}:`,
1061
+ "",
1062
+ ...files.map((f) => `- \`${f}\``),
1063
+ "",
1064
+ "你负责比较并做出决定,再按上文的要求写 `design.md`:",
1065
+ "1. 逐份读完,对照 spec 和代码核实其中的判断,不要只看结论。",
1066
+ "2. 选择一份为基础,或者把各份中更好的部分合并起来。不要为了合并而合并,取舍要有理由。",
1067
+ "3. 在 `design.md` 中增加\"方案比较\"一节:一张表列出各提案的思路、优点、风险、改动范围,写明最后的选择和理由,以及放弃了什么。",
1068
+ "4. 会影响需求方能看到的行为、成本或时间的取舍,在 `decisions.tsv` 中记一行,`needs_human` 为 `yes`。",
1069
+ ].join("\n");
1070
+ }
1071
+
1072
+ /** One review round; with a panel in the first round, members review first and this step consolidates. */
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);
1084
+ return runStep(ctx, step, round, maxRounds, {
1085
+ contextLines,
1086
+ target,
1087
+ sections: [consolidationSection(step, findings, members.length)],
1088
+ });
1089
+ }
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
+
1106
+ /** Runs one step outside the pipeline's flow (the spec review started from the chat). */
1107
+ export async function runSingleStep(ctx: PipelineContext, id: StepId, extras: StepExtras = {}): Promise<Record<string, unknown>> {
1108
+ try {
1109
+ return await runStep(ctx, id, null, null, extras);
1110
+ } catch (error) {
1111
+ throw new Error((error as Error).message);
1112
+ }
1113
+ }
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
+
1409
+ /** Develop phase: design → design review ⇄ revise → implement → code review ⇄ fix → handoff. Returns the design result. */
1410
+ async function developPhase(ctx: PipelineContext): Promise<Record<string, unknown>> {
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
+ }
1421
+
1422
+ let designExtras: StepExtras = {};
1423
+ if (proposalsApply(ctx) && !ctx.state.results.design) {
1424
+ const files = await runProposals(ctx);
1425
+ if (files.length > 0) designExtras = { sections: [proposalsSection(files, proposalMembers(ctx).length)] };
1426
+ else ctx.log("方案提案都没有完成,按原来的方式由一个 agent 做方案设计");
1427
+ }
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
+ }
1433
+
1434
+ let designRounds = 0;
1435
+ for (let r = 1; r <= rounds.designReview; r++) {
1436
+ const review = await runReview(ctx, "design_review", r, rounds.designReview);
1437
+ designRounds = r;
1438
+ if (review.verdict === "approved") break;
1439
+ if (review.verdict === "blocked") throw new BusinessDecisionNeeded(review.questions as string[]);
1440
+ // changes_required. The last round's revision goes straight to implementation;
1441
+ // the implementer reads both the review and the revision record.
1442
+ await runStep(ctx, "design_revise", r, rounds.designReview);
1443
+ }
1444
+ confirmDesign(ctx);
1445
+ const slices = implementationSlices(ctx);
1446
+ await milestone(
1447
+ ctx,
1448
+ "design-settled",
1449
+ `技术方案已经确定(审核 ${designRounds} 轮),开始写代码${slices.length > 0 ? `,分 ${slices.length} 个切片逐个完成` : ""}。写代码和代码检查通常是整个过程中最耗时的部分。`,
1450
+ );
1451
+
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);
1456
+ }
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) });
1486
+ return design;
1487
+ }
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
+
1512
+ /**
1513
+ * Verify phase: service start → verification → defects as configured.
1514
+ * Returns true when the defects go back to development (defects "handback").
1515
+ */
1516
+ async function verifyPhase(ctx: PipelineContext, design: Record<string, unknown>): Promise<boolean> {
1517
+ const settings = ctx.config.profiles[ctx.state.profile];
1518
+ const rounds = settings.rounds;
1519
+ const phases = runPhases(ctx.state);
1520
+ const serviceStart = phases.environment === "deployed" ? false : (ctx.config.phases.verify.serviceStart ?? settings.serviceStart);
1521
+
1522
+ if (design.has_runnable_service === true && serviceStart) {
1523
+ await runStep(ctx, "service_start");
1524
+ } else if (design.has_runnable_service === true && phases.environment === "deployed") {
1525
+ ctx.log("在已部署的测试环境中验证,不在本地做服务启动验证。");
1526
+ } else if (design.has_runnable_service === true) {
1527
+ ctx.log(`${PROFILE_LABELS[ctx.state.profile]}档不执行服务启动验证,由功能验证一并处理。`);
1528
+ } else {
1529
+ ctx.log("方案设计判断项目没有常驻服务,跳过服务启动验证。");
1530
+ }
1531
+
1532
+ const joint = jointTargets(ctx);
1533
+ const jointContext = joint.length > 0 ? { contextLines: jointLines(ctx) } : {};
1534
+ const verification = await runStep(ctx, "verify", null, null, jointContext);
1535
+ if (verification.result !== "defects_found") return false;
1536
+ if (phases.defects === "report") {
1537
+ ctx.log("验证发现了问题,按设置只记录、不修复,直接写报告。");
1538
+ return false;
1539
+ }
1540
+ if (phases.defects === "handback") {
1541
+ const used = ctx.state.handbacks ?? 0;
1542
+ if (used < rounds.defectFix) return true;
1543
+ ctx.log(`已经退回研发 ${used} 次(上限 ${rounds.defectFix}),不再退回,问题写进报告。`);
1544
+ return false;
1545
+ }
1546
+ await milestone(ctx, "fixing-defects", `验证中发现了一些问题,正在修复并重新验证(最多 ${rounds.defectFix} 轮)。`);
1547
+ let found = verification;
1548
+ for (let r = 1; r <= rounds.defectFix; r++) {
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);
1553
+ if (recheck.result === "pass") break;
1554
+ found = recheck;
1555
+ }
1556
+ return false;
1557
+ }
1558
+
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];
1573
+ }
1574
+
1575
+ /**
1576
+ * Defects found in the verify phase go back to development: recorded in changes.md (source
1577
+ * "测试验证"), then a new attempt starts from design and only handles them (incremental).
1578
+ */
1579
+ async function handBack(ctx: PipelineContext): Promise<void> {
1580
+ const n = (ctx.state.handbacks ?? 0) + 1;
1581
+ const changes = path.join(ctx.repo, ctx.featureDir, "changes.md");
1582
+ const today = new Date().toISOString().slice(0, 10);
1583
+ const section = [
1584
+ "",
1585
+ `## ${today} 测试验证退回(第 ${n} 次)`,
1586
+ "",
1587
+ "- 来源:测试验证",
1588
+ `- 内容:第 ${ctx.state.attempt} 次尝试的功能验证发现了缺陷,缺陷清单见 verification.md 最后一轮的“缺陷”一节`,
1589
+ "- 处理:回到研发阶段,从方案设计开始只处理这些缺陷,修复经过代码评审后重新写提测说明、再提测",
1590
+ "",
1591
+ ].join("\n");
1592
+ fs.appendFileSync(changes, fs.existsSync(changes) ? section : `# 变更记录\n${section}`);
1593
+ ctx.state.handbacks = n;
1594
+ ctx.state.incremental = {
1595
+ fromAttempt: ctx.state.attempt,
1596
+ reason: `测试验证发现缺陷,第 ${n} 次退回研发阶段修复(见 changes.md 和 verification.md)`,
1597
+ baseCommit: headCommit(ctx.repo),
1598
+ hadImplementation: true,
1599
+ };
1600
+ ctx.state.attempt += 1;
1601
+ ctx.state.results = {};
1602
+ ctx.state.milestones = [];
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 };
1606
+ save(ctx);
1607
+ ctx.log(`测试验证发现缺陷,第 ${n} 次退回研发阶段(第 ${ctx.state.attempt} 次尝试)`);
1608
+ const text = `测试验证发现了缺陷,按设置退回研发阶段修复(第 ${n} 次):从方案设计开始只处理这些缺陷,修完经过代码评审后${runPhases(ctx.state).mode === "gated" ? "重新提测" : "再次验证"}。`;
1609
+ await notifyOrigin(ctx, milestoneMessage(text));
1610
+ await notifyExternal(ctx, "milestone", text, { milestone: `handback-${n}` });
1611
+ }
1612
+
1613
+ function usageLines(ctx: PipelineContext): string[] {
1614
+ const thisAttempt = ctx.state.history.filter((h) => h.attempt === ctx.state.attempt);
1615
+ const lines = [`用时与用量(本次尝试,截至报告前):${describeTotals(totals(thisAttempt))}`];
1616
+ if (ctx.state.attempt > 1) lines.push(`用时与用量(这个需求的全部尝试):${describeTotals(totals(ctx.state.history))}`);
1617
+ if (ctx.state.phases && runPhases(ctx.state).mode !== "continuous") {
1618
+ for (const [phase, label] of [["develop", "研发阶段"], ["verify", "测试验证阶段"]] as const) {
1619
+ const records = ctx.state.history.filter((h) => (h.phase ?? "develop") === phase);
1620
+ if (records.length > 0) lines.push(`用时与用量(${label},全部尝试):${describeTotals(totals(records))}`);
1621
+ }
1622
+ }
1623
+ return lines;
1624
+ }
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
+
1659
+ /** Steps 3–11. Loop rounds and optional steps come from the run's profile; the gate from the execution mode. */
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
+ }
1676
+ for (;;) {
1677
+ const design = await developPhase(ctx);
1678
+ const phases = runPhases(ctx.state);
1679
+ if (phaseOf(ctx) !== "verify") {
1680
+ if (phases.mode === "develop-only") {
1681
+ await stopManagedServices(ctx);
1682
+ return runStep(ctx, "report", null, null, {
1683
+ contextLines: [...reportLines(ctx), "只做研发:本次不做测试验证,完成报告按本步骤说明中“只做研发时”一节写成研发报告"],
1684
+ });
1685
+ }
1686
+ if (phases.mode === "gated") {
1687
+ const at = ctx.state.verifyAt ? new Date(ctx.state.verifyAt) : null;
1688
+ if (!at || at.getTime() > Date.now()) throw new PhaseGate();
1689
+ ctx.log("研发完成时已经过了预约的测试验证时间,直接开始测试验证");
1690
+ ctx.state.verifyAt = null;
1691
+ } else {
1692
+ await milestone(ctx, "code-reviewed", "代码已经写完并通过检查,开始按验收标准逐条验证功能。");
1693
+ }
1694
+ ctx.state.phase = "verify";
1695
+ save(ctx);
1696
+ }
1697
+ if (!(await verifyPhase(ctx, design))) break;
1698
+ await handBack(ctx);
1699
+ }
1700
+ await stopManagedServices(ctx);
1701
+ return runStep(ctx, "report", null, null, { contextLines: reportLines(ctx) });
1702
+ }
1703
+
1704
+ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome> {
1705
+ ctx.state.status = "running";
1706
+ ctx.state.stopReason = null;
1707
+ save(ctx);
1708
+ try {
1709
+ await checkQuota(ctx);
1710
+ const report = await flow(ctx);
1711
+ ctx.state.status = "completed";
1712
+ ctx.state.outcome = report.outcome as string;
1713
+ save(ctx);
1714
+ const developOnly = runPhases(ctx.state).mode === "develop-only" && phaseOf(ctx) !== "verify";
1715
+ await notifyOrigin(
1716
+ ctx,
1717
+ completedMessage({
1718
+ featureDir: shownFeatureDir(ctx),
1719
+ outcome: report.outcome as string,
1720
+ summary: report.summary as string,
1721
+ reportPath: `${shownFeatureDir(ctx)}/report.md`,
1722
+ handoffPath: developOnly ? `${shownFeatureDir(ctx)}/handoff.md` : null,
1723
+ }),
1724
+ );
1725
+ const outcomeText: Record<string, string> = {
1726
+ completed: "已完成,可以验收",
1727
+ completed_with_issues: "已完成,但有遗留问题",
1728
+ not_completed: "未完成",
1729
+ };
1730
+ const text = developOnly
1731
+ ? `研发已完成(只做研发):${outcomeText[report.outcome as string] ?? report.outcome},请按提测说明人工测试。`
1732
+ : `开发流水线已结束:${outcomeText[report.outcome as string] ?? report.outcome}。`;
1733
+ await notifyExternal(ctx, "completed", text, {
1734
+ outcome: report.outcome,
1735
+ summary: report.summary,
1736
+ reportPath: `${ctx.featureDir}/report.md`,
1737
+ ...(developOnly ? { mode: "develop-only", handoffPath: `${ctx.featureDir}/handoff.md` } : {}),
1738
+ });
1739
+ return "completed";
1740
+ } catch (error) {
1741
+ if (error instanceof PhaseGate) {
1742
+ ctx.state.status = "phase_done";
1743
+ ctx.state.stopReason = null;
1744
+ save(ctx);
1745
+ await stopManagedServices(ctx);
1746
+ const verifyAt = await scheduleVerifyStart(ctx);
1747
+ const reminderHours = verifyAt ? null : await scheduleReminder(ctx, "phase");
1748
+ await notifyOrigin(
1749
+ ctx,
1750
+ phaseDoneMessage({
1751
+ featureDir: shownFeatureDir(ctx),
1752
+ handoffPath: `${shownFeatureDir(ctx)}/handoff.md`,
1753
+ verifyCommand: verifyCommandOf(ctx),
1754
+ environment: runPhases(ctx.state).environment,
1755
+ verifyAt,
1756
+ reminderHours,
1757
+ handbacks: ctx.state.handbacks ?? 0,
1758
+ }),
1759
+ );
1760
+ await notifyExternal(ctx, "phase_done", `研发已完成,可以提测:提测说明在 ${ctx.featureDir}/handoff.md。${verifyAt ? `已预约在 ${verifyAt} 开始测试验证。` : ""}`, {
1761
+ handoffPath: `${ctx.featureDir}/handoff.md`,
1762
+ mode: runPhases(ctx.state).mode,
1763
+ verifyCommand: verifyCommandOf(ctx),
1764
+ verifyAt: ctx.state.verifyAt ?? null,
1765
+ handbacks: ctx.state.handbacks ?? 0,
1766
+ });
1767
+ return "phase_done";
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
+ }
1795
+ if (error instanceof BusinessDecisionNeeded) {
1796
+ const reproduce = error.source === "reproduce";
1797
+ ctx.state.status = "waiting_for_business";
1798
+ ctx.state.stopReason = `${reproduce ? "没能复现缺陷,需要需求方补充信息" : "方案审核需要需求方决定"}:${error.questions.join(";")}`;
1799
+ save(ctx);
1800
+ const reminderHours = await scheduleReminder(ctx, error.questions);
1801
+ await notifyOrigin(
1802
+ ctx,
1803
+ blockedMessage({ featureDir: shownFeatureDir(ctx), questions: error.questions, resumeCommand: ctx.resumeCommand, reminderHours, source: error.source }),
1804
+ );
1805
+ const external = reproduce
1806
+ ? `没能复现这个缺陷,需要需求方补充 ${error.questions.length} 项信息,流水线已暂停,请到发起的对话中回答。`
1807
+ : `方案审核发现 ${error.questions.length} 个需要需求方决定的问题,流水线已暂停,请到发起的对话中回答。`;
1808
+ await notifyExternal(ctx, "blocked", external, {
1809
+ questions: error.questions,
1810
+ resumeCommand: ctx.resumeCommand,
1811
+ });
1812
+ return "waiting_for_business";
1813
+ }
1814
+ const stepTitle = error instanceof StepFailure ? error.stepTitle : "流水线";
1815
+ ctx.state.status = "failed";
1816
+ ctx.state.stopReason = `${stepTitle}:${(error as Error).message}`;
1817
+ save(ctx);
1818
+ const autoResumeAt =
1819
+ error instanceof StepFailure && isTransientError((error as Error).message) ? await scheduleAutoResume(ctx, (error as Error).message) : null;
1820
+ await notifyOrigin(
1821
+ ctx,
1822
+ failedMessage({
1823
+ stepTitle,
1824
+ reason: (error as Error).message,
1825
+ logPath: ctx.logPath,
1826
+ resumeCommand: ctx.resumeCommand,
1827
+ autoResumeAt,
1828
+ }),
1829
+ );
1830
+ await notifyExternal(ctx, "failed", `开发流水线在「${stepTitle}」步骤中断:${(error as Error).message}`, {
1831
+ stepTitle,
1832
+ reason: (error as Error).message,
1833
+ logPath: ctx.logPath,
1834
+ resumeCommand: ctx.resumeCommand,
1835
+ });
1836
+ return "failed";
1837
+ } finally {
1838
+ await stopManagedServices(ctx);
1839
+ }
1840
+ }