paseo-req-kit 1.8.0 → 1.9.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,23 @@
2
2
 
3
3
  每个版本分为"使用者会看到的变化"和"升级须知"。升级步骤见 [docs/guide/安装与升级.md](docs/guide/安装与升级.md)。
4
4
 
5
+ ## 1.9.0-beta.1
6
+
7
+ 1.9 的基础:本机知道自己有哪些需求,记下每次运行的用时和用量,并给程序一个稳定的读取接口。插件(beta.2)和外部系统集成(beta.3)都建在它上面。
8
+
9
+ ### 使用者会看到的变化
10
+
11
+ - **使用统计 `req stats`**:在任何目录运行,看本机最近 30 天(`--since`、`--month 2026-10`)的统计:有进展和完成的需求数、交付周期、返工(评审轮数、需要缺陷修复的占比、质量门失败次数)、等待确认和等待决定的时长、各步骤用时、token 用量和费用、不同 builder 的比较。
12
+ - **给管理方的汇报**:`req stats --report --month 2026-10` 生成汇总(`.md`)和每个需求一行的明细(`.csv`,可以用表格软件打开)。几台机器一起汇报时,每台 `req stats --month 2026-10 --export`,再在一处 `req stats --merge <文件…> --month 2026-10 --report`。
13
+ - **本机的需求索引**:运行过的需求会登记在本机,`req index` 查看。1.9 之前运行过的需求在项目目录运行一次 `req index --rebuild` 补登;`req index --prune` 清理目录已不在的。
14
+ - **`req api`**:给程序用的 JSON 输出:列出本机或当前仓库的需求、一个需求的步骤和质量门、读取功能目录中的文档、统计,以及确认方案、停止、继续、开始测试验证这几个管理操作。Paseo 插件将通过它工作。格式见 `contracts/cli-json.md`。
15
+
16
+ ### 升级须知
17
+
18
+ - 新增本机文件:`~/.req-kit/index/`(每个需求一个小文件)和 `~/.req-kit/usage/runs.jsonl`(每次运行一行)。只在本机,不记需求内容和代码,不会自动发往任何地方。不想记录时设置环境变量 `REQ_KIT_NO_RECORD=1`。
19
+ - 统计只从升级后的运行开始;之前的运行没有记录。
20
+ - 状态文件、结果文件和提示词都没有变化。设计决定见 ADR 0026。
21
+
5
22
  ## 1.8.0
6
23
 
7
24
  第一个正式版。汇总 1.8.0-beta.1 到 beta.3 的全部内容(各节见下),并让新机器一条命令就能从零用起来。
