paseo-req-kit 1.14.0-beta.3 → 1.14.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/req.mjs +8 -0
  3. package/contracts/cli-json.md +1 -1
  4. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +8 -0
  5. package/engine/prompts/06-code-review.md +12 -1
  6. package/engine/prompts/incremental.md +2 -0
  7. package/engine/src/api.ts +14 -2
  8. package/engine/src/cli-args.ts +4 -1
  9. package/engine/src/code-review.ts +419 -0
  10. package/engine/src/codeowners.ts +87 -0
  11. package/engine/src/main.ts +13 -3
  12. package/engine/src/preflight.ts +10 -1
  13. package/engine/src/review-assets/code.css +93 -0
  14. package/engine/src/review-assets/code.html +43 -0
  15. package/engine/src/review-assets/code.js +402 -0
  16. package/engine/src/review.ts +83 -1
  17. package/package.json +1 -1
  18. package/paseo-plugin/client/data.ts +2 -2
  19. package/paseo-plugin/client/detail.tsx +7 -2
  20. package/paseo-plugin/client/nav.ts +5 -0
  21. package/paseo-plugin/client/review.tsx +16 -6
  22. package/paseo-plugin/package.json +1 -1
  23. package/paseo-plugin/server/handlers.ts +1 -1
  24. package/paseo-plugin/shared/contracts.ts +4 -2
  25. package/site-dist/404.html +2 -2
  26. package/site-dist/assets/{app.BpC99f5a.js → app.Cp7WwPWE.js} +1 -1
  27. package/site-dist/assets/changelog.md.Zi-_3Rjh.js +1 -0
  28. package/site-dist/assets/chunks/@localSearchIndexroot.COxp5Rrc.js +1 -0
  29. package/site-dist/assets/chunks/{VPLocalSearchBox.CI43rhTA.js → VPLocalSearchBox.CMTXQK-Q.js} +1 -1
  30. package/site-dist/assets/chunks/{theme.S6pNEF23.js → theme.DvVUfKr3.js} +2 -2
  31. package/site-dist/assets/{guide_getting-started.md.a7iNP1Ky.js → guide_getting-started.md.D8XlIQOg.js} +1 -1
  32. package/site-dist/assets/{guide_user.md.D9GpGJ7y.js → guide_user.md.B4N9ywmr.js} +1 -1
  33. package/site-dist/assets/{reference_contracts_cli-json.md.BV1HtTmr.js → reference_contracts_cli-json.md.CCvKLasM.js} +1 -1
  34. package/site-dist/assets/{reference_contracts_cli-json.md.BV1HtTmr.lean.js → reference_contracts_cli-json.md.CCvKLasM.lean.js} +1 -1
  35. package/site-dist/changelog.html +5 -5
  36. package/site-dist/guide/getting-started.html +5 -5
  37. package/site-dist/guide/how-it-works.html +4 -4
  38. package/site-dist/guide/index.html +4 -4
  39. package/site-dist/guide/install.html +4 -4
  40. package/site-dist/guide/maintainer.html +4 -4
  41. package/site-dist/guide/phases.html +4 -4
  42. package/site-dist/guide/project.html +4 -4
  43. package/site-dist/guide/user.html +5 -5
  44. package/site-dist/hashmap.json +1 -1
  45. package/site-dist/index.html +4 -4
  46. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
  47. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
  48. package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
  49. package/site-dist/reference/adr/0004-result-files.html +4 -4
  50. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
  51. package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
  52. package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
  53. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
  54. package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
  55. package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
  56. package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
  57. package/site-dist/reference/adr/0012-review-panel.html +4 -4
  58. package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
  59. package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
  60. package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
  61. package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
  62. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
  63. package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
  64. package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
  65. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
  66. package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
  67. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
  68. package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
  69. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
  70. package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
  71. package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
  72. package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
  73. package/site-dist/reference/adr/0028-external-integrations.html +4 -4
  74. package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
  75. package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
  76. package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
  77. package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
  78. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +4 -4
  79. package/site-dist/reference/adr/index.html +4 -4
  80. package/site-dist/reference/commands.html +4 -4
  81. package/site-dist/reference/config.html +4 -4
  82. package/site-dist/reference/contracts/cli-json.html +5 -5
  83. package/site-dist/reference/contracts/feature-files.html +4 -4
  84. package/site-dist/reference/contracts/index.html +4 -4
  85. package/site-dist/reference/contracts/notifier-event.html +4 -4
  86. package/site-dist/reference/contracts/result-file.html +4 -4
  87. package/site-dist/reference/contracts/spec.html +4 -4
  88. package/site-dist/reference/contracts/state.html +4 -4
  89. package/site-dist/reference/contracts/usage-record.html +4 -4
  90. package/site-dist/reference/contracts/verify.html +4 -4
  91. package/site-dist/reference/scope.html +4 -4
  92. package/skills/req/SKILL.md +1 -0
  93. package/site-dist/assets/changelog.md.m4cSU5e4.js +0 -1
  94. package/site-dist/assets/chunks/@localSearchIndexroot.D6ywcTJ5.js +0 -1
  95. /package/site-dist/assets/{changelog.md.m4cSU5e4.lean.js → changelog.md.Zi-_3Rjh.lean.js} +0 -0
  96. /package/site-dist/assets/{guide_getting-started.md.a7iNP1Ky.lean.js → guide_getting-started.md.D8XlIQOg.lean.js} +0 -0
  97. /package/site-dist/assets/{guide_user.md.D9GpGJ7y.lean.js → guide_user.md.B4N9ywmr.lean.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  每个版本分为"使用者会看到的变化"和"升级须知"。升级步骤见 [docs/guide/安装与升级.md](docs/guide/安装与升级.md)。
4
4
 
5
+ ## 1.14.0-beta.4
6
+
7
+ ### 使用者会看到的变化
8
+
9
+ - **代码审查**(插件需求页面上的"代码审查",`req code-review <需求>`):列出需求分支相对起点的全部改动(需求文档放最后、折叠),AI 代码评审的意见放在对应的代码行旁边,带级别和修复步骤的处理结果;评审人可以在任意一行写意见、把 AI 意见标为"要改 / 不用改"、写总体意见。
10
+ - **审核人默认取自 `CODEOWNERS`**(支持 GitLab / GitHub 格式和 GitLab 的分节),按改动的文件给出,提交前可以修改,以后建合并请求时使用。
11
+ - **退回修改**:意见写进 `code-review.md` 的"人工评审"一节(HR-1 起)和 `changes.md` 并提交,流水线开始新的一次尝试(`req review-fix`),需求说明不变、方案确认不用重来,从方案设计开始只处理这些意见,之后照常经过 AI 代码评审和验证。**评审通过**:记下评审人、审核人和评审的提交;之后又有新提交时提示需要重新评审。
12
+ - AI 代码评审的结果文件新增 `findings`(每条意见的级别、文件、行号、问题、修复方向);没有它的旧需求从 `code-review.md` 的 CR 条目中读取。
13
+
14
+ ### 升级须知
15
+
16
+ - 合并请求还没有接入:等 shenbing 提供类似 `gh pr create / gh pr merge` 的命令后,用评审通过时记下的审核人建合并请求。
17
+ - `req api status` 新增 `humanReview`;`req api review-url` 的 `--page` 新增 `code`;启动参数新增 `--review-fix`(`req review-fix <需求>`)。
18
+
5
19
  ## 1.14.0-beta.3
6
20
 
7
21
  ### 使用者会看到的变化
package/bin/req.mjs CHANGED
@@ -31,6 +31,8 @@ const HELP = `req-kit ${version}(${kitRoot})
31
31
  让 Paseo 按时间表发送需求总览(例子为每周一 9:00);取消:req digest --cancel <ID>
32
32
  req sync <需求> 把需求同步到对接的外部系统(补发失败的调用、补建卡片、更新状态)
33
33
  req review <需求> 在浏览器打开原型审查页:点选要改的地方、直接改文字,提交意见或确认方案
34
+ req code-review <需求> 代码审查页:看这次的改动和 AI 评审意见,在代码行上写意见,退回修改或评审通过
35
+ req review-fix <需求> 按代码审查页上退回的意见开始修改(插件里提交"退回修改"时自动执行)
34
36
  req try <需求> [--stop] 把做好的需求跑起来试用:给出访问地址和测试数据(--stop 停止)
35
37
  req checklist <需求> 验收清单:按验收标准逐条勾选,提交后写成验收记录
36
38
  req ui-check <网址> [--prototype <原型文件>] [--width 1280,390]
@@ -168,6 +170,12 @@ switch (command) {
168
170
  case "approve":
169
171
  code = node([launcher, "--approve", ...feature(rest, true)]);
170
172
  break;
173
+ case "review-fix":
174
+ code = node([launcher, "--review-fix", ...feature(rest, true)]);
175
+ break;
176
+ case "code-review":
177
+ code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "review.ts"), "--page", "code", ...rest]) : 1;
178
+ break;
171
179
  case "start":
172
180
  // From a card in the requirement management system (1.10): no chat needed to begin.
173
181
  if (rest.some((a) => a === "--from" || a.startsWith("--from="))) code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "card-cmd.ts"), "start", ...rest]) : 1;
@@ -32,7 +32,7 @@
32
32
  | `req api config [--repo <目录>] [--machine]` | 1.12 起。`{ sources, ignored, error, repo, groups }`:`sources` 是生效的配置文件(`layer` 为 `machine` / `team` / `project`,按叠加顺序);`groups` 按主题分组,每项 `{ key, label, description, display, value, setBy, scope, changed }`:`display` 是用大白话写的值,`value` 中看起来像密钥的值和个人账号替换为 `••••`,`setBy` 是写了它的文件,`changed` 表示与默认值不同。配置有错误时 `error` 写明原因,值按各文件所写。`--machine` 不看项目配置 |
33
33
  | `req api set-agents --json <设置>` | 1.12 起。`{ ok, message }`。`<设置>` 是 `{ builder, reviewer, fallback }`(`fallback` 可以为 null),检查后写入本机配置的 `agents`,原文件备份 |
34
34
  | `req api watchdog [--by plugin\|factory] [--dry-run]` | 1.13 起。`{ checked, resumed, failed, skipped }`:把本机"运行中但后台进程已不在"的需求直接继续(`req resume`)。每个进程只继续一次,每个需求 24 小时内最多 3 次;`--by plugin` 同时记下插件在检查(流水线据此不再预约对话的定时检查) |
35
- | `req api review-url <需求> [--repo <目录>] [--read-only] [--page check]` | 1.13 起;`--page check`(1.14)给出同一服务上的验收清单页地址(需要 `spec.md`)。`{ url, readOnly }`:这个需求的原型审查页地址(需要时在后台启动审查页的服务)。`--read-only` 的地址只能看,不能提交意见或确认方案。需求不在本机的需求索引中或还没有 `prototype/` 时报 `not_found` |
35
+ | `req api review-url <需求> [--repo <目录>] [--read-only] [--page check]` | 1.13 起;`--page check`(1.14)给出同一服务上的验收清单页地址(需要 `spec.md`),`--page code`(1.14)给出代码审查页地址(需要已经写过代码)。`{ url, readOnly }`:这个需求的原型审查页地址(需要时在后台启动审查页的服务)。`--read-only` 的地址只能看,不能提交意见或确认方案。需求不在本机的需求索引中或还没有 `prototype/` 时报 `not_found` |
36
36
 
37
37
  `<需求>` 的写法与 `req status` 相同:功能目录、目录名或名称中能唯一确定的一部分。需求在为它新建的工作区中开发时,`--repo` 写对话所在的目录或那个工作区都可以。
38
38
 
@@ -97,6 +97,14 @@ req-kit 帮你把一个需求从"我想要……"做到"可以验收":你在 P
97
97
 
98
98
  然后说"我要验收"。AI 会按验收标准一条一条带你核对:告诉你怎么操作、应该看到什么;能用命令验证的给出命令,你可以自己执行。每条结果记入功能目录的 `acceptance.md`。
99
99
 
100
+ **代码审查(1.14 起,给开发同事和评审人)。** 需求页面上点**"代码审查"**(终端里是 `req code-review <需求>`),代码审查页在 Paseo 里打开:
101
+
102
+ - 列出这次改动的全部文件(相对需求分支的起点,需求文档排在最后、默认折叠),上面是提测说明里"这次改了什么"。
103
+ - AI 代码评审的每条意见放在它说的那一行旁边,标着级别(阻塞 / 重要 / 建议)和修复步骤的处理结果(已修复、判为不成立、决定不修复);可以标**要改**或**不用改**,补一句话。
104
+ - 点任意一行代码写意见;最下面可以写总体意见。
105
+ - **审核人**默认取自仓库的 `CODEOWNERS`(按改动的文件),提交前可以改;以后建合并请求时会用它。
106
+ - **退回修改**:意见写进 `code-review.md` 的"人工评审"一节(HR-1 起)和 `changes.md`,流水线随即开始新的一次尝试,需求说明不变,从方案设计开始只处理这些意见,改完再经过 AI 代码评审和验证,进展发回需求的对话。**评审通过**:记下评审人和审核人;之后代码又有改动时,页面会提示需要重新评审。
107
+
100
108
  **在插件里自己验收(1.14 起)。** 需求页面上点**"验收清单(可一键试用)"**(终端里是 `req checklist <需求>`),验收清单在 Paseo 里打开:
101
109
 
102
110
  - 每条验收标准写着前置条件、怎么操作、应该看到什么,以及流水线的验证结果;流水线没验证或没通过的排在最前面,报告里"需要你确认的决定"也列在后面请你表态。
@@ -49,6 +49,10 @@
49
49
  6. **评审意见**:按级别排列
50
50
  7. **处理记录**:留空,由下一步填写
51
51
 
52
+ ## 人工评审意见
53
+
54
+ `code-review.md` 中有"人工评审"一节、列出 HR-1、HR-2… 时,那是评审人在插件的代码审查页上提出的意见,这一次尝试就是为了处理它们。逐条核对是否已经解决:没有解决的按"重要"重新提出(在 CR 意见里注明对应的 HR 编号);评审人的意见与 spec 冲突时,按 spec 判断,并写明冲突、记入 `decisions.tsv`(`needs_human` 写 `yes`)。
55
+
52
56
  ## 第 2 轮及以后
53
57
 
54
58
  上一轮的意见已经由修复步骤处理,处理记录在 `code-review.md` 中。这一轮:
@@ -60,11 +64,18 @@
60
64
  ## 结果文件
61
65
 
62
66
  ```json
63
- { "verdict": "pass" }
67
+ {
68
+ "verdict": "changes_required",
69
+ "findings": [
70
+ { "id": "CR-1", "severity": "阻塞", "file": "src/order/export.ts", "line": 42, "title": "导出没有校验权限,普通用户也能导出全部订单", "suggestion": "调用 checkPermission('order:export')" }
71
+ ]
72
+ }
64
73
  ```
65
74
 
66
75
  - `verdict`,二选一:
67
76
  - `pass`:没有尚未解决的"阻塞"或"重要"级别意见。
68
77
  - `changes_required`:存在尚未解决的"阻塞"或"重要"级别意见,接下来会执行评审问题修复。
69
78
 
79
+ - `findings`:这一轮提出的每条意见(和 `code-review.md` 中的 CR 编号一致),给 Paseo 插件的代码审查页使用:人在改动的代码旁边看到它们,决定要不要改。`severity` 写"阻塞"、"重要"、"建议"或"待核实";`file` 是相对仓库根目录的路径,`line` 是改动后文件中的行号(不确定就省略);`title` 一句话说清问题,`suggestion` 一句话说修复方向。没有意见时写空数组。
80
+
70
81
  代码改动不存在,或者无法确定评审范围时,选择 `changes_required` 并在结论里说明情况。
@@ -20,3 +20,5 @@
20
20
  - **完成报告**:在开头说明这是第几次尝试、因为什么变化,验收对照表中标出哪些 AC 是这次新增或修改的。
21
21
 
22
22
  上一次没有写过代码时(`<pipeline-context>` 中"上一次是否已经写过代码:否"),实现类步骤按正常方式进行,只是方案设计要在已有的 `design.md` 基础上修订。
23
+
24
+ **人工代码评审退回的**(`changes.md` 最后一节的来源是"人工代码评审"):这次要处理的是 `code-review.md` 中最后一个"人工评审"小节里的 HR-1、HR-2…意见,需求说明没有变。方案只写针对这些意见的改法;实现逐条处理,并在 `implementation.md` 中逐条写明每个 HR 怎么改的(或为什么不改,记入 `decisions.tsv`,`needs_human` 写 `yes`);不要借机做意见之外的改动。
package/engine/src/api.ts CHANGED
@@ -27,6 +27,7 @@ import { defaultConfigPath, KIT_ROOT, kitVersion } from "./locations.js";
27
27
  import { configView } from "./config-view.js";
28
28
  import { pruneIndex, readIndex, registerRequirement, requirementKey } from "./records.js";
29
29
  import { listWorktreeRecords } from "./worktree.js";
30
+ import { readHumanReview } from "./code-review.js";
30
31
  import { isAlive, type PipelineState, readState, statePath, type StepRecord } from "./state.js";
31
32
  import { parsePeriod, summarizeRuns } from "./stats.js";
32
33
  import { readRuns, runsFile } from "./records.js";
@@ -376,12 +377,21 @@ export function apiStatus(repoArg: string, feature: string) {
376
377
  steps: state ? state.history.map((h) => stepJson(h, state)) : [],
377
378
  // 1.13: what to expect, from this machine's completed requirements of the same profile.
378
379
  estimate: estimateOf(state),
380
+ // 1.14: the person's code review (plugin's code review page).
381
+ humanReview: humanReviewOf(repo, featureDir),
379
382
  gate: state?.gate ?? null,
380
383
  external: Object.entries(state?.external ?? {}).map(([name, r]) => ({ name, id: r.id, url: r.url, status: r.status, syncedAt: r.syncedAt, pending: r.pending.length, lastError: r.pending.at(-1)?.error ?? null })),
381
384
  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 })),
382
385
  };
383
386
  }
384
387
 
388
+ function humanReviewOf(repo: string, featureDir: string) {
389
+ const r = readHumanReview(path.join(repo, featureDir));
390
+ if (!r) return null;
391
+ const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repo, encoding: "utf8", windowsHide: true }).stdout?.trim() || null;
392
+ return { status: r.status, reviewer: r.reviewer, at: r.at, reviewers: r.reviewers, items: r.items.length, stale: r.status === "approved" && !!head && r.head !== head, consumedAttempt: r.consumedAttempt };
393
+ }
394
+
385
395
  function estimateOf(state: PipelineState | null) {
386
396
  if (!state || state.status === "completed") return null;
387
397
  const t = typical(state.profile ?? "standard");
@@ -580,15 +590,17 @@ export function apiMain(argvIn: string[], cwd = process.cwd()): unknown {
580
590
  const feature = need("需求");
581
591
  const readOnly = flag(argv, "--read-only");
582
592
  // 1.14 --page check: the acceptance checklist page of the same server.
583
- const page = option(argv, "--page") === "check" ? "check" : "prototype";
593
+ const pageArg = option(argv, "--page");
594
+ const page = pageArg === "check" ? "check" : pageArg === "code" ? "code" : "prototype";
584
595
  const { repo: r, featureDir } = locate(repo, feature);
585
596
  return import("./review.js").then(async ({ ensureReviewServer, keyOf, reviewUrl }) => {
586
597
  const key = keyOf(r, featureDir);
587
598
  if (!key) throw new ApiError("not_found", `${featureDir} 不在本机的需求列表中`);
588
599
  if (page === "prototype" && !fs.existsSync(path.join(r, featureDir, "prototype"))) throw new ApiError("not_found", `${featureDir} 还没有界面原型`);
589
600
  if (page === "check" && !fs.existsSync(path.join(r, featureDir, "spec.md"))) throw new ApiError("not_found", `${featureDir} 中没有 spec.md`);
601
+ if (page === "code" && !["implementation.md", "code-review.md"].some((f) => fs.existsSync(path.join(r, featureDir, f)))) throw new ApiError("not_found", `${featureDir} 还没有写代码,没有可以审查的改动`);
590
602
  const url = reviewUrl(await ensureReviewServer(), key, readOnly);
591
- return { url: page === "check" ? `${url}check/` : url, readOnly };
603
+ return { url: page === "check" ? `${url}check/` : page === "code" ? `${url}code/` : url, readOnly };
592
604
  });
593
605
  }
594
606
  default:
@@ -56,6 +56,7 @@ export function parseLaunchArgs(argv: string[]): LaunchArgs {
56
56
  list: { type: "boolean", default: false },
57
57
  "card-fields": { type: "boolean", default: false },
58
58
  track: { type: "boolean", default: false },
59
+ "review-fix": { type: "boolean", default: false },
59
60
  "spec-review": { type: "boolean", default: false },
60
61
  worktree: { type: "boolean", default: false },
61
62
  "source-repo": { type: "string" },
@@ -118,7 +119,7 @@ export function parseLaunchArgs(argv: string[]): LaunchArgs {
118
119
  repo: path.resolve(values.repo ?? process.env.PASEO_AGENT_CWD ?? process.cwd()),
119
120
  featureDir: values.feature ?? "",
120
121
  originAgentId: origin ? origin : null,
121
- resume: (values.resume ?? false) || phase === "verify" || (values.approve ?? false),
122
+ resume: (values.resume ?? false) || phase === "verify" || (values.approve ?? false) || (values["review-fix"] ?? false),
122
123
  restart: values.restart ?? false,
123
124
  configPath: values.config ? path.resolve(values.config) : null,
124
125
  profile: (profile as ProfileName | undefined) ?? null,
@@ -127,6 +128,7 @@ export function parseLaunchArgs(argv: string[]): LaunchArgs {
127
128
  list: values.list ?? false,
128
129
  cardFields: values["card-fields"] ?? false,
129
130
  track: values.track ?? false,
131
+ reviewFix: values["review-fix"] ?? false,
130
132
  specReview: values["spec-review"] ?? false,
131
133
  worktree: values.worktree ?? false,
132
134
  sourceRepo: values["source-repo"] ? path.resolve(values["source-repo"]) : null,
@@ -210,6 +212,7 @@ export function launchArgv(args: LaunchArgs): string[] {
210
212
  if (args.profile) out.push("--profile", args.profile);
211
213
  if (args.resume) out.push("--resume");
212
214
  if (args.restart) out.push("--restart");
215
+ if (args.reviewFix) out.push("--review-fix");
213
216
  if (args.sourceRepo) out.push("--source-repo", args.sourceRepo);
214
217
  if (args.workspaceId) out.push("--workspace-id", args.workspaceId);
215
218
  if (args.specReview) out.push("--spec-review");
@@ -0,0 +1,419 @@
1
+ // Code review in the plugin (1.14): the requirement's change as a diff, the AI review's findings
2
+ // placed on the lines they are about, and a person's review on top: comments on lines, "要改" /
3
+ // "不用改" on each AI finding, reviewers (from CODEOWNERS, editable), then "退回修改" (a new
4
+ // incremental attempt handles the comments) or "评审通过" (recorded for the merge request).
5
+ // Plain git and file parsing, no model.
6
+ import { execFileSync, spawnSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { section } from "./checklist.js";
10
+ import { ownersFor, readCodeowners } from "./codeowners.js";
11
+ import { KIT_ROOT } from "./locations.js";
12
+ import { isAlive, readState, statePath } from "./state.js";
13
+ import { readWorktreeRecord } from "./worktree.js";
14
+
15
+ export interface DiffLine {
16
+ /** "+" added, "-" removed, " " context. */
17
+ t: "+" | "-" | " ";
18
+ o: number | null;
19
+ n: number | null;
20
+ s: string;
21
+ }
22
+
23
+ export interface DiffFile {
24
+ path: string;
25
+ oldPath: string | null;
26
+ status: "added" | "deleted" | "modified" | "renamed";
27
+ additions: number;
28
+ deletions: number;
29
+ binary: boolean;
30
+ /** The requirement's own documents (spec, design, …): shown last, folded. */
31
+ doc: boolean;
32
+ hunks: Array<{ header: string; lines: DiffLine[] }>;
33
+ /** Too big to show whole. */
34
+ truncated: boolean;
35
+ }
36
+
37
+ export type Severity = "阻塞" | "重要" | "建议" | "待核实";
38
+
39
+ export interface Finding {
40
+ id: string;
41
+ round: number;
42
+ severity: Severity;
43
+ file: string | null;
44
+ line: number | null;
45
+ title: string;
46
+ suggestion: string | null;
47
+ /** What the fix step recorded: fixed, rejected (不成立), wontfix (不修复), or open. */
48
+ status: "open" | "fixed" | "rejected" | "wontfix";
49
+ }
50
+
51
+ export interface HumanItem {
52
+ id: string;
53
+ file: string | null;
54
+ line: number | null;
55
+ text: string;
56
+ /** The AI finding it takes up, if any. */
57
+ finding: string | null;
58
+ }
59
+
60
+ export interface HumanReview {
61
+ status: "approved" | "changes_requested";
62
+ at: string;
63
+ reviewer: string;
64
+ reviewers: string[];
65
+ /** The commit that was reviewed. */
66
+ head: string | null;
67
+ items: HumanItem[];
68
+ note: string;
69
+ /** For changes_requested: the attempt that took the comments up (null until a run starts). */
70
+ consumedAttempt: number | null;
71
+ }
72
+
73
+ const MAX_LINES_PER_FILE = 3000;
74
+ const MAX_LINES_TOTAL = 20000;
75
+
76
+ function git(cwd: string, ...args: string[]): string | null {
77
+ try {
78
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function read(file: string): string {
85
+ try {
86
+ return fs.readFileSync(file, "utf8");
87
+ } catch {
88
+ return "";
89
+ }
90
+ }
91
+
92
+ /** Where the requirement's change starts: the fork point from the branch it was cut from. */
93
+ export function baseOf(repo: string, featureDir: string): { ref: string | null; commit: string | null } {
94
+ const state = readState(statePath(repo, featureDir));
95
+ const source = state?.worktree?.sourceRepo ?? null;
96
+ const recorded = source ? readWorktreeRecord(source, featureDir)?.baseRef ?? null : null;
97
+ const branch = (git(repo, "rev-parse", "--abbrev-ref", "HEAD") ?? "").trim();
98
+ const remoteHead = (git(repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") ?? "").trim();
99
+ const candidates = [recorded, remoteHead || null, "main", "master", "origin/main", "origin/master"].filter((x): x is string => !!x && x !== branch);
100
+ for (const ref of candidates) {
101
+ const mb = (git(repo, "merge-base", "HEAD", ref) ?? "").trim();
102
+ const head = (git(repo, "rev-parse", "HEAD") ?? "").trim();
103
+ if (mb && mb !== head) return { ref, commit: mb };
104
+ }
105
+ // On the base branch itself, or no common history: the baseline the implementation wrote down.
106
+ const baseline = /基线提交[^0-9a-f]{0,40}([0-9a-f]{7,40})/.exec(read(path.join(repo, featureDir, "implementation.md")))?.[1] ?? null;
107
+ return { ref: null, commit: baseline && git(repo, "cat-file", "-e", `${baseline}^{commit}`) !== null ? baseline : null };
108
+ }
109
+
110
+ /** `git diff` output as files and hunks. */
111
+ export function parseDiff(text: string, featureDir: string): DiffFile[] {
112
+ const files: DiffFile[] = [];
113
+ let file: DiffFile | null = null;
114
+ let hunk: DiffFile["hunks"][number] | null = null;
115
+ let o = 0;
116
+ let n = 0;
117
+ let total = 0;
118
+ let inFile = 0;
119
+ const unquote = (p: string) => p.replace(/^"|"$/g, "").replace(/^[ab]\//, "");
120
+ for (const line of text.split("\n")) {
121
+ if (line.startsWith("diff --git ")) {
122
+ const m = /^diff --git (?:"?a\/(.+?)"?) (?:"?b\/(.+?)"?)$/.exec(line);
123
+ const p = m ? m[2] : line.slice(11);
124
+ file = { path: p, oldPath: null, status: "modified", additions: 0, deletions: 0, binary: false, doc: p === featureDir || p.startsWith(`${featureDir}/`), hunks: [], truncated: false };
125
+ files.push(file);
126
+ hunk = null;
127
+ inFile = 0;
128
+ continue;
129
+ }
130
+ if (!file) continue;
131
+ if (line.startsWith("new file mode")) file.status = "added";
132
+ else if (line.startsWith("deleted file mode")) file.status = "deleted";
133
+ else if (line.startsWith("rename from ")) {
134
+ file.status = "renamed";
135
+ file.oldPath = line.slice("rename from ".length);
136
+ } else if (line.startsWith("Binary files ")) file.binary = true;
137
+ else if (line.startsWith("+++ ") || line.startsWith("--- ")) {
138
+ if (line.startsWith("+++ ") && line !== "+++ /dev/null") file.path = unquote(line.slice(4));
139
+ } else if (line.startsWith("@@")) {
140
+ const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(line);
141
+ o = m ? Number(m[1]) : 0;
142
+ n = m ? Number(m[2]) : 0;
143
+ hunk = { header: line, lines: [] };
144
+ file.hunks.push(hunk);
145
+ } else if (hunk && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
146
+ const t = line[0] as DiffLine["t"];
147
+ if (t === "+") file.additions++;
148
+ if (t === "-") file.deletions++;
149
+ if (inFile >= MAX_LINES_PER_FILE || total >= MAX_LINES_TOTAL) {
150
+ file.truncated = true;
151
+ continue;
152
+ }
153
+ hunk.lines.push({ t, o: t === "+" ? null : o, n: t === "-" ? null : n, s: line.slice(1) });
154
+ if (t !== "+") o++;
155
+ if (t !== "-") n++;
156
+ inFile++;
157
+ total++;
158
+ }
159
+ }
160
+ // Code first, then the requirement's documents.
161
+ return [...files.filter((f) => !f.doc), ...files.filter((f) => f.doc)];
162
+ }
163
+
164
+ const SEVERITIES: Array<[RegExp, Severity]> = [
165
+ [/阻塞|blocker|critical|high/i, "阻塞"],
166
+ [/重要|major|medium/i, "重要"],
167
+ [/待核实|unverified|unsure/i, "待核实"],
168
+ [/建议|minor|low|nit|suggest/i, "建议"],
169
+ ];
170
+
171
+ function severityOf(text: unknown): Severity {
172
+ const s = String(text ?? "");
173
+ for (const [re, v] of SEVERITIES) if (re.test(s)) return v;
174
+ return "重要";
175
+ }
176
+
177
+ function locationOf(text: string): { file: string | null; line: number | null } {
178
+ const m = /([\w@./\\-]+\.[A-Za-z0-9]+)(?::(\d+))?/.exec(text);
179
+ return { file: m ? m[1].replace(/\\/g, "/") : null, line: m?.[2] ? Number(m[2]) : null };
180
+ }
181
+
182
+ /** The fix step's record for each CR: fixed / rejected / won't fix. */
183
+ function statuses(review: string): Map<string, Finding["status"]> {
184
+ const out = new Map<string, Finding["status"]>();
185
+ const lines = review.split(/\r?\n/);
186
+ let inRecord = false;
187
+ for (const line of lines) {
188
+ if (/^#{1,6}\s/.test(line)) inRecord = /处理记录/.test(line);
189
+ if (!inRecord) continue;
190
+ for (const m of line.matchAll(/CR-(\d+)/g)) {
191
+ const id = `CR-${m[1]}`;
192
+ if (/不成立|驳回|不认可/.test(line)) out.set(id, "rejected");
193
+ else if (/不修复|暂不|以后再|不改/.test(line)) out.set(id, "wontfix");
194
+ else if (/已修复|修复|已改|已处理|fixed/i.test(line)) out.set(id, "fixed");
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /**
201
+ * The AI review's findings: the structured ones the review step put in its result file
202
+ * (`findings`), or, from older runs, the CR items of code-review.md.
203
+ */
204
+ export function aiFindings(repo: string, featureDir: string): { findings: Finding[]; rounds: Array<{ round: number; verdict: string }> } {
205
+ const state = readState(statePath(repo, featureDir));
206
+ const review = read(path.join(repo, featureDir, "code-review.md"));
207
+ const status = statuses(review);
208
+ const rounds: Array<{ round: number; verdict: string }> = [];
209
+ const findings: Finding[] = [];
210
+ const seen = new Set<string>();
211
+ for (const [key, value] of Object.entries(state?.results ?? {})) {
212
+ const m = /^code_review(?:-r(\d+))?$/.exec(key);
213
+ if (!m) continue;
214
+ const round = Number(m[1] ?? 1);
215
+ rounds.push({ round, verdict: String(value.verdict ?? "") });
216
+ const list = Array.isArray(value.findings) ? (value.findings as Array<Record<string, unknown>>) : [];
217
+ list.forEach((f, i) => {
218
+ if (!f || typeof f !== "object") return;
219
+ const title = String(f.title ?? f.problem ?? f.issue ?? f.description ?? "").trim();
220
+ if (!title) return;
221
+ const id = String(f.id ?? `CR-${round}.${i + 1}`).trim();
222
+ if (seen.has(id)) return;
223
+ seen.add(id);
224
+ const loc = typeof f.file === "string" ? { file: f.file.replace(/\\/g, "/"), line: Number.parseInt(String(f.line ?? ""), 10) || null } : locationOf(String(f.location ?? ""));
225
+ findings.push({ id, round, severity: severityOf(f.severity ?? f.level), file: loc.file, line: loc.line, title: title.slice(0, 600), suggestion: typeof f.suggestion === "string" ? f.suggestion.slice(0, 600) : typeof f.fix === "string" ? f.fix.slice(0, 600) : null, status: status.get(id) ?? "open" });
226
+ });
227
+ }
228
+ rounds.sort((a, b) => a.round - b.round);
229
+ if (findings.length === 0 && review) {
230
+ // Older runs: "CR-3(重要)src/a.ts:12 …" items in code-review.md.
231
+ const lines = review.split(/\r?\n/);
232
+ let round = 1;
233
+ let heading = "";
234
+ for (let i = 0; i < lines.length; i++) {
235
+ if (/^#{1,6}\s/.test(lines[i])) {
236
+ heading = lines[i];
237
+ const r = /第\s*(\d+)\s*轮/.exec(heading);
238
+ if (r) round = Number(r[1]);
239
+ }
240
+ // Items are where they are raised, not where the fix step records them.
241
+ if (/处理记录|人工评审/.test(heading) && !/^#{1,6}\s+(?:\**)?CR-/.test(lines[i])) continue;
242
+ const m = /^\s*(?:[-*]|#{1,6}|\d+[.、])?\s*\**\s*(CR-\d+)\b\**[\s::.、))-]*(.*)$/.exec(lines[i]);
243
+ if (!m || seen.has(m[1])) continue;
244
+ seen.add(m[1]);
245
+ const block = [m[2], ...lines.slice(i + 1, i + 8).filter((l) => l.trim() && !/CR-\d+/.test(l) && !/^#{1,6}\s/.test(l))].join(" ");
246
+ const loc = locationOf(block);
247
+ const title = block.replace(/\*\*/g, "").replace(/级别[::]\s*\S+/, "").replace(/位置[::]\s*\S+/, "").replace(/\s+/g, " ").trim();
248
+ findings.push({ id: m[1], round, severity: severityOf(block), file: loc.file, line: loc.line, title: title.slice(0, 600) || m[1], suggestion: null, status: status.get(m[1]) ?? "open" });
249
+ }
250
+ }
251
+ return { findings, rounds };
252
+ }
253
+
254
+ const humanDir = (featureAbs: string) => path.join(featureAbs, ".pipeline", "code-review");
255
+
256
+ export function readHumanReview(featureAbs: string): HumanReview | null {
257
+ try {
258
+ return JSON.parse(fs.readFileSync(path.join(humanDir(featureAbs), "human.json"), "utf8")) as HumanReview;
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
263
+
264
+ export function writeHumanReview(featureAbs: string, review: HumanReview): void {
265
+ fs.mkdirSync(humanDir(featureAbs), { recursive: true });
266
+ fs.writeFileSync(path.join(humanDir(featureAbs), "human.json"), JSON.stringify(review, null, 2));
267
+ }
268
+
269
+ export interface CodeReviewData {
270
+ title: string;
271
+ featureDir: string;
272
+ branch: string | null;
273
+ base: { ref: string | null; commit: string | null };
274
+ head: string | null;
275
+ files: DiffFile[];
276
+ findings: Finding[];
277
+ rounds: Array<{ round: number; verdict: string }>;
278
+ /** handoff.md "这次改了什么": the change in plain words. */
279
+ summary: string[];
280
+ reviewers: { suggested: string[]; byFile: Record<string, string[]>; source: string | null };
281
+ human: (HumanReview & { stale: boolean }) | null;
282
+ status: string | null;
283
+ running: boolean;
284
+ }
285
+
286
+ export function buildCodeReview(repo: string, featureDir: string): CodeReviewData {
287
+ const featureAbs = path.join(repo, featureDir);
288
+ const state = readState(statePath(repo, featureDir));
289
+ const base = baseOf(repo, featureDir);
290
+ const head = (git(repo, "rev-parse", "HEAD") ?? "").trim() || null;
291
+ const diff = base.commit ? (git(repo, "diff", "--no-color", "--no-ext-diff", "-M", "--unified=3", `${base.commit}..HEAD`) ?? "") : "";
292
+ const files = parseDiff(diff, featureDir);
293
+ const { findings, rounds } = aiFindings(repo, featureDir);
294
+ const owners = readCodeowners(repo);
295
+ const code = files.filter((f) => !f.doc).map((f) => f.path);
296
+ const o = owners ? ownersFor(owners.rules, code) : { byFile: {}, all: [] };
297
+ const spec = read(path.join(featureAbs, "spec.md"));
298
+ const human = readHumanReview(featureAbs);
299
+ const summary = section(read(path.join(featureAbs, "handoff.md")), /这次改了什么|改了什么|改动概要|变更内容/)
300
+ .map((l) => l.replace(/\s+$/, ""))
301
+ .filter((l) => l.trim())
302
+ .slice(0, 30);
303
+ return {
304
+ title: (/^#\s+(.+)$/m.exec(spec)?.[1] ?? path.basename(featureDir)).replace(/^需求说明[::\s]*/, "").trim(),
305
+ featureDir,
306
+ branch: state?.branch ?? ((git(repo, "rev-parse", "--abbrev-ref", "HEAD") ?? "").trim() || null),
307
+ base,
308
+ head,
309
+ files,
310
+ findings,
311
+ rounds,
312
+ summary,
313
+ reviewers: { suggested: o.all, byFile: o.byFile, source: owners?.file ?? null },
314
+ human: human ? { ...human, stale: human.status === "approved" && !!head && human.head !== head } : null,
315
+ status: state?.status ?? null,
316
+ running: !!state && state.status === "running" && isAlive(state.pid),
317
+ };
318
+ }
319
+
320
+ export interface Submission {
321
+ verdict: "approve" | "changes";
322
+ reviewer: string;
323
+ reviewers: string[];
324
+ comments: Array<{ file: string | null; line: number | null; text: string }>;
325
+ /** AI findings the reviewer wants fixed, with an optional remark. */
326
+ fix: Array<{ id: string; note?: string }>;
327
+ note: string;
328
+ }
329
+
330
+ function commitDocs(repo: string, files: string[], message: string): boolean {
331
+ const rels = files.filter((f) => fs.existsSync(path.join(repo, f)));
332
+ if (git(repo, "rev-parse", "--git-dir") === null || rels.length === 0) return false;
333
+ if (git(repo, "add", "--", ...rels) === null) return false;
334
+ if (!(git(repo, "diff", "--cached", "--name-only", "--", ...rels) ?? "").trim()) return false;
335
+ return git(repo, "commit", "-q", "-m", message, "--", ...rels) !== null;
336
+ }
337
+
338
+ /** The person's review, recorded in code-review.md (and changes.md when changes are asked for). */
339
+ export function recordHumanReview(repo: string, featureDir: string, sub: Submission, now = new Date()): { review: HumanReview; committed: boolean } {
340
+ const featureAbs = path.join(repo, featureDir);
341
+ const data = buildCodeReview(repo, featureDir);
342
+ const byId = new Map(data.findings.map((f) => [f.id, f]));
343
+ const items: HumanItem[] = [];
344
+ for (const f of sub.fix) {
345
+ const found = byId.get(f.id);
346
+ if (!found) continue;
347
+ items.push({ id: `HR-${items.length + 1}`, file: found.file, line: found.line, finding: found.id, text: `AI 评审意见 ${found.id}(${found.severity}):${found.title}${f.note?.trim() ? `。评审人补充:${f.note.trim()}` : ""}` });
348
+ }
349
+ for (const c of sub.comments) {
350
+ if (!c.text?.trim()) continue;
351
+ items.push({ id: `HR-${items.length + 1}`, file: c.file, line: c.line, finding: null, text: c.text.trim().slice(0, 2000) });
352
+ }
353
+ if (sub.note.trim() && sub.verdict === "changes") items.push({ id: `HR-${items.length + 1}`, file: null, line: null, finding: null, text: sub.note.trim().slice(0, 2000) });
354
+ const date = now.toISOString().slice(0, 10);
355
+ const reviewer = sub.reviewer.trim().slice(0, 60) || "评审人(插件)";
356
+ const review: HumanReview = {
357
+ status: sub.verdict === "approve" ? "approved" : "changes_requested",
358
+ at: now.toISOString(),
359
+ reviewer,
360
+ reviewers: [...new Set(sub.reviewers.map((r) => r.trim()).filter(Boolean))].slice(0, 20),
361
+ head: data.head,
362
+ items,
363
+ note: sub.note.trim().slice(0, 2000),
364
+ consumedAttempt: null,
365
+ };
366
+ const where = (i: HumanItem) => (i.file ? `[${i.file}${i.line ? `:${i.line}` : ""}] ` : "");
367
+ const block = [
368
+ "",
369
+ `## 人工评审(${date},${reviewer})`,
370
+ "",
371
+ `- 结论:${sub.verdict === "approve" ? "通过" : "需要修改"}`,
372
+ `- 评审的提交:${data.head ?? "(未知)"}`,
373
+ `- 审核人:${review.reviewers.length ? review.reviewers.join("、") : "(未指定)"}`,
374
+ ...(sub.verdict === "approve" && review.note ? [`- 说明:${review.note}`] : []),
375
+ ...(items.length ? ["", ...items.map((i) => `- **${i.id}** ${where(i)}${i.text}`)] : []),
376
+ ...(sub.verdict === "changes" ? ["", "以上 HR 意见由下一次尝试逐条处理;处理记录写在这一节之后。"] : []),
377
+ "",
378
+ ].join("\n");
379
+ const reviewFile = path.join(featureAbs, "code-review.md");
380
+ fs.appendFileSync(reviewFile, fs.existsSync(reviewFile) ? block : `# 代码评审\n${block}`);
381
+ const touched = [path.join(featureDir, "code-review.md")];
382
+ if (sub.verdict === "changes") {
383
+ const changes = path.join(featureAbs, "changes.md");
384
+ const n = (read(changes).match(/人工代码评审退回/g) ?? []).length + 1;
385
+ const text = [
386
+ "",
387
+ `## ${date} 人工代码评审退回(第 ${n} 次)`,
388
+ "",
389
+ "- 来源:人工代码评审",
390
+ `- 内容:${reviewer} 提出了 ${items.length} 条修改意见,见 code-review.md 的“人工评审(${date},${reviewer})”一节(HR-1 起)`,
391
+ "- 处理:从方案设计开始只处理这些意见(需求说明不变),修改经过代码评审后重新验证",
392
+ "",
393
+ ].join("\n");
394
+ fs.appendFileSync(changes, fs.existsSync(changes) ? text : `# 变更记录\n${text}`);
395
+ touched.push(path.join(featureDir, "changes.md"));
396
+ }
397
+ writeHumanReview(featureAbs, review);
398
+ const committed = commitDocs(repo, touched, `docs(${path.basename(featureDir)}): 人工代码评审${sub.verdict === "approve" ? "通过" : "意见"}`);
399
+ // A new review replaces the draft.
400
+ fs.rmSync(path.join(humanDir(featureAbs), "draft.json"), { force: true });
401
+ return { review, committed };
402
+ }
403
+
404
+ /** Starts the attempt that handles the comments: `req review-fix <需求>` in the requirement's checkout. */
405
+ export function startReviewFix(repo: string, featureDir: string): { ok: boolean; message: string } {
406
+ const state = readState(statePath(repo, featureDir));
407
+ const cwd = state?.worktree?.sourceRepo ?? repo;
408
+ const env = { ...process.env };
409
+ delete env.PASEO_AGENT_ID;
410
+ delete env.PASEO_AGENT_CWD;
411
+ const r = spawnSync(process.execPath, [path.join(KIT_ROOT, "bin", "req.mjs"), "review-fix", featureDir], { cwd, env, encoding: "utf8", timeout: 180_000, windowsHide: true });
412
+ return { ok: r.status === 0, message: r.error ? r.error.message : `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
413
+ }
414
+
415
+ /** Comments waiting for an attempt to take them up (for `--review-fix`). */
416
+ export function pendingHumanReview(featureAbs: string): HumanReview | null {
417
+ const r = readHumanReview(featureAbs);
418
+ return r && r.status === "changes_requested" && r.consumedAttempt === null ? r : null;
419
+ }