paseo-req-kit 1.11.0 → 1.12.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.
- package/CHANGELOG.md +45 -0
- package/bin/req.mjs +5 -0
- package/config.example.json +3 -1
- package/contracts/cli-json.md +5 -1
- package/contracts/notifier-event.md +17 -1
- package/contracts/state.md +3 -1
- package/contracts/usage-record.md +4 -1
- package/docs/adr/0031-fewer-interventions.md +41 -0
- package/docs/adr/README.md +1 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +3 -2
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +58 -4
- package/engine/prompts/04b-design-revise.md +15 -0
- package/engine/src/api.ts +74 -3
- package/engine/src/card-cmd.ts +21 -1
- package/engine/src/card.ts +38 -2
- package/engine/src/config-view.ts +289 -0
- package/engine/src/config.ts +65 -4
- package/engine/src/configure.ts +161 -36
- package/engine/src/control.ts +1 -0
- package/engine/src/doctor.ts +14 -0
- package/engine/src/factory.ts +162 -7
- package/engine/src/main.ts +1 -0
- package/engine/src/messages.ts +22 -0
- package/engine/src/notifiers.ts +32 -1
- package/engine/src/notify-test.ts +8 -2
- package/engine/src/paseo-profiles.ts +2 -0
- package/engine/src/paseo-runtime.ts +9 -0
- package/engine/src/pipeline.ts +222 -30
- package/engine/src/records.ts +8 -0
- package/engine/src/reply.ts +59 -0
- package/engine/src/runtime.ts +2 -0
- package/engine/src/state.ts +41 -1
- package/engine/src/stats.ts +90 -0
- package/engine/src/steps.ts +4 -1
- package/install.mjs +5 -1
- package/notifiers/im-template.mjs +31 -5
- package/package.json +2 -2
- package/paseo-plugin/OVERVIEW.md +2 -0
- package/paseo-plugin/client/config.tsx +216 -0
- package/paseo-plugin/client/data.ts +21 -2
- package/paseo-plugin/client/format.ts +1 -0
- package/paseo-plugin/client/inbox.tsx +173 -0
- package/paseo-plugin/client/models.tsx +117 -0
- package/paseo-plugin/client/overview.tsx +7 -3
- package/paseo-plugin/client/settings.tsx +3 -1
- package/paseo-plugin/index.client.tsx +10 -0
- package/paseo-plugin/index.server.ts +4 -1
- package/paseo-plugin/package.json +1 -1
- package/paseo-plugin/server/handlers.ts +21 -3
- package/paseo-plugin/shared/contracts.ts +77 -1
- package/skills/req/playbooks/new.md +14 -14
- package/skills/req/playbooks/small.md +3 -3
- package/skills/req/playbooks/unattended.md +5 -2
package/engine/src/card.ts
CHANGED
|
@@ -55,6 +55,10 @@ export interface SourceFile {
|
|
|
55
55
|
/** The progress line at the top of the AI section. */
|
|
56
56
|
progress: string | null;
|
|
57
57
|
publishedAt: string | null;
|
|
58
|
+
/** 1.12 (autoConfirm.hours): when the requirement starts by itself if nobody objects; null when it does not. */
|
|
59
|
+
autoConfirmAt?: string | null;
|
|
60
|
+
/** 1.12: set when the confirmation was the automatic one. */
|
|
61
|
+
autoConfirmed?: boolean;
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
export interface CardData {
|
|
@@ -186,8 +190,15 @@ export interface Reply {
|
|
|
186
190
|
planConfirmText: string;
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
// A plain yes, nothing after it but punctuation: "是否可以改成…" is not a yes.
|
|
190
|
-
|
|
193
|
+
// A plain yes, nothing after it but particles and punctuation: "是否可以改成…" is not a yes.
|
|
194
|
+
// 1.12: the everyday ways of saying it count too ("好的", "没问题", "按你说的来", "👍").
|
|
195
|
+
const YES =
|
|
196
|
+
/^(同意|确认|确定|认可|是|是的|对|对的|可以|好|好的|行|没问题|没有问题|通过|就这样|按这个来|按你说的(来|做|办)?|按方案(来|做)?|ok|okay|yes|y|lgtm|👍)\s*(的|了|吧|啊|呀|哈)?\s*[。.!!~~]*\s*$/i;
|
|
197
|
+
|
|
198
|
+
/** Whether a reply is a plain yes. */
|
|
199
|
+
export function isYes(text: string): boolean {
|
|
200
|
+
return YES.test(text.trim());
|
|
201
|
+
}
|
|
191
202
|
|
|
192
203
|
export function parseReply(reply: string | null): Reply {
|
|
193
204
|
const out: Reply = { answers: {}, notes: "", confirmed: false, planConfirmed: false, confirmText: "", planConfirmText: "" };
|
|
@@ -344,10 +355,27 @@ export interface PublishInput {
|
|
|
344
355
|
appendix?: string;
|
|
345
356
|
/** Adds the "方案确认" line to the reply section. */
|
|
346
357
|
planConfirm?: boolean;
|
|
358
|
+
/** 1.12 (autoConfirm.hours): a small requirement with nothing to answer starts this many hours after it is shown, unless someone objects; 0: off. */
|
|
359
|
+
autoConfirmHours?: number;
|
|
347
360
|
run?: Runner;
|
|
348
361
|
now?: Date;
|
|
349
362
|
}
|
|
350
363
|
|
|
364
|
+
/**
|
|
365
|
+
* 1.12: whether a card's requirement may start without a "确认:同意": the quick profile, nothing
|
|
366
|
+
* left to answer, and not confirmed yet. The requester can always object in the reply section.
|
|
367
|
+
*/
|
|
368
|
+
export function autoConfirmEligible(repo: string, featureDir: string, aiText: string, source: SourceFile): boolean {
|
|
369
|
+
if (source.confirmedAt) return false;
|
|
370
|
+
let spec = "";
|
|
371
|
+
try {
|
|
372
|
+
spec = fs.readFileSync(path.join(repo, featureDir, "spec.md"), "utf8");
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
return /^\s*-\s*档位\s*[::]\s*快速/m.test(spec) && questionIds(aiText).length === 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
351
379
|
/**
|
|
352
380
|
* Re-reads the card, replaces the AI section (progress line + card-ai.md + appendix), keeps the
|
|
353
381
|
* requester's text and replies, and writes the description back. Updates and saves source.json.
|
|
@@ -367,6 +395,14 @@ export async function publishCard(input: PublishInput): Promise<{ description: s
|
|
|
367
395
|
const progress = input.progress ?? source.progress ?? "正在整理需求说明";
|
|
368
396
|
const blocks = [progressLine(progress, input.now), session || "需求说明正在整理中,稍后会写在这里。"];
|
|
369
397
|
if (input.appendix?.trim()) blocks.push(input.appendix.trim());
|
|
398
|
+
// Each new version of the summary gets the whole window again.
|
|
399
|
+
const hours = input.autoConfirmHours ?? 0;
|
|
400
|
+
if (hours > 0 && session && autoConfirmEligible(repo, featureDir, session, source)) {
|
|
401
|
+
const at = new Date((input.now ?? new Date()).getTime() + hours * 3600_000);
|
|
402
|
+
source.autoConfirmAt = at.toISOString();
|
|
403
|
+
const when = at.toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
404
|
+
blocks.push(`**自动确认**:这是一个小改动,没有需要你回答的问题。到 ${when} 前没有异议,就按上面的说明开始开发;想先确认就在"确认:"后写"同意",要改就写在回复区。`);
|
|
405
|
+
} else if (!source.confirmedAt) source.autoConfirmAt = null;
|
|
370
406
|
const ai = blocks.join("\n\n");
|
|
371
407
|
const questions = questionIds(session);
|
|
372
408
|
const reply = replySection(parts.reply, questions, { planConfirm: input.planConfirm });
|
|
@@ -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
|
+
}
|
package/engine/src/config.ts
CHANGED
|
@@ -147,11 +147,42 @@ 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. */
|
|
153
168
|
daemonPasswordEnv: string;
|
|
154
|
-
|
|
169
|
+
/**
|
|
170
|
+
* builder and reviewer; `fallback` (1.12, optional) is used when a step's own agent keeps
|
|
171
|
+
* failing (rate limits or quota after the retries, an agent error): the step is run once
|
|
172
|
+
* more with it instead of stopping.
|
|
173
|
+
*/
|
|
174
|
+
agents: Record<"builder" | "reviewer", AgentProfile> & { fallback?: AgentProfile | null };
|
|
175
|
+
/**
|
|
176
|
+
* 1.12: handle failures without a person where that is safe: wait once more for a step that
|
|
177
|
+
* runs over its time, redo a step once after an agent error or an invalid result file.
|
|
178
|
+
*/
|
|
179
|
+
selfHeal: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* 1.12: questions the design review says only the requester can decide go to the builder
|
|
182
|
+
* first, who answers what the code, the documents and the spec settle; only the real
|
|
183
|
+
* business choices, each with a recommended answer, reach the requester.
|
|
184
|
+
*/
|
|
185
|
+
answerQuestionsFirst: boolean;
|
|
155
186
|
/** Profile used when neither --profile nor the spec names one. */
|
|
156
187
|
defaultProfile: ProfileName;
|
|
157
188
|
profiles: Record<ProfileName, ProfileSettings>;
|
|
@@ -176,6 +207,12 @@ export interface PipelineConfig {
|
|
|
176
207
|
reviewPanel: ReviewPanelSettings;
|
|
177
208
|
/** External notifiers (e.g. an internal IM). Machine or team config only. */
|
|
178
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>>;
|
|
179
216
|
/** External systems kept in step with requirements, e.g. an ALM's CLI (1.9). Machine or team config only: they run commands. */
|
|
180
217
|
integrations: IntegrationConfig[];
|
|
181
218
|
/** Per integration name, values for its {field.*} placeholders; a project's file may set these (e.g. its project id). */
|
|
@@ -185,7 +222,7 @@ export interface PipelineConfig {
|
|
|
185
222
|
* the handoff is written (1.10). Off by default; machine or team config only. Never forced,
|
|
186
223
|
* only to branches under `branchPrefix`, never merged.
|
|
187
224
|
*/
|
|
188
|
-
push: { enabled: boolean; remote: string; branchPrefix: string };
|
|
225
|
+
push: { enabled: boolean; remote: string; branchPrefix: string; mergeRequest?: MergeRequestSettings | null };
|
|
189
226
|
/**
|
|
190
227
|
* Which cards this repository takes (1.10, written by /req-setup in the project's file): the
|
|
191
228
|
* system, its space (project id) and subsystems; `titleKeywords` tells apart repositories
|
|
@@ -198,6 +235,11 @@ export interface PipelineConfig {
|
|
|
198
235
|
* config only.
|
|
199
236
|
*/
|
|
200
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 };
|
|
201
243
|
/** Automatic retries of a step after a transient error (rate limit, dropped connection). */
|
|
202
244
|
retry: { attempts: number; backoffSeconds: number[] };
|
|
203
245
|
/**
|
|
@@ -300,11 +342,13 @@ export const DEFAULT_CONFIG: PipelineConfig = {
|
|
|
300
342
|
requiredVersion: null,
|
|
301
343
|
reviewPanel: { enabled: false, steps: ["design_review", "code_review"], profiles: ["standard"], members: [] },
|
|
302
344
|
notifiers: [],
|
|
345
|
+
people: {},
|
|
303
346
|
integrations: [],
|
|
304
347
|
integrationDefaults: {},
|
|
305
348
|
push: { enabled: false, remote: "origin", branchPrefix: "req/" },
|
|
306
349
|
cardSource: null,
|
|
307
350
|
factory: { pollMinutes: 5, titleTags: ["[AI]", "【AI】"], maxNewPerPoll: 3 },
|
|
351
|
+
autoConfirm: { hours: 0 },
|
|
308
352
|
retry: { attempts: 2, backoffSeconds: [60, 300] },
|
|
309
353
|
paseoServices: "auto",
|
|
310
354
|
serviceReadySeconds: 120,
|
|
@@ -317,7 +361,9 @@ export const DEFAULT_CONFIG: PipelineConfig = {
|
|
|
317
361
|
waitingReminderHours: 24,
|
|
318
362
|
autoResumeMinutes: 30,
|
|
319
363
|
autoResumeMax: 2,
|
|
320
|
-
watchdogMinutes:
|
|
364
|
+
watchdogMinutes: 60,
|
|
365
|
+
selfHeal: true,
|
|
366
|
+
answerQuestionsFirst: true,
|
|
321
367
|
phases: { mode: "continuous", verify: { environment: "local", agents: {}, defects: "fix", serviceStart: null } },
|
|
322
368
|
requirementTypes: ["defect", "refactor", "assessment"],
|
|
323
369
|
checks: [],
|
|
@@ -370,6 +416,7 @@ function merge(base: PipelineConfig, raw: Record<string, unknown>): PipelineConf
|
|
|
370
416
|
}
|
|
371
417
|
merged.push = { ...base.push, ...(isRecord(raw.push) ? raw.push : {}) } as PipelineConfig["push"];
|
|
372
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"];
|
|
373
420
|
if (isRecord(raw.cardSource)) {
|
|
374
421
|
const c = raw.cardSource;
|
|
375
422
|
merged.cardSource = {
|
|
@@ -532,6 +579,9 @@ export function resolveRelatedRepo(repo: RelatedRepo, projectDir: string): strin
|
|
|
532
579
|
|
|
533
580
|
function validate(config: PipelineConfig): void {
|
|
534
581
|
for (const role of ["builder", "reviewer"] as const) validateProvider(`agents.${role}`, config.agents[role]);
|
|
582
|
+
if (config.agents.fallback !== undefined && config.agents.fallback !== null) validateProvider("agents.fallback", config.agents.fallback);
|
|
583
|
+
if (typeof config.selfHeal !== "boolean") throw new ConfigError("selfHeal 必须是 true 或 false");
|
|
584
|
+
if (typeof config.answerQuestionsFirst !== "boolean") throw new ConfigError("answerQuestionsFirst 必须是 true 或 false");
|
|
535
585
|
if (!PROFILE_NAMES.includes(config.defaultProfile)) {
|
|
536
586
|
throw new ConfigError(`defaultProfile 必须是 ${PROFILE_NAMES.join(" 或 ")}`);
|
|
537
587
|
}
|
|
@@ -571,6 +621,17 @@ function validate(config: PipelineConfig): void {
|
|
|
571
621
|
if (typeof push.enabled !== "boolean") throw new ConfigError("push.enabled 必须是 true 或 false");
|
|
572
622
|
if (typeof push.remote !== "string" || !/^[\w.-]+$/.test(push.remote)) throw new ConfigError('push.remote 必须是远程仓库名,例如 "origin"');
|
|
573
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 表示关闭)");
|
|
574
635
|
const f = config.factory;
|
|
575
636
|
if (typeof f.pollMinutes !== "number" || !(f.pollMinutes >= 1)) throw new ConfigError("factory.pollMinutes 必须是至少 1 的分钟数");
|
|
576
637
|
if (!Number.isInteger(f.maxNewPerPoll) || f.maxNewPerPoll < 1) throw new ConfigError("factory.maxNewPerPoll 必须是正整数");
|
|
@@ -617,7 +678,7 @@ export interface LoadedConfig {
|
|
|
617
678
|
}
|
|
618
679
|
|
|
619
680
|
/** Keys a project file may not set: they run code or reach outside the repository. */
|
|
620
|
-
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;
|
|
621
682
|
|
|
622
683
|
/** Notifier module paths are relative to the file that names them; "~/" is the home directory. */
|
|
623
684
|
function resolveNotifiers(raw: Record<string, unknown>, file: string): Record<string, unknown> {
|