paseo-req-kit 1.9.0-beta.1 → 1.9.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +2 -0
  3. package/bin/req-plugin.mjs +226 -0
  4. package/bin/req.mjs +16 -0
  5. package/contracts/cli-json.md +2 -1
  6. package/contracts/feature-files.md +10 -0
  7. package/contracts/state.md +1 -0
  8. package/docs/adr/0027-paseo-plugin.md +23 -0
  9. package/docs/adr/0028-external-integrations.md +22 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
  12. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +25 -0
  13. package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +3 -1
  14. 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 +32 -0
  15. package/engine/src/api.ts +2 -1
  16. package/engine/src/cli-args.ts +4 -1
  17. package/engine/src/config.ts +21 -1
  18. package/engine/src/control.ts +2 -0
  19. package/engine/src/integration-cmd.ts +161 -0
  20. package/engine/src/integrations.ts +400 -0
  21. package/engine/src/launch.ts +12 -0
  22. package/engine/src/pipeline.ts +31 -1
  23. package/engine/src/preflight.ts +2 -0
  24. package/engine/src/state.ts +3 -0
  25. package/install.mjs +3 -0
  26. package/package.json +2 -1
  27. package/paseo-plugin/OVERVIEW.md +7 -0
  28. package/paseo-plugin/client/data.ts +55 -0
  29. package/paseo-plugin/client/detail.tsx +303 -0
  30. package/paseo-plugin/client/format.ts +111 -0
  31. package/paseo-plugin/client/live.ts +161 -0
  32. package/paseo-plugin/client/markdown.tsx +219 -0
  33. package/paseo-plugin/client/overview.tsx +89 -0
  34. package/paseo-plugin/client/panel.tsx +56 -0
  35. package/paseo-plugin/client/row.tsx +40 -0
  36. package/paseo-plugin/client/settings.tsx +90 -0
  37. package/paseo-plugin/client/sidebar.tsx +27 -0
  38. package/paseo-plugin/client/stats.tsx +80 -0
  39. package/paseo-plugin/client/ui.tsx +111 -0
  40. package/paseo-plugin/index.client.tsx +52 -0
  41. package/paseo-plugin/index.server.ts +41 -0
  42. package/paseo-plugin/package.json +22 -0
  43. package/paseo-plugin/paseo-plugin.json +6 -0
  44. package/paseo-plugin/server/handlers.ts +45 -0
  45. package/paseo-plugin/server/req.ts +100 -0
  46. package/paseo-plugin/shared/contracts.ts +167 -0
  47. package/paseo-plugin/tsconfig.json +16 -0
  48. package/skills/req/SKILL.md +1 -0
  49. package/skills/req/playbooks/new.md +3 -1
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { type IntegrationConfig, validateIntegrations } from "./integrations.js";
4
5
  import { NOTIFIER_EVENT_TYPES, type NotifierConfig } from "./notifiers.js";
5
6
  import type { StepId } from "./steps.js";
6
7
 
