paseo-req-kit 1.14.0-beta.2 → 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 (105) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/req.mjs +16 -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 +15 -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 +18 -2
  8. package/engine/src/checklist-cmd.ts +37 -0
  9. package/engine/src/checklist.ts +254 -0
  10. package/engine/src/cli-args.ts +4 -1
  11. package/engine/src/code-review.ts +419 -0
  12. package/engine/src/codeowners.ts +87 -0
  13. package/engine/src/main.ts +13 -3
  14. package/engine/src/preflight.ts +10 -1
  15. package/engine/src/reply.ts +7 -4
  16. package/engine/src/review-assets/check.css +57 -0
  17. package/engine/src/review-assets/check.html +35 -0
  18. package/engine/src/review-assets/check.js +258 -0
  19. package/engine/src/review-assets/code.css +93 -0
  20. package/engine/src/review-assets/code.html +43 -0
  21. package/engine/src/review-assets/code.js +402 -0
  22. package/engine/src/review.ts +177 -2
  23. package/engine/src/try.ts +134 -0
  24. package/package.json +1 -1
  25. package/paseo-plugin/client/data.ts +3 -3
  26. package/paseo-plugin/client/detail.tsx +10 -2
  27. package/paseo-plugin/client/nav.ts +10 -0
  28. package/paseo-plugin/client/review.tsx +30 -6
  29. package/paseo-plugin/package.json +1 -1
  30. package/paseo-plugin/server/handlers.ts +2 -2
  31. package/paseo-plugin/shared/contracts.ts +4 -1
  32. package/site-dist/404.html +2 -2
  33. package/site-dist/assets/{app.gI1JF182.js → app.Cp7WwPWE.js} +1 -1
  34. package/site-dist/assets/changelog.md.Zi-_3Rjh.js +1 -0
  35. package/site-dist/assets/chunks/@localSearchIndexroot.COxp5Rrc.js +1 -0
  36. package/site-dist/assets/chunks/{VPLocalSearchBox.DNmhkkfS.js → VPLocalSearchBox.CMTXQK-Q.js} +1 -1
  37. package/site-dist/assets/chunks/{theme.CZzkCOAC.js → theme.DvVUfKr3.js} +2 -2
  38. package/site-dist/assets/{guide_getting-started.md.zmtXv15G.js → guide_getting-started.md.D8XlIQOg.js} +1 -1
  39. package/site-dist/assets/{guide_user.md.2v7b_fzd.js → guide_user.md.B4N9ywmr.js} +1 -1
  40. package/site-dist/assets/{guide_user.md.2v7b_fzd.lean.js → guide_user.md.B4N9ywmr.lean.js} +1 -1
  41. package/site-dist/assets/{reference_contracts_cli-json.md.BR6gwq-e.js → reference_contracts_cli-json.md.CCvKLasM.js} +1 -1
  42. package/site-dist/assets/{reference_contracts_cli-json.md.BR6gwq-e.lean.js → reference_contracts_cli-json.md.CCvKLasM.lean.js} +1 -1
  43. package/site-dist/changelog.html +5 -5
  44. package/site-dist/guide/getting-started.html +5 -5
  45. package/site-dist/guide/how-it-works.html +4 -4
  46. package/site-dist/guide/index.html +4 -4
  47. package/site-dist/guide/install.html +4 -4
  48. package/site-dist/guide/maintainer.html +4 -4
  49. package/site-dist/guide/phases.html +4 -4
  50. package/site-dist/guide/project.html +4 -4
  51. package/site-dist/guide/user.html +5 -5
  52. package/site-dist/hashmap.json +1 -1
  53. package/site-dist/index.html +4 -4
  54. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
  55. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
  56. package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
  57. package/site-dist/reference/adr/0004-result-files.html +4 -4
  58. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
  59. package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
  60. package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
  61. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
  62. package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
  63. package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
  64. package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
  65. package/site-dist/reference/adr/0012-review-panel.html +4 -4
  66. package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
  67. package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
  68. package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
  69. package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
  70. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
  71. package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
  72. package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
  73. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
  74. package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
  75. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
  76. package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
  77. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
  78. package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
  79. package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
  80. package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
  81. package/site-dist/reference/adr/0028-external-integrations.html +4 -4
  82. package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
  83. package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
  84. package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
  85. package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
  86. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +4 -4
  87. package/site-dist/reference/adr/index.html +4 -4
  88. package/site-dist/reference/commands.html +4 -4
  89. package/site-dist/reference/config.html +4 -4
  90. package/site-dist/reference/contracts/cli-json.html +5 -5
  91. package/site-dist/reference/contracts/feature-files.html +4 -4
  92. package/site-dist/reference/contracts/index.html +4 -4
  93. package/site-dist/reference/contracts/notifier-event.html +4 -4
  94. package/site-dist/reference/contracts/result-file.html +4 -4
  95. package/site-dist/reference/contracts/spec.html +4 -4
  96. package/site-dist/reference/contracts/state.html +4 -4
  97. package/site-dist/reference/contracts/usage-record.html +4 -4
  98. package/site-dist/reference/contracts/verify.html +4 -4
  99. package/site-dist/reference/scope.html +4 -4
  100. package/skills/req/SKILL.md +3 -0
  101. package/skills/req/playbooks/accept.md +2 -2
  102. package/site-dist/assets/changelog.md.2xf690VW.js +0 -1
  103. package/site-dist/assets/chunks/@localSearchIndexroot.CiFeI6qC.js +0 -1
  104. /package/site-dist/assets/{changelog.md.2xf690VW.lean.js → changelog.md.Zi-_3Rjh.lean.js} +0 -0
  105. /package/site-dist/assets/{guide_getting-started.md.zmtXv15G.lean.js → guide_getting-started.md.D8XlIQOg.lean.js} +0 -0
