paseo-req-kit 1.10.0-beta.2 → 1.10.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  每个版本分为"使用者会看到的变化"和"升级须知"。升级步骤见 [docs/guide/安装与升级.md](docs/guide/安装与升级.md)。
4
4
 
5
+ ## 1.10.0-beta.3
6
+
7
+ 从卡片开始的需求:按子系统自动选仓库、研发完成后推送分支、统计按需求方汇总。
8
+
9
+ ### 使用者会看到的变化
10
+
11
+ - **按子系统选仓库**:在仓库中运行 `/req-setup` 时可以写上这个仓库接哪个空间、哪些子系统的卡片(同一子系统有几个仓库时再写标题关键字),并在本机登记。之后 `req start --from ALM:<卡号>` 不用写 `--repo`。`req card route` 查看本机登记的仓库。
12
+ - **推送分支**(默认关闭):本机或团队配置打开 `push` 后,从卡片开始的需求在研发完成、写好提测说明后推送到 `origin` 的 `req/<卡号简称>` 分支,分支名写进卡片。不强推、不合并。
13
+ - **统计**:运行记录带上需求方和卡号,`req stats --report` 多一节"从卡片开始的需求(按需求方)",CSV 多 `requester`、`card` 两列。
14
+
15
+ ### 升级须知
16
+
17
+ - 都是可选的。推送只在 `push.enabled` 为 true、需求是从卡片开始的时候发生,并且只能在本机或团队配置中打开(ADR 0029 记录了这条对"只在本地提交"规则的放宽)。
18
+ - 新增项目配置 `cardSource`、本机文件 `~/.req-kit/card-repos.json`、状态文件可选字段 `pushed`、运行记录可选字段 `requirement.requester` 和 `requirement.source`,格式版本都不变。
19
+
5
20
  ## 1.10.0-beta.2
6
21
 
7
22
  (1.10.0-beta.1 的发布任务因一个测试的顺序问题失败,没有发布,内容相同,以 beta.2 发布。)
