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.
- package/CHANGELOG.md +31 -0
- package/bin/req.mjs +5 -0
- package/contracts/state.md +1 -0
- package/contracts/usage-record.md +1 -1
- package/docs/adr/0029-requirements-from-cards.md +3 -1
- package/docs/adr/0030-card-intake-process.md +22 -0
- package/docs/adr/README.md +1 -0
- 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 +58 -0
- package/engine/src/card-cmd.ts +146 -58
- package/engine/src/card.ts +84 -0
- package/engine/src/config.ts +48 -1
- package/engine/src/factory.ts +406 -0
- package/engine/src/integrations.ts +29 -1
- package/engine/src/pipeline.ts +38 -2
- package/engine/src/records.ts +16 -0
- package/engine/src/state.ts +5 -0
- package/engine/src/stats.ts +23 -0
- package/package.json +1 -1
- package/paseo-plugin/package.json +1 -1
- package/skills/req-setup/SKILL.md +4 -0
|
@@ -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)) {
|
package/engine/src/pipeline.ts
CHANGED
|
@@ -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
|
|
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`,
|
package/engine/src/records.ts
CHANGED
|
@@ -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;
|
package/engine/src/state.ts
CHANGED
|
@@ -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 {
|
package/engine/src/stats.ts
CHANGED
|
@@ -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.
|
|
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",
|