paseo-req-kit 1.11.0 → 1.12.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/req.mjs +1 -0
  3. package/config.example.json +3 -1
  4. package/contracts/cli-json.md +2 -0
  5. package/contracts/state.md +3 -1
  6. package/contracts/usage-record.md +4 -1
  7. package/docs/adr/0031-fewer-interventions.md +33 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +3 -2
  10. 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 +21 -3
  11. package/engine/prompts/04b-design-revise.md +15 -0
  12. package/engine/src/api.ts +33 -1
  13. package/engine/src/card-cmd.ts +8 -1
  14. package/engine/src/card.ts +9 -2
  15. package/engine/src/config.ts +23 -2
  16. package/engine/src/configure.ts +161 -36
  17. package/engine/src/control.ts +1 -0
  18. package/engine/src/factory.ts +48 -4
  19. package/engine/src/main.ts +1 -0
  20. package/engine/src/paseo-profiles.ts +2 -0
  21. package/engine/src/pipeline.ts +161 -26
  22. package/engine/src/records.ts +8 -0
  23. package/engine/src/state.ts +30 -0
  24. package/engine/src/stats.ts +90 -0
  25. package/engine/src/steps.ts +4 -1
  26. package/install.mjs +5 -1
  27. package/package.json +2 -2
  28. package/paseo-plugin/OVERVIEW.md +2 -0
  29. package/paseo-plugin/client/data.ts +15 -1
  30. package/paseo-plugin/client/models.tsx +117 -0
  31. package/paseo-plugin/client/settings.tsx +3 -1
  32. package/paseo-plugin/index.server.ts +3 -1
  33. package/paseo-plugin/package.json +1 -1
  34. package/paseo-plugin/server/handlers.ts +14 -1
  35. package/paseo-plugin/shared/contracts.ts +37 -0
  36. package/skills/req/playbooks/new.md +14 -14
  37. package/skills/req/playbooks/small.md +3 -3
  38. package/skills/req/playbooks/unattended.md +4 -2
@@ -122,6 +122,48 @@ async function chooseRole(
122
122
  return profileFor(provider, model, modeId);
123
123
  }
124
124
 
