paseo-req-kit 1.13.0-beta.3 → 1.13.0-beta.4

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 (109) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/contracts/cli-json.md +3 -1
  3. package/contracts/state.md +3 -1
  4. package/contracts/usage-record.md +2 -0
  5. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +4 -1
  6. 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 +15 -2
  7. package/engine/src/api.ts +29 -5
  8. package/engine/src/card-cmd.ts +2 -0
  9. package/engine/src/config-view.ts +5 -0
  10. package/engine/src/config.ts +13 -0
  11. package/engine/src/estimate.ts +95 -0
  12. package/engine/src/factory.ts +13 -30
  13. package/engine/src/launch.ts +5 -0
  14. package/engine/src/main.ts +5 -1
  15. package/engine/src/pipeline.ts +89 -1
  16. package/engine/src/records.ts +6 -0
  17. package/engine/src/state.ts +15 -0
  18. package/engine/src/stats.ts +83 -0
  19. package/engine/src/watchdog.ts +105 -0
  20. package/package.json +1 -1
  21. package/paseo-plugin/client/detail.tsx +10 -1
  22. package/paseo-plugin/client/format.ts +1 -0
  23. package/paseo-plugin/client/inbox.tsx +7 -4
  24. package/paseo-plugin/client/overview.tsx +1 -1
  25. package/paseo-plugin/client/stats.tsx +42 -0
  26. package/paseo-plugin/index.server.ts +16 -0
  27. package/paseo-plugin/package.json +1 -1
  28. package/paseo-plugin/shared/contracts.ts +23 -1
  29. package/site-dist/404.html +2 -2
  30. package/site-dist/assets/{app.8viPUf6o.js → app.n_bkEV-X.js} +1 -1
  31. package/site-dist/assets/changelog.md.RqcFias_.js +1 -0
  32. package/site-dist/assets/{changelog.md.fsAikR0t.lean.js → changelog.md.RqcFias_.lean.js} +1 -1
  33. package/site-dist/assets/chunks/@localSearchIndexroot.DQEikTmr.js +1 -0
  34. package/site-dist/assets/chunks/{VPLocalSearchBox.C-itfeI3.js → VPLocalSearchBox.B54kK3Ge.js} +1 -1
  35. package/site-dist/assets/chunks/{theme.CmA1-Qwc.js → theme.BH3XHrL3.js} +2 -2
  36. package/site-dist/assets/{guide_getting-started.md.DU5hdLl6.js → guide_getting-started.md.Di4587Bu.js} +1 -1
  37. package/site-dist/assets/{guide_project.md.Cc6JFJ5R.js → guide_project.md.DnD2-Dsd.js} +3 -3
  38. package/site-dist/assets/{guide_project.md.Cc6JFJ5R.lean.js → guide_project.md.DnD2-Dsd.lean.js} +3 -3
  39. package/site-dist/assets/{guide_user.md.8tCspVEK.js → guide_user.md.Cenm9vlN.js} +1 -1
  40. package/site-dist/assets/{guide_user.md.8tCspVEK.lean.js → guide_user.md.Cenm9vlN.lean.js} +1 -1
  41. package/site-dist/assets/reference_contracts_cli-json.md.B7o8deSY.js +1 -0
  42. package/site-dist/assets/reference_contracts_cli-json.md.B7o8deSY.lean.js +1 -0
  43. package/site-dist/assets/reference_contracts_state.md.TRfzD5bc.js +1 -0
  44. package/site-dist/assets/{reference_contracts_state.md.5bNTC3ow.lean.js → reference_contracts_state.md.TRfzD5bc.lean.js} +1 -1
  45. package/site-dist/assets/{reference_contracts_usage-record.md._C0OXWeq.js → reference_contracts_usage-record.md.DBMNY4OP.js} +2 -2
  46. package/site-dist/assets/{reference_contracts_usage-record.md._C0OXWeq.lean.js → reference_contracts_usage-record.md.DBMNY4OP.lean.js} +2 -2
  47. package/site-dist/changelog.html +5 -5
  48. package/site-dist/guide/getting-started.html +5 -5
  49. package/site-dist/guide/how-it-works.html +4 -4
  50. package/site-dist/guide/index.html +4 -4
  51. package/site-dist/guide/install.html +4 -4
  52. package/site-dist/guide/maintainer.html +4 -4
  53. package/site-dist/guide/phases.html +4 -4
  54. package/site-dist/guide/project.html +6 -6
  55. package/site-dist/guide/user.html +5 -5
  56. package/site-dist/hashmap.json +1 -1
  57. package/site-dist/index.html +4 -4
  58. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
  59. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
  60. package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
  61. package/site-dist/reference/adr/0004-result-files.html +4 -4
  62. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
  63. package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
  64. package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
  65. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
  66. package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
  67. package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
  68. package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
  69. package/site-dist/reference/adr/0012-review-panel.html +4 -4
  70. package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
  71. package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
  72. package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
  73. package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
  74. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
  75. package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
  76. package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
  77. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
  78. package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
  79. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
  80. package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
  81. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
  82. package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
  83. package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
  84. package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
  85. package/site-dist/reference/adr/0028-external-integrations.html +4 -4
  86. package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
  87. package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
  88. package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
  89. package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
  90. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +4 -4
  91. package/site-dist/reference/adr/index.html +4 -4
  92. package/site-dist/reference/commands.html +4 -4
  93. package/site-dist/reference/config.html +4 -4
  94. package/site-dist/reference/contracts/cli-json.html +5 -5
  95. package/site-dist/reference/contracts/feature-files.html +4 -4
  96. package/site-dist/reference/contracts/index.html +4 -4
  97. package/site-dist/reference/contracts/notifier-event.html +4 -4
  98. package/site-dist/reference/contracts/result-file.html +4 -4
  99. package/site-dist/reference/contracts/spec.html +4 -4
  100. package/site-dist/reference/contracts/state.html +5 -5
  101. package/site-dist/reference/contracts/usage-record.html +6 -6
  102. package/site-dist/reference/contracts/verify.html +4 -4
  103. package/site-dist/reference/scope.html +4 -4
  104. package/site-dist/assets/changelog.md.fsAikR0t.js +0 -1
  105. package/site-dist/assets/chunks/@localSearchIndexroot.D_9Dq76F.js +0 -1
  106. package/site-dist/assets/reference_contracts_cli-json.md.N-v2HNUd.js +0 -1
  107. package/site-dist/assets/reference_contracts_cli-json.md.N-v2HNUd.lean.js +0 -1
  108. package/site-dist/assets/reference_contracts_state.md.5bNTC3ow.js +0 -1
  109. /package/site-dist/assets/{guide_getting-started.md.DU5hdLl6.lean.js → guide_getting-started.md.Di4587Bu.lean.js} +0 -0
