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

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 (133) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/req.mjs +14 -0
  3. package/contracts/cli-json.md +1 -1
  4. package/contracts/notifier-event.md +1 -1
  5. package/contracts/spec.md +1 -0
  6. package/contracts/state.md +3 -1
  7. package/contracts/usage-record.md +16 -2
  8. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +11 -1
  9. package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +17 -1
  10. package/engine/prompts/00-shared-context.md +3 -2
  11. package/engine/prompts/06-code-review.md +12 -1
  12. package/engine/prompts/incremental.md +2 -0
  13. package/engine/src/activity.ts +21 -0
  14. package/engine/src/api.ts +44 -7
  15. package/engine/src/cli-args.ts +4 -1
  16. package/engine/src/code-review.ts +422 -0
  17. package/engine/src/codeowners.ts +87 -0
  18. package/engine/src/config-view.ts +10 -0
  19. package/engine/src/config.ts +27 -0
  20. package/engine/src/danger-guard.ts +180 -0
  21. package/engine/src/features.ts +1 -1
  22. package/engine/src/main.ts +13 -3
  23. package/engine/src/messages.ts +29 -0
  24. package/engine/src/outcomes.ts +145 -0
  25. package/engine/src/paseo-runtime.ts +9 -1
  26. package/engine/src/pipeline.ts +135 -6
  27. package/engine/src/preflight.ts +10 -1
  28. package/engine/src/records.ts +71 -0
  29. package/engine/src/reply.ts +30 -11
  30. package/engine/src/review-assets/code.css +93 -0
  31. package/engine/src/review-assets/code.html +43 -0
  32. package/engine/src/review-assets/code.js +402 -0
  33. package/engine/src/review.ts +98 -2
  34. package/engine/src/runtime.ts +2 -0
  35. package/engine/src/secret-scan.ts +177 -0
  36. package/engine/src/state.ts +30 -0
  37. package/engine/src/stats.ts +143 -9
  38. package/engine/src/watchdog.ts +38 -4
  39. package/package.json +1 -1
  40. package/paseo-plugin/client/data.ts +2 -2
  41. package/paseo-plugin/client/detail.tsx +7 -2
  42. package/paseo-plugin/client/nav.ts +5 -0
  43. package/paseo-plugin/client/review.tsx +16 -6
  44. package/paseo-plugin/client/stats.tsx +15 -0
  45. package/paseo-plugin/package.json +1 -1
  46. package/paseo-plugin/server/handlers.ts +1 -1
  47. package/paseo-plugin/shared/contracts.ts +11 -2
  48. package/site-dist/404.html +2 -2
  49. package/site-dist/assets/{app.BpC99f5a.js → app.TlOHPtfE.js} +1 -1
  50. package/site-dist/assets/changelog.md.DiGi8kHU.js +1 -0
  51. package/site-dist/assets/{changelog.md.m4cSU5e4.lean.js → changelog.md.DiGi8kHU.lean.js} +1 -1
  52. package/site-dist/assets/chunks/@localSearchIndexroot.Dk_-Yka5.js +1 -0
  53. package/site-dist/assets/chunks/{VPLocalSearchBox.CI43rhTA.js → VPLocalSearchBox.BUVNy7_o.js} +1 -1
  54. package/site-dist/assets/chunks/{theme.S6pNEF23.js → theme.BFeiG1US.js} +2 -2
  55. package/site-dist/assets/{guide_getting-started.md.a7iNP1Ky.js → guide_getting-started.md.UhkLCUoY.js} +1 -1
  56. package/site-dist/assets/{guide_getting-started.md.a7iNP1Ky.lean.js → guide_getting-started.md.UhkLCUoY.lean.js} +1 -1
  57. package/site-dist/assets/{guide_project.md.Csi5wiyt.js → guide_project.md.DIjDRoAq.js} +1 -1
  58. package/site-dist/assets/{guide_project.md.Csi5wiyt.lean.js → guide_project.md.DIjDRoAq.lean.js} +1 -1
  59. package/site-dist/assets/{guide_user.md.D9GpGJ7y.js → guide_user.md.UV0WxdJa.js} +1 -1
  60. package/site-dist/assets/{guide_user.md.D9GpGJ7y.lean.js → guide_user.md.UV0WxdJa.lean.js} +1 -1
  61. package/site-dist/assets/{reference_contracts_cli-json.md.BV1HtTmr.js → reference_contracts_cli-json.md.CCvKLasM.js} +1 -1
  62. package/site-dist/assets/{reference_contracts_cli-json.md.BV1HtTmr.lean.js → reference_contracts_cli-json.md.CCvKLasM.lean.js} +1 -1
  63. package/site-dist/assets/{reference_contracts_notifier-event.md.CCfrkm_A.js → reference_contracts_notifier-event.md.B9WqeZdO.js} +1 -1
  64. package/site-dist/assets/{reference_contracts_notifier-event.md.CCfrkm_A.lean.js → reference_contracts_notifier-event.md.B9WqeZdO.lean.js} +1 -1
  65. package/site-dist/assets/{reference_contracts_spec.md.BwwS0L_j.js → reference_contracts_spec.md.Dh5XczZL.js} +2 -2
  66. package/site-dist/assets/{reference_contracts_spec.md.BwwS0L_j.lean.js → reference_contracts_spec.md.Dh5XczZL.lean.js} +2 -2
  67. package/site-dist/assets/reference_contracts_state.md.STlqkPPS.js +1 -0
  68. package/site-dist/assets/{reference_contracts_usage-record.md.D5krNLW7.lean.js → reference_contracts_usage-record.md.MjVps4CA.js} +2 -2
  69. package/site-dist/assets/{reference_contracts_usage-record.md.D5krNLW7.js → reference_contracts_usage-record.md.MjVps4CA.lean.js} +2 -2
  70. package/site-dist/changelog.html +5 -5
  71. package/site-dist/guide/getting-started.html +5 -5
  72. package/site-dist/guide/how-it-works.html +4 -4
  73. package/site-dist/guide/index.html +4 -4
  74. package/site-dist/guide/install.html +4 -4
  75. package/site-dist/guide/maintainer.html +4 -4
  76. package/site-dist/guide/phases.html +4 -4
  77. package/site-dist/guide/project.html +6 -6
  78. package/site-dist/guide/user.html +5 -5
  79. package/site-dist/hashmap.json +1 -1
  80. package/site-dist/index.html +4 -4
  81. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
  82. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
  83. package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
  84. package/site-dist/reference/adr/0004-result-files.html +4 -4
  85. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
  86. package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
  87. package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
  88. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
  89. package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
  90. package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
  91. package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
  92. package/site-dist/reference/adr/0012-review-panel.html +4 -4
  93. package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
  94. package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
  95. package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
  96. package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
  97. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
  98. package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
  99. package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
  100. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
  101. package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
  102. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
  103. package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
  104. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
  105. package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
  106. package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
  107. package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
  108. package/site-dist/reference/adr/0028-external-integrations.html +4 -4
  109. package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
  110. package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
  111. package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
  112. package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
  113. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +4 -4
  114. package/site-dist/reference/adr/index.html +4 -4
  115. package/site-dist/reference/commands.html +4 -4
  116. package/site-dist/reference/config.html +4 -4
  117. package/site-dist/reference/contracts/cli-json.html +5 -5
  118. package/site-dist/reference/contracts/feature-files.html +4 -4
  119. package/site-dist/reference/contracts/index.html +4 -4
  120. package/site-dist/reference/contracts/notifier-event.html +6 -6
  121. package/site-dist/reference/contracts/result-file.html +4 -4
  122. package/site-dist/reference/contracts/spec.html +6 -6
  123. package/site-dist/reference/contracts/state.html +5 -5
  124. package/site-dist/reference/contracts/usage-record.html +6 -6
  125. package/site-dist/reference/contracts/verify.html +4 -4
  126. package/site-dist/reference/scope.html +4 -4
  127. package/skills/req/SKILL.md +2 -0
  128. package/skills/req/playbooks/incident.md +1 -1
  129. package/skills/spec-writing/SKILL.md +2 -1
  130. package/site-dist/assets/changelog.md.m4cSU5e4.js +0 -1
  131. package/site-dist/assets/chunks/@localSearchIndexroot.D6ywcTJ5.js +0 -1
  132. package/site-dist/assets/reference_contracts_state.md.EEBd_l2P.js +0 -1
  133. /package/site-dist/assets/{reference_contracts_state.md.EEBd_l2P.lean.js → reference_contracts_state.md.STlqkPPS.lean.js} +0 -0