package/bin/req.mjs CHANGED
@@ -35,6 +35,7 @@ const HELP = `req-kit ${version}(${kitRoot})
35
35
  req card check [<卡号>|--all] 读取需求方在卡片中的回复,转给需求的会话
36
36
  req card publish <卡号> 把需求会话写的 card-ai.md 写进卡片的 AI 区
37
37
  req card list 本机从卡片开始的需求
38
+ req card route 本机接收卡片的仓库和子系统(--add <仓库> 登记)
38
39
 
39
40
  使用情况(可以在任何目录运行):
40
41
  req stats [--since 30d] [--month 2026-10]
@@ -35,6 +35,7 @@
35
35
  | `joint` | array \| null | 可选,1.8.0 起。联合需求(spec 的 `- 联合开发:`)中其他仓库的工作区:`name`(`relatedRepos` 中的名称)、`slug`(步骤键的后缀,如 `implement@backend`)、`sourceRepo`(切出工作区的那份检出)、`repo`(工作区目录)、`workspaceId`、`branch`、`specHash`(最近一次同步过去的 spec),以及这个仓库自己的 `codeBase` 和 `gate`(格式同顶层)。见 ADR 0025 |
36
36
  | `approval` | object \| null | 可选,1.8.0 起。方案确认点:`attempt`(哪一次尝试的方案)、`status`(`waiting` / `approved`)、`at`。需求变更后的新尝试需要重新确认 |
37
37
  | `external` | object \| null | 可选,1.9.0 起。外部系统中的卡片,按集成名称:`id`(卡片编号)、`url`、`status`(最近设置的状态)、`syncedAt`、`pending`(没有成功、等待补发的调用:`action` 为 `create` / `status`、`event`、`status`、`at`、`attempts`、`error`)。见 ADR 0028 |
38
+ | `pushed` | array \| null | 可选,1.10 起。从卡片开始、配置了 `push` 的需求推送过的分支,每个仓库一条(主仓库在前):`repo`、`remote`、`branch`、`at`、`error`(成功时为 null)。见 ADR 0029 |
38
39
  | `gate` | object \| null | 可选,1.8.0 起。质量门:`baseline`(`attempt`、`commit`、`at`、`results`:每项检查改动前 `pass` / `fail`),`runs`(每次运行:`key` 步骤键、`attempt`、`at`、`checks`(`name`、`result`:`pass` / `fail` / `skipped`、`failedBefore`)、`newFailures`、`fixRounds`、`passed`)。各次运行的完整输出在 `.pipeline/checks/<步骤键>/` |
39
40
 
40
41
  `schedules[].kind` 在 1.7.0 起还可能是 `verify-start`(`--verify-at` 预约的测试验证开始),1.8.0 起还可能是 `approval-continue`(方案确认超时后按方案继续,`designApproval.autoContinueHours`)。`history` 中每条步骤记录在 1.7.0 起有可选的 `phase`(`develop` / `verify`),用于按阶段统计用时与用量;没有时按研发阶段统计。
@@ -29,7 +29,7 @@
29
29
  | --- | --- |
30
30
  | `schema` / `kind` | `1` / `"run"` |
31
31
  | `machine` / `kitVersion` | 主机名、req-kit 版本 |
32
- | `requirement` | `{ key, project, name, title, type, profile }` |
32
+ | `requirement` | `{ key, project, name, title, type, profile }`;从卡片开始的需求(1.10 起)另有 `requester`(卡片的提出人)和 `source`(`{ system, id }`) |
33
33
  | `run` | `{ startedAt, endedAt, attempt, phase, phaseMode, status, outcome, stopReason }`;`status` 是运行结束时的状态,`outcome` 是完成时报告的结论 |
34
34
  | `agents` | `{ builder, reviewer }`:当时配置的 agent(如 `claude/opus(bypassPermissions)`) |
35
35
  | `steps` | 这次运行中开始的步骤:`{ key, stepId, phase, attempt, round, startedAt, endedAt, minutes, outcome, inputTokens, outputTokens, costUsd }`;agent 没有报告的用量为 null |
@@ -30,4 +30,6 @@
30
30
  - 新命令:`req start --from`、`req card publish | check | list`。本机新增 `~/.req-kit/cards/`(从卡片开始的需求的登记)。
31
31
  - 功能目录新增 `source.json`、`card-source.md`、`card-ai.md`。
32
32
  - 运行时新增 `startSessionAgent`:建一个不挂在任何对话下的 agent,工作目录是仓库。
33
- - beta.3:推送 `req/<卡号>-<简称>` 分支(只对从卡片开始的需求、默认关闭)、`/req-setup` 记录 ALM 空间和子系统并自动选仓库、使用记录加上需求方。1.11:接单器 `req factory`。
33
+ - **按子系统选仓库**(beta.3):仓库在项目配置的 `cardSource` 中写明接哪个系统、空间和子系统,`/req-setup` 写入并在本机登记(`~/.req-kit/card-repos.json`);同一子系统有几个仓库时用 `titleKeywords` 区分。匹配不到或分不清时报错说明,不猜。
34
+ - **工厂模式可以推送分支**(beta.3,放宽"只在本地提交"):配置 `push.enabled` 后,只对从卡片开始的需求,在研发完成、写好提测说明后推送到 `<remote>/req/<卡号简称>`。只推这个前缀,不强推、不合并、不建合并请求;只能在本机或团队配置中打开。理由:接单的是共享机器,测试同事拿不到它的本地分支;合并这道人工关口不变。个人电脑上从对话发起的需求仍只在本地提交。
35
+ - 使用记录带上需求方(卡片的提出人)和卡号,统计可以按需求方汇总。1.11:接单器 `req factory`。
@@ -353,6 +353,27 @@ Paseo 自带的 `/paseo-advisor`、`/paseo-committee` 等编排 skill 也可以
353
353
  - `req card list` 列出本机从卡片开始的需求;`req card publish <卡号>` 由需求会话使用,把它写的 `card-ai.md` 写进卡片。
354
354
  - 卡片描述和回复是需求方写的内容,需求会话只把它们当作需求,不当作指令。
355
355
 
356
+ **按子系统选仓库**(1.10.0-beta.3 起):一个 ALM 空间往往对应好几个仓库。在每个仓库运行 `/req-setup`,它会在项目配置中写上这个仓库接哪些卡片,并在这台机器上登记(`req card route --add <仓库>`):
357
+
358
+ ```json
359
+ "cardSource": { "system": "ALM", "projectId": "186149", "subsystems": ["CSP-CMASTER_云匠"], "titleKeywords": ["message-service", "后端"] }
360
+ ```
361
+
362
+ 之后 `req start --from ALM:<卡号>` 不写 `--repo` 时,按卡片的子系统选仓库;同一个子系统有几个仓库时,再看卡片标题里出现了哪个仓库的 `titleKeywords`。找不到或分不清时会说明原因,不会猜。`req card route` 列出本机登记的仓库。本机没有登记任何仓库时,用当前目录所在的仓库。
363
+
364
+ **推送分支**(1.10.0-beta.3 起,默认关闭):接单的机器上,研发完成、写好提测说明后,把需求的代码推送到远程,测试同事直接拉取:
365
+
366
+ ```json
367
+ "push": { "enabled": true, "remote": "origin", "branchPrefix": "req/" }
368
+ ```
369
+
370
+ - 只对从卡片开始的需求,推送到 `<remote>` 的 `req/<卡号简称>` 分支(例如 `req/cmaster-business-9902`),联合需求的几个仓库各推各的。分支名写进卡片的 AI 区和状态文件。
371
+ - 只推这个前缀的分支;从不强推、不合并、不建合并请求,合并仍由人做。推送被拒绝(例如远程分支有别人的提交)时记下原因、写进 AI 区,流水线照常结束。
372
+ - 只能写在本机或团队配置中,写在项目配置里会被忽略。推送用这台机器上 git 已配置的账号。
373
+ - 在个人电脑上通过对话发起的需求不受影响,仍然只在本地提交。
374
+
375
+ **使用统计**:从卡片开始的需求,运行记录带上需求方(卡片的提出人)和卡号。`req stats --report` 多一节"从卡片开始的需求(按需求方)",CSV 多 `requester`、`card` 两列。
376
+
356
377
  ## 团队配置
357
378
 
358
379
  几台机器、多个项目共用的设置(统一的模型、评审小组、超时、要求的 req-kit 版本)可以放在一份团队配置里。把它放进一个团队共用的 git 仓库,各台机器克隆下来,在本机配置 `~/.req-kit/config.json` 中写上它的路径(绝对路径,或相对 `~/.req-kit/` 的路径):
@@ -25,7 +25,12 @@ import {
25
25
  planAppendix,
26
26
  publishCard,
27
27
  queryCard,
28
+ cardRoutes,
29
+ readRoute,
28
30
  readSource,
31
+ registeredRepos,
32
+ routeCard,
33
+ setRegisteredRepos,
29
34
  replyKey,
30
35
  saveCardEntry,
31
36
  type SourceFile,
@@ -43,10 +48,11 @@ import { readState, statePath } from "./state.js";
43
48
  const REQ = `node "${path.join(KIT_ROOT, "bin", "req.mjs").split(path.sep).join("/")}"`;
44
49
 
45
50
  const USAGE = `用法:
46
- req start --from <系统>:<卡号> [--repo <仓库目录>] 从需求管理系统中的一张卡片开始需求
51
+ req start --from <系统>:<卡号> [--repo <仓库目录>] 从需求管理系统中的一张卡片开始需求(不写 --repo 时按子系统选仓库)
47
52
  req card publish <卡号> [--progress <文字>] 把功能目录中的 card-ai.md 写进卡片的 AI 区
48
53
  req card check [<卡号> | --all] 读取需求方在卡片中的回复,把新内容转给需求的会话
49
54
  req card list 本机从卡片开始的需求
55
+ req card route [--add <仓库> | --remove <仓库>] 本机接收卡片的仓库及其子系统(--from 不写 --repo 时按它选仓库)
50
56
 
51
57
  <卡号> 可以写成 <系统>:<卡号>,例如 ALM:CMASTER-BUSINESS#9902;只配置了一个可以读取卡片的系统时可以省略系统。`;
52
58
 
@@ -139,10 +145,9 @@ async function startFromCard(argv: string[], opts: CardCmdOptions): Promise<numb
139
145
  const out = opts.out ?? console.log;
140
146
  const { values } = parseArgs({ args: argv, options: { from: { type: "string" }, repo: { type: "string" }, config: { type: "string" } }, allowPositionals: false });
141
147
  if (!values.from) fail(`缺少 --from\n\n${USAGE}`);
142
- const repo = gitTop(path.resolve(opts.cwd ?? process.cwd(), values.repo ?? "."));
143
148
  const configPath = values.config ? path.resolve(values.config) : defaultConfigPath(opts.env);
144
- const config = loadConfig(configPath, repo).config;
145
- const { config: integration, id } = parseCardRef(values.from, cardIntegrations(config));
149
+ // Integrations live in the machine or team config, so the card can be read before the repository is known.
150
+ const { config: integration, id } = parseCardRef(values.from, cardIntegrations(loadConfig(configPath).config));
146
151
 
147
152
  const existing = findCardEntry(integration.name, id, opts.env);
148
153
  if (existing) {
@@ -152,6 +157,11 @@ async function startFromCard(argv: string[], opts: CardCmdOptions): Promise<numb
152
157
  }
153
158
 
154
159
  const card = await queryCard(integration, id, opts.run);
160
+ const cwd = opts.cwd ?? process.cwd();
161
+ const routed = values.repo ? null : routeCard(card, integration.name, opts.env);
162
+ const repo = gitTop(values.repo ? path.resolve(cwd, values.repo) : (routed ?? cwd));
163
+ if (routed) out(`按子系统「${card.subsystem}」选择仓库:${repo}`);
164
+ const config = loadConfig(configPath, repo).config;
155
165
  const parts = splitDescription(card.body);
156
166
  const featureDir = `docs/features/${today()}-${cardSlug(id)}`;
157
167
  fs.mkdirSync(path.join(repo, featureDir), { recursive: true });
@@ -330,6 +340,36 @@ async function check(argv: string[], opts: CardCmdOptions): Promise<number> {
330
340
  return failed > 0 ? 1 : 0;
331
341
  }
332
342
 