@@ -170,6 +170,45 @@ export function isTransientError(message: string): boolean {
170
170
  return TRANSIENT_ERROR.test(message);
171
171
  }
172
172
 
173
+ /** 1.13: the requirement used its token budget (config budget); stops before the next step. */
174
+ class BudgetReached extends Error {
175
+ constructor(
176
+ public readonly used: number,
177
+ public readonly allowance: number,
178
+ ) {
179
+ super(`token 用量 ${tokenText(used)} 已到预算 ${tokenText(allowance)}`);
180
+ this.name = "BudgetReached";
181
+ }
182
+ }
183
+
184
+ function tokenText(n: number): string {
185
+ return n >= 10_000 ? `${Math.round(n / 1000) / 10} 万` : String(n);
186
+ }
187
+
188
+ /** Tokens (input + output) of every step of the requirement so far, all attempts. */
189
+ export function tokensUsed(state: Pick<PipelineState, "history">): number {
190
+ return state.history.reduce((sum, h) => sum + (h.usage?.inputTokens ?? 0) + (h.usage?.outputTokens ?? 0), 0);
191
+ }
192
+
193
+ /**
194
+ * Before a new step agent starts: warns once per budget level at warnPercent, and stops at the
195
+ * allowance (the step that crossed it is finished, the next one waits for a person).
196
+ */
197
+ async function checkBudget(ctx: PipelineContext, stepTitle: string): Promise<void> {
198
+ const limit = ctx.config.budget.tokens;
199
+ if (limit === null) return;
200
+ const budget = (ctx.state.budget ??= { allowance: limit, warnedAt: null });
201
+ const used = tokensUsed(ctx.state);
202
+ if (used >= budget.allowance) throw new BudgetReached(used, budget.allowance);
203
+ if (used >= (budget.allowance * ctx.config.budget.warnPercent) / 100 && budget.warnedAt !== budget.allowance) {
204
+ budget.warnedAt = budget.allowance;
205
+ save(ctx);
206
+ const text = `【需求开发流水线通知】${shownFeatureDir(ctx)} 已用 ${tokenText(used)} tokens,到了预算 ${tokenText(budget.allowance)} 的 ${Math.round((used / budget.allowance) * 100)}%。流水线继续(下一步:${stepTitle});用完预算时会做完当前步骤后停下等你决定。这条消息只是告知,不需要处理。`;
207
+ await notifyOrigin(ctx, text);
208
+ await notifyExternal(ctx, "milestone", `需求 ${featureName(ctx.featureDir)} 的 token 用量已到预算的 ${ctx.config.budget.warnPercent}%`, { used, budget: budget.allowance });
209
+ }
210
+ }
211
+
173
212
  class BusinessDecisionNeeded extends Error {
174
213
  constructor(
175
214
  public readonly questions: string[],
@@ -616,6 +655,12 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
616
655
  await ctx.sleep(Math.min(60_000, 5_000 * consecutiveErrors));
617
656
  continue;
618
657
  }
658
+ // 1.13: whether the step waits for a permission, for the plugin's "等你处理".
659
+ const waitingNow = outcome.status === "permission";
660
+ if (!waitingNow && ctx.state.waitingPermission) {
661
+ ctx.state.waitingPermission = null;
662
+ save(ctx);
663
+ }
619
664
  switch (outcome.status) {
620
665
  case "idle":
621
666
  return outcome.usage ?? null;
@@ -624,6 +669,12 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
624
669
  case "error":
625
670
  throw new AgentErrored(def.title, `agent 报告错误:${outcome.error ?? "未知错误"}`);
626
671
  case "permission": {
672
+ const requests = outcome.pendingPermissions.map((p) => p.title);
673
+ const w = ctx.state.waitingPermission;
674
+ if (!w || w.agentId !== agent.id || w.requests.join("\n") !== requests.join("\n")) {
675
+ ctx.state.waitingPermission = { agentId: agent.id, stepTitle: def.title, requests, since: w?.agentId === agent.id ? w.since : new Date().toISOString() };
676
+ save(ctx);
677
+ }
627
678
  const fresh = outcome.pendingPermissions.filter((p) => !notifiedPermissions.has(p.id));
628
679
  if (fresh.length > 0) {
629
680
  for (const p of fresh) notifiedPermissions.add(p.id);
@@ -768,6 +819,7 @@ async function runStepOnce(
768
819
  const agentTitle =
769
820
  `[${featureName(ctx.featureDir)}] ` +
770
821
  (extras.title ?? `${def.number}-${def.title}${round !== null && round > 1 ? ` 第${round}轮` : ""}${target ? `(${target.name})` : ""}`);
822
+ if (!leftover.agent) await checkBudget(ctx, def.title);
771
823
  const record: StepRecord = {
772
824
  key,
773
825
  stepId: id,
@@ -788,6 +840,8 @@ async function runStepOnce(
788
840
  }
789
841
  ctx.log(leftover.agent ? `继续等待 ${key}(${def.title})上一次的 agent ${leftover.agent.id}` : `开始 ${key}(${def.title})`);
790
842
 
843
+ const stepProfile = extras.profile ?? phaseAgent(ctx, id) ?? ctx.config.agents[def.role];
844
+ if (!leftover.agent) record.agent = agentName(stepProfile);
791
845
  try {
792
846
  const agent = leftover.agent ?? await ctx.runtime.startAgent({
793
847
  role: def.role,
@@ -801,7 +855,7 @@ async function runStepOnce(
801
855
  },
802
856
  parentAgentId: ctx.config.attachToOriginChat ? ctx.state.originAgentId : null,
803
857
  // Always passed: the run's config has Paseo profile names resolved; the runtime's may not.
804
- profile: extras.profile ?? phaseAgent(ctx, id) ?? ctx.config.agents[def.role],
858
+ profile: stepProfile,
805
859
  workspaceId: target ? target.workspaceId : (ctx.state.worktree?.workspaceId ?? null),
806
860
  });
807
861
  record.agentId = agent.id;
@@ -961,6 +1015,12 @@ function mergeExtras(
961
1015
  return { contextLines: [...(a?.contextLines ?? []), ...(b?.contextLines ?? [])], sections: [...(a?.sections ?? []), ...(b?.sections ?? [])] };
962
1016
  }
963
1017
 
1018
+ /** How a step's agent is named in records and statistics (1.13). */
1019
+ export function agentName(profile: AgentProfile | null | undefined): string | undefined {
1020
+ if (!profile) return undefined;
1021
+ return profile.paseoProfile?.trim() || profile.provider?.trim() || undefined;
1022
+ }
1023
+
964
1024
  /** Steps that check the result the way a user would. */
965
1025
  const BROWSER_STEPS: readonly StepId[] = ["verify", "defect_fix", "reverify"];
966
1026
 
@@ -2215,6 +2275,34 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
2215
2275
  });
2216
2276
  return "waiting_for_business";
2217
2277
  }
2278
+ if (error instanceof BudgetReached) {
2279
+ // Continuing (req resume, or "继续开发") gives one more budget.
2280
+ const limit = ctx.config.budget.tokens ?? error.allowance;
2281
+ ctx.state.status = "failed";
2282
+ ctx.state.stopKind = "budget";
2283
+ ctx.state.stopReason = `${error.message},已停下等你决定是否继续`;
2284
+ ctx.state.budget = { allowance: error.allowance + limit, warnedAt: ctx.state.budget?.warnedAt ?? null };
2285
+ save(ctx);
2286
+ await notifyOrigin(
2287
+ ctx,
2288
+ [
2289
+ "【需求开发流水线通知 · 用完 token 预算】",
2290
+ "",
2291
+ `功能目录:${shownFeatureDir(ctx)}`,
2292
+ `情况:这个需求已用 ${tokenText(error.used)} tokens,到了预算 ${tokenText(error.allowance)}。已完成的步骤都保留,流水线停在下一步开始之前。`,
2293
+ "",
2294
+ "请这样处理:把用量告诉用户(可以运行查看进度的命令看各步骤),问是否继续。",
2295
+ `- 继续:执行下面的命令,会再给一份预算(到 ${tokenText(error.allowance + limit)} 时再停):\n\n${ctx.resumeCommand}`,
2296
+ "- 不继续:不用执行任何命令;需要时可以缩小需求范围再继续。",
2297
+ ].join("\n"),
2298
+ );
2299
+ await integrate(ctx, "failed");
2300
+ await notifyExternal(ctx, "failed", `需求 ${featureName(ctx.featureDir)} 用完了 token 预算(${tokenText(error.used)}),已暂停等需求方决定`, {
2301
+ reason: error.message,
2302
+ resumeCommand: ctx.resumeCommand,
2303
+ });
2304
+ return "failed";
2305
+ }
2218
2306
  const stepTitle = error instanceof StepFailure ? error.stepTitle : "流水线";
2219
2307
  ctx.state.status = "failed";
2220
2308
  ctx.state.stopReason = `${stepTitle}:${(error as Error).message}`;
@@ -134,6 +134,10 @@ export interface StepUsageRecord {
134
134
  inputTokens: number | null;
135
135
  outputTokens: number | null;
136
136
  costUsd: number | null;
137
+ /** 1.13: input tokens served from the prompt cache (part of inputTokens), when the agent reports them. */
138
+ cachedInputTokens?: number | null;
139
+ /** 1.13: the agent the step ran with (Paseo profile name or "provider/model"). */
140
+ agent?: string | null;
137
141
  }
138
142
 
139
143
  /** One pipeline run: a process from its start to its end (contracts/usage-record.md). */
@@ -217,6 +221,8 @@ export function runRecord(input: {
217
221
  inputTokens: h.usage?.inputTokens ?? null,
218
222
  outputTokens: h.usage?.outputTokens ?? null,
219
223
  costUsd: h.usage?.totalCostUsd ?? null,
224
+ cachedInputTokens: h.usage?.cachedInputTokens ?? null,
225
+ agent: h.agent ?? null,
220
226
  }),
221
227
  );
222
228
  const gateRuns = [...(state.gate?.runs ?? []), ...(state.joint ?? []).flatMap((j) => j.gate?.runs ?? [])].filter(
@@ -43,6 +43,7 @@ export type StopKind =
43
43
  | "transient"
44
44
  | "stopped"
45
45
  | "process_exit"
46
+ | "budget"
46
47
  | "other";
47
48
 
48
49
  /** Something the pipeline handled by itself (1.12). */
@@ -78,6 +79,16 @@ export interface StepRecord {
78
79
  reattached?: number;
79
80
  /** 1.12.1: the step's time limit was already given its one extra period (selfHeal); a later run waiting for the same agent gets none. */
80
81
  extendedTimeout?: boolean;
82
+ /** 1.13: the agent the step ran with: the Paseo profile name, or "provider/model". */
83
+ agent?: string;
84
+ }
85
+
86
+ /** 1.13: the requirement's token budget so far (config budget). */
87
+ export interface BudgetState {
88
+ /** Tokens allowed before stopping: the budget, plus one more each time the person continued after a stop. */
89
+ allowance: number;
90
+ /** The allowance a warning was given for, so each level warns once. */
91
+ warnedAt: number | null;
81
92
  }
82
93
 
83
94
  export interface PipelineState {
@@ -97,6 +108,10 @@ export interface PipelineState {
97
108
  results: Record<string, Record<string, unknown>>;
98
109
  history: StepRecord[];
99
110
  stopReason: string | null;
111
+ /** 1.13: a step agent is waiting for someone to allow or deny an action; null otherwise. */
112
+ waitingPermission?: { agentId: string; stepTitle: string; requests: string[]; since: string } | null;
113
+ /** 1.13: token budget (config budget); absent until the first check. */
114
+ budget?: BudgetState;
100
115
  /** 1.12: why the run stopped for a person, as a code (contracts/usage-record.md); null while running or done. */
101
116
  stopKind?: StopKind | null;
102
117
  /** 1.12: what the pipeline handled by itself instead of stopping (retries, waiting longer, a fallback model…). */
@@ -121,6 +121,7 @@ export const STOP_LABELS: Record<string, string> = {
121
121
  transient: "限流、网络等临时错误(自动重试用完)",
122
122
  stopped: "被手动停止",
123
123
  process_exit: "后台进程意外退出",
124
+ budget: "用完 token 预算",
124
125
  other: "其他中断",
125
126
  };
126
127
 
@@ -236,6 +237,8 @@ export interface StatsSummary {
236
237
  /** Average minutes per requirement that waited, by kind, in the period's runs. */
237
238
  waits: Array<{ kind: WaitKind; label: string; requirements: number; averageMinutes: number }>;
238
239
  usage: { agentMinutes: number; inputTokens: number | null; outputTokens: number | null; costUsd: number | null };
240
+ /** 1.13: where the tokens (input + output) of the period's runs went. */
241
+ tokens: TokenBreakdown;
239
242
  byBuilder: Array<{ builder: string; requirements: number; averageReviewRounds: number | null; averageCostUsd: number | null }>;
240
243
  /** 1.12: why the period's runs stopped for a person, most frequent first. */
241
244
  stops: Array<{ kind: string; label: string; necessary: boolean; runs: number }>;
@@ -255,6 +258,66 @@ export interface StatsSummary {
255
258
  requirements: RequirementStats[];
256
259
  }
257
260
 
261
+ export interface TokenBreakdown {
262
+ /** Input + output of the period's steps that reported usage; null when none did. */
263
+ total: number | null;
264
+ /** Share of input tokens served from the prompt cache, when agents report it. */
265
+ cachedShare: number | null;
266
+ byStep: Array<{ stepId: string; title: string; steps: number; tokens: number; share: number }>;
267
+ byAgent: Array<{ agent: string; steps: number; tokens: number; share: number }>;
268
+ /** Fixing and re-checking: revisions, review fixes, defect fixes, re-verification, and review rounds after the first. */
269
+ rework: { tokens: number; share: number } | null;
270
+ /** Tokens per completed requirement (all its runs). */
271
+ perRequirement: { median: number | null; byProfile: Array<{ profile: string; requirements: number; median: number | null }> };
272
+ }
273
+
274
+ const REWORK_STEPS = new Set(["design_revise", "review_fix", "defect_fix", "reverify"]);
275
+
276
+ export function isRework(s: { stepId: string; round: number | null }): boolean {
277
+ return REWORK_STEPS.has(s.stepId) || ((s.stepId === "design_review" || s.stepId === "code_review") && (s.round ?? 1) > 1);
278
+ }
279
+
280
+ const stepTokens = (s: { inputTokens: number | null; outputTokens: number | null }) =>
281
+ s.inputTokens === null && s.outputTokens === null ? null : (s.inputTokens ?? 0) + (s.outputTokens ?? 0);
282
+
283
+ export function tokenBreakdown(steps: RunRecord["steps"], done: RequirementStats[]): TokenBreakdown {
284
+ const counted = steps.flatMap((s) => {
285
+ const t = stepTokens(s);
286
+ return t === null ? [] : [{ s, t }];
287
+ });
288
+ const total = counted.length ? counted.reduce((a, x) => a + x.t, 0) : null;
289
+ const share = (n: number) => (total ? Math.round((n / total) * 1000) / 1000 : 0);
290
+ const group = (key: (s: RunRecord["steps"][number]) => string) => {
291
+ const m = new Map<string, { steps: number; tokens: number }>();
292
+ for (const { s, t } of counted) {
293
+ const k = key(s);
294
+ const g = m.get(k) ?? { steps: 0, tokens: 0 };
295
+ m.set(k, { steps: g.steps + 1, tokens: g.tokens + t });
296
+ }
297
+ return [...m.entries()].sort((a, b) => b[1].tokens - a[1].tokens);
298
+ };
299
+ const reworkTokens = counted.filter(({ s }) => isRework(s)).reduce((a, x) => a + x.t, 0);
300
+ const input = steps.reduce((a, s) => a + (s.inputTokens ?? 0), 0);
301
+ const cached = steps.reduce<number | null>((a, s) => (typeof s.cachedInputTokens === "number" ? (a ?? 0) + s.cachedInputTokens : a), null);
302
+ const reqTokens = (q: RequirementStats) => (q.inputTokens === null && q.outputTokens === null ? null : (q.inputTokens ?? 0) + (q.outputTokens ?? 0));
303
+ const profiles = new Map<string, number[]>();
304
+ for (const q of done) {
305
+ const t = reqTokens(q);
306
+ if (t !== null) profiles.set(q.profile, [...(profiles.get(q.profile) ?? []), t]);
307
+ }
308
+ return {
309
+ total,
310
+ cachedShare: cached !== null && input > 0 ? Math.round((cached / input) * 1000) / 1000 : null,
311
+ byStep: group((s) => s.stepId).map(([stepId, g]) => ({ stepId, title: STEPS[stepId as StepId]?.title ?? stepId, steps: g.steps, tokens: g.tokens, share: share(g.tokens) })),
312
+ byAgent: group((s) => s.agent ?? "(未记录)").map(([agent, g]) => ({ agent, steps: g.steps, tokens: g.tokens, share: share(g.tokens) })),
313
+ rework: total ? { tokens: reworkTokens, share: share(reworkTokens) } : null,
314
+ perRequirement: {
315
+ median: median([...profiles.values()].flat()),
316
+ byProfile: [...profiles.entries()].map(([profile, ts]) => ({ profile, requirements: ts.length, median: median(ts) })),
317
+ },
318
+ };
319
+ }
320
+
258
321
  /** The summary of a period from run records (any machines). */
259
322
  export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
260
323
  const runs = dedupe(all);
@@ -341,6 +404,7 @@ export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
341
404
  outputTokens: steps.reduce<number | null>((s, x) => add(s, x.outputTokens), null),
342
405
  costUsd: steps.reduce<number | null>((s, x) => add(s, x.costUsd), null),
343
406
  },
407
+ tokens: tokenBreakdown(steps, done),
344
408
  byBuilder: [...builders.entries()].map(([builder, qs]) => ({
345
409
  builder,
346
410
  requirements: qs.length,
@@ -411,6 +475,25 @@ export function describeStats(s: StatsSummary): string {
411
475
  `- 完成:${s.completed.total} 个(${list(s.completed.byOutcome, OUTCOME_LABELS)})`,
412
476
  s.cycleHours.median !== null ? `- 交付周期(首次开始到完成):中位数 ${s.cycleHours.median} 小时,平均 ${s.cycleHours.average} 小时` : "- 交付周期:这段时间没有完成的需求",
413
477
  `- agent 合计用时:${hours(s.usage.agentMinutes)};输入 ${tokens(s.usage.inputTokens)} tokens,输出 ${tokens(s.usage.outputTokens)} tokens${s.usage.costUsd !== null ? `,约 $${s.usage.costUsd.toFixed(2)}` : ""}`,
478
+ );
479
+ const t = s.tokens;
480
+ if (t.total) {
481
+ const pct = (x: number) => `${Math.round(x * 100)}%`;
482
+ lines.push("", "## token 花在哪", "", `- 合计 ${tokens(t.total)} tokens(输入 + 输出)${t.cachedShare !== null ? `;输入中 ${pct(t.cachedShare)} 来自提示缓存` : ""}`);
483
+ if (t.rework) lines.push(`- 返工(方案修订、评审问题修复、缺陷修复、复验,以及第 2 轮起的审核和评审):${tokens(t.rework.tokens)},占 ${pct(t.rework.share)}`);
484
+ if (t.perRequirement.median !== null) {
485
+ lines.push(
486
+ `- 每个完成的需求:中位数 ${tokens(t.perRequirement.median)}${t.perRequirement.byProfile.length > 1 ? `(${t.perRequirement.byProfile.map((p) => `${p.profile === "quick" ? "快速档" : p.profile === "standard" ? "标准档" : p.profile} ${tokens(p.median)},${p.requirements} 个`).join(";")})` : ""}`,
487
+ );
488
+ }
489
+ lines.push("", "| 步骤 | 次数 | tokens | 占比 |", "| --- | --- | --- | --- |");
490
+ for (const r of t.byStep) lines.push(`| ${r.title} | ${r.steps} | ${tokens(r.tokens)} | ${pct(r.share)} |`);
491
+ if (t.byAgent.length > 1 || t.byAgent[0]?.agent !== "(未记录)") {
492
+ lines.push("", "| 模型 | 步骤数 | tokens | 占比 |", "| --- | --- | --- | --- |");
493
+ for (const r of t.byAgent) lines.push(`| ${r.agent} | ${r.steps} | ${tokens(r.tokens)} | ${pct(r.share)} |`);
494
+ }
495
+ }
496
+ lines.push(
414
497
  "",
415
498
  "## 返工",
416
499
  "",
@@ -0,0 +1,105 @@
1
+ // Continuing pipelines whose background process died (1.13), without asking a model: a plain
2
+ // check of this machine's requirements that runs `req resume` for each one whose state says
3
+ // "running" but whose process is gone. The Paseo plugin runs it every few minutes inside the
4
+ // daemon (`req api watchdog`), and the card factory on each poll. Before 1.13 this was a Paseo
5
+ // schedule that woke the chat agent every hour to look — a model turn each time, and a recovery
6
+ // that depended on the model understanding the prompt.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { kitHome } from "./locations.js";
10
+ import { readIndex } from "./records.js";
11
+ import { isAlive, readState, statePath, writeState } from "./state.js";
12
+
13
+ /** A run is left alone this long after its last write (it may be starting or finishing). */
14
+ const SETTLE_MS = 2 * 60_000;
15
+ /** At most this many automatic continuations per requirement per day. */
16
+ const MAX_PER_DAY = 3;
17
+ /** The plugin's watchdog counts as running when it checked within this time. */
18
+ export const HEARTBEAT_FRESH_MS = 15 * 60_000;
19
+
20
+ export const heartbeatFile = (env: NodeJS.ProcessEnv = process.env) => path.join(kitHome(env), "watchdog.json");
21
+
22
+ interface Memory {
23
+ /** Last check, ISO time, and who ran it. */
24
+ at?: string;
25
+ by?: string;
26
+ /** Per requirement: the dead process already handled, and when continuations happened. */
27
+ handled?: Record<string, { pid: number; times: string[] }>;
28
+ }
29
+
30
+ function readMemory(env: NodeJS.ProcessEnv): Memory {
31
+ try {
32
+ return JSON.parse(fs.readFileSync(heartbeatFile(env), "utf8")) as Memory;
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ function writeMemory(env: NodeJS.ProcessEnv, m: Memory): void {
39
+ try {
40
+ fs.mkdirSync(path.dirname(heartbeatFile(env)), { recursive: true });
41
+ const tmp = `${heartbeatFile(env)}.${process.pid}.tmp`;
42
+ fs.writeFileSync(tmp, JSON.stringify(m, null, 2));
43
+ fs.renameSync(tmp, heartbeatFile(env));
44
+ } catch {
45
+ // Best effort: the next check tries again.
46
+ }
47
+ }
48
+
49
+ /** Whether the plugin's watchdog has checked recently (so the pipeline need not ask the chat to). */
50
+ export function watchdogActive(env: NodeJS.ProcessEnv = process.env, now = Date.now()): boolean {
51
+ const m = readMemory(env);
52
+ return m.by === "plugin" && !!m.at && now - new Date(m.at).getTime() < HEARTBEAT_FRESH_MS;
53
+ }
54
+
55
+ export interface WatchdogResult {
56
+ checked: number;
57
+ resumed: Array<{ featureDir: string; repo: string; message: string }>;
58
+ failed: Array<{ featureDir: string; repo: string; message: string }>;
59
+ skipped: Array<{ featureDir: string; reason: string }>;
60
+ }
61
+
62
+ export type Resume = (repo: string, featureDir: string) => { ok: boolean; message: string };
63
+
64
+ export function watchdogPass(input: { env?: NodeJS.ProcessEnv; by: "plugin" | "factory" | "manual"; resume: Resume; now?: Date; dryRun?: boolean }): WatchdogResult {
65
+ const env = input.env ?? process.env;
66
+ const now = input.now ?? new Date();
67
+ const memory = readMemory(env);
68
+ const handled = memory.handled ?? {};
69
+ const result: WatchdogResult = { checked: 0, resumed: [], failed: [], skipped: [] };
70
+ for (const entry of readIndex(env)) {
71
+ if (!entry.present) continue;
72
+ const file = statePath(entry.repo, entry.featureDir);
73
+ const state = readState(file);
74
+ if (!state) continue;
75
+ result.checked++;
76
+ if (state.status !== "running" || isAlive(state.pid)) continue;
77
+ if (now.getTime() - new Date(state.updatedAt).getTime() < SETTLE_MS) continue;
78
+ const h = handled[entry.key];
79
+ if (h?.pid === state.pid) continue;
80
+ const recent = (h?.times ?? []).filter((t) => now.getTime() - new Date(t).getTime() < 24 * 3600_000);
81
+ if (recent.length >= MAX_PER_DAY) {
82
+ result.skipped.push({ featureDir: entry.featureDir, reason: `24 小时内已自动继续 ${recent.length} 次,不再自动继续,请人看一下日志` });
83
+ handled[entry.key] = { pid: state.pid, times: recent };
84
+ continue;
85
+ }
86
+ if (input.dryRun) {
87
+ result.resumed.push({ featureDir: entry.featureDir, repo: entry.repo, message: "(试运行,没有继续)" });
88
+ continue;
89
+ }
90
+ // Say why in the state first: the continued run reads it, and statistics count it.
91
+ state.stopKind = "process_exit";
92
+ state.stopReason = "后台进程意外退出";
93
+ state.recoveries = [...(state.recoveries ?? []), { at: now.toISOString(), key: "pipeline", kind: "auto_resume", detail: `后台进程 ${state.pid} 意外退出,${input.by === "plugin" ? "插件" : input.by === "factory" ? "接单器" : "检查"}自动继续` }];
94
+ try {
95
+ writeState(file, state);
96
+ } catch {
97
+ /* the resume still runs */
98
+ }
99
+ const r = input.resume(entry.sourceRepo ?? entry.repo, entry.featureDir);
100
+ handled[entry.key] = { pid: state.pid, times: [...recent, now.toISOString()] };
101
+ (r.ok ? result.resumed : result.failed).push({ featureDir: entry.featureDir, repo: entry.repo, message: r.message.split("\n").find((l) => l.trim()) ?? "" });
102
+ }
103
+ writeMemory(env, { at: now.toISOString(), by: input.by === "manual" ? (memory.by ?? "manual") : input.by, handled });
104
+ return result;
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-req-kit",
3
- "version": "1.13.0-beta.3",
3
+ "version": "1.13.0-beta.4",
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",
@@ -174,13 +174,22 @@ export function RequirementView({
174
174
  <View style={s.row}>
175
175
  <Text style={s.title}>{d.title}</Text>
176
176
  {d.attention ? (
177
- <Badge theme={theme} tone={d.attention === "approval" || d.attention === "business" ? "warning" : "danger"}>
177
+ <Badge theme={theme} tone={d.attention === "approval" || d.attention === "business" || d.attention === "permission" ? "warning" : "danger"}>
178
178
  {ATTENTION_LABELS[d.attention]}
179
179
  </Badge>
180
180
  ) : null}
181
181
  </View>
182
182
  <Text style={s.muted}>{facts(d)}</Text>
183
183
  <Text style={s.text}>{d.stage}</Text>
184
+ {d.estimate && d.attention !== "approval" ? (
185
+ <Text style={s.muted}>
186
+ {d.estimate.over
187
+ ? `已超过同档位需求通常的用时(已用 ${minutes(d.estimate.usedAgentMinutes)},通常 ${minutes(d.estimate.typicalAgentMinutes)})`
188
+ : `预计还要约 ${minutes(Math.max(5, d.estimate.leftMinutes))}`}
189
+ {d.estimate.usedTokens !== null ? ` · 已用 ${tokens(d.estimate.usedTokens)} tokens${d.estimate.typicalTokens !== null ? `(通常 ${tokens(d.estimate.typicalTokens)})` : ""}` : ""}
190
+ {` · 按本机最近 ${d.estimate.basis} 个同档位需求,不含等人的时间`}
191
+ </Text>
192
+ ) : null}
184
193
  {d.run?.stopReason && !d.running ? <Text style={s.danger}>{d.run.stopReason}</Text> : null}
185
194
  {d.attention === "business" ? <Text style={s.muted}>需求方的决定请在发起需求的对话中回答,回答后再继续。</Text> : null}
186
195
  </View>
@@ -4,6 +4,7 @@ import type { Attention, Requirement } from "../shared/contracts";
4
4
  export const ATTENTION_LABELS: Record<Exclude<Attention, null>, string> = {
5
5
  approval: "等待确认方案",
6
6
  business: "等待需求方决定",
7
+ permission: "等待批准权限",
7
8
  interrupted: "已中断,可以继续",
8
9
  not_completed: "结束但未完成",
9
10
  };
@@ -17,6 +17,7 @@ type Layout = PluginHostProps["layout"];
17
17
  const KIND: Record<Exclude<Attention, null>, { label: string; tone: "warning" | "danger" | "muted"; todo: string }> = {
18
18
  approval: { label: "确认方案", tone: "warning", todo: "方案已通过审核。看一下“做完后会看到什么”,没问题就确认,开始写代码;要改就写下来。" },
19
19
  business: { label: "回答问题", tone: "warning", todo: "方案审核有问题只有你能决定,回答后开发会接着进行。" },
20
+ permission: { label: "批准权限", tone: "warning", todo: "有一个步骤在等你允许它做一项操作,批准前会一直停在这里。打开这个 agent,在它的对话里选择允许或拒绝;拿不准时请开发同事看一下。" },
20
21
  interrupted: { label: "已中断", tone: "danger", todo: "开发中断了,自动处理没能解决。看一下原因,能继续就继续。" },
21
22
  not_completed: { label: "没有完成", tone: "muted", todo: "流水线结束了,但有部分没有做完。完成报告里写了原因和建议。" },
22
23
  };
@@ -37,7 +38,7 @@ export function inboxItems(items: Requirement[]): Requirement[] {
37
38
  .sort((a, b) => (a.waiting?.since ?? a.updatedAt).localeCompare(b.waiting?.since ?? b.updatedAt));
38
39
  }
39
40
 
40
- function InboxCard({ theme, layout, item, open, readOnly }: { theme: Theme; layout: Layout; item: Requirement; open(): void; readOnly: boolean }) {
41
+ function InboxCard({ theme, layout, item, open, readOnly, openAgent }: { theme: Theme; layout: Layout; item: Requirement; open(): void; readOnly: boolean; openAgent?: (agentId: string) => void }) {
41
42
  const s = useStyles(theme, layout.compact);
42
43
  const run = useAction();
43
44
  const toast = useToast();
@@ -77,8 +78,9 @@ function InboxCard({ theme, layout, item, open, readOnly }: { theme: Theme; layo
77
78
  </Text>
78
79
  <Text style={s.text}>{kind.todo}</Text>
79
80
 
80
- {item.attention === "business" && w && w.questions.length > 0 ? (
81
+ {(item.attention === "business" || item.attention === "permission") && w && w.questions.length > 0 ? (
81
82
  <View style={{ gap: 4 }}>
83
+ {item.attention === "permission" ? <Text style={s.muted}>{w.reason}。要批准的操作:</Text> : null}
82
84
  {w.questions.map((q, i) => (
83
85
  <Text key={i} style={s.text}>
84
86
  {i + 1}. {q}
@@ -140,6 +142,7 @@ function InboxCard({ theme, layout, item, open, readOnly }: { theme: Theme; layo
140
142
  {item.attention === "business" && w?.canReply ? (
141
143
  <Button theme={theme} primary label={run.isPending ? "正在发送…" : "发送回答"} disabled={run.isPending || !text.trim()} onPress={() => void act("reply")} />
142
144
  ) : null}
145
+ {item.attention === "permission" && w?.agentId && openAgent ? <Button theme={theme} primary label="打开这个 agent" onPress={() => openAgent(w.agentId!)} /> : null}
143
146
  {item.attention === "interrupted" ? (
144
147
  <Button theme={theme} primary label={run.isPending ? "正在执行…" : "继续"} disabled={run.isPending} onPress={() => void act("resume")} />
145
148
  ) : null}
@@ -151,7 +154,7 @@ function InboxCard({ theme, layout, item, open, readOnly }: { theme: Theme; layo
151
154
  );
152
155
  }
153
156
 
154
- export function Inbox({ theme, layout, items, open }: { theme: Theme; layout: Layout; items: Requirement[]; open(item: Requirement): void }) {
157
+ export function Inbox({ theme, layout, items, open, openAgent }: { theme: Theme; layout: Layout; items: Requirement[]; open(item: Requirement): void; openAgent?: (agentId: string) => void }) {
155
158
  const s = useStyles(theme, layout.compact);
156
159
  const { readOnly } = usePreferences();
157
160
  const waiting = inboxItems(items);
@@ -169,7 +172,7 @@ export function Inbox({ theme, layout, items, open }: { theme: Theme; layout: La
169
172
  <Text style={s.muted}>现在没有需要你处理的事。需要你确认或回答时,会出现在这里。</Text>
170
173
  </View>
171
174
  ) : (
172
- waiting.map((item) => <InboxCard key={item.key} theme={theme} layout={layout} item={item} open={() => open(item)} readOnly={readOnly} />)
175
+ waiting.map((item) => <InboxCard key={item.key} theme={theme} layout={layout} item={item} open={() => open(item)} readOnly={readOnly} openAgent={openAgent} />)
173
176
  )}
174
177
  </View>
175
178
  );
@@ -85,7 +85,7 @@ export function OverviewScreen(props: PluginScreenProps & { openScreen: ScreenOp
85
85
  error={query.error}
86
86
  empty={query.data && items.length === 0 ? "这台机器上还没有记录任何需求。在 Paseo 对话中用 /req 开始一个;1.9 之前运行过的需求,在项目目录运行 req index --rebuild。" : null}
87
87
  />
88
- {query.data && items.length > 0 ? <Inbox theme={theme} layout={layout} items={shown} open={open} /> : null}
88
+ {query.data && items.length > 0 ? <Inbox theme={theme} layout={layout} items={shown} open={open} openAgent={props.navigation ? (agentId) => props.navigation!.openAgent({ agentId }) : undefined} /> : null}
89
89
  <RequirementGroups theme={theme} layout={layout} items={shown} open={open} skip={["attention"]} />
90
90
  </ScrollView>
91
91
  </View>
@@ -7,6 +7,47 @@ import { useStats } from "./data";
7
7
  import { minutes, tokens, TYPE_LABELS } from "./format";
8
8
  import { Chip, Status, useStyles } from "./ui";
9
9
 
10
+ type Tokens = NonNullable<import("../shared/contracts").StatsSummary["tokens"]>;
11
+
12
+ const pct = (x: number) => `${Math.round(x * 100)}%`;
13
+
14
+ /** Where the tokens went (1.13): a bar per step and per model, and the share spent on rework. */
15
+ function TokenShare({ theme, compact, t }: { theme: import("@getpaseo/plugin/client").PluginHostProps["theme"]; compact: boolean; t: Tokens }) {
16
+ const s = useStyles(theme, compact);
17
+ const bar = (key: string, label: string, value: number, share: number, note: string) => (
18
+ <View key={key} style={{ gap: 2 }}>
19
+ <View style={[s.row, { justifyContent: "space-between", flexWrap: "nowrap" }]}>
20
+ <Text style={[s.text, { flexShrink: 1 }]}>{label}</Text>
21
+ <Text style={s.muted}>
22
+ {tokens(value)} · {pct(share)}
23
+ {note}
24
+ </Text>
25
+ </View>
26
+ <View style={{ height: 6, borderRadius: 3, backgroundColor: theme.colors.surface2, overflow: "hidden" }}>
27
+ <View style={{ width: `${Math.max(1, Math.round(share * 100))}%`, height: 6, backgroundColor: theme.colors.accent }} />
28
+ </View>
29
+ </View>
30
+ );
31
+ return (
32
+ <View style={[s.card, { gap: 10 }]}>
33
+ <Text style={s.heading}>token 花在哪</Text>
34
+ <Text style={s.text}>
35
+ 合计 {tokens(t.total)} tokens
36
+ {t.rework ? `,其中返工(修订、修复、复验和第 2 轮起的评审)占 ${pct(t.rework.share)}` : ""}
37
+ {t.cachedShare !== null ? `;输入中 ${pct(t.cachedShare)} 来自提示缓存` : ""}
38
+ </Text>
39
+ {t.perRequirement.median !== null ? <Text style={s.muted}>每个完成的需求中位数 {tokens(t.perRequirement.median)}</Text> : null}
40
+ <View style={{ gap: 8 }}>{t.byStep.slice(0, 12).map((r) => bar(r.stepId, r.title, r.tokens, r.share, `(${r.steps} 次)`))}</View>
41
+ {t.byAgent.some((a) => a.agent !== "(未记录)") ? (
42
+ <>
43
+ <Text style={[s.text, { fontWeight: "600" }]}>按模型</Text>
44
+ <View style={{ gap: 8 }}>{t.byAgent.map((r) => bar(r.agent, r.agent, r.tokens, r.share, `(${r.steps} 步)`))}</View>
45
+ </>
46
+ ) : null}
47
+ </View>
48
+ );
49
+ }
50
+
10
51
  function month(offset: number, now = new Date()): string {
11
52
  const d = new Date(now.getFullYear(), now.getMonth() + offset, 1);
12
53
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
@@ -50,6 +91,7 @@ export function StatsScreen({ theme, layout }: PluginScreenProps) {
50
91
  {card("agent 合计用时", minutes(d.usage.agentMinutes), `输入 ${tokens(d.usage.inputTokens)},输出 ${tokens(d.usage.outputTokens)} tokens`)}
51
92
  {d.usage.costUsd !== null ? card("费用", `$${d.usage.costUsd.toFixed(2)}`) : null}
52
93
  </View>
94
+ {d.tokens?.total ? <TokenShare theme={theme} compact={layout.compact} t={d.tokens} /> : null}
53
95
  {d.waits.length > 0 ? (
54
96
  <View style={s.card}>
55
97
  <Text style={s.heading}>等待</Text>
@@ -39,7 +39,23 @@ export default function contribute(server: PluginServerContext) {
39
39
  server.handle(configRpc, (input) => handlers.config(input));
40
40
  server.handle(setAgentsRpc, (input) => handlers.setAgents(input));
41
41
  server.handle(reviewUrlRpc, (input) => handlers.reviewUrl(input));
42
+ // 1.13: every few minutes, continue this machine's pipelines whose background process died
43
+ // (req api watchdog). Deterministic, no model involved; while it runs, pipelines do not ask
44
+ // their chats to check on them. Off when the plugin is read-only.
45
+ const check = async () => {
46
+ try {
47
+ await load();
48
+ if (readOnly) return;
49
+ await reqApi(kitHome)(["watchdog", "--by", "plugin"], { timeoutMs: 240_000 });
50
+ } catch {
51
+ // req-kit missing or a check failing: the next one tries again.
52
+ }
53
+ };
54
+ const first = setTimeout(() => void check(), 60_000);
55
+ const timer = setInterval(() => void check(), 5 * 60_000);
42
56
  return () => {
57
+ clearTimeout(first);
58
+ clearInterval(timer);
43
59
  unsubscribe();
44
60
  };
45
61
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "req-kit-paseo-plugin",
3
- "version": "1.13.0-beta.3",
3
+ "version": "1.13.0-beta.4",
4
4
  "private": true,
5
5
  "description": "The req-kit plugin for Paseo. Ships inside the paseo-req-kit package; installed with req plugin install.",
6
6
  "type": "module",