paseo-req-kit 1.9.0-beta.2 → 1.9.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,400 @@
1
+ // External system integrations (1.9, ADR 0028): the requirement as a card in the team's
2
+ // requirement management system, kept in step with the pipeline.
3
+ //
4
+ // An integration is a local command line tool (e.g. an ALM's CLI) described in the machine or
5
+ // team configuration: how to create a card, how to change its state, and which state each
6
+ // pipeline event maps to. The engine fills the arguments from the requirement and the card
7
+ // fields the requester confirmed with the spec (card.json, written by /req), runs the tool
8
+ // without a shell, reads the card's id and link from its output and keeps them in the state
9
+ // file. A call that fails is kept in order and sent again before the next one, or with
10
+ // `req sync`. An integration never stops the pipeline.
11
+
12
+ import { execFile, spawnSync } from "node:child_process";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import type { PipelineState } from "./state.js";
16
+
17
+ export const INTEGRATION_EVENTS = ["started", "approval", "blocked", "phase_done", "verify", "completed", "failed"] as const;
18
+ export type IntegrationEvent = (typeof INTEGRATION_EVENTS)[number];
19
+
20
+ export const INTEGRATION_EVENT_LABELS: Record<IntegrationEvent, string> = {
21
+ started: "开始开发",
22
+ approval: "等待确认方案",
23
+ blocked: "等待需求方决定",
24
+ phase_done: "研发完成,待测试",
25
+ verify: "开始测试验证",
26
+ completed: "完成",
27
+ failed: "中断",
28
+ };
29
+
30
+ /** One command: its arguments, each may contain {placeholders}. */
31
+ export interface IntegrationAction {
32
+ args: string[];
33
+ }
34
+
35
+ export interface IntegrationConfig {
36
+ /** Shown to people, and the key under which the card is kept, e.g. "ALM". */
37
+ name: string;
38
+ /** The tool to run, e.g. "shenbing". Found on PATH; npm's .cmd shims on Windows are run through node. */
39
+ command: string;
40
+ /** Creates the card. `id` is a regular expression for the card id in its output (the first group, or a group named id); `url` likewise, optional. */
41
+ create?: IntegrationAction & { id: string; url?: string };
42
+ /** Changes the card's state; {status} is the mapped state. */
43
+ status?: IntegrationAction;
44
+ /** Card state for each pipeline event; "completed_with_issues" and "not_completed" refine "completed". */
45
+ statusMap?: Partial<Record<IntegrationEvent | "completed_with_issues" | "not_completed", string>>;
46
+ /** Fields the requester confirms with the spec (card.json): name → what to fill in, for /req. */
47
+ cardFields?: Record<string, string>;
48
+ /** Values for {field.<name>} placeholders; a project's integrationDefaults[name] adds to these. */
49
+ defaults?: Record<string, string>;
50
+ timeoutSeconds?: number;
51
+ }
52
+
53
+ /** What is known about a requirement's card, in the state file's `external[name]`. */
54
+ export interface ExternalRecord {
55
+ id: string | null;
56
+ url: string | null;
57
+ /** Last state set on the card. */
58
+ status: string | null;
59
+ syncedAt: string | null;
60
+ /** Calls that failed, oldest first; sent again before the next call and by `req sync`. */
61
+ pending: Array<{ action: "create" | "status"; event: IntegrationEvent; status: string | null; at: string; attempts: number; error: string }>;
62
+ }
63
+
64
+ /** The card fields /req writes with the spec: card.json in the feature directory. */
65
+ export interface CardFile {
66
+ title?: string;
67
+ body?: string;
68
+ fields?: Record<string, string>;
69
+ }
70
+
71
+ export function validateIntegrations(value: unknown): string | null {
72
+ if (!Array.isArray(value)) return "integrations 必须是数组";
73
+ const names = new Set<string>();
74
+ for (const [i, raw] of value.entries()) {
75
+ const at = `integrations[${i}]`;
76
+ if (!raw || typeof raw !== "object") return `${at} 必须是对象`;
77
+ const c = raw as Record<string, unknown>;
78
+ if (typeof c.name !== "string" || !c.name.trim()) return `${at}.name 必须是名称,例如 "ALM"`;
79
+ if (names.has(c.name)) return `${at}.name「${c.name}」重复了`;
80
+ names.add(c.name);
81
+ if (typeof c.command !== "string" || !c.command.trim()) return `${at}.command 必须是要运行的命令,例如 "shenbing"`;
82
+ for (const key of ["create", "status"] as const) {
83
+ const action = c[key] as Record<string, unknown> | undefined;
84
+ if (action === undefined) continue;
85
+ if (!action || !Array.isArray(action.args) || !action.args.every((a) => typeof a === "string")) return `${at}.${key}.args 必须是字符串数组`;
86
+ }
87
+ const create = c.create as Record<string, unknown> | undefined;
88
+ if (create) {
89
+ if (typeof create.id !== "string") return `${at}.create.id 必须是从输出中取卡片编号的正则表达式`;
90
+ for (const k of ["id", "url"] as const) {
91
+ if (create[k] === undefined) continue;
92
+ try {
93
+ new RegExp(String(create[k]));
94
+ } catch (error) {
95
+ return `${at}.create.${k} 不是有效的正则表达式:${(error as Error).message}`;
96
+ }
97
+ }
98
+ }
99
+ if (c.statusMap !== undefined) {
100
+ if (!c.statusMap || typeof c.statusMap !== "object") return `${at}.statusMap 必须是对象`;
101
+ for (const [k, v] of Object.entries(c.statusMap)) {
102
+ if (![...INTEGRATION_EVENTS, "completed_with_issues", "not_completed"].includes(k)) return `${at}.statusMap 中的 ${k} 不是流水线事件(可用:${INTEGRATION_EVENTS.join("、")})`;
103
+ if (typeof v !== "string") return `${at}.statusMap.${k} 必须是字符串`;
104
+ }
105
+ if (!c.status) return `${at} 写了 statusMap,但没有 status(改状态的命令)`;
106
+ }
107
+ for (const key of ["cardFields", "defaults"] as const) {
108
+ const m = c[key];
109
+ if (m === undefined) continue;
110
+ if (!m || typeof m !== "object" || !Object.values(m).every((v) => typeof v === "string")) return `${at}.${key} 必须是"名称: 文字"的对象`;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Running the tool
118
+
119
+ /**
120
+ * How to run `command` without a shell. On Windows an npm-installed tool is a .cmd shim that
121
+ * only cmd.exe can run, and cmd.exe would mangle arguments such as a Markdown body; the shim
122
+ * names the script it runs, so run that with node instead.
123
+ */
124
+ export function resolveCommand(command: string, env: NodeJS.ProcessEnv = process.env): { file: string; prefix: string[] } {
125
+ if (process.platform !== "win32" || /[\\/]/.test(command) && !/\.cmd$/i.test(command)) return { file: command, prefix: [] };
126
+ const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
127
+ const candidates = /\.(cmd|exe|bat)$/i.test(command) ? [command] : [`${command}.exe`, `${command}.cmd`];
128
+ for (const dir of [...(/[\\/]/.test(command) ? [""] : dirs)]) {
129
+ for (const name of candidates) {
130
+ const full = dir ? path.join(dir, name) : name;
131
+ if (!fs.existsSync(full)) continue;
132
+ if (/\.exe$/i.test(full)) return { file: full, prefix: [] };
133
+ const text = fs.readFileSync(full, "utf8");
134
+ const script = /"%(?:~?dp0)%\\?([^"]+?\.(?:c|m)?js)"/i.exec(text)?.[1];
135
+ if (script) return { file: process.execPath, prefix: [path.join(path.dirname(full), script)] };
136
+ }
137
+ }
138
+ return { file: command, prefix: [] };
139
+ }
140
+
141
+ export interface RunResult {
142
+ ok: boolean;
143
+ output: string;
144
+ error: string | null;
145
+ }
146
+
147
+ export type Runner = (command: string, args: string[], timeoutMs: number) => Promise<RunResult>;
148
+
149
+ export const runCommand: Runner = (command, args, timeoutMs) =>
150
+ new Promise((resolve) => {
151
+ const { file, prefix } = resolveCommand(command);
152
+ execFile(file, [...prefix, ...args], { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
153
+ const output = `${stdout ?? ""}${stderr ? `\n${stderr}` : ""}`.trim();
154
+ if (error) {
155
+ const why = (error as NodeJS.ErrnoException).code === "ENOENT" ? `找不到命令 ${command}` : error.killed ? `超过 ${timeoutMs / 1000} 秒没有完成` : (stderr || error.message).trim().split("\n").slice(-2).join(" ");
156
+ resolve({ ok: false, output, error: why });
157
+ } else resolve({ ok: true, output, error: null });
158
+ });
159
+ });
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Arguments
163
+
164
+ export interface IntegrationContext {
165
+ repo: string;
166
+ featureDir: string;
167
+ state: Pick<PipelineState, "branch" | "profile" | "type" | "outcome" | "external">;
168
+ /** Project values for {field.*}, from the project's integrationDefaults. */
169
+ projectDefaults?: Record<string, string>;
170
+ }
171
+
172
+ function readFile(file: string): string | null {
173
+ try {
174
+ return fs.readFileSync(file, "utf8");
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ export function readCard(repo: string, featureDir: string): CardFile {
181
+ const raw = readFile(path.join(repo, featureDir, "card.json"));
182
+ if (!raw) return {};
183
+ try {
184
+ const parsed = JSON.parse(raw) as CardFile;
185
+ return parsed && typeof parsed === "object" ? parsed : {};
186
+ } catch {
187
+ return {};
188
+ }
189
+ }
190
+
191
+ /** The values arguments can name: {title}, {body}, {card.<field>}, {field.<name>}, {id}, {status}… */
192
+ export function placeholderValues(config: IntegrationConfig, ctx: IntegrationContext, extra: { status?: string | null; event?: IntegrationEvent } = {}): Record<string, string> {
193
+ const spec = readFile(path.join(ctx.repo, ctx.featureDir, "spec.md")) ?? "";
194
+ const card = readCard(ctx.repo, ctx.featureDir);
195
+ const record = ctx.state.external?.[config.name];
196
+ const values: Record<string, string> = {
197
+ title: card.title ?? /^#\s+(.+)$/m.exec(spec)?.[1]?.trim() ?? path.basename(ctx.featureDir),
198
+ body: card.body ?? spec,
199
+ feature: path.basename(ctx.featureDir),
200
+ featureDir: ctx.featureDir,
201
+ repo: ctx.repo,
202
+ branch: ctx.state.branch ?? "",
203
+ type: ctx.state.type ?? "",
204
+ profile: ctx.state.profile,
205
+ outcome: ctx.state.outcome ?? "",
206
+ id: record?.id ?? "",
207
+ url: record?.url ?? "",
208
+ status: extra.status ?? "",
209
+ event: extra.event ?? "",
210
+ };
211
+ for (const [k, v] of Object.entries({ ...(config.defaults ?? {}), ...(ctx.projectDefaults ?? {}) })) values[`field.${k}`] = v;
212
+ for (const [k, v] of Object.entries(card.fields ?? {})) values[`card.${k}`] = String(v);
213
+ // A card field the requester left out falls back to the configured default of the same name.
214
+ for (const k of Object.keys(config.cardFields ?? {})) if (values[`card.${k}`] === undefined) values[`card.${k}`] = values[`field.${k}`] ?? "";
215
+ return values;
216
+ }
217
+
218
+ /**
219
+ * The arguments with placeholders filled. An argument that is only a placeholder and comes out
220
+ * empty is dropped together with the option before it ("--priority {card.priority}" → nothing),
221
+ * so optional fields need no special syntax. Unknown placeholders are an error.
222
+ */
223
+ export function renderArgs(args: string[], values: Record<string, string>): string[] {
224
+ const out: string[] = [];
225
+ for (const arg of args) {
226
+ const only = /^\{([\w.-]+)\}$/.exec(arg);
227
+ const rendered = arg.replace(/\{([\w.-]+)\}/g, (_, key: string) => {
228
+ if (!(key in values)) throw new Error(`参数 ${arg} 中的 {${key}} 没有对应的值`);
229
+ return values[key];
230
+ });
231
+ if (only && rendered === "") {
232
+ if (out.length > 0 && out.at(-1)!.startsWith("-")) out.pop();
233
+ continue;
234
+ }
235
+ out.push(rendered);
236
+ }
237
+ return out;
238
+ }
239
+
240
+ function capture(pattern: string | undefined, output: string): string | null {
241
+ if (!pattern) return null;
242
+ const m = new RegExp(pattern).exec(output);
243
+ if (!m) return null;
244
+ return (m.groups?.id ?? m.groups?.url ?? m[1] ?? m[0]).trim();
245
+ }
246
+
247
+ /** The state an event sets, if any. */
248
+ export function statusFor(config: IntegrationConfig, event: IntegrationEvent, outcome: string | null): string | null {
249
+ const map = config.statusMap ?? {};
250
+ if (event === "completed" && (outcome === "completed_with_issues" || outcome === "not_completed") && map[outcome]) return map[outcome] ?? null;
251
+ return map[event] ?? null;
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Syncing
256
+
257
+ function emptyRecord(): ExternalRecord {
258
+ return { id: null, url: null, status: null, syncedAt: null, pending: [] };
259
+ }
260
+
261
+ /**
262
+ * Brings one integration up to date for an event: sends what failed before (in order), creates
263
+ * the card if there is none yet, then sets the event's state. Updates `state.external` in place;
264
+ * the caller saves the state. Returns lines for the log. Never throws.
265
+ */
266
+ export async function syncIntegration(input: {
267
+ config: IntegrationConfig;
268
+ ctx: IntegrationContext & { state: PipelineState };
269
+ event: IntegrationEvent | null;
270
+ run?: Runner;
271
+ log: (message: string) => void;
272
+ }): Promise<ExternalRecord> {
273
+ const { config, ctx, log } = input;
274
+ const run = input.run ?? runCommand;
275
+ const timeout = (config.timeoutSeconds ?? 60) * 1000;
276
+ ctx.state.external ??= {};
277
+ const record = (ctx.state.external[config.name] ??= emptyRecord());
278
+ const now = () => new Date().toISOString();
279
+
280
+ const attempt = async (action: "create" | "status", event: IntegrationEvent, status: string | null): Promise<boolean> => {
281
+ const def = action === "create" ? config.create : config.status;
282
+ if (!def) return true;
283
+ let args: string[];
284
+ try {
285
+ args = renderArgs(def.args, placeholderValues(config, ctx, { status, event }));
286
+ } catch (error) {
287
+ log(`${config.name}:${(error as Error).message},没有调用`);
288
+ return false;
289
+ }
290
+ const result = await run(config.command, args, timeout);
291
+ if (!result.ok) {
292
+ log(`${config.name}:${action === "create" ? "建卡" : `改为「${status}」`}失败:${result.error}`);
293
+ const prev = record.pending.find((p) => p.action === action && p.status === status);
294
+ if (prev) {
295
+ prev.attempts += 1;
296
+ prev.error = result.error ?? "";
297
+ } else {
298
+ if (action === "status") record.pending = record.pending.filter((p) => p.action !== "status");
299
+ record.pending.push({ action, event, status, at: now(), attempts: 1, error: result.error ?? "" });
300
+ }
301
+ return false;
302
+ }
303
+ if (action === "create") {
304
+ const id = capture(config.create!.id, result.output);
305
+ if (!id) {
306
+ log(`${config.name}:建卡命令成功了,但输出中没有找到卡片编号(create.id 的正则没有匹配)。输出:${result.output.slice(0, 300)}`);
307
+ record.pending.push({ action, event, status, at: now(), attempts: 1, error: "输出中没有卡片编号" });
308
+ return false;
309
+ }
310
+ record.id = id;
311
+ record.url = capture(config.create!.url, result.output);
312
+ log(`${config.name}:已建卡 ${id}${record.url ? `(${record.url})` : ""}`);
313
+ } else {
314
+ record.status = status;
315
+ log(`${config.name}:${record.id} 已改为「${status}」`);
316
+ }
317
+ record.syncedAt = now();
318
+ record.pending = record.pending.filter((p) => !(p.action === action && (action === "create" || p.status === status)));
319
+ return true;
320
+ };
321
+
322
+ try {
323
+ // A new state replaces one still waiting to be sent: only the latest matters.
324
+ const newStatus = input.event ? statusFor(config, input.event, ctx.state.outcome ?? null) : null;
325
+ if (newStatus) record.pending = record.pending.filter((p) => p.action !== "status");
326
+ // 1. What failed before, oldest first; stop at the first that still fails, keeping the order.
327
+ let blocked = false;
328
+ for (const p of [...record.pending]) {
329
+ if (p.action === "status" && !record.id) {
330
+ blocked = true;
331
+ break;
332
+ }
333
+ if ((p.action === "status" && p.status === record.status) || (p.action === "create" && record.id)) {
334
+ record.pending = record.pending.filter((x) => x !== p);
335
+ continue;
336
+ }
337
+ const index = record.pending.indexOf(p);
338
+ record.pending.splice(index, 1);
339
+ if (!(await attempt(p.action, p.event, p.status))) {
340
+ // attempt() appended it again; put it back where it was so the order holds.
341
+ const again = record.pending.pop();
342
+ if (again) record.pending.splice(index, 0, { ...again, attempts: p.attempts + 1, at: p.at });
343
+ blocked = true;
344
+ break;
345
+ }
346
+ }
347
+ if (!input.event) return record;
348
+ // 2. A card for this requirement.
349
+ if (!record.id && config.create && !blocked) {
350
+ if (!(await attempt("create", input.event, null))) blocked = true;
351
+ }
352
+ // 3. The event's state: now, or queued behind what is still waiting.
353
+ const status = newStatus;
354
+ if (status && status !== record.status && !record.pending.some((p) => p.action === "status" && p.status === status)) {
355
+ // Only the latest state matters: a state still waiting is replaced, not sent after this one.
356
+ if (blocked || !record.id) {
357
+ record.pending = record.pending.filter((p) => p.action !== "status");
358
+ record.pending.push({ action: "status", event: input.event, status, at: now(), attempts: 0, error: "等待前面的调用完成" });
359
+ }
360
+ else await attempt("status", input.event, status);
361
+ }
362
+ } catch (error) {
363
+ log(`${config.name}:${(error as Error).message}`);
364
+ }
365
+ return record;
366
+ }
367
+
368
+ /** Plain-language summary lines for status and reports. */
369
+ export function describeExternal(external: PipelineState["external"]): string[] {
370
+ const lines: string[] = [];
371
+ for (const [name, r] of Object.entries(external ?? {})) {
372
+ const head = r.id ? `${name}:${r.id}${r.url ? `(${r.url})` : ""}${r.status ? `,状态「${r.status}」` : ""}` : `${name}:还没有建卡`;
373
+ lines.push(r.pending.length > 0 ? `${head};有 ${r.pending.length} 次调用没有成功,下次自动补发(或 req sync):${r.pending.at(-1)!.error}` : head);
374
+ }
375
+ return lines;
376
+ }
377
+
378
+ /** For `--card-fields`: what /req should ask for and write to card.json, or null without integrations. */
379
+ export function cardFieldsText(integrations: IntegrationConfig[]): string | null {
380
+ const withFields = integrations.filter((i) => i.cardFields && Object.keys(i.cardFields).length > 0);
381
+ if (withFields.length === 0) return null;
382
+ const lines = [
383
+ "需求说明确认时,一并和需求方确认下面的卡片字段,写入功能目录的 card.json(和 spec.md 一起提交):",
384
+ '{ "title": "<卡片标题,默认用需求说明的标题>", "fields": { "<字段>": "<值>", … } }',
385
+ "",
386
+ ];
387
+ for (const i of withFields) {
388
+ lines.push(`${i.name}:`);
389
+ for (const [k, v] of Object.entries(i.cardFields!)) lines.push(`- ${k}:${v}${i.defaults?.[k] ? `(默认 ${i.defaults[k]})` : ""}`);
390
+ }
391
+ lines.push("", "能从需求说明中判断的直接填上请需求方确认;判断不了的问需求方;不知道的可以不写(用默认值或不填)。");
392
+ return lines.join("\n");
393
+ }
394
+
395
+ /** Checks that the tool can be found, for the doctor. */
396
+ export function commandAvailable(command: string): boolean {
397
+ const { file, prefix } = resolveCommand(command);
398
+ const r = spawnSync(file, [...prefix, "--help"], { encoding: "utf8", timeout: 20_000, windowsHide: true });
399
+ return !r.error;
400
+ }
@@ -13,6 +13,7 @@ import { scheduledStartPrompt } from "./messages.js";
13
13
  import { describeTime, parseStartTime } from "./schedule-time.js";
14
14
  import { moveToOwnWorktree, type OwnWorktree } from "./worktree.js";
15
15
  import { loadConfig, PHASE_MODE_LABELS, type PipelineConfig, PROFILE_LABELS } from "./config.js";
16
+ import { cardFieldsText } from "./integrations.js";
16
17
  import { defaultConfigPath, ENGINE_DIR } from "./locations.js";
17
18
  import { describeLiveActivity, describeStatus, stopPipeline } from "./control.js";
18
19
  import { describeFeatures, featuresWithRuns, findWorktreeRun, listFeatures, resolveFeatureArg } from "./features.js";
@@ -79,6 +80,17 @@ try {
79
80
  }
80
81
  const configPath = args.configPath ?? defaultConfigPath();
81
82
 
83
+ if (args.cardFields) {
84
+ try {
85
+ const text = cardFieldsText(loadConfig(configPath, fs.existsSync(args.repo) ? args.repo : undefined).config.integrations);
86
+ console.log(text ?? "没有配置外部系统对接(integrations),不需要 card.json,跳过这一步。");
87
+ process.exit(0);
88
+ } catch (error) {
89
+ console.error((error as Error).message);
90
+ process.exit(2);
91
+ }
92
+ }
93
+
82
94
  if (args.list) {
83
95
  console.log(describeFeatures(listFeatures(args.repo), args.repo));
84
96
  process.exit(0);
@@ -33,6 +33,7 @@ import {
33
33
  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
+ import { describeExternal, type IntegrationEvent, type Runner, syncIntegration } from "./integrations.js";
36
37
  import { principlesSection, projectAdditions, type PromptLocations, readToolPrompt } from "./prompts.js";
37
38
  import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent } from "./runtime.js";
38
39
  import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
@@ -56,6 +57,8 @@ export interface PipelineContext {
56
57
  approveCommand?: string;
57
58
  log: (message: string) => void;
58
59
  sleep: (ms: number) => Promise<void>;
60
+ /** Runs integration commands; tests replace it. Default: the real command line. */
61
+ integrationRunner?: Runner;
59
62
  /** Paseo-managed services of this run (filled in by the pipeline). */
60
63
  services?: { names: string[] | null | undefined; started: Set<string> };
61
64
  /** Paseo tools the step agents get; looked up once (undefined: not yet). */
@@ -384,6 +387,21 @@ async function notifyExternal(ctx: PipelineContext, type: NotifierEventType, tex
384
387
  );
385
388
  }
386
389
 
390
+ /** Keeps the requirement's card in each configured external system in step (1.9). Never throws. */
391
+ async function integrate(ctx: PipelineContext, event: IntegrationEvent): Promise<void> {
392
+ if (ctx.config.integrations.length === 0) return;
393
+ for (const config of ctx.config.integrations) {
394
+ await syncIntegration({
395
+ config,
396
+ ctx: { repo: ctx.repo, featureDir: ctx.featureDir, state: ctx.state, projectDefaults: ctx.config.integrationDefaults[config.name] },
397
+ event,
398
+ run: ctx.integrationRunner,
399
+ log: ctx.log,
400
+ });
401
+ }
402
+ save(ctx);
403
+ }
404
+
387
405
  /** Sends a progress note once per run attempt, when enabled. */
388
406
  async function milestone(ctx: PipelineContext, id: string, text: string): Promise<void> {
389
407
  if (!ctx.config.notifyMilestones || ctx.state.milestones.includes(id)) return;
@@ -1648,8 +1666,13 @@ async function setUpJoint(ctx: PipelineContext): Promise<void> {
1648
1666
  }
1649
1667
 
1650
1668
  /** What the report is told about this attempt: usage, the quality gate, the scope check. */
1669
+ /** The requirement's cards in external systems, for the report's appendix. */
1670
+ function externalLines(ctx: PipelineContext): string[] {
1671
+ return describeExternal(ctx.state.external).map((l) => `外部系统 ${l}`);
1672
+ }
1673
+
1651
1674
  function reportLines(ctx: PipelineContext): string[] {
1652
- const lines = [...usageLines(ctx), ...gateLines(ctx), ...scopeLines(ctx), ...acceptanceLines(ctx), ...jointLines(ctx)];
1675
+ const lines = [...usageLines(ctx), ...gateLines(ctx), ...scopeLines(ctx), ...acceptanceLines(ctx), ...jointLines(ctx), ...externalLines(ctx)];
1653
1676
  if (ctx.config.checks.length === 0 && ctx.state.type !== "assessment") {
1654
1677
  lines.push("质量门:项目没有配置检查(checks),流水线没有自己运行检查,以各步骤记录的检查结果为准");
1655
1678
  }
@@ -1693,6 +1716,7 @@ async function flow(ctx: PipelineContext): Promise<Record<string, unknown>> {
1693
1716
  }
1694
1717
  ctx.state.phase = "verify";
1695
1718
  save(ctx);
1719
+ await integrate(ctx, "verify");
1696
1720
  }
1697
1721
  if (!(await verifyPhase(ctx, design))) break;
1698
1722
  await handBack(ctx);
@@ -1705,6 +1729,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1705
1729
  ctx.state.status = "running";
1706
1730
  ctx.state.stopReason = null;
1707
1731
  save(ctx);
1732
+ await integrate(ctx, phaseOf(ctx) === "verify" ? "verify" : "started");
1708
1733
  try {
1709
1734
  await checkQuota(ctx);
1710
1735
  const report = await flow(ctx);
@@ -1730,6 +1755,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1730
1755
  const text = developOnly
1731
1756
  ? `研发已完成(只做研发):${outcomeText[report.outcome as string] ?? report.outcome},请按提测说明人工测试。`
1732
1757
  : `开发流水线已结束:${outcomeText[report.outcome as string] ?? report.outcome}。`;
1758
+ await integrate(ctx, "completed");
1733
1759
  await notifyExternal(ctx, "completed", text, {
1734
1760
  outcome: report.outcome,
1735
1761
  summary: report.summary,
@@ -1757,6 +1783,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1757
1783
  handbacks: ctx.state.handbacks ?? 0,
1758
1784
  }),
1759
1785
  );
1786
+ await integrate(ctx, "phase_done");
1760
1787
  await notifyExternal(ctx, "phase_done", `研发已完成,可以提测:提测说明在 ${ctx.featureDir}/handoff.md。${verifyAt ? `已预约在 ${verifyAt} 开始测试验证。` : ""}`, {
1761
1788
  handoffPath: `${ctx.featureDir}/handoff.md`,
1762
1789
  mode: runPhases(ctx.state).mode,
@@ -1785,6 +1812,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1785
1812
  autoContinueAt,
1786
1813
  }),
1787
1814
  );
1815
+ await integrate(ctx, "approval");
1788
1816
  await notifyExternal(ctx, "approval", `方案已通过审核,请需求方确认"做完后会看到什么"再开始写代码:${ctx.featureDir}/preview.md`, {
1789
1817
  previewPath: `${ctx.featureDir}/preview.md`,
1790
1818
  approveCommand: approveCommandOf(ctx),
@@ -1805,6 +1833,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1805
1833
  const external = reproduce
1806
1834
  ? `没能复现这个缺陷,需要需求方补充 ${error.questions.length} 项信息,流水线已暂停,请到发起的对话中回答。`
1807
1835
  : `方案审核发现 ${error.questions.length} 个需要需求方决定的问题,流水线已暂停,请到发起的对话中回答。`;
1836
+ await integrate(ctx, "blocked");
1808
1837
  await notifyExternal(ctx, "blocked", external, {
1809
1838
  questions: error.questions,
1810
1839
  resumeCommand: ctx.resumeCommand,
@@ -1827,6 +1856,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
1827
1856
  autoResumeAt,
1828
1857
  }),
1829
1858
  );
1859
+ await integrate(ctx, "failed");
1830
1860
  await notifyExternal(ctx, "failed", `开发流水线在「${stepTitle}」步骤中断:${(error as Error).message}`, {
1831
1861
  stepTitle,
1832
1862
  reason: (error as Error).message,
@@ -29,6 +29,8 @@ export interface LaunchArgs {
29
29
  stop: boolean;
30
30
  /** --list: print every requirement in this repository's worktrees. */
31
31
  list: boolean;
32
+ /** --card-fields: what /req confirms with the spec for the external systems (card.json). */
33
+ cardFields?: boolean;
32
34
  /** --spec-review: independent review of the draft spec, before confirmation. */
33
35
  specReview: boolean;
34
36
  /** --worktree: create a Paseo worktree workspace for this requirement and develop there. */
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import type { DefectHandling, PhaseMode, ProfileName, RequirementType, VerifyEnvironment } from "./config.js";
6
+ import type { ExternalRecord } from "./integrations.js";
6
7
  import { registerRequirement } from "./records.js";
7
8
  import type { AgentUsage } from "./usage.js";
8
9
  import type { StepId } from "./steps.js";
@@ -106,6 +107,8 @@ export interface PipelineState {
106
107
  approval?: { attempt: number; status: "waiting" | "approved"; at: string; auto?: boolean } | null;
107
108
  /** Quality gate: the checks' results before the change, and every run after a step (1.8). */
108
109
  gate?: { baseline: GateBaseline | null; runs: GateRun[] } | null;
110
+ /** External systems (1.9): the requirement's card in each, by integration name. */
111
+ external?: Record<string, ExternalRecord> | null;
109
112
  }
110
113
 
111
114
  export interface JointRepo {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-req-kit",
3
- "version": "1.9.0-beta.2",
3
+ "version": "1.9.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",
@@ -2,11 +2,13 @@
2
2
  // management actions (each one is `req <command>` on the daemon machine, after confirming).
3
3
  import type { PluginHostProps, PluginScreenProps, PluginSurfaceProps } from "@getpaseo/plugin/client";
4
4
  import { Modal, ScrollView, useToast } from "@getpaseo/plugin/client/react-native";
5
+ import { ExternalLink } from "@getpaseo/plugin/client/ui";
5
6
  import { useState } from "react";
6
7
  import { Pressable, Text, View } from "react-native";
7
8
  import type { Action, RequirementDetail, Step } from "../shared/contracts";
8
9
  import { useAction, useDocument, usePreferences, useRequirement, useRequirements } from "./data";
9
10
  import { ACTION_TEXT, actionsFor, ago, ATTENTION_LABELS, facts, FILE_LABELS, minutes, tokens } from "./format";
11
+ import { Markdown, tsvToMarkdown } from "./markdown";
10
12
  import { Badge, Button, Chip, Status, useStyles } from "./ui";
11
13
 
12
14
  type Theme = PluginHostProps["theme"];
@@ -64,9 +66,7 @@ function DocumentModal({
64
66
  <Status theme={theme} loading={doc.isPending} error={doc.error} />
65
67
  {doc.data ? (
66
68
  <>
67
- <Text selectable style={[s.text, { lineHeight: 21 }]}>
68
- {doc.data.content}
69
- </Text>
69
+ <Markdown theme={theme} source={doc.data.file.endsWith(".tsv") ? tsvToMarkdown(doc.data.content) : doc.data.content} />
70
70
  {doc.data.truncated ? <Text style={s.muted}>文件较大,只显示了开头的 512 KB。</Text> : null}
71
71
  </>
72
72
  ) : null}
@@ -221,6 +221,32 @@ export function RequirementView({
221
221
  ))}
222
222
  </View>
223
223
 
224
+ {(d.external ?? []).length > 0 ? (
225
+ <View style={s.card}>
226
+ <Text style={s.heading}>外部系统</Text>
227
+ {(d.external ?? []).map((e) => (
228
+ <View key={e.name} style={{ gap: 2 }}>
229
+ <View style={s.row}>
230
+ <Text style={s.text}>
231
+ {e.name}:{e.id ?? "还没有建卡"}
232
+ </Text>
233
+ {e.status ? (
234
+ <Badge theme={theme} tone="muted">
235
+ {e.status}
236
+ </Badge>
237
+ ) : null}
238
+ </View>
239
+ {e.url ? <ExternalLink href={e.url}>打开卡片</ExternalLink> : null}
240
+ {e.pending > 0 ? (
241
+ <Text style={s.danger}>
242
+ 有 {e.pending} 次调用没有成功:{e.lastError}
243
+ </Text>
244
+ ) : null}
245
+ </View>
246
+ ))}
247
+ </View>
248
+ ) : null}
249
+
224
250
  {d.usage ? (
225
251
  <View style={s.card}>
226
252
  <Text style={s.heading}>用时与用量</Text>
@@ -85,12 +85,21 @@ export function facts(r: Requirement): string {
85
85
  }
86
86
 
87
87
  /** What a person can do from here, by the requirement's state. */
88
- export function actionsFor(r: Pick<Requirement, "attention" | "running" | "status">): Array<"approve" | "stop" | "resume" | "verify"> {
89
- if (r.running) return ["stop"];
90
- if (r.attention === "approval") return ["approve"];
91
- if (r.attention === "interrupted" || r.attention === "business") return ["resume"];
92
- if (r.status === "phase_done") return ["verify"];
93
- return [];
88
+ export function actionsFor(
89
+ r: Pick<Requirement, "attention" | "running" | "status"> & { external?: Array<{ pending: number; id: string | null }> },
90
+ ): Array<"approve" | "stop" | "resume" | "verify" | "sync"> {
91
+ const base: Array<"approve" | "stop" | "resume" | "verify" | "sync"> = r.running
92
+ ? ["stop"]
93
+ : r.attention === "approval"
94
+ ? ["approve"]
95
+ : r.attention === "interrupted" || r.attention === "business"
96
+ ? ["resume"]
97
+ : r.status === "phase_done"
98
+ ? ["verify"]
99
+ : [];
100
+ // A running pipeline sends what failed by itself at its next step change.
101
+ if (!r.running && r.status && (r.external ?? []).some((e) => e.pending > 0 || !e.id)) base.push("sync");
102
+ return base;
94
103
  }
95
104
 
96
105
  export const ACTION_TEXT = {
@@ -98,4 +107,5 @@ export const ACTION_TEXT = {
98
107
  stop: { label: "停止", confirm: "停止这个需求的流水线?正在运行的步骤会被中断,之后可以继续。" },
99
108
  resume: { label: "继续", confirm: "从中断处继续?需求说明改过的话,只处理变化的部分。" },
100
109
  verify: { label: "开始测试验证", confirm: "开始测试验证?选择在哪里验证。" },
110
+ sync: { label: "同步到外部系统", confirm: "把这个需求同步到对接的外部系统:补发没有成功的调用、补建卡片、更新状态。" },
101
111
  } as const;