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

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 (116) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/req.mjs +6 -0
  3. package/contracts/notifier-event.md +1 -1
  4. package/contracts/spec.md +1 -0
  5. package/contracts/state.md +3 -1
  6. package/contracts/usage-record.md +16 -2
  7. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +3 -1
  8. 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
  9. package/engine/prompts/00-shared-context.md +3 -2
  10. package/engine/src/activity.ts +21 -0
  11. package/engine/src/api.ts +30 -5
  12. package/engine/src/code-review.ts +3 -0
  13. package/engine/src/config-view.ts +10 -0
  14. package/engine/src/config.ts +27 -0
  15. package/engine/src/danger-guard.ts +180 -0
  16. package/engine/src/features.ts +1 -1
  17. package/engine/src/messages.ts +29 -0
  18. package/engine/src/outcomes.ts +145 -0
  19. package/engine/src/paseo-runtime.ts +9 -1
  20. package/engine/src/pipeline.ts +135 -6
  21. package/engine/src/records.ts +71 -0
  22. package/engine/src/reply.ts +30 -11
  23. package/engine/src/review.ts +15 -1
  24. package/engine/src/runtime.ts +2 -0
  25. package/engine/src/secret-scan.ts +177 -0
  26. package/engine/src/state.ts +30 -0
  27. package/engine/src/stats.ts +143 -9
  28. package/engine/src/watchdog.ts +38 -4
  29. package/package.json +1 -1
  30. package/paseo-plugin/client/stats.tsx +15 -0
  31. package/paseo-plugin/package.json +1 -1
  32. package/paseo-plugin/shared/contracts.ts +7 -0
  33. package/site-dist/404.html +2 -2
  34. package/site-dist/assets/{app.Cp7WwPWE.js → app.VQ-QxRPH.js} +1 -1
  35. package/site-dist/assets/changelog.md.CIKYJXpF.js +1 -0
  36. package/site-dist/assets/{changelog.md.Zi-_3Rjh.lean.js → changelog.md.CIKYJXpF.lean.js} +1 -1
  37. package/site-dist/assets/chunks/@localSearchIndexroot.h7oQE_np.js +1 -0
  38. package/site-dist/assets/chunks/{VPLocalSearchBox.CMTXQK-Q.js → VPLocalSearchBox.CT4Vlj76.js} +1 -1
  39. package/site-dist/assets/chunks/{theme.DvVUfKr3.js → theme.B_UmHHou.js} +2 -2
  40. package/site-dist/assets/{guide_getting-started.md.D8XlIQOg.js → guide_getting-started.md.CZM53rO_.js} +1 -1
  41. package/site-dist/assets/{guide_project.md.Csi5wiyt.js → guide_project.md.DIjDRoAq.js} +1 -1
  42. package/site-dist/assets/{guide_project.md.Csi5wiyt.lean.js → guide_project.md.DIjDRoAq.lean.js} +1 -1
  43. package/site-dist/assets/{guide_user.md.B4N9ywmr.js → guide_user.md.UV0WxdJa.js} +1 -1
  44. package/site-dist/assets/{guide_user.md.B4N9ywmr.lean.js → guide_user.md.UV0WxdJa.lean.js} +1 -1
  45. package/site-dist/assets/{reference_contracts_notifier-event.md.CCfrkm_A.js → reference_contracts_notifier-event.md.B9WqeZdO.js} +1 -1
  46. package/site-dist/assets/{reference_contracts_notifier-event.md.CCfrkm_A.lean.js → reference_contracts_notifier-event.md.B9WqeZdO.lean.js} +1 -1
  47. package/site-dist/assets/{reference_contracts_spec.md.BwwS0L_j.js → reference_contracts_spec.md.Dh5XczZL.js} +2 -2
  48. package/site-dist/assets/{reference_contracts_spec.md.BwwS0L_j.lean.js → reference_contracts_spec.md.Dh5XczZL.lean.js} +2 -2
  49. package/site-dist/assets/reference_contracts_state.md.STlqkPPS.js +1 -0
  50. package/site-dist/assets/{reference_contracts_usage-record.md.D5krNLW7.lean.js → reference_contracts_usage-record.md.MjVps4CA.js} +2 -2
  51. package/site-dist/assets/{reference_contracts_usage-record.md.D5krNLW7.js → reference_contracts_usage-record.md.MjVps4CA.lean.js} +2 -2
  52. package/site-dist/changelog.html +5 -5
  53. package/site-dist/guide/getting-started.html +5 -5
  54. package/site-dist/guide/how-it-works.html +4 -4
  55. package/site-dist/guide/index.html +4 -4
  56. package/site-dist/guide/install.html +4 -4
  57. package/site-dist/guide/maintainer.html +4 -4
  58. package/site-dist/guide/phases.html +4 -4
  59. package/site-dist/guide/project.html +6 -6
  60. package/site-dist/guide/user.html +5 -5
  61. package/site-dist/hashmap.json +1 -1
  62. package/site-dist/index.html +4 -4
  63. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
  64. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
  65. package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
  66. package/site-dist/reference/adr/0004-result-files.html +4 -4
  67. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
  68. package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
  69. package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
  70. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
  71. package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
  72. package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
  73. package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
  74. package/site-dist/reference/adr/0012-review-panel.html +4 -4
  75. package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
  76. package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
  77. package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
  78. package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
  79. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
  80. package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
  81. package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
  82. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
  83. package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
  84. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
  85. package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
  86. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
  87. package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
  88. package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
  89. package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
  90. package/site-dist/reference/adr/0028-external-integrations.html +4 -4
  91. package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
  92. package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
  93. package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
  94. package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
  95. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +4 -4
  96. package/site-dist/reference/adr/index.html +4 -4
  97. package/site-dist/reference/commands.html +4 -4
  98. package/site-dist/reference/config.html +4 -4
  99. package/site-dist/reference/contracts/cli-json.html +4 -4
  100. package/site-dist/reference/contracts/feature-files.html +4 -4
  101. package/site-dist/reference/contracts/index.html +4 -4
  102. package/site-dist/reference/contracts/notifier-event.html +6 -6
  103. package/site-dist/reference/contracts/result-file.html +4 -4
  104. package/site-dist/reference/contracts/spec.html +6 -6
  105. package/site-dist/reference/contracts/state.html +5 -5
  106. package/site-dist/reference/contracts/usage-record.html +6 -6
  107. package/site-dist/reference/contracts/verify.html +4 -4
  108. package/site-dist/reference/scope.html +4 -4
  109. package/skills/req/SKILL.md +1 -0
  110. package/skills/req/playbooks/incident.md +1 -1
  111. package/skills/spec-writing/SKILL.md +2 -1
  112. package/site-dist/assets/changelog.md.Zi-_3Rjh.js +0 -1
  113. package/site-dist/assets/chunks/@localSearchIndexroot.COxp5Rrc.js +0 -1
  114. package/site-dist/assets/reference_contracts_state.md.EEBd_l2P.js +0 -1
  115. /package/site-dist/assets/{guide_getting-started.md.D8XlIQOg.lean.js → guide_getting-started.md.CZM53rO_.lean.js} +0 -0
  116. /package/site-dist/assets/{reference_contracts_state.md.EEBd_l2P.lean.js → reference_contracts_state.md.STlqkPPS.lean.js} +0 -0
