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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/req.mjs +5 -0
  3. package/config.example.json +3 -1
  4. package/contracts/cli-json.md +5 -1
  5. package/contracts/notifier-event.md +17 -1
  6. package/contracts/state.md +3 -1
  7. package/contracts/usage-record.md +4 -1
  8. package/docs/adr/0031-fewer-interventions.md +41 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
  11. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +3 -2
  12. 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 +58 -4
  13. package/engine/prompts/04b-design-revise.md +15 -0
  14. package/engine/src/api.ts +74 -3
  15. package/engine/src/card-cmd.ts +21 -1
  16. package/engine/src/card.ts +38 -2
  17. package/engine/src/config-view.ts +289 -0
  18. package/engine/src/config.ts +65 -4
  19. package/engine/src/configure.ts +161 -36
  20. package/engine/src/control.ts +1 -0
  21. package/engine/src/doctor.ts +14 -0
  22. package/engine/src/factory.ts +162 -7
  23. package/engine/src/main.ts +1 -0
  24. package/engine/src/messages.ts +22 -0
  25. package/engine/src/notifiers.ts +32 -1
  26. package/engine/src/notify-test.ts +8 -2
  27. package/engine/src/paseo-profiles.ts +2 -0
  28. package/engine/src/paseo-runtime.ts +9 -0
  29. package/engine/src/pipeline.ts +222 -30
  30. package/engine/src/records.ts +8 -0
  31. package/engine/src/reply.ts +59 -0
  32. package/engine/src/runtime.ts +2 -0
  33. package/engine/src/state.ts +41 -1
  34. package/engine/src/stats.ts +90 -0
  35. package/engine/src/steps.ts +4 -1
  36. package/install.mjs +5 -1
  37. package/notifiers/im-template.mjs +31 -5
  38. package/package.json +2 -2
  39. package/paseo-plugin/OVERVIEW.md +2 -0
  40. package/paseo-plugin/client/config.tsx +216 -0
  41. package/paseo-plugin/client/data.ts +21 -2
  42. package/paseo-plugin/client/format.ts +1 -0
  43. package/paseo-plugin/client/inbox.tsx +173 -0
  44. package/paseo-plugin/client/models.tsx +117 -0
  45. package/paseo-plugin/client/overview.tsx +7 -3
  46. package/paseo-plugin/client/settings.tsx +3 -1
  47. package/paseo-plugin/index.client.tsx +10 -0
  48. package/paseo-plugin/index.server.ts +4 -1
  49. package/paseo-plugin/package.json +1 -1
  50. package/paseo-plugin/server/handlers.ts +21 -3
  51. package/paseo-plugin/shared/contracts.ts +77 -1
  52. package/skills/req/playbooks/new.md +14 -14
  53. package/skills/req/playbooks/small.md +3 -3
  54. package/skills/req/playbooks/unattended.md +5 -2
@@ -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
  });
@@ -9,7 +9,9 @@
9
9
  // "options": { "webhook": "https://im.example.internal/hook/xxxx" } }
10
10
  // ]
11
11
  // module 可以是绝对路径、"~/" 开头的路径,或相对配置文件所在目录的路径。
12
- // events 不写时发送全部事件:milestone、blocked、permission、failed、completed、phase_done、approval、digest。
12
+ // events 不写时发送全部事件:milestone、blocked、permission、failed、completed、phase_done、approval、digest、alert。
13
+ // 按人发送(1.12):在本机或团队配置中写上姓名到账号的对应,event.people.recipients 中就会带上账号:
14
+ // "people": { "张广祥": { "um": "ZHANGGX001" } }
13
15
  // 3. 在终端运行 `req notify-test`(或 `req notify-test --type blocked`)发一条测试通知,不需要运行流水线。
14
16
  //
15
17
  // 约定(详见 contracts/notifier-event.md):
@@ -32,19 +34,40 @@ export default async function notify(event, options) {
32
34
  digest: "需求总览",
33
35
  phase_done: "研发完成,可以提测",
34
36
  approval: "请确认方案",
37
+ alert: "接单器告警",
35
38
  }[event.type] ?? event.type;
36
39
 
37
40
  // 一条通用的纯文本消息,大多数 IM 机器人都能直接使用。
38
41
  // 需求总览(digest)的 text 已经是完整的多行文字。
