paseo-req-kit 1.10.0-beta.2 → 1.11.0-beta.1

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,37 @@
2
2
 
3
3
  每个版本分为"使用者会看到的变化"和"升级须知"。升级步骤见 [docs/guide/安装与升级.md](docs/guide/安装与升级.md)。
4
4
 
5
+ ## 1.11.0-beta.1
6
+
7
+ 接单器:一台共享研发机器自动接卡片、转达回复,需求方只在卡片上操作。
8
+
9
+ ### 使用者会看到的变化
10
+
11
+ - **`req factory`**:每隔几分钟(`factory.pollMinutes`,默认 5)检查一次需求管理系统:本机登记的仓库所在空间中,标题以 `[AI]` 或 `【AI】` 开头、还没开始过的卡片,自动按子系统进入对应仓库、开始需求;需求方在卡片中的新回复自动转给需求会话。
12
+ - `req factory once` 检查一次;`start` / `stop` 在后台运行;`status` 看最近一次检查和没能接的卡片;`service` 给出作为系统服务(开机自动运行)的写法。
13
+ - 每次最多开始 3 个需求(`factory.maxNewPerPoll`),其余下次再接;接不了的卡片记下原因,卡片改了马上再试,否则一天后再试。
14
+ - **修正**:`req start --from` 在建需求会话时失败(例如 Paseo 没在运行)后,再运行一次会补建会话,而不是提示"已经开始过"。
15
+
16
+ ### 升级须知
17
+
18
+ - 需要在对接配置中加上 `list`(列出卡片的命令),写法见《项目定制与运行规则》的"接单器"一节;接单的仓库要在 `cardSource` 中写上空间(`projectId`)。
19
+ - 新增配置 `factory`(只能写在本机或团队配置中)、本机目录 `~/.req-kit/factory/`。现在只支持一台机器接单。设计决定见 ADR 0030。
20
+
21
+ ## 1.10.0-beta.3
22
+
23
+ 从卡片开始的需求:按子系统自动选仓库、研发完成后推送分支、统计按需求方汇总。
24
+
25
+ ### 使用者会看到的变化
26
+
27
+ - **按子系统选仓库**:在仓库中运行 `/req-setup` 时可以写上这个仓库接哪个空间、哪些子系统的卡片(同一子系统有几个仓库时再写标题关键字),并在本机登记。之后 `req start --from ALM:<卡号>` 不用写 `--repo`。`req card route` 查看本机登记的仓库。
28
+ - **推送分支**(默认关闭):本机或团队配置打开 `push` 后,从卡片开始的需求在研发完成、写好提测说明后推送到 `origin` 的 `req/<卡号简称>` 分支,分支名写进卡片。不强推、不合并。
29
+ - **统计**:运行记录带上需求方和卡号,`req stats --report` 多一节"从卡片开始的需求(按需求方)",CSV 多 `requester`、`card` 两列。
30
+
31
+ ### 升级须知
32
+
33
+ - 都是可选的。推送只在 `push.enabled` 为 true、需求是从卡片开始的时候发生,并且只能在本机或团队配置中打开(ADR 0029 记录了这条对"只在本地提交"规则的放宽)。
34
+ - 新增项目配置 `cardSource`、本机文件 `~/.req-kit/card-repos.json`、状态文件可选字段 `pushed`、运行记录可选字段 `requirement.requester` 和 `requirement.source`,格式版本都不变。
35
+
5
36
  ## 1.10.0-beta.2
6
37
 
7
38
  (1.10.0-beta.1 的发布任务因一个测试的顺序问题失败,没有发布,内容相同,以 beta.2 发布。)
package/bin/req.mjs CHANGED
@@ -35,6 +35,8 @@ 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 <仓库> 登记)
39
+ req factory start|stop|status 接单器:定时为标题带 [AI] 的新卡片开始需求、转达回复(once 检查一次)
38
40
 
39
41
  使用情况(可以在任何目录运行):
