paseo-req-kit 1.10.0-beta.2 → 1.11.0-beta.1

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.
@@ -0,0 +1,406 @@
1
+ // The intake process (1.11, ADR 0030): a shared development machine takes cards on its own.
2
+ //
3
+ // req factory once one look: start requirements for new cards, pass replies on
4
+ // req factory run look every factory.pollMinutes until stopped (for a service manager)
5
+ // req factory start | stop run it in the background on this machine
6
+ // req factory status whether it runs, the last look, cards it could not take
7
+ // req factory service how to run it as a system service
8
+ //
9
+ // A card is taken when it is listed by the integration's `list` command for a space (projectId)
10
+ // some registered repository takes (cardSource), and its title starts with one of
11
+ // factory.titleTags. Starting is `req start --from` (beginFromCard); passing replies on is
12
+ // `req card check --all` (checkCards). Files: ~/.req-kit/factory/.
13
+
14
+ import { spawn } from "node:child_process";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { parseArgs } from "node:util";
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";
21
+ import { type IntegrationConfig, renderArgs, runCommand, type Runner } from "./integrations.js";
22
+ import { defaultConfigPath, ENGINE_DIR, KIT_ROOT, kitHome } from "./locations.js";
23
+ import { isAlive } from "./state.js";
24
+
25
+ const USAGE = `用法:
26
+ req factory once 检查一次:为新卡片开始需求,把需求方的新回复转给需求会话
27
+ req factory run 每隔 factory.pollMinutes 分钟检查一次,直到停止(给系统服务用)
28
+ req factory start 在后台运行(日志:~/.req-kit/factory/factory.log)
29
+ req factory stop 停止后台运行
30
+ req factory status 是否在运行、最近一次检查、没能接的卡片
31
+ req factory service 说明怎样作为系统服务运行(开机自动启动)`;
32
+
33
+ export interface ListedCard {
34
+ id: string;
35
+ title: string;
36
+ state: string | null;
37
+ subsystem: string | null;
38
+ author: string | null;
39
+ }
40
+
41
+ interface Skipped {
42
+ reason: string;
43
+ /** Title and subsystem when it was skipped: a changed card is tried again. */
44
+ key: string;
45
+ at: string;
46
+ }
47
+
48
+ export interface PollResult {
49
+ started: string[];
50
+ skipped: string[];
51
+ relayed: string[];
52
+ errors: string[];
53
+ notes: string[];
54
+ }
55
+
56
+ export function factoryDir(env: NodeJS.ProcessEnv = process.env): string {
57
+ return path.join(kitHome(env), "factory");
58
+ }
59
+
60
+ function readJson<T>(file: string, fallback: T): T {
61
+ try {
62
+ return JSON.parse(fs.readFileSync(file, "utf8")) as T;
63
+ } catch {
64
+ return fallback;
65
+ }
66
+ }
67
+
68
+ function writeJson(file: string, value: unknown): void {
69
+ fs.mkdirSync(path.dirname(file), { recursive: true });
70
+ const tmp = `${file}.${process.pid}.tmp`;
71
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
72
+ fs.renameSync(tmp, file);
73
+ }
74
+
75
+ function at(value: unknown, dotted: string): unknown {
76
+ let cur = value;
77
+ for (const part of dotted.split(".").filter(Boolean)) {
78
+ if (cur === null || typeof cur !== "object") return undefined;
79
+ cur = (cur as Record<string, unknown>)[part];
80
+ }
81
+ return cur;
82
+ }
83
+
84
+ /** The list in a `list` command's output. */
85
+ export function listItems(parsed: unknown, itemsPath?: string): unknown[] {
86
+ if (itemsPath) {
87
+ const v = at(parsed, itemsPath);
88
+ if (!Array.isArray(v)) throw new Error(`输出中 ${itemsPath} 不是列表(list.items)`);
89
+ return v;
90
+ }
91
+ for (const p of ["data.list", "data.records", "data.items", "data.rows", "data", ""]) {
92
+ const v = p ? at(parsed, p) : parsed;
93
+ if (Array.isArray(v)) return v;
94
+ }
95
+ throw new Error("输出中没有找到卡片列表,请在 list.items 中写明它的位置(例如 \"data.list\")");
96
+ }
97
+
98
+ /** All cards of a space the `list` command returns, page by page. */
99
+ export async function listCards(config: IntegrationConfig, projectId: string, titleTag: string, run: Runner = runCommand): Promise<ListedCard[]> {
100
+ const list = config.list;
101
+ if (!list) throw new Error(`${config.name} 没有配置 list(列出卡片的命令)`);
102
+ const pageSize = list.pageSize ?? 100;
103
+ const maxPages = list.maxPages ?? 5;
104
+ const out: ListedCard[] = [];
105
+ for (let page = 1; page <= maxPages; page++) {
106
+ const args = renderArgs(list.args, { projectId, page: String(page), pageSize: String(pageSize), titleTag });
107
+ const result = await run(config.command, args, (config.timeoutSeconds ?? 60) * 1000);
108
+ if (!result.ok) throw new Error(`列出空间 ${projectId} 的卡片失败:${result.error}`);
109
+ let parsed: unknown;
110
+ try {
111
+ parsed = parseJsonOutput(result.output);
112
+ } catch (error) {
113
+ throw new Error(`列出空间 ${projectId} 的卡片:${(error as Error).message}`);
114
+ }
115
+ if (at(parsed, "success") === false) throw new Error(`列出空间 ${projectId} 的卡片失败:${String(at(parsed, "message") ?? "success 为 false")}`);
116
+ const items = listItems(parsed, list.items);
117
+ const str = (item: unknown, p: string | undefined) => {
118
+ if (!p) return null;
119
+ const v = at(item, p);
120
+ return v === undefined || v === null ? null : String(v);
121
+ };
122
+ for (const item of items) {
123
+ const id = str(item, list.fields.id);
124
+ const title = str(item, list.fields.title);
125
+ if (!id || title === null) continue;
126
+ out.push({ id, title, state: str(item, list.fields.state), subsystem: str(item, list.fields.subsystem), author: str(item, list.fields.author) });
127
+ }
128
+ if (items.length < pageSize) break;
129
+ }
130
+ return out;
131
+ }
132
+
133
+ export function hasTitleTag(title: string, tags: string[]): boolean {
134
+ const t = title.trim().toLowerCase();
135
+ return tags.some((tag) => t.startsWith(tag.trim().toLowerCase()));
136
+ }
137
+
138
+ /**
139
+ * One look. Never throws for one card's or one space's trouble: it is reported and the rest goes on.
140
+ * Cards that could not be taken are not tried again until the card changes or a day has passed.
141
+ */
142
+ export async function pollOnce(configPath: string, opts: CardCmdOptions = {}, now = new Date()): Promise<PollResult> {
143
+ const env = opts.env ?? process.env;
144
+ const dir = factoryDir(env);
145
+ fs.mkdirSync(dir, { recursive: true });
146
+ const result: PollResult = { started: [], skipped: [], relayed: [], errors: [], notes: [] };
147
+
148
+ // One look at a time on this machine.
149
+ const lock = path.join(dir, "poll.lock");
150
+ try {
151
+ fs.writeFileSync(lock, String(process.pid), { flag: "wx" });
152
+ } catch {
153
+ const holder = Number(fs.readFileSync(lock, "utf8"));
154
+ if (holder && holder !== process.pid && isAlive(holder)) {
155
+ result.notes.push(`上一次检查(进程 ${holder})还没有结束,这次跳过`);
156
+ return result;
157
+ }
158
+ fs.writeFileSync(lock, String(process.pid));
159
+ }
160
+
161
+ try {
162
+ const config = loadConfig(configPath).config;
163
+ const tags = config.factory.titleTags;
164
+ const skippedFile = path.join(dir, "skipped.json");
165
+ const skipped = readJson<Record<string, Skipped>>(skippedFile, {});
166
+ const { routes } = cardRoutes(env);
167
+ let newOnes = 0;
168
+ const integrations = cardIntegrations(config).filter((i) => i.list);
169
+ if (integrations.length === 0) result.notes.push("没有配置可以列出卡片的外部系统(integrations 中的 list),只转达回复");
170
+ for (const integration of integrations) {
171
+ const spaces = [...new Set(routes.filter((r) => r.system.toLowerCase() === integration.name.toLowerCase() && r.projectId).map((r) => r.projectId!))];
172
+ if (spaces.length === 0) {
173
+ result.notes.push(`${integration.name}:本机登记的仓库都没有写空间(cardSource.projectId),不知道去哪里找卡片`);
174
+ continue;
175
+ }
176
+ for (const space of spaces) {
177
+ let cards: ListedCard[];
178
+ try {
179
+ cards = await listCards(integration, space, tags[0], opts.run);
180
+ } catch (error) {
181
+ result.errors.push(`${integration.name} 空间 ${space}:${(error as Error).message}`);
182
+ continue;
183
+ }
184
+ for (const card of cards.filter((c) => hasTitleTag(c.title, tags))) {
185
+ const ref = `${integration.name}:${card.id}`;
186
+ if (findCardEntry(integration.name, card.id, env)) continue;
187
+ const key = hashText(`${card.title}\0${card.subsystem ?? ""}`);
188
+ const before = skipped[ref];
189
+ if (before && before.key === key && now.getTime() - new Date(before.at).getTime() < 24 * 3600_000) continue;
190
+ if (newOnes >= config.factory.maxNewPerPoll) {
191
+ result.notes.push(`这次已开始 ${newOnes} 个需求(factory.maxNewPerPoll),其余的下次再接`);
192
+ break;
193
+ }
194
+ try {
195
+ const begun = await beginFromCard({ integration, id: card.id, repoArg: null, configPath, opts: { ...opts, out: () => undefined } });
196
+ if (begun.status === "started") {
197
+ newOnes += 1;
198
+ result.started.push(`${ref}「${card.title}」→ ${path.join(begun.repo, begun.featureDir)}`);
199
+ }
200
+ delete skipped[ref];
201
+ } catch (error) {
202
+ skipped[ref] = { reason: (error as Error).message, key, at: now.toISOString() };
203
+ result.skipped.push(`${ref}「${card.title}」:${(error as Error).message}`);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ writeJson(skippedFile, skipped);
209
+
210
+ try {
211
+ const checked = await checkCards(null, opts, configPath, { skipFinished: true });
212
+ for (const line of checked.lines) {
213
+ if (/:没有新内容$/.test(line)) continue;
214
+ if (/已转给需求会话/.test(line)) result.relayed.push(line);
215
+ else result.errors.push(line);
216
+ }
217
+ } catch (error) {
218
+ result.errors.push(`转达回复:${(error as Error).message}`);
219
+ }
220
+ } catch (error) {
221
+ result.errors.push((error as Error).message);
222
+ } finally {
223
+ fs.rmSync(lock, { force: true });
224
+ }
225
+ writeJson(path.join(dir, "status.json"), { lastPollAt: now.toISOString(), pid: process.pid, ...result });
226
+ return result;
227
+ }
228
+
229
+ function describe(result: PollResult, now = new Date()): string[] {
230
+ const stamp = now.toLocaleString("zh-CN", { hour12: false });
231
+ const lines: string[] = [];
232
+ for (const s of result.started) lines.push(`[${stamp}] 已开始:${s}`);
233
+ for (const s of result.relayed) lines.push(`[${stamp}] ${s}`);
234
+ for (const s of result.skipped) lines.push(`[${stamp}] 没能接:${s}`);
235
+ for (const s of result.errors) lines.push(`[${stamp}] 出错:${s}`);
236
+ for (const s of result.notes) lines.push(`[${stamp}] ${s}`);
237
+ if (lines.length === 0) lines.push(`[${stamp}] 检查完毕,没有新卡片和新回复`);
238
+ return lines;
239
+ }
240
+
241
+ function pidFile(env: NodeJS.ProcessEnv): string {
242
+ return path.join(factoryDir(env), "factory.pid");
243
+ }
244
+
245
+ function runningPid(env: NodeJS.ProcessEnv): number | null {
246
+ const pid = Number(readJson<string | number>(pidFile(env), 0));
247
+ return pid && isAlive(pid) ? pid : null;
248
+ }
249
+
250
+ async function runLoop(configPath: string, opts: CardCmdOptions): Promise<number> {
251
+ const env = opts.env ?? process.env;
252
+ const out = opts.out ?? console.log;
253
+ const other = runningPid(env);
254
+ if (other && other !== process.pid) {
255
+ out(`接单器已经在运行(进程 ${other})。`);
256
+ return 1;
257
+ }
258
+ writeJson(pidFile(env), process.pid);
259
+ let stopping = false;
260
+ let wake: (() => void) | null = null;
261
+ const stop = () => {
262
+ stopping = true;
263
+ wake?.();
264
+ };
265
+ process.on("SIGTERM", stop);
266
+ process.on("SIGINT", stop);
267
+ const minutes = loadConfig(configPath).config.factory.pollMinutes;
268
+ out(`[${new Date().toLocaleString("zh-CN", { hour12: false })}] 接单器开始运行,每 ${minutes} 分钟检查一次(进程 ${process.pid})`);
269
+ try {
270
+ while (!stopping) {
271
+ for (const line of describe(await pollOnce(configPath, opts))) out(line);
272
+ if (stopping) break;
273
+ await new Promise<void>((resolve) => {
274
+ wake = resolve;
275
+ setTimeout(resolve, minutes * 60_000);
276
+ });
277
+ }
278
+ } finally {
279
+ if (runningPid(env) === process.pid) fs.rmSync(pidFile(env), { force: true });
280
+ out(`[${new Date().toLocaleString("zh-CN", { hour12: false })}] 接单器已停止`);
281
+ }
282
+ return 0;
283
+ }
284
+
285
+ function startBackground(configPath: string, opts: CardCmdOptions): number {
286
+ const env = opts.env ?? process.env;
287
+ const out = opts.out ?? console.log;
288
+ const pid = runningPid(env);
289
+ if (pid) {
290
+ out(`接单器已经在运行(进程 ${pid})。`);
291
+ return 0;
292
+ }
293
+ // Fail here, in front of the person, rather than silently in the background.
294
+ loadConfig(configPath);
295
+ fs.mkdirSync(factoryDir(env), { recursive: true });
296
+ const log = path.join(factoryDir(env), "factory.log");
297
+ const fd = fs.openSync(log, "a");
298
+ const child = spawn(process.execPath, [...process.execArgv, path.join(ENGINE_DIR, "src", "factory.ts"), "run", "--config", configPath], {
299
+ detached: true,
300
+ stdio: ["ignore", fd, fd],
301
+ env,
302
+ windowsHide: true,
303
+ });
304
+ child.unref();
305
+ writeJson(pidFile(env), child.pid);
306
+ out(`接单器已在后台运行(进程 ${child.pid})。日志:${log}`);
307
+ out("停止:req factory stop;查看:req factory status");
308
+ return 0;
309
+ }
310
+
311
+ function stopBackground(opts: CardCmdOptions): number {
312
+ const env = opts.env ?? process.env;
313
+ const out = opts.out ?? console.log;
314
+ const pid = runningPid(env);
315
+ if (!pid) {
316
+ out("接单器没有在运行。");
317
+ return 0;
318
+ }
319
+ process.kill(pid, "SIGTERM");
320
+ out(`已通知接单器(进程 ${pid})停止;正在进行的检查会做完再停。`);
321
+ return 0;
322
+ }
323
+
324
+ function status(opts: CardCmdOptions): number {
325
+ const env = opts.env ?? process.env;
326
+ const out = opts.out ?? console.log;
327
+ const pid = runningPid(env);
328
+ out(pid ? `接单器正在运行(进程 ${pid})。` : "接单器没有在运行。");
329
+ const last = readJson<(PollResult & { lastPollAt: string }) | null>(path.join(factoryDir(env), "status.json"), null);
330
+ if (last) {
331
+ out(`最近一次检查:${new Date(last.lastPollAt).toLocaleString("zh-CN", { hour12: false })};开始 ${last.started.length} 个需求,转达 ${last.relayed.length} 条回复,${last.errors.length} 个错误。`);
332
+ for (const e of last.errors) out(` 出错:${e}`);
333
+ for (const n of last.notes) out(` ${n}`);
334
+ }
335
+ const skipped = readJson<Record<string, Skipped>>(path.join(factoryDir(env), "skipped.json"), {});
336
+ const entries = Object.entries(skipped);
337
+ if (entries.length > 0) {
338
+ out("没能接的卡片(卡片修改后或一天后再试):");
339
+ for (const [ref, s] of entries) out(` ${ref}:${s.reason}`);
340
+ }
341
+ out(`日志:${path.join(factoryDir(env), "factory.log")}`);
342
+ return 0;
343
+ }
344
+
345
+ function service(configPath: string, opts: CardCmdOptions): number {
346
+ const out = opts.out ?? console.log;
347
+ const req = path.join(KIT_ROOT, "bin", "req.mjs");
348
+ const command = `"${process.execPath}" "${req}" factory run --config "${configPath}"`;
349
+ if (process.platform === "win32") {
350
+ out("在 Windows 上作为计划任务运行(登录后自动启动),在管理员 PowerShell 中执行:");
351
+ out(` schtasks /Create /TN "req-kit 接单器" /SC ONLOGON /RL LIMITED /TR '${command.replace(/'/g, "''")}'`);
352
+ out("立即启动:schtasks /Run /TN \"req-kit 接单器\";停止:schtasks /End /TN \"req-kit 接单器\"");
353
+ } else {
354
+ out("在 Linux 上作为 systemd 用户服务运行,把下面的内容保存为 ~/.config/systemd/user/req-kit-factory.service:");
355
+ out("");
356
+ out("[Unit]\nDescription=req-kit 接单器\nAfter=network-online.target\n\n[Service]");
357
+ out(`ExecStart=${command}\nRestart=on-failure\nRestartSec=60\n\n[Install]\nWantedBy=default.target`);
358
+ out("");
359
+ out("然后执行:systemctl --user daemon-reload && systemctl --user enable --now req-kit-factory");
360
+ out("(希望不登录也运行:loginctl enable-linger $USER)。macOS 可以用 launchd 或直接 req factory start。");
361
+ }
362
+ out("Paseo daemon 需要同时在运行:接单器通过它创建需求会话。");
363
+ return 0;
364
+ }
365
+
366
+ export async function main(argv: string[], opts: CardCmdOptions = {}): Promise<number> {
367
+ const out = opts.out ?? console.log;
368
+ const [command, ...rest] = argv;
369
+ const { values } = parseArgs({ args: rest, options: { config: { type: "string" } }, allowPositionals: false, strict: false });
370
+ const configPath = typeof values.config === "string" ? path.resolve(values.config) : defaultConfigPath(opts.env);
371
+ try {
372
+ switch (command) {
373
+ case "once": {
374
+ const result = await pollOnce(configPath, opts);
375
+ for (const line of describe(result)) out(line);
376
+ return result.errors.length > 0 ? 1 : 0;
377
+ }
378
+ case "run":
379
+ return await runLoop(configPath, opts);
380
+ case "start":
381
+ return startBackground(configPath, opts);
382
+ case "stop":
383
+ return stopBackground(opts);
384
+ case "status":
385
+ return status(opts);
386
+ case "service":
387
+ return service(configPath, opts);
388
+ default:
389
+ out(USAGE);
390
+ return command === undefined || command === "help" || command === "--help" ? 0 : 2;
391
+ }
392
+ } catch (error) {
393
+ console.error(`出错了:${(error as Error).message}`);
394
+ return 1;
395
+ }
396
+ }
397
+
398
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
399
+ main(process.argv.slice(2)).then(
400
+ (code) => process.exit(code),
401
+ (error) => {
402
+ console.error(error);
403
+ process.exit(1);
404
+ },
405
+ );
406
+ }
@@ -50,6 +50,13 @@ export interface IntegrationConfig {
50
50
  query?: IntegrationAction & { fields: CardQueryFields };
51
51
  /** Replaces the card's description (1.10): {description} is the whole new description. */
52
52
  describe?: IntegrationAction;
53
+ /**
54
+ * Lists cards for the intake process (1.11): {projectId}, {page}, {pageSize} and {titleTag}
55
+ * can be used. The output is JSON; `items` is where the list is (a dot path; by default the
56
+ * first array found at data.list, data.records, data.items, data or the top level), `fields`
57
+ * where each card's values are within an item.
58
+ */
59
+ list?: IntegrationAction & { items?: string; pageSize?: number; maxPages?: number; fields: CardListFields };
53
60
  /** Fields the requester confirms with the spec (card.json): name → what to fill in, for /req. */
54
61
  cardFields?: Record<string, string>;
55
62
  /** Values for {field.<name>} placeholders; a project's integrationDefaults[name] adds to these. */
@@ -67,6 +74,15 @@ export interface CardQueryFields {
67
74
  url?: string;
68
75
  }
69
76
 
77
+ /** Where `list` finds each card's values within an item. id and title are required. */
78
+ export interface CardListFields {
79
+ id: string;
80
+ title: string;
81
+ state?: string;
82
+ subsystem?: string;
83
+ author?: string;
84
+ }
85
+
70
86
  /** What is known about a requirement's card, in the state file's `external[name]`. */
71
87
  export interface ExternalRecord {
72
88
  id: string | null;
@@ -96,7 +112,7 @@ export function validateIntegrations(value: unknown): string | null {
96
112
  if (names.has(c.name)) return `${at}.name「${c.name}」重复了`;
97
113
  names.add(c.name);
98
114
  if (typeof c.command !== "string" || !c.command.trim()) return `${at}.command 必须是要运行的命令,例如 "shenbing"`;
99
- for (const key of ["create", "status", "query", "describe"] as const) {
115
+ for (const key of ["create", "status", "query", "describe", "list"] as const) {
100
116
  const action = c[key] as Record<string, unknown> | undefined;
101
117
  if (action === undefined) continue;
102
118
  if (!action || !Array.isArray(action.args) || !action.args.every((a) => typeof a === "string")) return `${at}.${key}.args 必须是字符串数组`;
@@ -123,6 +139,18 @@ export function validateIntegrations(value: unknown): string | null {
123
139
  if (typeof v !== "string") return `${at}.query.fields.${k} 必须是字符串`;
124
140
  }
125
141
  }
142
+ const list = c.list as Record<string, unknown> | undefined;
143
+ if (list) {
144
+ const fields = list.fields as Record<string, unknown> | undefined;
145
+ if (!fields || typeof fields !== "object") return `${at}.list.fields 必须写明每张卡片各值的位置,例如 { "id": "globalId", "title": "name" }`;
146
+ for (const k of ["id", "title"]) if (typeof fields[k] !== "string") return `${at}.list.fields.${k} 必须是卡片中的位置,例如 "globalId"`;
147
+ for (const [k, v] of Object.entries(fields)) {
148
+ if (!["id", "title", "state", "subsystem", "author"].includes(k)) return `${at}.list.fields 中的 ${k} 不认识(可用:id、title、state、subsystem、author)`;
149
+ if (typeof v !== "string") return `${at}.list.fields.${k} 必须是字符串`;
150
+ }
151
+ if (list.items !== undefined && typeof list.items !== "string") return `${at}.list.items 必须是输出中列表的位置,例如 "data.list"`;
152
+ for (const k of ["pageSize", "maxPages"]) if (list[k] !== undefined && !(Number.isInteger(list[k]) && (list[k] as number) > 0)) return `${at}.list.${k} 必须是正整数`;
153
+ }
126
154
  if (c.statusMap !== undefined) {
127
155
  if (!c.statusMap || typeof c.statusMap !== "object") return `${at}.statusMap 必须是对象`;
128
156
  for (const [k, v] of Object.entries(c.statusMap)) {
@@ -34,7 +34,7 @@ import { kitVersion } from "./locations.js";
34
34
  import { type AgentUsage, describeTotals, totals } from "./usage.js";
35
35
  import { dispatchNotifiers, type NotifierEventType } from "./notifiers.js";
36
36
  import { describeExternal, type IntegrationEvent, type Runner, syncIntegration } from "./integrations.js";
37
- import { planAppendix, publishCard, readSource } from "./card.js";
37
+ import { cardSlug, planAppendix, publishCard, readSource } from "./card.js";
38
38
  import { principlesSection, projectAdditions, type PromptLocations, readToolPrompt } from "./prompts.js";
39
39
  import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent } from "./runtime.js";
40
40
  import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
@@ -414,6 +414,40 @@ const CARD_PROGRESS: Record<IntegrationEvent, string> = {
414
414
  failed: "流水线中断,正在处理",
415
415
  };
416
416
 
417
+ /**
418
+ * A requirement that started from a card (1.10), with `push` on: once development is done and the
419
+ * handoff is written, pushes each repository's branch to <remote>/<prefix><card>. Never forced
420
+ * (a rejected push is reported, not retried with force), never merged. Never throws.
421
+ */
422
+ function pushBranch(ctx: PipelineContext): void {
423
+ const push = ctx.config.push;
424
+ if (!push.enabled) return;
425
+ const source = readSource(ctx.repo, ctx.featureDir);
426
+ if (!source) return;
427
+ const branch = `${push.branchPrefix}${cardSlug(source.id)}`;
428
+ const repos = [ctx.repo, ...(ctx.state.joint ?? []).map((j) => j.repo)];
429
+ const at = new Date().toISOString();
430
+ for (const repo of repos) {
431
+ let error: string | null = null;
432
+ try {
433
+ execFileSync("git", ["push", push.remote, `HEAD:refs/heads/${branch}`], { cwd: repo, stdio: ["ignore", "pipe", "pipe"], timeout: 180_000, windowsHide: true });
434
+ } catch (e) {
435
+ const stderr = (e as { stderr?: Buffer | string }).stderr?.toString().trim();
436
+ error = (stderr || (e as Error).message).split("\n").filter(Boolean).slice(-2).join(" ");
437
+ }
438
+ ctx.state.pushed = [...(ctx.state.pushed ?? []).filter((p) => p.repo !== repo), { repo, remote: push.remote, branch, at, error }];
439
+ ctx.log(error ? `推送 ${repo} 的分支到 ${push.remote}/${branch} 失败:${error}` : `已推送 ${repo} 的分支到 ${push.remote}/${branch}`);
440
+ }
441
+ save(ctx);
442
+ }
443
+
444
+ /** Where the requirement's code is, for the card: the pushed branch, or the local one. */
445
+ function branchText(ctx: PipelineContext): string | null {
446
+ const mine = ctx.state.pushed?.find((p) => p.repo === ctx.repo);
447
+ if (mine) return mine.error ? `分支 ${ctx.state.branch}(推送到 ${mine.remote}/${mine.branch} 失败:${mine.error})` : `代码已推送到 ${mine.remote} 的分支 ${mine.branch}`;
448
+ return ctx.state.branch ? `分支 ${ctx.state.branch}` : null;
449
+ }
450
+
417
451
  /** A requirement that started from a card (1.10): its AI section follows the pipeline. Never throws. */
418
452
  async function updateCard(ctx: PipelineContext, event: IntegrationEvent): Promise<void> {
419
453
  const source = readSource(ctx.repo, ctx.featureDir);
@@ -422,7 +456,7 @@ async function updateCard(ctx: PipelineContext, event: IntegrationEvent): Promis
422
456
  if (!config?.describe || !config.query) return;
423
457
  const approval = event === "approval";
424
458
  let progress = CARD_PROGRESS[event];
425
- if (event === "phase_done" && ctx.state.branch) progress += `,分支 ${ctx.state.branch}`;
459
+ if (event === "phase_done" && branchText(ctx)) progress += `,${branchText(ctx)}`;
426
460
  if (event === "completed" && ctx.state.outcome && ctx.state.outcome !== "completed") progress = `已结束(${ctx.state.outcome === "completed_with_issues" ? "有遗留问题,见完成报告" : "没有完成,见完成报告"})`;
427
461
  try {
428
462
  await publishCard({
@@ -1795,6 +1829,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1795
1829
  ? `研发已完成(只做研发):${outcomeText[report.outcome as string] ?? report.outcome},请按提测说明人工测试。`
1796
1830
  : `开发流水线已结束:${outcomeText[report.outcome as string] ?? report.outcome}。`;
1797
1831
  // Develop-only ends where testing by people begins: for the card that is "研发完成,待测试".
1832
+ if (developOnly && report.outcome !== "not_completed") pushBranch(ctx);
1798
1833
  await integrate(ctx, developOnly && report.outcome !== "not_completed" ? "phase_done" : "completed");
1799
1834
  await notifyExternal(ctx, "completed", text, {
1800
1835
  outcome: report.outcome,
@@ -1823,6 +1858,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1823
1858
  handbacks: ctx.state.handbacks ?? 0,
1824
1859
  }),
1825
1860
  );
1861
+ pushBranch(ctx);
1826
1862
  await integrate(ctx, "phase_done");
1827
1863
  await notifyExternal(ctx, "phase_done", `研发已完成,可以提测:提测说明在 ${ctx.featureDir}/handoff.md。${verifyAt ? `已预约在 ${verifyAt} 开始测试验证。` : ""}`, {
1828
1864
  handoffPath: `${ctx.featureDir}/handoff.md`,
@@ -149,6 +149,10 @@ export interface RunRecord {
149
149
  title: string;
150
150
  type: string | null;
151
151
  profile: string;
152
+ /** Who asked for it: the card's author for a requirement that started from a card (1.10). */
153
+ requester?: string | null;
154
+ /** The card it started from, e.g. { system: "ALM", id: "…" } (1.10). */
155
+ source?: { system: string; id: string } | null;
152
156
  };
153
157
  run: {
154
158
  startedAt: string;
@@ -226,6 +230,7 @@ export function runRecord(input: {
226
230
  title: input.title,
227
231
  type: state.type ?? null,
228
232
  profile: state.profile,
233
+ ...cardOrigin(state.repo, state.featureDir),
229
234
  },
230
235
  run: {
231
236
  startedAt: input.runStartedAt,
@@ -243,6 +248,17 @@ export function runRecord(input: {
243
248
  };
244
249
  }
245
250
 
251
+ /** The card a requirement started from (source.json), for the record. */
252
+ function cardOrigin(repo: string, featureDir: string): { requester: string | null; source: { system: string; id: string } } | Record<string, never> {
253
+ try {
254
+ const raw = JSON.parse(fs.readFileSync(path.join(repo, featureDir, "source.json"), "utf8")) as { system?: unknown; id?: unknown; author?: unknown };
255
+ if (typeof raw.system !== "string" || typeof raw.id !== "string") return {};
256
+ return { requester: typeof raw.author === "string" ? raw.author : null, source: { system: raw.system, id: raw.id } };
257
+ } catch {
258
+ return {};
259
+ }
260
+ }
261
+
246
262
  /** Appends a run record. Never throws. */
247
263
  export function appendRun(record: RunRecord, env: NodeJS.ProcessEnv = process.env): void {
248
264
  if (recordingOff(env)) return;
@@ -109,6 +109,11 @@ export interface PipelineState {
109
109
  gate?: { baseline: GateBaseline | null; runs: GateRun[] } | null;
110
110
  /** External systems (1.9): the requirement's card in each, by integration name. */
111
111
  external?: Record<string, ExternalRecord> | null;
112
+ /**
113
+ * Branches pushed for a requirement that started from a card (1.10, `push` config): one entry
114
+ * per repository (the main one, then joint ones), the latest attempt. `error` is null when it worked.
115
+ */
116
+ pushed?: Array<{ repo: string; remote: string; branch: string; at: string; error: string | null }> | null;
112
117
  }
113
118
 
114
119
  export interface JointRepo {
@@ -87,6 +87,10 @@ export interface RequirementStats {
87
87
  waits: Record<WaitKind, number>;
88
88
  builder: string | null;
89
89
  reviewer: string | null;
90
+ /** The card's author, for requirements that started from a card (1.10). */
91
+ requester: string | null;
92
+ /** "<system>:<id>" of that card. */
93
+ card: string | null;
90
94
  }
91
95
 
92
96
  function add(a: number | null, b: number | null): number | null {
@@ -162,6 +166,8 @@ export function byRequirement(runs: RunRecord[]): RequirementStats[] {
162
166
  waits,
163
167
  builder: last.agents.builder,
164
168
  reviewer: last.agents.reviewer,
169
+ requester: last.requirement.requester ?? null,
170
+ card: last.requirement.source ? `${last.requirement.source.system}:${last.requirement.source.id}` : null,
165
171
  });
166
172
  }
167
173
  return out.sort((a, b) => b.lastEndedAt.localeCompare(a.lastEndedAt));
@@ -202,6 +208,8 @@ export interface StatsSummary {
202
208
  waits: Array<{ kind: WaitKind; label: string; requirements: number; averageMinutes: number }>;
203
209
  usage: { agentMinutes: number; inputTokens: number | null; outputTokens: number | null; costUsd: number | null };
204
210
  byBuilder: Array<{ builder: string; requirements: number; averageReviewRounds: number | null; averageCostUsd: number | null }>;
211
+ /** Requirements that started from cards, by who asked for them (1.10); empty when there are none. */
212
+ byRequester: Array<{ requester: string; requirements: number; completed: number; medianCycleHours: number | null }>;
205
213
  requirements: RequirementStats[];
206
214
  }
207
215
 
@@ -246,6 +254,9 @@ export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
246
254
  const builders = new Map<string, RequirementStats[]>();
247
255
  for (const q of done) builders.set(q.builder ?? "(未记录)", [...(builders.get(q.builder ?? "(未记录)") ?? []), q]);
248
256
 
257
+ const requesters = new Map<string, RequirementStats[]>();
258
+ for (const q of active) if (q.card) requesters.set(q.requester ?? "(未记录)", [...(requesters.get(q.requester ?? "(未记录)") ?? []), q]);
259
+
249
260
  return {
250
261
  schema: 1,
251
262
  period: { from: period.from?.toISOString() ?? null, to: period.to?.toISOString() ?? null, label: period.label },
@@ -276,6 +287,12 @@ export function summarizeRuns(all: RunRecord[], period: Period): StatsSummary {
276
287
  averageReviewRounds: avg(qs.map((q) => q.reviewRounds)),
277
288
  averageCostUsd: avg(qs.flatMap((q) => (q.costUsd === null ? [] : [q.costUsd]))),
278
289
  })),
290
+ byRequester: [...requesters.entries()]
291
+ .map(([requester, qs]) => {
292
+ const finished = qs.filter((q) => done.includes(q));
293
+ return { requester, requirements: qs.length, completed: finished.length, medianCycleHours: median(finished.flatMap((q) => (q.cycleHours === null ? [] : [q.cycleHours]))) };
294
+ })
295
+ .sort((a, b) => b.requirements - a.requirements || a.requester.localeCompare(b.requester)),
279
296
  requirements: active,
280
297
  };
281
298
  }
@@ -342,6 +359,10 @@ export function describeStats(s: StatsSummary): string {
342
359
  lines.push("", "## 按 builder 比较(已完成的需求)", "", "| builder | 需求数 | 平均评审轮数 | 平均费用 |", "| --- | --- | --- | --- |");
343
360
  for (const b of s.byBuilder) lines.push(`| ${b.builder} | ${b.requirements} | ${b.averageReviewRounds ?? "-"} | ${b.averageCostUsd === null ? "-" : `$${b.averageCostUsd.toFixed(2)}`} |`);
344
361
  }
362
+ if (s.byRequester.length > 0) {
363
+ lines.push("", "## 从卡片开始的需求(按需求方)", "", "| 需求方 | 需求数 | 已完成 | 周期中位数 |", "| --- | --- | --- | --- |");
364
+ for (const r of s.byRequester) lines.push(`| ${r.requester} | ${r.requirements} | ${r.completed} | ${r.medianCycleHours === null ? "-" : `${r.medianCycleHours} 小时`} |`);
365
+ }
345
366
  lines.push("", "## 需求", "", "| 项目 | 需求 | 类型 | 状态 | 周期 | agent 用时 | 评审轮数 | 缺陷轮数 |", "| --- | --- | --- | --- | --- | --- | --- | --- |");
346
367
  for (const q of s.requirements) {
347
368
  const status = q.status === "completed" ? (OUTCOME_LABELS[q.outcome ?? "unknown"] ?? "已完成") : (STATUS_LABELS[q.status] ?? q.status);
@@ -378,6 +399,8 @@ const CSV_COLUMNS: Array<[string, (q: RequirementStats) => unknown]> = [
378
399
  ["wait_verify_minutes", (q) => q.waits.verify],
379
400
  ["builder", (q) => q.builder ?? ""],
380
401
  ["reviewer", (q) => q.reviewer ?? ""],
402
+ ["requester", (q) => q.requester ?? ""],
403
+ ["card", (q) => q.card ?? ""],
381
404
  ];
382
405
 
383
406
  function csvCell(v: unknown): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-req-kit",
3
- "version": "1.10.0-beta.2",
3
+ "version": "1.11.0-beta.1",
4
4
  "description": "req-kit: a requirement-development workflow for Paseo. /req aligns a requirement in chat; the engine runs design, implementation, review and verification as Paseo agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",