@@ -0,0 +1,422 @@
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 { appendEvent, requirementRef } from "./records.js";
13
+ import { isAlive, readState, statePath } from "./state.js";
14
+ import { readWorktreeRecord } from "./worktree.js";
15
+
16
+ export interface DiffLine {
17
+ /** "+" added, "-" removed, " " context. */
18
+ t: "+" | "-" | " ";
19
+ o: number | null;
20
+ n: number | null;
21
+ s: string;
22
+ }
23
+
24
+ export interface DiffFile {
25
+ path: string;
26
+ oldPath: string | null;
27
+ status: "added" | "deleted" | "modified" | "renamed";
28
+ additions: number;
29
+ deletions: number;
30
+ binary: boolean;
31
+ /** The requirement's own documents (spec, design, …): shown last, folded. */
32
+ doc: boolean;
33
+ hunks: Array<{ header: string; lines: DiffLine[] }>;
34
+ /** Too big to show whole. */
35
+ truncated: boolean;
36
+ }
37
+
38
+ export type Severity = "阻塞" | "重要" | "建议" | "待核实";
39
+
40
+ export interface Finding {
41
+ id: string;
42
+ round: number;
43
+ severity: Severity;
44
+ file: string | null;
45
+ line: number | null;
46
+ title: string;
47
+ suggestion: string | null;
48
+ /** What the fix step recorded: fixed, rejected (不成立), wontfix (不修复), or open. */
49
+ status: "open" | "fixed" | "rejected" | "wontfix";
50
+ }
51
+
52
+ export interface HumanItem {
53
+ id: string;
54
+ file: string | null;
55
+ line: number | null;
56
+ text: string;
57
+ /** The AI finding it takes up, if any. */
58
+ finding: string | null;
59
+ }
60
+
61
+ export interface HumanReview {
62
+ status: "approved" | "changes_requested";
63
+ at: string;
64
+ reviewer: string;
65
+ reviewers: string[];
66
+ /** The commit that was reviewed. */
67
+ head: string | null;
68
+ items: HumanItem[];
69
+ note: string;
70
+ /** For changes_requested: the attempt that took the comments up (null until a run starts). */
71
+ consumedAttempt: number | null;
72
+ }
73
+
74
+ const MAX_LINES_PER_FILE = 3000;
75
+ const MAX_LINES_TOTAL = 20000;
76
+
77
+ function git(cwd: string, ...args: string[]): string | null {
78
+ try {
79
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function read(file: string): string {
86
+ try {
87
+ return fs.readFileSync(file, "utf8");
88
+ } catch {
89
+ return "";
90
+ }
91
+ }
92
+
93
+ /** Where the requirement's change starts: the fork point from the branch it was cut from. */
94
+ export function baseOf(repo: string, featureDir: string): { ref: string | null; commit: string | null } {
95
+ const state = readState(statePath(repo, featureDir));
96
+ const source = state?.worktree?.sourceRepo ?? null;
97
+ const recorded = source ? readWorktreeRecord(source, featureDir)?.baseRef ?? null : null;
98
+ const branch = (git(repo, "rev-parse", "--abbrev-ref", "HEAD") ?? "").trim();
99
+ const remoteHead = (git(repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") ?? "").trim();
100
+ const candidates = [recorded, remoteHead || null, "main", "master", "origin/main", "origin/master"].filter((x): x is string => !!x && x !== branch);
101
+ for (const ref of candidates) {
102
+ const mb = (git(repo, "merge-base", "HEAD", ref) ?? "").trim();
103
+ const head = (git(repo, "rev-parse", "HEAD") ?? "").trim();
104
+ if (mb && mb !== head) return { ref, commit: mb };
105
+ }
106
+ // On the base branch itself, or no common history: the baseline the implementation wrote down.
107
+ const baseline = /基线提交[^0-9a-f]{0,40}([0-9a-f]{7,40})/.exec(read(path.join(repo, featureDir, "implementation.md")))?.[1] ?? null;
108
+ return { ref: null, commit: baseline && git(repo, "cat-file", "-e", `${baseline}^{commit}`) !== null ? baseline : null };
109
+ }
110
+
111
+ /** `git diff` output as files and hunks. */
112
+ export function parseDiff(text: string, featureDir: string): DiffFile[] {
113
+ const files: DiffFile[] = [];
114
+ let file: DiffFile | null = null;
115
+ let hunk: DiffFile["hunks"][number] | null = null;
116
+ let o = 0;
117
+ let n = 0;
118
+ let total = 0;
119
+ let inFile = 0;
120
+ const unquote = (p: string) => p.replace(/^"|"$/g, "").replace(/^[ab]\//, "");
121
+ for (const line of text.split("\n")) {
122
+ if (line.startsWith("diff --git ")) {
123
+ const m = /^diff --git (?:"?a\/(.+?)"?) (?:"?b\/(.+?)"?)$/.exec(line);
124
+ const p = m ? m[2] : line.slice(11);
125
+ file = { path: p, oldPath: null, status: "modified", additions: 0, deletions: 0, binary: false, doc: p === featureDir || p.startsWith(`${featureDir}/`), hunks: [], truncated: false };
126
+ files.push(file);
127
+ hunk = null;
128
+ inFile = 0;
129
+ continue;
130
+ }
131
+ if (!file) continue;
132
+ if (line.startsWith("new file mode")) file.status = "added";
133
+ else if (line.startsWith("deleted file mode")) file.status = "deleted";
134
+ else if (line.startsWith("rename from ")) {
135
+ file.status = "renamed";
136
+ file.oldPath = line.slice("rename from ".length);
137
+ } else if (line.startsWith("Binary files ")) file.binary = true;
138
+ else if (line.startsWith("+++ ") || line.startsWith("--- ")) {
139
+ if (line.startsWith("+++ ") && line !== "+++ /dev/null") file.path = unquote(line.slice(4));
140
+ } else if (line.startsWith("@@")) {
141
+ const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(line);
142
+ o = m ? Number(m[1]) : 0;
143
+ n = m ? Number(m[2]) : 0;
144
+ hunk = { header: line, lines: [] };
145
+ file.hunks.push(hunk);
146
+ } else if (hunk && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
147
+ const t = line[0] as DiffLine["t"];
148
+ if (t === "+") file.additions++;
149
+ if (t === "-") file.deletions++;
150
+ if (inFile >= MAX_LINES_PER_FILE || total >= MAX_LINES_TOTAL) {
151
+ file.truncated = true;
152
+ continue;
153
+ }
154
+ hunk.lines.push({ t, o: t === "+" ? null : o, n: t === "-" ? null : n, s: line.slice(1) });
155
+ if (t !== "+") o++;
156
+ if (t !== "-") n++;
157
+ inFile++;
158
+ total++;
159
+ }
160
+ }
161
+ // Code first, then the requirement's documents.
162
+ return [...files.filter((f) => !f.doc), ...files.filter((f) => f.doc)];
163
+ }
164
+
165
+ const SEVERITIES: Array<[RegExp, Severity]> = [
166
+ [/阻塞|blocker|critical|high/i, "阻塞"],
167
+ [/重要|major|medium/i, "重要"],
168
+ [/待核实|unverified|unsure/i, "待核实"],
169
+ [/建议|minor|low|nit|suggest/i, "建议"],
170
+ ];
171
+
172
+ function severityOf(text: unknown): Severity {
173
+ const s = String(text ?? "");
174
+ for (const [re, v] of SEVERITIES) if (re.test(s)) return v;
175
+ return "重要";
176
+ }
177
+
178
+ function locationOf(text: string): { file: string | null; line: number | null } {
179
+ const m = /([\w@./\\-]+\.[A-Za-z0-9]+)(?::(\d+))?/.exec(text);
180
+ return { file: m ? m[1].replace(/\\/g, "/") : null, line: m?.[2] ? Number(m[2]) : null };
181
+ }
182
+
183
+ /** The fix step's record for each CR: fixed / rejected / won't fix. */
184
+ function statuses(review: string): Map<string, Finding["status"]> {
185
+ const out = new Map<string, Finding["status"]>();
186
+ const lines = review.split(/\r?\n/);
187
+ let inRecord = false;
188
+ for (const line of lines) {
189
+ if (/^#{1,6}\s/.test(line)) inRecord = /处理记录/.test(line);
190
+ if (!inRecord) continue;
191
+ for (const m of line.matchAll(/CR-(\d+)/g)) {
192
+ const id = `CR-${m[1]}`;
193
+ if (/不成立|驳回|不认可/.test(line)) out.set(id, "rejected");
194
+ else if (/不修复|暂不|以后再|不改/.test(line)) out.set(id, "wontfix");
195
+ else if (/已修复|修复|已改|已处理|fixed/i.test(line)) out.set(id, "fixed");
196
+ }
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * The AI review's findings: the structured ones the review step put in its result file
203
+ * (`findings`), or, from older runs, the CR items of code-review.md.
204
+ */
205
+ export function aiFindings(repo: string, featureDir: string): { findings: Finding[]; rounds: Array<{ round: number; verdict: string }> } {
206
+ const state = readState(statePath(repo, featureDir));
207
+ const review = read(path.join(repo, featureDir, "code-review.md"));
208
+ const status = statuses(review);
209
+ const rounds: Array<{ round: number; verdict: string }> = [];
210
+ const findings: Finding[] = [];
211
+ const seen = new Set<string>();
212
+ for (const [key, value] of Object.entries(state?.results ?? {})) {
213
+ const m = /^code_review(?:-r(\d+))?$/.exec(key);
214
+ if (!m) continue;
215
+ const round = Number(m[1] ?? 1);
216
+ rounds.push({ round, verdict: String(value.verdict ?? "") });
217
+ const list = Array.isArray(value.findings) ? (value.findings as Array<Record<string, unknown>>) : [];
218
+ list.forEach((f, i) => {
219
+ if (!f || typeof f !== "object") return;
220
+ const title = String(f.title ?? f.problem ?? f.issue ?? f.description ?? "").trim();
221
+ if (!title) return;
222
+ const id = String(f.id ?? `CR-${round}.${i + 1}`).trim();
223
+ if (seen.has(id)) return;
224
+ seen.add(id);
225
+ const loc = typeof f.file === "string" ? { file: f.file.replace(/\\/g, "/"), line: Number.parseInt(String(f.line ?? ""), 10) || null } : locationOf(String(f.location ?? ""));
226
+ 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" });
227
+ });
228
+ }
229
+ rounds.sort((a, b) => a.round - b.round);
230
+ if (findings.length === 0 && review) {
231
+ // Older runs: "CR-3(重要)src/a.ts:12 …" items in code-review.md.
232
+ const lines = review.split(/\r?\n/);
233
+ let round = 1;
234
+ let heading = "";
235
+ for (let i = 0; i < lines.length; i++) {
236
+ if (/^#{1,6}\s/.test(lines[i])) {
237
+ heading = lines[i];
238
+ const r = /第\s*(\d+)\s*轮/.exec(heading);
239
+ if (r) round = Number(r[1]);
240
+ }
241
+ // Items are where they are raised, not where the fix step records them.
242
+ if (/处理记录|人工评审/.test(heading) && !/^#{1,6}\s+(?:\**)?CR-/.test(lines[i])) continue;
243
+ const m = /^\s*(?:[-*]|#{1,6}|\d+[.、])?\s*\**\s*(CR-\d+)\b\**[\s::.、))-]*(.*)$/.exec(lines[i]);
244
+ if (!m || seen.has(m[1])) continue;
245
+ seen.add(m[1]);
246
+ const block = [m[2], ...lines.slice(i + 1, i + 8).filter((l) => l.trim() && !/CR-\d+/.test(l) && !/^#{1,6}\s/.test(l))].join(" ");
247
+ const loc = locationOf(block);
248
+ const title = block.replace(/\*\*/g, "").replace(/级别[::]\s*\S+/, "").replace(/位置[::]\s*\S+/, "").replace(/\s+/g, " ").trim();
249
+ 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" });
250
+ }
251
+ }
252
+ return { findings, rounds };
253
+ }
254
+
255
+ const humanDir = (featureAbs: string) => path.join(featureAbs, ".pipeline", "code-review");
256
+
257
+ export function readHumanReview(featureAbs: string): HumanReview | null {
258
+ try {
259
+ return JSON.parse(fs.readFileSync(path.join(humanDir(featureAbs), "human.json"), "utf8")) as HumanReview;
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
265
+ export function writeHumanReview(featureAbs: string, review: HumanReview): void {
266
+ fs.mkdirSync(humanDir(featureAbs), { recursive: true });
267
+ fs.writeFileSync(path.join(humanDir(featureAbs), "human.json"), JSON.stringify(review, null, 2));
268
+ }
269
+
270
+ export interface CodeReviewData {
271
+ title: string;
272
+ featureDir: string;
273
+ branch: string | null;
274
+ base: { ref: string | null; commit: string | null };
275
+ head: string | null;
276
+ files: DiffFile[];
277
+ findings: Finding[];
278
+ rounds: Array<{ round: number; verdict: string }>;
279
+ /** handoff.md "这次改了什么": the change in plain words. */
280
+ summary: string[];
281
+ reviewers: { suggested: string[]; byFile: Record<string, string[]>; source: string | null };
282
+ human: (HumanReview & { stale: boolean }) | null;
283
+ status: string | null;
284
+ running: boolean;
285
+ }
286
+
287
+ export function buildCodeReview(repo: string, featureDir: string): CodeReviewData {
288
+ const featureAbs = path.join(repo, featureDir);
289
+ const state = readState(statePath(repo, featureDir));
290
+ const base = baseOf(repo, featureDir);
291
+ const head = (git(repo, "rev-parse", "HEAD") ?? "").trim() || null;
292
+ const diff = base.commit ? (git(repo, "diff", "--no-color", "--no-ext-diff", "-M", "--unified=3", `${base.commit}..HEAD`) ?? "") : "";
293
+ const files = parseDiff(diff, featureDir);
294
+ const { findings, rounds } = aiFindings(repo, featureDir);
295
+ const owners = readCodeowners(repo);
296
+ const code = files.filter((f) => !f.doc).map((f) => f.path);
297
+ const o = owners ? ownersFor(owners.rules, code) : { byFile: {}, all: [] };
298
+ const spec = read(path.join(featureAbs, "spec.md"));
299
+ const human = readHumanReview(featureAbs);
300
+ const summary = section(read(path.join(featureAbs, "handoff.md")), /这次改了什么|改了什么|改动概要|变更内容/)
301
+ .map((l) => l.replace(/\s+$/, ""))
302
+ .filter((l) => l.trim())
303
+ .slice(0, 30);
304
+ return {
305
+ title: (/^#\s+(.+)$/m.exec(spec)?.[1] ?? path.basename(featureDir)).replace(/^需求说明[::\s]*/, "").trim(),
306
+ featureDir,
307
+ branch: state?.branch ?? ((git(repo, "rev-parse", "--abbrev-ref", "HEAD") ?? "").trim() || null),
308
+ base,
309
+ head,
310
+ files,
311
+ findings,
312
+ rounds,
313
+ summary,
314
+ reviewers: { suggested: o.all, byFile: o.byFile, source: owners?.file ?? null },
315
+ human: human ? { ...human, stale: human.status === "approved" && !!head && human.head !== head } : null,
316
+ status: state?.status ?? null,
317
+ running: !!state && state.status === "running" && isAlive(state.pid),
318
+ };
319
+ }
320
+
321
+ export interface Submission {
322
+ verdict: "approve" | "changes";
323
+ reviewer: string;
324
+ reviewers: string[];
325
+ comments: Array<{ file: string | null; line: number | null; text: string }>;
326
+ /** AI findings the reviewer wants fixed, with an optional remark. */
327
+ fix: Array<{ id: string; note?: string }>;
328
+ note: string;
329
+ }
330
+
331
+ function commitDocs(repo: string, files: string[], message: string): boolean {
332
+ const rels = files.filter((f) => fs.existsSync(path.join(repo, f)));
333
+ if (git(repo, "rev-parse", "--git-dir") === null || rels.length === 0) return false;
334
+ if (git(repo, "add", "--", ...rels) === null) return false;
335
+ if (!(git(repo, "diff", "--cached", "--name-only", "--", ...rels) ?? "").trim()) return false;
336
+ return git(repo, "commit", "-q", "-m", message, "--", ...rels) !== null;
337
+ }
338
+
339
+ /** The person's review, recorded in code-review.md (and changes.md when changes are asked for). */
340
+ export function recordHumanReview(repo: string, featureDir: string, sub: Submission, now = new Date()): { review: HumanReview; committed: boolean } {
341
+ const featureAbs = path.join(repo, featureDir);
342
+ const data = buildCodeReview(repo, featureDir);
343
+ const byId = new Map(data.findings.map((f) => [f.id, f]));
344
+ const items: HumanItem[] = [];
345
+ for (const f of sub.fix) {
346
+ const found = byId.get(f.id);
347
+ if (!found) continue;
348
+ 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()}` : ""}` });
349
+ }
350
+ for (const c of sub.comments) {
351
+ if (!c.text?.trim()) continue;
352
+ items.push({ id: `HR-${items.length + 1}`, file: c.file, line: c.line, finding: null, text: c.text.trim().slice(0, 2000) });
353
+ }
354
+ 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) });
355
+ const date = now.toISOString().slice(0, 10);
356
+ const reviewer = sub.reviewer.trim().slice(0, 60) || "评审人(插件)";
357
+ const review: HumanReview = {
358
+ status: sub.verdict === "approve" ? "approved" : "changes_requested",
359
+ at: now.toISOString(),
360
+ reviewer,
361
+ reviewers: [...new Set(sub.reviewers.map((r) => r.trim()).filter(Boolean))].slice(0, 20),
362
+ head: data.head,
363
+ items,
364
+ note: sub.note.trim().slice(0, 2000),
365
+ consumedAttempt: null,
366
+ };
367
+ const where = (i: HumanItem) => (i.file ? `[${i.file}${i.line ? `:${i.line}` : ""}] ` : "");
368
+ const block = [
369
+ "",
370
+ `## 人工评审(${date},${reviewer})`,
371
+ "",
372
+ `- 结论:${sub.verdict === "approve" ? "通过" : "需要修改"}`,
373
+ `- 评审的提交:${data.head ?? "(未知)"}`,
374
+ `- 审核人:${review.reviewers.length ? review.reviewers.join("、") : "(未指定)"}`,
375
+ ...(sub.verdict === "approve" && review.note ? [`- 说明:${review.note}`] : []),
376
+ ...(items.length ? ["", ...items.map((i) => `- **${i.id}** ${where(i)}${i.text}`)] : []),
377
+ ...(sub.verdict === "changes" ? ["", "以上 HR 意见由下一次尝试逐条处理;处理记录写在这一节之后。"] : []),
378
+ "",
379
+ ].join("\n");
380
+ const reviewFile = path.join(featureAbs, "code-review.md");
381
+ fs.appendFileSync(reviewFile, fs.existsSync(reviewFile) ? block : `# 代码评审\n${block}`);
382
+ const touched = [path.join(featureDir, "code-review.md")];
383
+ if (sub.verdict === "changes") {
384
+ const changes = path.join(featureAbs, "changes.md");
385
+ const n = (read(changes).match(/人工代码评审退回/g) ?? []).length + 1;
386
+ const text = [
387
+ "",
388
+ `## ${date} 人工代码评审退回(第 ${n} 次)`,
389
+ "",
390
+ "- 来源:人工代码评审",
391
+ `- 内容:${reviewer} 提出了 ${items.length} 条修改意见,见 code-review.md 的“人工评审(${date},${reviewer})”一节(HR-1 起)`,
392
+ "- 处理:从方案设计开始只处理这些意见(需求说明不变),修改经过代码评审后重新验证",
393
+ "",
394
+ ].join("\n");
395
+ fs.appendFileSync(changes, fs.existsSync(changes) ? text : `# 变更记录\n${text}`);
396
+ touched.push(path.join(featureDir, "changes.md"));
397
+ }
398
+ writeHumanReview(featureAbs, review);
399
+ const committed = commitDocs(repo, touched, `docs(${path.basename(featureDir)}): 人工代码评审${sub.verdict === "approve" ? "通过" : "意见"}`);
400
+ // A new review replaces the draft.
401
+ fs.rmSync(path.join(humanDir(featureAbs), "draft.json"), { force: true });
402
+ // 1.14: the review also goes into the usage records, for req stats.
403
+ appendEvent({ kind: "review", at: review.at, requirement: requirementRef(repo, featureDir), review: { verdict: review.status, reviewer, items: items.length, head: review.head } });
404
+ return { review, committed };
405
+ }
406
+
407
+ /** Starts the attempt that handles the comments: `req review-fix <需求>` in the requirement's checkout. */
408
+ export function startReviewFix(repo: string, featureDir: string): { ok: boolean; message: string } {
409
+ const state = readState(statePath(repo, featureDir));
410
+ const cwd = state?.worktree?.sourceRepo ?? repo;
411
+ const env = { ...process.env };
412
+ delete env.PASEO_AGENT_ID;
413
+ delete env.PASEO_AGENT_CWD;
414
+ const r = spawnSync(process.execPath, [path.join(KIT_ROOT, "bin", "req.mjs"), "review-fix", featureDir], { cwd, env, encoding: "utf8", timeout: 180_000, windowsHide: true });
415
+ return { ok: r.status === 0, message: r.error ? r.error.message : `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
416
+ }
417
+
418
+ /** Comments waiting for an attempt to take them up (for `--review-fix`). */
419
+ export function pendingHumanReview(featureAbs: string): HumanReview | null {
420
+ const r = readHumanReview(featureAbs);
421
+ return r && r.status === "changes_requested" && r.consumedAttempt === null ? r : null;
422
+ }
@@ -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
+ }
@@ -127,6 +127,16 @@ const CATALOG: Array<{ id: string; title: string; description: string; keys: Rec
127
127
  description: "步骤 agent 搜索整个磁盘、用户目录、Maven 仓库(.m2)、全局 node_modules 等很大的目录时,一分钟后打断它并告诉它该怎么查;任何搜索运行太久也打断。",
128
128
  display: (v) => { const g = v as PipelineConfig["scanGuard"]; return g.enabled ? `开,搜索超过 ${g.minutes} 分钟也打断${g.paths.length ? `;另外不许整个搜索:${g.paths.join("、")}` : ""}` : "关"; },
129
129
  },
130
+ dangerGuard: {
131
+ label: "拦截危险命令",
132
+ description: "步骤 agent 推送代码、发布制品、改动集群、连接其他机器上的数据库或主机、删除仓库以外的目录、下载脚本直接执行,或命令里出现生产环境的地址时,打断它并通知你。",
133
+ display: (v) => { const g = v as PipelineConfig["dangerGuard"]; return g.enabled ? `开${g.production.length ? `;生产环境标记 ${g.production.length} 个` : ""}${g.allow.length ? `;放行 ${g.allow.length} 条` : ""}` : "关"; },
134
+ },
135
+ secretScan: {
136
+ label: "密钥扫描",
137
+ description: "每次改代码的步骤之后、推送分支之前,检查本需求新增的内容里有没有密钥、令牌、口令;发现了让 agent 去掉,去不掉就不推送。",
138
+ display: (v) => { const g = v as PipelineConfig["secretScan"]; return g.enabled ? `开${g.allow.length ? `;不扫描:${g.allow.join("、")}` : ""}` : "关"; },
139
+ },
130
140
  },
131
141
  },
132
142
  {
@@ -251,6 +251,15 @@ export interface PipelineConfig {
251
251
  * and tell it how to find what it needs instead. `paths`: more directories never to search whole.
252
252
  */
253
253
  scanGuard: import("./scan-guard.js").ScanGuardSettings;
254
+ /**
255
+ * 1.14: stop a step agent's dangerous commands (push, publish, cluster changes, remote databases
256
+ * and hosts, deleting outside the repository, downloaded scripts) and tell the person.
257
+ * `production`: text that marks production systems (host names, addresses); `allow`: regular
258
+ * expressions of commands that are allowed after all.
259
+ */
260
+ dangerGuard: import("./danger-guard.js").DangerGuardSettings;
261
+ /** 1.14: look for keys, tokens and passwords in what the requirement added (quality gate, before push). `allow`: files never scanned. */
262
+ secretScan: import("./secret-scan.js").SecretScanSettings;
254
263
  /** Automatic retries of a step after a transient error (rate limit, dropped connection). */
255
264
  retry: { attempts: number; backoffSeconds: number[] };
256
265
  /**
@@ -362,6 +371,8 @@ export const DEFAULT_CONFIG: PipelineConfig = {
362
371
  autoConfirm: { hours: 0 },
363
372
  budget: { tokens: null, warnPercent: 80 },
364
373
  scanGuard: { enabled: true, minutes: 5, paths: [] },
374
+ dangerGuard: { enabled: true, production: [], allow: [] },
375
+ secretScan: { enabled: true, allow: [] },
365
376
  retry: { attempts: 2, backoffSeconds: [60, 300] },
366
377
  paseoServices: "auto",
367
378
  serviceReadySeconds: 120,
@@ -432,6 +443,8 @@ function merge(base: PipelineConfig, raw: Record<string, unknown>): PipelineConf
432
443
  merged.autoConfirm = { ...base.autoConfirm, ...(isRecord(raw.autoConfirm) ? raw.autoConfirm : {}) } as PipelineConfig["autoConfirm"];
433
444
  merged.budget = { ...base.budget, ...(isRecord(raw.budget) ? raw.budget : {}) } as PipelineConfig["budget"];
434
445
  merged.scanGuard = { ...base.scanGuard, ...(isRecord(raw.scanGuard) ? raw.scanGuard : {}) } as PipelineConfig["scanGuard"];
446
+ merged.dangerGuard = { ...base.dangerGuard, ...(isRecord(raw.dangerGuard) ? raw.dangerGuard : {}) } as PipelineConfig["dangerGuard"];
447
+ merged.secretScan = { ...base.secretScan, ...(isRecord(raw.secretScan) ? raw.secretScan : {}) } as PipelineConfig["secretScan"];
435
448
  if (isRecord(raw.cardSource)) {
436
449
  const c = raw.cardSource;
437
450
  merged.cardSource = {
@@ -657,6 +670,20 @@ function validate(config: PipelineConfig): void {
657
670
  if (typeof sg.enabled !== "boolean") throw new ConfigError("scanGuard.enabled 必须是 true 或 false");
658
671
  if (typeof sg.minutes !== "number" || !(sg.minutes >= 1)) throw new ConfigError("scanGuard.minutes 必须是至少 1 的分钟数(搜索运行多久后打断)");
659
672
  if (!Array.isArray(sg.paths) || !sg.paths.every((p) => typeof p === "string" && p.trim())) throw new ConfigError('scanGuard.paths 必须是目录路径的数组,例如 ["D:/share"]');
673
+ const dg = config.dangerGuard;
674
+ if (typeof dg.enabled !== "boolean") throw new ConfigError("dangerGuard.enabled 必须是 true 或 false");
675
+ if (!Array.isArray(dg.production) || !dg.production.every((p) => typeof p === "string" && p.trim())) throw new ConfigError('dangerGuard.production 必须是字符串数组(生产环境的主机名、地址等),例如 ["prod.example.com"]');
676
+ if (!Array.isArray(dg.allow) || !dg.allow.every((p) => typeof p === "string" && p.trim())) throw new ConfigError('dangerGuard.allow 必须是正则表达式字符串的数组,例如 ["^ssh test-box"]');
677
+ for (const a of dg.allow) {
678
+ try {
679
+ new RegExp(a);
680
+ } catch (error) {
681
+ throw new ConfigError(`dangerGuard.allow 里的「${a}」不是合法的正则表达式:${(error as Error).message}`);
682
+ }
683
+ }
684
+ const ss = config.secretScan;
685
+ if (typeof ss.enabled !== "boolean") throw new ConfigError("secretScan.enabled 必须是 true 或 false");
686
+ if (!Array.isArray(ss.allow) || !ss.allow.every((p) => typeof p === "string" && p.trim())) throw new ConfigError('secretScan.allow 必须是文件路径或通配符的数组,例如 ["test/fixtures/**"]');
660
687
  const f = config.factory;
661
688
  if (typeof f.pollMinutes !== "number" || !(f.pollMinutes >= 1)) throw new ConfigError("factory.pollMinutes 必须是至少 1 的分钟数");
662
689
  if (!Number.isInteger(f.maxNewPerPoll) || f.maxNewPerPoll < 1) throw new ConfigError("factory.maxNewPerPoll 必须是正整数");