40
42
  req stats [--since 30d] [--month 2026-10]
@@ -159,6 +161,9 @@ switch (command) {
159
161
  case "card":
160
162
  code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "card-cmd.ts"), ...rest]) : 1;
161
163
  break;
164
+ case "factory":
165
+ code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "factory.ts"), ...rest]) : 1;
166
+ break;
162
167
  case "doctor":
163
168
  code = node([path.join(kitRoot, "bin", "req-doctor.mjs"), ...rest]);
164
169
  break;
@@ -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`。
@@ -0,0 +1,22 @@
1
+ # 0030 接单器:共享机器上定时接卡片、转达回复
2
+
3
+ - 状态:已采纳(1.11.0-beta.1;建在 [0029](0029-requirements-from-cards.md) 之上)
4
+
5
+ ## 背景
6
+
7
+ 1.10 已经可以从一张卡片开始需求(`req start --from`)、把需求方在卡片中的回复转给需求会话(`req card check`),但这两步都要有人运行。"软件工厂"要求需求方只在卡片上操作:写好卡片、标题加上约定的标识,就有一台共享研发机器接手。
8
+
9
+ ## 决定
10
+
11
+ - **一个常驻进程,两件事**:`req factory run` 每隔 `factory.pollMinutes`(默认 5 分钟)检查一次:先用对接配置的 `list` 命令列出各空间(本机登记仓库的 `cardSource.projectId`)中的卡片,标题以 `factory.titleTags`(默认 `[AI]`、`【AI】`)开头、本机还没开始过的,按 1.10 的 `req start --from` 开始(按子系统选仓库、建需求会话);再按 `req card check --all` 转达回复,跳过流水线已完成的需求。同一台机器上一次只做一次检查(`~/.req-kit/factory/poll.lock`)。
12
+ - **轮询外部系统,不用回调**:需求管理系统没有我们能用的事件推送;轮询的间隔是几分钟,对需求开发足够,失败了下次再来。卡片状态、条件(例如只查"新建"的用户故事)写在 `list` 的参数里,由团队配置决定。
13
+ - **克制地接单**:每次最多开始 `factory.maxNewPerPoll`(默认 3)个需求,其余下次再接;流水线本身仍受 `maxConcurrentPipelines` 限制。接不了的卡片(例如子系统没有对应的仓库)记下原因,卡片的标题和子系统不变就一天内不再试,`req factory status` 中可以看到。
14
+ - **只读卡片、不改卡片状态来"占位"**:本机的登记(`~/.req-kit/cards/`)就是去重的依据。卡片状态仍只由流水线事件改(开发中、待测试)。将来多台机器同时接单时再引入占位(例如改状态或经内网 Hub 派工),见 [0029](0029-requirements-from-cards.md)。
15
+ - **运行方式交给系统**:`req factory start` / `stop` 在后台运行,适合先试;长期运行用系统服务(`req factory service` 给出 systemd 用户服务或 Windows 计划任务的写法),机器重启后自动恢复。接单器通过 Paseo daemon 创建需求会话,daemon 需要同时运行。
16
+ - **配置只在本机或团队配置中**:`factory` 和 `integrations` 一样会让机器做事,写在项目配置中会被忽略。
17
+
18
+ ## 影响
19
+
20
+ - 对接配置新增 `list`(参数可用 `{projectId}`、`{page}`、`{pageSize}`、`{titleTag}`;`fields` 写每张卡片中 id、title 等的位置;`items` 写输出中列表的位置,不写时依次找 data.list、data.records、data.items、data.rows、data)。
21
+ - 新增配置 `factory`(`pollMinutes`、`titleTags`、`maxNewPerPoll`)、命令 `req factory once | run | start | stop | status | service`、本机目录 `~/.req-kit/factory/`(日志、状态、没能接的卡片)。
22
+ - `req start --from` 中途失败(例如 Paseo 没在运行,会话没建成)时,再次运行会补建会话,而不是当作已经开始过。
@@ -31,3 +31,4 @@ req-kit 自身的设计决定。修改其中任何一条,请新增一条 ADR
31
31
  - [0027 Paseo 插件:随 req-kit 一起发布,只通过 req api 工作](0027-paseo-plugin.md)
32
32
  - [0028 外部系统对接:命令写法在配置里,内容由 AI 整理,调用是确定的](0028-external-integrations.md)
33
33
  - [0029 从卡片开始的需求:需求会话代替人,卡片描述代替对话](0029-requirements-from-cards.md)
34
+ - [0030 接单器:共享机器上定时接卡片、转达回复](0030-card-intake-process.md)
@@ -353,6 +353,64 @@ 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
+
377
+ ### 接单器(1.11 起,beta)
378
+
379
+ 接单器在一台共享研发机器上常驻运行,需求方只在卡片上操作:标题以 `[AI]`(或 `【AI】`)开头的新卡片会被自动接手,需求方在卡片中的回复会自动转给需求会话。
380
+
381
+ 在对接配置中再写 `list`(列出卡片的命令),例如:
382
+
383
+ ```json
384
+ "list": {
385
+ "args": ["alm-card", "list", "--project-id", "{projectId}", "-t", "用户故事", "-S", "新建", "-s", "AI", "-L", "{pageSize}", "-P", "{page}", "--fields", "globalId,name,state,subsysName,createdBy"],
386
+ "fields": { "id": "globalId", "title": "name", "state": "state", "subsystem": "subsysName", "author": "createdBy" }
387
+ }
388
+ ```
389
+
390
+ - `{projectId}` 是本机登记的仓库在 `cardSource` 中写的空间,每个空间查一次;`{page}`、`{pageSize}` 用于翻页(`pageSize` 默认 100,最多 `maxPages` 页,默认 5)。
391
+ - 输出中卡片列表的位置不在 data.list、data.records、data.items、data.rows 或 data 时,用 `items` 写明,例如 `"items": "data.result"`。
392
+ - 只接什么样的卡片(类型、状态)写在参数里;接单器再按标题开头的标识筛一遍,`-s` 只是让列表短一些。
393
+
394
+ 本机或团队配置中可以调整(都有默认值):
395
+
396
+ ```json
397
+ "factory": { "pollMinutes": 5, "titleTags": ["[AI]", "【AI】"], "maxNewPerPoll": 3 }
398
+ ```
399
+
400
+ 运行:
401
+
402
+ | 命令 | 做什么 |
403
+ | --- | --- |
404
+ | `req factory once` | 检查一次:为新卡片开始需求,转达新回复。先用它试配置 |
405
+ | `req factory start` / `stop` | 在后台运行 / 停止,日志在 `~/.req-kit/factory/factory.log` |
406
+ | `req factory status` | 是否在运行、最近一次检查的结果、没能接的卡片和原因 |
407
+ | `req factory service` | 作为系统服务运行的写法(Linux systemd 用户服务、Windows 计划任务),机器重启后自动恢复 |
408
+
409
+ - 每次最多开始 `maxNewPerPoll` 个需求,其余下次再接;开发仍受 `maxConcurrentPipelines` 限制。
410
+ - 接不了的卡片(例如子系统没有对应的仓库)记下原因;卡片的标题和子系统改了会马上再试,否则一天后再试。
411
+ - Paseo daemon 要同时运行:接单器通过它创建需求会话。
412
+ - 现在只支持一台机器接单。几台机器同时运行接单器会重复接同一张卡片。
413
+
356
414
  ## 团队配置
357
415
 
358
416
  几台机器、多个项目共用的设置(统一的模型、评审小组、超时、要求的 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
 
@@ -56,7 +62,7 @@ function fail(message: string): never {
56
62
  throw new CardError(message);
57
63
  }
58
64
 
59
- function cardIntegrations(config: PipelineConfig): IntegrationConfig[] {
65
+ export function cardIntegrations(config: PipelineConfig): IntegrationConfig[] {
60
66
  return config.integrations.filter((i) => i.query);
61
67
  }
62
68
 
@@ -135,62 +141,100 @@ export interface CardCmdOptions {
135
141
  out?: (line: string) => void;
136
142
  }
137
143
 
138
- async function startFromCard(argv: string[], opts: CardCmdOptions): Promise<number> {
139
- const out = opts.out ?? console.log;
140
- const { values } = parseArgs({ args: argv, options: { from: { type: "string" }, repo: { type: "string" }, config: { type: "string" } }, allowPositionals: false });
141
- if (!values.from) fail(`缺少 --from\n\n${USAGE}`);
142
- const repo = gitTop(path.resolve(opts.cwd ?? process.cwd(), values.repo ?? "."));
143
- 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));
144
+ export interface BeginResult {
145
+ status: "started" | "exists";
146
+ repo: string;
147
+ featureDir: string;
148
+ title: string;
149
+ sessionAgentId: string | null;
150
+ /** Set when the repository was chosen by the card's subsystem. */
151
+ routed: boolean;
152
+ }
146
153
 
154
+ /**
155
+ * Starts a requirement from a card: reads it, picks the repository (repoArg, else by subsystem,
156
+ * else cwd), creates the feature directory and the requirement's chat. A card that already
157
+ * started is left alone, unless its chat was never created (then that is tried again).
158
+ */
159
+ export async function beginFromCard(input: { integration: IntegrationConfig; id: string; repoArg: string | null; configPath: string; opts: CardCmdOptions }): Promise<BeginResult> {
160
+ const { integration, id, opts } = input;
147
161
  const existing = findCardEntry(integration.name, id, opts.env);
162
+ let repo: string;
163
+ let featureDir: string;
164
+ let card: Awaited<ReturnType<typeof queryCard>> | null = null;
165
+ let source: SourceFile;
166
+ let routed = false;
148
167
  if (existing) {
149
168
  const where = locateCardRequirement(existing, opts.env) ?? existing.sourceRepo;
150
- out(`卡片 ${id} 已经开始过:${path.join(where, existing.featureDir)}。用 req card check ${integration.name}:${id} 转达需求方的新回复。`);
151
- return 0;
169
+ const known = readSource(where, existing.featureDir);
170
+ if (!known || known.sessionAgentId) {
171
+ return { status: "exists", repo: where, featureDir: existing.featureDir, title: known?.title ?? "", sessionAgentId: known?.sessionAgentId ?? null, routed: false };
172
+ }
173
+ repo = where;
174
+ featureDir = existing.featureDir;
175
+ source = known;
176
+ } else {
177
+ card = await queryCard(integration, id, opts.run);
178
+ const cwd = opts.cwd ?? process.cwd();
179
+ const byRoute = input.repoArg ? null : routeCard(card, integration.name, opts.env);
180
+ routed = byRoute !== null;
181
+ repo = gitTop(input.repoArg ? path.resolve(cwd, input.repoArg) : (byRoute ?? cwd));
182
+ const parts = splitDescription(card.body);
183
+ featureDir = `docs/features/${today()}-${cardSlug(id)}`;
184
+ fs.mkdirSync(path.join(repo, featureDir), { recursive: true });
185
+ fs.writeFileSync(path.join(repo, featureDir, CARD_SOURCE_FILE), cardSourceText(card, parts.original));
186
+ source = {
187
+ v: 1,
188
+ system: integration.name,
189
+ id,
190
+ url: card.url,
191
+ title: card.title,
192
+ author: card.author,
193
+ subsystem: card.subsystem,
194
+ state: card.state,
195
+ fetchedAt: new Date().toISOString(),
196
+ sourceRepo: repo,
197
+ sessionAgentId: null,
198
+ originalHash: null,
199
+ replyHash: null,
200
+ confirmedAt: null,
201
+ planConfirmedAt: null,
202
+ progress: null,
203
+ publishedAt: null,
204
+ };
205
+ writeSource(repo, featureDir, source);
206
+ saveCardEntry({ system: integration.name, id, sourceRepo: repo, featureDir, createdAt: source.fetchedAt }, opts.env);
152
207
  }
153
-
154
- const card = await queryCard(integration, id, opts.run);
155
- const parts = splitDescription(card.body);
156
- const featureDir = `docs/features/${today()}-${cardSlug(id)}`;
157
- fs.mkdirSync(path.join(repo, featureDir), { recursive: true });
158
- fs.writeFileSync(path.join(repo, featureDir, CARD_SOURCE_FILE), cardSourceText(card, parts.original));
159
- const source: SourceFile = {
160
- v: 1,
161
- system: integration.name,
162
- id,
163
- url: card.url,
164
- title: card.title,
165
- author: card.author,
166
- subsystem: card.subsystem,
167
- state: card.state,
168
- fetchedAt: new Date().toISOString(),
169
- sourceRepo: repo,
170
- sessionAgentId: null,
171
- originalHash: null,
172
- replyHash: null,
173
- confirmedAt: null,
174
- planConfirmedAt: null,
175
- progress: null,
176
- publishedAt: null,
177
- };
178
- writeSource(repo, featureDir, source);
179
- saveCardEntry({ system: integration.name, id, sourceRepo: repo, featureDir, createdAt: source.fetchedAt }, opts.env);
180
-
208
+ const config = loadConfig(input.configPath, repo).config;
181
209
  source.sessionAgentId = await withRuntime(config, (runtime, resolved) =>
182
210
  runtime.startSessionAgent({
183
- title: `需求 ${id}:${card.title}`.slice(0, 120),
184
- prompt: sessionStartPrompt({ system: integration.name, id, title: card.title, featureDir }),
211
+ title: `需求 ${id}:${source.title}`.slice(0, 120),
212
+ prompt: sessionStartPrompt({ system: integration.name, id, title: source.title, featureDir }),
185
213
  cwd: repo,
186
214
  profile: resolved.agents.builder,
187
215
  labels: { "req-kit": "requirement-session" },
188
216
  }),
189
217
  );
190
218
  writeSource(repo, featureDir, source);
191
- out(`已从卡片 ${id}「${card.title}」开始需求。`);
192
- out(`功能目录:${path.join(repo, featureDir)}`);
193
- out(`需求会话:agent ${source.sessionAgentId}(在 Paseo 中打开它可以看到整理需求的过程)。`);
219
+ return { status: "started", repo, featureDir, title: source.title, sessionAgentId: source.sessionAgentId, routed };
220
+ }
221
+
222
+ async function startFromCard(argv: string[], opts: CardCmdOptions): Promise<number> {
223
+ const out = opts.out ?? console.log;
224
+ const { values } = parseArgs({ args: argv, options: { from: { type: "string" }, repo: { type: "string" }, config: { type: "string" } }, allowPositionals: false });
225
+ if (!values.from) fail(`缺少 --from\n\n${USAGE}`);
226
+ const configPath = values.config ? path.resolve(values.config) : defaultConfigPath(opts.env);
227
+ // Integrations live in the machine or team config, so the card can be read before the repository is known.
228
+ const { config: integration, id } = parseCardRef(values.from, cardIntegrations(loadConfig(configPath).config));
229
+ const result = await beginFromCard({ integration, id, repoArg: values.repo ?? null, configPath, opts });
230
+ if (result.status === "exists") {
231
+ out(`卡片 ${id} 已经开始过:${path.join(result.repo, result.featureDir)}。用 req card check ${integration.name}:${id} 转达需求方的新回复。`);
232
+ return 0;
233
+ }
234
+ if (result.routed) out(`按子系统选择仓库:${result.repo}`);
235
+ out(`已从卡片 ${id}「${result.title}」开始需求。`);
236
+ out(`功能目录:${path.join(result.repo, result.featureDir)}`);
237
+ out(`需求会话:agent ${result.sessionAgentId}(在 Paseo 中打开它可以看到整理需求的过程)。`);
194
238
  out(`需求说明和问题会写进卡片描述末尾的 AI 区;需求方回复后运行 req card check ${integration.name}:${id}(接单器会定时运行它)。`);
195
239
  return 0;
196
240
  }
@@ -299,16 +343,14 @@ export async function checkOne(target: Located, opts: CardCmdOptions, notify: (a
299
343
  return `${source.id}:已转给需求会话(${changes.join(";")})`;
300
344
  }
301
345
 
302
- async function check(argv: string[], opts: CardCmdOptions): Promise<number> {
303
- const out = opts.out ?? console.log;
304
- const { values, positionals } = parseArgs({ args: argv, options: { all: { type: "boolean" }, config: { type: "string" } }, allowPositionals: true });
305
- if (!positionals[0] && !values.all) fail(`写卡号,或用 --all 检查本机所有从卡片开始的需求\n\n${USAGE}`);
306
- const configPath = values.config ? path.resolve(values.config) : defaultConfigPath(opts.env);
307
- const targets = locateCards(positionals[0] ?? null, opts, configPath);
308
- if (targets.length === 0) {
309
- out("本机没有从卡片开始的需求。");
310
- return 0;
311
- }
346
+ /**
347
+ * Checks cards for new replies and passes them on. `skipFinished` leaves out requirements whose
348
+ * pipeline has completed (the intake process uses it to spare the external system).
349
+ */
350
+ export async function checkCards(ref: string | null, opts: CardCmdOptions, configPath: string, options: { skipFinished?: boolean } = {}): Promise<{ lines: string[]; failed: number; checked: number }> {
351
+ let targets = locateCards(ref, opts, configPath);
352
+ if (options.skipFinished) targets = targets.filter((t) => readState(statePath(t.repo, t.entry.featureDir))?.status !== "completed");
353
+ const lines: string[] = [];
312
354
  let runtime: AgentRuntime | null = null;
313
355
  const notify = async (agentId: string, text: string) => {
314
356
  runtime ??= await (await loadRuntimeFactory())({ config: targets[0].pipelineConfig, log: () => undefined });
@@ -318,16 +360,60 @@ async function check(argv: string[], opts: CardCmdOptions): Promise<number> {
318
360
  try {
319
361
  for (const target of targets) {
320
362
  try {
321
- out(await checkOne(target, opts, notify));
363
+ lines.push(await checkOne(target, opts, notify));
322
364
  } catch (error) {
323
365
  failed += 1;
324
- out(`${target.source.id}:${(error as Error).message}`);
366
+ lines.push(`${target.source.id}:${(error as Error).message}`);
325
367
  }
326
368
  }
327
369
  } finally {
328
370
  await (runtime as AgentRuntime | null)?.close().catch(() => undefined);
329
371
  }
330
- return failed > 0 ? 1 : 0;
372
+ return { lines, failed, checked: targets.length };
373
+ }
374
+
375
+ async function check(argv: string[], opts: CardCmdOptions): Promise<number> {
376
+ const out = opts.out ?? console.log;
377
+ const { values, positionals } = parseArgs({ args: argv, options: { all: { type: "boolean" }, config: { type: "string" } }, allowPositionals: true });
378
+ if (!positionals[0] && !values.all) fail(`写卡号,或用 --all 检查本机所有从卡片开始的需求\n\n${USAGE}`);
379
+ const configPath = values.config ? path.resolve(values.config) : defaultConfigPath(opts.env);
380
+ const result = await checkCards(positionals[0] ?? null, opts, configPath);
381
+ if (result.checked === 0) {
382
+ out("本机没有从卡片开始的需求。");
383
+ return 0;
384
+ }
385
+ for (const line of result.lines) out(line);
386
+ return result.failed > 0 ? 1 : 0;
387
+ }
388
+
389
+ function route(argv: string[], opts: CardCmdOptions): number {
390
+ const out = opts.out ?? console.log;
391
+ const { values } = parseArgs({ args: argv, options: { add: { type: "string" }, remove: { type: "string" }, config: { type: "string" } }, allowPositionals: false });
392
+ const cwd = opts.cwd ?? process.cwd();
393
+ const configPath = values.config ? path.resolve(values.config) : defaultConfigPath(opts.env);
394
+ const systems = fs.existsSync(configPath) ? cardIntegrations(loadConfig(configPath).config).map((i) => i.name) : [];
395
+ if (values.add) {
396
+ const repo = gitTop(path.resolve(cwd, values.add));
397
+ const r = readRoute(repo);
398
+ setRegisteredRepos([...registeredRepos(opts.env), repo], opts.env);
399
+ out(r ? `已登记 ${repo}:${r.system} 空间 ${r.projectId ?? "(未写)"},子系统 ${r.subsystems.join("、") || "(未写)"}` : `已登记 ${repo},但它的项目配置还没有 cardSource(运行 /req-setup 写上)`);
400
+ return 0;
401
+ }
402
+ if (values.remove) {
403
+ const repo = gitTop(path.resolve(cwd, values.remove));
404
+ setRegisteredRepos(registeredRepos(opts.env).filter((r) => r !== repo), opts.env);
405
+ out(`已取消登记 ${repo}`);
406
+ return 0;
407
+ }
408
+ if (systems.length === 0) out("没有配置可以读取卡片的外部系统(本机或团队配置的 integrations 中没有写 query)。");
409
+ const { routes, missing } = cardRoutes(opts.env);
410
+ if (routes.length === 0 && missing.length === 0) {
411
+ out("本机还没有登记接收卡片的仓库:在仓库中运行 /req-setup(或 req card route --add <仓库目录>)。");
412
+ return 0;
413
+ }
414
+ for (const r of routes) out(`${r.system} 空间 ${r.projectId ?? "-"} 子系统 ${r.subsystems.join("、") || "-"} 标题关键字 ${r.titleKeywords.join("、") || "-"} ${r.repo}`);
415
+ for (const m of missing) out(`(没有 cardSource 或目录不存在)${m}`);
416
+ return 0;
331
417
  }
332
418
 
333
419
  function list(opts: CardCmdOptions): number {
@@ -360,6 +446,8 @@ export async function main(argv: string[], opts: CardCmdOptions = {}): Promise<n
360
446
  case "list":
361
447
  case "ls":
362
448
  return list(opts);
449
+ case "route":
450
+ return route(rest, opts);
363
451
  default:
364
452
  console.log(USAGE);
365
453
  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,24 @@ 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;
195
+ /**
196
+ * The intake process (1.11, `req factory`): how often it looks for new cards, which title tags
197
+ * mark a card for it, and how many requirements it starts per look at most. Machine or team
198
+ * config only.
199
+ */
200
+ factory: { pollMinutes: number; titleTags: string[]; maxNewPerPoll: number };
183
201
  /** Automatic retries of a step after a transient error (rate limit, dropped connection). */
184
202
  retry: { attempts: number; backoffSeconds: number[] };
185
203
  /**
@@ -284,6 +302,9 @@ export const DEFAULT_CONFIG: PipelineConfig = {
284
302
  notifiers: [],
285
303
  integrations: [],
286
304
  integrationDefaults: {},
305
+ push: { enabled: false, remote: "origin", branchPrefix: "req/" },
306
+ cardSource: null,
307
+ factory: { pollMinutes: 5, titleTags: ["[AI]", "【AI】"], maxNewPerPoll: 3 },
287
308
  retry: { attempts: 2, backoffSeconds: [60, 300] },
288
309
  paseoServices: "auto",
289
310
  serviceReadySeconds: 120,
@@ -347,6 +368,17 @@ function merge(base: PipelineConfig, raw: Record<string, unknown>): PipelineConf
347
368
  }
348
369
  merged.integrationDefaults = combined;
349
370
  }
371
+ merged.push = { ...base.push, ...(isRecord(raw.push) ? raw.push : {}) } as PipelineConfig["push"];
372
+ merged.factory = { ...base.factory, ...(isRecord(raw.factory) ? raw.factory : {}) } as PipelineConfig["factory"];
373
+ if (isRecord(raw.cardSource)) {
374
+ const c = raw.cardSource;
375
+ merged.cardSource = {
376
+ system: c.system as string,
377
+ projectId: c.projectId === undefined || c.projectId === null ? null : String(c.projectId),
378
+ subsystems: (c.subsystems ?? []) as string[],
379
+ titleKeywords: (c.titleKeywords ?? []) as string[],
380
+ };
381
+ }
350
382
  merged.stepTimeoutMinutes = {
351
383
  ...base.stepTimeoutMinutes,
352
384
  ...(isRecord(raw.stepTimeoutMinutes) ? raw.stepTimeoutMinutes : {}),
@@ -535,6 +567,21 @@ function validate(config: PipelineConfig): void {
535
567
  if (!isRecord(config.integrationDefaults) || !Object.values(config.integrationDefaults).every((m) => isRecord(m) && Object.values(m).every((v) => typeof v === "string"))) {
536
568
  throw new ConfigError('integrationDefaults 必须是 { "<集成名称>": { "<字段>": "<值>" } }');
537
569
  }
570
+ const push = config.push;
571
+ if (typeof push.enabled !== "boolean") throw new ConfigError("push.enabled 必须是 true 或 false");
572
+ if (typeof push.remote !== "string" || !/^[\w.-]+$/.test(push.remote)) throw new ConfigError('push.remote 必须是远程仓库名,例如 "origin"');
573
+ if (typeof push.branchPrefix !== "string" || !/^[A-Za-z0-9._-]+\/$/.test(push.branchPrefix)) throw new ConfigError('push.branchPrefix 必须是以 / 结尾的分支前缀,例如 "req/"');
574
+ const f = config.factory;
575
+ if (typeof f.pollMinutes !== "number" || !(f.pollMinutes >= 1)) throw new ConfigError("factory.pollMinutes 必须是至少 1 的分钟数");
576
+ if (!Number.isInteger(f.maxNewPerPoll) || f.maxNewPerPoll < 1) throw new ConfigError("factory.maxNewPerPoll 必须是正整数");
577
+ if (!Array.isArray(f.titleTags) || f.titleTags.length === 0 || !f.titleTags.every((t) => typeof t === "string" && t.trim())) throw new ConfigError('factory.titleTags 必须是非空的字符串数组,例如 ["[AI]"]');
578
+ const cs = config.cardSource;
579
+ if (cs !== null) {
580
+ if (typeof cs.system !== "string" || !cs.system.trim()) throw new ConfigError('cardSource.system 必须是对接系统的名称,例如 "ALM"');
581
+ for (const k of ["subsystems", "titleKeywords"] as const) {
582
+ if (!Array.isArray(cs[k]) || !cs[k].every((v) => typeof v === "string" && v.trim())) throw new ConfigError(`cardSource.${k} 必须是字符串数组`);
583
+ }
584
+ }
538
585
  if (config.requiredVersion !== null && typeof config.requiredVersion !== "string") {
539
586
  throw new ConfigError('requiredVersion 必须是版本范围字符串,例如 ">=1.2.0 <2"');
540
587
  }
@@ -570,7 +617,7 @@ export interface LoadedConfig {
570
617
  }
571
618
 
572
619
  /** 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;
620
+ const NOT_IN_PROJECT_KEYS = ["notifiers", "integrations", "push", "factory"] as const;
574
621
 
575
622
  /** Notifier module paths are relative to the file that names them; "~/" is the home directory. */
576
623
  function resolveNotifiers(raw: Record<string, unknown>, file: string): Record<string, unknown> {