343
+ function route(argv: string[], opts: CardCmdOptions): number {
344
+ const out = opts.out ?? console.log;
345
+ const { values } = parseArgs({ args: argv, options: { add: { type: "string" }, remove: { type: "string" }, config: { type: "string" } }, allowPositionals: false });
346
+ const cwd = opts.cwd ?? process.cwd();
347
+ const configPath = values.config ? path.resolve(values.config) : defaultConfigPath(opts.env);
348
+ const systems = fs.existsSync(configPath) ? cardIntegrations(loadConfig(configPath).config).map((i) => i.name) : [];
349
+ if (values.add) {
350
+ const repo = gitTop(path.resolve(cwd, values.add));
351
+ const r = readRoute(repo);
352
+ setRegisteredRepos([...registeredRepos(opts.env), repo], opts.env);
353
+ out(r ? `已登记 ${repo}:${r.system} 空间 ${r.projectId ?? "(未写)"},子系统 ${r.subsystems.join("、") || "(未写)"}` : `已登记 ${repo},但它的项目配置还没有 cardSource(运行 /req-setup 写上)`);
354
+ return 0;
355
+ }
356
+ if (values.remove) {
357
+ const repo = gitTop(path.resolve(cwd, values.remove));
358
+ setRegisteredRepos(registeredRepos(opts.env).filter((r) => r !== repo), opts.env);
359
+ out(`已取消登记 ${repo}`);
360
+ return 0;
361
+ }
362
+ if (systems.length === 0) out("没有配置可以读取卡片的外部系统(本机或团队配置的 integrations 中没有写 query)。");
363
+ const { routes, missing } = cardRoutes(opts.env);
364
+ if (routes.length === 0 && missing.length === 0) {
365
+ out("本机还没有登记接收卡片的仓库:在仓库中运行 /req-setup(或 req card route --add <仓库目录>)。");
366
+ return 0;
367
+ }
368
+ for (const r of routes) out(`${r.system} 空间 ${r.projectId ?? "-"} 子系统 ${r.subsystems.join("、") || "-"} 标题关键字 ${r.titleKeywords.join("、") || "-"} ${r.repo}`);
369
+ for (const m of missing) out(`(没有 cardSource 或目录不存在)${m}`);
370
+ return 0;
371
+ }
372
+
333
373
  function list(opts: CardCmdOptions): number {
334
374
  const out = opts.out ?? console.log;
335
375
  const entries = listCardEntries(opts.env);
@@ -360,6 +400,8 @@ export async function main(argv: string[], opts: CardCmdOptions = {}): Promise<n
360
400
  case "list":
361
401
  case "ls":
362
402
  return list(opts);
403
+ case "route":
404
+ return route(rest, opts);
363
405
  default:
364
406
  console.log(USAGE);
365
407
  return command === undefined || command === "help" || command === "--help" ? 0 : 2;
@@ -395,3 +395,87 @@ export function planAppendix(repo: string, featureDir: string): string | null {
395
395
  const shown = preview.length > 4000 ? `${preview.slice(0, 4000)}\n\n……(完整内容见功能目录中的 preview.md)` : preview;
396
396
  return `### 待确认的方案\n\n${shown.replace(/^(#{1,4})\s/gm, "$1## ")}\n\n确认没问题就在回复区写“方案确认:同意”;要改就在那一行后写明要改什么。`;
397
397
  }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Which repository a card belongs to (1.10): each repository says in its project file
401
+ // (cardSource, written by /req-setup) which system, space and subsystems it takes; the
402
+ // machine keeps a list of repositories that take cards (~/.req-kit/card-repos.json).
403
+
404
+ export interface CardRoute {
405
+ repo: string;
406
+ system: string;
407
+ projectId: string | null;
408
+ subsystems: string[];
409
+ titleKeywords: string[];
410
+ }
411
+
412
+ function cardReposFile(env: NodeJS.ProcessEnv): string {
413
+ return path.join(kitHome(env), "card-repos.json");
414
+ }
415
+
416
+ export function registeredRepos(env: NodeJS.ProcessEnv = process.env): string[] {
417
+ try {
418
+ const list = JSON.parse(fs.readFileSync(cardReposFile(env), "utf8")) as unknown;
419
+ return Array.isArray(list) ? list.filter((r): r is string => typeof r === "string") : [];
420
+ } catch {
421
+ return [];
422
+ }
423
+ }
424
+
425
+ export function setRegisteredRepos(repos: string[], env: NodeJS.ProcessEnv = process.env): void {
426
+ fs.mkdirSync(path.dirname(cardReposFile(env)), { recursive: true });
427
+ fs.writeFileSync(cardReposFile(env), `${JSON.stringify([...new Set(repos)].sort(), null, 2)}\n`);
428
+ }
429
+
430
+ /** The repository's cardSource, read from its project file (not merged with other layers). */
431
+ export function readRoute(repo: string): CardRoute | null {
432
+ try {
433
+ const raw = JSON.parse(fs.readFileSync(path.join(repo, ".requirement-pipeline.json"), "utf8")) as { cardSource?: Record<string, unknown> };
434
+ const c = raw.cardSource;
435
+ if (!c || typeof c.system !== "string") return null;
436
+ const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.trim() !== "") : []);
437
+ return { repo, system: c.system, projectId: c.projectId === undefined || c.projectId === null ? null : String(c.projectId), subsystems: strings(c.subsystems), titleKeywords: strings(c.titleKeywords) };
438
+ } catch {
439
+ return null;
440
+ }
441
+ }
442
+
443
+ /** Routes of the repositories this machine knows: registered ones, and those cards started in. */
444
+ export function cardRoutes(env: NodeJS.ProcessEnv = process.env): { routes: CardRoute[]; missing: string[] } {
445
+ const repos = [...new Set([...registeredRepos(env), ...listCardEntries(env).map((e) => e.sourceRepo)])];
446
+ const routes: CardRoute[] = [];
447
+ const missing: string[] = [];
448
+ for (const repo of repos) {
449
+ const route = fs.existsSync(repo) ? readRoute(repo) : null;
450
+ if (route) routes.push(route);
451
+ else missing.push(repo);
452
+ }
453
+ return { routes, missing };
454
+ }
455
+
456
+ const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
457
+
458
+ /**
459
+ * The repository for a card: the one whose cardSource names the card's system and subsystem;
460
+ * when several share the subsystem, the one whose titleKeywords appear in the card's title.
461
+ * Null when no repository on this machine takes cards (then the caller uses --repo or the
462
+ * current directory). Throws when repositories take cards but none or several match.
463
+ */
464
+ export function routeCard(card: Pick<CardData, "id" | "title" | "subsystem">, system: string, env: NodeJS.ProcessEnv = process.env): string | null {
465
+ const { routes } = cardRoutes(env);
466
+ const ofSystem = routes.filter((r) => same(r.system, system));
467
+ if (ofSystem.length === 0) return null;
468
+ const bySubsystem = card.subsystem ? ofSystem.filter((r) => r.subsystems.some((s) => same(s, card.subsystem!))) : [];
469
+ if (bySubsystem.length === 0) {
470
+ throw new Error(
471
+ `卡片 ${card.id} 的子系统「${card.subsystem ?? "(没有)"}」没有对应的仓库。本机登记的仓库:${ofSystem.map((r) => `${r.repo}(${r.subsystems.join("、") || "未写子系统"})`).join(";")}。请在对应仓库运行 /req-setup 写上这个子系统,或用 --repo 指定。`,
472
+ );
473
+ }
474
+ if (bySubsystem.length === 1) return bySubsystem[0].repo;
475
+ const title = card.title.toLowerCase();
476
+ const byKeyword = bySubsystem.filter((r) => r.titleKeywords.some((k) => title.includes(k.toLowerCase())));
477
+ if (byKeyword.length === 1) return byKeyword[0].repo;
478
+ throw new Error(
479
+ `卡片 ${card.id}「${card.title}」可能属于几个仓库:${(byKeyword.length ? byKeyword : bySubsystem).map((r) => `${r.repo}(标题关键字:${r.titleKeywords.join("、") || "未写"})`).join(";")}。请在各仓库的 cardSource.titleKeywords 中写上能区分它们的词(例如服务名、"前端""后端"),或用 --repo 指定。`,
480
+ );
481
+ }
@@ -180,6 +180,18 @@ export interface PipelineConfig {
180
180
  integrations: IntegrationConfig[];
181
181
  /** Per integration name, values for its {field.*} placeholders; a project's file may set these (e.g. its project id). */
182
182
  integrationDefaults: Record<string, Record<string, string>>;
183
+ /**
184
+ * Pushing the branch of a requirement that started from a card, once development is done and
185
+ * the handoff is written (1.10). Off by default; machine or team config only. Never forced,
186
+ * only to branches under `branchPrefix`, never merged.
187
+ */
188
+ push: { enabled: boolean; remote: string; branchPrefix: string };
189
+ /**
190
+ * Which cards this repository takes (1.10, written by /req-setup in the project's file): the
191
+ * system, its space (project id) and subsystems; `titleKeywords` tells apart repositories
192
+ * that share a subsystem (e.g. "message-service", "后端").
193
+ */
194
+ cardSource: { system: string; projectId: string | null; subsystems: string[]; titleKeywords: string[] } | null;
183
195
  /** Automatic retries of a step after a transient error (rate limit, dropped connection). */
184
196
  retry: { attempts: number; backoffSeconds: number[] };
185
197
  /**
@@ -284,6 +296,8 @@ export const DEFAULT_CONFIG: PipelineConfig = {
284
296
  notifiers: [],
285
297
  integrations: [],
286
298
  integrationDefaults: {},
299
+ push: { enabled: false, remote: "origin", branchPrefix: "req/" },
300
+ cardSource: null,
287
301
  retry: { attempts: 2, backoffSeconds: [60, 300] },
288
302
  paseoServices: "auto",
289
303
  serviceReadySeconds: 120,
@@ -347,6 +361,16 @@ function merge(base: PipelineConfig, raw: Record<string, unknown>): PipelineConf
347
361
  }
348
362
  merged.integrationDefaults = combined;
349
363
  }
364
+ merged.push = { ...base.push, ...(isRecord(raw.push) ? raw.push : {}) } as PipelineConfig["push"];
365
+ if (isRecord(raw.cardSource)) {
366
+ const c = raw.cardSource;
367
+ merged.cardSource = {
368
+ system: c.system as string,
369
+ projectId: c.projectId === undefined || c.projectId === null ? null : String(c.projectId),
370
+ subsystems: (c.subsystems ?? []) as string[],
371
+ titleKeywords: (c.titleKeywords ?? []) as string[],
372
+ };
373
+ }
350
374
  merged.stepTimeoutMinutes = {
351
375
  ...base.stepTimeoutMinutes,
352
376
  ...(isRecord(raw.stepTimeoutMinutes) ? raw.stepTimeoutMinutes : {}),
@@ -535,6 +559,17 @@ function validate(config: PipelineConfig): void {
535
559
  if (!isRecord(config.integrationDefaults) || !Object.values(config.integrationDefaults).every((m) => isRecord(m) && Object.values(m).every((v) => typeof v === "string"))) {
536
560
  throw new ConfigError('integrationDefaults 必须是 { "<集成名称>": { "<字段>": "<值>" } }');
537
561
  }
562
+ const push = config.push;
563
+ if (typeof push.enabled !== "boolean") throw new ConfigError("push.enabled 必须是 true 或 false");
564
+ if (typeof push.remote !== "string" || !/^[\w.-]+$/.test(push.remote)) throw new ConfigError('push.remote 必须是远程仓库名,例如 "origin"');
565
+ if (typeof push.branchPrefix !== "string" || !/^[A-Za-z0-9._-]+\/$/.test(push.branchPrefix)) throw new ConfigError('push.branchPrefix 必须是以 / 结尾的分支前缀,例如 "req/"');
566
+ const cs = config.cardSource;
567
+ if (cs !== null) {
568
+ if (typeof cs.system !== "string" || !cs.system.trim()) throw new ConfigError('cardSource.system 必须是对接系统的名称,例如 "ALM"');
569
+ for (const k of ["subsystems", "titleKeywords"] as const) {
570
+ if (!Array.isArray(cs[k]) || !cs[k].every((v) => typeof v === "string" && v.trim())) throw new ConfigError(`cardSource.${k} 必须是字符串数组`);
571
+ }
572
+ }
538
573
  if (config.requiredVersion !== null && typeof config.requiredVersion !== "string") {
539
574
  throw new ConfigError('requiredVersion 必须是版本范围字符串,例如 ">=1.2.0 <2"');
540
575
  }
@@ -570,7 +605,7 @@ export interface LoadedConfig {
570
605
  }
571
606
 
572
607
  /** Keys a project file may not set: they run code or reach outside the repository. */
573
- const NOT_IN_PROJECT_KEYS = ["notifiers", "integrations"] as const;
608
+ const NOT_IN_PROJECT_KEYS = ["notifiers", "integrations", "push"] as const;
574
609
 
575
610
  /** Notifier module paths are relative to the file that names them; "~/" is the home directory. */
576
611
  function resolveNotifiers(raw: Record<string, unknown>, file: string): Record<string, unknown> {
@@ -34,7 +34,7 @@ import { kitVersion } from "./locations.js";
34
34
  import { type AgentUsage, describeTotals, totals } from "./usage.js";
35
35
  import { dispatchNotifiers, type NotifierEventType } from "./notifiers.js";
36
36
  import { describeExternal, type IntegrationEvent, type Runner, syncIntegration } from "./integrations.js";
37
- import { planAppendix, publishCard, readSource } from "./card.js";
37
+ import { cardSlug, planAppendix, publishCard, readSource } from "./card.js";
38
38
  import { principlesSection, projectAdditions, type PromptLocations, readToolPrompt } from "./prompts.js";
39
39
  import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent } from "./runtime.js";
40
40
  import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
@@ -414,6 +414,40 @@ const CARD_PROGRESS: Record<IntegrationEvent, string> = {
414
414
  failed: "流水线中断,正在处理",
415
415
  };
416
416
 
417
+ /**
418
+ * A requirement that started from a card (1.10), with `push` on: once development is done and the
419
+ * handoff is written, pushes each repository's branch to <remote>/<prefix><card>. Never forced
420
+ * (a rejected push is reported, not retried with force), never merged. Never throws.
421
+ */
422
+ function pushBranch(ctx: PipelineContext): void {
423
+ const push = ctx.config.push;
424
+ if (!push.enabled) return;
425
+ const source = readSource(ctx.repo, ctx.featureDir);
426
+ if (!source) return;
427
+ const branch = `${push.branchPrefix}${cardSlug(source.id)}`;
428
+ const repos = [ctx.repo, ...(ctx.state.joint ?? []).map((j) => j.repo)];
429
+ const at = new Date().toISOString();
430
+ for (const repo of repos) {
431
+ let error: string | null = null;
432
+ try {
433
+ execFileSync("git", ["push", push.remote, `HEAD:refs/heads/${branch}`], { cwd: repo, stdio: ["ignore", "pipe", "pipe"], timeout: 180_000, windowsHide: true });
434
+ } catch (e) {
435
+ const stderr = (e as { stderr?: Buffer | string }).stderr?.toString().trim();
436
+ error = (stderr || (e as Error).message).split("\n").filter(Boolean).slice(-2).join(" ");
437
+ }
438
+ ctx.state.pushed = [...(ctx.state.pushed ?? []).filter((p) => p.repo !== repo), { repo, remote: push.remote, branch, at, error }];
439
+ ctx.log(error ? `推送 ${repo} 的分支到 ${push.remote}/${branch} 失败:${error}` : `已推送 ${repo} 的分支到 ${push.remote}/${branch}`);
440
+ }
441
+ save(ctx);
442
+ }
443
+
444
+ /** Where the requirement's code is, for the card: the pushed branch, or the local one. */
445
+ function branchText(ctx: PipelineContext): string | null {
446
+ const mine = ctx.state.pushed?.find((p) => p.repo === ctx.repo);
447
+ if (mine) return mine.error ? `分支 ${ctx.state.branch}(推送到 ${mine.remote}/${mine.branch} 失败:${mine.error})` : `代码已推送到 ${mine.remote} 的分支 ${mine.branch}`;
448
+ return ctx.state.branch ? `分支 ${ctx.state.branch}` : null;
449
+ }
450
+
417
451
  /** A requirement that started from a card (1.10): its AI section follows the pipeline. Never throws. */
418
452
  async function updateCard(ctx: PipelineContext, event: IntegrationEvent): Promise<void> {
419
453
  const source = readSource(ctx.repo, ctx.featureDir);
@@ -422,7 +456,7 @@ async function updateCard(ctx: PipelineContext, event: IntegrationEvent): Promis
422
456
  if (!config?.describe || !config.query) return;
423
457
  const approval = event === "approval";
424
458
  let progress = CARD_PROGRESS[event];
425
- if (event === "phase_done" && ctx.state.branch) progress += `,分支 ${ctx.state.branch}`;
459
+ if (event === "phase_done" && branchText(ctx)) progress += `,${branchText(ctx)}`;
426
460
  if (event === "completed" && ctx.state.outcome && ctx.state.outcome !== "completed") progress = `已结束(${ctx.state.outcome === "completed_with_issues" ? "有遗留问题,见完成报告" : "没有完成,见完成报告"})`;
427
461
  try {
428
462
  await publishCard({
@@ -1795,6 +1829,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1795
1829
  ? `研发已完成(只做研发):${outcomeText[report.outcome as string] ?? report.outcome},请按提测说明人工测试。`
1796
1830
  : `开发流水线已结束:${outcomeText[report.outcome as string] ?? report.outcome}。`;
1797
1831
  // Develop-only ends where testing by people begins: for the card that is "研发完成,待测试".
1832
+ if (developOnly && report.outcome !== "not_completed") pushBranch(ctx);
1798
1833
  await integrate(ctx, developOnly && report.outcome !== "not_completed" ? "phase_done" : "completed");
1799
1834
  await notifyExternal(ctx, "completed", text, {
1800
1835
  outcome: report.outcome,
@@ -1823,6 +1858,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1823
1858
  handbacks: ctx.state.handbacks ?? 0,
1824
1859
  }),
1825
1860
  );
1861
+ pushBranch(ctx);
1826
1862
  await integrate(ctx, "phase_done");
1827
1863
  await notifyExternal(ctx, "phase_done", `研发已完成,可以提测:提测说明在 ${ctx.featureDir}/handoff.md。${verifyAt ? `已预约在 ${verifyAt} 开始测试验证。` : ""}`, {
1828
1864
  handoffPath: `${ctx.featureDir}/handoff.md`,
@@ -149,6 +149,10 @@ export interface RunRecord {
149
149
  title: string;
150
150
  type: string | null;
151
151
  profile: string;
152
+ /** Who asked for it: the card's author for a requirement that started from a card (1.10). */
153
+ requester?: string | null;
154
+ /** The card it started from, e.g. { system: "ALM", id: "…" } (1.10). */
155
+ source?: { system: string; id: string } | null;
152
156
  };
153
157
  run: {
154
158
  startedAt: string;
@@ -226,6 +230,7 @@ export function runRecord(input: {
226
230
  title: input.title,
227
231
  type: state.type ?? null,
228
232
  profile: state.profile,
233
+ ...cardOrigin(state.repo, state.featureDir),
229
234
  },
230
235
  run: {
231
236
  startedAt: input.runStartedAt,
@@ -243,6 +248,17 @@ export function runRecord(input: {
243
248
  };
244
249
  }
245
250
 
251
+ /** The card a requirement started from (source.json), for the record. */
252
+ function cardOrigin(repo: string, featureDir: string): { requester: string | null; source: { system: string; id: string } } | Record<string, never> {
253
+ try {
254
+ const raw = JSON.parse(fs.readFileSync(path.join(repo, featureDir, "source.json"), "utf8")) as { system?: unknown; id?: unknown; author?: unknown };
255
+ if (typeof raw.system !== "string" || typeof raw.id !== "string") return {};
256
+ return { requester: typeof raw.author === "string" ? raw.author : null, source: { system: raw.system, id: raw.id } };
257
+ } catch {
258
+ return {};
259
+ }
260
+ }
261
+
246
262
  /** Appends a run record. Never throws. */
247
263
  export function appendRun(record: RunRecord, env: NodeJS.ProcessEnv = process.env): void {
248
264
  if (recordingOff(env)) return;
@@ -109,6 +109,11 @@ export interface PipelineState {
109
109
  gate?: { baseline: GateBaseline | null; runs: GateRun[] } | null;
110
110
  /** External systems (1.9): the requirement's card in each, by integration name. */
111
111
  external?: Record<string, ExternalRecord> | null;
112
+ /**
113
+ * Branches pushed for a requirement that started from a card (1.10, `push` config): one entry
114
+ * per repository (the main one, then joint ones), the latest attempt. `error` is null when it worked.
115
+ */
116
+ pushed?: Array<{ repo: string; remote: string; branch: string; at: string; error: string | null }> | null;
112
117
  }
113
118
 
114
119
  export interface JointRepo {
@@ -87,6 +87,10 @@ export interface RequirementStats {
87
87
  waits: Record<WaitKind, number>;
88
88
  builder: string | null;
89
89
  reviewer: string | null;
90
+ /** The card's author, for requirements that started from a card (1.10). */
91
+ requester: string | null;
92
+ /** "<system>:<id>" of that card. */
93
+ card: string | null;
90
94
  }
91
95
 
92
96
  function add(a: number | null, b: number | null): number | null {
@@ -162,6 +166,8 @@ export function byRequirement(runs: RunRecord[]): RequirementStats[] {
162
166
  waits,
163
167
  builder: last.agents.builder,
164
168
  reviewer: last.agents.reviewer,
169
+ requester: last.requirement.requester ?? null,
170
+ card: last.requirement.source ? `${last.requirement.source.system}:${last.requirement.source.id}` : null,
165
171
  });
166
172
  }
167
173
  return out.sort((a, b) => b.lastEndedAt.localeCompare(a.lastEndedAt));
@@ -202,6 +208,8 @@ export interface StatsSummary {
202
208
  waits: Array<{ kind: WaitKind; label: string; requirements: number; averageMinutes: number }>;
203
209
  usage: { agentMinutes: number; inputTokens: number | null; outputTokens: number | null; costUsd: number | null };
204
210
  byBuilder: Array<{ builder: string; requirements: number; averageReviewRounds: number | null; averageCostUsd: number | null }>;
211
+ /** Requirements that started from cards, by who asked for them (1.10); empty when there are none. */
212
+ byRequester: Array<{ requester: string; requirements: number; completed: number; medianCycleHours: number | null }>;
205
213
  requirements: RequirementStats[];
206
214
  }
207
215
 
@@ -246,6 +254,9 @@ export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
246
254
  const builders = new Map<string, RequirementStats[]>();
247
255
  for (const q of done) builders.set(q.builder ?? "(未记录)", [...(builders.get(q.builder ?? "(未记录)") ?? []), q]);
248
256
 
257
+ const requesters = new Map<string, RequirementStats[]>();
258
+ for (const q of active) if (q.card) requesters.set(q.requester ?? "(未记录)", [...(requesters.get(q.requester ?? "(未记录)") ?? []), q]);
259
+
249
260
  return {
250
261
  schema: 1,
251
262
  period: { from: period.from?.toISOString() ?? null, to: period.to?.toISOString() ?? null, label: period.label },
@@ -276,6 +287,12 @@ export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
276
287
  averageReviewRounds: avg(qs.map((q) => q.reviewRounds)),
277
288
  averageCostUsd: avg(qs.flatMap((q) => (q.costUsd === null ? [] : [q.costUsd]))),
278
289
  })),
290
+ byRequester: [...requesters.entries()]
291
+ .map(([requester, qs]) => {
292
+ const finished = qs.filter((q) => done.includes(q));
293
+ return { requester, requirements: qs.length, completed: finished.length, medianCycleHours: median(finished.flatMap((q) => (q.cycleHours === null ? [] : [q.cycleHours]))) };
294
+ })
295
+ .sort((a, b) => b.requirements - a.requirements || a.requester.localeCompare(b.requester)),
279
296
  requirements: active,
280
297
  };
281
298
  }
@@ -342,6 +359,10 @@ export function describeStats(s: StatsSummary): string {
342
359
  lines.push("", "## 按 builder 比较(已完成的需求)", "", "| builder | 需求数 | 平均评审轮数 | 平均费用 |", "| --- | --- | --- | --- |");
343
360
  for (const b of s.byBuilder) lines.push(`| ${b.builder} | ${b.requirements} | ${b.averageReviewRounds ?? "-"} | ${b.averageCostUsd === null ? "-" : `$${b.averageCostUsd.toFixed(2)}`} |`);
344
361
  }
362
+ if (s.byRequester.length > 0) {
363
+ lines.push("", "## 从卡片开始的需求(按需求方)", "", "| 需求方 | 需求数 | 已完成 | 周期中位数 |", "| --- | --- | --- | --- |");
364
+ for (const r of s.byRequester) lines.push(`| ${r.requester} | ${r.requirements} | ${r.completed} | ${r.medianCycleHours === null ? "-" : `${r.medianCycleHours} 小时`} |`);
365
+ }
345
366
  lines.push("", "## 需求", "", "| 项目 | 需求 | 类型 | 状态 | 周期 | agent 用时 | 评审轮数 | 缺陷轮数 |", "| --- | --- | --- | --- | --- | --- | --- | --- |");
346
367
  for (const q of s.requirements) {
347
368
  const status = q.status === "completed" ? (OUTCOME_LABELS[q.outcome ?? "unknown"] ?? "已完成") : (STATUS_LABELS[q.status] ?? q.status);
@@ -378,6 +399,8 @@ const CSV_COLUMNS: Array<[string, (q: RequirementStats) => unknown]> = [
378
399
  ["wait_verify_minutes", (q) => q.waits.verify],
379
400
  ["builder", (q) => q.builder ?? ""],
380
401
  ["reviewer", (q) => q.reviewer ?? ""],
402
+ ["requester", (q) => q.requester ?? ""],
403
+ ["card", (q) => q.card ?? ""],
381
404
  ];
382
405
 
383
406
  function csvCell(v: unknown): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-req-kit",
3
- "version": "1.10.0-beta.2",
3
+ "version": "1.10.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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "req-kit-paseo-plugin",
3
- "version": "1.10.0-beta.2",
3
+ "version": "1.10.0-beta.3",
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",
@@ -60,6 +60,7 @@ metadata:
60
60
  - 有没有相关的其他仓库(例如这是前端,接口在另一个后端仓库):在这台机器上的位置、它提供什么。登记后流水线的步骤可以只读参考它,不会改动它
61
61
  - 多模块的仓库(例如 `web/` 和 `server/` 各有自己的测试):确认各模块的检查命令,第 5 步按模块写进 `checks`
62
62
  - 项目已经有 `paseo.json` 时,要不要由你补充;没有时,要不要生成(推荐生成,见第 5 步)
63
+ - **从卡片接需求**(团队对接了需求管理系统时):先执行 `node "{{REQ_KIT_ROOT}}/bin/req.mjs" card route`。输出"没有配置可以读取卡片的外部系统"或团队不用卡片开始需求时跳过;否则问:这个仓库属于需求管理系统中的哪个空间(项目编号,如 ALM 的 projectId)、接哪些子系统的卡片(卡片上显示的子系统名称,可以多个);同一个子系统还有别的仓库时(例如前端和后端各一个),卡片标题中用什么词区分(例如服务名"message-service"、"后端")。第 5 步写进 `cardSource`
63
64
 
64
65
  ## 4. 实际运行验证命令
65
66
 
@@ -106,6 +107,7 @@ metadata:
106
107
  }
107
108
  ```
108
109
  - `relatedRepos`(有关联仓库时):`[{ "name": "后端", "path": "../backend", "role": "提供登录和订单接口" }]`。`path` 写相对项目目录的路径(团队约定把相关仓库放在同一个上级目录最方便),也可以写 `~/` 开头的路径。平时流水线的步骤只读不改;需求说明写了"联合开发"时,流水线会为这个需求在它那里新建工作区,一起开发。关联仓库最好也运行过 `/req-setup`,它自己的检查命令、验证方法和项目说明会用在它的步骤里。
110
+ - `cardSource`(第 3 步确认了从卡片接需求时):`{ "system": "ALM", "projectId": "186149", "subsystems": ["CSP-CMASTER_云匠"], "titleKeywords": ["message-service"] }`。`system` 是团队对接配置中的系统名称;`titleKeywords` 只在同一子系统有几个仓库时写。之后 `req start --from` 不写仓库时,按卡片的子系统和标题选到这个仓库
109
111
  - 其他设置只在需要和本机配置不同时才写,例如 `"defaultProfile": "quick"`。不要写 `daemonUrl`、`daemonPasswordEnv`、`teamConfig`,写了也会被忽略。
110
112
  - `.gitignore`:确认包含 `docs/features/*/.pipeline/`(流水线的运行状态),没有就建议加上。
111
113
 
@@ -115,4 +117,6 @@ metadata:
115
117
 
116
118
  用户确认后,只提交这几个文件(以及 `GLOSSARY.md`、`scripts/paseo/` 和 `.gitignore` 的改动)。`paseo.json` 必须提交:Paseo 新建 worktree 时读取的是基准分支上已提交的版本,提交信息例如 `chore(req-kit): 项目接入说明与验证方法`。不 push。
117
119
 
120
+ 写了 `cardSource` 的,提交后执行 `node "{{REQ_KIT_ROOT}}/bin/req.mjs" card route --add .`,在这台机器上登记这个仓库接收卡片(每台接单的机器各登记一次)。
121
+
118
122
  最后告诉用户:之后项目的构建、测试或启动方式有变化时,再运行一次 `/req-setup`;也可以直接编辑这些文件。可以运行 `/req-doctor` 确认项目检查全部正常。