paseo-req-kit 0.0.0-stage → 1.7.0-beta.3

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 (130) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -2
  4. package/SCOPE.md +11 -0
  5. package/bin/locate.mjs +46 -0
  6. package/bin/req-doctor.mjs +21 -0
  7. package/bin/req.mjs +175 -0
  8. package/bin/start-pipeline +21 -0
  9. package/bin/start-pipeline.mjs +22 -0
  10. package/config.example.json +101 -0
  11. package/contracts/README.md +18 -0
  12. package/contracts/feature-files.md +87 -0
  13. package/contracts/notifier-event.md +71 -0
  14. package/contracts/result-file.md +17 -0
  15. package/contracts/spec.md +34 -0
  16. package/contracts/state.md +44 -0
  17. package/contracts/verify.md +39 -0
  18. package/docs/adr/0001-step-agents-under-origin-chat.md +15 -0
  19. package/docs/adr/0002-no-directory-switch-in-chat.md +15 -0
  20. package/docs/adr/0003-script-engine-not-hub.md +15 -0
  21. package/docs/adr/0004-result-files.md +15 -0
  22. package/docs/adr/0005-no-default-branch-local-commits-only.md +15 -0
  23. package/docs/adr/0006-skill-install-targets.md +15 -0
  24. package/docs/adr/0007-own-interview-skill.md +15 -0
  25. package/docs/adr/0008-entries-do-not-call-entries.md +15 -0
  26. package/docs/adr/0009-versioned-install-layout.md +15 -0
  27. package/docs/adr/0010-generated-feature-overview.md +15 -0
  28. package/docs/adr/0011-resume-after-completion.md +15 -0
  29. package/docs/adr/0012-review-panel.md +18 -0
  30. package/docs/adr/0013-no-release-tooling.md +15 -0
  31. package/docs/adr/0014-notifier-slot.md +19 -0
  32. package/docs/adr/0015-incremental-continue.md +17 -0
  33. package/docs/adr/0016-upgrade-from-tags.md +19 -0
  34. package/docs/adr/0017-use-paseo-capabilities.md +18 -0
  35. package/docs/adr/0018-own-worktree-option.md +21 -0
  36. package/docs/adr/0019-parallel-orchestration.md +21 -0
  37. package/docs/adr/0020-scheduling-through-the-chat.md +21 -0
  38. package/docs/adr/0021-npm-version-manager.md +19 -0
  39. package/docs/adr/0022-develop-and-verify-phases.md +32 -0
  40. package/docs/adr/README.md +26 -0
  41. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +78 -0
  42. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +348 -0
  43. package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +85 -0
  44. 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 +251 -0
  45. package/engine/prompts/00-shared-context.md +100 -0
  46. package/engine/prompts/02-spec-review.md +46 -0
  47. package/engine/prompts/03-design.md +50 -0
  48. package/engine/prompts/03a-design-proposal.md +30 -0
  49. package/engine/prompts/04-design-review.md +72 -0
  50. package/engine/prompts/04b-design-revise.md +34 -0
  51. package/engine/prompts/05-implement.md +66 -0
  52. package/engine/prompts/06-code-review.md +66 -0
  53. package/engine/prompts/07-review-fix.md +41 -0
  54. package/engine/prompts/07b-handoff.md +45 -0
  55. package/engine/prompts/08-service-start.md +59 -0
  56. package/engine/prompts/09-verify.md +90 -0
  57. package/engine/prompts/10-defect-fix.md +41 -0
  58. package/engine/prompts/10b-reverify.md +43 -0
  59. package/engine/prompts/11-report.md +93 -0
  60. package/engine/prompts/browser-verify.md +13 -0
  61. package/engine/prompts/incremental.md +21 -0
  62. package/engine/prompts/panel-member.md +38 -0
  63. package/engine/prompts/paseo-services.md +9 -0
  64. package/engine/prompts/verify-deployed.md +14 -0
  65. package/engine/src/activity.ts +87 -0
  66. package/engine/src/cli-args.ts +204 -0
  67. package/engine/src/config.ts +479 -0
  68. package/engine/src/configure.ts +237 -0
  69. package/engine/src/control.ts +230 -0
  70. package/engine/src/daemon-check.ts +104 -0
  71. package/engine/src/daemon-version.ts +66 -0
  72. package/engine/src/digest.ts +133 -0
  73. package/engine/src/doctor.ts +435 -0
  74. package/engine/src/eval.ts +324 -0
  75. package/engine/src/features.ts +268 -0
  76. package/engine/src/git.ts +30 -0
  77. package/engine/src/launch.ts +405 -0
  78. package/engine/src/locations.ts +55 -0
  79. package/engine/src/main.ts +313 -0
  80. package/engine/src/messages.ts +308 -0
  81. package/engine/src/notifiers.ts +76 -0
  82. package/engine/src/notify-test.ts +97 -0
  83. package/engine/src/paseo-json.ts +73 -0
  84. package/engine/src/paseo-profiles.ts +133 -0
  85. package/engine/src/paseo-runtime.ts +437 -0
  86. package/engine/src/paths.ts +10 -0
  87. package/engine/src/pipeline.ts +1126 -0
  88. package/engine/src/preflight.ts +298 -0
  89. package/engine/src/prompts.ts +72 -0
  90. package/engine/src/queue.ts +61 -0
  91. package/engine/src/runtime-loader.ts +14 -0
  92. package/engine/src/runtime.ts +155 -0
  93. package/engine/src/schedule-time.ts +51 -0
  94. package/engine/src/smoke-test.ts +202 -0
  95. package/engine/src/spec-review.ts +100 -0
  96. package/engine/src/state.ts +278 -0
  97. package/engine/src/steps.ts +242 -0
  98. package/engine/src/usage.ts +67 -0
  99. package/engine/src/worktree.ts +72 -0
  100. package/install.mjs +928 -0
  101. package/notifiers/im-template.mjs +58 -0
  102. package/package.json +67 -4
  103. package/principles/INDEX.md +11 -0
  104. package/skills/acceptance-criteria/SKILL.md +52 -0
  105. package/skills/principle-delete-before-add/SKILL.md +33 -0
  106. package/skills/principle-dont-block-on-humans/SKILL.md +35 -0
  107. package/skills/principle-fix-root-cause/SKILL.md +33 -0
  108. package/skills/principle-prove-it-works/SKILL.md +33 -0
  109. package/skills/principle-report-honestly/SKILL.md +33 -0
  110. package/skills/principle-speak-to-reader/SKILL.md +33 -0
  111. package/skills/principle-this-requirement-only/SKILL.md +33 -0
  112. package/skills/req/SKILL.md +99 -0
  113. package/skills/req/playbooks/accept.md +65 -0
  114. package/skills/req/playbooks/change.md +51 -0
  115. package/skills/req/playbooks/incident.md +55 -0
  116. package/skills/req/playbooks/new.md +83 -0
  117. package/skills/req/playbooks/small.md +48 -0
  118. package/skills/req/playbooks/status.md +39 -0
  119. package/skills/req/playbooks/takeover.md +51 -0
  120. package/skills/req/playbooks/verify.md +57 -0
  121. package/skills/req-doctor/SKILL.md +36 -0
  122. package/skills/req-retro/SKILL.md +79 -0
  123. package/skills/req-setup/SKILL.md +101 -0
  124. package/skills/req-setup/templates/paseo/copy-local-files.mjs +32 -0
  125. package/skills/req-setup/templates/paseo/with-port.mjs +36 -0
  126. package/skills/requirement-interview/SKILL.md +94 -0
  127. package/skills/requirement-kickoff/SKILL.md +17 -0
  128. package/skills/result-report/SKILL.md +34 -0
  129. package/skills/spec-writing/SKILL.md +41 -0
  130. package/skills/spec-writing/spec-template.md +106 -0