package/bin/req.mjs CHANGED
@@ -30,6 +30,16 @@ const HELP = `req-kit ${version}(${kitRoot})
30
30
  req digest --schedule "0 9 * * 1"
31
31
  让 Paseo 按时间表发送需求总览(例子为每周一 9:00);取消:req digest --cancel <ID>
32
32
 
33
+ 使用情况(可以在任何目录运行):
34
+ req stats [--since 30d] [--month 2026-10]
35
+ 本机的使用统计:需求数、交付周期、返工、等待、各步骤用时、用量
36
+ req stats --report [--out 目录]
37
+ 生成汇报用的汇总(.md)和每个需求一行的表格(.csv)
38
+ req stats --export [文件] 导出本机的运行记录;汇总多台机器:req stats --merge <文件…> --report
39
+ req index [--rebuild [目录…]] [--prune]
40
+ 本机记录的需求(Paseo 插件据此找到所有需求);1.9 之前运行过的用 --rebuild 补登
41
+ req api <命令> … 给程序用的 JSON 输出(见 contracts/cli-json.md)
42
+
33
43
  本机维护:
34
44
  req doctor [--no-project] 体检:安装、配置、daemon、当前项目
35
45
  req configure 配置向导:选择 builder 和 reviewer 用的 agent
@@ -156,6 +166,15 @@ switch (command) {
156
166
  case "digest":
157
167
  code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "digest.ts"), ...rest]) : 1;
158
168
  break;
169
+ case "stats":
170
+ code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "stats.ts"), ...rest]) : 1;
171
+ break;
172
+ case "index":
173
+ code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "index-cmd.ts"), ...rest]) : 1;
174
+ break;
175
+ case "api":
176
+ code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "api.ts"), ...rest]) : 1;
177
+ break;
159
178
  case "notify-test":
160
179
  code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "notify-test.ts"), ...rest]) : 1;
161
180
  break;
@@ -10,6 +10,8 @@
10
10
  | `decisions.tsv`、`changes.md`、`acceptance.md` 与需求总览 | [feature-files.md](feature-files.md) | 见各文件说明 |
11
11
  | `.requirement-pipeline/verify.md` | [verify.md](verify.md) | 自由格式,建议结构见文件 |
12
12
  | 外部通知(企业 IM 等)收到的事件 | [notifier-event.md](notifier-event.md) | `schema: 1` |
13
+ | `req api` 的输出(Paseo 插件等程序读取) | [cli-json.md](cli-json.md) | `schema: 1` |
14
+ | `~/.req-kit/index/`、`~/.req-kit/usage/runs.jsonl` | [usage-record.md](usage-record.md) | `schema: 1` |
13
15
 
14
16
  格式版本的规则:
15
17
 
@@ -0,0 +1,66 @@
1
+ # 给程序用的 JSON 输出:`req api`
2
+
3
+ 1.9.0 起。Paseo 插件和其他程序通过 `req api` 读取需求、执行管理操作,不直接读写状态文件。每个命令输出一行 JSON:
4
+
5
+ ```json
6
+ { "schema": 1, "kitVersion": "1.9.0", "data": … }
7
+ ```
8
+
9
+ 出错时退出码为 1:
10
+
11
+ ```json
12
+ { "schema": 1, "kitVersion": "1.9.0", "error": { "code": "not_found", "message": "没有找到需求……" } }
13
+ ```
14
+
15
+ `code`:`bad_arguments`(参数不对)、`not_found`(目录或需求不存在)、`not_allowed`(不允许的文件或命令)、`failed`(其他错误)。
16
+
17
+ `schema` 只在不兼容的修改时增加;新增字段不改变 `schema`,读取方应忽略不认识的字段。
18
+
19
+ ## 命令
20
+
21
+ | 命令 | `data` |
22
+ | --- | --- |
23
+ | `req api version` | `{ kitVersion, kitRoot, apiSchema, paseo, featuresDir }`:`paseo` 是这个版本支持的 Paseo 范围 |
24
+ | `req api list [--repo <目录>]` | 这个仓库所有工作区中的需求,数组,每项见下面的"需求" |
25
+ | `req api list --all` | 本机记录过的所有需求([需求索引](usage-record.md)),同上;目录已不在的 `present: false` |
26
+ | `req api status <需求> [--repo <目录>]` | 一个需求的详情,见下面的"详情" |
27
+ | `req api file <需求> <文件> [--repo <目录>]` | `{ featureDir, file, size, truncated, content }`;只能读取功能目录中的文档(见下),最多 512 KB |
28
+ | `req api stats [--since 30d] [--month 2026-10]` | `req stats --json` 的内容,见 [使用记录](usage-record.md) |
29
+ | `req api index [--rebuild <目录>…] [--prune]` | 不带选项:索引的全部条目;`--rebuild`:`{ scanned, registered, pruned, total }` |
30
+ | `req api run <approve\|stop\|resume\|verify> <需求> [--repo <目录>] [选项]` | `{ command, featureDir, ok, exitCode, message }`。在 `--repo`(默认当前目录)中执行与 `req <命令> <需求>` 完全相同的操作;`ok` 为 false 时退出码为 1 |
31
+
32
+ `<需求>` 的写法与 `req status` 相同:功能目录、目录名或名称中能唯一确定的一部分。需求在为它新建的工作区中开发时,`--repo` 写对话所在的目录或那个工作区都可以。
33
+
34
+ `req api file` 能读取的文件:`spec.md`、`alignment.md`、`spec-review.md`、`changes.md`、`decisions.tsv`、`design.md`、`design-review.md`、`preview.md`、`contract.md`、`diagnosis.md`、`assessment.md`、`implementation.md`、`progress.md`、`code-review.md`、`handoff.md`、`service-start.md`、`verification.md`、`report.md`、`acceptance.md`、`lessons.tsv`。
35
+
36
+ ## 需求(`list` 的每一项)
37
+
38
+ | 字段 | 含义 |
39
+ | --- | --- |
40
+ | `key` | 需求在本机的标识(对话所在目录 + 功能目录) |
41
+ | `project` | 项目名:对话所在目录的目录名 |
42
+ | `repo` | 需求所在的目录(有自己的工作区时是那个工作区) |
43
+ | `sourceRepo` / `workspaceId` | 有自己的工作区时:对话所在的目录、Paseo 工作区 ID;否则 null |
44
+ | `featureDir` / `name` / `title` | 功能目录、目录名、需求标题 |
45
+ | `type` / `profile` / `branch` | 需求类型(`defect`、`refactor`、`assessment` 或 null)、档位、分支 |
46
+ | `stage` | 与 `req list` 相同的阶段说明,如"开发中""等待确认方案""待验收" |
47
+ | `status` | 状态文件的 `status`;还没运行过为 null |
48
+ | `running` | 后台进程是否在运行 |
49
+ | `phase` | `develop` / `verify` |
50
+ | `attention` | 需要人处理的事:`approval`(确认方案)、`business`(需求方决定)、`interrupted`(中断,可以继续)、`not_completed`(结束但未完成);没有为 null |
51
+ | `current` | 正在运行的步骤 `[{ key, title, agentId }]` |
52
+ | `updatedAt` | 最近一次变化的时间 |
53
+ | `usage` | `{ steps, agentMinutes, inputTokens, outputTokens, costUsd }`;没运行过为 null |
54
+ | `present` | 目录是否还在 |
55
+
56
+ ## 详情(`status`)
57
+
58
+ "需求"的全部字段,另加:
59
+
60
+ | 字段 | 含义 |
61
+ | --- | --- |
62
+ | `files` | 功能目录中存在的、可以用 `req api file` 读取的文件 |
63
+ | `run` | `{ attempt, startedAt, updatedAt, pid, phaseMode, stopReason, outcome, queuedSince, verifyAt, approval, originAgentId }`;没运行过为 null |
64
+ | `steps` | 每个步骤一项:`{ key, stepId, title, phase, attempt, round, agentId, startedAt, endedAt, minutes, outcome, result, error, usage }`;`title` 是给人看的名称(如"代码评审(第 2 轮)"),`result` 是结论的文字(如"需要修改") |
65
+ | `gate` | 质量门:状态文件中的 `gate` |
66
+ | `joint` | 联合需求的其他仓库:`[{ name, slug, repo, sourceRepo, branch, workspaceId, gate }]` |
@@ -0,0 +1,48 @@
1
+ # 本机的需求索引与使用记录
2
+
3
+ 1.9.0 起,引擎在本机配置目录(默认 `~/.req-kit`,可用 `REQ_KIT_HOME` 改变)下记两样东西。它们只在这台机器上,不提交进任何仓库,也不会自动发往任何地方。环境变量 `REQ_KIT_NO_RECORD=1` 时两样都不记(评估运行就是这样)。
4
+
5
+ ## 需求索引 `index/<key>.json`
6
+
7
+ 每个在本机运行过的需求一个文件,记录它在哪里,供 `req api list --all` 和 Paseo 插件找到本机所有的需求。需求的阶段、进度仍然现场从功能目录读取(ADR 0010)。
8
+
9
+ ```json
10
+ {
11
+ "schema": 1,
12
+ "key": "3f2a9c1e5b7d4e8a",
13
+ "repo": "/home/me/work/shop-sms-login",
14
+ "featureDir": "docs/features/20261012-sms-login",
15
+ "sourceRepo": "/home/me/work/shop",
16
+ "workspaceId": "ws_123",
17
+ "project": "shop",
18
+ "registeredAt": "2026-10-12T09:00:00.000Z"
19
+ }
20
+ ```
21
+
22
+ `key` 由对话所在的目录(没有自己的工作区时即 `repo`)和功能目录算出。`req index --rebuild` 为 1.9 之前运行过的需求补登,`req index --prune` 移除目录已不在的。
23
+
24
+ ## 使用记录 `usage/runs.jsonl`
25
+
26
+ 每次流水线运行(后台进程从开始到结束,包括停在等待确认、等待需求方、研发完成处)结束时追加一行:
27
+
28
+ | 字段 | 含义 |
29
+ | --- | --- |
30
+ | `schema` / `kind` | `1` / `"run"` |
31
+ | `machine` / `kitVersion` | 主机名、req-kit 版本 |
32
+ | `requirement` | `{ key, project, name, title, type, profile }` |
33
+ | `run` | `{ startedAt, endedAt, attempt, phase, phaseMode, status, outcome, stopReason }`;`status` 是运行结束时的状态,`outcome` 是完成时报告的结论 |
34
+ | `agents` | `{ builder, reviewer }`:当时配置的 agent(如 `claude/opus(bypassPermissions)`) |
35
+ | `steps` | 这次运行中开始的步骤:`{ key, stepId, phase, attempt, round, startedAt, endedAt, minutes, outcome, inputTokens, outputTokens, costUsd }`;agent 没有报告的用量为 null |
36
+ | `gate` | `{ runs, failed }`:这次运行中质量门运行的次数、发现新失败的次数 |
37
+
38
+ 不记录需求内容、代码和对话。
39
+
40
+ ## 统计口径(`req stats`)
41
+
42
+ - 一段时间的用量、步骤用时:结束时间在这段时间内的运行。
43
+ - 交付周期:需求第一次开始到完成(最后一次运行以"已完成"结束)的小时数,用这个需求的全部运行,算在完成的那段时间。
44
+ - 代码评审轮数、缺陷修复轮数:不同的(尝试、轮次、仓库)个数;评审分方向或评审小组的几个成员算一轮。
45
+ - 等待:相邻两次运行之间的间隔,按前一次结束时的状态归类:等待确认方案、等待需求方决定、研发完成到开始测试验证、中断到继续。
46
+ - 合并多台机器的导出时,同一机器、同一需求、同一开始时间的运行只算一次。
47
+
48
+ `req stats --json` 和 `req api stats` 输出同样的对象(`schema: 1`):`period`、`machines`、`active`、`completed`、`byType`、`cycleHours`、`steps`、`rework`、`waits`、`usage`、`byBuilder`、`requirements`(每个需求一项,字段与 `req stats --csv` 的列对应)。
@@ -0,0 +1,23 @@
1
+ # 0026 本机的需求索引、使用记录和 req api
2
+
3
+ - 状态:已采纳(补充 [0010](0010-generated-feature-overview.md):仓库内的总览仍现场生成;[0014](0014-notifier-slot.md):通知槽不变)
4
+
5
+ ## 背景
6
+
7
+ 1.9 要做两件依赖同一份数据的事:Paseo 插件在网页端列出本机所有项目的需求、做确认和停止这类管理操作;管理方要定期看到 req-kit 的使用情况(多少需求、多快、返工多少、花了多少)。在此之前,需求的状态分散在各仓库各工作区的功能目录里,`req list` 只看得到当前仓库;用时和用量只在每个需求的状态文件中,没有跨需求的汇总;`req list`、`req status` 只输出给人看的文字,程序要用只能解析文字或直接读状态文件,后者把状态文件的内部格式变成了对外约定。
8
+
9
+ ## 决定
10
+
11
+ - **需求索引**:引擎每次写状态文件时,确认本机配置目录下的 `index/<key>.json` 记着这个需求在哪里(目录、功能目录、对话所在目录、工作区),一个需求一个文件,几个流水线同时运行也不会互相覆盖。索引只记位置,阶段和进度仍然现场从功能目录读取,所以不会和实际情况不一致。
12
+ - **使用记录**:每次运行(后台进程从开始到结束)结束时向 `usage/runs.jsonl` 追加一行:步骤、用时、用量、质量门、结束时的状态。按运行而不是按需求记,追加即可,不用改写旧记录;需求层面的数字(交付周期、评审轮数、等待时长)在统计时从同一需求的各次运行算出。
13
+ - **只在本机,不自动上报**:两样都不提交进仓库,也不发往任何地方;不记需求内容和代码。汇总给管理方时由人导出(`req stats --export`)再合并(`--merge`);之后定下汇总通道时,用 1.9 的集成模块订阅"运行结束",数据格式不变。
14
+ - **`req stats`**:Markdown 汇总、JSON、每个需求一行的 CSV,`--report` 生成汇报用的两份文件。
15
+ - **`req api`**:给程序的稳定接口,一行 JSON,带 `schema`。读操作(list、status、file、stats、index)不改变任何东西;管理操作 `req api run <approve|stop|resume|verify>` 就是在相应目录执行 `req <命令>`,引擎仍是唯一修改状态的一方。`file` 只能读功能目录中的文档白名单。
16
+ - **评估和测试不进记录**:`REQ_KIT_NO_RECORD=1` 关闭记录,评估运行设置它;测试把本机配置目录指向临时目录。
17
+
18
+ ## 影响
19
+
20
+ - 插件和脚本只依赖 `contracts/cli-json.md`,状态文件的内部格式可以继续演进。
21
+ - 1.9 之前运行过的需求不在索引里,需要 `req index --rebuild` 补登;删掉的目录在索引里显示为"已移除",`--prune` 清理。
22
+ - 记录是尽力而为的:写失败不影响流水线,被强制结束(kill -9、断电)的运行没有记录,统计会少算这次运行。
23
+ - 主机名写进每条记录,用于合并多台机器的数据时区分来源。
@@ -27,3 +27,4 @@ req-kit 自身的设计决定。修改其中任何一条,请新增一条 ADR
27
27
  - [0023 需求类型、质量门、按切片实现](0023-types-gate-slices.md)
28
28
  - [0024 让分歧更早暴露:验收例子、方案确认点、两个方向的评审、反复的问题变成规则](0024-confirm-early-review-axes.md)
29
29
  - [0025 联合需求:一个需求同时改几个仓库](0025-joint-requirements.md)
30
+ - [0026 本机的需求索引、使用记录和 req api](0026-machine-records-and-api.md)
@@ -125,6 +125,8 @@ node install.mjs --configure
125
125
  | `req upgrade` | 升级到最新版本 |
126
126
  | `req notify-test` | 给外部通知发测试消息 |
127
127
  | `req digest` | 项目的需求总览;`--notify` 发到外部通知,`--schedule "0 9 * * 1"` 每周一 9 点自动发送 |
128
+ | `req stats` | 本机的使用统计;`--report --month 2026-10` 生成汇报用的文件,`--export` / `--merge` 汇总多台机器 |
129
+ | `req index --rebuild` | 把 1.9 之前运行过的需求登记进本机的需求索引(Paseo 插件据此列出所有需求) |
128
130
 
129
131
  `<需求>` 可以只写名称中能唯一确定的一部分,如 `sms-login`。`req help` 查看全部。不加 PATH 也可以用完整路径 `~/.req-kit/bin/req`(Windows 为 `%USERPROFILE%\.req-kit\bin\req.cmd`)。
130
132
 
@@ -275,6 +275,16 @@ Paseo 自带的 `/paseo-advisor`、`/paseo-committee` 等编排 skill 也可以
275
275
 
276
276
  `req digest` 在项目目录中列出所有工作区里的需求,按"需要处理、待验收、开发中、对齐中、已验收(最近 7 天)"分组,附用时与用量。`--notify` 同时发给外部通知(事件类型 `digest`);`--schedule "<cron>"` 让 Paseo 按时间表(本机时区)启动一个短暂的 agent 执行 `req digest --notify`,例如每周一 9 点:`req digest --schedule "0 9 * * 1"`;取消:`req digest --cancel <定时任务 ID>`。
277
277
 
278
+ ## 使用统计
279
+
280
+ 1.9.0 起,每次流水线运行结束时,引擎在本机的 `~/.req-kit/usage/runs.jsonl` 追加一条记录:各步骤的用时和用量、质量门、评审和缺陷轮数、结束时的状态(格式见 [使用记录](../../contracts/usage-record.md))。只记在本机,不记需求内容和代码,也不会自动发往任何地方。
281
+
282
+ - `req stats`:最近 30 天的统计(`--since 2w`、`--since 2026-10-01`、`--month 2026-10`):有进展和完成的需求数、交付周期、返工(评审轮数、需要缺陷修复的占比、质量门失败)、各种等待的时长、各步骤用时、用量,以及不同 builder 的比较。`--json`、`--csv` 输出机器可读的格式。
283
+ - `req stats --report --month 2026-10 [--out 目录]`:生成给管理方汇报用的两份文件:`req-kit-usage-2026-10.md`(汇总)和 `.csv`(每个需求一行,可以用表格软件打开)。
284
+ - 几台机器一起汇报:每台机器运行 `req stats --month 2026-10 --export`,得到 `req-kit-usage-<主机名>-<日期>.jsonl`;把这些文件放到一起,运行 `req stats --merge <文件…> --month 2026-10 --report`。
285
+
286
+ 本机的需求索引 `~/.req-kit/index/` 记录每个运行过的需求在哪个目录,Paseo 插件据此在一个页面列出本机所有项目的需求。1.9 之前运行过的需求不在索引里,在项目目录运行一次 `req index --rebuild` 补登;目录删掉后用 `req index --prune` 清理。
287
+
278
288
  ## 团队配置
279
289
 
280
290
  几台机器、多个项目共用的设置(统一的模型、评审小组、超时、要求的 req-kit 版本)可以放在一份团队配置里。把它放进一个团队共用的 git 仓库,各台机器克隆下来,在本机配置 `~/.req-kit/config.json` 中写上它的路径(绝对路径,或相对 `~/.req-kit/` 的路径):
@@ -0,0 +1,397 @@
1
+ // `req api`: machine-readable output for programs (the Paseo plugin, scripts), 1.9.
2
+ // The format is a contract (contracts/cli-json.md): every command prints one JSON object,
3
+ // { "schema": 1, "kitVersion": "…", "data": … } exit 0
4
+ // { "schema": 1, "kitVersion": "…", "error": { "code", "message" } } exit 1
5
+ //
6
+ // req api version
7
+ // req api list [--all] [--repo <dir>]
8
+ // req api status <需求> [--repo <dir>]
9
+ // req api file <需求> <文件> [--repo <dir>]
10
+ // req api stats [--since 30d] [--month 2026-10]
11
+ // req api index [--rebuild <dir>...] [--prune]
12
+ // req api run <approve|stop|resume|verify> <需求> [--repo <dir>] [其他选项]
13
+ //
14
+ // Reading never changes anything. `run` performs the same command as `req <command>` in that
15
+ // directory, so the engine stays the only one that changes a requirement's state.
16
+
17
+ import { spawnSync } from "node:child_process";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { resultText, stepName } from "./control.js";
21
+ import { FEATURES_DIR, findWorktreeRun, listFeatures, resolveFeatureArg, summarize } from "./features.js";
22
+ import { KIT_ROOT, kitVersion } from "./locations.js";
23
+ import { pruneIndex, readIndex, registerRequirement, requirementKey } from "./records.js";
24
+ import { isAlive, type PipelineState, readState, statePath, type StepRecord } from "./state.js";
25
+ import { parsePeriod, summarizeRuns } from "./stats.js";
26
+ import { readRuns, runsFile } from "./records.js";
27
+ import { totals } from "./usage.js";
28
+
29
+ export const API_SCHEMA = 1;
30
+
31
+ export class ApiError extends Error {
32
+ constructor(
33
+ readonly code: string,
34
+ message: string,
35
+ ) {
36
+ super(message);
37
+ }
38
+ }
39
+
40
+ /** Documents a program may read from a feature directory (`req api file`). */
41
+ export const READABLE_FILES = [
42
+ "spec.md",
43
+ "alignment.md",
44
+ "spec-review.md",
45
+ "changes.md",
46
+ "decisions.tsv",
47
+ "design.md",
48
+ "design-review.md",
49
+ "preview.md",
50
+ "contract.md",
51
+ "diagnosis.md",
52
+ "assessment.md",
53
+ "implementation.md",
54
+ "progress.md",
55
+ "code-review.md",
56
+ "handoff.md",
57
+ "service-start.md",
58
+ "verification.md",
59
+ "report.md",
60
+ "acceptance.md",
61
+ "lessons.tsv",
62
+ ] as const;
63
+
64
+ const MAX_FILE_BYTES = 512 * 1024;
65
+
66
+ export type Attention = "approval" | "business" | "interrupted" | "not_completed" | null;
67
+
68
+ /** What a person needs to do about a requirement, if anything. */
69
+ export function attentionOf(state: Pick<PipelineState, "status" | "pid" | "outcome"> | null): Attention {
70
+ if (!state) return null;
71
+ if (state.status === "waiting_for_approval") return "approval";
72
+ if (state.status === "waiting_for_business") return "business";
73
+ if (state.status === "failed" || (state.status === "running" && !isAlive(state.pid))) return "interrupted";
74
+ if (state.status === "completed" && state.outcome === "not_completed") return "not_completed";
75
+ return null;
76
+ }
77
+
78
+ function usageOf(state: PipelineState | null) {
79
+ if (!state || state.history.length === 0) return null;
80
+ const t = totals(state.history);
81
+ return { steps: t.steps, agentMinutes: Math.round(t.agentMinutes * 10) / 10, inputTokens: t.inputTokens, outputTokens: t.outputTokens, costUsd: t.costUsd };
82
+ }
83
+
84
+ function readStateSafe(repo: string, featureDir: string): PipelineState | null {
85
+ try {
86
+ return readState(statePath(repo, featureDir));
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function currentSteps(state: PipelineState | null) {
93
+ if (!state || state.status !== "running" || !isAlive(state.pid)) return [];
94
+ return state.history.filter((h) => h.outcome === "running").map((h) => ({ key: h.key, title: stepName(h, state), agentId: h.agentId }));
95
+ }
96
+
97
+ export interface ListItem {
98
+ key: string;
99
+ project: string;
100
+ repo: string;
101
+ sourceRepo: string | null;
102
+ workspaceId: string | null;
103
+ featureDir: string;
104
+ name: string;
105
+ title: string;
106
+ type: string | null;
107
+ profile: string | null;
108
+ branch: string | null;
109
+ /** Plain-language stage, as `req list` shows it. */
110
+ stage: string;
111
+ /** The state file's status; null before the first run. */
112
+ status: string | null;
113
+ running: boolean;
114
+ phase: string | null;
115
+ attention: Attention;
116
+ current: Array<{ key: string; title: string; agentId: string }>;
117
+ updatedAt: string;
118
+ usage: ReturnType<typeof usageOf>;
119
+ /** False when the index still lists a requirement whose directory is gone. */
120
+ present: boolean;
121
+ }
122
+
123
+ function item(repo: string, branch: string | null, featureDir: string, extra: { sourceRepo: string | null; workspaceId: string | null; project: string }): ListItem | null {
124
+ const name = path.basename(featureDir);
125
+ const summary = summarize({ path: repo, branch }, name);
126
+ if (!summary) return null;
127
+ const state = readStateSafe(repo, featureDir);
128
+ return {
129
+ key: requirementKey(extra.sourceRepo ?? repo, featureDir),
130
+ project: extra.project,
131
+ repo,
132
+ sourceRepo: extra.sourceRepo,
133
+ workspaceId: extra.workspaceId ?? state?.worktree?.workspaceId ?? null,
134
+ featureDir: summary.featureDir,
135
+ name,
136
+ title: summary.title,
137
+ type: state?.type ?? null,
138
+ profile: summary.profile ?? state?.profile ?? null,
139
+ branch: state?.branch ?? summary.branch,
140
+ stage: summary.stage,
141
+ status: state?.status ?? null,
142
+ running: !!state && state.status === "running" && isAlive(state.pid),
143
+ phase: state?.phase ?? null,
144
+ attention: attentionOf(state),
145
+ current: currentSteps(state),
146
+ updatedAt: summary.updatedAt,
147
+ usage: usageOf(state),
148
+ present: true,
149
+ };
150
+ }
151
+
152
+ /** Requirements of the repository at `repo` (all its worktrees), or of the whole machine. */
153
+ export function apiList(input: { repo: string; all: boolean }): ListItem[] {
154
+ if (!input.all) {
155
+ const repo = fs.realpathSync(input.repo);
156
+ return listFeatures(repo)
157
+ .map((f) => {
158
+ const state = readStateSafe(f.worktree, f.featureDir);
159
+ const source = state?.worktree?.sourceRepo ?? null;
160
+ return item(f.worktree, f.branch, f.featureDir, { sourceRepo: source, workspaceId: state?.worktree?.workspaceId ?? null, project: path.basename(source ?? repo) });
161
+ })
162
+ .filter((x): x is ListItem => x !== null);
163
+ }
164
+ const out: ListItem[] = [];
165
+ for (const e of readIndex()) {
166
+ const found = e.present ? item(e.repo, null, e.featureDir, { sourceRepo: e.sourceRepo, workspaceId: e.workspaceId, project: e.project }) : null;
167
+ out.push(
168
+ found ?? {
169
+ key: e.key,
170
+ project: e.project,
171
+ repo: e.repo,
172
+ sourceRepo: e.sourceRepo,
173
+ workspaceId: e.workspaceId,
174
+ featureDir: e.featureDir,
175
+ name: path.basename(e.featureDir),
176
+ title: path.basename(e.featureDir),
177
+ type: null,
178
+ profile: null,
179
+ branch: null,
180
+ stage: "已移除(目录不在了)",
181
+ status: null,
182
+ running: false,
183
+ phase: null,
184
+ attention: null,
185
+ current: [],
186
+ updatedAt: e.registeredAt,
187
+ usage: null,
188
+ present: false,
189
+ },
190
+ );
191
+ }
192
+ return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
193
+ }
194
+
195
+ /** The repository and feature directory a requirement argument names, as `req status` resolves it. */
196
+ export function locate(repoArg: string, feature: string): { repo: string; featureDir: string; sourceRepo: string | null } {
197
+ if (!fs.existsSync(repoArg)) throw new ApiError("not_found", `目录不存在:${repoArg}`);
198
+ const repo = fs.realpathSync(repoArg);
199
+ let here: string | null = null;
200
+ try {
201
+ here = resolveFeatureArg(repo, feature);
202
+ } catch {
203
+ here = null;
204
+ }
205
+ if (here && fs.existsSync(statePath(repo, here))) return { repo, featureDir: here, sourceRepo: null };
206
+ const away = findWorktreeRun(repo, here ?? feature);
207
+ if (away) return { repo: away.repo, featureDir: away.featureDir, sourceRepo: repo };
208
+ if (here) return { repo, featureDir: here, sourceRepo: null };
209
+ throw new ApiError("not_found", `没有找到需求"${feature}"(${repo})`);
210
+ }
211
+
212
+ function stepJson(h: StepRecord, state: PipelineState) {
213
+ const minutes = Math.max(0, ((h.endedAt ? new Date(h.endedAt) : new Date()).getTime() - new Date(h.startedAt).getTime()) / 60_000);
214
+ return {
215
+ key: h.key,
216
+ stepId: h.stepId,
217
+ title: stepName(h, state),
218
+ phase: h.phase ?? null,
219
+ attempt: h.attempt,
220
+ round: h.round,
221
+ agentId: h.agentId,
222
+ startedAt: h.startedAt,
223
+ endedAt: h.endedAt,
224
+ minutes: Math.round(minutes * 10) / 10,
225
+ outcome: h.outcome,
226
+ result: h.outcome === "completed" ? resultText(h) : null,
227
+ error: h.error,
228
+ usage: h.usage ?? null,
229
+ };
230
+ }
231
+
232
+ export function apiStatus(repoArg: string, feature: string) {
233
+ const { repo, featureDir, sourceRepo } = locate(repoArg, feature);
234
+ const state = readStateSafe(repo, featureDir);
235
+ const source = sourceRepo ?? state?.worktree?.sourceRepo ?? null;
236
+ const base = item(repo, state?.branch ?? null, featureDir, { sourceRepo: source, workspaceId: state?.worktree?.workspaceId ?? null, project: path.basename(source ?? repo) });
237
+ if (!base) throw new ApiError("not_found", `${featureDir} 中没有 spec.md`);
238
+ const dir = path.join(repo, featureDir);
239
+ return {
240
+ ...base,
241
+ files: READABLE_FILES.filter((f) => fs.existsSync(path.join(dir, f))),
242
+ run: state
243
+ ? {
244
+ attempt: state.attempt,
245
+ startedAt: state.startedAt,
246
+ updatedAt: state.updatedAt,
247
+ pid: state.pid,
248
+ phaseMode: state.phases?.mode ?? null,
249
+ stopReason: state.stopReason,
250
+ outcome: state.outcome,
251
+ queuedSince: state.queuedSince ?? null,
252
+ verifyAt: state.verifyAt ?? null,
253
+ approval: state.approval ?? null,
254
+ originAgentId: state.originAgentId,
255
+ }
256
+ : null,
257
+ steps: state ? state.history.map((h) => stepJson(h, state)) : [],
258
+ gate: state?.gate ?? null,
259
+ joint: (state?.joint ?? []).map((j) => ({ name: j.name, slug: j.slug, repo: j.repo, sourceRepo: j.sourceRepo, branch: j.branch, workspaceId: j.workspaceId, gate: j.gate ?? null })),
260
+ };
261
+ }
262
+
263
+ export function apiFile(repoArg: string, feature: string, file: string) {
264
+ if (!(READABLE_FILES as readonly string[]).includes(file)) {
265
+ throw new ApiError("not_allowed", `只能读取这些文件:${READABLE_FILES.join("、")}`);
266
+ }
267
+ const { repo, featureDir } = locate(repoArg, feature);
268
+ const full = path.join(repo, featureDir, file);
269
+ if (!fs.existsSync(full)) throw new ApiError("not_found", `${featureDir} 中没有 ${file}`);
270
+ const size = fs.statSync(full).size;
271
+ const buf = fs.readFileSync(full);
272
+ return { featureDir, file, size, truncated: size > MAX_FILE_BYTES, content: buf.subarray(0, MAX_FILE_BYTES).toString("utf8") };
273
+ }
274
+
275
+ /** Registers the requirements that have run in these directories' repositories (all worktrees). */
276
+ export function apiIndex(input: { rebuild: string[]; prune: boolean }) {
277
+ let registered = 0;
278
+ const dirs = new Set(input.rebuild.map((d) => path.resolve(d)));
279
+ for (const e of readIndex()) if (input.rebuild.length > 0 && e.present) dirs.add(e.sourceRepo ?? e.repo);
280
+ for (const dir of dirs) {
281
+ if (!fs.existsSync(dir)) continue;
282
+ for (const f of listFeatures(dir)) {
283
+ const state = readStateSafe(f.worktree, f.featureDir);
284
+ if (!state) continue;
285
+ registerRequirement({ repo: f.worktree, featureDir: f.featureDir, worktree: state.worktree ?? null });
286
+ registered++;
287
+ }
288
+ }
289
+ const pruned = input.prune ? pruneIndex() : 0;
290
+ return { scanned: [...dirs], registered, pruned, total: readIndex().length };
291
+ }
292
+
293
+ const RUNNABLE = ["approve", "stop", "resume", "verify"] as const;
294
+
295
+ /** Runs `req <command> <需求>` where people would, and reports what it said. */
296
+ export function apiRun(command: string, repoArg: string, feature: string, extra: string[]) {
297
+ if (!(RUNNABLE as readonly string[]).includes(command)) throw new ApiError("not_allowed", `只能执行:${RUNNABLE.join("、")}`);
298
+ if (!fs.existsSync(repoArg)) throw new ApiError("not_found", `目录不存在:${repoArg}`);
299
+ const cwd = fs.realpathSync(repoArg);
300
+ // Not from a chat: the run keeps reporting to the chat that started it.
301
+ const env = { ...process.env };
302
+ delete env.PASEO_AGENT_ID;
303
+ delete env.PASEO_AGENT_CWD;
304
+ const r = spawnSync(process.execPath, [path.join(KIT_ROOT, "bin", "req.mjs"), command, feature, ...extra], {
305
+ cwd,
306
+ env,
307
+ encoding: "utf8",
308
+ timeout: 180_000,
309
+ windowsHide: true,
310
+ });
311
+ const output = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
312
+ return { command, featureDir: feature, ok: r.status === 0, exitCode: r.status, message: r.error ? r.error.message : output };
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // CLI
317
+
318
+ function option(argv: string[], name: string): string | null {
319
+ const i = argv.indexOf(name);
320
+ if (i < 0) return null;
321
+ const v = argv[i + 1];
322
+ if (v === undefined || v.startsWith("--")) throw new ApiError("bad_arguments", `${name} 后面要写值`);
323
+ argv.splice(i, 2);
324
+ return v;
325
+ }
326
+
327
+ function flag(argv: string[], name: string): boolean {
328
+ const i = argv.indexOf(name);
329
+ if (i < 0) return false;
330
+ argv.splice(i, 1);
331
+ return true;
332
+ }
333
+
334
+ export function apiMain(argvIn: string[], cwd = process.cwd()): unknown {
335
+ const argv = [...argvIn];
336
+ const command = argv.shift();
337
+ const repo = option(argv, "--repo") ?? cwd;
338
+ const need = (what: string) => {
339
+ const v = argv.shift();
340
+ if (!v || v.startsWith("--")) throw new ApiError("bad_arguments", `缺少${what}`);
341
+ return v;
342
+ };
343
+ switch (command) {
344
+ case "version":
345
+ return {
346
+ kitVersion: kitVersion(),
347
+ kitRoot: KIT_ROOT,
348
+ apiSchema: API_SCHEMA,
349
+ paseo: (JSON.parse(fs.readFileSync(path.join(KIT_ROOT, "package.json"), "utf8")) as { reqKit?: { paseo?: string } }).reqKit?.paseo ?? null,
350
+ featuresDir: FEATURES_DIR.split(path.sep).join("/"),
351
+ };
352
+ case "list":
353
+ return apiList({ repo, all: flag(argv, "--all") });
354
+ case "status":
355
+ return apiStatus(repo, need("需求"));
356
+ case "file": {
357
+ const feature = need("需求");
358
+ return apiFile(repo, feature, need("文件名"));
359
+ }
360
+ case "stats": {
361
+ const since = option(argv, "--since");
362
+ const month = option(argv, "--month");
363
+ return summarizeRuns(readRuns(runsFile()), parsePeriod({ since, month }));
364
+ }
365
+ case "index": {
366
+ const prune = flag(argv, "--prune");
367
+ const rebuild: string[] = [];
368
+ if (flag(argv, "--rebuild")) {
369
+ while (argv[0] && !argv[0].startsWith("--")) rebuild.push(argv.shift()!);
370
+ if (rebuild.length === 0) rebuild.push(cwd);
371
+ }
372
+ return rebuild.length > 0 || prune ? apiIndex({ rebuild, prune }) : readIndex();
373
+ }
374
+ case "run": {
375
+ const what = need("命令(approve、stop、resume、verify)");
376
+ return apiRun(what, repo, need("需求"), argv.splice(0));
377
+ }
378
+ default:
379
+ throw new ApiError("bad_arguments", `没有 "${command ?? ""}" 这个 api 命令。可用:version、list、status、file、stats、index、run`);
380
+ }
381
+ }
382
+
383
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
384
+ let code = 0;
385
+ let out: Record<string, unknown>;
386
+ try {
387
+ out = { schema: API_SCHEMA, kitVersion: kitVersion(), data: apiMain(process.argv.slice(2)) };
388
+ const data = out.data as { ok?: boolean } | null;
389
+ if (data && data.ok === false) code = 1;
390
+ } catch (error) {
391
+ const e = error as Error & { code?: string };
392
+ out = { schema: API_SCHEMA, kitVersion: kitVersion(), error: { code: error instanceof ApiError ? e.code : "failed", message: e.message } };
393
+ code = 1;
394
+ }
395
+ process.stdout.write(`${JSON.stringify(out)}\n`);
396
+ process.exit(code);
397
+ }
@@ -31,7 +31,7 @@ const RESULT_TEXT: Record<string, string> = {
31
31
  not_completed: "未完成",
32
32
  };
33
33
 
34
- function resultText(record: StepRecord): string {
34
+ export function resultText(record: StepRecord): string {
35
35
  const r = record.result ?? {};
36
36
  if (typeof r.has_runnable_service === "boolean") return r.has_runnable_service ? "完成(项目有常驻服务)" : "完成";
37
37
  if (typeof r.started === "boolean") return r.started ? "服务启动成功" : "服务启动失败";
@@ -42,7 +42,7 @@ function resultText(record: StepRecord): string {
42
42
  return "完成";
43
43
  }
44
44
 
45
- function stepName(record: StepRecord, state: Pick<PipelineState, "type" | "joint"> | null = null): string {
45
+ export function stepName(record: StepRecord, state: Pick<PipelineState, "type" | "joint"> | null = null): string {
46
46
  const [base, slug] = record.key.split("@");
47
47
  const repo = slug ? (state?.joint ?? []).find((j) => j.slug === slug)?.name ?? slug : null;
48
48
  const title = `${stepTitle(state?.type, record.stepId)}${repo ? `·${repo}` : ""}`;