dsh-issue2pr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ // lib/assistant.js — 悬浮智能助手的上下文聚合(每次提问时现读,不落盘不缓存)
2
+ // focus 来自前端 viewStore:{ nav, slug, runId }(用户当前所在页面 / 选中项目 / 选中 Run)
3
+ import { join } from "node:path";
4
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
+ import { listProjects, readArtifact } from "./store.js";
6
+ import { loadRun } from "./pipeline.js";
7
+ import { STAGE_DEFS } from "./stageConfig.js";
8
+
9
+ export const ASSISTANT_SYSTEM_HEAD = [
10
+ "你是 Issue2PR 插件(运行在 DSH 宿主里)内置的智能助手,悬浮在工作台右上角,",
11
+ "职责是解答用户对这个工作台的提问、疑惑,以及运行过程中的状态询问。",
12
+ "",
13
+ "回答守则:",
14
+ "1. 回答运行状态类问题(跑到哪了/为什么失败/卡住了吗)时,只依据下方「实时上下文」,不要臆造;上下文里没有的信息就明说「当前上下文里没有」,并建议用户先在项目页选中项目或运行页选中 Run。",
15
+ "2. 概念类问题(P1-P11 是什么、复核门、外部委托、reviewMode 等)可基于你对插件设计的理解回答,下方附有阶段速查表。",
16
+ "3. 用户提到「这个项目/这个 Run」时,优先理解为实时上下文里「当前聚焦」的部分。",
17
+ "4. 用简体中文,简洁直接,先给结论;涉及界面操作的给出具体点击路径(左侧目录:项目/运行/产物/配置/说明)。",
18
+ "5. 不要编造不存在的功能或数据。",
19
+ ].join("\n");
20
+
21
+ // 上下文总长保护:超出时优先保住「聚焦 Run + 最近事件」(截断在末尾按序丢弃全局部分)
22
+ const MAX_CONTEXT_CHARS = 12000;
23
+ const EVENT_TAIL = 30;
24
+ const EVENT_DETAIL_CHARS = 300;
25
+
26
+ const NAV_LABELS = {
27
+ projects: "项目页", runs: "运行页", artifacts: "产物页", config: "配置页", guide: "说明页",
28
+ };
29
+
30
+ // session/claude 模式外部执行进度(口径与 index.js externalProgress 一致:不 import 是为避免循环依赖)
31
+ function externalProgressOf(runDir) {
32
+ const dir = join(runDir, "06-implementation", "patches");
33
+ let patches = 0;
34
+ if (existsSync(dir)) patches = readdirSync(dir).filter((f) => f.endsWith(".diff")).length;
35
+ let tasks = null;
36
+ try { tasks = JSON.parse(readFileSync(join(runDir, "05-task-graph.json"), "utf8")).nodes.length; } catch { /* 缺任务图时只报 patch 数 */ }
37
+ return { patches, tasks, report: existsSync(join(runDir, "06-implementation", "coder-report.json")) };
38
+ }
39
+
40
+ // 单项目最近一个 Run 摘要(runId 前缀即时间戳,目录名字典序 = 时间序,取最后一个)
41
+ function latestRunOf(root, slug) {
42
+ const runsDir = join(root, "projects", slug, "runs");
43
+ if (!existsSync(runsDir)) return null;
44
+ const ids = readdirSync(runsDir).filter((id) => existsSync(join(runsDir, id, "run.json"))).sort();
45
+ if (!ids.length) return null;
46
+ const run = loadRun(join(runsDir, ids[ids.length - 1]));
47
+ return run ? { id: run.id, status: run.status, current: run.current, createdAt: run.createdAt, _dir: join(runsDir, ids[ids.length - 1]) } : null;
48
+ }
49
+
50
+ function stageTable(run) {
51
+ return STAGES_LINE.map(([id, name, desc]) => {
52
+ const st = run.stages[id] || {};
53
+ return `- ${id} ${name}(${desc}):${st.status || "pending"}`
54
+ + (st.error ? `,错误: ${String(st.error).slice(0, 200)}` : "");
55
+ }).join("\n");
56
+ }
57
+ const STAGES_LINE = Object.entries(STAGE_DEFS).map(([id, def]) => [id, def.name, def.desc]);
58
+
59
+ function eventTail(runDir) {
60
+ const raw = readArtifact(runDir, "trace/events.jsonl");
61
+ if (!raw) return [];
62
+ const lines = raw.split("\n").filter((l) => l.trim());
63
+ return lines.slice(-EVENT_TAIL).map((l) => {
64
+ try {
65
+ const e = JSON.parse(l);
66
+ const detail = String(e.detail == null ? "" : e.detail).slice(0, EVENT_DETAIL_CHARS);
67
+ return `[${String(e.at || "")}] ${e.stage || ""} ${e.name || ""}${e.ok === false ? "(失败)" : ""}${detail ? "\n " + detail.replace(/\n/g, "\n ") : ""}`;
68
+ } catch { return null; }
69
+ }).filter(Boolean);
70
+ }
71
+
72
+ export function buildAssistantContext(root, focus = {}) {
73
+ const out = [];
74
+ const push = (s) => out.push(s);
75
+
76
+ // —— 0. 用户当前位置 ——
77
+ const navLabel = NAV_LABELS[focus.nav] || (focus.nav ? String(focus.nav) : "未知");
78
+ push(`## 用户当前位置\n正在看「${navLabel}」;选中项目:${focus.slug || "(未选中)"};选中 Run:${focus.runId || "(未选中)"}。`);
79
+
80
+ // —— 1. 全局:所有项目概要(最多 10 个,每个带最近 Run 状态) ——
81
+ const projects = listProjects(root);
82
+ push("\n## 全局:项目概要(共 " + projects.length + " 个)");
83
+ if (!projects.length) push("(还没有项目。用户可在「项目」页新建。)");
84
+ for (const p of projects.slice(0, 10)) {
85
+ const trig = (p.triggers || []).map((t) => t.kind + " " + t.uri).join(";") || "(无)";
86
+ const last = latestRunOf(root, p.slug);
87
+ push(`- ${p.name}(slug: ${p.slug}):reviewMode=${p.reviewMode},P6 模式=${p.p6Mode};触发源:${trig}`
88
+ + (last ? `;最近 Run ${last.id}(${String(last.createdAt || "").slice(0, 19)})状态=${last.status},当前阶段=${last.current}` : ";尚无 Run"));
89
+ }
90
+
91
+ // —— 2. 聚焦项目配置摘要 ——
92
+ if (focus.slug && /^[a-z0-9-]+$/.test(focus.slug)) {
93
+ const p = projects.find((x) => x.slug === focus.slug);
94
+ if (p) {
95
+ const custom = Object.keys(p.stageConfig || {});
96
+ const repos = (p.repos || []).map((r) => r.uri).join(";");
97
+ push(`\n## 当前聚焦项目:${p.name}(${p.slug})`);
98
+ push(`仓库:${repos}`);
99
+ push(`reviewMode=${p.reviewMode}(every=每阶段都要人工复核 / key-only=仅关键阶段 / auto=全自动);P6 模式=${p.p6Mode}(builtin=内置执行 / session=生成任务包等外部会话 / claude=委托 Claude Code CLI)`);
100
+ push(custom.length ? `已自定义配置的阶段:${custom.join("、")}` : "各阶段均用默认配置");
101
+ }
102
+ }
103
+
104
+ // —— 3. 聚焦 Run 状态机 + 外部执行进度 + 最近事件 ——
105
+ if (focus.slug && focus.runId && /^[a-z0-9-]+$/.test(focus.slug) && /^\d{8}-\d{6}-[a-z0-9-]+$/.test(focus.runId)) {
106
+ const runsDir = join(root, "projects", focus.slug, "runs", focus.runId);
107
+ const run = loadRun(runsDir);
108
+ if (run) {
109
+ push(`\n## 当前聚焦 Run:${run.id}`);
110
+ push(`状态=${run.status};当前阶段=${run.current};创建于 ${run.createdAt};reviewMode=${run.reviewMode};P6 模式=${run.p6Mode};触发源:${run.trigger?.kind || ""} ${run.trigger?.uri || ""}`);
111
+ if (run.externalExec) {
112
+ const e = run.externalExec;
113
+ push(`外部执行:executor=${e.executor || "?"},status=${e.status || "?"}`
114
+ + (e.statsSummary ? `,${e.statsSummary}` : "")
115
+ + (e.error ? `,错误: ${String(e.error).slice(0, 200)}` : ""));
116
+ }
117
+ if (run.p6Mode === "session" || run.p6Mode === "claude") {
118
+ const ep = externalProgressOf(runsDir);
119
+ push(`外部进度:patch ${ep.patches}${ep.tasks != null ? "/" + ep.tasks : ""} 份,coder-report ${ep.report ? "已生成" : "未生成"}`);
120
+ }
121
+ push("\n### 各阶段状态");
122
+ push(stageTable(run));
123
+ const evs = eventTail(runsDir);
124
+ if (evs.length) {
125
+ push(`\n### 最近事件(${evs.length} 条,按时间序)`);
126
+ push(evs.join("\n"));
127
+ }
128
+ }
129
+ }
130
+
131
+ // —— 4. 概念速查 ——
132
+ push("\n## 阶段速查表(P1-P11 流水线)");
133
+ for (const [id, def] of Object.entries(STAGE_DEFS)) push(`- ${id} ${def.name}:${def.desc}`);
134
+
135
+ const text = out.join("\n");
136
+ return text.length > MAX_CONTEXT_CHARS ? text.slice(0, MAX_CONTEXT_CHARS) + "\n…(上下文过长已截断)" : text;
137
+ }
@@ -0,0 +1,114 @@
1
+ // lib/connections.js — Git 托管连接(GitHub / GitLab / 华为云 CodeArts 凭据)
2
+ // 全局共享:<dataRoot>/connections.json(与项目无关,一套凭据多项目复用)。
3
+ // token 明文落盘,与本机 GITHUB_TOKEN 环境变量同级安全;UI 注明存储路径。
4
+ import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ export const CONNECTION_KINDS = {
8
+ github: { label: "GitHub", defaultHost: "github.com", hostFixed: true, needsUsername: false, tokenLabel: "Access Token" },
9
+ gitlab: { label: "GitLab", defaultHost: "gitlab.com", hostFixed: false, needsUsername: false, tokenLabel: "Personal Access Token" },
10
+ codearts: { label: "华为云 CodeArts", defaultHost: "", hostFixed: false, needsUsername: true, tokenLabel: "HTTPS 密码" },
11
+ };
12
+
13
+ const connFile = (root) => join(root, "connections.json");
14
+
15
+ export function loadConnections(root) {
16
+ const file = connFile(root);
17
+ if (!existsSync(file)) return [];
18
+ try {
19
+ const list = JSON.parse(readFileSync(file, "utf8"));
20
+ return Array.isArray(list) ? list : [];
21
+ } catch { return []; }
22
+ }
23
+
24
+ export function saveConnections(root, list) {
25
+ const tmp = connFile(root) + ".tmp";
26
+ writeFileSync(tmp, JSON.stringify(list, null, 2));
27
+ renameSync(tmp, connFile(root));
28
+ }
29
+
30
+ // 校验 + 规范化;id 直接用 host(同 host 唯一,天然 upsert 键)
31
+ export function normalizeConnection(c, existing = []) {
32
+ if (!c || typeof c !== "object") throw new Error("连接必须是对象");
33
+ const kind = CONNECTION_KINDS[c.kind] ? c.kind : null;
34
+ if (!kind) throw new Error("kind 仅允许 github|gitlab|codearts");
35
+ const meta = CONNECTION_KINDS[kind];
36
+ let host = String(c.host || meta.defaultHost || "").trim().toLowerCase();
37
+ // 去掉用户误粘贴的协议前缀与路径
38
+ host = host.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
39
+ if (!/^[a-z0-9.-]+(\.[a-z0-9-]+)*$/.test(host)) {
40
+ throw new Error("host 必须是合法域名(如 github.com / gitlab.example.com)");
41
+ }
42
+ if (meta.hostFixed && host !== meta.defaultHost) {
43
+ throw new Error(kind + " 连接的 host 固定为 " + meta.defaultHost);
44
+ }
45
+ const token = String(c.token || "").trim();
46
+ if (!token) throw new Error("token 不能为空");
47
+ if (meta.needsUsername && !String(c.username || "").trim()) {
48
+ throw new Error("CodeArts 连接需要 HTTPS 用户名(租户名/IAM用户名,在 CodeArts「个人设置 → HTTPS密码」页获取)");
49
+ }
50
+ const dup = existing.find((x) => x.host === host);
51
+ if (dup && dup.kind !== kind) throw new Error(host + " 已配置为 " + CONNECTION_KINDS[dup.kind].label + " 连接,请先删除原连接");
52
+ const out = { id: host, kind, host, token, createdAt: dup?.createdAt || new Date().toISOString() };
53
+ if (meta.needsUsername) out.username = String(c.username).trim();
54
+ return out;
55
+ }
56
+
57
+ export function upsertConnection(root, c) {
58
+ const list = loadConnections(root);
59
+ const norm = normalizeConnection(c, list);
60
+ const i = list.findIndex((x) => x.host === norm.host);
61
+ if (i >= 0) list[i] = norm; else list.push(norm);
62
+ saveConnections(root, list);
63
+ return norm;
64
+ }
65
+
66
+ export function deleteConnection(root, id) {
67
+ const list = loadConnections(root);
68
+ const i = list.findIndex((x) => x.id === id || x.host === id);
69
+ if (i < 0) return null;
70
+ const [removed] = list.splice(i, 1);
71
+ saveConnections(root, list);
72
+ return removed;
73
+ }
74
+
75
+ // 从仓库/issue URI 解析 hostname:支持 https、ssh://、scp 形态(git@host:path)
76
+ export function hostOf(uri) {
77
+ const s = String(uri || "").trim();
78
+ const m = s.match(/^(?:https?|ssh|git):\/\/(?:[^@\/]+@)?([^\/:?#]+)/i);
79
+ if (m) return m[1].toLowerCase();
80
+ const scp = s.match(/^git@([^\/:?#]+):/);
81
+ if (scp) return scp[1].toLowerCase();
82
+ return null;
83
+ }
84
+
85
+ export function matchConnection(uri, list) {
86
+ const host = hostOf(uri);
87
+ if (!host) return null;
88
+ return list.find((c) => c.host === host) || null;
89
+ }
90
+
91
+ // https 仓库地址注入凭据;ssh/scp 形态或已内嵌凭据的地址原样返回(用户显式配置优先)
92
+ export function injectGitCredentials(uri, conn) {
93
+ const s = String(uri || "");
94
+ if (!conn || !/^https?:\/\//i.test(s)) return s;
95
+ try {
96
+ const u = new URL(s);
97
+ if (u.username || u.password) return s;
98
+ if (conn.kind === "github") { u.username = "x-access-token"; u.password = conn.token; }
99
+ else if (conn.kind === "gitlab") { u.username = "oauth2"; u.password = conn.token; }
100
+ else { u.username = conn.username || ""; u.password = conn.token; }
101
+ return u.toString();
102
+ } catch { return s; }
103
+ }
104
+
105
+ // 错误信息/事件日志脱敏:https://user:pass@… → https://***@…(URL 可嵌在句子中间,全局替换)
106
+ export function redactUrl(uri) {
107
+ return String(uri || "").replace(/(https?:\/\/)[^@\/\s'"]+@/gi, "$1***@");
108
+ }
109
+
110
+ export function maskToken(token) {
111
+ const t = String(token || "");
112
+ if (t.length <= 8) return "****";
113
+ return t.slice(0, 4) + "…" + t.slice(-4);
114
+ }
package/lib/llm.js ADDED
@@ -0,0 +1,159 @@
1
+ // lib/llm.js — ctx.llm.stream 封装 + JSON 契约解析(重试一次)
2
+ // makeLlm(ctx, hook, overridesOf):overridesOf() 返回当前阶段的覆盖配置
3
+ // (provider/model/reasoningEffort/timeoutMs/maxTokens,来自 project.stageConfig),
4
+ // 每次调用时现取,阶段推进/配置修改即时生效。
5
+ const DEFAULT_ROUTE = { provider: "deepseek-official", model: "deepseek-v4-pro" };
6
+
7
+ // 路由解析 + 来源标注(source:stage=阶段覆盖 / host=宿主默认 / plugin=插件配置 / default=插件兜底)。
8
+ // preflight 端点用它把「当前默认模型从哪来」台面化。
9
+ export function routeInfo(ctx, ov) {
10
+ // 0) 阶段级覆盖最高优先(配置页按阶段指定模型)
11
+ if (ov && ov.provider && ov.model) {
12
+ const route = { provider: ov.provider, model: ov.model };
13
+ if (ov.reasoningEffort) route.reasoningEffort = ov.reasoningEffort;
14
+ return { route, source: "stage" };
15
+ }
16
+ // 1) 首选宿主 agentDefaultModel 服务(真实生效的默认路由),服务可能不存在,需 try/catch
17
+ try {
18
+ const sel = ctx.agentDefaultModel && typeof ctx.agentDefaultModel.currentSelection === "function"
19
+ ? ctx.agentDefaultModel.currentSelection() : undefined;
20
+ if (sel && sel.provider && sel.model) {
21
+ const route = { provider: sel.provider, model: sel.model };
22
+ if (sel.reasoningEffort) route.reasoningEffort = sel.reasoningEffort; // GenerateOptions 支持该字段
23
+ return { route, source: "host" };
24
+ }
25
+ } catch {}
26
+ // 2) 次级回退:插件配置 agent-default-model(向后兼容既有测试)
27
+ try {
28
+ const cfg = typeof ctx.getConfig === "function" ? ctx.getConfig("agent-default-model") : undefined;
29
+ if (cfg && cfg.provider && cfg.model) return { route: { provider: cfg.provider, model: cfg.model }, source: "plugin" };
30
+ } catch {}
31
+ // 3) 最终兜底
32
+ return { route: DEFAULT_ROUTE, source: "default" };
33
+ }
34
+
35
+ function resolveRoute(ctx, ov) {
36
+ return routeInfo(ctx, ov).route;
37
+ }
38
+
39
+ export function extractJson(text) {
40
+ const fenced = String(text).match(/```(?:json)?\s*([\s\S]*?)```/);
41
+ const candidate = fenced ? fenced[1] : (() => {
42
+ const s = String(text);
43
+ const i = s.indexOf("{"), j = s.lastIndexOf("}");
44
+ if (i === -1 || j <= i) throw new Error("契约解析失败: 输出不含 JSON 对象");
45
+ return s.slice(i, j + 1);
46
+ })();
47
+ try { return JSON.parse(candidate); }
48
+ catch (e) { throw new Error("契约解析失败: " + e.message); }
49
+ }
50
+
51
+ export function makeLlm(ctx, hook, overridesOf) {
52
+ const takeOverrides = () => (typeof overridesOf === "function" ? overridesOf() : null) || {};
53
+ async function complete({ system, user, maxTokens = 8192, signal } = {}) {
54
+ const ov = takeOverrides();
55
+ const route = resolveRoute(ctx, ov);
56
+ const effMaxTokens = ov.maxTokens > 0 ? Math.floor(ov.maxTokens) : maxTokens;
57
+ const timeoutMs = ov.timeoutMs > 0 ? ov.timeoutMs : 0;
58
+ const t0 = Date.now();
59
+ // 事件预览:prompt 头 + 响应头,截断防膨胀
60
+ const preview = (s, n) => {
61
+ const str = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
62
+ return str.length > n ? str.slice(0, n) + "…" : str;
63
+ };
64
+ // 开打即记「调用中」:LLM 单次调用可达分钟级,进行中就要在阶段详情可见
65
+ hook?.({ kind: "llm", name: route.model + " · 调用中", ms: null, ok: true,
66
+ detail: `【prompt】${preview(user, 300)}` });
67
+ // 流式累积包成 Promise,外层与超时 race(宿主 stream 的 signal 中断不可依赖,
68
+ // 超时直接拒绝,让阶段失败可感知可重试,而不是无限挂起)
69
+ const streamP = (async () => {
70
+ let text = "";
71
+ for await (const chunk of ctx.llm.stream({
72
+ provider: route.provider, model: route.model,
73
+ // 路由若带 reasoningEffort 则一并传给 stream(GenerateOptions 支持该字段)
74
+ ...(route.reasoningEffort ? { reasoningEffort: route.reasoningEffort } : {}),
75
+ // dsh-llm 的 Message.content 是 ContentBlock[](types/message.d.ts:126),字符串会触发适配器 content.some 异常
76
+ system, messages: [{ role: "user", content: [{ type: "text", text: user }] }], maxTokens: effMaxTokens, signal,
77
+ })) {
78
+ if (chunk.type === "text-delta") text += chunk.text;
79
+ if (chunk.type === "finish") {
80
+ // dsh-llm 的 finish reason 是对象({kind,failure?}),同时兼容字符串
81
+ const kind = typeof chunk.reason === "string" ? chunk.reason : chunk.reason?.kind;
82
+ if (kind === "error" || kind === "aborted") {
83
+ // failure 可能是对象,取最有信息量的字段;都没有就用 kind
84
+ const detail = chunk.reason?.failure?.message || chunk.reason?.failure?.code || kind;
85
+ throw new Error("LLM 调用失败: " + detail);
86
+ }
87
+ }
88
+ }
89
+ if (!text.trim()) throw new Error("LLM 返回为空");
90
+ return text;
91
+ })();
92
+ let timer = null;
93
+ const timeoutP = timeoutMs > 0
94
+ ? new Promise((_, reject) => {
95
+ timer = setTimeout(() => reject(new Error(`LLM 调用超时(${timeoutMs}ms,可在「配置」页调整该阶段的超时时间)`)), timeoutMs);
96
+ })
97
+ : null;
98
+ try {
99
+ const text = await (timeoutP ? Promise.race([streamP, timeoutP]) : streamP);
100
+ hook?.({ kind: "llm", name: route.model + " · 完成", ms: Date.now() - t0, ok: true,
101
+ detail: `prompt ${String(user || "").length} 字 → 响应 ${text.length} 字\n【prompt】${preview(user, 300)}\n——\n【响应】${preview(text, 500)}` });
102
+ return text;
103
+ } catch (e) {
104
+ hook?.({ kind: "llm", name: route.model + " · 失败", ms: Date.now() - t0, ok: false,
105
+ detail: `prompt ${String(user || "").length} 字\n【prompt】${preview(user, 300)}\n——\n【错误】${String((e && e.message) || e)}` });
106
+ throw e;
107
+ } finally {
108
+ if (timer) clearTimeout(timer);
109
+ }
110
+ }
111
+
112
+ // 流式单次调用(悬浮智能助手用):messages 为简化形态 [{role, text}],
113
+ // 内部转 ContentBlock 数组;每个 text-delta 回调 onDelta,返回完整文本。
114
+ // 路由解析与 finish 错误判定与 complete 一致,但不带超时 race 与事件钩子
115
+ // (助手调用不属于任何 Run,不写 events.jsonl;中断由调用方 signal 控制)
116
+ async function streamText({ system, messages = [], maxTokens = 8192, signal, onDelta } = {}) {
117
+ const route = resolveRoute(ctx, takeOverrides());
118
+ let text = "";
119
+ for await (const chunk of ctx.llm.stream({
120
+ provider: route.provider, model: route.model,
121
+ ...(route.reasoningEffort ? { reasoningEffort: route.reasoningEffort } : {}),
122
+ system,
123
+ messages: messages.map(({ role, text: t }) => ({ role, content: [{ type: "text", text: String(t || "") }] })),
124
+ maxTokens, signal,
125
+ })) {
126
+ if (chunk.type === "text-delta") {
127
+ text += chunk.text;
128
+ if (typeof onDelta === "function") onDelta(chunk.text);
129
+ }
130
+ if (chunk.type === "finish") {
131
+ const kind = typeof chunk.reason === "string" ? chunk.reason : chunk.reason?.kind;
132
+ if (kind === "error" || kind === "aborted") {
133
+ const detail = chunk.reason?.failure?.message || chunk.reason?.failure?.code || kind;
134
+ throw new Error("LLM 调用失败: " + detail);
135
+ }
136
+ }
137
+ }
138
+ if (!text.trim()) throw new Error("LLM 返回为空");
139
+ return text;
140
+ }
141
+
142
+ async function completeJson({ system, user, required = [], maxTokens, signal } = {}) {
143
+ let lastErr = null;
144
+ for (let attempt = 0; attempt < 2; attempt++) {
145
+ const prompt = attempt === 0 ? user
146
+ : user + `\n\n【上次输出无法解析:${lastErr}。请只输出一个合法 JSON 对象,不要输出任何其他文字。】`;
147
+ try {
148
+ const out = extractJson(await complete({ system, user: prompt, maxTokens, signal }));
149
+ if (typeof out !== "object" || out === null || Array.isArray(out)) throw new Error("契约解析失败: 输出不是 JSON 对象");
150
+ const missing = required.filter((k) => !(k in out));
151
+ if (missing.length) throw new Error("契约解析失败: 缺字段 " + missing.join(","));
152
+ return out;
153
+ } catch (e) { lastErr = (e && e.message) || String(e); }
154
+ }
155
+ throw new Error(lastErr);
156
+ }
157
+
158
+ return { complete, completeJson, streamText };
159
+ }
@@ -0,0 +1,151 @@
1
+ // lib/pipeline.js — 状态机核心(不含任何 LLM/执行细节)
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { writeArtifact, appendArtifactLine, timestamp } from "./store.js";
5
+ import { STAGE_DEFS, stageDelegated, delegateReady } from "./stageConfig.js";
6
+
7
+ export const STAGES = [
8
+ { id: "P1", name: "IssueAnalyzer", artifact: "01-issue-analysis.json", key: false },
9
+ { id: "P2", name: "Search Layer", artifact: "02-search-candidates.json", key: false },
10
+ { id: "P3", name: "Code Understanding", artifact: "03-code-understanding.md", key: false },
11
+ { id: "P4", name: "Hypothesis", artifact: "04-hypotheses.json", key: false },
12
+ { id: "P5", name: "Planner", artifact: "05-task-graph.json", key: true },
13
+ { id: "P6", name: "代码优化", artifact: "06-implementation/", key: true },
14
+ { id: "P7", name: "Patch Pipeline", artifact: "ledger/patch-ledger.jsonl", key: false },
15
+ { id: "P8", name: "TestRunner", artifact: "07-test-report.json", key: false },
16
+ { id: "P9", name: "Reviewer", artifact: "08-review-report.json", key: true },
17
+ { id: "P10", name: "FailureClassifier", artifact: "09-failure-analysis.json", key: false },
18
+ { id: "P11", name: "PRBuilder + Eval", artifact: "10-pr-description.md", key: true },
19
+ ];
20
+ export const MAIN_FLOW = ["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "P11"];
21
+
22
+ export function initRun({ runId, slug, trigger, reviewMode, p6Mode }) {
23
+ return {
24
+ id: runId, project: slug, trigger, reviewMode, p6Mode,
25
+ createdAt: new Date().toISOString(), status: "pending", current: "P1",
26
+ stages: Object.fromEntries(STAGES.map((s) => [s.id, { status: "pending", attempts: 0 }])),
27
+ };
28
+ }
29
+
30
+ const runFile = (runDir) => join(runDir, "run.json");
31
+ export function saveRun(runDir, run) { writeArtifact(runDir, "run.json", JSON.stringify(run, null, 2)); }
32
+ export function loadRun(runDir) {
33
+ return existsSync(runFile(runDir)) ? JSON.parse(readFileSync(runFile(runDir), "utf8")) : null;
34
+ }
35
+
36
+ export function isGate(run, stageId) {
37
+ if (run.reviewMode === "every") return true;
38
+ if (run.reviewMode === "key-only") return !!STAGES.find((s) => s.id === stageId)?.key;
39
+ return false;
40
+ }
41
+
42
+ // session 模式的外部执行就绪判定(与 p7-patch 的 collectPatches 口径一致:
43
+ // coder-report.json 存在,或 patches/ 下有 *.diff)
44
+ export function sessionPatchesReady(runDir) {
45
+ if (existsSync(join(runDir, "06-implementation", "coder-report.json"))) return true;
46
+ const dir = join(runDir, "06-implementation", "patches");
47
+ if (!existsSync(dir)) return false;
48
+ return readdirSync(dir).some((f) => f.endsWith(".diff"));
49
+ }
50
+
51
+ function appendSpan(runDir, span) {
52
+ mkdirSync(join(runDir, "trace"), { recursive: true });
53
+ appendFileSync(join(runDir, "trace", "spans.jsonl"), JSON.stringify({ at: new Date().toISOString(), ...span }) + "\n");
54
+ }
55
+
56
+ // 过程事件(trace/events.jsonl,UI 阶段详情展示;写失败不影响主流程)
57
+ function appendEvent(runDir, stage, ev) {
58
+ try {
59
+ appendArtifactLine(runDir, "trace/events.jsonl", {
60
+ at: new Date().toISOString(), stage,
61
+ kind: ev.kind || "stage", name: String(ev.name || "").slice(0, 200),
62
+ detail: String(ev.detail == null ? "" : ev.detail).slice(0, 2000),
63
+ ms: typeof ev.ms === "number" ? Math.round(ev.ms) : null, ok: ev.ok !== false,
64
+ });
65
+ } catch { /* 忽略 */ }
66
+ }
67
+
68
+ export async function advance(rcx) {
69
+ const { run, runDir, executors } = rcx;
70
+ // 处于复核门等待时不再推进,避免重复执行门阶段(违反不变式并浪费执行)
71
+ if (run.status === "awaiting_review") return;
72
+ // 一次调用持续推进,直到遇上复核门(awaiting_review)或全部通过(completed);
73
+ // 阶段失败则立即停在该阶段(failed)。每次状态变化都同步落盘 run.json。
74
+ for (;;) {
75
+ // 每个阶段开始前查盘:外部 stop/delete 改写了 run.json 时立即终止推进
76
+ const disk = loadRun(runDir);
77
+ if (!disk || disk.status === "stopped" || disk.status === "deleted") { Object.assign(run, disk || { status: "stopped" }); return; }
78
+ const nextId = MAIN_FLOW.find((id) => run.stages[id].status !== "approved");
79
+ if (!nextId) { run.status = "completed"; saveRun(runDir, run); return; }
80
+ const st = run.stages[nextId];
81
+ run.current = nextId; run.status = "running"; st.status = "running";
82
+ st.startedAt = new Date().toISOString();
83
+ saveRun(runDir, run);
84
+ appendEvent(runDir, nextId, { kind: "stage", name: nextId + " 开始", detail: STAGES.find((s) => s.id === nextId)?.name || "" });
85
+ const t0 = Date.now();
86
+ try {
87
+ const result = await executors[nextId](rcx);
88
+ // 执行器运行期间可能被外部 stop/delete:落盘前复查,避免把 running 写回覆盖 stopped
89
+ const diskAfter = loadRun(runDir);
90
+ if (!diskAfter || diskAfter.status === "stopped" || diskAfter.status === "deleted") {
91
+ Object.assign(run, diskAfter || { status: "stopped" });
92
+ return;
93
+ }
94
+ st.artifact = result && result.artifact;
95
+ if (result && result.external) st.external = true; // session 模式:实施移交外部会话
96
+ st.finishedAt = new Date().toISOString();
97
+ appendSpan(runDir, { span: nextId, ms: Date.now() - t0, decision: (result && result.summary) || "" });
98
+ st.status = isGate(run, nextId) ? "awaiting_review" : "approved";
99
+ run.status = st.status === "approved" ? "running" : "awaiting_review";
100
+ // external 阶段的实施不在本阶段完成,事件文案不能写"完成"(避免误导为已交付)
101
+ const doneLabel = result && result.external
102
+ ? nextId + " 任务包已生成 · 等待外部会话执行"
103
+ : nextId + (st.status === "awaiting_review" ? " 完成 · 待复核" : " 完成");
104
+ appendEvent(runDir, nextId, { kind: "stage", name: doneLabel,
105
+ detail: (result && result.summary) || "", ms: Date.now() - t0 });
106
+ } catch (e) {
107
+ st.status = "failed"; st.error = String((e && e.message) || e);
108
+ run.status = "failed";
109
+ appendSpan(runDir, { span: nextId, ms: Date.now() - t0, error: st.error });
110
+ appendEvent(runDir, nextId, { kind: "stage", name: nextId + " 失败", detail: st.error, ms: Date.now() - t0, ok: false });
111
+ saveRun(runDir, run);
112
+ return;
113
+ }
114
+ saveRun(runDir, run);
115
+ if (st.status === "awaiting_review") return;
116
+ }
117
+ }
118
+
119
+ export function applyReview(rcx, { decision, comment }) {
120
+ const { run, runDir } = rcx;
121
+ const id = run.current;
122
+ const st = run.stages[id];
123
+ if (!st || st.status !== "awaiting_review") return [false, "当前阶段不在待复核状态"];
124
+ if (decision === "reject" && !String(comment || "").trim()) return [false, "打回必须填写复核意见"];
125
+ if (decision !== "approve" && decision !== "reject") return [false, "decision 仅允许 approve|reject"];
126
+ // 委托模式(P6 session/claude、其余阶段配置页委托开关):实施在插件外完成。
127
+ // 空手放行会让下游拿不到产物而必然失败,因此通过前必须看到该阶段产出。
128
+ // 按 p6Mode/stageConfig 推导(不依赖 st.external 字段):旧版本创建的 run 也能被拦截。
129
+ if (decision === "approve" && stageDelegated(rcx, id) && !delegateReady(runDir, id)) {
130
+ const isP6 = id === "P6";
131
+ const output = isP6 ? "patches/*.diff 或 coder-report.json" : (STAGE_DEFS[id]?.delegateSpec?.output || "阶段产物");
132
+ const mode = isP6 ? ((run.p6Mode || "session") + " 模式") : "委托模式";
133
+ return [false, id + " " + mode + ":外部执行方尚未产出(未检测到 " + output + ")。请先在外部智能体执行任务包,产出落盘后再通过复核门"];
134
+ }
135
+ const record = { stage: id, decision, comment: comment || "", at: new Date().toISOString() };
136
+ writeArtifact(runDir, `reviews/${timestamp()}-${decision}-${id}.json`, JSON.stringify(record, null, 2));
137
+ appendEvent(runDir, id, { kind: "stage", name: id + (decision === "approve" ? " 复核通过" : " 复核打回"),
138
+ detail: comment || "无意见" });
139
+ if (decision === "approve") {
140
+ st.status = "approved";
141
+ run.status = "running";
142
+ rcx.reviewComment = "";
143
+ } else {
144
+ st.status = "pending";
145
+ st.attempts += 1;
146
+ run.status = "running";
147
+ rcx.reviewComment = comment;
148
+ }
149
+ saveRun(runDir, run);
150
+ return [true, decision === "approve" ? "已通过" : "已打回,将带意见重跑"];
151
+ }