@@ -0,0 +1,177 @@
1
+ // Secret scan (1.14): before the change leaves the step (quality gate) and before the branch
2
+ // leaves the machine (push), the pipeline looks for keys, tokens and passwords in what the
3
+ // requirement added: the lines its commits added since the base and the changes not yet
4
+ // committed. Well-known key formats are found anywhere; "password = ..." style assignments
5
+ // only outside tests and docs, and not when the value is a placeholder or read from the
6
+ // environment. A line with "req-kit:allow-secret" (or "gitleaks:allow") is skipped.
7
+ import { execFileSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { matchesPath } from "./gate.js";
11
+
12
+ export interface SecretScanSettings {
13
+ enabled: boolean;
14
+ /** Files never scanned (paths or globs, e.g. "test/fixtures/**"). */
15
+ allow: string[];
16
+ }
17
+
18
+ export interface SecretFinding {
19
+ file: string;
20
+ line: number;
21
+ /** The commit that added it (short), or null for a change not committed yet. */
22
+ commit: string | null;
23
+ /** What it looks like, for the person ("私钥", "GitLab 令牌", "口令"). */
24
+ kind: string;
25
+ /** The line with the secret masked. */
26
+ preview: string;
27
+ }
28
+
29
+ const STRONG: Array<{ re: RegExp; kind: string }> = [
30
+ { re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY( BLOCK)?-----/, kind: "私钥" },
31
+ { re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, kind: "AWS 访问密钥" },
32
+ { re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{40,}\b/, kind: "GitHub 令牌" },
33
+ { re: /\bglpat-[A-Za-z0-9_-]{20,}\b/, kind: "GitLab 令牌" },
34
+ { re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, kind: "Slack 令牌" },
35
+ { re: /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}\b/, kind: "模型服务的 API 密钥" },
36
+ { re: /\bAIza[0-9A-Za-z_-]{35}\b/, kind: "Google API 密钥" },
37
+ { re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, kind: "令牌(JWT)" },
38
+ { re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@'"]+:([^\s@/'"]{4,})@[^\s'"]+/i, kind: "地址里带口令" },
39
+ ];
40
+
41
+ const ASSIGN = /(?:^|[\s"'{(,;])([A-Za-z0-9_.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|credential)[A-Za-z0-9_.-]*)["']?\s*(?:[:=]|:=|=>)\s*(["'`]?)([^\s"'`,;)}]+)\2/i;
42
+
43
+ /** Values that are not secrets: placeholders, environment lookups, words, booleans. */
44
+ function placeholder(value: string): boolean {
45
+ const v = value.trim();
46
+ if (v.length < 8) return true;
47
+ if (/[${}<>%]|\(|\[|^\*+$|^x+$|^\.+$/i.test(v)) return true;
48
+ if (/^(true|false|null|none|undefined|nil|required|optional|string|number|boolean)$/i.test(v)) return true;
49
+ if (/(change.?me|example|sample|dummy|fake|mock|placeholder|your[_-]?|xxx|todo|redacted|\*\*\*|test|demo|secret|password|token)/i.test(v)) return true;
50
+ // Code, not a value: a property path or a function name.
51
+ if (/^[A-Za-z_][\w]*(\.[A-Za-z_][\w]*)+$/.test(v) || /^(process\.env|os\.environ|System\.getenv|env\.)/.test(v)) return true;
52
+ // A secret has some mix: letters and digits, or symbols.
53
+ return !(/[A-Za-z]/.test(v) && (/\d/.test(v) || /[^A-Za-z0-9]/.test(v)));
54
+ }
55
+
56
+ const TEST_OR_DOC = /(^|\/)(tests?|__tests__|spec|specs|fixtures?|mocks?|testdata|examples?|docs?)\/|\.(test|spec)\.[a-z]+$|_test\.go$|Test\.java$|\.(md|txt|rst|adoc)$/i;
57
+ const SKIP_FILE = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|go\.sum|poetry\.lock|composer\.lock)$|\.(min\.js|map|svg|png|jpe?g|gif|ico|pdf|zip|jar|woff2?)$/i;
58
+ const ALLOW_MARK = /req-kit:allow-secret|gitleaks:allow/;
59
+
60
+ function mask(line: string, secret: string): string {
61
+ const shown = secret.length > 6 ? `${secret.slice(0, 4)}…` : "…";
62
+ const text = line.replace(secret, shown).trim();
63
+ return text.length > 140 ? `${text.slice(0, 139)}…` : text;
64
+ }
65
+
66
+ /** The secret on one added line, if any. */
67
+ export function secretInLine(file: string, line: string): { kind: string; preview: string } | null {
68
+ if (ALLOW_MARK.test(line)) return null;
69
+ for (const { re, kind } of STRONG) {
70
+ const m = re.exec(line);
71
+ if (m) {
72
+ if (kind === "地址里带口令" && placeholder(m[1])) continue;
73
+ return { kind, preview: mask(line, m[1] ?? m[0]) };
74
+ }
75
+ }
76
+ if (TEST_OR_DOC.test(file)) return null;
77
+ const a = ASSIGN.exec(line);
78
+ if (a && !placeholder(a[3])) return { kind: "口令或密钥", preview: mask(line, a[3]) };
79
+ return null;
80
+ }
81
+
82
+ function git(repo: string, args: string[]): string | null {
83
+ try {
84
+ return execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024, windowsHide: true });
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /** Added lines of a patch (git log -p / git diff), with the file, the line in the new file and the commit. */
91
+ export function scanPatch(patch: string, allow: string[] = []): SecretFinding[] {
92
+ const out: SecretFinding[] = [];
93
+ let commit: string | null = null;
94
+ let file = "";
95
+ let line = 0;
96
+ for (const raw of patch.split("\n")) {
97
+ const c = /^commit ([0-9a-f]{7,40})$/.exec(raw) ?? /^([0-9a-f]{40})\0?$/.exec(raw);
98
+ if (c) {
99
+ commit = c[1].slice(0, 8);
100
+ continue;
101
+ }
102
+ if (raw.startsWith("+++ ")) {
103
+ file = raw.slice(4).replace(/^b\//, "").trim();
104
+ continue;
105
+ }
106
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
107
+ if (h) {
108
+ line = Number(h[1]);
109
+ continue;
110
+ }
111
+ if (!file || file === "/dev/null") continue;
112
+ if (raw.startsWith("+")) {
113
+ if (!SKIP_FILE.test(file) && !file.includes("/.pipeline/") && !allow.some((p) => matchesPath(file, p))) {
114
+ const s = secretInLine(file, raw.slice(1));
115
+ if (s) out.push({ file, line, commit, ...s });
116
+ }
117
+ line += 1;
118
+ } else if (raw.startsWith(" ")) line += 1;
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Secrets the requirement added since `base`: in its commits (each commit's added lines) and in
125
+ * changes not committed yet (including new files). Null when it cannot be told (no git, no base).
126
+ */
127
+ export function scanChange(repo: string, base: string | null, settings: Pick<SecretScanSettings, "allow">): SecretFinding[] | null {
128
+ if (!base || git(repo, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`]) === null) return null;
129
+ const history = git(repo, ["log", "-p", "--no-color", "--no-ext-diff", "--format=commit %H", "-U0", `${base}..HEAD`]);
130
+ if (history === null) return null;
131
+ const pending = git(repo, ["diff", "--no-color", "--no-ext-diff", "-U0", "HEAD"]) ?? "";
132
+ const findings = [...scanPatch(history, settings.allow), ...scanPatch(pending, settings.allow)];
133
+ for (const f of (git(repo, ["ls-files", "--others", "--exclude-standard"]) ?? "").split("\n").map((s) => s.trim()).filter(Boolean)) {
134
+ if (SKIP_FILE.test(f) || f.includes("/.pipeline/") || settings.allow.some((p) => matchesPath(f, p))) continue;
135
+ const abs = path.join(repo, f);
136
+ try {
137
+ if (fs.statSync(abs).size > 1024 * 1024) continue;
138
+ const lines = fs.readFileSync(abs, "utf8").split("\n");
139
+ lines.forEach((l, i) => {
140
+ const s = secretInLine(f, l);
141
+ if (s) findings.push({ file: f, line: i + 1, commit: null, ...s });
142
+ });
143
+ } catch {
144
+ /* unreadable: skip */
145
+ }
146
+ }
147
+ // The same line found in a commit and still pending counts once.
148
+ const seen = new Set<string>();
149
+ return findings.filter((f) => {
150
+ const k = `${f.file}:${f.preview}`;
151
+ if (seen.has(k)) return false;
152
+ seen.add(k);
153
+ return true;
154
+ });
155
+ }
156
+
157
+ export function describeFinding(f: SecretFinding): string {
158
+ return `${f.file}:${f.line}(${f.kind}${f.commit ? `,提交 ${f.commit}` : ",还没提交"}):${f.preview}`;
159
+ }
160
+
161
+ /** What the step's agent is told when the scan finds something. */
162
+ export function secretFollowUp(input: { findings: SecretFinding[]; base: string; round: number; max: number }): string {
163
+ const commits = [...new Set(input.findings.map((f) => f.commit).filter((c): c is string => !!c))];
164
+ return [
165
+ `流水线在你这一步结束后检查了本需求新增的内容,发现疑似密钥、令牌或口令(第 ${input.round} 次要求处理,最多 ${input.max} 次):`,
166
+ "",
167
+ ...input.findings.slice(0, 30).map((f) => `- ${describeFinding(f)}`),
168
+ "",
169
+ "请处理:",
170
+ "1. 从文件里去掉这些值,改成从配置或环境变量读取(配置示例里只写变量名或占位符,例如 `<your-token>`)。测试需要的值用明显的假数据。",
171
+ commits.length > 0
172
+ ? `2. 这些值已经写进了本需求的提交(${commits.join("、")})。这些提交还没有推送,要把它们从历史里去掉:改好文件后用 \`git commit --fixup=<那个提交>\` 提交,再运行 \`git -c sequence.editor=: rebase -i --autosquash <那个提交>^\`(不会打开编辑器);只在本需求的分支上做,不要动 ${input.base.slice(0, 8)} 之前的提交。`
173
+ : "2. 这些值还没有提交,改好文件再提交即可。",
174
+ "3. 确实不是密钥的(例如示例里的假值),在那一行末尾加注释 `req-kit:allow-secret`。",
175
+ "4. 在本步骤产物里记一笔:哪些地方、怎么处理的。如果它是真实的密钥,写明\"建议更换这个密钥\",由人来做。",
176
+ ].join("\n");
177
+ }
@@ -46,6 +46,32 @@ export type StopKind =
46
46
  | "budget"
47
47
  | "other";
48
48
 
49
+ /** 1.14: a dangerous command a step agent ran or started (contracts/state.md). */
50
+ export interface DangerEvent {
51
+ at: string;
52
+ /** The step's key, e.g. "implement". */
53
+ key: string;
54
+ stepTitle: string;
55
+ command: string;
56
+ label: string;
57
+ /** Whether it had already finished when it was seen (false: interrupted while running). */
58
+ ran: boolean;
59
+ /** The agent and its tool call, so the same call is reported once. */
60
+ agentId?: string;
61
+ callId?: string;
62
+ }
63
+
64
+ /** 1.14: a secret scan after a step that changed code. */
65
+ export interface SecretScanRun {
66
+ key: string;
67
+ attempt: number;
68
+ at: string;
69
+ findings: import("./secret-scan.js").SecretFinding[];
70
+ /** How many times the agent was asked to remove them. */
71
+ fixRounds: number;
72
+ passed: boolean;
73
+ }
74
+
49
75
  /** Something the pipeline handled by itself (1.12). */
50
76
  export interface Recovery {
51
77
  at: string;
@@ -118,6 +144,10 @@ export interface PipelineState {
118
144
  stopKind?: StopKind | null;
119
145
  /** 1.12: what the pipeline handled by itself instead of stopping (retries, waiting longer, a fallback model…). */
120
146
  recoveries?: Recovery[];
147
+ /** 1.14: dangerous commands step agents ran or started (dangerGuard), newest last; at most 50. */
148
+ dangers?: DangerEvent[];
149
+ /** 1.14: the latest secret scan of each step that changed code (secretScan), for the review and the report. */
150
+ secretScans?: SecretScanRun[];
121
151
  /** 1.12: the questions a run waiting for the requester (waiting_for_business) asks; cleared when it runs again. */
122
152
  pendingQuestions?: string[] | null;
123
153
  outcome: string | null;
@@ -12,7 +12,8 @@
12
12
  import fs from "node:fs";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
- import { type RunRecord, readRuns, runsFile } from "./records.js";
15
+ import { type EventRecord, type RunRecord, readEvents, readRuns, runsFile } from "./records.js";
16
+ import { scanOutcomes } from "./outcomes.js";
16
17
  import { STEPS, type StepId } from "./steps.js";
17
18
 
18
19
  export interface Period {
@@ -91,6 +92,49 @@ export interface RequirementStats {
91
92
  requester: string | null;
92
93
  /** "<system>:<id>" of that card. */
93
94
  card: string | null;
95
+ /** 1.14: what happened after the runs (events): people's code reviews, acceptance, merge, problems after it. */
96
+ afterRun: AfterRun;
97
+ }
98
+
99
+ export interface AfterRun {
100
+ /** Code reviews in the plugin, and how many sent the change back. */
101
+ humanReviews: number;
102
+ sentBack: number;
103
+ /** The latest review: "approved" or "changes_requested"; null without one. */
104
+ lastReview: string | null;
105
+ /** The latest acceptance verdict, and how many acceptances failed. */
106
+ acceptance: string | null;
107
+ acceptanceFailed: number;
108
+ /** When it reached the main line (the merge commit's time, else when it was found); null when not (yet) known. */
109
+ mergedAt: string | null;
110
+ /** Problems found after the merge. */
111
+ issues: number;
112
+ }
113
+
114
+ const eventKey = (e: { requirement: { project: string; key: string } }) => `${e.requirement.project}\0${e.requirement.key}`;
115
+
116
+ /** A requirement's events summed up (all of them, not only the period's). */
117
+ export function afterRunOf(events: EventRecord[]): AfterRun {
118
+ const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at));
119
+ const reviews = sorted.filter((e) => e.kind === "review" && e.review);
120
+ const acceptances = sorted.filter((e) => e.kind === "acceptance" && e.acceptance);
121
+ const merge = sorted.find((e) => e.kind === "merge" && e.merge);
122
+ return {
123
+ humanReviews: reviews.length,
124
+ sentBack: reviews.filter((e) => e.review!.verdict === "changes_requested").length,
125
+ lastReview: reviews.at(-1)?.review?.verdict ?? null,
126
+ acceptance: acceptances.at(-1)?.acceptance?.verdict ?? null,
127
+ acceptanceFailed: acceptances.filter((e) => e.acceptance!.verdict === "不通过").length,
128
+ mergedAt: merge ? (merge.merge!.mergedAt ?? merge.at) : null,
129
+ issues: new Set(sorted.filter((e) => e.kind === "issue").map((e) => e.issue?.id ?? e.at)).size,
130
+ };
131
+ }
132
+
133
+ /** Events seen twice (merged exports) count once. */
134
+ export function dedupeEvents(events: EventRecord[]): EventRecord[] {
135
+ const seen = new Map<string, EventRecord>();
136
+ for (const e of events) seen.set(`${eventKey(e)}\0${e.kind}\0${e.kind === "issue" ? e.issue?.id : e.at}`, e);
137
+ return [...seen.values()];
94
138
  }
95
139
 
96
140
  function add(a: number | null, b: number | null): number | null {
@@ -147,7 +191,9 @@ export function dedupe(runs: RunRecord[]): RunRecord[] {
147
191
  }
148
192
 
149
193
  /** Per requirement, from all of its runs (sorted by start). */
150
- export function byRequirement(runs: RunRecord[]): RequirementStats[] {
194
+ export function byRequirement(runs: RunRecord[], events: EventRecord[] = []): RequirementStats[] {
195
+ const eventsOf = new Map<string, EventRecord[]>();
196
+ for (const e of events) eventsOf.set(eventKey(e), [...(eventsOf.get(eventKey(e)) ?? []), e]);
151
197
  const groups = new Map<string, RunRecord[]>();
152
198
  for (const r of runs) {
153
199
  const key = `${r.requirement.project}\0${r.requirement.key}`;
@@ -198,6 +244,7 @@ export function byRequirement(runs: RunRecord[]): RequirementStats[] {
198
244
  reviewer: last.agents.reviewer,
199
245
  requester: last.requirement.requester ?? null,
200
246
  card: last.requirement.source ? `${last.requirement.source.system}:${last.requirement.source.id}` : null,
247
+ afterRun: afterRunOf(eventsOf.get(`${last.requirement.project}\0${last.requirement.key}`) ?? []),
201
248
  });
202
249
  }
203
250
  return out.sort((a, b) => b.lastEndedAt.localeCompare(a.lastEndedAt));
@@ -253,11 +300,64 @@ export interface StatsSummary {
253
300
  /** recovered / (recovered + interrupted). */
254
301
  autoRecoveryShare: number | null;
255
302
  };
303
+ /** 1.14: people's checks and what happened after the runs, from the period's events. */
304
+ oversight: Oversight;
256
305
  /** Requirements that started from cards, by who asked for them (1.10); empty when there are none. */
257
306
  byRequester: Array<{ requester: string; requirements: number; completed: number; medianCycleHours: number | null }>;
258
307
  requirements: RequirementStats[];
259
308
  }
260
309
 
310
+ export interface Oversight {
311
+ /** Code reviews in the plugin: how many, of how many requirements, sent back, passed at the first review. */
312
+ reviews: { total: number; requirements: number; sentBack: number; passedFirstTime: number; items: number };
313
+ /** Acceptances on the checklist page. */
314
+ acceptance: { total: number; requirements: number; failed: number; passedFirstTime: number };
315
+ /** Requirements that reached the main line, and hours from completion to the merge (median). */
316
+ merged: { requirements: number; medianHoursAfterCompletion: number | null };
317
+ /** Problems found after the merge, and of how many requirements. */
318
+ issues: { total: number; requirements: number };
319
+ }
320
+
321
+ /** The period's events summed up; "first time" looks at each requirement's first event of the kind (any time). */
322
+ export function oversightOf(events: EventRecord[], period: Period, reqs: RequirementStats[]): Oversight {
323
+ const inP = events.filter((e) => inPeriod(e.at, period));
324
+ const of = (kind: EventRecord["kind"]) => inP.filter((e) => e.kind === kind);
325
+ const firsts = (kind: EventRecord["kind"]) => {
326
+ const m = new Map<string, EventRecord>();
327
+ for (const e of [...events].filter((x) => x.kind === kind).sort((a, b) => a.at.localeCompare(b.at))) if (!m.has(eventKey(e))) m.set(eventKey(e), e);
328
+ return [...m.values()].filter((e) => inPeriod(e.at, period));
329
+ };
330
+ const reviews = of("review");
331
+ const acceptances = of("acceptance");
332
+ const merges = of("merge");
333
+ const issues = of("issue");
334
+ const completed = new Map(reqs.map((q) => [`${q.project}\0${q.key}`, q.completedAt ?? q.lastEndedAt]));
335
+ const lags = merges.flatMap((e) => {
336
+ const done = completed.get(eventKey(e));
337
+ const at = e.merge?.mergedAt ?? e.at;
338
+ if (!done) return [];
339
+ const h = (new Date(at).getTime() - new Date(done).getTime()) / 3600_000;
340
+ return Number.isFinite(h) && h >= 0 ? [round1(h)] : [];
341
+ });
342
+ return {
343
+ reviews: {
344
+ total: reviews.length,
345
+ requirements: new Set(reviews.map(eventKey)).size,
346
+ sentBack: reviews.filter((e) => e.review?.verdict === "changes_requested").length,
347
+ passedFirstTime: firsts("review").filter((e) => e.review?.verdict === "approved").length,
348
+ items: reviews.reduce((n, e) => n + (e.review?.items ?? 0), 0),
349
+ },
350
+ acceptance: {
351
+ total: acceptances.length,
352
+ requirements: new Set(acceptances.map(eventKey)).size,
353
+ failed: acceptances.filter((e) => e.acceptance?.verdict === "不通过").length,
354
+ passedFirstTime: firsts("acceptance").filter((e) => e.acceptance?.verdict === "通过").length,
355
+ },
356
+ merged: { requirements: new Set(merges.map(eventKey)).size, medianHoursAfterCompletion: median(lags) },
357
+ issues: { total: new Set(issues.map((e) => e.issue?.id ?? e.at)).size, requirements: new Set(issues.map(eventKey)).size },
358
+ };
359
+ }
360
+
261
361
  export interface TokenBreakdown {
262
362
  /** Input + output of the period's steps that reported usage; null when none did. */
263
363
  total: number | null;
@@ -319,9 +419,10 @@ export function tokenBreakdown(steps: RunRecord["steps"], done: RequirementStats
319
419
  }
320
420
 
321
421
  /** The summary of a period from run records (any machines). */
322
- export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
422
+ export function summarizeRuns(all: RunRecord[], period: Period, allEvents: EventRecord[] = []): StatsSummary {
323
423
  const runs = dedupe(all);
324
- const reqs = byRequirement(runs);
424
+ const events = dedupeEvents(allEvents);
425
+ const reqs = byRequirement(runs, events);
325
426
  const periodRuns = runs.filter((r) => inPeriod(r.run.endedAt, period));
326
427
  const activeKeys = new Set(periodRuns.map((r) => `${r.requirement.project}\0${r.requirement.key}`));
327
428
  const active = reqs.filter((q) => activeKeys.has(`${q.project}\0${q.key}`));
@@ -420,6 +521,7 @@ export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
420
521
  interrupted,
421
522
  autoRecoveryShare: recoveredTotal + interrupted === 0 ? null : Math.round((recoveredTotal / (recoveredTotal + interrupted)) * 100) / 100,
422
523
  },
524
+ oversight: oversightOf(events, period, reqs),
423
525
  byRequester: [...requesters.entries()]
424
526
  .map(([requester, qs]) => {
425
527
  const finished = qs.filter((q) => done.includes(q));
@@ -501,6 +603,18 @@ export function describeStats(s: StatsSummary): string {
501
603
  `- 需要缺陷修复的需求占比:${s.rework.withDefectRounds === null ? "无数据" : `${Math.round(s.rework.withDefectRounds * 100)}%`}`,
502
604
  `- 质量门:运行 ${s.rework.gateRuns} 次,其中发现新失败 ${s.rework.gateFailed} 次`,
503
605
  );
606
+ const o = s.oversight;
607
+ if (o.reviews.total + o.acceptance.total + o.merged.requirements + o.issues.total > 0) {
608
+ lines.push(
609
+ "",
610
+ "## 人工把关和合并后",
611
+ "",
612
+ `- 人工代码评审:${o.reviews.total} 次(${o.reviews.requirements} 个需求),退回修改 ${o.reviews.sentBack} 次、共 ${o.reviews.items} 条意见;第一次评审就通过的需求 ${o.reviews.passedFirstTime} 个`,
613
+ `- 需求方验收:${o.acceptance.total} 次(${o.acceptance.requirements} 个需求),不通过 ${o.acceptance.failed} 次;第一次验收就通过的需求 ${o.acceptance.passedFirstTime} 个`,
614
+ `- 合并进主干:${o.merged.requirements} 个需求${o.merged.medianHoursAfterCompletion === null ? "" : `,完成到合并中位数 ${o.merged.medianHoursAfterCompletion} 小时`}`,
615
+ `- 合并后发现的问题:${o.issues.total} 个(${o.issues.requirements} 个需求)${o.merged.requirements > 0 ? `,按合并的需求算 ${Math.round((o.issues.requirements / o.merged.requirements) * 100)}%` : ""}`,
616
+ );
617
+ }
504
618
  const RECOVERY_LABELS: Record<string, string> = {
505
619
  retry: "临时错误重试",
506
620
  reattach: "接着等还在做的 agent",
@@ -537,16 +651,22 @@ export function describeStats(s: StatsSummary): string {
537
651
  lines.push("", "## 从卡片开始的需求(按需求方)", "", "| 需求方 | 需求数 | 已完成 | 周期中位数 |", "| --- | --- | --- | --- |");
538
652
  for (const r of s.byRequester) lines.push(`| ${r.requester} | ${r.requirements} | ${r.completed} | ${r.medianCycleHours === null ? "-" : `${r.medianCycleHours} 小时`} |`);
539
653
  }
540
- lines.push("", "## 需求", "", "| 项目 | 需求 | 类型 | 状态 | 周期 | agent 用时 | 评审轮数 | 缺陷轮数 |", "| --- | --- | --- | --- | --- | --- | --- | --- |");
654
+ lines.push("", "## 需求", "", "| 项目 | 需求 | 类型 | 状态 | 周期 | agent 用时 | 评审轮数 | 缺陷轮数 | 人工评审 | 验收 | 合并 | 合并后问题 |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |");
541
655
  for (const q of s.requirements) {
542
656
  const status = q.status === "completed" ? (OUTCOME_LABELS[q.outcome ?? "unknown"] ?? "已完成") : (STATUS_LABELS[q.status] ?? q.status);
543
657
  lines.push(
544
- `| ${q.project} | ${q.title.replace(/\|/g, "/")} | ${TYPE_LABELS[q.type ?? "normal"] ?? q.type} | ${status} | ${q.cycleHours === null ? "-" : `${q.cycleHours} 小时`} | ${hours(q.agentMinutes)} | ${q.reviewRounds} | ${q.defectRounds} |`,
658
+ `| ${q.project} | ${q.title.replace(/\|/g, "/")} | ${TYPE_LABELS[q.type ?? "normal"] ?? q.type} | ${status} | ${q.cycleHours === null ? "-" : `${q.cycleHours} 小时`} | ${hours(q.agentMinutes)} | ${q.reviewRounds} | ${q.defectRounds} | ${reviewText(q.afterRun)} | ${q.afterRun.acceptance ?? "-"} | ${q.afterRun.mergedAt ? q.afterRun.mergedAt.slice(0, 10) : "-"} | ${q.afterRun.issues || "-"} |`,
545
659
  );
546
660
  }
547
661
  return lines.join("\n");
548
662
  }
549
663
 
664
+ function reviewText(a: AfterRun): string {
665
+ if (a.humanReviews === 0) return "-";
666
+ const last = a.lastReview === "approved" ? "通过" : "退回";
667
+ return a.sentBack > 0 ? `${last}(退回 ${a.sentBack} 次)` : last;
668
+ }
669
+
550
670
  const CSV_COLUMNS: Array<[string, (q: RequirementStats) => unknown]> = [
551
671
  ["project", (q) => q.project],
552
672
  ["name", (q) => q.name],
@@ -575,6 +695,13 @@ const CSV_COLUMNS: Array<[string, (q: RequirementStats) => unknown]> = [
575
695
  ["reviewer", (q) => q.reviewer ?? ""],
576
696
  ["requester", (q) => q.requester ?? ""],
577
697
  ["card", (q) => q.card ?? ""],
698
+ ["human_reviews", (q) => q.afterRun.humanReviews],
699
+ ["human_sent_back", (q) => q.afterRun.sentBack],
700
+ ["last_human_review", (q) => q.afterRun.lastReview ?? ""],
701
+ ["acceptance", (q) => q.afterRun.acceptance ?? ""],
702
+ ["acceptance_failed", (q) => q.afterRun.acceptanceFailed],
703
+ ["merged_at", (q) => q.afterRun.mergedAt ?? ""],
704
+ ["issues_after_merge", (q) => q.afterRun.issues],
578
705
  ];
579
706
 
580
707
  function csvCell(v: unknown): string {
@@ -640,17 +767,24 @@ export function statsMain(argv: string[], env: NodeJS.ProcessEnv = process.env,
640
767
  const sources = a.merge.length > 0 ? a.merge.map((f) => path.resolve(cwd, f)) : [runsFile(env)];
641
768
  // This machine may have no records yet (nothing ran since 1.9): an empty summary. Named files must exist.
642
769
  if (a.merge.length > 0) for (const f of sources) if (!fs.existsSync(f)) throw new Error(`没有这个文件:${f}`);
770
+ // 1.14: merges and post-merge problems this machine can see, before reading the records.
771
+ if (a.merge.length === 0) scanOutcomes(env);
643
772
  const runs = sources.flatMap((f) => readRuns(f));
773
+ const events = sources.flatMap((f) => readEvents(f));
644
774
 
645
775
  if (a.export) {
646
776
  const picked = dedupe(runs).filter((r) => inPeriod(r.run.endedAt, period));
777
+ // Events of the requirements exported (any time, so first reviews count right), and the period's others.
778
+ const keys = new Set(picked.map((r) => `${r.requirement.project}\0${r.requirement.key}`));
779
+ const pickedEvents = dedupeEvents(events).filter((e) => keys.has(`${e.requirement.project}\0${e.requirement.key}`) || inPeriod(e.at, period));
647
780
  const file = path.resolve(cwd, a.export === true ? `req-kit-usage-${os.hostname()}-${stamp()}.jsonl` : a.export);
648
- fs.writeFileSync(file, picked.map((r) => JSON.stringify(r)).join("\n") + (picked.length ? "\n" : ""));
649
- console.log(`已导出 ${picked.length} 条运行记录(${period.label}):${file}\n汇总多台机器:req stats --merge <各机器导出的文件> --report`);
781
+ const lines = [...picked, ...pickedEvents].map((r) => JSON.stringify(r));
782
+ fs.writeFileSync(file, lines.join("\n") + (lines.length ? "\n" : ""));
783
+ console.log(`已导出 ${picked.length} 条运行记录和 ${pickedEvents.length} 条评审、验收、合并记录(${period.label}):${file}\n汇总多台机器:req stats --merge <各机器导出的文件> --report`);
650
784
  return 0;
651
785
  }
652
786
 
653
- const summary = summarizeRuns(runs, period);
787
+ const summary = summarizeRuns(runs, period, events);
654
788
  if (a.report) {
655
789
  const dir = path.resolve(cwd, a.out ?? ".");
656
790
  fs.mkdirSync(dir, { recursive: true });
@@ -7,6 +7,8 @@
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { kitHome } from "./locations.js";
10
+ import { developedIn } from "./features.js";
11
+ import { isSpecConfirmed } from "./preflight.js";
10
12
  import { readIndex } from "./records.js";
11
13
  import { isAlive, readState, statePath, writeState } from "./state.js";
12
14
 
@@ -25,6 +27,8 @@ interface Memory {
25
27
  by?: string;
26
28
  /** Per requirement: the dead process already handled, and when continuations happened. */
27
29
  handled?: Record<string, { pid: number; times: string[] }>;
30
+ /** 1.14: requirements whose chat was reminded that a confirmed spec has not started (once each). */
31
+ reminded?: Record<string, string>;
28
32
  }
29
33
 
30
34
  function readMemory(env: NodeJS.ProcessEnv): Memory {
@@ -57,21 +61,51 @@ export interface WatchdogResult {
57
61
  resumed: Array<{ featureDir: string; repo: string; message: string }>;
58
62
  failed: Array<{ featureDir: string; repo: string; message: string }>;
59
63
  skipped: Array<{ featureDir: string; reason: string }>;
64
+ /** 1.14: chats reminded that their confirmed requirement has not started. */
65
+ reminded: Array<{ featureDir: string; repo: string; ok: boolean; message: string }>;
66
+ }
67
+
68
+ /** 1.14: a confirmed spec with no run gets a reminder in its chat after this long, once. */
69
+ export const REMIND_NOT_STARTED_MS = 30 * 60_000;
70
+ /** Older confirmations are not reminded (e.g. right after an upgrade). */
71
+ const REMIND_WITHIN_MS = 3 * 24 * 3600_000;
72
+
73
+ /** A requirement confirmed a while ago with no run and nothing developed: since when (the spec's last change). */
74
+ function confirmedNotStarted(repo: string, featureDir: string): number | null {
75
+ const dir = path.join(repo, featureDir);
76
+ const spec = path.join(dir, "spec.md");
77
+ try {
78
+ if (!isSpecConfirmed(fs.readFileSync(spec, "utf8")) || developedIn(dir)) return null;
79
+ return fs.statSync(spec).mtimeMs;
80
+ } catch {
81
+ return null;
82
+ }
60
83
  }
61
84
 
62
85
  export type Resume = (repo: string, featureDir: string) => { ok: boolean; message: string };
63
86
 
64
- export function watchdogPass(input: { env?: NodeJS.ProcessEnv; by: "plugin" | "factory" | "manual"; resume: Resume; now?: Date; dryRun?: boolean }): WatchdogResult {
87
+ export function watchdogPass(input: { env?: NodeJS.ProcessEnv; by: "plugin" | "factory" | "manual"; resume: Resume; remind?: Resume; now?: Date; dryRun?: boolean }): WatchdogResult {
65
88
  const env = input.env ?? process.env;
66
89
  const now = input.now ?? new Date();
67
90
  const memory = readMemory(env);
68
91
  const handled = memory.handled ?? {};
69
- const result: WatchdogResult = { checked: 0, resumed: [], failed: [], skipped: [] };
92
+ const reminded = memory.reminded ?? {};
93
+ const result: WatchdogResult = { checked: 0, resumed: [], failed: [], skipped: [], reminded: [] };
70
94
  for (const entry of readIndex(env)) {
71
95
  if (!entry.present) continue;
72
96
  const file = statePath(entry.repo, entry.featureDir);
73
97
  const state = readState(file);
74
- if (!state) continue;
98
+ if (!state) {
99
+ // 1.14: confirmed, never started: remind the chat it was aligned in, once.
100
+ const since = input.remind && entry.chatAgentId && !reminded[entry.key] ? confirmedNotStarted(entry.repo, entry.featureDir) : null;
101
+ const age = since === null ? -1 : now.getTime() - since;
102
+ if (since !== null && age >= REMIND_NOT_STARTED_MS && age < REMIND_WITHIN_MS && !input.dryRun) {
103
+ const r = input.remind!(entry.repo, entry.featureDir);
104
+ reminded[entry.key] = now.toISOString();
105
+ result.reminded.push({ featureDir: entry.featureDir, repo: entry.repo, ok: r.ok, message: r.message.split("\n").find((l) => l.trim()) ?? "" });
106
+ }
107
+ continue;
108
+ }
75
109
  result.checked++;
76
110
  if (state.status !== "running" || isAlive(state.pid)) continue;
77
111
  if (now.getTime() - new Date(state.updatedAt).getTime() < SETTLE_MS) continue;
@@ -100,6 +134,6 @@ export function watchdogPass(input: { env?: NodeJS.ProcessEnv; by: "plugin" | "f
100
134
  handled[entry.key] = { pid: state.pid, times: [...recent, now.toISOString()] };
101
135
  (r.ok ? result.resumed : result.failed).push({ featureDir: entry.featureDir, repo: entry.repo, message: r.message.split("\n").find((l) => l.trim()) ?? "" });
102
136
  }
103
- writeMemory(env, { at: now.toISOString(), by: input.by === "manual" ? (memory.by ?? "manual") : input.by, handled });
137
+ writeMemory(env, { at: now.toISOString(), by: input.by === "manual" ? (memory.by ?? "manual") : input.by, handled, reminded });
104
138
  return result;
105
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-req-kit",
3
- "version": "1.14.0-beta.4",
3
+ "version": "1.14.0",
4
4
  "description": "req-kit: a requirement-development workflow for Paseo. /req aligns a requirement in chat; the engine runs design, implementation, review and verification as Paseo agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -91,6 +91,21 @@ export function StatsScreen({ theme, layout }: PluginScreenProps) {
91
91
  {card("agent 合计用时", minutes(d.usage.agentMinutes), `输入 ${tokens(d.usage.inputTokens)},输出 ${tokens(d.usage.outputTokens)} tokens`)}
92
92
  {d.usage.costUsd !== null ? card("费用", `$${d.usage.costUsd.toFixed(2)}`) : null}
93
93
  </View>
94
+ {d.oversight && d.oversight.reviews.total + d.oversight.acceptance.total + d.oversight.merged.requirements + d.oversight.issues.total > 0 ? (
95
+ <View style={s.card}>
96
+ <Text style={s.heading}>人工把关和合并后</Text>
97
+ <Text style={s.text}>
98
+ 人工代码评审 {d.oversight.reviews.total} 次({d.oversight.reviews.requirements} 个需求),退回修改 {d.oversight.reviews.sentBack} 次;第一次就通过 {d.oversight.reviews.passedFirstTime} 个
99
+ </Text>
100
+ <Text style={s.text}>
101
+ 需求方验收 {d.oversight.acceptance.total} 次,不通过 {d.oversight.acceptance.failed} 次;第一次就通过 {d.oversight.acceptance.passedFirstTime} 个
102
+ </Text>
103
+ <Text style={s.text}>
104
+ 合并进主干 {d.oversight.merged.requirements} 个需求
105
+ {d.oversight.merged.medianHoursAfterCompletion === null ? "" : `,完成到合并中位数 ${d.oversight.merged.medianHoursAfterCompletion} 小时`};合并后发现问题 {d.oversight.issues.total} 个
106
+ </Text>
107
+ </View>
108
+ ) : null}
94
109
  {d.tokens?.total ? <TokenShare theme={theme} compact={layout.compact} t={d.tokens} /> : null}
95
110
  {d.waits.length > 0 ? (
96
111
  <View style={s.card}>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "req-kit-paseo-plugin",
3
- "version": "1.14.0-beta.4",
3
+ "version": "1.14.0",
4
4
  "private": true,
5
5
  "description": "The req-kit plugin for Paseo. Ships inside the paseo-req-kit package; installed with req plugin install.",
6
6
  "type": "module",
@@ -130,6 +130,13 @@ export interface StatsSummary {
130
130
  rework: { tokens: number; share: number } | null;
131
131
  perRequirement: { median: number | null; byProfile: Array<{ profile: string; requirements: number; median: number | null }> };
132
132
  };
133
+ /** req-kit 1.14+: people's code reviews, acceptance, merges and problems after them. */
134
+ oversight?: {
135
+ reviews: { total: number; requirements: number; sentBack: number; passedFirstTime: number; items: number };
136
+ acceptance: { total: number; requirements: number; failed: number; passedFirstTime: number };
137
+ merged: { requirements: number; medianHoursAfterCompletion: number | null };
138
+ issues: { total: number; requirements: number };
139
+ };
133
140
  }
134
141
 
135
142
  export interface KitInfo {