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,237 @@
1
+ // Configuration wizard: `node install.mjs --configure` (or `req configure`).
2
+ // Lists what the local Paseo daemon offers and writes ~/.req-kit/config.json from
3
+ // a few multiple-choice questions, so nobody has to hand-edit JSON or guess mode names.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { parseArgs } from "node:util";
9
+ import type { AgentProfile } from "./config.js";
10
+ import { connect } from "./daemon-check.js";
11
+ import { type PaseoProfile, profilesFromDaemonConfig } from "./paseo-profiles.js";
12
+ import { defaultConfigPath, KIT_ROOT } from "./locations.js";
13
+
14
+ export interface CatalogEntry {
15
+ id: string;
16
+ label?: string;
17
+ }
18
+
19
+ /** What the daemon offers; the real one wraps the Paseo SDK, tests use a fixed one. */
20
+ export interface Catalog {
21
+ providers(): Promise<Array<CatalogEntry & { status: string }>>;
22
+ models(provider: string): Promise<CatalogEntry[]>;
23
+ modes(provider: string): Promise<CatalogEntry[]>;
24
+ /** Agent profiles set up in Paseo; optional. */
25
+ profiles?(): Promise<PaseoProfile[]>;
26
+ }
27
+
28
+ export interface Choice<T> {
29
+ label: string;
30
+ value: T;
31
+ }
32
+
33
+ /** Asks one multiple-choice question; options[0] is the recommendation. */
34
+ export type Chooser = <T>(question: string, options: Choice<T>[]) => Promise<T>;
35
+
36
+ const ROLE_TEXT = {
37
+ builder: "builder(方案设计、写代码、修复、写报告)",
38
+ reviewer: "reviewer(方案审核、代码评审、功能验证)",
39
+ } as const;
40
+
41
+ /** Mode that never stops for approval, per provider; null when the provider has no modes. */
42
+ export function unattendedMode(provider: string, modes: CatalogEntry[]): string | null {
43
+ if (modes.length === 0) return null;
44
+ const preferred: Record<string, string[]> = {
45
+ claude: ["bypassPermissions"],
46
+ codex: ["full-access"],
47
+ opencode: ["build"],
48
+ };
49
+ const ids = modes.map((m) => m.id);
50
+ for (const id of preferred[provider] ?? ["full-access", "bypassPermissions", "build"]) {
51
+ if (ids.includes(id)) return id;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Builds one role's agent settings from the chosen provider, model and mode. */
57
+ export function profileFor(provider: string, model: string, modeId: string | null): AgentProfile {
58
+ const profile: AgentProfile = { provider: `${provider}/${model}` };
59
+ if (modeId) profile.modeId = modeId;
60
+ if (provider === "opencode") profile.featureValues = { auto_accept: true };
61
+ return profile;
62
+ }
63
+
64
+ const ROLE_HINTS = {
65
+ builder: /实现|开发|编码|写代码|implement|build|cod/i,
66
+ reviewer: /评审|审核|审查|检查|review|audit/i,
67
+ } as const;
68
+
69
+ /** Offers the Paseo profiles first, when there are any; null means "choose the agent directly". */
70
+ async function chooseProfile(
71
+ role: "builder" | "reviewer",
72
+ profiles: PaseoProfile[],
73
+ choose: Chooser,
74
+ avoidProvider: string | null,
75
+ ): Promise<PaseoProfile | null> {
76
+ const usable = profiles.filter((p) => p.model);
77
+ if (usable.length === 0) return null;
78
+ const score = (p: PaseoProfile) =>
79
+ (ROLE_HINTS[role].test(`${p.name} ${p.notes ?? ""}`) ? 2 : 0) + (avoidProvider && p.provider !== avoidProvider ? 1 : 0);
80
+ const ordered = [...usable].sort((a, b) => score(b) - score(a));
81
+ const picked = await choose<PaseoProfile | null>(`${ROLE_TEXT[role]}用 Paseo 中的哪个 agent profile?`, [
82
+ ...ordered.map((p) => ({
83
+ label: `「${p.name}」:${p.provider}/${p.model}${p.modeId ? `,模式 ${p.modeId}` : ""}${p.notes ? `(${p.notes.slice(0, 40)})` : ""}`,
84
+ value: p as PaseoProfile | null,
85
+ })),
86
+ { label: "不用 profile,直接选择 agent 和模型", value: null },
87
+ ]);
88
+ return picked;
89
+ }
90
+
91
+ async function chooseRole(
92
+ role: "builder" | "reviewer",
93
+ catalog: Catalog,
94
+ choose: Chooser,
95
+ avoidProvider: string | null,
96
+ profiles: PaseoProfile[] = [],
97
+ ): Promise<AgentProfile> {
98
+ const fromPaseo = await chooseProfile(role, profiles, choose, avoidProvider);
99
+ if (fromPaseo) return { paseoProfile: fromPaseo.name };
100
+ const providers = (await catalog.providers()).filter((p) => p.status === "ready");
101
+ if (providers.length === 0) throw new Error("daemon 上没有可用的 agent。请先在 Paseo 中安装并登录至少一种 agent。");
102
+ // Recommend a different provider for the reviewer: a second model catches different problems.
103
+ const ordered = avoidProvider ? [...providers.filter((p) => p.id !== avoidProvider), ...providers.filter((p) => p.id === avoidProvider)] : providers;
104
+ const provider = await choose(
105
+ `${ROLE_TEXT[role]}用哪个 agent?${avoidProvider ? "(建议与 builder 不同,评审更容易发现问题)" : ""}`,
106
+ ordered.map((p) => ({ label: p.label ? `${p.label}(${p.id})` : p.id, value: p.id })),
107
+ );
108
+ const models = await catalog.models(provider);
109
+ if (models.length === 0) throw new Error(`${provider} 没有列出可用的模型。请在 Paseo 中检查它的登录状态。`);
110
+ const model = await choose(
111
+ `${ROLE_TEXT[role]}用哪个模型?`,
112
+ models.map((m) => ({ label: m.label && m.label !== m.id ? `${m.label}(${m.id})` : m.id, value: m.id })),
113
+ );
114
+ const modes = await catalog.modes(provider);
115
+ let modeId = unattendedMode(provider, modes);
116
+ if (modes.length > 0 && !modeId) {
117
+ modeId = await choose(
118
+ `没有找到 ${provider} 的免确认模式,请选择一个不会停下来等待批准的模式:`,
119
+ modes.map((m) => ({ label: m.label ? `${m.label}(${m.id})` : m.id, value: m.id })),
120
+ );
121
+ }
122
+ return profileFor(provider, model, modeId);
123
+ }
124
+
125
+ export interface WizardResult {
126
+ config: Record<string, unknown>;
127
+ summary: string[];
128
+ }
129
+
130
+ /** Asks the questions and returns the new config (existing settings are kept). */
131
+ export async function runWizard(existing: Record<string, unknown>, catalog: Catalog, choose: Chooser): Promise<WizardResult> {
132
+ const profiles = (await catalog.profiles?.().catch(() => [])) ?? [];
133
+ const providerOf = (p: AgentProfile) => (p.provider ?? profiles.find((x) => x.name === p.paseoProfile)?.provider ?? "").split("/")[0];
134
+ const builder = await chooseRole("builder", catalog, choose, null, profiles);
135
+ const reviewer = await chooseRole("reviewer", catalog, choose, providerOf(builder), profiles);
136
+ const defaultProfile = await choose("没有特别说明时,需求默认走哪个档位?", [
137
+ { label: "标准:完整的方案审核、代码评审和验证(推荐)", value: "standard" },
138
+ { label: "快速:每个环节一轮,适合以小改动为主的项目", value: "quick" },
139
+ ]);
140
+ const notifyMilestones = await choose("开发过程中要不要在对话里发几句进展(方案定了、开始验证等)?", [
141
+ { label: "要(推荐)", value: true },
142
+ { label: "不要,只发需要处理的事和最终结果", value: false },
143
+ ]);
144
+ const config = { ...existing, agents: { builder, reviewer }, defaultProfile, notifyMilestones };
145
+ const describe = (p: AgentProfile) =>
146
+ p.paseoProfile
147
+ ? `Paseo profile「${p.paseoProfile}」(在 Paseo 中修改这个 profile 即可换模型)`
148
+ : `${p.provider}${p.modeId ? `(模式 ${p.modeId})` : ""}${p.featureValues ? ",自动接受权限请求" : ""}`;
149
+ return {
150
+ config,
151
+ summary: [`builder:${describe(builder)}`, `reviewer:${describe(reviewer)}`, `默认档位:${defaultProfile}`, `进展通知:${notifyMilestones ? "开" : "关"}`],
152
+ };
153
+ }
154
+
155
+ function terminalChooser(): { choose: Chooser; close: () => void } {
156
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
157
+ const choose: Chooser = async (question, options) => {
158
+ console.log(`\n${question}`);
159
+ options.forEach((o, i) => console.log(` ${i + 1}. ${o.label}${i === 0 ? " ← 推荐" : ""}`));
160
+ for (;;) {
161
+ const answer = (await rl.question(`请输入编号(直接回车选 1):`)).trim();
162
+ if (answer === "") return options[0].value;
163
+ const n = Number(answer);
164
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1].value;
165
+ console.log(`请输入 1 到 ${options.length} 之间的编号。`);
166
+ }
167
+ };
168
+ return { choose, close: () => rl.close() };
169
+ }
170
+
171
+ async function main(): Promise<number> {
172
+ const { values } = parseArgs({ args: process.argv.slice(2), options: { config: { type: "string" } } });
173
+ const configPath = values.config ? path.resolve(values.config) : defaultConfigPath();
174
+ const existing = fs.existsSync(configPath)
175
+ ? (JSON.parse(fs.readFileSync(configPath, "utf8")) as Record<string, unknown>)
176
+ : (JSON.parse(fs.readFileSync(path.join(KIT_ROOT, "config.example.json"), "utf8")) as Record<string, unknown>);
177
+ const settings = {
178
+ daemonUrl: typeof existing.daemonUrl === "string" ? existing.daemonUrl : "ws://127.0.0.1:6767/ws",
179
+ daemonPasswordEnv: typeof existing.daemonPasswordEnv === "string" ? existing.daemonPasswordEnv : "PASEO_DAEMON_PASSWORD",
180
+ };
181
+ console.log(`配置向导:连接 Paseo daemon ${settings.daemonUrl} ……`);
182
+ let client;
183
+ try {
184
+ client = await connect(settings as never);
185
+ } catch (error) {
186
+ console.error(`无法连接 daemon:${(error as Error).message}`);
187
+ console.error(`请确认 Paseo 正在运行;设置了密码时,确认环境变量 ${settings.daemonPasswordEnv} 已设置。`);
188
+ return 1;
189
+ }
190
+ const catalog: Catalog = {
191
+ async providers() {
192
+ const snapshot = await client.providers.waitForReady({ timeoutMs: 60_000 });
193
+ return snapshot.entries.map((e) => ({ id: e.provider, status: e.status }));
194
+ },
195
+ async models(provider) {
196
+ return ((await client.providers.listModels(provider)).models ?? []).map((m) => ({ id: m.id, label: m.label }));
197
+ },
198
+ async modes(provider) {
199
+ return ((await client.providers.listModes(provider)).modes ?? []).map((m) => ({ id: m.id, label: m.label }));
200
+ },
201
+ async profiles() {
202
+ return profilesFromDaemonConfig((await client.config.get()).config) ?? [];
203
+ },
204
+ };
205
+ const terminal = terminalChooser();
206
+ try {
207
+ const { config, summary } = await runWizard(existing, catalog, terminal.choose);
208
+ console.log("\n将写入以下设置:");
209
+ for (const line of summary) console.log(` ${line}`);
210
+ const ok = await terminal.choose("确认写入吗?", [
211
+ { label: "写入", value: true },
212
+ { label: "放弃", value: false },
213
+ ]);
214
+ if (!ok) {
215
+ console.log("没有修改配置。");
216
+ return 1;
217
+ }
218
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
219
+ if (fs.existsSync(configPath)) fs.copyFileSync(configPath, `${configPath}.bak-${Date.now()}`);
220
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
221
+ console.log(`\n已写入 ${configPath}(原文件已备份)。接下来运行 npm run smoke -- check 确认两个 agent 能正常工作。`);
222
+ return 0;
223
+ } finally {
224
+ terminal.close();
225
+ await client.close().catch(() => undefined);
226
+ }
227
+ }
228
+
229
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
230
+ main().then(
231
+ (code) => process.exit(code),
232
+ (error: Error) => {
233
+ console.error(`配置向导出错:${error.message}`);
234
+ process.exit(1);
235
+ },
236
+ );
237
+ }
@@ -0,0 +1,230 @@
1
+ // --status and --stop: what a chat agent runs when the user asks "how far along
2
+ // is it?" or "stop for now". Output is plain language for relaying to the user.
3
+
4
+ import { describeActivity } from "./activity.js";
5
+ import { PHASE_MODE_LABELS, PROFILE_LABELS, type PipelineConfig } from "./config.js";
6
+ import type { AgentRuntime, RuntimeFactory } from "./runtime.js";
7
+ import { isAlive, type PipelineState, readState, type StepRecord, writeState } from "./state.js";
8
+ import { STEPS } from "./steps.js";
9
+ import { describeTotals, totals } from "./usage.js";
10
+
11
+ export function duration(fromIso: string, to: Date = new Date()): string {
12
+ const minutes = Math.floor((to.getTime() - new Date(fromIso).getTime()) / 60_000);
13
+ if (minutes < 1) return "不到 1 分钟";
14
+ if (minutes < 60) return `${minutes} 分钟`;
15
+ const hours = Math.floor(minutes / 60);
16
+ const rest = minutes % 60;
17
+ return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分`;
18
+ }
19
+
20
+ const RESULT_TEXT: Record<string, string> = {
21
+ approved: "通过",
22
+ changes_required: "需要修改",
23
+ blocked: "需要需求方决定",
24
+ pass: "通过",
25
+ defects_found: "发现问题",
26
+ defects_remaining: "仍有问题",
27
+ done: "完成",
28
+ partial: "部分完成",
29
+ completed: "已完成,可以验收",
30
+ completed_with_issues: "已完成,有遗留问题",
31
+ not_completed: "未完成",
32
+ };
33
+
34
+ function resultText(record: StepRecord): string {
35
+ const r = record.result ?? {};
36
+ if (typeof r.has_runnable_service === "boolean") return r.has_runnable_service ? "完成(项目有常驻服务)" : "完成";
37
+ if (typeof r.started === "boolean") return r.started ? "服务启动成功" : "服务启动失败";
38
+ for (const key of ["verdict", "result", "status", "outcome"]) {
39
+ const value = r[key];
40
+ if (typeof value === "string") return RESULT_TEXT[value] ?? value;
41
+ }
42
+ return "完成";
43
+ }
44
+
45
+ function stepName(record: StepRecord): string {
46
+ const title = STEPS[record.stepId].title;
47
+ return record.round !== null ? `${title}(第 ${record.round} 轮)` : title;
48
+ }
49
+
50
+ export function describeStatus(state: PipelineState | null, featureDir: string, now = new Date()): string {
51
+ if (!state) return `需求 ${featureDir} 还没有启动过开发流水线。`;
52
+ const running = state.status === "running" && isAlive(state.pid);
53
+ const statusText = running
54
+ ? "运行中"
55
+ : state.status === "running"
56
+ ? "已中断(后台进程意外退出,可以继续)"
57
+ : state.status === "waiting_for_business"
58
+ ? "已暂停,等待需求方决定"
59
+ : state.status === "phase_done"
60
+ ? "研发完成,待测试验证(提测说明见功能目录中的 handoff.md)"
61
+ : state.status === "failed"
62
+ ? "已中断,可以继续"
63
+ : state.phases?.mode === "develop-only" && state.phase !== "verify"
64
+ ? `研发已完成,待人工测试(只做研发;研发结论:${RESULT_TEXT[state.outcome ?? ""] ?? state.outcome ?? ""})`
65
+ : `已完成(${RESULT_TEXT[state.outcome ?? ""] ?? state.outcome ?? ""})`;
66
+
67
+ const lines = [
68
+ `需求:${featureDir}(档位:${PROFILE_LABELS[state.profile ?? "standard"]})`,
69
+ `状态:${running && state.queuedSince ? `排队中(本机同时运行的流水线已达上限,自 ${new Date(state.queuedSince).toLocaleString("zh-CN", { hour12: false })} 起等待,轮到时自动开始)` : statusText}`,
70
+ ...(state.phases && state.phases.mode !== "continuous"
71
+ ? [`执行方式:${PHASE_MODE_LABELS[state.phases.mode]}(当前阶段:${state.phase === "verify" ? "测试验证" : "研发"}${state.handbacks ? `,测试已退回 ${state.handbacks} 次` : ""})`]
72
+ : []),
73
+ `开始时间:${new Date(state.startedAt).toLocaleString("zh-CN", { hour12: false })},距今 ${duration(state.startedAt, now)}`,
74
+ ];
75
+ if (state.stopReason && !running) lines.push(`原因:${state.stopReason}`);
76
+ const KIND_TEXT = { reminder: "提醒", "auto-resume": "自动继续", watchdog: "定时检查", "verify-start": "开始测试验证" } as const;
77
+ for (const s of state.schedules ?? []) {
78
+ if (s.kind === "watchdog" && !running) continue;
79
+ const when = s.at ? `${new Date(s.at).toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })} ` : "";
80
+ lines.push(`已预约:${when}${KIND_TEXT[s.kind]}(Paseo 定时任务,执行暂停命令可以取消)`);
81
+ }
82
+
83
+ const records = state.history.filter((h) => h.attempt === state.attempt);
84
+ const current = records.find((h) => h.outcome === "running");
85
+ if (current && running) {
86
+ lines.push(`当前步骤:${stepName(current)},已进行 ${duration(current.startedAt, now)}`);
87
+ }
88
+ const done = records.filter((h) => h.outcome === "completed");
89
+ if (done.length > 0) {
90
+ lines.push("已完成的步骤:");
91
+ done.forEach((h, i) => {
92
+ const took = h.endedAt ? duration(h.startedAt, new Date(h.endedAt)) : "";
93
+ lines.push(` ${i + 1}. ${stepName(h)}:${resultText(h)}${took ? `(用时 ${took})` : ""}`);
94
+ });
95
+ } else if (!current) {
96
+ lines.push("还没有完成的步骤。");
97
+ }
98
+ const failed = records.filter((h) => h.outcome === "failed").at(-1);
99
+ if (failed && !running) lines.push(`中断在:${stepName(failed)}(${failed.error ?? "原因未知"})`);
100
+ if (records.length > 0) lines.push(`用时与用量:${describeTotals(totals(records, now))}`);
101
+ if (state.attempt > 1) lines.push(`这个需求的全部 ${state.attempt} 次尝试合计:${describeTotals(totals(state.history, now))}`);
102
+ if (state.phases && state.phases.mode !== "continuous") {
103
+ const verify = state.history.filter((h) => h.phase === "verify");
104
+ if (verify.length > 0) {
105
+ lines.push(`其中研发阶段:${describeTotals(totals(state.history.filter((h) => (h.phase ?? "develop") === "develop"), now))};测试验证阶段:${describeTotals(totals(verify, now))}`);
106
+ }
107
+ }
108
+ return lines.join("\n");
109
+ }
110
+
111
+ export interface StopInput {
112
+ statePath: string;
113
+ featureDir: string;
114
+ config: PipelineConfig;
115
+ createRuntime: RuntimeFactory;
116
+ log: (message: string) => void;
117
+ sleep: (ms: number) => Promise<void>;
118
+ }
119
+
120
+ /** Stops a running pipeline and its current step agent. Returns the message for the user. */
121
+ /** Cancels the Paseo schedules a run left (reminder, automatic continuation, watchdog). */
122
+ async function cancelSchedules(input: StopInput, state: PipelineState): Promise<number> {
123
+ const schedules = state.schedules ?? [];
124
+ if (schedules.length === 0) return 0;
125
+ try {
126
+ const runtime = await input.createRuntime({ config: input.config, log: input.log });
127
+ for (const s of schedules) await runtime.cancelSchedule(s.id).catch((error: Error) => input.log(`无法取消定时任务 ${s.id}:${error.message}`));
128
+ await runtime.close().catch(() => undefined);
129
+ } catch (error) {
130
+ input.log(`无法连接 Paseo 取消定时任务:${(error as Error).message}`);
131
+ return 0;
132
+ }
133
+ const latest = readState(input.statePath) ?? state;
134
+ latest.schedules = [];
135
+ writeState(input.statePath, latest);
136
+ return schedules.length;
137
+ }
138
+
139
+ export async function stopPipeline(input: StopInput): Promise<{ stopped: boolean; message: string }> {
140
+ const state = readState(input.statePath);
141
+ if (!state) return { stopped: false, message: `需求 ${input.featureDir} 还没有启动过开发流水线。` };
142
+ const cancelled = await cancelSchedules(input, state);
143
+ const cancelledText = cancelled > 0 ? `已取消预约的提醒或自动继续(${cancelled} 个)。` : "";
144
+ if (state.status !== "running") {
145
+ return {
146
+ stopped: cancelled > 0,
147
+ message: `${cancelledText}流水线当前没有在运行(${describeStatus(state, input.featureDir).split("\n")[1]})。`,
148
+ };
149
+ }
150
+
151
+ const wasAlive = isAlive(state.pid);
152
+ if (wasAlive) {
153
+ try {
154
+ process.kill(state.pid, "SIGTERM");
155
+ } catch {
156
+ // Already gone.
157
+ }
158
+ for (let i = 0; i < 20 && isAlive(state.pid); i++) await input.sleep(250);
159
+ if (isAlive(state.pid)) {
160
+ try {
161
+ process.kill(state.pid, "SIGKILL");
162
+ } catch {
163
+ // Already gone.
164
+ }
165
+ }
166
+ }
167
+
168
+ // Re-read: on POSIX the pipeline records its own shutdown before exiting.
169
+ const latest = readState(input.statePath) ?? state;
170
+ const interrupted = latest.history.filter((h) => h.outcome === "running" || (h.endedAt === null && h.agentId));
171
+ for (const record of interrupted) {
172
+ record.outcome = "failed";
173
+ record.endedAt = new Date().toISOString();
174
+ record.error = "被手动停止";
175
+ }
176
+ latest.status = "failed";
177
+ latest.stopReason = wasAlive ? "已被手动停止" : "后台进程已意外退出";
178
+ writeState(input.statePath, latest);
179
+
180
+ // The step agent keeps working unless told otherwise; stop it so it changes nothing more.
181
+ if (interrupted.some((r) => r.agentId)) {
182
+ try {
183
+ const runtime = await input.createRuntime({ config: input.config, log: input.log });
184
+ for (const record of interrupted) {
185
+ if (!record.agentId) continue;
186
+ await runtime
187
+ .interruptAgent(
188
+ record.agentId,
189
+ "开发流水线已被用户停止。请立即停止当前工作,不要再修改任何文件,也不要写结果文件,直接结束。",
190
+ )
191
+ .catch((error: Error) => input.log(`无法停止 agent ${record.agentId}:${error.message}`));
192
+ }
193
+ await runtime.close().catch(() => undefined);
194
+ } catch (error) {
195
+ input.log(`无法连接 Paseo 停止当前步骤的 agent:${(error as Error).message}`);
196
+ }
197
+ }
198
+
199
+ const where = interrupted.length > 0 ? `,停在「${stepName(interrupted[0])}」` : "";
200
+ return {
201
+ stopped: true,
202
+ message: wasAlive
203
+ ? `流水线已停止${where}。已完成的步骤会保留,之后可以用 --resume 从这里继续。`
204
+ : `流水线的后台进程已经意外退出${where},状态已更新。可以用 --resume 继续。`,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * What the running step agents did most recently, read from Paseo. Empty when the
210
+ * pipeline is not running or the daemon cannot be reached within `timeoutMs`.
211
+ */
212
+ export async function describeLiveActivity(
213
+ state: PipelineState | null,
214
+ runtime: Pick<AgentRuntime, "recentActivity">,
215
+ timeoutMs = 8000,
216
+ now = new Date(),
217
+ ): Promise<string[]> {
218
+ if (!state || state.status !== "running" || !isAlive(state.pid)) return [];
219
+ const running = state.history.filter((h) => h.attempt === state.attempt && h.outcome === "running" && h.agentId).slice(0, 3);
220
+ const lines: string[] = [];
221
+ for (const record of running) {
222
+ const items = await Promise.race([
223
+ runtime.recentActivity(record.agentId, 5).catch(() => null),
224
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs).unref()),
225
+ ]);
226
+ if (!items || items.length === 0) continue;
227
+ lines.push(`「${stepName(record)}」的 agent 最近在做:`, ...describeActivity(items, now));
228
+ }
229
+ return lines;
230
+ }
@@ -0,0 +1,104 @@
1
+ // Checks against the real Paseo daemon, shared by the smoke test and the doctor.
2
+
3
+ import type { AgentProfile, PipelineConfig } from "./config.js";
4
+ import { profilesFromDaemonConfig, resolveConfigProfiles, usesPaseoProfiles } from "./paseo-profiles.js";
5
+
6
+ export type Report = (level: "ok" | "warn" | "fail", text: string, fix?: string) => void;
7
+
8
+ export async function connect(config: PipelineConfig) {
9
+ const { createPaseoClient } = await import("@getpaseo/client");
10
+ const password = process.env[config.daemonPasswordEnv];
11
+ const client = createPaseoClient({ url: config.daemonUrl, ...(password ? { password } : {}) });
12
+ await client.connect();
13
+ return client;
14
+ }
15
+
16
+ export type Client = Awaited<ReturnType<typeof connect>>;
17
+
18
+ /** The config with Paseo profile names resolved against this daemon. Throws ProfileResolutionError. */
19
+ export async function resolveWithDaemon(client: Client, config: PipelineConfig): Promise<PipelineConfig> {
20
+ if (!usesPaseoProfiles(config)) return config;
21
+ const daemonConfig = await client.config.get().then((r) => r.config).catch(() => null);
22
+ return resolveConfigProfiles(config, daemonConfig ? profilesFromDaemonConfig(daemonConfig) : null);
23
+ }
24
+
25
+ /** Checks that a role's provider, model and mode exist and will not stop for approvals. */
26
+ export async function checkProfile(client: Client, role: string, profile: AgentProfile, report: Report): Promise<boolean> {
27
+ const full = profile.provider ?? "";
28
+ const slash = full.indexOf("/");
29
+ const provider = full.slice(0, slash);
30
+ const model = full.slice(slash + 1);
31
+ const where = `~/.req-kit/config.json 的 agents.${role}`;
32
+ let ok = true;
33
+ try {
34
+ const models = await client.providers.listModels(provider);
35
+ const ids = (models.models ?? []).map((m) => m.id);
36
+ if (ids.includes(model)) {
37
+ report("ok", `${role}:模型 ${profile.provider}`);
38
+ } else {
39
+ ok = false;
40
+ report("fail", `${role}:模型 ${profile.provider} 在 daemon 中不存在`, `修改 ${where}.provider。可用:${ids.map((id) => `${provider}/${id}`).join(", ") || "(无)"}`);
41
+ }
42
+ } catch (error) {
43
+ report("fail", `${role}:无法列出 ${provider} 的模型:${(error as Error).message}`, `确认 Paseo 中已安装并登录 ${provider}`);
44
+ return false;
45
+ }
46
+ // Some providers (e.g. Pi) have no modes and never stop for approvals; they need no modeId.
47
+ try {
48
+ const modes = await client.providers.listModes(provider);
49
+ const ids = (modes.modes ?? []).map((m) => m.id);
50
+ if (ids.length === 0) {
51
+ if (profile.modeId) {
52
+ ok = false;
53
+ report("fail", `${role}:${provider} 没有模式,但配置了 modeId(${profile.modeId})`, `删除 ${where}.modeId`);
54
+ } else {
55
+ report("ok", `${role}:${provider} 没有模式,不需要 modeId`);
56
+ }
57
+ } else if (!profile.modeId) {
58
+ ok = false;
59
+ report("fail", `${role}:没有设置 modeId,agent 可能会停下来等待权限批准`, `设置 ${where}.modeId,可用:${ids.join(", ")}`);
60
+ } else if (!ids.includes(profile.modeId)) {
61
+ ok = false;
62
+ report("fail", `${role}:模式 ${profile.modeId} 不存在`, `修改 ${where}.modeId,可用:${ids.join(", ")}`);
63
+ } else if (provider === "opencode" && profile.featureValues?.auto_accept !== true) {
64
+ ok = false;
65
+ report("fail", `${role}:OpenCode 没有开启自动接受,会停下来等待权限批准`, `在 ${where} 中加上 "featureValues": { "auto_accept": true }`);
66
+ } else {
67
+ report("ok", `${role}:模式 ${profile.modeId}`);
68
+ }
69
+ } catch (error) {
70
+ ok = false;
71
+ report("fail", `${role}:无法列出 ${provider} 的模式:${(error as Error).message}`);
72
+ }
73
+ return ok;
74
+ }
75
+
76
+ /** Minimal "x.y.z satisfies '>=a.b.c <d.e.f'" for the ranges req-kit declares. */
77
+ export function satisfies(version: string, range: string): boolean {
78
+ const parse = (v: string) => v.replace(/^v/, "").split(/[-+]/)[0].split(".").map((n) => Number(n) || 0);
79
+ const cmp = (a: number[], b: number[]) => {
80
+ for (let i = 0; i < 3; i += 1) if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) - (b[i] ?? 0);
81
+ return 0;
82
+ };
83
+ const v = parse(version);
84
+ return range
85
+ .trim()
86
+ .split(/\s+/)
87
+ .every((part) => {
88
+ const match = /^(>=|<=|>|<|=)?(.+)$/.exec(part);
89
+ if (!match) return false;
90
+ const c = cmp(v, parse(match[2]));
91
+ switch (match[1] ?? "=") {
92
+ case ">=":
93
+ return c >= 0;
94
+ case "<=":
95
+ return c <= 0;
96
+ case ">":
97
+ return c > 0;
98
+ case "<":
99
+ return c < 0;
100
+ default:
101
+ return c === 0;
102
+ }
103
+ });
104
+ }
@@ -0,0 +1,66 @@
1
+ // Prints the version of the Paseo daemon this machine uses (one line), for the
2
+ // installer's version choice: node <tsx> engine/src/daemon-version.ts [--config <file>]
3
+ // Exit code 1 and nothing on stdout when it cannot be read.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { parseArgs } from "node:util";
8
+ import { DEFAULT_CONFIG } from "./config.js";
9
+ import { defaultConfigPath } from "./locations.js";
10
+
11
+ interface ServerInfoClient {
12
+ connect(): Promise<void>;
13
+ close(): Promise<void>;
14
+ getLastServerInfoMessage(): { version?: string | null } | null | undefined;
15
+ }
16
+
17
+ export async function daemonVersion(daemonUrl: string, password: string | undefined, timeoutMs = 8000): Promise<string | null> {
18
+ const internal = (await import("@getpaseo/client/internal/daemon-client").catch(() => null)) as {
19
+ DaemonClient?: new (options: Record<string, unknown>) => ServerInfoClient;
20
+ } | null;
21
+ if (!internal?.DaemonClient) return null;
22
+ const client = new internal.DaemonClient({
23
+ url: daemonUrl,
24
+ clientId: `req-kit-version-${process.pid}`,
25
+ clientType: "cli",
26
+ connectTimeoutMs: timeoutMs,
27
+ ...(password ? { password } : {}),
28
+ });
29
+ try {
30
+ await client.connect();
31
+ const deadline = Date.now() + timeoutMs;
32
+ for (;;) {
33
+ const version = client.getLastServerInfoMessage()?.version;
34
+ if (version) return version;
35
+ if (Date.now() > deadline) return null;
36
+ await new Promise((resolve) => setTimeout(resolve, 100));
37
+ }
38
+ } finally {
39
+ await client.close().catch(() => undefined);
40
+ }
41
+ }
42
+
43
+ async function main(): Promise<number> {
44
+ const { values } = parseArgs({ args: process.argv.slice(2), options: { config: { type: "string" } } });
45
+ const file = values.config ? path.resolve(values.config) : defaultConfigPath();
46
+ let url = DEFAULT_CONFIG.daemonUrl;
47
+ let passwordEnv = DEFAULT_CONFIG.daemonPasswordEnv;
48
+ try {
49
+ const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { daemonUrl?: string; daemonPasswordEnv?: string };
50
+ url = raw.daemonUrl ?? url;
51
+ passwordEnv = raw.daemonPasswordEnv ?? passwordEnv;
52
+ } catch {
53
+ // No machine config yet: the default address.
54
+ }
55
+ const version = await daemonVersion(url, process.env[passwordEnv]).catch(() => null);
56
+ if (!version) return 1;
57
+ console.log(version);
58
+ return 0;
59
+ }
60
+
61
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
62
+ main().then(
63
+ (code) => process.exit(code),
64
+ () => process.exit(1),
65
+ );
66
+ }