paseo-req-kit 1.11.0 → 1.12.0-beta.2

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 (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/req.mjs +1 -0
  3. package/config.example.json +3 -1
  4. package/contracts/cli-json.md +2 -0
  5. package/contracts/state.md +3 -1
  6. package/contracts/usage-record.md +4 -1
  7. package/docs/adr/0031-fewer-interventions.md +33 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +3 -2
  10. 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 +21 -3
  11. package/engine/prompts/04b-design-revise.md +15 -0
  12. package/engine/src/api.ts +33 -1
  13. package/engine/src/card-cmd.ts +8 -1
  14. package/engine/src/card.ts +9 -2
  15. package/engine/src/config.ts +23 -2
  16. package/engine/src/configure.ts +161 -36
  17. package/engine/src/control.ts +1 -0
  18. package/engine/src/factory.ts +48 -4
  19. package/engine/src/main.ts +1 -0
  20. package/engine/src/paseo-profiles.ts +2 -0
  21. package/engine/src/pipeline.ts +161 -26
  22. package/engine/src/records.ts +8 -0
  23. package/engine/src/state.ts +30 -0
  24. package/engine/src/stats.ts +90 -0
  25. package/engine/src/steps.ts +4 -1
  26. package/install.mjs +5 -1
  27. package/package.json +2 -2
  28. package/paseo-plugin/OVERVIEW.md +2 -0
  29. package/paseo-plugin/client/data.ts +15 -1
  30. package/paseo-plugin/client/models.tsx +117 -0
  31. package/paseo-plugin/client/settings.tsx +3 -1
  32. package/paseo-plugin/index.server.ts +3 -1
  33. package/paseo-plugin/package.json +1 -1
  34. package/paseo-plugin/server/handlers.ts +14 -1
  35. package/paseo-plugin/shared/contracts.ts +37 -0
  36. package/skills/req/playbooks/new.md +14 -14
  37. package/skills/req/playbooks/small.md +3 -3
  38. package/skills/req/playbooks/unattended.md +4 -2
@@ -37,7 +37,7 @@ import { describeExternal, type IntegrationEvent, type Runner, syncIntegration }
37
37
  import { cardSlug, planAppendix, publishCard, readSource } from "./card.js";
38
38
  import { principlesSection, projectAdditions, type PromptLocations, readToolPrompt } from "./prompts.js";
39
39
  import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent, WaitOutcome } from "./runtime.js";
40
- import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
40
+ import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type Recovery, type RunPhases, type StepRecord, type StopKind, writeState } from "./state.js";
41
41
  import { type PanelStep, STEPS, type StepDef, type StepId, stepKey, validateResult, VERIFY_PHASE_STEPS } from "./steps.js";
42
42
 
