paseo-req-kit 1.7.0-beta.3 → 1.8.0

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