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

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 (35) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/bin/req.mjs +4 -0
  3. package/contracts/cli-json.md +3 -1
  4. package/contracts/notifier-event.md +17 -1
  5. package/docs/adr/0031-fewer-interventions.md +8 -0
  6. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
  7. 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 +37 -1
  8. package/engine/src/api.ts +42 -3
  9. package/engine/src/card-cmd.ts +13 -0
  10. package/engine/src/card.ts +29 -0
  11. package/engine/src/config-view.ts +289 -0
  12. package/engine/src/config.ts +42 -2
  13. package/engine/src/doctor.ts +14 -0
  14. package/engine/src/factory.ts +115 -4
  15. package/engine/src/messages.ts +22 -0
  16. package/engine/src/notifiers.ts +32 -1
  17. package/engine/src/notify-test.ts +8 -2
  18. package/engine/src/paseo-runtime.ts +9 -0
  19. package/engine/src/pipeline.ts +61 -4
  20. package/engine/src/reply.ts +59 -0
  21. package/engine/src/runtime.ts +2 -0
  22. package/engine/src/state.ts +11 -1
  23. package/notifiers/im-template.mjs +31 -5
  24. package/package.json +1 -1
  25. package/paseo-plugin/client/config.tsx +216 -0
  26. package/paseo-plugin/client/data.ts +7 -2
  27. package/paseo-plugin/client/format.ts +1 -0
  28. package/paseo-plugin/client/inbox.tsx +173 -0
  29. package/paseo-plugin/client/overview.tsx +7 -3
  30. package/paseo-plugin/index.client.tsx +10 -0
  31. package/paseo-plugin/index.server.ts +2 -1
  32. package/paseo-plugin/package.json +1 -1
  33. package/paseo-plugin/server/handlers.ts +8 -3
  34. package/paseo-plugin/shared/contracts.ts +41 -2
  35. package/skills/req/playbooks/unattended.md +1 -0