39
- const lines = event.type === "digest" ? [event.text] : [`【req-kit · ${title}】${event.feature.title}`, event.text];
42
+ const lines = event.type === "digest" || event.type === "alert" ? [event.type === "alert" ? `【req-kit · ${title}】${event.text}` : event.text] : [`【req-kit · ${title}】${event.feature.title}`, event.text];
40
43
  if (event.type === "completed" && event.details.summary) lines.push("", String(event.details.summary));
41
44
  if (event.type === "phase_done") lines.push("", `提测说明:${event.details.handoffPath}`);
42
45
  if (event.type === "approval") lines.push("", `要确认的内容:${event.details.previewPath}`);
43
46
  if (event.type === "blocked") lines.push("", ...event.details.questions.map((q, i) => `${i + 1}. ${q}`));
44
- if (event.type !== "digest") lines.push("", `需求目录:${event.feature.dir}${event.branch ? `(分支 ${event.branch})` : ""}`);
47
+ if (event.people?.card) lines.push("", `卡片:${event.people.card.system} ${event.people.card.id}${event.people.card.url ? ` ${event.people.card.url}` : ""}`);
48
+ if (event.type !== "digest" && event.type !== "alert") lines.push("", `需求目录:${event.feature.dir}${event.branch ? `(分支 ${event.branch})` : ""}`);
45
49
  const text = lines.join("\n");
46
50
 
47
- // TODO:按企业内部 IM 的接口实现发送。例如一个接收 JSON 的群机器人:
51
+ // 发给谁(1.12):需要需求方处理的事和结果(blocked、approval、phase_done、completed),
52
+ // 在需求从卡片开始时 audience 为 "requester",recipients 是需求方本人和他在 people 配置中的账号;
53
+ // 其余事件(进展、中断、接单器告警)audience 为 "team",发到群里。
54
+ const people = event.people ?? { audience: "team", recipients: [] };
55
+ const toPerson = people.audience === "requester" && people.recipients.some((r) => Object.keys(r.accounts).length > 0);
56
+
57
+ // TODO:按企业内部 IM 的接口实现发送。例如按人发送:
58
+ //
59
+ // if (toPerson) {
60
+ // for (const r of people.recipients) {
61
+ // await fetch(String(options.personApi), {
62
+ // method: "POST",
63
+ // headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.MY_IM_TOKEN}` },
64
+ // body: JSON.stringify({ to: r.accounts.um, text }),
65
+ // });
66
+ // }
67
+ // return;
68
+ // }
69
+ //
70
+ // 或者一个接收 JSON 的群机器人:
48
71
  //
49
72
  // const response = await fetch(String(options.webhook), {
50
73
  // method: "POST",