@@ -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
+ }
@@ -0,0 +1,87 @@
1
+ // CODEOWNERS (1.14): who should review a change, from the repository's own CODEOWNERS file
2
+ // (GitLab and GitHub format: "pattern @user @group email", the last matching line wins; GitLab
3
+ // "[Section]" headers and "^[Optional]" are accepted). Used as the default reviewers on the code
4
+ // review page, which the person can change before submitting.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ export const CODEOWNERS_FILES = ["CODEOWNERS", ".gitlab/CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
9
+
10
+ export interface OwnerRule {
11
+ pattern: string;
12
+ owners: string[];
13
+ /** The GitLab section the line is in, or null. */
14
+ section: string | null;
15
+ }
16
+
17
+ export function readCodeowners(repo: string): { file: string; rules: OwnerRule[] } | null {
18
+ for (const rel of CODEOWNERS_FILES) {
19
+ const file = path.join(repo, rel);
20
+ try {
21
+ return { file: rel, rules: parseCodeowners(fs.readFileSync(file, "utf8")) };
22
+ } catch {
23
+ // Not this one.
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export function parseCodeowners(text: string): OwnerRule[] {
30
+ const rules: OwnerRule[] = [];
31
+ let section: string | null = null;
32
+ let sectionOwners: string[] = [];
33
+ for (const raw of text.split(/\r?\n/)) {
34
+ const line = raw.replace(/(^|\s)#.*$/, "").trim();
35
+ if (!line) continue;
36
+ // GitLab: "[Section]", "^[Optional section]", "[Section][2] @default-owner".
37
+ const sec = /^\^?\[([^\]]+)\](?:\[\d+\])?\s*(.*)$/.exec(line);
38
+ if (sec) {
39
+ section = sec[1].trim();
40
+ sectionOwners = sec[2].split(/\s+/).filter(Boolean);
41
+ continue;
42
+ }
43
+ const parts = line.split(/\s+/);
44
+ const pattern = parts[0].replace(/\\ /g, " ");
45
+ const owners = parts.slice(1);
46
+ rules.push({ pattern, owners: owners.length ? owners : sectionOwners, section });
47
+ }
48
+ return rules;
49
+ }
50
+
51
+ /** gitignore-like matching of a CODEOWNERS pattern against a "/"-separated path. */
52
+ export function matchesPattern(pattern: string, file: string): boolean {
53
+ let p = pattern;
54
+ const anchored = p.startsWith("/") || p.slice(0, -1).includes("/");
55
+ p = p.replace(/^\//, "");
56
+ const dirOnly = p.endsWith("/");
57
+ if (dirOnly) p = p.slice(0, -1);
58
+ let re = "";
59
+ for (let i = 0; i < p.length; i++) {
60
+ const c = p[i];
61
+ if (c === "*" && p[i + 1] === "*") {
62
+ // "**/" any directories, "/**" everything below.
63
+ re += p[i + 2] === "/" ? "(?:.*/)?" : ".*";
64
+ i += p[i + 2] === "/" ? 2 : 1;
65
+ } else if (c === "*") re += "[^/]*";
66
+ else if (c === "?") re += "[^/]";
67
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
68
+ }
69
+ const prefix = anchored ? "^" : "^(?:.*/)?";
70
+ // A directory pattern (or a name that is a directory) covers everything below it.
71
+ const rx = new RegExp(`${prefix}${re}${dirOnly ? "/.*" : "(?:/.*)?"}$`);
72
+ return rx.test(file);
73
+ }
74
+
75
+ /** The owners of each file (last matching rule per section), and all of them together. */
76
+ export function ownersFor(rules: OwnerRule[], files: string[]): { byFile: Record<string, string[]>; all: string[] } {
77
+ const byFile: Record<string, string[]> = {};
78
+ const all = new Set<string>();
79
+ for (const file of files) {
80
+ const winners = new Map<string | null, string[]>();
81
+ for (const r of rules) if (matchesPattern(r.pattern, file)) winners.set(r.section, r.owners);
82
+ const owners = [...new Set([...winners.values()].flat())];
83
+ byFile[file] = owners;
84
+ for (const o of owners) all.add(o);
85
+ }
86
+ return { byFile, all: [...all] };
87
+ }
@@ -1,6 +1,7 @@
1
1
  // The long-running pipeline process. Normally started by launch.ts, detached
2
2
  // from the chat agent's shell; can also be run in the foreground for debugging.
3
3
 
4
+ import { pendingHumanReview, writeHumanReview } from "./code-review.js";
4
5
  import { execFileSync } from "node:child_process";
5
6
  import fs from "node:fs";
6
7
  import path from "node:path";
@@ -191,11 +192,20 @@ async function main(): Promise<number> {
191
192
  approval:
192
193
  args.approve && mode.previous.status === "waiting_for_approval"
193
194
  ? { attempt: mode.previous.attempt, status: "approved", at: now }
194
- : startOver
195
- ? null
196
- : (mode.previous.approval ?? null),
195
+ : // 1.14: code review comments do not change what the requester confirmed: no second confirmation.
196
+ args.reviewFix && mode.previous.approval?.status === "approved"
197
+ ? { ...mode.previous.approval, attempt: mode.previous.attempt + 1 }
198
+ : startOver
199
+ ? null
200
+ : (mode.previous.approval ?? null),
197
201
  gate: mode.kind === "restart" ? null : (mode.previous.gate ?? null),
198
202
  };
203
+ // 1.14: the person's code review comments are this attempt's to handle.
204
+ if (args.reviewFix && mode.kind === "resume-from-design") {
205
+ const featureAbs = path.join(repo, featureDir);
206
+ const pending = pendingHumanReview(featureAbs);
207
+ if (pending) writeHumanReview(featureAbs, { ...pending, consumedAttempt: mode.previous.attempt + 1 });
208
+ }
199
209
  if (checked.entersVerify) {
200
210
  // A develop-only run that ends with its development report gets a full report after verification.
201
211
  delete state.results.report;
@@ -1,3 +1,4 @@
1
+ import { pendingHumanReview } from "./code-review.js";
1
2
  import { execFileSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -14,7 +15,7 @@ import {
14
15
  } from "./config.js";
15
16
  import { gitInfo } from "./git.js";
16
17
  import { samePath } from "./paths.js";
17
- import { currentLockHolder, type PipelineState, readState, statePath } from "./state.js";
18
+ import { currentLockHolder, isAlive, type PipelineState, readState, statePath } from "./state.js";
18
19
 
19
20
  export interface LaunchArgs {
20
21
  repo: string;
@@ -33,6 +34,8 @@ export interface LaunchArgs {
33
34
  cardFields?: boolean;
34
35
  /** 1.14 --track: register a requirement being aligned, before the pipeline runs (shown in the plugin). */
35
36
  track?: boolean;
37
+ /** 1.14 --review-fix: a person's code review asked for changes; a new attempt handles them. */
38
+ reviewFix?: boolean;
36
39
  /** --spec-review: independent review of the draft spec, before confirmation. */
37
40
  specReview: boolean;
38
41
  /** --worktree: create a Paseo worktree workspace for this requirement and develop there. */
@@ -254,6 +257,12 @@ export function preflight(
254
257
  ? "这个需求的流水线已经完成。如需重新完整运行,请加 --restart。"
255
258
  : `这个需求已有运行记录(状态:${previous.status})。从中断处继续请加 --resume,从头开始请加 --restart。`;
256
259
  throw new PreflightError(hint);
260
+ } else if (args.reviewFix) {
261
+ // 1.14: the person's code review (plugin) asked for changes; the spec itself did not change.
262
+ if (previous.status === "running" && isAlive(previous.pid)) throw new PreflightError("流水线正在运行,等它结束后再处理人工评审意见。");
263
+ const pending = pendingHumanReview(path.join(repo, featureDir));
264
+ if (!pending) throw new PreflightError("没有待处理的人工代码评审意见(在插件的代码审查页上提交\"退回修改\"后才有)。");
265
+ mode = { kind: "resume-from-design", previous, reason: `人工代码评审提出了 ${pending.items.length} 条修改意见(见 code-review.md 的"人工评审"一节和 changes.md),需求说明不变,从方案设计开始只处理这些意见` };
257
266
  } else if (args.approve && previous.status !== "waiting_for_approval") {
258
267
  throw new PreflightError(`这个需求没有等待确认的方案(状态:${previous.status}),不需要 --approve。继续开发请用 --resume。`);
259
268
  } else if (previous.status === "waiting_for_approval" && currentSpecHash(specPath) !== previous.specHash) {
@@ -8,7 +8,10 @@ import { pluginReplyMessage } from "./messages.js";
8
8
  import { loadRuntimeFactory } from "./runtime-loader.js";
9
9
  import { readState, statePath } from "./state.js";
10
10
 
11
- async function main(argv: string[]): Promise<number> {
11
+ async function main(argvIn: string[]): Promise<number> {
12
+ // 1.14 --acceptance: the checklist page found problems; the text is the whole notice.
13
+ const acceptance = argvIn.includes("--acceptance");
14
+ const argv = argvIn.filter((a) => a !== "--acceptance");
12
15
  const [feature, ...words] = argv;
13
16
  const text = words.join(" ").trim();
14
17
  if (!feature || !text) {
@@ -17,7 +20,7 @@ async function main(argv: string[]): Promise<number> {
17
20
  }
18
21
  const { repo, featureDir } = locate(process.cwd(), feature);
19
22
  const state = readState(statePath(repo, featureDir));
20
- if (!state || (state.status !== "waiting_for_business" && state.status !== "waiting_for_approval")) {
23
+ if (!state || (!acceptance && state.status !== "waiting_for_business" && state.status !== "waiting_for_approval")) {
21
24
  console.error(`${featureDir} 现在没有在等待回答或确认,不用回复。`);
22
25
  return 1;
23
26
  }
@@ -36,7 +39,7 @@ async function main(argv: string[]): Promise<number> {
36
39
  try {
37
40
  await runtime.notifyAgent(
38
41
  state.originAgentId,
39
- pluginReplyMessage({
42
+ acceptance ? text.slice(0, 8000) : pluginReplyMessage({
40
43
  featureDir,
41
44
  waitingFor: state.status === "waiting_for_approval" ? "approval" : "business",
42
45
  text: text.slice(0, 8000),
@@ -46,7 +49,7 @@ async function main(argv: string[]): Promise<number> {
46
49
  } finally {
47
50
  await runtime.close().catch(() => undefined);
48
51
  }
49
- console.log("已转给这个需求的对话,它会按回复更新需求说明并继续开发。");
52
+ console.log(acceptance ? "已转给这个需求的对话。" : "已转给这个需求的对话,它会按回复更新需求说明并继续开发。");
50
53
  return 0;
51
54
  }
52
55