@@ -175,6 +176,10 @@ export interface PipelineConfig {
175
176
  reviewPanel: ReviewPanelSettings;
176
177
  /** External notifiers (e.g. an internal IM). Machine or team config only. */
177
178
  notifiers: NotifierConfig[];
179
+ /** External systems kept in step with requirements, e.g. an ALM's CLI (1.9). Machine or team config only: they run commands. */
180
+ integrations: IntegrationConfig[];
181
+ /** Per integration name, values for its {field.*} placeholders; a project's file may set these (e.g. its project id). */
182
+ integrationDefaults: Record<string, Record<string, string>>;
178
183
  /** Automatic retries of a step after a transient error (rate limit, dropped connection). */
179
184
  retry: { attempts: number; backoffSeconds: number[] };
180
185
  /**
@@ -277,6 +282,8 @@ export const DEFAULT_CONFIG: PipelineConfig = {
277
282
  requiredVersion: null,
278
283
  reviewPanel: { enabled: false, steps: ["design_review", "code_review"], profiles: ["standard"], members: [] },
279
284
  notifiers: [],
285
+ integrations: [],
286
+ integrationDefaults: {},
280
287
  retry: { attempts: 2, backoffSeconds: [60, 300] },
281
288
  paseoServices: "auto",
282
289
  serviceReadySeconds: 120,
@@ -332,6 +339,14 @@ function readJson(file: string, label: string): Record<string, unknown> {
332
339
  function merge(base: PipelineConfig, raw: Record<string, unknown>): PipelineConfig {
333
340
  const merged: PipelineConfig = { ...base, ...(raw as Partial<PipelineConfig>) };
334
341
  merged.agents = { ...base.agents, ...(isRecord(raw.agents) ? raw.agents : {}) } as PipelineConfig["agents"];
342
+ // Integration defaults add up by integration and field: the team sets the shared ones, a project its own.
343
+ if (isRecord(raw.integrationDefaults)) {
344
+ const combined: Record<string, Record<string, string>> = { ...base.integrationDefaults };
345
+ for (const [name, fields] of Object.entries(raw.integrationDefaults)) {
346
+ combined[name] = { ...(combined[name] ?? {}), ...(isRecord(fields) ? (fields as Record<string, string>) : {}) };
347
+ }
348
+ merged.integrationDefaults = combined;
349
+ }
335
350
  merged.stepTimeoutMinutes = {
336
351
  ...base.stepTimeoutMinutes,
337
352
  ...(isRecord(raw.stepTimeoutMinutes) ? raw.stepTimeoutMinutes : {}),
@@ -515,6 +530,11 @@ function validate(config: PipelineConfig): void {
515
530
  throw new ConfigError(`notifiers[${i}].events 只能包含 ${NOTIFIER_EVENT_TYPES.join("、")}`);
516
531
  }
517
532
  });
533
+ const integrationProblem = validateIntegrations(config.integrations);
534
+ if (integrationProblem) throw new ConfigError(integrationProblem);
535
+ if (!isRecord(config.integrationDefaults) || !Object.values(config.integrationDefaults).every((m) => isRecord(m) && Object.values(m).every((v) => typeof v === "string"))) {
536
+ throw new ConfigError('integrationDefaults 必须是 { "<集成名称>": { "<字段>": "<值>" } }');
537
+ }
518
538
  if (config.requiredVersion !== null && typeof config.requiredVersion !== "string") {
519
539
  throw new ConfigError('requiredVersion 必须是版本范围字符串,例如 ">=1.2.0 <2"');
520
540
  }
@@ -550,7 +570,7 @@ export interface LoadedConfig {
550
570
  }
551
571
 
552
572
  /** Keys a project file may not set: they run code or reach outside the repository. */
553
- const NOT_IN_PROJECT_KEYS = ["notifiers"] as const;
573
+ const NOT_IN_PROJECT_KEYS = ["notifiers", "integrations"] as const;
554
574
 
555
575
  /** Notifier module paths are relative to the file that names them; "~/" is the home directory. */
556
576
  function resolveNotifiers(raw: Record<string, unknown>, file: string): Record<string, unknown> {
@@ -2,6 +2,7 @@
2
2
  // is it?" or "stop for now". Output is plain language for relaying to the user.
3
3
 
4
4
  import { describeActivity } from "./activity.js";
5
+ import { describeExternal } from "./integrations.js";
5
6
  import { PHASE_MODE_LABELS, PROFILE_LABELS, type PipelineConfig, REQUIREMENT_TYPE_LABELS } from "./config.js";
6
7
  import type { AgentRuntime, RuntimeFactory } from "./runtime.js";
7
8
  import { isAlive, type PipelineState, readState, type StepRecord, writeState } from "./state.js";
@@ -80,6 +81,7 @@ export function describeStatus(state: PipelineState | null, featureDir: string,
80
81
  ];
81
82
  if (state.stopReason && !running) lines.push(`原因:${state.stopReason}`);
82
83
  for (const j of state.joint ?? []) lines.push(`联合开发:${j.name} 在工作区 ${j.repo}(分支 ${j.branch})`);
84
+ for (const l of describeExternal(state.external)) lines.push(`外部系统 ${l}`);
83
85
  const KIND_TEXT = { reminder: "提醒", "auto-resume": "自动继续", watchdog: "定时检查", "verify-start": "开始测试验证", "approval-continue": "未确认时按方案继续" } as const;
84
86
  for (const s of state.schedules ?? []) {
85
87
  if (s.kind === "watchdog" && !running) continue;
@@ -0,0 +1,161 @@
1
+ // `req integration` and `req sync` (1.9, ADR 0028).
2
+ //
3
+ // req integration test [需求] what each configured integration would run, without running it
4
+ // req integration fields the card fields /req confirms with the spec (card.json)
5
+ // req sync <需求> bring the requirement's cards up to date now: send what failed,
6
+ // create a missing card, set the state for where the requirement is
7
+
8
+ import path from "node:path";
9
+ import { locate } from "./api.js";
10
+ import { loadConfig, type PipelineConfig } from "./config.js";
11
+ import {
12
+ cardFieldsText,
13
+ commandAvailable,
14
+ describeExternal,
15
+ INTEGRATION_EVENT_LABELS,
16
+ INTEGRATION_EVENTS,
17
+ type IntegrationConfig,
18
+ type IntegrationEvent,
19
+ placeholderValues,
20
+ renderArgs,
21
+ resolveCommand,
22
+ statusFor,
23
+ syncIntegration,
24
+ } from "./integrations.js";
25
+ import { defaultConfigPath } from "./locations.js";
26
+ import { acquireLock, isAlive, type PipelineState, readState, statePath, writeState } from "./state.js";
27
+
28
+ /** The event that describes where a requirement is now. */
29
+ export function eventForState(state: Pick<PipelineState, "status" | "phase" | "pid">): IntegrationEvent {
30
+ switch (state.status) {
31
+ case "waiting_for_approval":
32
+ return "approval";
33
+ case "waiting_for_business":
34
+ return "blocked";
35
+ case "phase_done":
36
+ return "phase_done";
37
+ case "completed":
38
+ return "completed";
39
+ case "failed":
40
+ return "failed";
41
+ default:
42
+ return state.phase === "verify" ? "verify" : "started";
43
+ }
44
+ }
45
+
46
+ function quoteArg(a: string): string {
47
+ const shown = a.length > 120 ? `${a.slice(0, 117)}…` : a;
48
+ return /^[^\s"'\\$`&|;<>()*?!]+$/.test(shown) ? shown : `"${shown.replace(/\n/g, "\\n").replace(/"/g, '\\"')}"`;
49
+ }
50
+
51
+ function testIntegration(config: IntegrationConfig, project: PipelineConfig, target: { repo: string; featureDir: string; state: PipelineState | null } | null): string[] {
52
+ const lines = [`== ${config.name}(命令 ${config.command})`];
53
+ const { file, prefix } = resolveCommand(config.command);
54
+ lines.push(commandAvailable(config.command) ? `✓ 找到命令:${[file, ...prefix].join(" ")}` : `✗ 找不到命令 ${config.command}:请确认它已安装并在 PATH 中(运行 Paseo daemon 的用户的 PATH)`);
55
+ const ctx = {
56
+ repo: target?.repo ?? process.cwd(),
57
+ featureDir: target?.featureDir ?? "docs/features/20261015-示例需求",
58
+ state: { branch: target?.state?.branch ?? "feature/示例", profile: target?.state?.profile ?? "standard", type: target?.state?.type ?? null, outcome: target?.state?.outcome ?? null, external: target?.state?.external ?? null },
59
+ projectDefaults: project.integrationDefaults[config.name],
60
+ } as Parameters<typeof placeholderValues>[1];
61
+ if (!target) lines.push("(没有指定需求:用示例值;指定一个需求可以看到用它的需求说明和 card.json 填出的命令)");
62
+ const show = (label: string, args: string[], status?: string | null, event?: IntegrationEvent) => {
63
+ try {
64
+ const values = placeholderValues(config, ctx, { status, event });
65
+ if (!values.id) values.id = "<卡片编号>";
66
+ lines.push(`${label}:${config.command} ${renderArgs(args, values).map(quoteArg).join(" ")}`);
67
+ } catch (error) {
68
+ lines.push(`${label}:✗ ${(error as Error).message}`);
69
+ }
70
+ };
71
+ if (config.create) show("建卡", config.create.args);
72
+ for (const event of INTEGRATION_EVENTS) {
73
+ const status = statusFor(config, event, null);
74
+ if (status && config.status) show(`${INTEGRATION_EVENT_LABELS[event]} → 「${status}」`, config.status.args, status, event);
75
+ }
76
+ for (const outcome of ["completed_with_issues", "not_completed"] as const) {
77
+ const status = config.statusMap?.[outcome];
78
+ if (status && config.status) show(`完成(${outcome === "not_completed" ? "未完成" : "有遗留问题"})→ 「${status}」`, config.status.args, status, "completed");
79
+ }
80
+ return lines;
81
+ }
82
+
83
+ async function main(argv: string[]): Promise<number> {
84
+ const [sub, ...rest] = argv;
85
+ const cwd = process.cwd();
86
+ const configPath = defaultConfigPath();
87
+ if (sub === "fields") {
88
+ const config = loadConfig(configPath, cwd).config;
89
+ console.log(cardFieldsText(config.integrations) ?? "没有配置外部系统对接(integrations)。");
90
+ return 0;
91
+ }
92
+ if (sub === "test") {
93
+ let target: { repo: string; featureDir: string; state: PipelineState | null } | null = null;
94
+ if (rest[0]) {
95
+ const found = locate(cwd, rest[0]);
96
+ target = { ...found, state: readState(statePath(found.repo, found.featureDir)) };
97
+ }
98
+ const config = loadConfig(configPath, target?.repo ?? cwd).config;
99
+ if (config.integrations.length === 0) {
100
+ console.log("没有配置外部系统对接(integrations)。写法见 docs/guide/项目定制与运行规则.md 的“外部系统对接”。");
101
+ return 0;
102
+ }
103
+ console.log("只显示将要执行的命令,不会真正调用。\n");
104
+ for (const i of config.integrations) console.log(`${testIntegration(i, config, target).join("\n")}\n`);
105
+ return 0;
106
+ }
107
+ if (sub === "sync") {
108
+ if (!rest[0]) {
109
+ console.error("请写出是哪个需求,例如:req sync sms-login");
110
+ return 2;
111
+ }
112
+ const { repo, featureDir } = locate(cwd, rest[0]);
113
+ const file = statePath(repo, featureDir);
114
+ const state = readState(file);
115
+ if (!state) {
116
+ console.log(`${featureDir} 还没有启动过开发流水线,没有要同步的。`);
117
+ return 0;
118
+ }
119
+ if (state.status === "running" && isAlive(state.pid)) {
120
+ console.log("流水线正在运行:它会在下一个阶段变化时自动补发。现在不需要 req sync。");
121
+ return 0;
122
+ }
123
+ const config = loadConfig(configPath, repo).config;
124
+ if (config.integrations.length === 0) {
125
+ console.log("没有配置外部系统对接(integrations)。");
126
+ return 0;
127
+ }
128
+ const release = acquireLock(repo, featureDir);
129
+ try {
130
+ const event = eventForState(state);
131
+ for (const i of config.integrations) {
132
+ await syncIntegration({
133
+ config: i,
134
+ ctx: { repo, featureDir, state, projectDefaults: config.integrationDefaults[i.name] },
135
+ event,
136
+ log: (m) => console.log(m),
137
+ });
138
+ }
139
+ writeState(file, state);
140
+ } finally {
141
+ release();
142
+ }
143
+ const lines = describeExternal(state.external);
144
+ console.log(lines.length > 0 ? `\n${lines.join("\n")}` : "");
145
+ return Object.values(state.external ?? {}).some((r) => r.pending.length > 0) ? 1 : 0;
146
+ }
147
+ console.error("用法:req integration test [需求] | req integration fields | req sync <需求>");
148
+ return 2;
149
+ }
150
+
151
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
152
+ main(process.argv.slice(2)).then(
153
+ (code) => process.exit(code),
154
+ (error: Error) => {
155
+ console.error(error.message);
156
+ process.exit(2);
157
+ },
158
+ );
159
+ }
160
+
161
+ export { main as integrationMain };
@@ -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);