paseo-req-kit 1.13.0-beta.2 → 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 (116) hide show
  1. package/CHANGELOG.md +28 -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 +5 -2
  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 +17 -4
  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/messages.ts +7 -2
  16. package/engine/src/pipeline.ts +89 -1
  17. package/engine/src/records.ts +6 -0
  18. package/engine/src/review.ts +14 -3
  19. package/engine/src/state.ts +15 -0
  20. package/engine/src/stats.ts +83 -0
  21. package/engine/src/watchdog.ts +105 -0
  22. package/package.json +1 -1
  23. package/paseo-plugin/client/detail.tsx +14 -6
  24. package/paseo-plugin/client/format.ts +1 -0
  25. package/paseo-plugin/client/inbox.tsx +11 -7
  26. package/paseo-plugin/client/nav.ts +12 -0
  27. package/paseo-plugin/client/overview.tsx +1 -1
  28. package/paseo-plugin/client/review.tsx +46 -0
  29. package/paseo-plugin/client/stats.tsx +42 -0
  30. package/paseo-plugin/client/web.ts +17 -0
  31. package/paseo-plugin/index.client.tsx +4 -0
  32. package/paseo-plugin/index.server.ts +16 -0
  33. package/paseo-plugin/package.json +1 -1
  34. package/paseo-plugin/shared/contracts.ts +23 -1
  35. package/site-dist/404.html +2 -2
  36. package/site-dist/assets/{app.DWPPPhFm.js → app.n_bkEV-X.js} +1 -1
  37. package/site-dist/assets/changelog.md.RqcFias_.js +1 -0
  38. package/site-dist/assets/{changelog.md.CoelFFCL.lean.js → changelog.md.RqcFias_.lean.js} +1 -1
  39. package/site-dist/assets/chunks/@localSearchIndexroot.DQEikTmr.js +1 -0
  40. package/site-dist/assets/chunks/{VPLocalSearchBox.D3_9dRGY.js → VPLocalSearchBox.B54kK3Ge.js} +1 -1
  41. package/site-dist/assets/chunks/{theme.Bm8aLiLN.js → theme.BH3XHrL3.js} +2 -2
  42. package/site-dist/assets/{guide_getting-started.md.B4GJeoPz.js → guide_getting-started.md.Di4587Bu.js} +1 -1
  43. package/site-dist/assets/{guide_project.md.nGlTCo4D.js → guide_project.md.DnD2-Dsd.js} +3 -3
  44. package/site-dist/assets/{guide_project.md.nGlTCo4D.lean.js → guide_project.md.DnD2-Dsd.lean.js} +3 -3
  45. package/site-dist/assets/{guide_user.md.tiHFxIsQ.js → guide_user.md.Cenm9vlN.js} +1 -1
  46. package/site-dist/assets/{guide_user.md.tiHFxIsQ.lean.js → guide_user.md.Cenm9vlN.lean.js} +1 -1
  47. package/site-dist/assets/reference_contracts_cli-json.md.B7o8deSY.js +1 -0
  48. package/site-dist/assets/reference_contracts_cli-json.md.B7o8deSY.lean.js +1 -0
  49. package/site-dist/assets/reference_contracts_state.md.TRfzD5bc.js +1 -0
  50. package/site-dist/assets/{reference_contracts_state.md.5bNTC3ow.lean.js → reference_contracts_state.md.TRfzD5bc.lean.js} +1 -1
  51. package/site-dist/assets/{reference_contracts_usage-record.md._C0OXWeq.js → reference_contracts_usage-record.md.DBMNY4OP.js} +2 -2
  52. package/site-dist/assets/{reference_contracts_usage-record.md._C0OXWeq.lean.js → reference_contracts_usage-record.md.DBMNY4OP.lean.js} +2 -2
  53. package/site-dist/changelog.html +5 -5
  54. package/site-dist/guide/getting-started.html +5 -5
  55. package/site-dist/guide/how-it-works.html +4 -4
  56. package/site-dist/guide/index.html +4 -4
  57. package/site-dist/guide/install.html +4 -4
  58. package/site-dist/guide/maintainer.html +4 -4
  59. package/site-dist/guide/phases.html +4 -4
  60. package/site-dist/guide/project.html +6 -6
  61. package/site-dist/guide/user.html +5 -5
  62. package/site-dist/hashmap.json +1 -1
  63. package/site-dist/index.html +4 -4
  64. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
  65. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
  66. package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
  67. package/site-dist/reference/adr/0004-result-files.html +4 -4
  68. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
  69. package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
  70. package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
  71. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
  72. package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
  73. package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
  74. package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
  75. package/site-dist/reference/adr/0012-review-panel.html +4 -4
  76. package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
  77. package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
  78. package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
  79. package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
  80. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
  81. package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
  82. package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
  83. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
  84. package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
  85. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
  86. package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
  87. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
  88. package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
  89. package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
  90. package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
  91. package/site-dist/reference/adr/0028-external-integrations.html +4 -4
  92. package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
  93. package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
  94. package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
  95. package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
  96. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +4 -4
  97. package/site-dist/reference/adr/index.html +4 -4
  98. package/site-dist/reference/commands.html +4 -4
  99. package/site-dist/reference/config.html +4 -4
  100. package/site-dist/reference/contracts/cli-json.html +5 -5
  101. package/site-dist/reference/contracts/feature-files.html +4 -4
  102. package/site-dist/reference/contracts/index.html +4 -4
  103. package/site-dist/reference/contracts/notifier-event.html +4 -4
  104. package/site-dist/reference/contracts/result-file.html +4 -4
  105. package/site-dist/reference/contracts/spec.html +4 -4
  106. package/site-dist/reference/contracts/state.html +5 -5
  107. package/site-dist/reference/contracts/usage-record.html +6 -6
  108. package/site-dist/reference/contracts/verify.html +4 -4
  109. package/site-dist/reference/scope.html +4 -4
  110. package/paseo-plugin/client/review-link.tsx +0 -29
  111. package/site-dist/assets/changelog.md.CoelFFCL.js +0 -1
  112. package/site-dist/assets/chunks/@localSearchIndexroot.Hdq0-4L3.js +0 -1
  113. package/site-dist/assets/reference_contracts_cli-json.md.N-v2HNUd.js +0 -1
  114. package/site-dist/assets/reference_contracts_cli-json.md.N-v2HNUd.lean.js +0 -1
  115. package/site-dist/assets/reference_contracts_state.md.5bNTC3ow.js +0 -1
  116. /package/site-dist/assets/{guide_getting-started.md.B4GJeoPz.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(
@@ -237,11 +237,22 @@ function inside(base: string, rel: string): string | null {
237
237
  return target === base || target.startsWith(base + path.sep) ? target : null;
238
238
  }
239
239
 
240
+ // The page may be shown inside Paseo (the req-kit plugin embeds it): Paseo's web UI on this
241
+ // machine, or, with REQ_KIT_REVIEW_HOST set for other computers, any page that has the token.
242
+ function appCsp(): string {
243
+ const host = process.env.REQ_KIT_REVIEW_HOST?.trim();
244
+ const local = !host || host === "127.0.0.1" || host === "localhost" || host === "::1";
245
+ const custom = process.env.REQ_KIT_REVIEW_FRAME_ANCESTORS?.trim();
246
+ // 'self' always: the page shows the prototype in a frame of its own.
247
+ const ancestors = custom ? (custom.includes("'self'") || custom === "*" ? custom : `'self' ${custom}`) : local ? "'self' http://127.0.0.1:* http://localhost:* http://[::1]:*" : "*";
248
+ return `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors ${ancestors}`;
249
+ }
250
+ const APP_CSP = appCsp();
240
251
  // Prototypes are written by an agent (from text the requester or a card supplied) and are served
241
252
  // next to the page's own API, so nothing in them may run: the page reads them from outside.
242
- const PROTOTYPE_CSP =
243
- "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; media-src 'self' data:; script-src 'none'; form-action 'none'; frame-ancestors 'self'";
244
- const APP_CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'";
253
+ // frame-ancestors is checked against every page above, so the prototype allows the same ones as
254
+ // the review page (Paseo around it).
255
+ const PROTOTYPE_CSP = `default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; media-src 'self' data:; script-src 'none'; form-action 'none'; ${APP_CSP.slice(APP_CSP.indexOf("frame-ancestors"))}`;
245
256
 
246
257
  function send(res: http.ServerResponse, status: number, body: string | Buffer, type = "text/plain; charset=utf-8", csp = APP_CSP) {
247
258
  res.writeHead(status, {
@@ -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.2",
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",
@@ -9,7 +9,7 @@ import type { Action, RequirementDetail, Step } from "../shared/contracts";
9
9
  import { useAction, useDocument, usePreferences, useRequirement, useRequirements } from "./data";
10
10
  import { ACTION_TEXT, actionsFor, ago, ATTENTION_LABELS, facts, FILE_LABELS, minutes, tokens } from "./format";
11
11
  import { Markdown, tsvToMarkdown } from "./markdown";
12
- import { ReviewLink } from "./review-link";
12
+ import { ReviewButton } from "./review";
13
13
  import { Badge, Button, Chip, Status, useStyles } from "./ui";
14
14
 
15
15
  type Theme = PluginHostProps["theme"];
@@ -174,27 +174,35 @@ 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>
187
196
 
188
- {actions.length > 0 ? (
197
+ {actions.length > 0 || d.hasPrototype ? (
189
198
  <View style={s.row}>
199
+ {d.hasPrototype ? <ReviewButton theme={theme} repo={d.sourceRepo ?? d.repo} featureDir={d.featureDir} title={d.title} waiting={d.attention === "approval"} /> : null}
190
200
  {actions.map((a) => (
191
- <Button key={a} theme={theme} primary={a !== "stop"} label={ACTION_TEXT[a].label} onPress={() => setAction(a)} />
201
+ <Button key={a} theme={theme} primary={a !== "stop" && !(a === "approve" && d.hasPrototype)} label={ACTION_TEXT[a].label} onPress={() => setAction(a)} />
192
202
  ))}
193
203
  </View>
194
204
  ) : null}
195
205
 
196
- {d.hasPrototype ? <ReviewLink theme={theme} compact={layout.compact} repo={d.sourceRepo ?? d.repo} featureDir={d.featureDir} waiting={d.attention === "approval"} /> : null}
197
-
198
206
  {d.files.length > 0 ? (
199
207
  <View style={{ gap: 6 }}>
200
208
  <Text style={s.heading}>文档</Text>
@@ -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
  };
@@ -8,7 +8,7 @@ import { Text, View } from "react-native";
8
8
  import type { Attention, Requirement } from "../shared/contracts";
9
9
  import { useAction, usePreferences } from "./data";
10
10
  import { facts } from "./format";
11
- import { ReviewLink } from "./review-link";
11
+ import { ReviewButton } from "./review";
12
12
  import { Badge, Button, useStyles } from "./ui";
13
13
 
14
14
  type Theme = PluginHostProps["theme"];
@@ -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}
@@ -99,7 +101,7 @@ function InboxCard({ theme, layout, item, open, readOnly }: { theme: Theme; layo
99
101
  </View>
100
102
  ) : (
101
103
  <>
102
- {item.attention === "approval" && item.hasPrototype ? <ReviewLink theme={theme} compact={layout.compact} repo={repo} featureDir={item.featureDir} waiting /> : null}
104
+ {item.attention === "approval" && item.hasPrototype ? <Text style={s.text}>这个方案有界面原型:在原型上点选要改的地方,比写文字说得清楚。</Text> : null}
103
105
  {writing && w?.canReply ? (
104
106
  <View style={{ gap: 8 }}>
105
107
  <TextInput
@@ -126,7 +128,8 @@ function InboxCard({ theme, layout, item, open, readOnly }: { theme: Theme; layo
126
128
  <View style={s.row}>
127
129
  {item.attention === "approval" ? (
128
130
  <>
129
- <Button theme={theme} primary label={run.isPending ? "正在执行…" : "确认方案"} disabled={run.isPending} onPress={() => void act("approve")} />
131
+ {item.hasPrototype ? <ReviewButton theme={theme} repo={repo} featureDir={item.featureDir} title={item.title} waiting /> : null}
132
+ <Button theme={theme} primary={!item.hasPrototype} label={run.isPending ? "正在执行…" : "确认方案"} disabled={run.isPending} onPress={() => void act("approve")} />
130
133
  {w?.canReply ? (
131
134
  writing ? (
132
135
  <Button theme={theme} label="发送修改意见" disabled={run.isPending || !text.trim()} onPress={() => void act("reply")} />
@@ -139,6 +142,7 @@ function InboxCard({ theme, layout, item, open, readOnly }: { theme: Theme; layo
139
142
  {item.attention === "business" && w?.canReply ? (
140
143
  <Button theme={theme} primary label={run.isPending ? "正在发送…" : "发送回答"} disabled={run.isPending || !text.trim()} onPress={() => void act("reply")} />
141
144
  ) : null}
145
+ {item.attention === "permission" && w?.agentId && openAgent ? <Button theme={theme} primary label="打开这个 agent" onPress={() => openAgent(w.agentId!)} /> : null}
142
146
  {item.attention === "interrupted" ? (
143
147
  <Button theme={theme} primary label={run.isPending ? "正在执行…" : "继续"} disabled={run.isPending} onPress={() => void act("resume")} />
144
148
  ) : null}
@@ -150,7 +154,7 @@ function InboxCard({ theme, layout, item, open, readOnly }: { theme: Theme; layo
150
154
  );
151
155
  }
152
156
 
153
- 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 }) {
154
158
  const s = useStyles(theme, layout.compact);
155
159
  const { readOnly } = usePreferences();
156
160
  const waiting = inboxItems(items);
@@ -168,7 +172,7 @@ export function Inbox({ theme, layout, items, open }: { theme: Theme; layout: La
168
172
  <Text style={s.muted}>现在没有需要你处理的事。需要你确认或回答时,会出现在这里。</Text>
169
173
  </View>
170
174
  ) : (
171
- 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} />)
172
176
  )}
173
177
  </View>
174
178
  );
@@ -0,0 +1,12 @@
1
+ // Opening the plugin's screens from anywhere in it (screens themselves are not handed an opener).
2
+ type Opener = (input: { screenId: string; params?: Record<string, string> }) => void;
3
+
4
+ let opener: Opener | null = null;
5
+
6
+ export function setScreenOpener(open: Opener): void {
7
+ opener = open;
8
+ }
9
+
10
+ export function openReview(input: { repo: string; featureDir: string; title: string }): void {
11
+ opener?.({ screenId: "review", params: { repo: input.repo, featureDir: input.featureDir, title: input.title } });
12
+ }
@@ -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>