125
+ /** One agent a role can use: a Paseo profile, or a provider's model in its unattended mode (1.12). */
126
+ export interface AgentChoice {
127
+ id: string;
128
+ label: string;
129
+ provider: string;
130
+ profile: AgentProfile;
131
+ }
132
+
133
+ /** Everything the daemon offers that can run unattended; Paseo profiles first. */
134
+ export async function agentChoices(catalog: Catalog): Promise<AgentChoice[]> {
135
+ const out: AgentChoice[] = [];
136
+ for (const p of ((await catalog.profiles?.().catch(() => [])) ?? []).filter((x) => x.model)) {
137
+ out.push({ id: `paseo:${p.name}`, label: `Paseo profile「${p.name}」:${p.provider}/${p.model}`, provider: p.provider ?? "", profile: { paseoProfile: p.name } });
138
+ }
139
+ for (const provider of (await catalog.providers()).filter((p) => p.status === "ready")) {
140
+ const modes = await catalog.modes(provider.id).catch(() => []);
141
+ const modeId = unattendedMode(provider.id, modes);
142
+ // A provider whose modes all stop for approval would hold the pipeline: left out.
143
+ if (modes.length > 0 && !modeId) continue;
144
+ for (const m of await catalog.models(provider.id).catch(() => [])) {
145
+ out.push({
146
+ id: `${provider.id}/${m.id}`,
147
+ label: `${m.label && m.label !== m.id ? `${m.label}(${provider.id}/${m.id})` : `${provider.id}/${m.id}`}${modeId ? `,模式 ${modeId}` : ""}`,
148
+ provider: provider.id,
149
+ profile: profileFor(provider.id, m.id, modeId),
150
+ });
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /** The recommended fallback: another vendor than both roles if there is one, else the reviewer's when it differs from the builder's. */
157
+ export function recommendFallback(choices: AgentChoice[], builderProvider: string, reviewerProvider: string): AgentChoice | null {
158
+ return (
159
+ choices.find((c) => c.provider !== builderProvider && c.provider !== reviewerProvider) ??
160
+ (reviewerProvider !== builderProvider ? (choices.find((c) => c.provider === reviewerProvider) ?? null) : null)
161
+ );
162
+ }
163
+
164
+ /** Takes the recommended answer to every question (install, `req configure --auto`). */
165
+ export const recommended: Chooser = async (_question, options) => options[0].value;
166
+
125
167
  export interface WizardResult {
126
168
  config: Record<string, unknown>;
127
169
  summary: string[];
@@ -133,6 +175,17 @@ export async function runWizard(existing: Record<string, unknown>, catalog: Cata
133
175
  const providerOf = (p: AgentProfile) => (p.provider ?? profiles.find((x) => x.name === p.paseoProfile)?.provider ?? "").split("/")[0];
134
176
  const builder = await chooseRole("builder", catalog, choose, null, profiles);
135
177
  const reviewer = await chooseRole("reviewer", catalog, choose, providerOf(builder), profiles);
178
+ const choices = await agentChoices(catalog);
179
+ const suggested = recommendFallback(choices, providerOf(builder), providerOf(reviewer));
180
+ const others = choices.filter((c) => c.id !== suggested?.id && c.provider !== providerOf(builder)).slice(0, 8);
181
+ const fallback = await choose<AgentProfile | null>(
182
+ "备用模型:builder 或 reviewer 一直限流、额度用完或报错时,换它把这一步做完,而不是停下等人。用哪个?",
183
+ [
184
+ ...(suggested ? [{ label: `${suggested.label}(推荐:与主模型不同厂商)`, value: suggested.profile as AgentProfile | null }] : []),
185
+ { label: "不用备用模型", value: null },
186
+ ...others.map((c) => ({ label: c.label, value: c.profile as AgentProfile | null })),
187
+ ],
188
+ );
136
189
  const defaultProfile = await choose("没有特别说明时,需求默认走哪个档位?", [
137
190
  { label: "标准:完整的方案审核、代码评审和验证(推荐)", value: "standard" },
138
191
  { label: "快速:每个环节一轮,适合以小改动为主的项目", value: "quick" },
@@ -141,14 +194,18 @@ export async function runWizard(existing: Record<string, unknown>, catalog: Cata
141
194
  { label: "要(推荐)", value: true },
142
195
  { label: "不要,只发需要处理的事和最终结果", value: false },
143
196
  ]);
144
- const config = { ...existing, agents: { builder, reviewer }, defaultProfile, notifyMilestones };
197
+ const config = { ...existing, agents: { builder, reviewer, ...(fallback ? { fallback } : {}) }, defaultProfile, notifyMilestones };
145
198
  const describe = (p: AgentProfile) =>
146
199
  p.paseoProfile
147
200
  ? `Paseo profile「${p.paseoProfile}」(在 Paseo 中修改这个 profile 即可换模型)`
148
201
  : `${p.provider}${p.modeId ? `(模式 ${p.modeId})` : ""}${p.featureValues ? ",自动接受权限请求" : ""}`;
149
202
  return {
150
203
  config,
151
- summary: [`builder:${describe(builder)}`, `reviewer:${describe(reviewer)}`, `默认档位:${defaultProfile}`, `进展通知:${notifyMilestones ? "开" : "关"}`],
204
+ summary: [
205
+ `builder:${describe(builder)}`,
206
+ `reviewer:${describe(reviewer)}${providerOf(reviewer) === providerOf(builder) ? "(只有一种 agent 可用,评审用同一个;以后登录第二种 agent 再运行 req configure,评审更容易发现问题)" : ""}`,
207
+ `备用模型:${fallback ? describe(fallback) : "不用"}`,
208
+ `默认档位:${defaultProfile}`, `进展通知:${notifyMilestones ? "开" : "关"}`],
152
209
  };
153
210
  }
154
211
 
@@ -168,26 +225,35 @@ function terminalChooser(): { choose: Chooser; close: () => void } {
168
225
  return { choose, close: () => rl.close() };
169
226
  }
170
227
 
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)
228
+ function readConfigFile(configPath: string): Record<string, unknown> {
229
+ return fs.existsSync(configPath)
175
230
  ? (JSON.parse(fs.readFileSync(configPath, "utf8")) as Record<string, unknown>)
176
231
  : (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",
232
+ }
233
+
234
+ function writeConfigFile(configPath: string, config: Record<string, unknown>): void {
235
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
236
+ if (fs.existsSync(configPath)) fs.copyFileSync(configPath, `${configPath}.bak-${Date.now()}`);
237
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
238
+ }
239
+
240
+ /** The agents the plugin's settings page chose (1.12): checked, then written to the local config. */
241
+ export function applyAgents(existing: Record<string, unknown>, raw: unknown): Record<string, unknown> {
242
+ const input = (raw ?? {}) as { builder?: unknown; reviewer?: unknown; fallback?: unknown };
243
+ const isProfile = (p: unknown): p is AgentProfile => {
244
+ if (!p || typeof p !== "object") return false;
245
+ const q = p as AgentProfile;
246
+ return (typeof q.paseoProfile === "string" && q.paseoProfile.trim() !== "") || (typeof q.provider === "string" && /^[^/]+\/.+/.test(q.provider) && !q.provider.includes("CHANGE-ME"));
180
247
  };
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 = {
248
+ if (!isProfile(input.builder) || !isProfile(input.reviewer)) throw new Error("builder 和 reviewer 都要选一个 agent");
249
+ if (input.fallback !== null && input.fallback !== undefined && !isProfile(input.fallback)) throw new Error("备用模型的设置不对");
250
+ const agents: Record<string, AgentProfile> = { builder: input.builder, reviewer: input.reviewer };
251
+ if (input.fallback) agents.fallback = input.fallback as AgentProfile;
252
+ return { ...existing, agents };
253
+ }
254
+
255
+ function daemonCatalog(client: Awaited<ReturnType<typeof connect>>): Catalog {
256
+ return {
191
257
  async providers() {
192
258
  const snapshot = await client.providers.waitForReady({ timeoutMs: 60_000 });
193
259
  return snapshot.entries.map((e) => ({ id: e.provider, status: e.status }));
@@ -202,26 +268,85 @@ async function main(): Promise<number> {
202
268
  return profilesFromDaemonConfig((await client.config.get()).config) ?? [];
203
269
  },
204
270
  };
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("没有修改配置。");
271
+ }
272
+
273
+ async function main(): Promise<number> {
274
+ const { values } = parseArgs({
275
+ args: process.argv.slice(2),
276
+ options: { config: { type: "string" }, auto: { type: "boolean", default: false }, "catalog-json": { type: "boolean", default: false }, set: { type: "string" } },
277
+ });
278
+ const configPath = values.config ? path.resolve(values.config) : defaultConfigPath();
279
+ const existing = readConfigFile(configPath);
280
+ const json = (data: unknown) => console.log(JSON.stringify(data));
281
+
282
+ if (values.set !== undefined) {
283
+ try {
284
+ writeConfigFile(configPath, applyAgents(existing, JSON.parse(values.set)));
285
+ json({ ok: true, message: `已写入 ${configPath}(原文件已备份)。之后开始的需求使用新的设置。` });
286
+ return 0;
287
+ } catch (error) {
288
+ json({ ok: false, message: (error as Error).message });
216
289
  return 1;
217
290
  }
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;
291
+ }
292
+
293
+ const settings = {
294
+ daemonUrl: typeof existing.daemonUrl === "string" ? existing.daemonUrl : "ws://127.0.0.1:6767/ws",
295
+ daemonPasswordEnv: typeof existing.daemonPasswordEnv === "string" ? existing.daemonPasswordEnv : "PASEO_DAEMON_PASSWORD",
296
+ };
297
+ if (!values["catalog-json"]) console.log(`配置向导:连接 Paseo daemon ${settings.daemonUrl} ……`);
298
+ let client;
299
+ try {
300
+ // A daemon that is not running must not hold up the installer: give up after 20 seconds.
301
+ let timer: NodeJS.Timeout | undefined;
302
+ client = await Promise.race([
303
+ connect(settings as never),
304
+ new Promise<never>((_, reject) => {
305
+ timer = setTimeout(() => reject(new Error("20 秒内没有连上")), 20_000);
306
+ }),
307
+ ]).finally(() => clearTimeout(timer));
308
+ } catch (error) {
309
+ const why = `无法连接 daemon:${(error as Error).message}。请确认 Paseo 正在运行;设置了密码时,确认环境变量 ${settings.daemonPasswordEnv} 已设置。`;
310
+ if (values["catalog-json"]) json({ ok: false, message: why });
311
+ else console.error(why);
312
+ return 1;
313
+ }
314
+ const catalog = daemonCatalog(client);
315
+ try {
316
+ if (values["catalog-json"]) {
317
+ const choices = await agentChoices(catalog);
318
+ const auto = await runWizard({}, catalog, recommended).catch(() => null);
319
+ json({ ok: true, current: existing.agents ?? null, choices, recommended: auto ? auto.config.agents : null });
320
+ return 0;
321
+ }
322
+ if (values.auto) {
323
+ const { config, summary } = await runWizard(existing, catalog, recommended);
324
+ writeConfigFile(configPath, config);
325
+ console.log("已按推荐自动选好 agent:");
326
+ for (const line of summary) console.log(` ${line}`);
327
+ console.log(`写入 ${configPath}。要换:运行 req configure,或在 Paseo 的 设置 → 插件 → req-kit 中修改。`);
328
+ return 0;
329
+ }
330
+ const terminal = terminalChooser();
331
+ try {
332
+ const { config, summary } = await runWizard(existing, catalog, terminal.choose);
333
+ console.log("\n将写入以下设置:");
334
+ for (const line of summary) console.log(` ${line}`);
335
+ const ok = await terminal.choose("确认写入吗?", [
336
+ { label: "写入", value: true },
337
+ { label: "放弃", value: false },
338
+ ]);
339
+ if (!ok) {
340
+ console.log("没有修改配置。");
341
+ return 1;
342
+ }
343
+ writeConfigFile(configPath, config);
344
+ console.log(`\n已写入 ${configPath}(原文件已备份)。接下来运行 npm run smoke -- check 确认两个 agent 能正常工作。`);
345
+ return 0;
346
+ } finally {
347
+ terminal.close();
348
+ }
223
349
  } finally {
224
- terminal.close();
225
350
  await client.close().catch(() => undefined);
226
351
  }
227
352
  }
@@ -193,6 +193,7 @@ export async function stopPipeline(input: StopInput): Promise<{ stopped: boolean
193
193
  }
194
194
  latest.status = "failed";
195
195
  latest.stopReason = wasAlive ? "已被手动停止" : "后台进程已意外退出";
196
+ latest.stopKind = wasAlive ? "stopped" : "process_exit";
196
197
  writeState(input.statePath, latest);
197
198
 
198
199
  // The step agent keeps working unless told otherwise; stop it so it changes nothing more.
@@ -16,11 +16,14 @@ import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { parseArgs } from "node:util";
18
18
  import { cardRoutes, findCardEntry, hashText, parseJsonOutput } from "./card.js";
19
- import { beginFromCard, type CardCmdOptions, cardIntegrations, checkCards } from "./card-cmd.js";
20
- import { loadConfig } from "./config.js";
19
+ import { beginFromCard, type CardCmdOptions, cardIntegrations, checkCards, withRuntime } from "./card-cmd.js";
20
+ import { resumeCommand, statusCommand } from "./cli-args.js";
21
+ import { loadConfig, type PipelineConfig } from "./config.js";
22
+ import { watchdogPrompt } from "./messages.js";
23
+ import { readIndex } from "./records.js";
21
24
  import { type IntegrationConfig, renderArgs, runCommand, type Runner } from "./integrations.js";
22
25
  import { defaultConfigPath, ENGINE_DIR, KIT_ROOT, kitHome } from "./locations.js";
23
- import { isAlive } from "./state.js";
26
+ import { isAlive, readState, statePath } from "./state.js";
24
27
 
25
28
  const USAGE = `用法:
26
29
  req factory once 检查一次:为新卡片开始需求,把需求方的新回复转给需求会话
@@ -49,6 +52,8 @@ export interface PollResult {
49
52
  started: string[];
50
53
  skipped: string[];
51
54
  relayed: string[];
55
+ /** 1.12: interrupted pipelines whose chat was asked to continue them. */
56
+ resumed: string[];
52
57
  errors: string[];
53
58
  notes: string[];
54
59
  }
@@ -143,7 +148,7 @@ export async function pollOnce(configPath: string, opts: CardCmdOptions = {}, no
143
148
  const env = opts.env ?? process.env;
144
149
  const dir = factoryDir(env);
145
150
  fs.mkdirSync(dir, { recursive: true });
146
- const result: PollResult = { started: [], skipped: [], relayed: [], errors: [], notes: [] };
151
+ const result: PollResult = { started: [], skipped: [], relayed: [], resumed: [], errors: [], notes: [] };
147
152
 
148
153
  // One look at a time on this machine.
149
154
  const lock = path.join(dir, "poll.lock");
@@ -207,6 +212,12 @@ export async function pollOnce(configPath: string, opts: CardCmdOptions = {}, no
207
212
  }
208
213
  writeJson(skippedFile, skipped);
209
214
 
215
+ try {
216
+ await nudgeInterrupted(config, env, dir, result);
217
+ } catch (error) {
218
+ result.errors.push(`续跑中断的需求:${(error as Error).message}`);
219
+ }
220
+
210
221
  try {
211
222
  const checked = await checkCards(null, opts, configPath, { skipFinished: true });
212
223
  for (const line of checked.lines) {
@@ -226,11 +237,44 @@ export async function pollOnce(configPath: string, opts: CardCmdOptions = {}, no
226
237
  return result;
227
238
  }
228
239
 
240
+ /**
241
+ * 1.12: a pipeline on this machine still marked running whose process is gone (the machine
242
+ * restarted, the process was killed): its chat gets the same message as the watchdog's, so
243
+ * the chat checks and continues it. Once per process; a chat that cannot be reached is noted.
244
+ */
245
+ async function nudgeInterrupted(config: PipelineConfig, env: NodeJS.ProcessEnv, dir: string, result: PollResult): Promise<void> {
246
+ const file = path.join(dir, "nudged.json");
247
+ const nudged = readJson<Record<string, number>>(file, {});
248
+ const due: Array<{ key: string; pid: number; origin: string; featureDir: string; repo: string }> = [];
249
+ for (const entry of readIndex(env)) {
250
+ if (!entry.present) continue;
251
+ const state = readState(statePath(entry.repo, entry.featureDir));
252
+ if (!state || state.status !== "running" || isAlive(state.pid) || !state.originAgentId) continue;
253
+ if (nudged[entry.key] === state.pid) continue;
254
+ due.push({ key: entry.key, pid: state.pid, origin: state.originAgentId, featureDir: entry.featureDir, repo: entry.sourceRepo ?? entry.repo });
255
+ }
256
+ if (due.length === 0) return;
257
+ await withRuntime(config, async (runtime) => {
258
+ for (const d of due) {
259
+ const commands = { repo: d.repo, featureDir: d.featureDir, configPath: null };
260
+ try {
261
+ await runtime.notifyAgent(d.origin, watchdogPrompt({ featureDir: d.featureDir, statusCommand: statusCommand(commands), resumeCommand: resumeCommand(commands) }));
262
+ nudged[d.key] = d.pid;
263
+ result.resumed.push(`${path.join(d.repo, d.featureDir)}:后台进程已退出,已请它的对话检查并继续`);
264
+ } catch (error) {
265
+ result.errors.push(`${path.join(d.repo, d.featureDir)} 的后台进程已退出,但没能通知它的对话:${(error as Error).message}`);
266
+ }
267
+ }
268
+ });
269
+ writeJson(file, nudged);
270
+ }
271
+
229
272
  function describe(result: PollResult, now = new Date()): string[] {
230
273
  const stamp = now.toLocaleString("zh-CN", { hour12: false });
231
274
  const lines: string[] = [];
232
275
  for (const s of result.started) lines.push(`[${stamp}] 已开始:${s}`);
233
276
  for (const s of result.relayed) lines.push(`[${stamp}] ${s}`);
277
+ for (const s of result.resumed) lines.push(`[${stamp}] 续跑:${s}`);
234
278
  for (const s of result.skipped) lines.push(`[${stamp}] 没能接:${s}`);
235
279
  for (const s of result.errors) lines.push(`[${stamp}] 出错:${s}`);
236
280
  for (const s of result.notes) lines.push(`[${stamp}] ${s}`);
@@ -272,6 +272,7 @@ async function main(): Promise<number> {
272
272
  log(`收到 ${signal},流水线停止`);
273
273
  state.status = "failed";
274
274
  state.stopReason = `流水线进程被 ${signal} 终止`;
275
+ state.stopKind = "stopped";
275
276
  state.queuedSince = null;
276
277
  writeState(stateFile, state);
277
278
  recordRun();
@@ -93,6 +93,7 @@ export function usesPaseoProfiles(config: PipelineConfig): boolean {
93
93
  return [
94
94
  config.agents.builder,
95
95
  config.agents.reviewer,
96
+ ...(config.agents.fallback ? [config.agents.fallback] : []),
96
97
  ...config.reviewPanel.members,
97
98
  ...config.designProposals.members,
98
99
  ...Object.values(config.phases.verify.agents),
@@ -108,6 +109,7 @@ export function resolveConfigProfiles(config: PipelineConfig, available: PaseoPr
108
109
  agents: {
109
110
  builder: resolveAgentProfile("agents.builder", config.agents.builder, available),
110
111
  reviewer: resolveAgentProfile("agents.reviewer", config.agents.reviewer, available),
112
+ ...(config.agents.fallback ? { fallback: resolveAgentProfile("agents.fallback", config.agents.fallback, available) } : {}),
111
113
  },
112
114
  reviewPanel: {
113
115
  ...config.reviewPanel,