@@ -0,0 +1,289 @@
1
+ // The whole req-kit configuration as people read it (1.12, `req api config`, the plugin's
2
+ // settings view): every setting with a plain name, what it does, its value in words, and which
3
+ // file set it (default, this machine, the team, the project). Secrets are never shown.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import {
8
+ type AgentProfile,
9
+ DEFAULT_CONFIG,
10
+ loadConfig,
11
+ MACHINE_ONLY_KEYS,
12
+ NOT_IN_PROJECT_KEYS,
13
+ PHASE_MODE_LABELS,
14
+ type PipelineConfig,
15
+ PROFILE_LABELS,
16
+ PROJECT_CONFIG_FILE,
17
+ } from "./config.js";
18
+ import { describeAgentProfile } from "./paseo-profiles.js";
19
+ import { STEPS, type StepId } from "./steps.js";
20
+
21
+ export type Layer = "default" | "machine" | "team" | "project";
22
+
23
+ export const LAYER_LABELS: Record<Layer, string> = { default: "默认", machine: "本机", team: "团队", project: "项目" };
24
+
25
+ export interface ConfigItem {
26
+ key: string;
27
+ label: string;
28
+ description: string;
29
+ /** The value in words. */
30
+ display: string;
31
+ /** The value itself, secrets masked. */
32
+ value: unknown;
33
+ /** The files that set it, in the order they apply; empty when it is the default. */
34
+ setBy: Exclude<Layer, "default">[];
35
+ /** Where it may be set: machine-only settings are ignored in team and project files. */
36
+ scope: "any" | "machine" | "machine_team";
37
+ /** Whether the value differs from req-kit's default (a file may repeat the default). */
38
+ changed: boolean;
39
+ }
40
+
41
+ export interface ConfigGroup {
42
+ id: string;
43
+ title: string;
44
+ description: string;
45
+ items: ConfigItem[];
46
+ }
47
+
48
+ export interface ConfigView {
49
+ sources: Array<{ layer: Exclude<Layer, "default">; path: string }>;
50
+ /** Settings a team or project file may not set, which were left out. */
51
+ ignored: string[];
52
+ /** The configuration does not load; the values shown are the files' as written. */
53
+ error: string | null;
54
+ repo: string | null;
55
+ groups: ConfigGroup[];
56
+ }
57
+
58
+ const onOff = (v: unknown) => (v ? "开" : "关");
59
+ const minutes = (v: unknown) => (Number(v) > 0 ? `${v} 分钟` : "关闭");
60
+ const hours = (v: unknown) => (Number(v) > 0 ? `${v} 小时` : "关闭");
61
+ const list = (v: unknown) => (Array.isArray(v) && v.length > 0 ? v.join("、") : "无");
62
+ const profilesText = (v: unknown) => (Array.isArray(v) ? v.map((p) => PROFILE_LABELS[p as "quick"] ?? p).join("、") + "档" : String(v));
63
+
64
+ function agent(p: AgentProfile | null | undefined): string {
65
+ return p ? describeAgentProfile(p) : "未设置";
66
+ }
67
+
68
+ interface Entry {
69
+ label: string;
70
+ description: string;
71
+ display?: (value: unknown, config: PipelineConfig) => string;
72
+ }
73
+
74
+ /** Groups in the order people look for them; a key not listed here goes to "其他". */
75
+ const CATALOG: Array<{ id: string; title: string; description: string; keys: Record<string, Entry> }> = [
76
+ {
77
+ id: "agents",
78
+ title: "模型与评审",
79
+ description: "谁来写代码、谁来评审,以及评审有多严格。",
80
+ keys: {
81
+ agents: {
82
+ label: "agent",
83
+ description: "builder 做方案、写代码和修复;reviewer 审方案、评审代码和验证;备用模型在主模型一直失败时接手一步。",
84
+ display: (v) => {
85
+ const a = v as PipelineConfig["agents"];
86
+ return `builder:${agent(a.builder)};reviewer:${agent(a.reviewer)};备用模型:${a.fallback ? agent(a.fallback) : "不用"}`;
87
+ },
88
+ },
89
+ defaultProfile: { label: "默认档位", description: "需求说明没写档位时用哪个。", display: (v) => `${PROFILE_LABELS[v as "quick"] ?? v}档` },
90
+ profiles: {
91
+ label: "各档位的轮数",
92
+ description: "方案审核、代码评审、缺陷修复最多几轮,以及是否先验证服务能启动。",
93
+ display: (v) =>
94
+ Object.entries(v as PipelineConfig["profiles"])
95
+ .map(([name, p]) => `${PROFILE_LABELS[name as "quick"] ?? name}档:方案审核 ${p.rounds.designReview} 轮、代码评审 ${p.rounds.codeReview} 轮、缺陷修复 ${p.rounds.defectFix} 轮${p.serviceStart ? "、验证服务启动" : ""}`)
96
+ .join(";"),
97
+ },
98
+ reviewAxes: { label: "分方向评审", description: "代码评审分“规格”和“规范”两个方向各看一遍。", display: (v) => { const r = v as PipelineConfig["reviewAxes"]; return r.enabled ? `开(${profilesText(r.profiles)})` : "关"; } },
99
+ reviewPanel: { label: "评审小组", description: "几个模型同时评审,再汇总。", display: (v) => { const r = v as PipelineConfig["reviewPanel"]; return r.enabled ? `${r.members.length} 位成员,用于${r.steps.map((s) => STEPS[s as StepId]?.title ?? s).join("、")}(${profilesText(r.profiles)})` : "关"; } },
100
+ designProposals: { label: "方案提案", description: "方案设计前先让几个模型各出一份提案。", display: (v) => { const r = v as PipelineConfig["designProposals"]; return r.enabled ? `开(${profilesText(r.profiles)}${r.members.length ? `,${r.members.length} 位成员` : ""})` : "关"; } },
101
+ },
102
+ },
103
+ {
104
+ id: "flow",
105
+ title: "流程",
106
+ description: "需求按什么顺序走、在哪里停下等人。",
107
+ keys: {
108
+ phases: {
109
+ label: "执行方式",
110
+ description: "研发完成后接着测试验证,还是停下等测试,或只做研发。",
111
+ display: (v) => { const p = v as PipelineConfig["phases"]; return `${PHASE_MODE_LABELS[p.mode]};测试验证在${p.verify.environment === "deployed" ? "已部署的测试环境" : "本地"};发现缺陷时${p.verify.defects === "fix" ? "直接修复" : p.verify.defects === "handback" ? "退回研发阶段" : "只记录"}`; },
112
+ },
113
+ designApproval: { label: "方案确认点", description: "写代码前请需求方确认“做完后会看到什么”。", display: (v) => { const d = v as PipelineConfig["designApproval"]; return d.mode === "off" ? "关" : `${d.mode === "always" ? "总是" : "有界面改动时"}(${profilesText(d.profiles)})${d.autoContinueHours > 0 ? `,${d.autoContinueHours} 小时没回应按方案继续` : ",一直等确认"}`; } },
114
+ specReview: { label: "需求说明审阅", description: "开发前由另一个模型审一遍需求说明。", display: (v) => ({ off: "关", standard: "标准档", always: "所有档位" })[String(v)] ?? String(v) },
115
+ requirementTypes: { label: "按类型的做法", description: "缺陷、重构、评估各走自己的做法。", display: (v) => list((v as string[]).map((t) => ({ defect: "缺陷", refactor: "重构", assessment: "评估" })[t] ?? t)) },
116
+ slices: { label: "按切片实现", description: "方案分几片时逐片实现。", display: (v) => { const s = v as PipelineConfig["slices"]; return `${profilesText(s.profiles)},最多 ${s.max} 片`; } },
117
+ browserVerify: { label: "用浏览器验证", description: "Paseo 有内置浏览器时,验证网页界面。", display: (v) => ({ auto: "自动", off: "关" })[String(v)] ?? String(v) },
118
+ paseoServices: { label: "由 Paseo 启动服务", description: "用 paseo.json 中的服务脚本启动和重启服务。", display: (v) => ({ auto: "自动", off: "关" })[String(v)] ?? String(v) },
119
+ serviceReadySeconds: { label: "等服务就绪", description: "启动服务后最多等多久。", display: (v) => `${v} 秒` },
120
+ },
121
+ },
122
+ {
123
+ id: "quality",
124
+ title: "质量",
125
+ description: "流水线自己运行的检查,以及可以参考的其他仓库。",
126
+ keys: {
127
+ checks: { label: "质量门的检查", description: "写代码和修复后,流水线自己运行这些命令,比较改动前后。通常由 /req-setup 写在项目配置中。", display: (v) => (Array.isArray(v) && v.length > 0 ? (v as Array<{ name: string; command: string }>).map((c) => `${c.name}(${c.command})`).join("、") : "没有(不运行质量门)") },
128
+ qualityGate: { label: "质量门的修复", description: "检查发现新失败时,同一个 agent 修几次;每项检查的时限。", display: (v) => { const q = v as PipelineConfig["qualityGate"]; return `最多修 ${q.fixAttempts} 次,每项 ${q.timeoutMinutes} 分钟`; } },
129
+ relatedRepos: { label: "关联仓库", description: "步骤 agent 可以读、不会修改的其他仓库(例如前端项目的后端)。", display: (v) => (Array.isArray(v) && v.length > 0 ? (v as Array<{ name: string; path: string }>).map((r) => `${r.name}(${r.path})`).join("、") : "无") },
130
+ },
131
+ },
132
+ {
133
+ id: "automation",
134
+ title: "自动处理与时限",
135
+ description: "出了问题时流水线自己怎么处理,什么时候才停下等人。",
136
+ keys: {
137
+ selfHeal: { label: "自愈", description: "超时时 agent 还在做就多等一轮;agent 报错、结果文件无效时新开 agent 重做一次。", display: onOff },
138
+ answerQuestionsFirst: { label: "方案审核的问题先自答", description: "审核要需求方决定的问题,先由 builder 从代码和文档中找答案。", display: onOff },
139
+ retry: { label: "临时错误重试", description: "限流、网络中断时换一个 agent 重做这一步。", display: (v) => { const r = v as PipelineConfig["retry"]; return r.attempts > 0 ? `${r.attempts} 次,分别等 ${r.backoffSeconds.join("、")} 秒` : "关"; } },
140
+ autoResumeMinutes: { label: "自动继续", description: "临时错误中断后,多久自动继续(限流时按额度恢复时间)。", display: minutes },
141
+ autoResumeMax: { label: "自动继续的次数", description: "每次尝试最多自动继续几次。", display: (v) => `${v} 次` },
142
+ watchdogMinutes: { label: "定时检查", description: "运行期间定时检查,后台进程意外退出时自动继续。", display: (v) => (Number(v) > 0 ? `每 ${v} 分钟` : "关闭") },
143
+ stepTimeoutMinutes: {
144
+ label: "各步骤的时限",
145
+ description: "超过时限仍未完成就中断(打开自愈时先多等一轮)。",
146
+ display: (v) =>
147
+ Object.entries(v as Record<string, number>)
148
+ .filter(([id]) => STEPS[id as StepId])
149
+ .map(([id, m]) => `${STEPS[id as StepId].title} ${m} 分钟`)
150
+ .join("、"),
151
+ },
152
+ resultFollowUps: { label: "提醒补写结果", description: "结果文件缺失或格式不对时,提醒 agent 几次。", display: (v) => `${v} 次` },
153
+ quotaWarnPercent: { label: "额度提醒", description: "开始时额度用到这么多就提醒。", display: (v) => (Number(v) > 0 ? `${v}%` : "关闭") },
154
+ waitingReminderHours: { label: "等待提醒", description: "等需求方回答或等测试超过这么久,提醒一次。", display: hours },
155
+ maxConcurrentPipelines: { label: "同时运行的上限", description: "本机同时运行的流水线数,满了排队。", display: (v) => (Number(v) > 0 ? `${v} 个` : "不限") },
156
+ queuePollSeconds: { label: "排队检查间隔", description: "排队时多久看一次有没有空位。", display: (v) => `${v} 秒` },
157
+ },
158
+ },
159
+ {
160
+ id: "cards",
161
+ title: "卡片与接单器",
162
+ description: "和需求管理系统的对接、从卡片开始的需求,以及共享机器上的接单器。",
163
+ keys: {
164
+ integrations: { label: "对接的外部系统", description: "用命令读写需求管理系统中的卡片。", display: (v) => (Array.isArray(v) && v.length > 0 ? (v as Array<{ name: string; command: string } & Record<string, unknown>>).map((i) => `${i.name}(${i.command};${["create", "status", "query", "describe", "list"].filter((k) => i[k]).join("、")})`).join(";") : "无") },
165
+ integrationDefaults: { label: "对接字段的默认值", description: "按系统填卡片字段的固定值(例如项目编号)。", display: (v) => { const n = Object.keys(v as object).length; return n > 0 ? `${n} 个系统` : "无"; } },
166
+ cardSource: { label: "这个仓库接哪些卡片", description: "系统、空间和子系统,由 /req-setup 写在项目配置中。", display: (v) => { if (!v) return "未设置"; const c = v as NonNullable<PipelineConfig["cardSource"]>; return `${c.system}${c.projectId ? ` 空间 ${c.projectId}` : ""};子系统 ${list(c.subsystems)}${c.titleKeywords.length ? `;标题关键字 ${list(c.titleKeywords)}` : ""}`; } },
167
+ push: { label: "推送分支", description: "从卡片开始的需求研发完成后,推送到 req/ 前缀的分支(可以再建草稿合并请求)。不强推、不合并。", display: (v) => { const p = v as PipelineConfig["push"]; return p.enabled ? `推送到 ${p.remote}/${p.branchPrefix}<卡号>${p.mergeRequest ? `,用 ${p.mergeRequest.command} 建草稿合并请求` : ""}` : "关"; } },
168
+ factory: { label: "接单器", description: "多久检查一次、认哪些标题标识、每次最多接几张。", display: (v) => { const f = v as PipelineConfig["factory"]; return `每 ${f.pollMinutes} 分钟;标识 ${list(f.titleTags)};每次最多 ${f.maxNewPerPoll} 个`; } },
169
+ autoConfirm: { label: "小改动自动确认", description: "快速档、没有待答问题的卡片,过这么久没有异议就开始。", display: (v) => hours((v as PipelineConfig["autoConfirm"]).hours) },
170
+ },
171
+ },
172
+ {
173
+ id: "notify",
174
+ title: "通知",
175
+ description: "进展和需要人的事发到哪里、发给谁。",
176
+ keys: {
177
+ notifyMilestones: { label: "进展通知", description: "方案定了、开始验证等关键点在对话里说一句。", display: onOff },
178
+ notifiers: { label: "外部通知", description: "额外调用的通知模块(例如内部 IM)。", display: (v) => (Array.isArray(v) && v.length > 0 ? (v as Array<{ module: string; events?: string[] }>).map((n) => `${path.basename(n.module)}(${n.events ? n.events.join("、") : "全部事件"})`).join(";") : "无") },
179
+ people: { label: "按人通知的账号", description: "需求方姓名对应的 IM 账号,通知模块按人发送时用。", display: (v) => { const n = Object.keys(v as object).length; return n > 0 ? `${n} 人` : "无"; } },
180
+ },
181
+ },
182
+ {
183
+ id: "machine",
184
+ title: "连接与运行",
185
+ description: "这台机器怎么连 Paseo,以及步骤 agent 怎么创建。",
186
+ keys: {
187
+ daemonUrl: { label: "Paseo 地址", description: "本机 Paseo daemon 的地址。" },
188
+ daemonPasswordEnv: { label: "Paseo 密码的环境变量", description: "daemon 设置了密码时,从这个环境变量读取(这里只显示变量名)。" },
189
+ teamConfig: { label: "团队配置", description: "团队共用的配置文件。", display: (v) => (v ? String(v) : "未使用") },
190
+ requiredVersion: { label: "版本要求", description: "团队或项目要求的 req-kit 版本范围。", display: (v) => (v ? String(v) : "无") },
191
+ attachToOriginChat: { label: "步骤挂在发起的对话下", description: "步骤 agent 作为子 agent 显示在发起的对话中。", display: onOff },
192
+ archiveStepAgents: { label: "完成后归档步骤", description: "步骤完成后把它的 agent 归档。", display: onOff },
193
+ waitChunkSeconds: { label: "等待分段", description: "等待 agent 时每段的长度。", display: (v) => `${v} 秒` },
194
+ permissionPollSeconds: { label: "权限检查间隔", description: "agent 等待批准时多久看一次。", display: (v) => `${v} 秒` },
195
+ },
196
+ },
197
+ ];
198
+
199
+ const SECRET = /token|password|passwd|secret|webhook|api[_-]?key|authorization|cookie/i;
200
+
201
+ /** The value with anything that looks like a secret, and people's accounts, replaced. */
202
+ export function masked(value: unknown, key = ""): unknown {
203
+ if (key === "people" && value && typeof value === "object") {
204
+ return Object.fromEntries(Object.keys(value as object).map((name) => [name, "••••"]));
205
+ }
206
+ if (Array.isArray(value)) return value.map((v) => masked(v));
207
+ if (value && typeof value === "object") {
208
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, SECRET.test(k) && typeof v === "string" ? "••••" : masked(v, k)]));
209
+ }
210
+ return value;
211
+ }
212
+
213
+ function readRaw(file: string): Record<string, unknown> | null {
214
+ try {
215
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
216
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ function plain(value: unknown): string {
223
+ if (value === null || value === undefined) return "未设置";
224
+ if (typeof value === "boolean") return onOff(value);
225
+ if (typeof value === "string" || typeof value === "number") return String(value);
226
+ return JSON.stringify(value);
227
+ }
228
+
229
+ /** The configuration that applies in `repo` (or on this machine, outside any project). */
230
+ export function configView(configPath: string, repo: string | null): ConfigView {
231
+ const layers: Array<{ layer: Exclude<Layer, "default">; path: string; raw: Record<string, unknown> }> = [];
232
+ const machine = readRaw(configPath);
233
+ if (machine) layers.push({ layer: "machine", path: configPath, raw: machine });
234
+ if (machine && typeof machine.teamConfig === "string" && machine.teamConfig) {
235
+ const teamFile = path.resolve(path.dirname(configPath), machine.teamConfig);
236
+ const team = readRaw(teamFile);
237
+ if (team) layers.push({ layer: "team", path: teamFile, raw: team });
238
+ }
239
+ if (repo) {
240
+ const projectFile = path.join(repo, PROJECT_CONFIG_FILE);
241
+ const project = readRaw(projectFile);
242
+ if (project) layers.push({ layer: "project", path: projectFile, raw: project });
243
+ }
244
+
245
+ let config: PipelineConfig;
246
+ let error: string | null = null;
247
+ let ignored: string[] = [];
248
+ try {
249
+ const loaded = loadConfig(configPath, repo ?? undefined);
250
+ config = loaded.config;
251
+ ignored = loaded.ignored;
252
+ } catch (e) {
253
+ error = (e as Error).message;
254
+ config = { ...DEFAULT_CONFIG, ...Object.assign({}, ...layers.map((l) => l.raw)) } as PipelineConfig;
255
+ }
256
+
257
+ const allowed = (layer: Exclude<Layer, "default">, key: string) =>
258
+ layer === "machine" || (!(MACHINE_ONLY_KEYS as readonly string[]).includes(key) && !(layer === "project" && (NOT_IN_PROJECT_KEYS as readonly string[]).includes(key)));
259
+ const scopeOf = (key: string): ConfigItem["scope"] =>
260
+ (MACHINE_ONLY_KEYS as readonly string[]).includes(key) ? "machine" : (NOT_IN_PROJECT_KEYS as readonly string[]).includes(key) ? "machine_team" : "any";
261
+
262
+ const listed = new Set<string>();
263
+ const item = (key: string, entry: Entry): ConfigItem => {
264
+ listed.add(key);
265
+ const value = (config as unknown as Record<string, unknown>)[key];
266
+ let display: string;
267
+ try {
268
+ display = entry.display ? entry.display(value, config) : plain(value);
269
+ } catch {
270
+ display = plain(value);
271
+ }
272
+ return {
273
+ key,
274
+ label: entry.label,
275
+ description: entry.description,
276
+ display,
277
+ value: masked(value, key),
278
+ setBy: layers.filter((l) => key in l.raw && allowed(l.layer, key)).map((l) => l.layer),
279
+ scope: scopeOf(key),
280
+ changed: JSON.stringify(value) !== JSON.stringify((DEFAULT_CONFIG as unknown as Record<string, unknown>)[key]),
281
+ };
282
+ };
283
+ const groups: ConfigGroup[] = CATALOG.map((g) => ({ id: g.id, title: g.title, description: g.description, items: Object.entries(g.keys).map(([k, e]) => item(k, e)) }));
284
+ const rest = Object.keys(config).filter((k) => !listed.has(k));
285
+ if (rest.length > 0) {
286
+ groups.push({ id: "other", title: "其他", description: "上面没有归类的设置。", items: rest.map((k) => item(k, { label: k, description: "" })) });
287
+ }
288
+ return { sources: layers.map(({ layer, path: p }) => ({ layer, path: p })), ignored, error, repo, groups };
289
+ }
@@ -147,6 +147,21 @@ export interface RelatedRepo {
147
147
  role?: string;
148
148
  }
149
149
 
150
+ /**
151
+ * 1.12: after the push, a draft merge request made with the machine's own GitLab (or other)
152
+ * command, logged in beforehand. Placeholders: {branch} {target} {title} {description} {card}
153
+ * {cardUrl}. Never merged by req-kit.
154
+ */
155
+ export interface MergeRequestSettings {
156
+ command: string;
157
+ args: string[];
158
+ /** Branch the merge request targets; default: the remote's default branch (origin/HEAD), else main. */
159
+ target?: string | null;
160
+ /** Arguments that check the command is logged in (req doctor), e.g. ["auth", "status"]. */
161
+ check?: string[] | null;
162
+ timeoutSeconds?: number;
163
+ }
164
+
150
165
  export interface PipelineConfig {
151
166
  daemonUrl: string;
152
167
  /** Name of the environment variable holding the daemon password, if the daemon has one. */
@@ -192,6 +207,12 @@ export interface PipelineConfig {
192
207
  reviewPanel: ReviewPanelSettings;
193
208
  /** External notifiers (e.g. an internal IM). Machine or team config only. */
194
209
  notifiers: NotifierConfig[];
210
+ /**
211
+ * 1.12: how to reach people by name, for notifiers that send to a person: the requester's
212
+ * name as the card shows it → their accounts, e.g. { "张广祥": { "um": "ZHANGGX001" } }.
213
+ * Machine or team config only.
214
+ */
215
+ people: Record<string, Record<string, string>>;
195
216
  /** External systems kept in step with requirements, e.g. an ALM's CLI (1.9). Machine or team config only: they run commands. */
196
217
  integrations: IntegrationConfig[];
197
218
  /** Per integration name, values for its {field.*} placeholders; a project's file may set these (e.g. its project id). */
@@ -201,7 +222,7 @@ export interface PipelineConfig {
201
222
  * the handoff is written (1.10). Off by default; machine or team config only. Never forced,
202
223
  * only to branches under `branchPrefix`, never merged.
203
224
  */
204
- push: { enabled: boolean; remote: string; branchPrefix: string };
225
+ push: { enabled: boolean; remote: string; branchPrefix: string; mergeRequest?: MergeRequestSettings | null };
205
226
  /**
206
227
  * Which cards this repository takes (1.10, written by /req-setup in the project's file): the
207
228
  * system, its space (project id) and subsystems; `titleKeywords` tells apart repositories
@@ -214,6 +235,11 @@ export interface PipelineConfig {
214
235
  * config only.
215
236
  */
216
237
  factory: { pollMinutes: number; titleTags: string[]; maxNewPerPoll: number };
238
+ /**
239
+ * 1.12: a requirement from a card that is small (quick profile) and has nothing to answer
240
+ * starts this many hours after its summary is on the card, unless the requester objects; 0: off.
241
+ */
242
+ autoConfirm: { hours: number };
217
243
  /** Automatic retries of a step after a transient error (rate limit, dropped connection). */
218
244
  retry: { attempts: number; backoffSeconds: number[] };
219
245
  /**
@@ -316,11 +342,13 @@ export const DEFAULT_CONFIG: PipelineConfig = {
316
342
  requiredVersion: null,
317
343
  reviewPanel: { enabled: false, steps: ["design_review", "code_review"], profiles: ["standard"], members: [] },
318
344
  notifiers: [],
345
+ people: {},
319
346
  integrations: [],
320
347
  integrationDefaults: {},
321
348
  push: { enabled: false, remote: "origin", branchPrefix: "req/" },
322
349
  cardSource: null,
323
350
  factory: { pollMinutes: 5, titleTags: ["[AI]", "【AI】"], maxNewPerPoll: 3 },
351
+ autoConfirm: { hours: 0 },
324
352
  retry: { attempts: 2, backoffSeconds: [60, 300] },
325
353
  paseoServices: "auto",
326
354
  serviceReadySeconds: 120,
@@ -388,6 +416,7 @@ function merge(base: PipelineConfig, raw: Record<string, unknown>): PipelineConf
388
416
  }
389
417
  merged.push = { ...base.push, ...(isRecord(raw.push) ? raw.push : {}) } as PipelineConfig["push"];
390
418
  merged.factory = { ...base.factory, ...(isRecord(raw.factory) ? raw.factory : {}) } as PipelineConfig["factory"];
419
+ merged.autoConfirm = { ...base.autoConfirm, ...(isRecord(raw.autoConfirm) ? raw.autoConfirm : {}) } as PipelineConfig["autoConfirm"];
391
420
  if (isRecord(raw.cardSource)) {
392
421
  const c = raw.cardSource;
393
422
  merged.cardSource = {
@@ -592,6 +621,17 @@ function validate(config: PipelineConfig): void {
592
621
  if (typeof push.enabled !== "boolean") throw new ConfigError("push.enabled 必须是 true 或 false");
593
622
  if (typeof push.remote !== "string" || !/^[\w.-]+$/.test(push.remote)) throw new ConfigError('push.remote 必须是远程仓库名,例如 "origin"');
594
623
  if (typeof push.branchPrefix !== "string" || !/^[A-Za-z0-9._-]+\/$/.test(push.branchPrefix)) throw new ConfigError('push.branchPrefix 必须是以 / 结尾的分支前缀,例如 "req/"');
624
+ const mr = push.mergeRequest;
625
+ if (mr !== undefined && mr !== null) {
626
+ if (!isRecord(mr) || typeof mr.command !== "string" || !mr.command.trim() || !Array.isArray(mr.args) || !mr.args.every((a) => typeof a === "string")) {
627
+ throw new ConfigError('push.mergeRequest 必须写成 { "command": "glab", "args": ["mr", "create", "--draft", …] },参数中可以用 {branch} {target} {title} {description}');
628
+ }
629
+ if (!mr.args.some((a) => a.includes("{branch}"))) throw new ConfigError("push.mergeRequest.args 中要有 {branch}(推送的分支)");
630
+ }
631
+ if (!isRecord(config.people) || !Object.values(config.people).every((v) => isRecord(v) && Object.values(v).every((x) => typeof x === "string"))) {
632
+ throw new ConfigError('people 必须写成 { "<姓名>": { "<账号类型>": "<账号>" } },例如 { "张广祥": { "um": "ZHANGGX001" } }');
633
+ }
634
+ if (typeof config.autoConfirm.hours !== "number" || config.autoConfirm.hours < 0) throw new ConfigError("autoConfirm.hours 必须是不小于 0 的小时数(0 表示关闭)");
595
635
  const f = config.factory;
596
636
  if (typeof f.pollMinutes !== "number" || !(f.pollMinutes >= 1)) throw new ConfigError("factory.pollMinutes 必须是至少 1 的分钟数");
597
637
  if (!Number.isInteger(f.maxNewPerPoll) || f.maxNewPerPoll < 1) throw new ConfigError("factory.maxNewPerPoll 必须是正整数");
@@ -638,7 +678,7 @@ export interface LoadedConfig {
638
678
  }
639
679
 
640
680
  /** Keys a project file may not set: they run code or reach outside the repository. */
641
- const NOT_IN_PROJECT_KEYS = ["notifiers", "integrations", "push", "factory"] as const;
681
+ export const NOT_IN_PROJECT_KEYS = ["notifiers", "integrations", "push", "factory", "people"] as const;
642
682
 
643
683
  /** Notifier module paths are relative to the file that names them; "~/" is the home directory. */
644
684
  function resolveNotifiers(raw: Record<string, unknown>, file: string): Record<string, unknown> {
@@ -18,6 +18,7 @@ import { defaultConfigPath, findDependency, KIT_ROOT, kitVersion } from "./locat
18
18
  import { PROJECT_DIR } from "./prompts.js";
19
19
  import { describeAgentProfile, ProfileResolutionError, profilesFromDaemonConfig, resolveConfigProfiles, usesPaseoProfiles } from "./paseo-profiles.js";
20
20
  import { isAlive, STATE_VERSION } from "./state.js";
21
+ import { resolveCommand } from "./integrations.js";
21
22
 
22
23
  const counts = { fail: 0, warn: 0 };
23
24
  const SYMBOL = { ok: "✓", warn: "⚠", fail: "✗" } as const;
@@ -141,6 +142,19 @@ function configuration(configPath: string, repo: string | null): PipelineConfig
141
142
  else report("warn", `关联仓库「${r.name}」不在这台机器上:${dir}`, "把它克隆到这个位置;或修改 relatedRepos 中的路径(相对项目目录,团队约定把相关仓库放在同一个上级目录最方便)");
142
143
  }
143
144
  }
145
+ const push = loaded.config.push;
146
+ if (push.enabled) {
147
+ report("ok", `推送分支:${push.remote}/${push.branchPrefix}<卡号>(只对从卡片开始的需求)`);
148
+ const mr = push.mergeRequest;
149
+ if (mr) {
150
+ if (mr.check && mr.check.length > 0) {
151
+ const { file, prefix } = resolveCommand(mr.command);
152
+ const r = spawnSync(file, [...prefix, ...mr.check], { encoding: "utf8", timeout: 30_000, windowsHide: true, cwd: repo ?? undefined });
153
+ if (r.status === 0) report("ok", `草稿合并请求:${mr.command} 已登录`);
154
+ else report("warn", `草稿合并请求:${mr.command} ${mr.check.join(" ")} 没有通过(${(r.error?.message ?? `${r.stderr ?? ""}${r.stdout ?? ""}`).trim().split("\n").slice(-1)[0] || `退出码 ${r.status}`})`, `在这台机器上登录 ${mr.command}(例如 ${mr.command} auth login),否则分支照常推送,但不会建合并请求`);
155
+ } else report("ok", `草稿合并请求:推送后用 ${mr.command} 建(没有配置 check,无法检查是否已登录)`);
156
+ }
157
+ }
144
158
  const required = loaded.config.requiredVersion;
145
159
  if (required) {
146
160
  if (satisfies(kitVersion(), required)) report("ok", `版本要求 ${required}:当前 ${kitVersion()} 满足`);
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { spawn } from "node:child_process";
15
15
  import fs from "node:fs";
16
+ import os from "node:os";
16
17
  import path from "node:path";
17
18
  import { parseArgs } from "node:util";
18
19
  import { cardRoutes, findCardEntry, hashText, parseJsonOutput } from "./card.js";
@@ -20,9 +21,10 @@ import { beginFromCard, type CardCmdOptions, cardIntegrations, checkCards, withR
20
21
  import { resumeCommand, statusCommand } from "./cli-args.js";
21
22
  import { loadConfig, type PipelineConfig } from "./config.js";
22
23
  import { watchdogPrompt } from "./messages.js";
24
+ import { dispatchNotifiers } from "./notifiers.js";
23
25
  import { readIndex } from "./records.js";
24
26
  import { type IntegrationConfig, renderArgs, runCommand, type Runner } from "./integrations.js";
25
- import { defaultConfigPath, ENGINE_DIR, KIT_ROOT, kitHome } from "./locations.js";
27
+ import { defaultConfigPath, ENGINE_DIR, KIT_ROOT, kitHome, kitVersion } from "./locations.js";
26
28
  import { isAlive, readState, statePath } from "./state.js";
27
29
 
28
30
  const USAGE = `用法:
@@ -54,6 +56,8 @@ export interface PollResult {
54
56
  relayed: string[];
55
57
  /** 1.12: interrupted pipelines whose chat was asked to continue them. */
56
58
  resumed: string[];
59
+ /** 1.12: what the self-check found wrong; no new cards are taken while it is not empty. */
60
+ problems: string[];
57
61
  errors: string[];
58
62
  notes: string[];
59
63
  }
@@ -148,7 +152,7 @@ export async function pollOnce(configPath: string, opts: CardCmdOptions = {}, no
148
152
  const env = opts.env ?? process.env;
149
153
  const dir = factoryDir(env);
150
154
  fs.mkdirSync(dir, { recursive: true });
151
- const result: PollResult = { started: [], skipped: [], relayed: [], resumed: [], errors: [], notes: [] };
155
+ const result: PollResult = { started: [], skipped: [], relayed: [], resumed: [], problems: [], errors: [], notes: [] };
152
156
 
153
157
  // One look at a time on this machine.
154
158
  const lock = path.join(dir, "poll.lock");
@@ -163,13 +167,22 @@ export async function pollOnce(configPath: string, opts: CardCmdOptions = {}, no
163
167
  fs.writeFileSync(lock, String(process.pid));
164
168
  }
165
169
 
170
+ let config: PipelineConfig | null = null;
166
171
  try {
167
- const config = loadConfig(configPath).config;
172
+ config = loadConfig(configPath).config;
173
+ // 1.12: Paseo, the agents and their quota must work before anything is taken on.
174
+ result.problems = opts.skipHealthCheck ? [] : await healthProblems(config);
175
+ if (result.problems.length > 0) {
176
+ result.notes.push("自检没有通过,这次不接新卡片、不转达回复");
177
+ throw new HealthStop();
178
+ }
168
179
  const tags = config.factory.titleTags;
169
180
  const skippedFile = path.join(dir, "skipped.json");
170
181
  const skipped = readJson<Record<string, Skipped>>(skippedFile, {});
171
182
  const { routes } = cardRoutes(env);
172
183
  let newOnes = 0;
184
+ let listAttempts = 0;
185
+ const listFailures: string[] = [];
173
186
  const integrations = cardIntegrations(config).filter((i) => i.list);
174
187
  if (integrations.length === 0) result.notes.push("没有配置可以列出卡片的外部系统(integrations 中的 list),只转达回复");
175
188
  for (const integration of integrations) {
@@ -180,9 +193,11 @@ export async function pollOnce(configPath: string, opts: CardCmdOptions = {}, no
180
193
  }
181
194
  for (const space of spaces) {
182
195
  let cards: ListedCard[];
196
+ listAttempts += 1;
183
197
  try {
184
198
  cards = await listCards(integration, space, tags[0], opts.run);
185
199
  } catch (error) {
200
+ listFailures.push(`${integration.name} 空间 ${space}:${(error as Error).message}`);
186
201
  result.errors.push(`${integration.name} 空间 ${space}:${(error as Error).message}`);
187
202
  continue;
188
203
  }
@@ -211,6 +226,9 @@ export async function pollOnce(configPath: string, opts: CardCmdOptions = {}, no
211
226
  }
212
227
  }
213
228
  writeJson(skippedFile, skipped);
229
+ if (listAttempts > 0 && listFailures.length === listAttempts) {
230
+ result.problems.push(`列不出卡片,需求管理系统的命令可能不可用:${listFailures[0].slice(0, 200)}`);
231
+ }
214
232
 
215
233
  try {
216
234
  await nudgeInterrupted(config, env, dir, result);
@@ -229,14 +247,105 @@ export async function pollOnce(configPath: string, opts: CardCmdOptions = {}, no
229
247
  result.errors.push(`转达回复:${(error as Error).message}`);
230
248
  }
231
249
  } catch (error) {
232
- result.errors.push((error as Error).message);
250
+ if (!(error instanceof HealthStop)) result.errors.push((error as Error).message);
233
251
  } finally {
234
252
  fs.rmSync(lock, { force: true });
235
253
  }
254
+ if (config) await alertOnce(config, dir, result.problems, now);
236
255
  writeJson(path.join(dir, "status.json"), { lastPollAt: now.toISOString(), pid: process.pid, ...result });
237
256
  return result;
238
257
  }
239
258
 
259
+ class HealthStop extends Error {}
260
+
261
+ /** Runs `work`, giving up after `ms` (a daemon that is not running may never answer). */
262
+ async function within<T>(ms: number, what: string, work: Promise<T>): Promise<T> {
263
+ let timer: NodeJS.Timeout | undefined;
264
+ try {
265
+ return await Promise.race([work, new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new Error(`${what}超过 ${ms / 1000} 秒没有响应`)), ms)))]);
266
+ } finally {
267
+ clearTimeout(timer);
268
+ }
269
+ }
270
+
271
+ /**
272
+ * 1.12: what keeps this machine from working on a new requirement: Paseo not reachable, an
273
+ * agent provider not logged in, the subscription used up. Empty when all is well.
274
+ */
275
+ export async function healthProblems(config: PipelineConfig): Promise<string[]> {
276
+ const problems: string[] = [];
277
+ try {
278
+ await within(30_000, "连接 Paseo ", withRuntime(config, async (runtime, resolved) => {
279
+ const roles: Array<[string, string]> = [
280
+ ["builder", resolved.agents.builder.provider ?? ""],
281
+ ["reviewer", resolved.agents.reviewer.provider ?? ""],
282
+ ];
283
+ const status = await runtime.providerStatus();
284
+ for (const [role, full] of roles) {
285
+ const provider = full.split("/")[0];
286
+ if (!provider || !status) continue;
287
+ const found = status.find((s) => s.provider === provider);
288
+ if (!found || found.status !== "ready") problems.push(`${role} 用的 ${provider} ${found ? `不可用(${found.status})` : "没有安装"}:在 Paseo 中登录它`);
289
+ }
290
+ // A used-up subscription matters when there is nothing else to fall back on.
291
+ const usable = [resolved.agents.builder, ...(resolved.agents.fallback ? [resolved.agents.fallback] : [])].map((p) => (p.provider ?? "").split("/")[0]).filter(Boolean);
292
+ const quotas = (await runtime.providerQuota([...new Set(usable)])) ?? [];
293
+ const exhausted = (provider: string) => quotas.some((q) => q.provider === provider && q.usedPct !== null && q.usedPct >= 100);
294
+ if (usable.length > 0 && usable.every(exhausted)) {
295
+ const q = quotas.find((x) => x.provider === usable[0] && x.usedPct !== null && x.usedPct >= 100)!;
296
+ problems.push(`${q.displayName} 的「${q.window}」额度已用完${q.resetsAt ? `,${new Date(q.resetsAt).toLocaleString("zh-CN", { hour12: false })} 恢复` : ""}`);
297
+ }
298
+ }));
299
+ } catch (error) {
300
+ problems.push(`连不上 Paseo(${(error as Error).message}):确认这台机器上的 Paseo 正在运行`);
301
+ }
302
+ return problems;
303
+ }
304
+
305
+ /**
306
+ * Tells people once when the self-check starts failing (or what fails changes), and once when
307
+ * it passes again — not on every look. Through the configured notifiers (event "alert").
308
+ */
309
+ async function alertOnce(config: PipelineConfig, dir: string, problems: string[], now: Date): Promise<void> {
310
+ const file = path.join(dir, "health.json");
311
+ const before = readJson<{ key: string; since: string | null }>(file, { key: "", since: null });
312
+ const key = problems.join("\n");
313
+ if (key === before.key) return;
314
+ writeJson(file, { key, since: problems.length > 0 ? now.toISOString() : null });
315
+ if (!key && !before.key) return;
316
+ const text = problems.length > 0
317
+ ? `接单器自检没有通过,暂停接新卡片:${problems.join(";")}`
318
+ : `接单器自检恢复正常,继续接卡片${before.since ? `(自 ${new Date(before.since).toLocaleString("zh-CN", { hour12: false })} 起暂停)` : ""}`;
319
+ appendLog(dir, text, now);
320
+ if (config.notifiers.length === 0) return;
321
+ await dispatchNotifiers(
322
+ config.notifiers,
323
+ {
324
+ schema: 1,
325
+ type: "alert",
326
+ at: now.toISOString(),
327
+ kitVersion: kitVersion(),
328
+ feature: { dir: "", name: "", title: "接单器" },
329
+ repo: "",
330
+ branch: null,
331
+ profile: "",
332
+ originAgentId: null,
333
+ text,
334
+ details: { source: "factory", machine: os.hostname(), problems, recovered: problems.length === 0 },
335
+ people: { audience: "team", requester: null, card: null, recipients: [] },
336
+ },
337
+ () => undefined,
338
+ );
339
+ }
340
+
341
+ function appendLog(dir: string, text: string, now: Date): void {
342
+ try {
343
+ fs.appendFileSync(path.join(dir, "factory.log"), `[${now.toLocaleString("zh-CN", { hour12: false })}] ${text}\n`);
344
+ } catch {
345
+ // The log is a convenience.
346
+ }
347
+ }
348
+
240
349
  /**
241
350
  * 1.12: a pipeline on this machine still marked running whose process is gone (the machine
242
351
  * restarted, the process was killed): its chat gets the same message as the watchdog's, so
@@ -275,6 +384,7 @@ function describe(result: PollResult, now = new Date()): string[] {
275
384
  for (const s of result.started) lines.push(`[${stamp}] 已开始:${s}`);
276
385
  for (const s of result.relayed) lines.push(`[${stamp}] ${s}`);
277
386
  for (const s of result.resumed) lines.push(`[${stamp}] 续跑:${s}`);
387
+ for (const s of result.problems) lines.push(`[${stamp}] 自检:${s}`);
278
388
  for (const s of result.skipped) lines.push(`[${stamp}] 没能接:${s}`);
279
389
  for (const s of result.errors) lines.push(`[${stamp}] 出错:${s}`);
280
390
  for (const s of result.notes) lines.push(`[${stamp}] ${s}`);
@@ -373,6 +483,7 @@ function status(opts: CardCmdOptions): number {
373
483
  const last = readJson<(PollResult & { lastPollAt: string }) | null>(path.join(factoryDir(env), "status.json"), null);
374
484
  if (last) {
375
485
  out(`最近一次检查:${new Date(last.lastPollAt).toLocaleString("zh-CN", { hour12: false })};开始 ${last.started.length} 个需求,转达 ${last.relayed.length} 条回复,${last.errors.length} 个错误。`);
486
+ for (const p of last.problems ?? []) out(` 自检:${p}(这期间不接新卡片)`);
376
487
  for (const e of last.errors) out(` 出错:${e}`);
377
488
  for (const n of last.notes) out(` ${n}`);
378
489
  }