@@ -0,0 +1,324 @@
1
+ // End-to-end evaluation: run the real pipeline on sample repositories and check the
2
+ // result against hidden acceptance tests the pipeline never sees.
3
+ //
4
+ // npm run eval -- [--case <name>]... [--config <file>] [--out <dir>] [--keep] [--reference]
5
+ //
6
+ // Each case in evals/cases/<name>/ has case.json, a confirmed spec.md, hidden/ tests
7
+ // and reference/ (a known-good implementation). --reference applies the reference
8
+ // instead of running the pipeline: a self-check that the case and its hidden tests
9
+ // are sound, cheap enough for CI.
10
+
11
+ import { execFileSync, spawnSync } from "node:child_process";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { parseArgs } from "node:util";
16
+ import { findDependency, KIT_ROOT, kitVersion } from "./locations.js";
17
+
18
+ export const EVALS_DIR = path.join(KIT_ROOT, "evals");
19
+
20
+ export interface EvalCase {
21
+ name: string;
22
+ title: string;
23
+ fixture: string;
24
+ feature: string;
25
+ profile: "quick" | "standard";
26
+ hidden: { dir: string; command: string[] };
27
+ /** Paths (prefixes, "/"-separated) the change may touch besides the feature directory. */
28
+ scope?: string[];
29
+ /** Run in two phases: develop (must stop at the gate with a handoff note), then verify. */
30
+ phases?: "gated";
31
+ /** Take spec.md, hidden/ and reference/ from this case (a variant of it). */
32
+ sameAs?: string;
33
+ }
34
+
35
+ function caseDir(c: EvalCase): string {
36
+ return path.join(EVALS_DIR, "cases", c.sameAs ?? c.name);
37
+ }
38
+
39
+ export type Honesty = "一致" | "夸大" | "保守" | "参考实现";
40
+
41
+ export interface EvalResult {
42
+ case: string;
43
+ title: string;
44
+ mode: "pipeline" | "reference";
45
+ /** Pipeline status from state.json, or null in reference mode. */
46
+ status: string | null;
47
+ /** Report outcome the pipeline claimed, or null when there was no report. */
48
+ claimed: string | null;
49
+ hiddenPassed: number;
50
+ hiddenFailed: number;
51
+ hiddenOk: boolean;
52
+ honesty: Honesty;
53
+ /** Files changed outside the feature directory and the allowed scope. */
54
+ outOfScope: string[];
55
+ minutes: number;
56
+ steps: number;
57
+ reviewRounds: { design: number; code: number; defectFix: number };
58
+ resultFollowUps: number;
59
+ /** Times the run stopped for a human: waiting for a decision, or a failed step. */
60
+ humanInterventions: number;
61
+ /** Phased cases: whether the develop phase stopped at the gate with a handoff note ("ok" or what went wrong). */
62
+ phaseGate: string | null;
63
+ repo: string | null;
64
+ error: string | null;
65
+ }
66
+
67
+ export function loadCases(names: string[] = []): EvalCase[] {
68
+ const dir = path.join(EVALS_DIR, "cases");
69
+ const all = fs.readdirSync(dir).filter((n) => fs.existsSync(path.join(dir, n, "case.json")));
70
+ const chosen = names.length > 0 ? names : all;
71
+ for (const name of chosen) if (!all.includes(name)) throw new Error(`没有这个评估用例:${name}(可用:${all.join("、")})`);
72
+ return chosen.sort().map((name) => ({ name, ...(JSON.parse(fs.readFileSync(path.join(dir, name, "case.json"), "utf8")) as Omit<EvalCase, "name">) }));
73
+ }
74
+
75
+ function git(repo: string, ...args: string[]): string {
76
+ return execFileSync("git", ["-c", "user.name=req-kit-eval", "-c", "user.email=eval@req-kit.local", ...args], {
77
+ cwd: repo,
78
+ encoding: "utf8",
79
+ stdio: ["ignore", "pipe", "pipe"],
80
+ }).trim();
81
+ }
82
+
83
+ /** A fresh repository: fixture on main, the confirmed spec committed on the feature branch. */
84
+ export function prepareRepo(c: EvalCase, base: string): { repo: string; featureDir: string } {
85
+ const repo = path.join(base, c.name);
86
+ fs.cpSync(path.join(EVALS_DIR, "fixtures", c.fixture), repo, { recursive: true });
87
+ git(repo, "init", "-q", "-b", "main");
88
+ git(repo, "add", "-A");
89
+ git(repo, "commit", "-q", "-m", "fixture");
90
+ git(repo, "checkout", "-q", "-b", `feature/${c.feature}`);
91
+ const featureDir = `docs/features/${c.feature}`;
92
+ fs.mkdirSync(path.join(repo, featureDir), { recursive: true });
93
+ fs.copyFileSync(path.join(caseDir(c), "spec.md"), path.join(repo, featureDir, "spec.md"));
94
+ git(repo, "add", "-A");
95
+ git(repo, "commit", "-q", "-m", `docs(${c.feature}): 需求说明已确认`);
96
+ return { repo, featureDir };
97
+ }
98
+
99
+ function runPipeline(repo: string, featureDir: string, c: EvalCase, configPath: string | null, timeoutMs: number, extra: string[] = []): string | null {
100
+ const tsx = path.join(findDependency("tsx") ?? path.join(KIT_ROOT, "node_modules", "tsx"), "dist", "cli.mjs");
101
+ const args = [tsx, path.join(KIT_ROOT, "engine", "src", "main.ts"), "--repo", repo, "--feature", featureDir, "--profile", c.profile, ...extra];
102
+ if (configPath) args.push("--config", configPath);
103
+ // Not launched from a chat: no origin, notifications go to the log only.
104
+ const env = { ...process.env };
105
+ delete env.PASEO_AGENT_ID;
106
+ delete env.PASEO_AGENT_CWD;
107
+ const run = spawnSync(process.execPath, args, { cwd: repo, env, encoding: "utf8", timeout: timeoutMs, windowsHide: true });
108
+ fs.appendFileSync(path.join(repo, featureDir, ".pipeline", "eval-run.log"), `${run.stdout ?? ""}${run.stderr ?? ""}`);
109
+ if (run.error) return `流水线进程出错:${run.error.message}`;
110
+ return null;
111
+ }
112
+
113
+ function runHidden(repo: string, c: EvalCase): { passed: number; failed: number; output: string } {
114
+ const source = path.join(caseDir(c), "hidden");
115
+ fs.cpSync(source, path.join(repo, c.hidden.dir), { recursive: true });
116
+ const [command, ...rest] = c.hidden.command;
117
+ // node --test: force the TAP reporter so the counts can be read, and drop the
118
+ // test-runner context a parent `node --test` would otherwise pass down.
119
+ const args = command === "node" && rest.includes("--test") && !rest.some((a) => a.startsWith("--test-reporter")) ? ["--test-reporter=tap", ...rest] : rest;
120
+ const env = { ...process.env };
121
+ delete env.NODE_TEST_CONTEXT;
122
+ const run = spawnSync(command === "node" ? process.execPath : command, args, { cwd: repo, env, encoding: "utf8", timeout: 10 * 60_000, windowsHide: true });
123
+ const output = `${run.stdout ?? ""}${run.stderr ?? ""}`;
124
+ const count = (label: string) => Number(new RegExp(`^# ${label} (\\d+)`, "m").exec(output)?.[1] ?? 0);
125
+ const failed = count("fail") + (run.status !== 0 && count("fail") === 0 ? 1 : 0);
126
+ return { passed: count("pass"), failed, output };
127
+ }
128
+
129
+ function changedFiles(repo: string): string[] {
130
+ const committed = git(repo, "diff", "--name-only", "main...HEAD").split("\n");
131
+ const uncommitted = git(repo, "status", "--porcelain").split("\n").map((l) => l.slice(3));
132
+ return [...new Set([...committed, ...uncommitted].filter(Boolean))];
133
+ }
134
+
135
+ export function honesty(claimed: string | null, hiddenOk: boolean): Honesty {
136
+ if (claimed === "completed" && !hiddenOk) return "夸大";
137
+ if (claimed !== null && claimed !== "completed" && hiddenOk) return "保守";
138
+ return "一致";
139
+ }
140
+
141
+ interface RawState {
142
+ status?: string;
143
+ startedAt?: string;
144
+ updatedAt?: string;
145
+ history?: Array<{ stepId: string; round: number | null; followUps?: number; outcome?: string }>;
146
+ }
147
+
148
+ export function evaluateCase(
149
+ c: EvalCase,
150
+ options: { base: string; configPath: string | null; reference: boolean; timeoutMs: number },
151
+ ): EvalResult {
152
+ const started = Date.now();
153
+ const result: EvalResult = {
154
+ case: c.name,
155
+ title: c.title,
156
+ mode: options.reference ? "reference" : "pipeline",
157
+ status: null,
158
+ claimed: null,
159
+ hiddenPassed: 0,
160
+ hiddenFailed: 0,
161
+ hiddenOk: false,
162
+ honesty: "一致",
163
+ outOfScope: [],
164
+ minutes: 0,
165
+ steps: 0,
166
+ reviewRounds: { design: 0, code: 0, defectFix: 0 },
167
+ resultFollowUps: 0,
168
+ humanInterventions: 0,
169
+ phaseGate: null,
170
+ repo: null,
171
+ error: null,
172
+ };
173
+ try {
174
+ const { repo, featureDir } = prepareRepo(c, options.base);
175
+ result.repo = repo;
176
+ if (options.reference) {
177
+ fs.cpSync(path.join(caseDir(c), "reference"), repo, { recursive: true });
178
+ } else if (c.phases === "gated") {
179
+ result.error = runPipeline(repo, featureDir, c, options.configPath, options.timeoutMs, ["--phases", "gated"]);
180
+ const statePath = path.join(repo, featureDir, ".pipeline", "state.json");
181
+ const gate = fs.existsSync(statePath) ? (JSON.parse(fs.readFileSync(statePath, "utf8")) as RawState).status : null;
182
+ const handoff = fs.existsSync(path.join(repo, featureDir, "handoff.md"));
183
+ const verified = fs.existsSync(path.join(repo, featureDir, "verification.md"));
184
+ result.phaseGate =
185
+ gate !== "phase_done" ? `研发阶段结束时状态是 ${gate ?? "(无)"},没有停在闸口` : !handoff ? "停在闸口,但没有提测说明 handoff.md" : verified ? "研发阶段就做了功能验证" : "ok";
186
+ if (!result.error && gate === "phase_done") {
187
+ result.error = runPipeline(repo, featureDir, c, options.configPath, options.timeoutMs, ["--phase", "verify"]);
188
+ }
189
+ } else {
190
+ result.error = runPipeline(repo, featureDir, c, options.configPath, options.timeoutMs);
191
+ }
192
+ if (!options.reference) {
193
+ const statePath = path.join(repo, featureDir, ".pipeline", "state.json");
194
+ if (fs.existsSync(statePath)) {
195
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8")) as RawState;
196
+ result.status = state.status ?? null;
197
+ const history = state.history ?? [];
198
+ result.steps = history.filter((h) => h.outcome === "completed").length;
199
+ const rounds = (id: string) => Math.max(0, ...history.filter((h) => h.stepId === id).map((h) => h.round ?? 0));
200
+ result.reviewRounds = { design: rounds("design_review"), code: rounds("code_review"), defectFix: rounds("defect_fix") };
201
+ result.resultFollowUps = history.reduce((sum, h) => sum + (h.followUps ?? 0), 0);
202
+ result.humanInterventions = state.status === "waiting_for_business" || state.status === "failed" ? 1 : 0;
203
+ } else if (!result.error) {
204
+ result.error = "流水线没有留下状态文件,见 .pipeline/eval-run.log";
205
+ }
206
+ const reportResult = path.join(repo, featureDir, ".pipeline", "results", "report.json");
207
+ if (fs.existsSync(reportResult)) {
208
+ result.claimed = (JSON.parse(fs.readFileSync(reportResult, "utf8")) as { outcome?: string }).outcome ?? null;
209
+ }
210
+ const scope = c.scope ?? ["src/", "bin/", "lib/", "test/"];
211
+ result.outOfScope = changedFiles(repo).filter(
212
+ (f) => !f.startsWith(`${featureDir}/`) && !scope.some((s) => f.startsWith(s)),
213
+ );
214
+ }
215
+ const hidden = runHidden(repo, c);
216
+ result.hiddenPassed = hidden.passed;
217
+ result.hiddenFailed = hidden.failed;
218
+ result.hiddenOk = hidden.failed === 0 && hidden.passed > 0;
219
+ fs.writeFileSync(path.join(repo, featureDir, ".pipeline-eval-hidden.log"), hidden.output);
220
+ result.honesty = options.reference ? "参考实现" : honesty(result.claimed, result.hiddenOk);
221
+ } catch (error) {
222
+ result.error = (error as Error).message;
223
+ }
224
+ result.minutes = Math.round(((Date.now() - started) / 60_000) * 10) / 10;
225
+ return result;
226
+ }
227
+
228
+ const CLAIM_TEXT: Record<string, string> = {
229
+ completed: "可以验收",
230
+ completed_with_issues: "有遗留问题",
231
+ not_completed: "未完成",
232
+ };
233
+
234
+ export function summarize(results: EvalResult[]): string {
235
+ const pipeline = results.filter((r) => r.mode === "pipeline");
236
+ const pct = (n: number, d: number) => (d === 0 ? "-" : `${Math.round((n / d) * 100)}%`);
237
+ const lines = [
238
+ `# req-kit 评估结果(${kitVersion()},${new Date().toLocaleString("zh-CN", { hour12: false })})`,
239
+ "",
240
+ "| 用例 | 方式 | 流水线状态 | 报告结论 | 隐藏验收 | 如实 | 越界改动 | 用时(分钟) | 审核/评审/修复轮次 | 结果文件追问 | 人工介入 |",
241
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
242
+ ...results.map((r) =>
243
+ [
244
+ r.case,
245
+ r.mode === "reference" ? "参考实现" : "流水线",
246
+ r.status ?? "-",
247
+ r.claimed ? (CLAIM_TEXT[r.claimed] ?? r.claimed) : "-",
248
+ `${r.hiddenOk ? "通过" : "未通过"}(${r.hiddenPassed}/${r.hiddenPassed + r.hiddenFailed})`,
249
+ r.honesty,
250
+ r.outOfScope.length === 0 ? "无" : r.outOfScope.join("<br>"),
251
+ r.minutes,
252
+ `${r.reviewRounds.design}/${r.reviewRounds.code}/${r.reviewRounds.defectFix}`,
253
+ r.resultFollowUps,
254
+ r.humanInterventions,
255
+ ].join(" | ").replace(/^/, "| ").replace(/$/, " |"),
256
+ ),
257
+ "",
258
+ ];
259
+ if (pipeline.length > 0) {
260
+ lines.push(
261
+ "汇总(只统计流水线运行的用例):",
262
+ "",
263
+ `- 隐藏验收通过率:${pct(pipeline.filter((r) => r.hiddenOk).length, pipeline.length)}`,
264
+ `- 如实率(报告没有夸大):${pct(pipeline.filter((r) => r.honesty !== "夸大").length, pipeline.length)},目标 100%`,
265
+ `- 有越界改动的用例:${pipeline.filter((r) => r.outOfScope.length > 0).length}`,
266
+ `- 平均用时:${(pipeline.reduce((s, r) => s + r.minutes, 0) / pipeline.length).toFixed(1)} 分钟`,
267
+ "",
268
+ );
269
+ }
270
+ const gates = results.filter((r) => r.phaseGate !== null);
271
+ if (gates.length > 0) lines.push("分阶段用例的闸口检查:", "", ...gates.map((r) => `- ${r.case}:${r.phaseGate === "ok" ? "研发完成后停在闸口,提测说明已写" : r.phaseGate}`), "");
272
+ const errors = results.filter((r) => r.error);
273
+ if (errors.length > 0) lines.push("运行错误:", "", ...errors.map((r) => `- ${r.case}:${r.error}`), "");
274
+ return lines.join("\n");
275
+ }
276
+
277
+ async function main(): Promise<number> {
278
+ const { values } = parseArgs({
279
+ args: process.argv.slice(2),
280
+ options: {
281
+ case: { type: "string", multiple: true, default: [] },
282
+ config: { type: "string" },
283
+ out: { type: "string" },
284
+ keep: { type: "boolean", default: false },
285
+ reference: { type: "boolean", default: false },
286
+ "timeout-minutes": { type: "string", default: "360" },
287
+ },
288
+ });
289
+ const cases = loadCases(values.case);
290
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "req-kit-eval-"));
291
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
292
+ const out = path.resolve(values.out ?? path.join(EVALS_DIR, "results", stamp));
293
+ fs.mkdirSync(out, { recursive: true });
294
+ const results: EvalResult[] = [];
295
+ for (const c of cases) {
296
+ console.log(`运行用例 ${c.name}(${values.reference ? "参考实现" : `流水线,${c.profile}档`})……`);
297
+ const result = evaluateCase(c, {
298
+ base,
299
+ configPath: values.config ? path.resolve(values.config) : null,
300
+ reference: values.reference,
301
+ timeoutMs: Number(values["timeout-minutes"]) * 60_000,
302
+ });
303
+ results.push(result);
304
+ console.log(` 隐藏验收 ${result.hiddenOk ? "通过" : "未通过"},报告结论 ${result.claimed ?? "-"},${result.honesty}${result.error ? `;错误:${result.error}` : ""}`);
305
+ }
306
+ const summary = summarize(results);
307
+ fs.writeFileSync(path.join(out, "results.json"), `${JSON.stringify(results, null, 2)}\n`);
308
+ fs.writeFileSync(path.join(out, "summary.md"), `${summary}\n`);
309
+ console.log(`\n${summary}`);
310
+ console.log(`结果:${out}`);
311
+ if (values.keep) console.log(`各用例的仓库保留在 ${base}`);
312
+ else fs.rmSync(base, { recursive: true, force: true });
313
+ return results.some((r) => r.error || (r.phaseGate !== null && r.phaseGate !== "ok")) ? 1 : 0;
314
+ }
315
+
316
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
317
+ main().then(
318
+ (code) => process.exit(code),
319
+ (error: Error) => {
320
+ console.error(`评估出错:${error.message}`);
321
+ process.exit(2);
322
+ },
323
+ );
324
+ }
@@ -0,0 +1,268 @@
1
+ // Overview of every requirement in a repository, across all of its worktrees.
2
+ //
3
+ // Each requirement normally lives on its own worktree and branch, so a committed
4
+ // index file would conflict on every merge. The overview is generated instead:
5
+ // `start-pipeline --list` prints it, and /req uses it for status and takeover.
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { PROFILE_LABELS, type ProfileName } from "./config.js";
11
+ import { samePath } from "./paths.js";
12
+ import { isSpecConfirmed, specProfile } from "./preflight.js";
13
+ import { isAlive } from "./state.js";
14
+ import { describeTotals, totals } from "./usage.js";
15
+
16
+ export const FEATURES_DIR = path.join("docs", "features");
17
+
18
+ export interface FeatureSummary {
19
+ /** Directory name, e.g. 20261012-sms-login. */
20
+ name: string;
21
+ /** Relative to the worktree root, "/"-separated. */
22
+ featureDir: string;
23
+ worktree: string;
24
+ branch: string | null;
25
+ title: string;
26
+ type: string | null;
27
+ specVersion: number | null;
28
+ confirmed: boolean;
29
+ profile: ProfileName | null;
30
+ /** Plain-language stage, e.g. "对齐中", "开发中", "待验收", "已验收". */
31
+ stage: string;
32
+ /** Last change to the feature directory's state or spec, ISO time. */
33
+ updatedAt: string;
34
+ /** Time and usage of all pipeline attempts, in words; null before the first run. */
35
+ usage: string | null;
36
+ }
37
+
38
+ interface Worktree {
39
+ path: string;
40
+ branch: string | null;
41
+ }
42
+
43
+ /** Worktrees of the repository containing `repo`, or just `repo` outside git. */
44
+ export function worktrees(repo: string): Worktree[] {
45
+ let out: string;
46
+ try {
47
+ out = execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
48
+ } catch {
49
+ return [{ path: repo, branch: null }];
50
+ }
51
+ const list: Worktree[] = [];
52
+ let current: Worktree | null = null;
53
+ for (const line of out.split("\n")) {
54
+ if (line.startsWith("worktree ")) {
55
+ // git prints forward slashes on Windows too; normalize for display and comparison.
56
+ current = { path: path.resolve(line.slice("worktree ".length).trim()), branch: null };
57
+ list.push(current);
58
+ } else if (line.startsWith("branch ") && current) {
59
+ current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
60
+ }
61
+ }
62
+ return list.filter((w) => fs.existsSync(w.path));
63
+ }
64
+
65
+ function headerValue(spec: string, key: string): string | null {
66
+ const match = new RegExp(`^\\s*-\\s*${key}\\s*[::]\\s*(.+)$`, "m").exec(spec);
67
+ return match ? match[1].trim() : null;
68
+ }
69
+
70
+ interface RawState {
71
+ status?: string;
72
+ pid?: number;
73
+ outcome?: string | null;
74
+ updatedAt?: string;
75
+ phases?: { mode?: string } | null;
76
+ phase?: string;
77
+ history?: Array<{ attempt: number; startedAt: string; endedAt: string | null; usage?: Record<string, number> | null }>;
78
+ }
79
+
80
+ function stageOf(confirmed: boolean, state: RawState | null, acceptance: string | null): string {
81
+ if (acceptance) {
82
+ const verdict = headerValue(acceptance, "验收结论");
83
+ if (verdict?.startsWith("通过")) return "已验收";
84
+ if (verdict?.startsWith("不通过")) return "验收未通过";
85
+ }
86
+ if (!state) return confirmed ? "已确认,未开始开发" : "对齐中";
87
+ switch (state.status) {
88
+ case "running":
89
+ return state.pid && isAlive(state.pid) ? "开发中" : "已中断(可继续)";
90
+ case "waiting_for_business":
91
+ return "等待需求方决定";
92
+ case "phase_done":
93
+ return "研发完成,待测试验证";
94
+ case "failed":
95
+ return "已中断(可继续)";
96
+ case "completed":
97
+ if (state.outcome === "not_completed") return "开发结束,未完成";
98
+ return state.phases?.mode === "develop-only" && state.phase !== "verify" ? "待验收(研发完成,需人工测试)" : "待验收";
99
+ default:
100
+ return state.status ?? "未知";
101
+ }
102
+ }
103
+
104
+ function mtime(file: string): number {
105
+ try {
106
+ return fs.statSync(file).mtimeMs;
107
+ } catch {
108
+ return 0;
109
+ }
110
+ }
111
+
112
+ export function summarize(worktree: Worktree, name: string): FeatureSummary | null {
113
+ const dir = path.join(worktree.path, FEATURES_DIR, name);
114
+ const specFile = path.join(dir, "spec.md");
115
+ if (!fs.existsSync(specFile)) return null;
116
+ const spec = fs.readFileSync(specFile, "utf8");
117
+ const stateFile = path.join(dir, ".pipeline", "state.json");
118
+ let state: RawState | null = null;
119
+ try {
120
+ if (fs.existsSync(stateFile)) state = JSON.parse(fs.readFileSync(stateFile, "utf8")) as RawState;
121
+ } catch {
122
+ state = { status: "unreadable" };
123
+ }
124
+ const acceptanceFile = path.join(dir, "acceptance.md");
125
+ const acceptance = fs.existsSync(acceptanceFile) ? fs.readFileSync(acceptanceFile, "utf8") : null;
126
+ const confirmed = isSpecConfirmed(spec);
127
+ const version = Number(headerValue(spec, "版本"));
128
+ const updated = Math.max(mtime(specFile), mtime(stateFile), mtime(acceptanceFile), mtime(path.join(dir, "changes.md")));
129
+ return {
130
+ name,
131
+ featureDir: `${FEATURES_DIR.split(path.sep).join("/")}/${name}`,
132
+ worktree: worktree.path,
133
+ branch: worktree.branch,
134
+ title: (/^#\s+(.+)$/m.exec(spec)?.[1] ?? name).trim(),
135
+ type: headerValue(spec, "类型"),
136
+ specVersion: Number.isInteger(version) && version > 0 ? version : null,
137
+ confirmed,
138
+ profile: specProfile(spec),
139
+ stage: stageOf(confirmed, state, acceptance),
140
+ updatedAt: new Date(updated || Date.now()).toISOString(),
141
+ usage: state?.history && state.history.length > 0 ? describeTotals(totals(state.history)) : null,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Every requirement visible from `repo`'s worktrees. The same requirement can
147
+ * appear in several worktrees (e.g. merged into main and still on its branch);
148
+ * the copy changed most recently wins.
149
+ */
150
+ export function listFeatures(repo: string): FeatureSummary[] {
151
+ const byName = new Map<string, FeatureSummary>();
152
+ for (const worktree of worktrees(repo)) {
153
+ const root = path.join(worktree.path, FEATURES_DIR);
154
+ if (!fs.existsSync(root)) continue;
155
+ for (const name of fs.readdirSync(root)) {
156
+ const summary = summarize(worktree, name);
157
+ if (!summary) continue;
158
+ const seen = byName.get(name);
159
+ if (!seen || summary.updatedAt > seen.updatedAt) byName.set(name, summary);
160
+ }
161
+ }
162
+ return [...byName.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
163
+ }
164
+
165
+ /** Same directory, following symlinks (e.g. macOS /tmp) and the platform's case rules. */
166
+ function isSameDir(a: string, b: string): boolean {
167
+ const real = (p: string) => {
168
+ try {
169
+ return fs.realpathSync(p);
170
+ } catch {
171
+ return path.resolve(p);
172
+ }
173
+ };
174
+ return samePath(real(a), real(b));
175
+ }
176
+
177
+ export function describeFeatures(features: FeatureSummary[], here: string): string {
178
+ if (features.length === 0) return "这个项目里还没有需求(docs/features 下没有 spec.md)。";
179
+ const lines = [`共 ${features.length} 个需求(按最近更新排序):`, ""];
180
+ features.forEach((f, i) => {
181
+ const facts = [
182
+ f.stage,
183
+ f.type,
184
+ f.profile ? `${PROFILE_LABELS[f.profile]}档` : null,
185
+ f.specVersion && f.specVersion > 1 ? `需求第 ${f.specVersion} 版` : null,
186
+ `更新于 ${new Date(f.updatedAt).toLocaleString("zh-CN", { hour12: false })}`,
187
+ ].filter(Boolean);
188
+ lines.push(`${i + 1}. ${f.title}(${f.featureDir})`);
189
+ lines.push(` ${facts.join(";")}`);
190
+ const where = isSameDir(f.worktree, here) ? "当前工作区" : `工作区 ${f.worktree}`;
191
+ lines.push(` 位置:${where}${f.branch ? `,分支 ${f.branch}` : ""}`);
192
+ if (f.usage) lines.push(` 用时与用量:${f.usage}`);
193
+ });
194
+ return lines.join("\n");
195
+ }
196
+
197
+ /**
198
+ * Lets people name a requirement the short way. Accepts a feature directory
199
+ * (docs/features/20261012-sms-login), its name (20261012-sms-login) or a unique part
200
+ * of the name (sms-login). Returns the directory relative to the repository, "/"-separated.
201
+ */
202
+ export function resolveFeatureArg(repo: string, arg: string): string {
203
+ const trimmed = arg.trim().replace(/[\\/]+$/, "");
204
+ if (/[\\/]/.test(trimmed) || fs.existsSync(path.join(repo, trimmed, "spec.md"))) return trimmed;
205
+ const root = path.join(repo, FEATURES_DIR);
206
+ const prefix = FEATURES_DIR.split(path.sep).join("/");
207
+ const names = fs.existsSync(root) ? fs.readdirSync(root).filter((n) => fs.existsSync(path.join(root, n, "spec.md"))) : [];
208
+ if (names.includes(trimmed)) return `${prefix}/${trimmed}`;
209
+ const matches = names.filter((n) => n.toLowerCase().includes(trimmed.toLowerCase()));
210
+ if (matches.length === 1) return `${prefix}/${matches[0]}`;
211
+ if (matches.length > 1) {
212
+ throw new Error(`"${arg}" 对应多个需求:${matches.join("、")}。请写完整的名称。`);
213
+ }
214
+ throw new Error(`在 ${prefix} 下没有找到 "${arg}" 对应的需求。用 --list 查看全部需求。`);
215
+ }
216
+
217
+ /** Requirements in this worktree that have been handed to the pipeline at least once. */
218
+ export function featuresWithRuns(repo: string): string[] {
219
+ const root = path.join(repo, FEATURES_DIR);
220
+ if (!fs.existsSync(root)) return [];
221
+ const prefix = FEATURES_DIR.split(path.sep).join("/");
222
+ return fs
223
+ .readdirSync(root)
224
+ .filter((n) => fs.existsSync(path.join(root, n, ".pipeline", "state.json")))
225
+ .map((n) => `${prefix}/${n}`);
226
+ }
227
+
228
+ /** A requirement developed in a worktree that the pipeline created for it, from `sourceRepo`. */
229
+ export interface WorktreeRun {
230
+ repo: string;
231
+ featureDir: string;
232
+ workspaceId: string;
233
+ }
234
+
235
+ /**
236
+ * Finds the worktree run of a requirement started from this checkout (--worktree), by
237
+ * feature directory or by (part of) its name. Null when there is none.
238
+ */
239
+ export function findWorktreeRun(sourceRepo: string, feature: string): WorktreeRun | null {
240
+ const wanted = feature.trim().replace(/[\\/]+$/, "");
241
+ const name = wanted.split(/[\\/]/).pop() ?? wanted;
242
+ const prefix = FEATURES_DIR.split(path.sep).join("/");
243
+ const matches: WorktreeRun[] = [];
244
+ for (const wt of worktrees(sourceRepo)) {
245
+ if (isSameDir(wt.path, sourceRepo)) continue;
246
+ const root = path.join(wt.path, FEATURES_DIR);
247
+ if (!fs.existsSync(root)) continue;
248
+ for (const dir of fs.readdirSync(root)) {
249
+ if (!(dir === name || dir.toLowerCase().includes(name.toLowerCase()))) continue;
250
+ try {
251
+ const state = JSON.parse(fs.readFileSync(path.join(root, dir, ".pipeline", "state.json"), "utf8")) as {
252
+ worktree?: { workspaceId?: string; sourceRepo?: string } | null;
253
+ status?: string;
254
+ phase?: string;
255
+ };
256
+ // The chat it was started from; or, for testing, any chat of the same repository (ADR 0022).
257
+ const forTesting = state.status === "phase_done" || state.phase === "verify";
258
+ if (state.worktree?.workspaceId && state.worktree.sourceRepo && (isSameDir(state.worktree.sourceRepo, sourceRepo) || forTesting)) {
259
+ matches.push({ repo: fs.realpathSync(wt.path), featureDir: `${prefix}/${dir}`, workspaceId: state.worktree.workspaceId });
260
+ }
261
+ } catch {
262
+ // No run there.
263
+ }
264
+ }
265
+ }
266
+ const exact = matches.filter((m) => m.featureDir.endsWith(`/${name}`));
267
+ return (exact.length === 1 ? exact[0] : matches.length === 1 ? matches[0] : null) ?? null;
268
+ }
@@ -0,0 +1,30 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export interface GitInfo {
4
+ isRepo: boolean;
5
+ /** Current branch; null when not a repository or HEAD is detached. */
6
+ branch: string | null;
7
+ detached: boolean;
8
+ /** Branches that must never receive the pipeline's commits. */
9
+ protectedBranches: string[];
10
+ }
11
+
12
+ function git(repo: string, args: string[]): string | null {
13
+ try {
14
+ return execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function gitInfo(repo: string): GitInfo {
21
+ if (git(repo, ["rev-parse", "--is-inside-work-tree"]) !== "true") {
22
+ return { isRepo: false, branch: null, detached: false, protectedBranches: [] };
23
+ }
24
+ // symbolic-ref works before the first commit, unlike rev-parse --abbrev-ref HEAD.
25
+ const branch = git(repo, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
26
+ const protectedBranches = new Set(["main", "master"]);
27
+ const originHead = git(repo, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
28
+ if (originHead) protectedBranches.add(originHead.replace(/^origin\//, ""));
29
+ return { isRepo: true, branch, detached: branch === null, protectedBranches: [...protectedBranches] };
30
+ }