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,479 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { NOTIFIER_EVENT_TYPES, type NotifierConfig } from "./notifiers.js";
5
+ import type { StepId } from "./steps.js";
6
+
7
+ export interface AgentProfile {
8
+ /**
9
+ * Name (or ID) of a Paseo agent profile to take the settings from, e.g. "实现".
10
+ * Resolved against the daemon when a run starts; fields written next to it win.
11
+ * See paseo-profiles.ts.
12
+ */
13
+ paseoProfile?: string;
14
+ /**
15
+ * "provider/model", split at the first "/". Examples: "claude/claude-sonnet-5",
16
+ * "pi/anthropic/claude-sonnet-5" (Pi model IDs carry their own "upstream/model").
17
+ * Run `npm run smoke -- check` to list what your daemon offers. Required unless
18
+ * paseoProfile is given.
19
+ */
20
+ provider?: string;
21
+ /**
22
+ * Provider mode. Use one that does not stop for approvals, e.g. "bypassPermissions" (Claude)
23
+ * or "full-access" (Codex). Omit it for providers without modes, such as Pi.
24
+ */
25
+ modeId?: string;
26
+ thinkingOptionId?: string;
27
+ /** Provider feature toggles, e.g. { "auto_accept": true } for OpenCode. */
28
+ featureValues?: Record<string, unknown>;
29
+ /** Provider-native options, passed through unchanged. */
30
+ options?: Record<string, unknown>;
31
+ }
32
+
33
+ /** Settings ready to launch an agent: a Paseo profile, if named, has been resolved. */
34
+ export type ResolvedProfile = AgentProfile & { provider: string };
35
+
36
+ export function isResolved(profile: AgentProfile): profile is ResolvedProfile {
37
+ return typeof profile.provider === "string" && profile.provider.includes("/");
38
+ }
39
+
40
+ export interface ReviewPanelMember extends AgentProfile {
41
+ /** Short name shown in titles and the consolidated review, e.g. "安全视角". */
42
+ name?: string;
43
+ /** Extra focus for this member, appended to its prompt, e.g. "重点检查权限和数据安全". */
44
+ focus?: string;
45
+ }
46
+
47
+ export interface ReviewPanelSettings {
48
+ enabled: boolean;
49
+ /** Which reviews use the panel (first round only; later rounds check fixes with the single reviewer). */
50
+ steps: Array<"design_review" | "code_review">;
51
+ /** Which profiles use the panel. */
52
+ profiles: ProfileName[];
53
+ /** Reviewers that run in parallel; the configured reviewer then consolidates their findings. */
54
+ members: ReviewPanelMember[];
55
+ }
56
+
57
+ export type ProfileName = "quick" | "standard";
58
+
59
+ export const PROFILE_NAMES: readonly ProfileName[] = ["quick", "standard"];
60
+
61
+ export const PROFILE_LABELS: Record<ProfileName, string> = { quick: "快速", standard: "标准" };
62
+
63
+ export interface ProfileSettings {
64
+ /** Maximum number of rounds for each review/fix loop. */
65
+ rounds: { designReview: number; codeReview: number; defectFix: number };
66
+ /** Run the service start check when the design says the project has a long-running service. */
67
+ serviceStart: boolean;
68
+ }
69
+
70
+ /**
71
+ * How the develop phase (design → code review → handoff) and the verify phase
72
+ * (service start → verification → defect fixes) relate. See ADR 0022.
73
+ * - continuous: verification follows straight away (the behaviour before 1.7)
74
+ * - gated: the run stops after the handoff ("研发完成,待测试验证"); someone starts the verify phase later
75
+ * - develop-only: the run ends after the handoff with a development report; people test by hand
76
+ */
77
+ export type PhaseMode = "continuous" | "gated" | "develop-only";
78
+
79
+ export const PHASE_MODES: readonly PhaseMode[] = ["continuous", "gated", "develop-only"];
80
+
81
+ export const PHASE_MODE_LABELS: Record<PhaseMode, string> = { continuous: "连续", gated: "分阶段", "develop-only": "只做研发" };
82
+
83
+ /** Where the verify phase checks the result: services started here, or an environment deployed for testing. */
84
+ export type VerifyEnvironment = "local" | "deployed";
85
+
86
+ /** What the verify phase does with defects: fix and re-verify, hand back to a new develop round, or only record them. */
87
+ export type DefectHandling = "fix" | "handback" | "report";
88
+
89
+ export interface VerifyPhaseSettings {
90
+ environment: VerifyEnvironment;
91
+ /** Agents for the verify-phase steps, per role; a role left out uses the top-level agents. */
92
+ agents: Partial<Record<"builder" | "reviewer", AgentProfile>>;
93
+ defects: DefectHandling;
94
+ /** Run the service start check in the verify phase; null: as the profile says. Ignored for "deployed". */
95
+ serviceStart: boolean | null;
96
+ }
97
+
98
+ export interface PhaseSettings {
99
+ mode: PhaseMode;
100
+ verify: VerifyPhaseSettings;
101
+ }
102
+
103
+ export interface PipelineConfig {
104
+ daemonUrl: string;
105
+ /** Name of the environment variable holding the daemon password, if the daemon has one. */
106
+ daemonPasswordEnv: string;
107
+ agents: Record<"builder" | "reviewer", AgentProfile>;
108
+ /** Profile used when neither --profile nor the spec names one. */
109
+ defaultProfile: ProfileName;
110
+ profiles: Record<ProfileName, ProfileSettings>;
111
+ stepTimeoutMinutes: Record<StepId, number>;
112
+ /** Send short progress notes to the chat at key points (design settled, review done, fixing defects). */
113
+ notifyMilestones: boolean;
114
+ /** Length of one waitForFinish() call. Long waits are split so a dropped connection costs little. */
115
+ waitChunkSeconds: number;
116
+ /** How often to re-check an agent that is waiting for a permission decision. */
117
+ permissionPollSeconds: number;
118
+ /** How many times to ask an agent to fix a missing or invalid result file. */
119
+ resultFollowUps: number;
120
+ /** Create step agents as subagents of the chat that launched the pipeline. */
121
+ attachToOriginChat: boolean;
122
+ /** Archive each step agent after its step completes. */
123
+ archiveStepAgents: boolean;
124
+ /** Machine config only: path of a team config file merged between the machine and project configs. */
125
+ teamConfig: string | null;
126
+ /** Range of req-kit versions this team or project expects, e.g. ">=1.2.0 <2". Checked, never enforced. */
127
+ requiredVersion: string | null;
128
+ /** Optional review panel: several reviewers in parallel, then a consolidation. Off by default. */
129
+ reviewPanel: ReviewPanelSettings;
130
+ /** External notifiers (e.g. an internal IM). Machine or team config only. */
131
+ notifiers: NotifierConfig[];
132
+ /** Automatic retries of a step after a transient error (rate limit, dropped connection). */
133
+ retry: { attempts: number; backoffSeconds: number[] };
134
+ /**
135
+ * "auto": when the chat's workspace has service scripts in paseo.json, the pipeline starts
136
+ * them through Paseo (supervised, own port per worktree) for the service start and
137
+ * verification steps, and stops them afterwards. "off": step agents start services themselves.
138
+ */
139
+ paseoServices: "auto" | "off";
140
+ /** How long to wait for Paseo-managed services to report healthy. */
141
+ serviceReadySeconds: number;
142
+ /** Warn before a run when a provider's subscription window is at least this full (percent); 0 turns it off. */
143
+ quotaWarnPercent: number;
144
+ /**
145
+ * Parallel design proposals: before the design step, several agents (different models
146
+ * work best) each propose an approach; the design step compares them and decides.
147
+ * Members default to the builder and the reviewer. Off by default (it costs extra runs).
148
+ */
149
+ designProposals: { enabled: boolean; profiles: ProfileName[]; members: ReviewPanelMember[] };
150
+ /** "auto": tell verification steps to use Paseo's browser tools for web pages when they are on. */
151
+ browserVerify: "auto" | "off";
152
+ /** When /req offers the independent spec review before confirmation: "standard" (standard profile only), "always", "off". */
153
+ specReview: "standard" | "always" | "off";
154
+ /** Machine-wide limit on pipelines running at once; later ones queue. 0: no limit. */
155
+ maxConcurrentPipelines: number;
156
+ /** How often a queued pipeline checks for a free slot. */
157
+ queuePollSeconds: number;
158
+ /** Remind the chat (a Paseo schedule) when a decision has been waited for this long; 0: never. */
159
+ waitingReminderHours: number;
160
+ /** After a stop caused by a transient error, continue automatically after this many minutes (through the chat); 0: never. */
161
+ autoResumeMinutes: number;
162
+ /** How many automatic continuations per attempt. */
163
+ autoResumeMax: number;
164
+ /** Ask the chat to check on a running pipeline this often and continue it if the process died; 0: off. */
165
+ watchdogMinutes: number;
166
+ /** Develop and verify phases: continuous (default), gated, or develop-only, plus verify-phase options. */
167
+ phases: PhaseSettings;
168
+ }
169
+
170
+ export const DEFAULT_CONFIG: PipelineConfig = {
171
+ daemonUrl: "ws://127.0.0.1:6767/ws",
172
+ daemonPasswordEnv: "PASEO_DAEMON_PASSWORD",
173
+ agents: {
174
+ builder: { provider: "claude/CHANGE-ME", modeId: "bypassPermissions" },
175
+ reviewer: { provider: "codex/CHANGE-ME", modeId: "full-access" },
176
+ },
177
+ defaultProfile: "standard",
178
+ profiles: {
179
+ quick: { rounds: { designReview: 1, codeReview: 1, defectFix: 1 }, serviceStart: false },
180
+ standard: { rounds: { designReview: 2, codeReview: 3, defectFix: 3 }, serviceStart: true },
181
+ },
182
+ stepTimeoutMinutes: {
183
+ design: 60,
184
+ design_review: 30,
185
+ design_revise: 30,
186
+ implement: 240,
187
+ code_review: 45,
188
+ review_fix: 90,
189
+ service_start: 30,
190
+ verify: 120,
191
+ defect_fix: 120,
192
+ reverify: 90,
193
+ report: 30,
194
+ handoff: 30,
195
+ panel_review: 45,
196
+ spec_review: 20,
197
+ design_proposal: 45,
198
+ },
199
+ notifyMilestones: true,
200
+ waitChunkSeconds: 600,
201
+ permissionPollSeconds: 30,
202
+ resultFollowUps: 2,
203
+ attachToOriginChat: true,
204
+ archiveStepAgents: false,
205
+ teamConfig: null,
206
+ requiredVersion: null,
207
+ reviewPanel: { enabled: false, steps: ["design_review", "code_review"], profiles: ["standard"], members: [] },
208
+ notifiers: [],
209
+ retry: { attempts: 2, backoffSeconds: [60, 300] },
210
+ paseoServices: "auto",
211
+ serviceReadySeconds: 120,
212
+ quotaWarnPercent: 85,
213
+ designProposals: { enabled: false, profiles: ["standard"], members: [] },
214
+ browserVerify: "auto",
215
+ specReview: "standard",
216
+ maxConcurrentPipelines: 0,
217
+ queuePollSeconds: 60,
218
+ waitingReminderHours: 24,
219
+ autoResumeMinutes: 30,
220
+ autoResumeMax: 2,
221
+ watchdogMinutes: 0,
222
+ phases: { mode: "continuous", verify: { environment: "local", agents: {}, defects: "fix", serviceStart: null } },
223
+ };
224
+
225
+ /** Project file, at the repository root, that overrides the machine-wide configuration. */
226
+ export const PROJECT_CONFIG_FILE = ".requirement-pipeline.json";
227
+
228
+ /** Settings that describe this machine, not a project; a project file may not change them. */
229
+ export const MACHINE_ONLY_KEYS = ["daemonUrl", "daemonPasswordEnv", "teamConfig"] as const;
230
+
231
+ export class ConfigError extends Error {
232
+ constructor(message: string) {
233
+ super(message);
234
+ this.name = "ConfigError";
235
+ }
236
+ }
237
+
238
+ function isRecord(value: unknown): value is Record<string, unknown> {
239
+ return typeof value === "object" && value !== null && !Array.isArray(value);
240
+ }
241
+
242
+ function readJson(file: string, label: string): Record<string, unknown> {
243
+ let raw: unknown;
244
+ try {
245
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
246
+ } catch (error) {
247
+ throw new ConfigError(`${label}不是合法的 JSON(${file}):${(error as Error).message}`);
248
+ }
249
+ if (!isRecord(raw)) throw new ConfigError(`${label}的顶层必须是对象(${file})`);
250
+ return raw;
251
+ }
252
+
253
+ /** Layers `raw` over `base`. Agents are replaced per role; nested settings are merged per key. */
254
+ function merge(base: PipelineConfig, raw: Record<string, unknown>): PipelineConfig {
255
+ const merged: PipelineConfig = { ...base, ...(raw as Partial<PipelineConfig>) };
256
+ merged.agents = { ...base.agents, ...(isRecord(raw.agents) ? raw.agents : {}) } as PipelineConfig["agents"];
257
+ merged.stepTimeoutMinutes = {
258
+ ...base.stepTimeoutMinutes,
259
+ ...(isRecord(raw.stepTimeoutMinutes) ? raw.stepTimeoutMinutes : {}),
260
+ } as PipelineConfig["stepTimeoutMinutes"];
261
+
262
+ const profiles = { ...base.profiles };
263
+ // COMPAT: configs written before profiles existed had a top-level "rounds" for the standard run.
264
+ if (isRecord(raw.rounds)) {
265
+ profiles.standard = { ...profiles.standard, rounds: { ...profiles.standard.rounds, ...raw.rounds } };
266
+ }
267
+ if (isRecord(raw.profiles)) {
268
+ for (const name of PROFILE_NAMES) {
269
+ const override = raw.profiles[name];
270
+ if (!isRecord(override)) continue;
271
+ profiles[name] = {
272
+ ...profiles[name],
273
+ ...(override as Partial<ProfileSettings>),
274
+ rounds: { ...profiles[name].rounds, ...(isRecord(override.rounds) ? override.rounds : {}) },
275
+ };
276
+ }
277
+ }
278
+ merged.profiles = profiles;
279
+ merged.retry = { ...base.retry, ...(isRecord(raw.retry) ? (raw.retry as Partial<PipelineConfig["retry"]>) : {}) };
280
+ merged.reviewPanel = { ...base.reviewPanel, ...(isRecord(raw.reviewPanel) ? (raw.reviewPanel as Partial<ReviewPanelSettings>) : {}) };
281
+ merged.designProposals = {
282
+ ...base.designProposals,
283
+ ...(isRecord(raw.designProposals) ? (raw.designProposals as Partial<PipelineConfig["designProposals"]>) : {}),
284
+ };
285
+ const rawPhases = isRecord(raw.phases) ? raw.phases : {};
286
+ const rawVerify = isRecord(rawPhases.verify) ? rawPhases.verify : {};
287
+ merged.phases = {
288
+ ...base.phases,
289
+ ...(rawPhases as Partial<PhaseSettings>),
290
+ verify: {
291
+ ...base.phases.verify,
292
+ ...(rawVerify as Partial<VerifyPhaseSettings>),
293
+ agents: { ...base.phases.verify.agents, ...(isRecord(rawVerify.agents) ? rawVerify.agents : {}) },
294
+ },
295
+ };
296
+ delete (merged as unknown as Record<string, unknown>).rounds;
297
+ return merged;
298
+ }
299
+
300
+ function validateProvider(label: string, profile: unknown): void {
301
+ if (isRecord(profile) && profile.paseoProfile !== undefined) {
302
+ if (typeof profile.paseoProfile !== "string" || profile.paseoProfile.trim() === "") {
303
+ throw new ConfigError(`${label}.paseoProfile 必须是 Paseo 中 agent profile 的名称`);
304
+ }
305
+ if (profile.provider === undefined) return;
306
+ }
307
+ if (!isRecord(profile) || typeof profile.provider !== "string") {
308
+ throw new ConfigError(`${label}.provider 缺失(或用 paseoProfile 写 Paseo 中 agent profile 的名称)`);
309
+ }
310
+ const slash = profile.provider.indexOf("/");
311
+ if (slash <= 0 || slash === profile.provider.length - 1) {
312
+ throw new ConfigError(`${label}.provider 必须是 "provider/model" 格式,当前为 "${profile.provider}"`);
313
+ }
314
+ if (profile.provider.includes("CHANGE-ME")) {
315
+ throw new ConfigError(`${label}.provider 还是示例值,请在终端运行配置向导 req configure(或在 req-kit 安装目录运行 node install.mjs --configure),也可以手工填写真实的 provider/model`);
316
+ }
317
+ }
318
+
319
+ function validatePanel(panel: ReviewPanelSettings): void {
320
+ if (typeof panel.enabled !== "boolean") throw new ConfigError("reviewPanel.enabled 必须是 true 或 false");
321
+ if (!Array.isArray(panel.steps) || !panel.steps.every((s) => s === "design_review" || s === "code_review")) {
322
+ throw new ConfigError('reviewPanel.steps 只能包含 "design_review" 和 "code_review"');
323
+ }
324
+ if (!Array.isArray(panel.profiles) || !panel.profiles.every((p) => PROFILE_NAMES.includes(p))) {
325
+ throw new ConfigError('reviewPanel.profiles 只能包含 "quick" 和 "standard"');
326
+ }
327
+ if (!Array.isArray(panel.members)) throw new ConfigError("reviewPanel.members 必须是数组");
328
+ if (panel.enabled && panel.members.length === 0) {
329
+ throw new ConfigError("开启了 reviewPanel,但 reviewPanel.members 为空。请至少配置一位成员(建议 2–3 位,使用不同的模型)");
330
+ }
331
+ panel.members.forEach((member, i) => validateProvider(`reviewPanel.members[${i}]`, member));
332
+ }
333
+
334
+ function validatePhases(phases: PhaseSettings): void {
335
+ if (!PHASE_MODES.includes(phases.mode)) throw new ConfigError(`phases.mode 只能是 ${PHASE_MODES.map((m) => `"${m}"`).join("、")}`);
336
+ const v = phases.verify;
337
+ if (v.environment !== "local" && v.environment !== "deployed") throw new ConfigError('phases.verify.environment 只能是 "local" 或 "deployed"');
338
+ if (!["fix", "handback", "report"].includes(v.defects)) throw new ConfigError('phases.verify.defects 只能是 "fix"、"handback" 或 "report"');
339
+ if (v.serviceStart !== null && typeof v.serviceStart !== "boolean") throw new ConfigError("phases.verify.serviceStart 必须是 true、false 或 null");
340
+ if (!isRecord(v.agents)) throw new ConfigError("phases.verify.agents 必须是对象,例如 { \"reviewer\": { \"paseoProfile\": \"测试\" } }");
341
+ for (const [role, profile] of Object.entries(v.agents)) {
342
+ if (role !== "builder" && role !== "reviewer") throw new ConfigError("phases.verify.agents 只能包含 builder 和 reviewer");
343
+ validateProvider(`phases.verify.agents.${role}`, profile);
344
+ }
345
+ }
346
+
347
+ function validate(config: PipelineConfig): void {
348
+ for (const role of ["builder", "reviewer"] as const) validateProvider(`agents.${role}`, config.agents[role]);
349
+ if (!PROFILE_NAMES.includes(config.defaultProfile)) {
350
+ throw new ConfigError(`defaultProfile 必须是 ${PROFILE_NAMES.join(" 或 ")}`);
351
+ }
352
+ for (const name of PROFILE_NAMES) {
353
+ for (const [key, value] of Object.entries(config.profiles[name].rounds)) {
354
+ if (!Number.isInteger(value) || value < 1) {
355
+ throw new ConfigError(`profiles.${name}.rounds.${key} 必须是不小于 1 的整数`);
356
+ }
357
+ }
358
+ if (typeof config.profiles[name].serviceStart !== "boolean") {
359
+ throw new ConfigError(`profiles.${name}.serviceStart 必须是 true 或 false`);
360
+ }
361
+ }
362
+ for (const [key, value] of Object.entries(config.stepTimeoutMinutes)) {
363
+ if (typeof value !== "number" || value <= 0) throw new ConfigError(`stepTimeoutMinutes.${key} 必须是正数`);
364
+ }
365
+ validatePanel(config.reviewPanel);
366
+ if (!Number.isInteger(config.retry.attempts) || config.retry.attempts < 0) throw new ConfigError("retry.attempts 必须是不小于 0 的整数");
367
+ if (!Array.isArray(config.retry.backoffSeconds) || config.retry.backoffSeconds.length === 0 || !config.retry.backoffSeconds.every((n) => typeof n === "number" && n >= 0)) {
368
+ throw new ConfigError("retry.backoffSeconds 必须是非空的秒数数组,例如 [60, 300]");
369
+ }
370
+ if (!Array.isArray(config.notifiers)) throw new ConfigError("notifiers 必须是数组");
371
+ config.notifiers.forEach((n, i) => {
372
+ if (!isRecord(n) || typeof n.module !== "string" || n.module.trim() === "") {
373
+ throw new ConfigError(`notifiers[${i}].module 必须是通知模块文件的路径`);
374
+ }
375
+ if (n.events !== undefined && (!Array.isArray(n.events) || !n.events.every((e) => NOTIFIER_EVENT_TYPES.includes(e)))) {
376
+ throw new ConfigError(`notifiers[${i}].events 只能包含 ${NOTIFIER_EVENT_TYPES.join("、")}`);
377
+ }
378
+ });
379
+ if (config.requiredVersion !== null && typeof config.requiredVersion !== "string") {
380
+ throw new ConfigError('requiredVersion 必须是版本范围字符串,例如 ">=1.2.0 <2"');
381
+ }
382
+ if (config.paseoServices !== "auto" && config.paseoServices !== "off") throw new ConfigError('paseoServices 只能是 "auto" 或 "off"');
383
+ if (config.browserVerify !== "auto" && config.browserVerify !== "off") throw new ConfigError('browserVerify 只能是 "auto" 或 "off"');
384
+ if (!["standard", "always", "off"].includes(config.specReview)) throw new ConfigError('specReview 只能是 "standard"、"always" 或 "off"');
385
+ const proposals = config.designProposals;
386
+ if (typeof proposals.enabled !== "boolean") throw new ConfigError("designProposals.enabled 必须是 true 或 false");
387
+ if (!Array.isArray(proposals.profiles) || !proposals.profiles.every((p) => PROFILE_NAMES.includes(p))) {
388
+ throw new ConfigError('designProposals.profiles 只能包含 "quick" 和 "standard"');
389
+ }
390
+ if (!Array.isArray(proposals.members)) throw new ConfigError("designProposals.members 必须是数组");
391
+ proposals.members.forEach((member, i) => validateProvider(`designProposals.members[${i}]`, member));
392
+ if (typeof config.quotaWarnPercent !== "number" || config.quotaWarnPercent < 0 || config.quotaWarnPercent > 100) {
393
+ throw new ConfigError("quotaWarnPercent 必须是 0 到 100 之间的数(0 表示不提醒)");
394
+ }
395
+ for (const key of ["maxConcurrentPipelines", "waitingReminderHours", "autoResumeMinutes", "autoResumeMax", "watchdogMinutes"] as const) {
396
+ if (typeof config[key] !== "number" || config[key] < 0) throw new ConfigError(`${key} 必须是不小于 0 的数(0 表示关闭)`);
397
+ }
398
+ validatePhases(config.phases);
399
+ for (const key of ["waitChunkSeconds", "permissionPollSeconds", "serviceReadySeconds", "queuePollSeconds"] as const) {
400
+ if (typeof config[key] !== "number" || config[key] <= 0) throw new ConfigError(`${key} 必须是正数`);
401
+ }
402
+ }
403
+
404
+ export interface LoadedConfig {
405
+ config: PipelineConfig;
406
+ /** Files that contributed, in the order they were applied. */
407
+ sources: string[];
408
+ /** Settings a team or project file tried to set but may not, e.g. "项目配置中的 daemonUrl". */
409
+ ignored: string[];
410
+ }
411
+
412
+ /** Keys a project file may not set: they run code or reach outside the repository. */
413
+ const NOT_IN_PROJECT_KEYS = ["notifiers"] as const;
414
+
415
+ /** Notifier module paths are relative to the file that names them; "~/" is the home directory. */
416
+ function resolveNotifiers(raw: Record<string, unknown>, file: string): Record<string, unknown> {
417
+ if (!Array.isArray(raw.notifiers)) return raw;
418
+ raw.notifiers = raw.notifiers.map((n: unknown) => {
419
+ if (!isRecord(n) || typeof n.module !== "string") return n;
420
+ const module = n.module.startsWith("~/") || n.module.startsWith("~\\")
421
+ ? path.join(os.homedir(), n.module.slice(2))
422
+ : path.resolve(path.dirname(file), n.module);
423
+ return { ...n, module };
424
+ });
425
+ return raw;
426
+ }
427
+
428
+ /** Removes machine-only keys from a shared (team or project) file, recording them. */
429
+ function stripMachineKeys(
430
+ raw: Record<string, unknown>,
431
+ label: string,
432
+ ignored: string[],
433
+ extra: readonly string[] = [],
434
+ ): Record<string, unknown> {
435
+ for (const key of [...MACHINE_ONLY_KEYS, ...extra]) {
436
+ if (key in raw) {
437
+ ignored.push(`${label}中的 ${key}`);
438
+ delete raw[key];
439
+ }
440
+ }
441
+ return raw;
442
+ }
443
+
444
+ /**
445
+ * Layers, later wins: machine file (~/.req-kit/config.json by default) → team file named by
446
+ * the machine file's teamConfig → the project's own file at the repository root when `repo`
447
+ * is given. Machine-only keys (daemon address and password, teamConfig) are honoured only
448
+ * in the machine file.
449
+ */
450
+ export function loadConfig(configPath: string, repo?: string): LoadedConfig {
451
+ if (!fs.existsSync(configPath)) {
452
+ throw new ConfigError(
453
+ `找不到配置文件 ${configPath}。请在 req-kit 的安装目录运行 node install.mjs,它会生成这个文件;然后填写两个 agent 的 provider/model。`,
454
+ );
455
+ }
456
+ let config = merge(DEFAULT_CONFIG, resolveNotifiers(readJson(configPath, "配置文件"), configPath));
457
+ const sources = [configPath];
458
+ const ignored: string[] = [];
459
+
460
+ if (config.teamConfig) {
461
+ const teamFile = path.resolve(path.dirname(configPath), config.teamConfig);
462
+ if (!fs.existsSync(teamFile)) {
463
+ throw new ConfigError(`找不到本机配置中 teamConfig 指向的团队配置文件:${teamFile}`);
464
+ }
465
+ config = merge(config, resolveNotifiers(stripMachineKeys(readJson(teamFile, "团队配置文件"), "团队配置", ignored), teamFile));
466
+ sources.push(teamFile);
467
+ }
468
+
469
+ if (repo) {
470
+ const projectFile = path.join(repo, PROJECT_CONFIG_FILE);
471
+ if (fs.existsSync(projectFile)) {
472
+ config = merge(config, stripMachineKeys(readJson(projectFile, "项目配置文件"), "项目配置", ignored, NOT_IN_PROJECT_KEYS));
473
+ sources.push(projectFile);
474
+ }
475
+ }
476
+ validate(config);
477
+ return { config, sources, ignored };
478
+ }
479
+