43
43
  export interface PipelineContext {
@@ -125,7 +125,38 @@ class StepFailure extends Error {
125
125
  }
126
126
 
127
127
  /** The step gave up on its agent while the agent may still be working (1.11). */
128
- class AgentLeftRunning extends StepFailure {}
128
+ class AgentLeftRunning extends StepFailure {
129
+ constructor(
130
+ stepTitle: string,
131
+ message: string,
132
+ readonly timedOut: boolean,
133
+ ) {
134
+ super(stepTitle, message);
135
+ }
136
+ }
137
+
138
+ /** The agent's turn ended with an error status (1.12). */
139
+ class AgentErrored extends StepFailure {}
140
+
141
+ /** The agent did not write a valid result file, even after the reminders (1.12). */
142
+ class ResultInvalid extends StepFailure {}
143
+
144
+ /** Notes something the pipeline handled by itself (state.recoveries, the log). */
145
+ function recover(ctx: PipelineContext, key: string, kind: Recovery["kind"], detail: string): void {
146
+ ctx.state.recoveries = [...(ctx.state.recoveries ?? []), { at: new Date().toISOString(), key, kind, detail: detail.slice(0, 300) }].slice(-200);
147
+ save(ctx);
148
+ ctx.log(`自动处理(${key}):${detail}`);
149
+ }
150
+
151
+ /** Why a run stopped, as a code for the usage record (1.12). */
152
+ function failureKind(error: unknown): StopKind {
153
+ if (error instanceof AgentLeftRunning) return error.timedOut ? "timeout" : "transient";
154
+ const message = (error as Error).message ?? "";
155
+ if (error instanceof StepFailure && isTransientError(message)) return "transient";
156
+ if (error instanceof AgentErrored) return "agent_error";
157
+ if (error instanceof ResultInvalid) return "invalid_result";
158
+ return "other";
159
+ }
129
160
 
130
161
  /**
131
162
  * Errors that usually go away by themselves: provider rate limits and overload,
@@ -487,15 +518,31 @@ async function milestone(ctx: PipelineContext, id: string, text: string): Promis
487
518
  await notifyExternal(ctx, "milestone", text, { milestone: id });
488
519
  }
489
520
 
490
- async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef, agentTitle: string, deadline: number): Promise<AgentUsage | null> {
521
+ /** A step's time limit; `extended` once it was given a second period (selfHeal). */
522
+ interface Deadline {
523
+ at: number;
524
+ extended: boolean;
525
+ key: string;
526
+ }
527
+
528
+ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef, agentTitle: string, deadline: Deadline): Promise<AgentUsage | null> {
491
529
  const notifiedPermissions = new Set<string>();
492
530
  let consecutiveErrors = 0;
493
531
  for (;;) {
494
- const remaining = deadline - Date.now();
532
+ let remaining = deadline.at - Date.now();
533
+ if (remaining <= 0 && ctx.config.selfHeal && !deadline.extended) {
534
+ // Usually a big step, not a stuck one: the agent keeps its progress and gets as long again.
535
+ deadline.extended = true;
536
+ deadline.at = Date.now() + ctx.config.stepTimeoutMinutes[def.id] * 60_000;
537
+ remaining = deadline.at - Date.now();
538
+ recover(ctx, deadline.key, "extend_timeout", `超过 ${ctx.config.stepTimeoutMinutes[def.id]} 分钟仍未完成,agent 还在做,再给它同样长的时间`);
539
+ }
495
540
  if (remaining <= 0) {
541
+ const total = ctx.config.stepTimeoutMinutes[def.id] * (deadline.extended ? 2 : 1);
496
542
  throw new AgentLeftRunning(
497
543
  def.title,
498
- `超过 ${ctx.config.stepTimeoutMinutes[def.id]} 分钟仍未完成。agent「${agentTitle}」还在运行,没有停止它;继续时会先看它的情况:做完了就用它的结果,还在做就接着等它,不会另起一个 agent 重复做。`,
544
+ `超过 ${total} 分钟仍未完成。agent「${agentTitle}」还在运行,没有停止它;继续时会先看它的情况:做完了就用它的结果,还在做就接着等它,不会另起一个 agent 重复做。`,
545
+ true,
499
546
  );
500
547
  }
501
548
  let outcome;
@@ -506,7 +553,7 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
506
553
  consecutiveErrors += 1;
507
554
  ctx.log(`等待 ${agentTitle} 时出错(第 ${consecutiveErrors} 次):${(error as Error).message}`);
508
555
  if (consecutiveErrors >= 5) {
509
- throw new AgentLeftRunning(def.title, `连续 ${consecutiveErrors} 次无法获取 agent 状态:${(error as Error).message}`);
556
+ throw new AgentLeftRunning(def.title, `连续 ${consecutiveErrors} 次无法获取 agent 状态:${(error as Error).message}`, false);
510
557
  }
511
558
  await ctx.sleep(Math.min(60_000, 5_000 * consecutiveErrors));
512
559
  continue;
@@ -517,7 +564,7 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
517
564
  case "timeout":
518
565
  continue;
519
566
  case "error":
520
- throw new StepFailure(def.title, `agent 报告错误:${outcome.error ?? "未知错误"}`);
567
+ throw new AgentErrored(def.title, `agent 报告错误:${outcome.error ?? "未知错误"}`);
521
568
  case "permission": {
522
569
  const fresh = outcome.pendingPermissions.filter((p) => !notifiedPermissions.has(p.id));
523
570
  if (fresh.length > 0) {
@@ -562,31 +609,63 @@ async function runStep(
562
609
  extras: StepExtras = {},
563
610
  ): Promise<Record<string, unknown>> {
564
611
  const { attempts, backoffSeconds } = ctx.config.retry;
612
+ const key = extras.key ?? keyFor(stepKey(id, round), extras.target);
613
+ const fallback = ctx.config.agents.fallback ?? null;
614
+ let redone = false;
615
+ let usedFallback = false;
565
616
  for (let retry = 0; ; retry += 1) {
566
617
  try {
567
618
  return await runStepOnce(ctx, id, round, maxRounds, extras);
568
619
  } catch (error) {
569
620
  const message = (error as Error).message;
570
- if (!(error instanceof StepFailure) || !isTransientError(message) || retry >= attempts) {
621
+ const transient = error instanceof StepFailure && isTransientError(message);
622
+ const healable = error instanceof AgentErrored || error instanceof ResultInvalid;
623
+ // 1.12: a step whose agent errored or wrote no valid result is redone once with a new
624
+ // agent; one that keeps hitting rate limits gets the fallback model, when there is one.
625
+ if (error instanceof StepFailure && ctx.config.selfHeal && !transient && healable && !redone) {
626
+ redone = true;
627
+ const switchModel = fallback !== null && !usedFallback && error instanceof AgentErrored;
628
+ if (switchModel) usedFallback = true;
629
+ recover(
630
+ ctx,
631
+ key,
632
+ switchModel ? "fallback" : "redo",
633
+ `${message.slice(0, 160)};${switchModel ? "换备用模型" : "新开一个 agent"}重做这一步`,
634
+ );
635
+ extras = withRetryNote({ ...extras, ...(switchModel ? { profile: fallback } : {}) }, message);
636
+ continue;
637
+ }
638
+ if (error instanceof StepFailure && transient && retry >= attempts && fallback !== null && !usedFallback && !(error instanceof AgentLeftRunning)) {
639
+ usedFallback = true;
640
+ recover(ctx, key, "fallback", `重试 ${retry} 次后仍然${message.slice(0, 120)};换备用模型继续这一步`);
641
+ extras = withRetryNote({ ...extras, profile: fallback }, message);
642
+ continue;
643
+ }
644
+ if (!(error instanceof StepFailure) || !transient || retry >= attempts) {
571
645
  if (error instanceof StepFailure && retry > 0) {
572
- throw new StepFailure(error.stepTitle, `${message}(已自动重试 ${retry} 次)`);
646
+ throw new (error.constructor as typeof StepFailure)(error.stepTitle, `${message}(已自动重试 ${retry} 次)`);
573
647
  }
574
648
  throw error;
575
649
  }
576
650
  const wait = backoffSeconds.at(Math.min(retry, backoffSeconds.length - 1)) ?? 60;
577
- ctx.log(`${error.stepTitle} 遇到临时错误:${message}。${wait} 秒后自动重试(第 ${retry + 1} 次,最多 ${attempts} 次)`);
651
+ recover(ctx, key, "retry", `遇到临时错误:${message.slice(0, 160)}。${wait} 秒后自动重试(第 ${retry + 1} 次,最多 ${attempts} 次)`);
578
652
  await ctx.sleep(wait * 1000);
579
- extras = {
580
- ...extras,
581
- contextLines: [
582
- ...(extras.contextLines ?? []).filter((l) => !l.startsWith("自动重试:")),
583
- `自动重试:本步骤上一次因临时错误中断(${message.slice(0, 200)})。工作区里可能留有上一次没做完的改动,先用 git status 和 git diff 查看,在此基础上继续,不要重复提交`,
584
- ],
585
- };
653
+ extras = withRetryNote(extras, message);
586
654
  }
587
655
  }
588
656
  }
589
657
 
658
+ /** Tells the next agent of a step that an earlier one stopped part way. */
659
+ function withRetryNote(extras: StepExtras, message: string): StepExtras {
660
+ return {
661
+ ...extras,
662
+ contextLines: [
663
+ ...(extras.contextLines ?? []).filter((l) => !l.startsWith("自动重试:")),
664
+ `自动重试:本步骤上一次中断了(${message.slice(0, 200)})。工作区里可能留有上一次没做完的改动,先用 git status 和 git diff 查看,在此基础上继续,不要重复提交`,
665
+ ],
666
+ };
667
+ }
668
+
590
669
  async function runStepOnce(
591
670
  ctx: PipelineContext,
592
671
  id: StepId,
@@ -671,7 +750,7 @@ async function runStepOnce(
671
750
  ctx.state.history.push(record);
672
751
  save(ctx);
673
752
 
674
- const deadline = Date.now() + ctx.config.stepTimeoutMinutes[id] * 60_000;
753
+ const deadline: Deadline = { at: Date.now() + ctx.config.stepTimeoutMinutes[id] * 60_000, extended: false, key };
675
754
  record.usage = (await waitForTurn(ctx, agent, def, agentTitle, deadline)) ?? record.usage ?? null;
676
755
  let validation = readResultFile(resultAbs, def);
677
756
  while (!validation.ok && record.followUps < ctx.config.resultFollowUps) {
@@ -683,7 +762,7 @@ async function runStepOnce(
683
762
  validation = readResultFile(resultAbs, def);
684
763
  }
685
764
  if (!validation.ok) {
686
- throw new StepFailure(def.title, `提醒 ${record.followUps} 次后仍没有得到有效的结果文件:${validation.error}`);
765
+ throw new ResultInvalid(def.title, `提醒 ${record.followUps} 次后仍没有得到有效的结果文件:${validation.error}`);
687
766
  }
688
767
 
689
768
  if (extras.gate) {
@@ -761,7 +840,7 @@ async function checkLeftover(
761
840
  last.result = validation.value;
762
841
  ctx.state.results[key] = validation.value;
763
842
  save(ctx);
764
- ctx.log(`${key} 上一次的 agent ${last.agentId} 已经做完,沿用它的结果:${JSON.stringify(validation.value)}`);
843
+ recover(ctx, key, "reuse_result", `上一次的 agent ${last.agentId} 已经做完,沿用它的结果`);
765
844
  if (ctx.config.archiveStepAgents) await agent.archive().catch(() => undefined);
766
845
  return { done: validation.value };
767
846
  }
@@ -769,7 +848,10 @@ async function checkLeftover(
769
848
  return {};
770
849
  }
771
850
  if (status === "timeout" || status === "permission") {
772
- if ((last.reattached ?? 0) < 1) return { agent, reattached: (last.reattached ?? 0) + 1 };
851
+ if ((last.reattached ?? 0) < 1) {
852
+ recover(ctx, key, "reattach", `上一次的 agent ${last.agentId} 还在做,接着等它,不另起一个`);
853
+ return { agent, reattached: (last.reattached ?? 0) + 1 };
854
+ }
773
855
  ctx.log(`${key} 上一次的 agent ${last.agentId} 已经等过两轮仍未完成,停止它并新建一个 agent`);
774
856
  await ctx.runtime
775
857
  .interruptAgent(last.agentId, "这一步已超时,流水线会另起一个 agent 重做。请立即停止当前工作,不要再修改任何文件,也不要写结果文件,直接结束。")
@@ -1023,6 +1105,26 @@ async function scheduleApprovalContinue(ctx: PipelineContext): Promise<string |
1023
1105
  }
1024
1106
 
1025
1107
  /** A Paseo schedule that has the chat continue the run later; returns the time as text, or null. */
1108
+ /** Rate limits and used-up subscriptions, as opposed to network trouble. */
1109
+ const QUOTA_ERROR = /rate.?limit|too many requests|\b429\b|quota|usage limit|额度|限流/i;
1110
+
1111
+ /**
1112
+ * 1.12: when a step stopped on rate limits and Paseo says when a used-up subscription window
1113
+ * resets, the time to continue (5 minutes after the reset, within 24 hours); null otherwise.
1114
+ */
1115
+ async function quotaResetTime(ctx: PipelineContext, reason: string): Promise<Date | null> {
1116
+ if (!QUOTA_ERROR.test(reason)) return null;
1117
+ const profiles = [ctx.config.agents.builder, ctx.config.agents.reviewer, ...(ctx.config.agents.fallback ? [ctx.config.agents.fallback] : [])];
1118
+ const providers = [...new Set(profiles.map((p) => (p.provider ?? "").split("/")[0]).filter(Boolean))];
1119
+ const quotas = await ctx.runtime.providerQuota(providers).catch(() => null);
1120
+ const now = Date.now();
1121
+ const resets = (quotas ?? [])
1122
+ .filter((q) => q.resetsAt && ((q.usedPct !== null && q.usedPct >= 95) || q.tone === "danger"))
1123
+ .map((q) => new Date(q.resetsAt!).getTime())
1124
+ .filter((t) => t > now && t < now + 24 * 3600_000);
1125
+ return resets.length === 0 ? null : new Date(Math.max(...resets) + 5 * 60_000);
1126
+ }
1127
+
1026
1128
  async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise<string | null> {
1027
1129
  const minutes = ctx.config.autoResumeMinutes;
1028
1130
  const origin = ctx.state.originAgentId;
@@ -1032,7 +1134,8 @@ async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise
1032
1134
  ctx.log(`这次尝试已经自动继续过 ${used} 次,不再预约`);
1033
1135
  return null;
1034
1136
  }
1035
- const at = new Date(Date.now() + minutes * 60_000);
1137
+ const reset = await quotaResetTime(ctx, reason);
1138
+ const at = reset ?? new Date(Date.now() + minutes * 60_000);
1036
1139
  try {
1037
1140
  const id = await ctx.runtime.scheduleMessage({
1038
1141
  agentId: origin,
@@ -1049,7 +1152,9 @@ async function scheduleAutoResume(ctx: PipelineContext, reason: string): Promise
1049
1152
  ctx.state.schedules = [...(ctx.state.schedules ?? []), { id, kind: "auto-resume", at: at.toISOString() }];
1050
1153
  ctx.state.autoResumes = { attempt: ctx.state.attempt, count: used + 1 };
1051
1154
  save(ctx);
1052
- ctx.log(`已预约 ${minutes} 分钟后自动继续(Paseo 定时任务 ${id})`);
1155
+ const when = at.toLocaleString("zh-CN", { hour12: false });
1156
+ if (reset) recover(ctx, "pipeline", "quota_wait", `额度用完,Paseo 显示会恢复;已预约在 ${when} 自动继续(Paseo 定时任务 ${id})`);
1157
+ else recover(ctx, "pipeline", "auto_resume", `已预约 ${minutes} 分钟后自动继续(Paseo 定时任务 ${id})`);
1053
1158
  return at.toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
1054
1159
  } catch (error) {
1055
1160
  ctx.log(`预约自动继续失败:${(error as Error).message}`);
@@ -1080,7 +1185,7 @@ async function checkQuota(ctx: PipelineContext): Promise<void> {
1080
1185
  [
1081
1186
  "【需求开发流水线通知】这条消息来自后台流水线,不是用户发的。",
1082
1187
  `额度提醒:${items.join(";")}。`,
1083
- "流水线照常进行;额度用完时会遇到限流,流水线会自动等待重试,重试用完会中断,之后可以继续。如果希望等额度恢复后再开发,请转告用户,用户同意的话可以先暂停。",
1188
+ `流水线照常进行;额度用完时会遇到限流,流水线会先重试${ctx.config.agents.fallback ? "、再换备用模型" : ""},仍不行就预约在额度恢复后自动继续,不需要人处理。如果希望等额度恢复后再开发,请转告用户,用户同意的话可以先暂停,再预约在恢复时间开始。`,
1084
1189
  ].join("\n"),
1085
1190
  );
1086
1191
  }
@@ -1567,11 +1672,33 @@ async function assessmentPhase(ctx: PipelineContext): Promise<void> {
1567
1672
  for (let r = 1; r <= rounds.designReview; r++) {
1568
1673
  const review = await runReview(ctx, "design_review", r, rounds.designReview);
1569
1674
  if (review.verdict === "approved") break;
1570
- if (review.verdict === "blocked") throw new BusinessDecisionNeeded(review.questions as string[]);
1675
+ if (review.verdict === "blocked") {
1676
+ await answerFirst(ctx, review, r, rounds.designReview);
1677
+ continue;
1678
+ }
1571
1679
  await runStep(ctx, "design_revise", r, rounds.designReview);
1572
1680
  }
1573
1681
  }
1574
1682
 
1683
+ /**
1684
+ * The review says only the requester can decide (1.12): the builder first answers what the
1685
+ * code, the documents and the spec settle, in a revision of the design; what is left goes to
1686
+ * the requester, each with a recommended answer.
1687
+ */
1688
+ async function answerFirst(ctx: PipelineContext, review: Record<string, unknown>, round: number, maxRounds: number): Promise<void> {
1689
+ const questions = (review.questions as string[]) ?? [];
1690
+ if (!ctx.config.answerQuestionsFirst) throw new BusinessDecisionNeeded(questions);
1691
+ const revised = await runStep(ctx, "design_revise", round, maxRounds, {
1692
+ contextLines: [`审核结论:需要需求方决定(${questions.length} 个问题,见 design-review.md)。按本步骤说明中"审核要求需求方决定时"一节,先自己找答案`],
1693
+ });
1694
+ const left = Array.isArray(revised.business_questions) ? (revised.business_questions as string[]).map((q) => q.trim()).filter(Boolean) : [];
1695
+ const answered = Math.max(0, questions.length - left.length);
1696
+ if (answered > 0) {
1697
+ recover(ctx, `design_revise-r${round}`, "self_answer", `方案审核提出的 ${questions.length} 个问题中,${answered} 个由 builder 从代码和文档中确定了答案${left.length > 0 ? `,${left.length} 个交给需求方` : ",不用等需求方"}`);
1698
+ }
1699
+ if (left.length > 0) throw new BusinessDecisionNeeded(left);
1700
+ }
1701
+
1575
1702
  /** Develop phase: design → design review ⇄ revise → implement → code review ⇄ fix → handoff. Returns the design result. */
1576
1703
  async function developPhase(ctx: PipelineContext): Promise<Record<string, unknown>> {
1577
1704
  const rounds = ctx.config.profiles[ctx.state.profile].rounds;
@@ -1602,7 +1729,10 @@ async function developPhase(ctx: PipelineContext): Promise<Record<string, unknow
1602
1729
  const review = await runReview(ctx, "design_review", r, rounds.designReview);
1603
1730
  designRounds = r;
1604
1731
  if (review.verdict === "approved") break;
1605
- if (review.verdict === "blocked") throw new BusinessDecisionNeeded(review.questions as string[]);
1732
+ if (review.verdict === "blocked") {
1733
+ await answerFirst(ctx, review, r, rounds.designReview);
1734
+ continue;
1735
+ }
1606
1736
  // changes_required. The last round's revision goes straight to implementation;
1607
1737
  // the implementer reads both the review and the revision record.
1608
1738
  await runStep(ctx, "design_revise", r, rounds.designReview);
@@ -1876,6 +2006,7 @@ async function flow(ctx: PipelineContext): Promise<Record<string, unknown>> {
1876
2006
  export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome> {
1877
2007
  ctx.state.status = "running";
1878
2008
  ctx.state.stopReason = null;
2009
+ ctx.state.stopKind = null;
1879
2010
  save(ctx);
1880
2011
  await integrate(ctx, phaseOf(ctx) === "verify" ? "verify" : "started");
1881
2012
  try {
@@ -1917,6 +2048,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1917
2048
  if (error instanceof PhaseGate) {
1918
2049
  ctx.state.status = "phase_done";
1919
2050
  ctx.state.stopReason = null;
2051
+ ctx.state.stopKind = "phase_gate";
1920
2052
  save(ctx);
1921
2053
  await stopManagedServices(ctx);
1922
2054
  const verifyAt = await scheduleVerifyStart(ctx);
@@ -1947,6 +2079,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1947
2079
  if (error instanceof ApprovalNeeded) {
1948
2080
  ctx.state.status = "waiting_for_approval";
1949
2081
  ctx.state.stopReason = null;
2082
+ ctx.state.stopKind = "approval";
1950
2083
  save(ctx);
1951
2084
  const autoContinueAt = await scheduleApprovalContinue(ctx);
1952
2085
  const reminderHours = await scheduleReminder(ctx, "approval");
@@ -1974,6 +2107,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1974
2107
  if (error instanceof BusinessDecisionNeeded) {
1975
2108
  const reproduce = error.source === "reproduce";
1976
2109
  ctx.state.status = "waiting_for_business";
2110
+ ctx.state.stopKind = reproduce ? "reproduce" : "business";
1977
2111
  ctx.state.stopReason = `${reproduce ? "没能复现缺陷,需要需求方补充信息" : "方案审核需要需求方决定"}:${error.questions.join(";")}`;
1978
2112
  save(ctx);
1979
2113
  const reminderHours = await scheduleReminder(ctx, error.questions);
@@ -1994,6 +2128,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1994
2128
  const stepTitle = error instanceof StepFailure ? error.stepTitle : "流水线";
1995
2129
  ctx.state.status = "failed";
1996
2130
  ctx.state.stopReason = `${stepTitle}:${(error as Error).message}`;
2131
+ ctx.state.stopKind = failureKind(error);
1997
2132
  save(ctx);
1998
2133
  const autoResumeAt =
1999
2134
  error instanceof StepFailure && isTransientError((error as Error).message) ? await scheduleAutoResume(ctx, (error as Error).message) : null;
@@ -165,6 +165,10 @@ export interface RunRecord {
165
165
  /** The report's verdict when the run completed (completed, completed_with_issues, not_completed). */
166
166
  outcome: string | null;
167
167
  stopReason: string | null;
168
+ /** 1.12: why the run stopped for a person, as a code (absent in older records). */
169
+ stopKind?: string | null;
170
+ /** 1.12: what the pipeline handled by itself during the run, by kind (absent in older records). */
171
+ recoveries?: Record<string, number>;
168
172
  };
169
173
  agents: { builder: string | null; reviewer: string | null };
170
174
  steps: StepUsageRecord[];
@@ -241,6 +245,10 @@ export function runRecord(input: {
241
245
  status: state.status,
242
246
  outcome: state.status === "completed" ? state.outcome : null,
243
247
  stopReason: state.stopReason,
248
+ stopKind: state.status === "completed" || state.status === "running" ? null : (state.stopKind ?? null),
249
+ recoveries: (state.recoveries ?? [])
250
+ .filter((r) => new Date(r.at).getTime() >= since)
251
+ .reduce<Record<string, number>>((m, r) => ({ ...m, [r.kind]: (m[r.kind] ?? 0) + 1 }), {}),
244
252
  },
245
253
  agents: input.agents,
246
254
  steps,
@@ -27,6 +27,32 @@ export interface RunPhases {
27
27
  defects: DefectHandling;
28
28
  }
29
29
 
30
+ /**
31
+ * Why a run stopped for a person (1.12). Necessary decisions: approval (the plan), business
32
+ * (a question only the requester can answer), reproduce (a defect that could not be reproduced),
33
+ * phase_gate (development done, waiting for testing). The rest are interruptions.
34
+ */
35
+ export type StopKind =
36
+ | "approval"
37
+ | "business"
38
+ | "reproduce"
39
+ | "phase_gate"
40
+ | "timeout"
41
+ | "agent_error"
42
+ | "invalid_result"
43
+ | "transient"
44
+ | "stopped"
45
+ | "process_exit"
46
+ | "other";
47
+
48
+ /** Something the pipeline handled by itself (1.12). */
49
+ export interface Recovery {
50
+ at: string;
51
+ key: string;
52
+ kind: "retry" | "reattach" | "reuse_result" | "extend_timeout" | "redo" | "fallback" | "auto_resume" | "quota_wait" | "self_answer";
53
+ detail: string;
54
+ }
55
+
30
56
  export interface StepRecord {
31
57
  key: string;
32
58
  stepId: StepId;
@@ -69,6 +95,10 @@ export interface PipelineState {
69
95
  results: Record<string, Record<string, unknown>>;
70
96
  history: StepRecord[];
71
97
  stopReason: string | null;
98
+ /** 1.12: why the run stopped for a person, as a code (contracts/usage-record.md); null while running or done. */
99
+ stopKind?: StopKind | null;
100
+ /** 1.12: what the pipeline handled by itself instead of stopping (retries, waiting longer, a fallback model…). */
101
+ recoveries?: Recovery[];
72
102
  outcome: string | null;
73
103
  /** "quick" or "standard"; decides loop rounds and optional steps. */
74
104
  profile: ProfileName;
@@ -109,6 +109,35 @@ function waitKind(status: string): WaitKind {
109
109
  return "other";
110
110
  }
111
111
 
112
+ /** Why runs stopped for a person (1.12, state.stopKind). */
113
+ export const STOP_LABELS: Record<string, string> = {
114
+ approval: "确认方案",
115
+ phase_gate: "研发完成,等待测试",
116
+ business: "方案审核的问题等需求方决定",
117
+ reproduce: "缺陷复现不了,等需求方补充",
118
+ timeout: "步骤超时",
119
+ agent_error: "agent 报错",
120
+ invalid_result: "结果文件无效",
121
+ transient: "限流、网络等临时错误(自动重试用完)",
122
+ stopped: "被手动停止",
123
+ process_exit: "后台进程意外退出",
124
+ other: "其他中断",
125
+ };
126
+
127
+ /** Stops that are the decisions people are meant to make; the rest are what 1.12 works to remove. */
128
+ export const NECESSARY_STOPS = ["approval", "phase_gate"];
129
+
130
+ /** The stop code of a run; older records (before 1.12) are inferred from the status. */
131
+ export function stopKindOf(r: RunRecord): string | null {
132
+ if (r.run.stopKind !== undefined) return r.run.stopKind;
133
+ if (r.run.status === "waiting_for_approval") return "approval";
134
+ if (r.run.status === "waiting_for_business") return "business";
135
+ if (r.run.status === "phase_done") return "phase_gate";
136
+ if (r.run.status === "failed") return /手动停止|被 SIG/.test(r.run.stopReason ?? "") ? "stopped" : "other";
137
+ if (r.run.status === "running") return "process_exit";
138
+ return null;
139
+ }
140
+
112
141
  /** Records of the same run seen twice (merged exports) count once. */
113
142
  export function dedupe(runs: RunRecord[]): RunRecord[] {
114
143
  const seen = new Map<string, RunRecord>();
@@ -208,6 +237,19 @@ export interface StatsSummary {
208
237
  waits: Array<{ kind: WaitKind; label: string; requirements: number; averageMinutes: number }>;
209
238
  usage: { agentMinutes: number; inputTokens: number | null; outputTokens: number | null; costUsd: number | null };
210
239
  byBuilder: Array<{ builder: string; requirements: number; averageReviewRounds: number | null; averageCostUsd: number | null }>;
240
+ /** 1.12: why the period's runs stopped for a person, most frequent first. */
241
+ stops: Array<{ kind: string; label: string; necessary: boolean; runs: number }>;
242
+ /** 1.12: how much went without a person. */
243
+ automation: {
244
+ /** Of the requirements that reached testing (or completion) in the period, the share with no stop other than the necessary ones. */
245
+ unattendedShare: number | null;
246
+ /** Problems the pipeline handled by itself in the period's runs, by kind. */
247
+ recovered: Record<string, number>;
248
+ /** Runs that ended in an interruption (not a necessary decision). */
249
+ interrupted: number;
250
+ /** recovered / (recovered + interrupted). */
251
+ autoRecoveryShare: number | null;
252
+ };
211
253
  /** Requirements that started from cards, by who asked for them (1.10); empty when there are none. */
212
254
  byRequester: Array<{ requester: string; requirements: number; completed: number; medianCycleHours: number | null }>;
213
255
  requirements: RequirementStats[];
@@ -257,6 +299,24 @@ export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
257
299
  const requesters = new Map<string, RequirementStats[]>();
258
300
  for (const q of active) if (q.card) requesters.set(q.requester ?? "(未记录)", [...(requesters.get(q.requester ?? "(未记录)") ?? []), q]);
259
301
 
302
+ const stopCounts = count(periodRuns.flatMap((r) => (stopKindOf(r) ? [stopKindOf(r)!] : [])));
303
+ const recovered: Record<string, number> = {};
304
+ for (const r of periodRuns) for (const [k, v] of Object.entries(r.run.recoveries ?? {})) recovered[k] = (recovered[k] ?? 0) + v;
305
+ const recoveredTotal = Object.values(recovered).reduce((a, b) => a + b, 0);
306
+ const interrupted = periodRuns.filter((r) => {
307
+ const k = stopKindOf(r);
308
+ return k !== null && !NECESSARY_STOPS.includes(k) && k !== "business" && k !== "reproduce";
309
+ }).length;
310
+ const reachedTesting = active.filter((q) => q.status === "completed" || q.status === "phase_done");
311
+ const unattended = reachedTesting.filter((q) =>
312
+ runs
313
+ .filter((r) => r.requirement.key === q.key && r.requirement.project === q.project)
314
+ .every((r) => {
315
+ const k = stopKindOf(r);
316
+ return k === null || NECESSARY_STOPS.includes(k);
317
+ }),
318
+ );
319
+
260
320
  return {
261
321
  schema: 1,
262
322
  period: { from: period.from?.toISOString() ?? null, to: period.to?.toISOString() ?? null, label: period.label },
@@ -287,6 +347,15 @@ export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
287
347
  averageReviewRounds: avg(qs.map((q) => q.reviewRounds)),
288
348
  averageCostUsd: avg(qs.flatMap((q) => (q.costUsd === null ? [] : [q.costUsd]))),
289
349
  })),
350
+ stops: Object.entries(stopCounts)
351
+ .map(([kind, n]) => ({ kind, label: STOP_LABELS[kind] ?? kind, necessary: NECESSARY_STOPS.includes(kind), runs: n }))
352
+ .sort((a, b) => b.runs - a.runs || a.kind.localeCompare(b.kind)),
353
+ automation: {
354
+ unattendedShare: reachedTesting.length === 0 ? null : Math.round((unattended.length / reachedTesting.length) * 100) / 100,
355
+ recovered,
356
+ interrupted,
357
+ autoRecoveryShare: recoveredTotal + interrupted === 0 ? null : Math.round((recoveredTotal / (recoveredTotal + interrupted)) * 100) / 100,
358
+ },
290
359
  byRequester: [...requesters.entries()]
291
360
  .map(([requester, qs]) => {
292
361
  const finished = qs.filter((q) => done.includes(q));
@@ -349,6 +418,27 @@ export function describeStats(s: StatsSummary): string {
349
418
  `- 需要缺陷修复的需求占比:${s.rework.withDefectRounds === null ? "无数据" : `${Math.round(s.rework.withDefectRounds * 100)}%`}`,
350
419
  `- 质量门:运行 ${s.rework.gateRuns} 次,其中发现新失败 ${s.rework.gateFailed} 次`,
351
420
  );
421
+ const RECOVERY_LABELS: Record<string, string> = {
422
+ retry: "临时错误重试",
423
+ reattach: "接着等还在做的 agent",
424
+ reuse_result: "沿用旧 agent 的结果",
425
+ extend_timeout: "超时后多等一轮",
426
+ redo: "新开 agent 重做",
427
+ fallback: "换备用模型",
428
+ auto_resume: "自动继续",
429
+ quota_wait: "等额度恢复后继续",
430
+ };
431
+ lines.push(
432
+ "",
433
+ "## 人工介入",
434
+ "",
435
+ `- 到达提测(或完成)的需求中,除了确认方案外没有停下等人的:${s.automation.unattendedShare === null ? "无数据" : `${Math.round(s.automation.unattendedShare * 100)}%`}`,
436
+ `- 自动处理的问题 ${Object.values(s.automation.recovered).reduce((a, b) => a + b, 0)} 次(${list(s.automation.recovered, RECOVERY_LABELS)}),中断等人 ${s.automation.interrupted} 次${s.automation.autoRecoveryShare === null ? "" : `,自动处理的比例 ${Math.round(s.automation.autoRecoveryShare * 100)}%`}`,
437
+ );
438
+ if (s.stops.length > 0) {
439
+ lines.push("", "| 停下等人的原因 | 次数 | 必要的决定 |", "| --- | --- | --- |");
440
+ for (const st of s.stops) lines.push(`| ${st.label} | ${st.runs} | ${st.necessary ? "是" : "否"} |`);
441
+ }
352
442
  if (s.waits.length > 0) {
353
443
  lines.push("", "## 等待", "", "| 等待 | 需求数 | 平均 |", "| --- | --- | --- |");
354
444
  for (const w of s.waits) lines.push(`| ${w.label} | ${w.requirements} | ${hours(w.averageMinutes)} |`);
@@ -132,7 +132,10 @@ export const STEPS: Record<StepId, StepDef> = {
132
132
  title: "方案修订",
133
133
  role: "builder",
134
134
  promptFile: "04b-design-revise.md",
135
- result: { required: done.required, optional: { slices: { kind: "string[]" }, files: { kind: "string[]" }, ui_change: { kind: "boolean" } } },
135
+ result: {
136
+ required: done.required,
137
+ optional: { slices: { kind: "string[]" }, files: { kind: "string[]" }, ui_change: { kind: "boolean" }, business_questions: { kind: "string[]" } },
138
+ },
136
139
  },
137
140
  implement: {
138
141
  id: "implement",
package/install.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  // node install.mjs --prune list old version directories that can be deleted
10
10
  // node install.mjs --uninstall remove the skills req-kit installed (keeps ~/.req-kit)
11
11
  // node install.mjs --configure configuration wizard: pick the agents from what Paseo offers
12
+ // node install.mjs --configure --auto the same without questions: the recommended agents (1.12)
12
13
  // node install.mjs --upgrade [version] download a version (default: the newest) next to this one,
13
14
  // install its dependencies and switch to it
14
15
  // node install.mjs --latest print the newest published version
@@ -47,6 +48,9 @@ const { values, positionals } = parseArgs({
47
48
  allowPositionals: true,
48
49
  options: {
49
50
  configure: { type: "boolean", default: false },
51
+ auto: { type: "boolean", default: false },
52
+ "catalog-json": { type: "boolean", default: false },
53
+ set: { type: "string" },
50
54
  upgrade: { type: "boolean", default: false },
51
55
  latest: { type: "boolean", default: false },
52
56
  from: { type: "string" },
@@ -844,7 +848,7 @@ function configureCommand() {
844
848
  console.error(`依赖未安装:在 ${KIT_ROOT} 中执行 npm install 后再运行配置向导。`);
845
849
  return 1;
846
850
  }
847
- const result = spawnSync(process.execPath, [tsx, path.join(KIT_ROOT, "engine", "src", "configure.ts"), "--config", CONFIG], {
851
+ const result = spawnSync(process.execPath, [tsx, path.join(KIT_ROOT, "engine", "src", "configure.ts"), "--config", CONFIG, ...(values.auto ? ["--auto"] : []), ...(values["catalog-json"] ? ["--catalog-json"] : []), ...(values.set !== undefined ? ["--set", values.set] : [])], {
848
852
  stdio: "inherit",
849
853
  windowsHide: true,
850
854
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-req-kit",
3
- "version": "1.11.0",
3
+ "version": "1.12.0-beta.2",
4
4
  "description": "req-kit: a requirement-development workflow for Paseo. /req aligns a requirement in chat; the engine runs design, implementation, review and verification as Paseo agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "scripts": {
33
33
  "typecheck": "tsc --noEmit",
34
- "test": "node --import tsx --import ./engine/test/isolate.ts --test --test-concurrency=1 engine/test/*.test.ts test/*.test.ts",
34
+ "test": "node --import tsx --import ./engine/test/isolate.ts --test --test-concurrency=1 --test-timeout=300000 engine/test/*.test.ts test/*.test.ts",
35
35
  "smoke": "tsx engine/src/smoke-test.ts",
36
36
  "doctor": "node bin/req-doctor.mjs",
37
37
  "eval": "tsx engine/src/eval.ts",
@@ -2,6 +2,8 @@ req-kit 的需求在 Paseo 里的样子:本机所有项目的需求一页看
2
2
 
3
3
  需要人处理的时候,可以直接在这里确认方案、停止、继续或开始测试验证。这些操作和在终端运行 `req approve`、`req stop`、`req resume`、`req verify` 完全一样,流水线本身不受插件影响;不想让人在网页上操作时,在插件设置中打开"只读"。
4
4
 
5
+ 在插件设置的"模型"一页,可以从 Paseo 中已登录的 agent 里选择 builder、reviewer 和备用模型,保存到 req-kit 的本机配置,和在终端运行 `req configure` 一样。
6
+
5
7
  还有:侧边栏的"需求"(带待处理数)、每个工作区头部的"需求"按钮和面板、步骤 agent 输入框旁的"需求"按钮,以及使用统计(交付周期、返工、等待、用量)。
6
8
 
7
9
  需要这台 daemon 上安装 req-kit 1.9 或更新的版本,插件通过本机的 `req api` 读取需求;它不连接任何外部服务。用 `req plugin install` 安装和更新。
@@ -1,7 +1,7 @@
1
1
  // Data hooks: every read is an RPC to the daemon side (req api), cached by React Query.
2
2
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
3
  import { useRpc, useSettings } from "@getpaseo/plugin/client";
4
- import { fileRpc, listRpc, preferences, runRpc, statsRpc, statusRpc, versionRpc, type Action } from "../shared/contracts";
4
+ import { fileRpc, listRpc, modelsRpc, preferences, runRpc, setAgentsRpc, statsRpc, statusRpc, versionRpc, type Action, type AgentProfileData } from "../shared/contracts";
5
5
 
6
6
  export function usePreferences(): { readOnly: boolean; refreshMs: number } {
7
7
  const settings = useSettings(preferences);
@@ -53,3 +53,17 @@ export function useAction() {
53
53
  onSettled: () => queries.invalidateQueries({ queryKey: ["req"] }),
54
54
  });
55
55
  }
56
+
57
+ export function useModels() {
58
+ const models = useRpc(modelsRpc);
59
+ return useQuery({ queryKey: ["req", "models"], queryFn: () => models({}), retry: false });
60
+ }
61
+
62
+ export function useSetAgents() {
63
+ const set = useRpc(setAgentsRpc);
64
+ const queries = useQueryClient();
65
+ return useMutation({
66
+ mutationFn: (input: { builder: AgentProfileData; reviewer: AgentProfileData; fallback: AgentProfileData | null }) => set(input),
67
+ onSettled: () => queries.invalidateQueries({ queryKey: ["req", "models"] }),
68
+ });
69
+ }