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.
- package/CHANGELOG.md +39 -0
- package/bin/req.mjs +6 -0
- package/contracts/notifier-event.md +1 -1
- package/contracts/spec.md +1 -0
- package/contracts/state.md +3 -1
- package/contracts/usage-record.md +16 -2
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +3 -1
- 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
- package/engine/prompts/00-shared-context.md +3 -2
- package/engine/src/activity.ts +21 -0
- package/engine/src/api.ts +30 -5
- package/engine/src/code-review.ts +3 -0
- package/engine/src/config-view.ts +10 -0
- package/engine/src/config.ts +27 -0
- package/engine/src/danger-guard.ts +180 -0
- package/engine/src/features.ts +1 -1
- package/engine/src/messages.ts +29 -0
- package/engine/src/outcomes.ts +145 -0
- package/engine/src/paseo-runtime.ts +9 -1
- package/engine/src/pipeline.ts +135 -6
- package/engine/src/records.ts +71 -0
- package/engine/src/reply.ts +30 -11
- package/engine/src/review.ts +15 -1
- package/engine/src/runtime.ts +2 -0
- package/engine/src/secret-scan.ts +177 -0
- package/engine/src/state.ts +30 -0
- package/engine/src/stats.ts +143 -9
- package/engine/src/watchdog.ts +38 -4
- package/package.json +1 -1
- package/paseo-plugin/client/stats.tsx +15 -0
- package/paseo-plugin/package.json +1 -1
- package/paseo-plugin/shared/contracts.ts +7 -0
- package/site-dist/404.html +2 -2
- package/site-dist/assets/{app.Cp7WwPWE.js → app.VQ-QxRPH.js} +1 -1
- package/site-dist/assets/changelog.md.CIKYJXpF.js +1 -0
- package/site-dist/assets/{changelog.md.Zi-_3Rjh.lean.js → changelog.md.CIKYJXpF.lean.js} +1 -1
- package/site-dist/assets/chunks/@localSearchIndexroot.h7oQE_np.js +1 -0
- package/site-dist/assets/chunks/{VPLocalSearchBox.CMTXQK-Q.js → VPLocalSearchBox.CT4Vlj76.js} +1 -1
- package/site-dist/assets/chunks/{theme.DvVUfKr3.js → theme.B_UmHHou.js} +2 -2
- package/site-dist/assets/{guide_getting-started.md.D8XlIQOg.js → guide_getting-started.md.CZM53rO_.js} +1 -1
- package/site-dist/assets/{guide_project.md.Csi5wiyt.js → guide_project.md.DIjDRoAq.js} +1 -1
- package/site-dist/assets/{guide_project.md.Csi5wiyt.lean.js → guide_project.md.DIjDRoAq.lean.js} +1 -1
- package/site-dist/assets/{guide_user.md.B4N9ywmr.js → guide_user.md.UV0WxdJa.js} +1 -1
- package/site-dist/assets/{guide_user.md.B4N9ywmr.lean.js → guide_user.md.UV0WxdJa.lean.js} +1 -1
- package/site-dist/assets/{reference_contracts_notifier-event.md.CCfrkm_A.js → reference_contracts_notifier-event.md.B9WqeZdO.js} +1 -1
- package/site-dist/assets/{reference_contracts_notifier-event.md.CCfrkm_A.lean.js → reference_contracts_notifier-event.md.B9WqeZdO.lean.js} +1 -1
- package/site-dist/assets/{reference_contracts_spec.md.BwwS0L_j.js → reference_contracts_spec.md.Dh5XczZL.js} +2 -2
- package/site-dist/assets/{reference_contracts_spec.md.BwwS0L_j.lean.js → reference_contracts_spec.md.Dh5XczZL.lean.js} +2 -2
- package/site-dist/assets/reference_contracts_state.md.STlqkPPS.js +1 -0
- package/site-dist/assets/{reference_contracts_usage-record.md.D5krNLW7.lean.js → reference_contracts_usage-record.md.MjVps4CA.js} +2 -2
- package/site-dist/assets/{reference_contracts_usage-record.md.D5krNLW7.js → reference_contracts_usage-record.md.MjVps4CA.lean.js} +2 -2
- package/site-dist/changelog.html +5 -5
- package/site-dist/guide/getting-started.html +5 -5
- package/site-dist/guide/how-it-works.html +4 -4
- package/site-dist/guide/index.html +4 -4
- package/site-dist/guide/install.html +4 -4
- package/site-dist/guide/maintainer.html +4 -4
- package/site-dist/guide/phases.html +4 -4
- package/site-dist/guide/project.html +6 -6
- package/site-dist/guide/user.html +5 -5
- package/site-dist/hashmap.json +1 -1
- package/site-dist/index.html +4 -4
- package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
- package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
- package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
- package/site-dist/reference/adr/0004-result-files.html +4 -4
- package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
- package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
- package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
- package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
- package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
- package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
- package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
- package/site-dist/reference/adr/0012-review-panel.html +4 -4
- package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
- package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
- package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
- package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
- package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
- package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
- package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
- package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
- package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
- package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
- package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
- package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
- package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
- package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
- package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
- package/site-dist/reference/adr/0028-external-integrations.html +4 -4
- package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
- package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
- package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
- package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
- package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +4 -4
- package/site-dist/reference/adr/index.html +4 -4
- package/site-dist/reference/commands.html +4 -4
- package/site-dist/reference/config.html +4 -4
- package/site-dist/reference/contracts/cli-json.html +4 -4
- package/site-dist/reference/contracts/feature-files.html +4 -4
- package/site-dist/reference/contracts/index.html +4 -4
- package/site-dist/reference/contracts/notifier-event.html +6 -6
- package/site-dist/reference/contracts/result-file.html +4 -4
- package/site-dist/reference/contracts/spec.html +6 -6
- package/site-dist/reference/contracts/state.html +5 -5
- package/site-dist/reference/contracts/usage-record.html +6 -6
- package/site-dist/reference/contracts/verify.html +4 -4
- package/site-dist/reference/scope.html +4 -4
- package/skills/req/SKILL.md +1 -0
- package/skills/req/playbooks/incident.md +1 -1
- package/skills/spec-writing/SKILL.md +2 -1
- package/site-dist/assets/changelog.md.Zi-_3Rjh.js +0 -1
- package/site-dist/assets/chunks/@localSearchIndexroot.COxp5Rrc.js +0 -1
- package/site-dist/assets/reference_contracts_state.md.EEBd_l2P.js +0 -1
- /package/site-dist/assets/{guide_getting-started.md.D8XlIQOg.lean.js → guide_getting-started.md.CZM53rO_.lean.js} +0 -0
- /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
|
+
}
|
package/engine/src/state.ts
CHANGED
|
@@ -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;
|
package/engine/src/stats.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
649
|
-
|
|
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 });
|
package/engine/src/watchdog.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
|
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}>
|
|
@@ -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 {
|