@@ -55,6 +78,9 @@ export default async function notify(event, options) {
55
78
  //
56
79
  // 还没有实现时,先什么也不做(流水线日志中会记录"外部通知已发送")。
57
80
  // 用 req notify-test 测试时,把将要发送的内容打印出来,方便核对排版。
58
- if (process.env.REQ_KIT_NOTIFY_TEST === "1") console.log(`--- 将要发送的内容 ---\n${text}\n---------------------`);
81
+ if (process.env.REQ_KIT_NOTIFY_TEST === "1") {
82
+ const to = toPerson ? people.recipients.map((r) => `${r.name}(${Object.entries(r.accounts).map(([k, v]) => `${k} ${v}`).join(",")})`).join("、") : people.audience === "requester" ? `${people.recipients.map((r) => r.name).join("、")}(people 配置中没有账号,改发到群)` : "群";
83
+ console.log(`--- 将要发送的内容(发给:${to})---\n${text}\n---------------------`);
84
+ }
59
85
  void options;
60
86
  }
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.3",
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` 安装和更新。
@@ -0,0 +1,216 @@
1
+ // "req-kit 配置" (1.12): every setting that applies, in plain words, with where each one comes
2
+ // from (default, this machine, the team, the project) and how to change it. Read-only: changes
3
+ // go through the files, req configure, /req-setup, or the model settings page.
4
+ import type { PluginHostProps, PluginScreenProps } from "@getpaseo/plugin/client";
5
+ import { ScrollView, TextInput } from "@getpaseo/plugin/client/react-native";
6
+ import { useMemo, useState } from "react";
7
+ import { Pressable, Text, View } from "react-native";
8
+ import type { ConfigItemData, ConfigLayer, ConfigViewData } from "../shared/contracts";
9
+ import { useConfigView, useRequirements } from "./data";
10
+ import { Badge, Chip, Status, useStyles } from "./ui";
11
+
12
+ type Theme = PluginHostProps["theme"];
13
+ type Layout = PluginHostProps["layout"];
14
+
15
+ const LAYER: Record<ConfigLayer, { label: string; tone: "accent" | "success" | "warning"; hint: string }> = {
16
+ machine: { label: "本机", tone: "accent", hint: "这台机器自己的设置" },
17
+ team: { label: "团队", tone: "warning", hint: "团队共用的配置,覆盖本机的同名设置" },
18
+ project: { label: "项目", tone: "success", hint: "项目仓库中的设置,覆盖本机和团队的" },
19
+ };
20
+
21
+ const SCOPE_NOTE: Record<ConfigItemData["scope"], string | null> = {
22
+ any: null,
23
+ machine: "只能在本机配置中设置",
24
+ machine_team: "只能在本机或团队配置中设置(会运行命令或发消息)",
25
+ };
26
+
27
+ /** Where an item's value comes from: the last file that set it to something other than the default. */
28
+ function originOf(item: ConfigItemData): ConfigLayer | null {
29
+ return item.changed ? (item.setBy.at(-1) ?? null) : null;
30
+ }
31
+
32
+ function ItemRow({ theme, layout, item }: { theme: Theme; layout: Layout; item: ConfigItemData }) {
33
+ const s = useStyles(theme, layout.compact);
34
+ const [open, setOpen] = useState(false);
35
+ const origin = originOf(item);
36
+ const scope = SCOPE_NOTE[item.scope];
37
+ return (
38
+ <Pressable
39
+ accessibilityRole="button"
40
+ accessibilityLabel={`${item.label}:${item.display}`}
41
+ accessibilityState={{ selected: open }}
42
+ onPress={() => setOpen((v) => !v)}
43
+ style={{ paddingVertical: 10, gap: 4 }}
44
+ >
45
+ <View style={[s.row, { justifyContent: "space-between", flexWrap: "nowrap" }]}>
46
+ <Text style={[s.text, { fontWeight: "600", flexShrink: 1 }]}>{item.label}</Text>
47
+ {origin ? (
48
+ <Badge theme={theme} tone={LAYER[origin].tone}>
49
+ {LAYER[origin].label}
50
+ </Badge>
51
+ ) : (
52
+ <Badge theme={theme} tone="muted">
53
+ 默认
54
+ </Badge>
55
+ )}
56
+ </View>
57
+ <Text style={s.text}>{item.display}</Text>
58
+ {item.description ? <Text style={s.muted}>{item.description}</Text> : null}
59
+ {open ? (
60
+ <View style={{ gap: 6, marginTop: 4 }}>
61
+ <Text style={s.muted}>
62
+ 配置项 <Text style={{ fontFamily: "monospace", color: theme.colors.foreground }}>{item.key}</Text>
63
+ {item.setBy.length > 0 ? ` · 写在${item.setBy.map((l) => LAYER[l].label).join("、")}配置中` : " · 没有在任何配置文件中写,用的是默认值"}
64
+ {scope ? ` · ${scope}` : ""}
65
+ </Text>
66
+ <View style={{ backgroundColor: theme.colors.surface0, borderRadius: 8, borderWidth: 1, borderColor: theme.colors.border, padding: 10 }}>
67
+ <Text selectable style={{ fontFamily: "monospace", fontSize: 12, color: theme.colors.foreground }}>
68
+ {JSON.stringify(item.value, null, 2) ?? "null"}
69
+ </Text>
70
+ </View>
71
+ </View>
72
+ ) : null}
73
+ </Pressable>
74
+ );
75
+ }
76
+
77
+ function Sources({ theme, layout, view }: { theme: Theme; layout: Layout; view: ConfigViewData }) {
78
+ const s = useStyles(theme, layout.compact);
79
+ return (
80
+ <View style={[s.card, { gap: 8 }]}>
81
+ <Text style={s.heading}>配置从哪里来</Text>
82
+ <Text style={s.muted}>按下面的顺序叠加,后面的覆盖前面的;没有写的用 req-kit 的默认值。</Text>
83
+ {view.sources.length === 0 ? <Text style={s.text}>还没有本机配置文件。在终端运行 req setup 生成。</Text> : null}
84
+ {view.sources.map((src, i) => (
85
+ <View key={src.path} style={[s.row, { flexWrap: "nowrap", alignItems: "flex-start" }]}>
86
+ <Text style={[s.muted, { width: 18 }]}>{i + 1}.</Text>
87
+ <Badge theme={theme} tone={LAYER[src.layer].tone}>
88
+ {LAYER[src.layer].label}
89
+ </Badge>
90
+ <View style={{ flexShrink: 1, gap: 2 }}>
91
+ <Text selectable style={[s.text, { fontFamily: "monospace", fontSize: 12 }]}>
92
+ {src.path}
93
+ </Text>
94
+ <Text style={s.muted}>{LAYER[src.layer].hint}</Text>
95
+ </View>
96
+ </View>
97
+ ))}
98
+ {view.ignored.length > 0 ? <Text style={{ color: theme.colors.statusWarning, fontSize: 13 }}>没有生效:{view.ignored.join("、")}(这些设置不能写在团队或项目配置中)</Text> : null}
99
+ </View>
100
+ );
101
+ }
102
+
103
+ export function ConfigScreen({ theme, layout }: PluginScreenProps) {
104
+ const s = useStyles(theme, layout.compact);
105
+ const requirements = useRequirements();
106
+ // A project is any checkout requirements were started from.
107
+ const projects = useMemo(() => {
108
+ const map = new Map<string, string>();
109
+ for (const r of requirements.data ?? []) if (r.present) map.set(r.sourceRepo ?? r.repo, r.project);
110
+ return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1]));
111
+ }, [requirements.data]);
112
+ const [repo, setRepo] = useState<string | null>(null);
113
+ const [query, setQuery] = useState("");
114
+ const [changedOnly, setChangedOnly] = useState(false);
115
+ const config = useConfigView(repo);
116
+ const view = config.data;
117
+
118
+ const groups = useMemo(() => {
119
+ if (!view) return [];
120
+ const q = query.trim().toLowerCase();
121
+ return view.groups
122
+ .map((g) => ({
123
+ ...g,
124
+ items: g.items.filter(
125
+ (i) => (!changedOnly || i.changed) && (!q || `${i.label} ${i.key} ${i.display} ${i.description}`.toLowerCase().includes(q)),
126
+ ),
127
+ }))
128
+ .filter((g) => g.items.length > 0);
129
+ }, [view, query, changedOnly]);
130
+ const changedCount = view ? view.groups.reduce((n, g) => n + g.items.filter((i) => i.changed).length, 0) : 0;
131
+
132
+ return (
133
+ <View style={s.screen}>
134
+ <ScrollView contentContainerStyle={s.body}>
135
+ <View style={{ gap: 4 }}>
136
+ <Text style={s.title}>req-kit 配置</Text>
137
+ <Text style={s.muted}>这台机器上生效的全部设置,用大白话写出每一项是什么、现在是什么、来自哪个文件。这一页只看不改,修改方法在最下面。</Text>
138
+ </View>
139
+
140
+ <View style={{ gap: 6 }}>
141
+ <Text style={s.heading}>看哪里的配置</Text>
142
+ <View style={s.row}>
143
+ <Chip theme={theme} label="本机(不含项目)" active={repo === null} onPress={() => setRepo(null)} />
144
+ {projects.map(([dir, name]) => (
145
+ <Chip key={dir} theme={theme} label={name} active={repo === dir} onPress={() => setRepo(dir)} />
146
+ ))}
147
+ </View>
148
+ {projects.length === 0 ? <Text style={s.muted}>开始过需求的项目会出现在这里,可以看到项目配置叠加后的结果。</Text> : null}
149
+ </View>
150
+
151
+ <Status theme={theme} loading={config.isPending} error={config.error} />
152
+ {view ? (
153
+ <>
154
+ {view.error ? (
155
+ <View style={[s.card, { borderColor: theme.colors.statusDanger }]}>
156
+ <Text style={[s.text, { color: theme.colors.statusDanger, fontWeight: "600" }]}>配置有错误,流水线现在无法启动</Text>
157
+ <Text style={s.text}>{view.error}</Text>
158
+ <Text style={s.muted}>下面显示的是各文件中写的值。修好后在终端运行 req doctor 检查。</Text>
159
+ </View>
160
+ ) : null}
161
+ <Sources theme={theme} layout={layout} view={view} />
162
+
163
+ <View style={{ gap: 8 }}>
164
+ <TextInput
165
+ value={query}
166
+ onChangeText={setQuery}
167
+ placeholder="搜索设置,例如:超时、备用模型、推送"
168
+ placeholderTextColor={theme.colors.foregroundMuted}
169
+ style={{
170
+ paddingHorizontal: 12,
171
+ paddingVertical: 8,
172
+ borderRadius: 8,
173
+ borderWidth: 1,
174
+ borderColor: theme.colors.border,
175
+ backgroundColor: theme.colors.surface1,
176
+ color: theme.colors.foreground,
177
+ fontSize: 14,
178
+ }}
179
+ />
180
+ <View style={s.row}>
181
+ <Chip theme={theme} label="全部" active={!changedOnly} onPress={() => setChangedOnly(false)} />
182
+ <Chip theme={theme} label={`只看改过的(${changedCount})`} active={changedOnly} onPress={() => setChangedOnly(true)} />
183
+ </View>
184
+ </View>
185
+
186
+ {groups.length === 0 ? <Text style={s.muted}>{changedOnly && !query ? "所有设置都是默认值。" : "没有符合的设置。"}</Text> : null}
187
+ {groups.map((g) => (
188
+ <View key={g.id} style={{ gap: 6 }}>
189
+ <View style={{ gap: 2 }}>
190
+ <Text style={[s.text, { fontSize: 15, fontWeight: "600" }]}>{g.title}</Text>
191
+ <Text style={s.muted}>{g.description}</Text>
192
+ </View>
193
+ <View style={[s.card, { paddingVertical: 2 }]}>
194
+ {g.items.map((item, i) => (
195
+ <View key={item.key}>
196
+ {i > 0 ? <View style={s.divider} /> : null}
197
+ <ItemRow theme={theme} layout={layout} item={item} />
198
+ </View>
199
+ ))}
200
+ </View>
201
+ </View>
202
+ ))}
203
+
204
+ <View style={[s.card, { gap: 6 }]}>
205
+ <Text style={s.heading}>怎么修改</Text>
206
+ <Text style={s.text}>· 模型(builder、reviewer、备用模型):设置 → 插件 → req-kit → 模型,或在终端运行 req configure。</Text>
207
+ <Text style={s.text}>· 项目的检查命令、服务启动方式、接哪些卡片:在项目的对话中运行 /req-setup。</Text>
208
+ <Text style={s.text}>· 其他设置:编辑上面"配置从哪里来"中的文件,写法见 req-kit 文档的《项目定制与运行规则》。改完运行 req doctor 检查。</Text>
209
+ <Text style={s.muted}>密码、令牌等看起来像密钥的值和个人账号不会在这里显示。</Text>
210
+ </View>
211
+ </>
212
+ ) : null}
213
+ </ScrollView>
214
+ </View>
215
+ );
216
+ }
@@ -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 { configRpc, 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);
@@ -49,7 +49,26 @@ export function useAction() {
49
49
  const run = useRpc(runRpc);
50
50
  const queries = useQueryClient();
51
51
  return useMutation({
52
- mutationFn: (input: { action: Action; repo: string; featureDir: string; verifyEnv?: "local" | "deployed" }) => run(input),
52
+ mutationFn: (input: { action: Action; repo: string; featureDir: string; verifyEnv?: "local" | "deployed"; text?: string }) => run(input),
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
+ }
70
+
71
+ export function useConfigView(repo: string | null) {
72
+ const config = useRpc(configRpc);
73
+ return useQuery({ queryKey: ["req", "config", repo ?? ""], queryFn: () => config(repo ? { repo } : {}), retry: false });
74
+ }
@@ -108,4 +108,5 @@ export const ACTION_TEXT = {
108
108
  resume: { label: "继续", confirm: "从中断处继续?需求说明改过的话,只处理变化的部分。" },
109
109
  verify: { label: "开始测试验证", confirm: "开始测试验证?选择在哪里验证。" },
110
110
  sync: { label: "同步到外部系统", confirm: "把这个需求同步到对接的外部系统:补发没有成功的调用、补建卡片、更新状态。" },
111
+ reply: { label: "发送回复", confirm: "把回复转给这个需求的对话,由它更新需求说明并继续开发。" },
111
112
  } as const;