paseo-req-kit 1.8.0 → 1.9.0-beta.2
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 +38 -0
- package/README.md +2 -0
- package/bin/req-plugin.mjs +226 -0
- package/bin/req.mjs +27 -0
- package/contracts/README.md +2 -0
- package/contracts/cli-json.md +66 -0
- package/contracts/usage-record.md +48 -0
- package/docs/adr/0026-machine-records-and-api.md +23 -0
- package/docs/adr/0027-paseo-plugin.md +23 -0
- package/docs/adr/README.md +2 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +26 -0
- package/docs/guide//347/273/264/346/212/244/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 +10 -0
- package/engine/src/api.ts +397 -0
- package/engine/src/control.ts +2 -2
- package/engine/src/eval.ts +2 -0
- package/engine/src/index-cmd.ts +33 -0
- package/engine/src/main.ts +26 -0
- package/engine/src/records.ts +271 -0
- package/engine/src/state.ts +3 -0
- package/engine/src/stats.ts +479 -0
- package/install.mjs +3 -0
- package/package.json +3 -2
- package/paseo-plugin/OVERVIEW.md +7 -0
- package/paseo-plugin/client/data.ts +55 -0
- package/paseo-plugin/client/detail.tsx +277 -0
- package/paseo-plugin/client/format.ts +101 -0
- package/paseo-plugin/client/live.ts +161 -0
- package/paseo-plugin/client/overview.tsx +89 -0
- package/paseo-plugin/client/panel.tsx +56 -0
- package/paseo-plugin/client/row.tsx +40 -0
- package/paseo-plugin/client/settings.tsx +90 -0
- package/paseo-plugin/client/sidebar.tsx +27 -0
- package/paseo-plugin/client/stats.tsx +80 -0
- package/paseo-plugin/client/ui.tsx +111 -0
- package/paseo-plugin/index.client.tsx +52 -0
- package/paseo-plugin/index.server.ts +41 -0
- package/paseo-plugin/package.json +22 -0
- package/paseo-plugin/paseo-plugin.json +6 -0
- package/paseo-plugin/server/handlers.ts +45 -0
- package/paseo-plugin/server/req.ts +100 -0
- package/paseo-plugin/shared/contracts.ts +165 -0
- package/paseo-plugin/tsconfig.json +16 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
// `req stats`: what the usage records say about how requirements went (1.9, ADR 0026).
|
|
2
|
+
//
|
|
3
|
+
// req stats [--since 30d|2026-10-01] [--month 2026-10] summary (Markdown)
|
|
4
|
+
// [--json] [--csv] the same as JSON / one CSV row per requirement
|
|
5
|
+
// [--report [--out <dir>]] report files for management: .md and .csv
|
|
6
|
+
// [--export [<file>]] this machine's records for the period, to merge elsewhere
|
|
7
|
+
// [--merge <file>...] report on exported files (several machines) instead
|
|
8
|
+
//
|
|
9
|
+
// Usage of a period counts the runs that ended in it. A requirement's cycle (first start to
|
|
10
|
+
// completion) uses all of its runs, so a requirement finished this month counts its whole time.
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { type RunRecord, readRuns, runsFile } from "./records.js";
|
|
16
|
+
import { STEPS, type StepId } from "./steps.js";
|
|
17
|
+
|
|
18
|
+
export interface Period {
|
|
19
|
+
from: Date | null;
|
|
20
|
+
to: Date | null;
|
|
21
|
+
label: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DAY = 24 * 3600_000;
|
|
25
|
+
|
|
26
|
+
/** --since 30d / 2w / 2026-10-01, --month 2026-10. Default: the last 30 days. */
|
|
27
|
+
export function parsePeriod(input: { since?: string | null; month?: string | null }, now = new Date()): Period {
|
|
28
|
+
if (input.month) {
|
|
29
|
+
const m = /^(\d{4})-(\d{1,2})$/.exec(input.month.trim());
|
|
30
|
+
if (!m) throw new Error(`看不懂月份"${input.month}",请写成 2026-10`);
|
|
31
|
+
const from = new Date(Number(m[1]), Number(m[2]) - 1, 1);
|
|
32
|
+
const to = new Date(Number(m[1]), Number(m[2]), 1);
|
|
33
|
+
return { from, to, label: `${m[1]} 年 ${Number(m[2])} 月` };
|
|
34
|
+
}
|
|
35
|
+
const since = (input.since ?? "30d").trim();
|
|
36
|
+
const rel = /^(\d+)\s*([dw])$/i.exec(since);
|
|
37
|
+
if (rel) {
|
|
38
|
+
const days = Number(rel[1]) * (rel[2].toLowerCase() === "w" ? 7 : 1);
|
|
39
|
+
return { from: new Date(now.getTime() - days * DAY), to: null, label: `最近 ${days} 天` };
|
|
40
|
+
}
|
|
41
|
+
if (since === "all") return { from: null, to: null, label: "全部记录" };
|
|
42
|
+
const at = Date.parse(since);
|
|
43
|
+
if (Number.isNaN(at)) throw new Error(`看不懂"${since}",可以写 30d、2w、2026-10-01 或 all`);
|
|
44
|
+
return { from: new Date(at), to: null, label: `${since} 以来` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function inPeriod(iso: string, p: Period): boolean {
|
|
48
|
+
const t = new Date(iso).getTime();
|
|
49
|
+
return (!p.from || t >= p.from.getTime()) && (!p.to || t < p.to.getTime());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type WaitKind = "approval" | "business" | "verify" | "interrupted" | "other";
|
|
53
|
+
|
|
54
|
+
export const WAIT_LABELS: Record<WaitKind, string> = {
|
|
55
|
+
approval: "等待确认方案",
|
|
56
|
+
business: "等待需求方决定",
|
|
57
|
+
verify: "研发完成到开始测试验证",
|
|
58
|
+
interrupted: "中断到继续",
|
|
59
|
+
other: "其他间隔",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface RequirementStats {
|
|
63
|
+
key: string;
|
|
64
|
+
project: string;
|
|
65
|
+
name: string;
|
|
66
|
+
title: string;
|
|
67
|
+
type: string | null;
|
|
68
|
+
profile: string;
|
|
69
|
+
machines: string[];
|
|
70
|
+
runs: number;
|
|
71
|
+
firstStartedAt: string;
|
|
72
|
+
lastEndedAt: string;
|
|
73
|
+
/** State after the last run. */
|
|
74
|
+
status: string;
|
|
75
|
+
outcome: string | null;
|
|
76
|
+
completedAt: string | null;
|
|
77
|
+
/** First start to completion, hours; null until completed. */
|
|
78
|
+
cycleHours: number | null;
|
|
79
|
+
agentMinutes: number;
|
|
80
|
+
inputTokens: number | null;
|
|
81
|
+
outputTokens: number | null;
|
|
82
|
+
costUsd: number | null;
|
|
83
|
+
reviewRounds: number;
|
|
84
|
+
defectRounds: number;
|
|
85
|
+
gateRuns: number;
|
|
86
|
+
gateFailed: number;
|
|
87
|
+
waits: Record<WaitKind, number>;
|
|
88
|
+
builder: string | null;
|
|
89
|
+
reviewer: string | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function add(a: number | null, b: number | null): number | null {
|
|
93
|
+
return b === null ? a : (a ?? 0) + b;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function round1(n: number): number {
|
|
97
|
+
return Math.round(n * 10) / 10;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function waitKind(status: string): WaitKind {
|
|
101
|
+
if (status === "waiting_for_approval") return "approval";
|
|
102
|
+
if (status === "waiting_for_business") return "business";
|
|
103
|
+
if (status === "phase_done") return "verify";
|
|
104
|
+
if (status === "failed" || status === "running") return "interrupted";
|
|
105
|
+
return "other";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Records of the same run seen twice (merged exports) count once. */
|
|
109
|
+
export function dedupe(runs: RunRecord[]): RunRecord[] {
|
|
110
|
+
const seen = new Map<string, RunRecord>();
|
|
111
|
+
for (const r of runs) seen.set(`${r.machine}\0${r.requirement.key}\0${r.run.startedAt}`, r);
|
|
112
|
+
return [...seen.values()];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Per requirement, from all of its runs (sorted by start). */
|
|
116
|
+
export function byRequirement(runs: RunRecord[]): RequirementStats[] {
|
|
117
|
+
const groups = new Map<string, RunRecord[]>();
|
|
118
|
+
for (const r of runs) {
|
|
119
|
+
const key = `${r.requirement.project}\0${r.requirement.key}`;
|
|
120
|
+
groups.set(key, [...(groups.get(key) ?? []), r]);
|
|
121
|
+
}
|
|
122
|
+
const out: RequirementStats[] = [];
|
|
123
|
+
for (const list of groups.values()) {
|
|
124
|
+
list.sort((a, b) => a.run.startedAt.localeCompare(b.run.startedAt));
|
|
125
|
+
const last = list.at(-1)!;
|
|
126
|
+
const steps = list.flatMap((r) => r.steps);
|
|
127
|
+
const completed = [...list].reverse().find((r) => r.run.status === "completed");
|
|
128
|
+
const waits: Record<WaitKind, number> = { approval: 0, business: 0, verify: 0, interrupted: 0, other: 0 };
|
|
129
|
+
for (let i = 1; i < list.length; i++) {
|
|
130
|
+
const prev = list.at(i - 1)!;
|
|
131
|
+
const gap = (new Date(list.at(i)!.run.startedAt).getTime() - new Date(prev.run.endedAt).getTime()) / 60_000;
|
|
132
|
+
if (gap > 0) waits[waitKind(prev.run.status)] += round1(gap);
|
|
133
|
+
}
|
|
134
|
+
// Review and defect rounds: distinct (attempt, round) of those steps; a review panel's members count once.
|
|
135
|
+
const rounds = (id: string) => new Set(steps.filter((s) => s.stepId === id).map((s) => `${s.attempt}/${s.round ?? 0}/${s.key.split("@")[1] ?? ""}`)).size;
|
|
136
|
+
out.push({
|
|
137
|
+
key: last.requirement.key,
|
|
138
|
+
project: last.requirement.project,
|
|
139
|
+
name: last.requirement.name,
|
|
140
|
+
title: last.requirement.title,
|
|
141
|
+
type: last.requirement.type,
|
|
142
|
+
profile: last.requirement.profile,
|
|
143
|
+
machines: [...new Set(list.map((r) => r.machine))],
|
|
144
|
+
runs: list.length,
|
|
145
|
+
firstStartedAt: list[0].run.startedAt,
|
|
146
|
+
lastEndedAt: last.run.endedAt,
|
|
147
|
+
status: last.run.status,
|
|
148
|
+
outcome: last.run.status === "completed" ? last.run.outcome : null,
|
|
149
|
+
completedAt: last.run.status === "completed" && completed ? completed.run.endedAt : null,
|
|
150
|
+
cycleHours:
|
|
151
|
+
last.run.status === "completed" && completed
|
|
152
|
+
? round1((new Date(completed.run.endedAt).getTime() - new Date(list[0].run.startedAt).getTime()) / 3600_000)
|
|
153
|
+
: null,
|
|
154
|
+
agentMinutes: round1(steps.reduce((sum, s) => sum + s.minutes, 0)),
|
|
155
|
+
inputTokens: steps.reduce<number | null>((sum, s) => add(sum, s.inputTokens), null),
|
|
156
|
+
outputTokens: steps.reduce<number | null>((sum, s) => add(sum, s.outputTokens), null),
|
|
157
|
+
costUsd: steps.reduce<number | null>((sum, s) => add(sum, s.costUsd), null),
|
|
158
|
+
reviewRounds: rounds("code_review"),
|
|
159
|
+
defectRounds: rounds("defect_fix"),
|
|
160
|
+
gateRuns: list.reduce((sum, r) => sum + r.gate.runs, 0),
|
|
161
|
+
gateFailed: list.reduce((sum, r) => sum + r.gate.failed, 0),
|
|
162
|
+
waits,
|
|
163
|
+
builder: last.agents.builder,
|
|
164
|
+
reviewer: last.agents.reviewer,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return out.sort((a, b) => b.lastEndedAt.localeCompare(a.lastEndedAt));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function median(values: number[]): number | null {
|
|
171
|
+
if (values.length === 0) return null;
|
|
172
|
+
const s = [...values].sort((a, b) => a - b);
|
|
173
|
+
const mid = Math.floor(s.length / 2);
|
|
174
|
+
return s.length % 2 ? s.at(mid)! : round1((s.at(mid - 1)! + s.at(mid)!) / 2);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function avg(values: number[]): number | null {
|
|
178
|
+
return values.length === 0 ? null : round1(values.reduce((a, b) => a + b, 0) / values.length);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface StatsSummary {
|
|
182
|
+
schema: 1;
|
|
183
|
+
period: { from: string | null; to: string | null; label: string };
|
|
184
|
+
machines: string[];
|
|
185
|
+
/** Requirements with a run that ended in the period. */
|
|
186
|
+
active: number;
|
|
187
|
+
/** Completed in the period, by the report's verdict. */
|
|
188
|
+
completed: { total: number; byOutcome: Record<string, number> };
|
|
189
|
+
byType: Record<string, number>;
|
|
190
|
+
/** Hours from first start to completion, of those completed in the period. */
|
|
191
|
+
cycleHours: { median: number | null; average: number | null };
|
|
192
|
+
steps: Array<{ stepId: string; title: string; count: number; averageMinutes: number; totalMinutes: number }>;
|
|
193
|
+
rework: {
|
|
194
|
+
/** Code review rounds per requirement completed in the period. */
|
|
195
|
+
averageReviewRounds: number | null;
|
|
196
|
+
/** Share of completed requirements that needed at least one defect-fix round. */
|
|
197
|
+
withDefectRounds: number | null;
|
|
198
|
+
gateRuns: number;
|
|
199
|
+
gateFailed: number;
|
|
200
|
+
};
|
|
201
|
+
/** Average minutes per requirement that waited, by kind, in the period's runs. */
|
|
202
|
+
waits: Array<{ kind: WaitKind; label: string; requirements: number; averageMinutes: number }>;
|
|
203
|
+
usage: { agentMinutes: number; inputTokens: number | null; outputTokens: number | null; costUsd: number | null };
|
|
204
|
+
byBuilder: Array<{ builder: string; requirements: number; averageReviewRounds: number | null; averageCostUsd: number | null }>;
|
|
205
|
+
requirements: RequirementStats[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The summary of a period from run records (any machines). */
|
|
209
|
+
export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
|
|
210
|
+
const runs = dedupe(all);
|
|
211
|
+
const reqs = byRequirement(runs);
|
|
212
|
+
const periodRuns = runs.filter((r) => inPeriod(r.run.endedAt, period));
|
|
213
|
+
const activeKeys = new Set(periodRuns.map((r) => `${r.requirement.project}\0${r.requirement.key}`));
|
|
214
|
+
const active = reqs.filter((q) => activeKeys.has(`${q.project}\0${q.key}`));
|
|
215
|
+
const done = active.filter((q) => q.completedAt && inPeriod(q.completedAt, period));
|
|
216
|
+
const count = <T extends string>(values: T[]) => values.reduce<Record<string, number>>((m, v) => ({ ...m, [v]: (m[v] ?? 0) + 1 }), {});
|
|
217
|
+
|
|
218
|
+
const steps = periodRuns.flatMap((r) => r.steps);
|
|
219
|
+
const byStep = new Map<string, number[]>();
|
|
220
|
+
for (const s of steps) byStep.set(s.stepId, [...(byStep.get(s.stepId) ?? []), s.minutes]);
|
|
221
|
+
const order = Object.keys(STEPS);
|
|
222
|
+
const stepRows = [...byStep.entries()]
|
|
223
|
+
.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
|
|
224
|
+
.map(([stepId, minutes]) => ({
|
|
225
|
+
stepId,
|
|
226
|
+
title: STEPS[stepId as StepId]?.title ?? stepId,
|
|
227
|
+
count: minutes.length,
|
|
228
|
+
averageMinutes: avg(minutes) ?? 0,
|
|
229
|
+
totalMinutes: round1(minutes.reduce((a, b) => a + b, 0)),
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
// Waits of the period: gaps between consecutive runs where the later run started in it.
|
|
233
|
+
const waitTotals = new Map<WaitKind, number[]>();
|
|
234
|
+
for (const q of active) {
|
|
235
|
+
const list = runs.filter((r) => r.requirement.key === q.key && r.requirement.project === q.project).sort((a, b) => a.run.startedAt.localeCompare(b.run.startedAt));
|
|
236
|
+
const per: Partial<Record<WaitKind, number>> = {};
|
|
237
|
+
for (let i = 1; i < list.length; i++) {
|
|
238
|
+
if (!inPeriod(list.at(i)!.run.startedAt, period)) continue;
|
|
239
|
+
const prev = list.at(i - 1)!;
|
|
240
|
+
const gap = (new Date(list.at(i)!.run.startedAt).getTime() - new Date(prev.run.endedAt).getTime()) / 60_000;
|
|
241
|
+
if (gap > 0) per[waitKind(prev.run.status)] = (per[waitKind(prev.run.status)] ?? 0) + gap;
|
|
242
|
+
}
|
|
243
|
+
for (const [k, v] of Object.entries(per) as Array<[WaitKind, number]>) waitTotals.set(k, [...(waitTotals.get(k) ?? []), v]);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const builders = new Map<string, RequirementStats[]>();
|
|
247
|
+
for (const q of done) builders.set(q.builder ?? "(未记录)", [...(builders.get(q.builder ?? "(未记录)") ?? []), q]);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
schema: 1,
|
|
251
|
+
period: { from: period.from?.toISOString() ?? null, to: period.to?.toISOString() ?? null, label: period.label },
|
|
252
|
+
machines: [...new Set(periodRuns.map((r) => r.machine))].sort(),
|
|
253
|
+
active: active.length,
|
|
254
|
+
completed: { total: done.length, byOutcome: count(done.map((q) => q.outcome ?? "unknown")) },
|
|
255
|
+
byType: count(active.map((q) => q.type ?? "normal")),
|
|
256
|
+
cycleHours: { median: median(done.flatMap((q) => (q.cycleHours === null ? [] : [q.cycleHours]))), average: avg(done.flatMap((q) => (q.cycleHours === null ? [] : [q.cycleHours]))) },
|
|
257
|
+
steps: stepRows,
|
|
258
|
+
rework: {
|
|
259
|
+
averageReviewRounds: avg(done.map((q) => q.reviewRounds)),
|
|
260
|
+
withDefectRounds: done.length === 0 ? null : round1((done.filter((q) => q.defectRounds > 0).length / done.length) * 100) / 100,
|
|
261
|
+
gateRuns: periodRuns.reduce((s, r) => s + r.gate.runs, 0),
|
|
262
|
+
gateFailed: periodRuns.reduce((s, r) => s + r.gate.failed, 0),
|
|
263
|
+
},
|
|
264
|
+
waits: (Object.keys(WAIT_LABELS) as WaitKind[])
|
|
265
|
+
.filter((k) => waitTotals.has(k))
|
|
266
|
+
.map((k) => ({ kind: k, label: WAIT_LABELS[k], requirements: waitTotals.get(k)!.length, averageMinutes: avg(waitTotals.get(k)!) ?? 0 })),
|
|
267
|
+
usage: {
|
|
268
|
+
agentMinutes: round1(steps.reduce((s, x) => s + x.minutes, 0)),
|
|
269
|
+
inputTokens: steps.reduce<number | null>((s, x) => add(s, x.inputTokens), null),
|
|
270
|
+
outputTokens: steps.reduce<number | null>((s, x) => add(s, x.outputTokens), null),
|
|
271
|
+
costUsd: steps.reduce<number | null>((s, x) => add(s, x.costUsd), null),
|
|
272
|
+
},
|
|
273
|
+
byBuilder: [...builders.entries()].map(([builder, qs]) => ({
|
|
274
|
+
builder,
|
|
275
|
+
requirements: qs.length,
|
|
276
|
+
averageReviewRounds: avg(qs.map((q) => q.reviewRounds)),
|
|
277
|
+
averageCostUsd: avg(qs.flatMap((q) => (q.costUsd === null ? [] : [q.costUsd]))),
|
|
278
|
+
})),
|
|
279
|
+
requirements: active,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const TYPE_LABELS: Record<string, string> = { normal: "普通", defect: "缺陷", refactor: "重构", assessment: "评估" };
|
|
284
|
+
const OUTCOME_LABELS: Record<string, string> = {
|
|
285
|
+
completed: "已完成",
|
|
286
|
+
completed_with_issues: "已完成,有遗留问题",
|
|
287
|
+
not_completed: "未完成",
|
|
288
|
+
unknown: "结论未记录",
|
|
289
|
+
};
|
|
290
|
+
const STATUS_LABELS: Record<string, string> = {
|
|
291
|
+
completed: "已完成",
|
|
292
|
+
phase_done: "研发完成,待测试验证",
|
|
293
|
+
waiting_for_approval: "等待确认方案",
|
|
294
|
+
waiting_for_business: "等待需求方决定",
|
|
295
|
+
failed: "已中断",
|
|
296
|
+
running: "已中断",
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
function hours(minutes: number): string {
|
|
300
|
+
return minutes < 60 ? `${Math.round(minutes)} 分钟` : `${round1(minutes / 60)} 小时`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function tokens(n: number | null): string {
|
|
304
|
+
if (n === null) return "未报告";
|
|
305
|
+
return n >= 10_000 ? `${round1(n / 10_000)} 万` : String(n);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function list(m: Record<string, number>, labels: Record<string, string>): string {
|
|
309
|
+
const order = Object.keys(labels);
|
|
310
|
+
const rank = (k: string) => (order.includes(k) ? order.indexOf(k) : order.length);
|
|
311
|
+
const entries = Object.entries(m).sort((a, b) => rank(a[0]) - rank(b[0]));
|
|
312
|
+
return entries.length === 0 ? "无" : entries.map(([k, v]) => `${labels[k] ?? k} ${v}`).join(",");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The summary as a Markdown report for people (management included). */
|
|
316
|
+
export function describeStats(s: StatsSummary): string {
|
|
317
|
+
const lines = [`# req-kit 使用情况:${s.period.label}`, ""];
|
|
318
|
+
if (s.active === 0) {
|
|
319
|
+
lines.push("这段时间没有流水线运行记录。");
|
|
320
|
+
return lines.join("\n");
|
|
321
|
+
}
|
|
322
|
+
lines.push(
|
|
323
|
+
`- 机器:${s.machines.join("、")}`,
|
|
324
|
+
`- 有进展的需求:${s.active} 个(${list(s.byType, TYPE_LABELS)})`,
|
|
325
|
+
`- 完成:${s.completed.total} 个(${list(s.completed.byOutcome, OUTCOME_LABELS)})`,
|
|
326
|
+
s.cycleHours.median !== null ? `- 交付周期(首次开始到完成):中位数 ${s.cycleHours.median} 小时,平均 ${s.cycleHours.average} 小时` : "- 交付周期:这段时间没有完成的需求",
|
|
327
|
+
`- agent 合计用时:${hours(s.usage.agentMinutes)};输入 ${tokens(s.usage.inputTokens)} tokens,输出 ${tokens(s.usage.outputTokens)} tokens${s.usage.costUsd !== null ? `,约 $${s.usage.costUsd.toFixed(2)}` : ""}`,
|
|
328
|
+
"",
|
|
329
|
+
"## 返工",
|
|
330
|
+
"",
|
|
331
|
+
`- 平均代码评审轮数(已完成的需求):${s.rework.averageReviewRounds ?? "无数据"}`,
|
|
332
|
+
`- 需要缺陷修复的需求占比:${s.rework.withDefectRounds === null ? "无数据" : `${Math.round(s.rework.withDefectRounds * 100)}%`}`,
|
|
333
|
+
`- 质量门:运行 ${s.rework.gateRuns} 次,其中发现新失败 ${s.rework.gateFailed} 次`,
|
|
334
|
+
);
|
|
335
|
+
if (s.waits.length > 0) {
|
|
336
|
+
lines.push("", "## 等待", "", "| 等待 | 需求数 | 平均 |", "| --- | --- | --- |");
|
|
337
|
+
for (const w of s.waits) lines.push(`| ${w.label} | ${w.requirements} | ${hours(w.averageMinutes)} |`);
|
|
338
|
+
}
|
|
339
|
+
lines.push("", "## 各步骤用时", "", "| 步骤 | 次数 | 平均 | 合计 |", "| --- | --- | --- | --- |");
|
|
340
|
+
for (const st of s.steps) lines.push(`| ${st.title} | ${st.count} | ${hours(st.averageMinutes)} | ${hours(st.totalMinutes)} |`);
|
|
341
|
+
if (s.byBuilder.length > 1) {
|
|
342
|
+
lines.push("", "## 按 builder 比较(已完成的需求)", "", "| builder | 需求数 | 平均评审轮数 | 平均费用 |", "| --- | --- | --- | --- |");
|
|
343
|
+
for (const b of s.byBuilder) lines.push(`| ${b.builder} | ${b.requirements} | ${b.averageReviewRounds ?? "-"} | ${b.averageCostUsd === null ? "-" : `$${b.averageCostUsd.toFixed(2)}`} |`);
|
|
344
|
+
}
|
|
345
|
+
lines.push("", "## 需求", "", "| 项目 | 需求 | 类型 | 状态 | 周期 | agent 用时 | 评审轮数 | 缺陷轮数 |", "| --- | --- | --- | --- | --- | --- | --- | --- |");
|
|
346
|
+
for (const q of s.requirements) {
|
|
347
|
+
const status = q.status === "completed" ? (OUTCOME_LABELS[q.outcome ?? "unknown"] ?? "已完成") : (STATUS_LABELS[q.status] ?? q.status);
|
|
348
|
+
lines.push(
|
|
349
|
+
`| ${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} |`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return lines.join("\n");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const CSV_COLUMNS: Array<[string, (q: RequirementStats) => unknown]> = [
|
|
356
|
+
["project", (q) => q.project],
|
|
357
|
+
["name", (q) => q.name],
|
|
358
|
+
["title", (q) => q.title],
|
|
359
|
+
["type", (q) => q.type ?? "normal"],
|
|
360
|
+
["profile", (q) => q.profile],
|
|
361
|
+
["machines", (q) => q.machines.join(" ")],
|
|
362
|
+
["runs", (q) => q.runs],
|
|
363
|
+
["first_started_at", (q) => q.firstStartedAt],
|
|
364
|
+
["last_ended_at", (q) => q.lastEndedAt],
|
|
365
|
+
["status", (q) => q.status],
|
|
366
|
+
["outcome", (q) => q.outcome ?? ""],
|
|
367
|
+
["cycle_hours", (q) => q.cycleHours ?? ""],
|
|
368
|
+
["agent_minutes", (q) => q.agentMinutes],
|
|
369
|
+
["input_tokens", (q) => q.inputTokens ?? ""],
|
|
370
|
+
["output_tokens", (q) => q.outputTokens ?? ""],
|
|
371
|
+
["cost_usd", (q) => q.costUsd ?? ""],
|
|
372
|
+
["review_rounds", (q) => q.reviewRounds],
|
|
373
|
+
["defect_rounds", (q) => q.defectRounds],
|
|
374
|
+
["gate_runs", (q) => q.gateRuns],
|
|
375
|
+
["gate_failed", (q) => q.gateFailed],
|
|
376
|
+
["wait_approval_minutes", (q) => q.waits.approval],
|
|
377
|
+
["wait_business_minutes", (q) => q.waits.business],
|
|
378
|
+
["wait_verify_minutes", (q) => q.waits.verify],
|
|
379
|
+
["builder", (q) => q.builder ?? ""],
|
|
380
|
+
["reviewer", (q) => q.reviewer ?? ""],
|
|
381
|
+
];
|
|
382
|
+
|
|
383
|
+
function csvCell(v: unknown): string {
|
|
384
|
+
const s = String(v);
|
|
385
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** One row per requirement; UTF-8 with a BOM so spreadsheet programs read the Chinese right. */
|
|
389
|
+
export function statsCsv(s: StatsSummary): string {
|
|
390
|
+
const rows = [CSV_COLUMNS.map(([name]) => name).join(",")];
|
|
391
|
+
for (const q of s.requirements) rows.push(CSV_COLUMNS.map(([, get]) => csvCell(get(q))).join(","));
|
|
392
|
+
return `${rows.join("\n")}\n`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// CLI
|
|
397
|
+
|
|
398
|
+
interface StatsArgs {
|
|
399
|
+
since: string | null;
|
|
400
|
+
month: string | null;
|
|
401
|
+
json: boolean;
|
|
402
|
+
csv: boolean;
|
|
403
|
+
report: boolean;
|
|
404
|
+
out: string | null;
|
|
405
|
+
export: string | true | null;
|
|
406
|
+
merge: string[];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function parseStatsArgs(argv: string[]): StatsArgs {
|
|
410
|
+
const a: StatsArgs = { since: null, month: null, json: false, csv: false, report: false, out: null, export: null, merge: [] };
|
|
411
|
+
for (let i = 0; i < argv.length; i++) {
|
|
412
|
+
const arg = argv[i];
|
|
413
|
+
const value = () => {
|
|
414
|
+
const v = argv[i + 1];
|
|
415
|
+
if (v === undefined || v.startsWith("--")) throw new Error(`${arg} 后面要写值`);
|
|
416
|
+
i++;
|
|
417
|
+
return v;
|
|
418
|
+
};
|
|
419
|
+
if (arg === "--since") a.since = value();
|
|
420
|
+
else if (arg === "--month") a.month = value();
|
|
421
|
+
else if (arg === "--json") a.json = true;
|
|
422
|
+
else if (arg === "--csv") a.csv = true;
|
|
423
|
+
else if (arg === "--report") a.report = true;
|
|
424
|
+
else if (arg === "--out") a.out = value();
|
|
425
|
+
else if (arg === "--export") {
|
|
426
|
+
const next = argv[i + 1];
|
|
427
|
+
a.export = next && !next.startsWith("--") ? argv[++i] : true;
|
|
428
|
+
} else if (arg === "--merge") {
|
|
429
|
+
while (argv[i + 1] && !argv[i + 1].startsWith("--")) a.merge.push(argv[++i]);
|
|
430
|
+
if (a.merge.length === 0) throw new Error("--merge 后面要写导出的文件");
|
|
431
|
+
} else throw new Error(`不认识的选项:${arg}`);
|
|
432
|
+
}
|
|
433
|
+
return a;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function stamp(d = new Date()): string {
|
|
437
|
+
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function statsMain(argv: string[], env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): number {
|
|
441
|
+
const a = parseStatsArgs(argv);
|
|
442
|
+
const period = parsePeriod({ since: a.since, month: a.month });
|
|
443
|
+
const sources = a.merge.length > 0 ? a.merge.map((f) => path.resolve(cwd, f)) : [runsFile(env)];
|
|
444
|
+
// This machine may have no records yet (nothing ran since 1.9): an empty summary. Named files must exist.
|
|
445
|
+
if (a.merge.length > 0) for (const f of sources) if (!fs.existsSync(f)) throw new Error(`没有这个文件:${f}`);
|
|
446
|
+
const runs = sources.flatMap((f) => readRuns(f));
|
|
447
|
+
|
|
448
|
+
if (a.export) {
|
|
449
|
+
const picked = dedupe(runs).filter((r) => inPeriod(r.run.endedAt, period));
|
|
450
|
+
const file = path.resolve(cwd, a.export === true ? `req-kit-usage-${os.hostname()}-${stamp()}.jsonl` : a.export);
|
|
451
|
+
fs.writeFileSync(file, picked.map((r) => JSON.stringify(r)).join("\n") + (picked.length ? "\n" : ""));
|
|
452
|
+
console.log(`已导出 ${picked.length} 条运行记录(${period.label}):${file}\n汇总多台机器:req stats --merge <各机器导出的文件> --report`);
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const summary = summarizeRuns(runs, period);
|
|
457
|
+
if (a.report) {
|
|
458
|
+
const dir = path.resolve(cwd, a.out ?? ".");
|
|
459
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
460
|
+
const base = path.join(dir, `req-kit-usage-${a.month ?? stamp()}`);
|
|
461
|
+
fs.writeFileSync(`${base}.md`, `${describeStats(summary)}\n`);
|
|
462
|
+
fs.writeFileSync(`${base}.csv`, statsCsv(summary));
|
|
463
|
+
console.log(`已生成:\n ${base}.md(汇报用的汇总)\n ${base}.csv(每个需求一行,可以用表格软件打开)`);
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
466
|
+
if (a.json) console.log(JSON.stringify(summary, null, 2));
|
|
467
|
+
else if (a.csv) process.stdout.write(statsCsv(summary));
|
|
468
|
+
else console.log(describeStats(summary));
|
|
469
|
+
return 0;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
|
473
|
+
try {
|
|
474
|
+
process.exit(statsMain(process.argv.slice(2)));
|
|
475
|
+
} catch (error) {
|
|
476
|
+
console.error((error as Error).message);
|
|
477
|
+
process.exit(2);
|
|
478
|
+
}
|
|
479
|
+
}
|
package/install.mjs
CHANGED
|
@@ -911,6 +911,9 @@ if (values.check) {
|
|
|
911
911
|
if (active && path.resolve(active.root) !== path.resolve(KIT_ROOT)) ok(`已从 ${active.version} 切换到 ${VERSION}`);
|
|
912
912
|
else ok(`已启用 ${VERSION}`);
|
|
913
913
|
writeShims();
|
|
914
|
+
// The Paseo plugin, when installed, follows the active version (bin/req-plugin.mjs).
|
|
915
|
+
const { refreshPlugin } = await import("./bin/req-plugin.mjs");
|
|
916
|
+
refreshPlugin({ kitRoot: KIT_ROOT, log: (message) => ok(message) });
|
|
914
917
|
}
|
|
915
918
|
checkLegacyLeftovers(roots);
|
|
916
919
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-req-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0-beta.2",
|
|
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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
|
-
"test": "node --import tsx --test --test-concurrency=1 engine/test/*.test.ts test/*.test.ts",
|
|
34
|
+
"test": "node --import tsx --import ./engine/test/isolate.ts --test --test-concurrency=1 engine/test/*.test.ts test/*.test.ts",
|
|
35
35
|
"smoke": "tsx engine/src/smoke-test.ts",
|
|
36
36
|
"doctor": "node bin/req-doctor.mjs",
|
|
37
37
|
"eval": "tsx engine/src/eval.ts",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"bin/",
|
|
50
50
|
"engine/src/",
|
|
51
51
|
"engine/prompts/",
|
|
52
|
+
"paseo-plugin/",
|
|
52
53
|
"skills/",
|
|
53
54
|
"principles/",
|
|
54
55
|
"contracts/",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
req-kit 的需求在 Paseo 里的样子:本机所有项目的需求一页看完,哪些在等你确认、哪些卡住了、哪些在运行;点开一个需求,看它走到哪一步、每一步用了多久、质量门的结果,读需求说明、方案、确认预览、提测说明和完成报告。
|
|
2
|
+
|
|
3
|
+
需要人处理的时候,可以直接在这里确认方案、停止、继续或开始测试验证。这些操作和在终端运行 `req approve`、`req stop`、`req resume`、`req verify` 完全一样,流水线本身不受插件影响;不想让人在网页上操作时,在插件设置中打开"只读"。
|
|
4
|
+
|
|
5
|
+
还有:侧边栏的"需求"(带待处理数)、每个工作区头部的"需求"按钮和面板、步骤 agent 输入框旁的"需求"按钮,以及使用统计(交付周期、返工、等待、用量)。
|
|
6
|
+
|
|
7
|
+
需要这台 daemon 上安装 req-kit 1.9 或更新的版本,插件通过本机的 `req api` 读取需求;它不连接任何外部服务。用 `req plugin install` 安装和更新。
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Data hooks: every read is an RPC to the daemon side (req api), cached by React Query.
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useRpc, useSettings } from "@getpaseo/plugin/client";
|
|
4
|
+
import { fileRpc, listRpc, preferences, runRpc, statsRpc, statusRpc, versionRpc, type Action } from "../shared/contracts";
|
|
5
|
+
|
|
6
|
+
export function usePreferences(): { readOnly: boolean; refreshMs: number } {
|
|
7
|
+
const settings = useSettings(preferences);
|
|
8
|
+
if (settings.status !== "ready") return { readOnly: true, refreshMs: 10_000 };
|
|
9
|
+
return { readOnly: settings.values.readOnly, refreshMs: settings.values.refreshSeconds * 1000 };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useRequirements() {
|
|
13
|
+
const list = useRpc(listRpc);
|
|
14
|
+
const { refreshMs } = usePreferences();
|
|
15
|
+
return useQuery({ queryKey: ["req", "list"], queryFn: () => list({}), refetchInterval: refreshMs });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useRequirement(repo: string, featureDir: string) {
|
|
19
|
+
const status = useRpc(statusRpc);
|
|
20
|
+
const { refreshMs } = usePreferences();
|
|
21
|
+
return useQuery({
|
|
22
|
+
queryKey: ["req", "status", repo, featureDir],
|
|
23
|
+
queryFn: () => status({ repo, featureDir }),
|
|
24
|
+
refetchInterval: refreshMs,
|
|
25
|
+
enabled: Boolean(repo && featureDir),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useDocument(repo: string, featureDir: string, file: string | null) {
|
|
30
|
+
const read = useRpc(fileRpc);
|
|
31
|
+
return useQuery({
|
|
32
|
+
queryKey: ["req", "file", repo, featureDir, file],
|
|
33
|
+
queryFn: () => read({ repo, featureDir, file: file ?? "" }),
|
|
34
|
+
enabled: Boolean(file),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useStats(period: { since?: string; month?: string }) {
|
|
39
|
+
const stats = useRpc(statsRpc);
|
|
40
|
+
return useQuery({ queryKey: ["req", "stats", period.since ?? "", period.month ?? ""], queryFn: () => stats(period) });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useKitInfo() {
|
|
44
|
+
const version = useRpc(versionRpc);
|
|
45
|
+
return useQuery({ queryKey: ["req", "version"], queryFn: () => version({}), retry: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useAction() {
|
|
49
|
+
const run = useRpc(runRpc);
|
|
50
|
+
const queries = useQueryClient();
|
|
51
|
+
return useMutation({
|
|
52
|
+
mutationFn: (input: { action: Action; repo: string; featureDir: string; verifyEnv?: "local" | "deployed" }) => run(input),
|
|
53
|
+
onSettled: () => queries.invalidateQueries({ queryKey: ["req"] }),
|
|
54
|
+
});
|
|
55
|
+
}
|