@jayyuen66/dsh-quality-gate 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.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/client.js +479 -0
- package/cordis.patch.yml +20 -0
- package/host.js +1248 -0
- package/icon.svg +1 -0
- package/package.json +101 -0
package/host.js
ADDED
|
@@ -0,0 +1,1248 @@
|
|
|
1
|
+
// GENERATED by build-host.mjs — edit host.ts / lib/*.ts, then run `node build-host.mjs`.
|
|
2
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
6
|
+
import { credentialRef, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
|
|
7
|
+
import { LOCALE_SETTINGS_NAMESPACE, messagesFor, resolveLocalePreference } from "@jayyuen66/dsh-plugin-shared/lib/locale";
|
|
8
|
+
import { deriveProjectKey } from "@jayyuen66/dsh-plugin-shared/lib/project-key";
|
|
9
|
+
import { editPathOf, scanToolEvents } from "@jayyuen66/dsh-plugin-shared/lib/tool-events";
|
|
10
|
+
import { settleLessonCall } from "@jayyuen66/dsh-plugin-shared/lib/lesson-bus";
|
|
11
|
+
import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
|
|
12
|
+
//#region lib/accumulator.ts
|
|
13
|
+
/**
|
|
14
|
+
* 需要过门禁的扩展名(代码与配置;md/txt/图片等免检)。
|
|
15
|
+
*
|
|
16
|
+
* **不含 `.sh`**:四种门禁命令(tsc / ruff / cargo / pnpm check)没有一个能校验 shell
|
|
17
|
+
* 脚本,`.sh` 只会把根解析到最近的 package.json——`~/.dsh/run-all-tests.sh` 因此把门禁
|
|
18
|
+
* 拖到用户主目录那个 `npm init` 空壳上跑出假失败(本机实测)。
|
|
19
|
+
*/
|
|
20
|
+
const GATED_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|json|ya?ml|toml)$/iu;
|
|
21
|
+
/** 编辑类工具名。str_replace_editor 是 dsh-base 并存的另一套编辑器
|
|
22
|
+
* (host 侧只对 create/str_replace/insert 记账,view 只读不入这里)。 */
|
|
23
|
+
const EDIT_TOOLS = /* @__PURE__ */ new Set([
|
|
24
|
+
"edit",
|
|
25
|
+
"write",
|
|
26
|
+
"str_replace_editor"
|
|
27
|
+
]);
|
|
28
|
+
const DEFAULT_MAX_FILES = 500;
|
|
29
|
+
/** 回合内编辑文件累积器:note O(1),drain 一次性取出并清空。 */
|
|
30
|
+
var EditAccumulator = class {
|
|
31
|
+
files = /* @__PURE__ */ new Set();
|
|
32
|
+
maxFiles;
|
|
33
|
+
/** 容量淘汰累计丢弃数:drain 时清零(C10:500-cap 静默淘汰可观测)。 */
|
|
34
|
+
dropped = 0;
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
|
37
|
+
}
|
|
38
|
+
/** 记一笔:编辑类工具 + 代码/配置扩展名 + 非空路径才收;容量淘汰时计数。 */
|
|
39
|
+
note(name, filePath) {
|
|
40
|
+
if (name === void 0 || !EDIT_TOOLS.has(name)) return;
|
|
41
|
+
if (typeof filePath !== "string" || filePath.length === 0) return;
|
|
42
|
+
if (!GATED_EXT.test(filePath)) return;
|
|
43
|
+
if (this.files.size >= this.maxFiles && !this.files.has(filePath)) this.evictOldest();
|
|
44
|
+
this.files.add(filePath);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 淘汰插入序最旧的一项并计数。调用点已保证集合非空(`size >= maxFiles >= 1`,本包
|
|
48
|
+
* maxFiles 只取默认 500 或测试里的 2),所以原意是**取首项这一次动作**,不是迭代——
|
|
49
|
+
* 旧写法拿 `for…of` + 无条件 `break` 伪装单次取值,那正是 eslint(no-unreachable-loop)
|
|
50
|
+
* 判的「循环体只允许一次迭代」:循环结构在这里没有承载任何语义。改成直接取首个元素,
|
|
51
|
+
* 单次淘汰的行为(删最旧 + 计数)与原样一致。
|
|
52
|
+
*/
|
|
53
|
+
evictOldest() {
|
|
54
|
+
const [oldest] = this.files;
|
|
55
|
+
if (oldest === void 0) return;
|
|
56
|
+
this.files.delete(oldest);
|
|
57
|
+
this.dropped += 1;
|
|
58
|
+
}
|
|
59
|
+
/** 自上次 drain 以来容量淘汰丢弃的文件数(C10:>0 时 host 侧 console.warn)。 */
|
|
60
|
+
droppedCount() {
|
|
61
|
+
return this.dropped;
|
|
62
|
+
}
|
|
63
|
+
/** 取出全部去重文件并清空(Stop 批处理后调用;重复调用得空表;同时清零淘汰计数)。 */
|
|
64
|
+
drain() {
|
|
65
|
+
const out = [...this.files];
|
|
66
|
+
this.files.clear();
|
|
67
|
+
this.dropped = 0;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region lib/gate-detect.ts
|
|
73
|
+
/**
|
|
74
|
+
* `pnpm check` 是否可用:必须有已声明的 check 脚本,**信息缺失时一律判否**。
|
|
75
|
+
*
|
|
76
|
+
* 为什么取保守方向(宁可不给门禁,也不发这条命令):没有声明 `check` 的 pnpm 工程
|
|
77
|
+
* 是绝大多数,`pnpm check` 会以「Command "check" not found」非零退出;而本包把门禁
|
|
78
|
+
* 失败当**真实质量缺陷**处理——开新回合注入"修这些错"、写 lesson-loop 总线、可选写
|
|
79
|
+
* 记忆环。于是对一个代码本来就没问题的使用者,这是一条稳定复现的假失败注入。
|
|
80
|
+
* 反方向的代价只是"这个根本轮没有自动核验"(读不到 package.json 的工程本就少见),
|
|
81
|
+
* 用户可以自己跑检查。两害相权:**假失败比漏检查更贵**。
|
|
82
|
+
*/
|
|
83
|
+
function pnpmCheckReady(scripts) {
|
|
84
|
+
return scripts?.includes("check") === true;
|
|
85
|
+
}
|
|
86
|
+
/** 从 ProjectRoot(根目录相对名清单 + 已声明脚本)判定单根门禁。 */
|
|
87
|
+
function gateForRoot(entry) {
|
|
88
|
+
const { root, files, scripts } = entry;
|
|
89
|
+
const has = (name) => files.includes(name);
|
|
90
|
+
let gate;
|
|
91
|
+
if (has("pnpm-workspace.yaml") && has("package.json") && pnpmCheckReady(scripts)) gate = {
|
|
92
|
+
root,
|
|
93
|
+
command: ["pnpm", "check"]
|
|
94
|
+
};
|
|
95
|
+
else if (has("Cargo.toml")) gate = {
|
|
96
|
+
root,
|
|
97
|
+
command: ["cargo", "check"]
|
|
98
|
+
};
|
|
99
|
+
else if (has("pyproject.toml") || has("ruff.toml")) gate = {
|
|
100
|
+
root,
|
|
101
|
+
command: [
|
|
102
|
+
"ruff",
|
|
103
|
+
"check",
|
|
104
|
+
"."
|
|
105
|
+
]
|
|
106
|
+
};
|
|
107
|
+
else if (has("package.json") && has("tsconfig.json")) gate = {
|
|
108
|
+
root,
|
|
109
|
+
command: [
|
|
110
|
+
"npx",
|
|
111
|
+
"tsc",
|
|
112
|
+
"--noEmit"
|
|
113
|
+
]
|
|
114
|
+
};
|
|
115
|
+
return gate;
|
|
116
|
+
}
|
|
117
|
+
/** 多根归并探测:每个根独立判定,无清单的根跳过(返回 undefined)。 */
|
|
118
|
+
function detectGate(roots) {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const entry of roots) {
|
|
121
|
+
const gate = gateForRoot(entry);
|
|
122
|
+
if (gate !== void 0) out.push(gate);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region lib/messages.ts
|
|
128
|
+
const MESSAGES = {
|
|
129
|
+
zh: {
|
|
130
|
+
evidenceTruncated: "[证据截断] {label} 内存里只保留尾部(首个错误可能在被丢弃的头部)。完整输出:{spill}",
|
|
131
|
+
noSpillFile: "无落盘文件",
|
|
132
|
+
evidenceHeadTail: "—— 头部摘录(根因多在此)——\n{head}\n—— 尾部 ——\n{text}",
|
|
133
|
+
gateFailed: "[quality-gate] 回合收口门禁未通过({command},根 {root}):\n{output}\n请修复上述问题后结束回合。不要重做已完成的工作,只修门禁报出的错误。",
|
|
134
|
+
gateNotRun: "[quality-gate] 回合收口门禁**未能执行**({command},根 {root}):\n{reason}\n这不是代码错误的证据:**不要为此修改代码**,也不要把它当成已通过。请在结束本回合前手动运行该命令确认代码健康,或向用户说明门禁无法执行的原因。",
|
|
135
|
+
gaveUp: "[quality-gate] 已自动注入 {attempts} 次修复指令,门禁({command},根 {root})仍未通过。为避免无限消耗 token,本轮停止自动修复并放行。请手动运行该命令确认代码健康;若确需修复,请明确告知用户需要人工介入,不要反复重试同一门禁。",
|
|
136
|
+
gatesSkipped: "剩余预算不足 {budgetMs}ms,以下门禁本轮未执行:\n{list}\n这些根的代码健康**尚未核验**,不要把它们当已通过。",
|
|
137
|
+
skippedGateItem: "- {command}(根 {root})",
|
|
138
|
+
sandboxMode: "沙箱模式={mode}",
|
|
139
|
+
sandboxEnforcement: ",enforcement={enforcement}",
|
|
140
|
+
runnerFailed: "沙箱 runner 启动失败,命令根本没跑({sandbox})。",
|
|
141
|
+
policyDenied: "沙箱策略拒绝了对门禁工作目录的写入({sandbox}):命令未获准真正执行。",
|
|
142
|
+
timedOut: "exit=timeout({seconds}s 预算内未完成),门禁未得到结果。",
|
|
143
|
+
aborted: "门禁执行被调用方取消(aborted),结果未知。",
|
|
144
|
+
toolMissing: "命令不存在(工具未安装)",
|
|
145
|
+
toolNotExecutable: "命令不可执行",
|
|
146
|
+
toolNotRun: "{cause},门禁没有运行。",
|
|
147
|
+
toolStub: "门禁工具本身不可用(npx 抓到的是 stub 包,不是真工具)。",
|
|
148
|
+
policyUnresolvable: "沙箱策略无法解析({message}),门禁拒绝在未确认的沙箱边界下执行。",
|
|
149
|
+
sandboxUnavailable: "沙箱在本机不可用({message}):命令被拒绝执行,未做任何检查。",
|
|
150
|
+
gateStartFailed: "门禁未能启动({message})。",
|
|
151
|
+
memoryFeedback: "[quality-gate 反馈] 门禁失败 {command}(根 {root}):{failure}"
|
|
152
|
+
},
|
|
153
|
+
en: {
|
|
154
|
+
evidenceTruncated: "[evidence truncated] only the tail of {label} is kept in memory (the first error may be in the dropped head). Full output: {spill}",
|
|
155
|
+
noSpillFile: "no spill file",
|
|
156
|
+
evidenceHeadTail: "—— head excerpt (root cause usually here) ——\n{head}\n—— tail ——\n{text}",
|
|
157
|
+
gateFailed: "[quality-gate] the turn-end quality gate did not pass ({command}, root {root}):\n{output}\nFix the problems above before ending the turn. Do not redo work that is already done — fix only what the gate reported.",
|
|
158
|
+
gateNotRun: "[quality-gate] the turn-end quality gate **could not run** ({command}, root {root}):\n{reason}\nThis is not evidence of a code error: **do not change code because of it**, and do not treat it as passed. Run that command manually before ending the turn to confirm the code is healthy, or tell the user why the gate could not run.",
|
|
159
|
+
gaveUp: "[quality-gate] {attempts} repair instructions were injected automatically and the gate ({command}, root {root}) still does not pass. To avoid burning tokens forever, automatic repair stops here and this turn is released. Run the command manually to confirm the code is healthy; if a fix really is needed, tell the user explicitly that human intervention is required instead of retrying the same gate.",
|
|
160
|
+
gatesSkipped: "Less than {budgetMs}ms of budget remains, so these gates did not run this turn:\n{list}\nThe code health of these roots **has not been verified** — do not treat them as passed.",
|
|
161
|
+
skippedGateItem: "- {command} (root {root})",
|
|
162
|
+
sandboxMode: "sandbox mode={mode}",
|
|
163
|
+
sandboxEnforcement: ", enforcement={enforcement}",
|
|
164
|
+
runnerFailed: "The sandbox runner failed to start, so the command never ran ({sandbox}).",
|
|
165
|
+
policyDenied: "The sandbox policy denied writes to the gate's working directory ({sandbox}): the command was never allowed to actually run.",
|
|
166
|
+
timedOut: "exit=timeout (not finished within the {seconds}s budget), the gate got no result.",
|
|
167
|
+
aborted: "Gate execution was cancelled by the caller (aborted); the result is unknown.",
|
|
168
|
+
toolMissing: "command not found (tool not installed)",
|
|
169
|
+
toolNotExecutable: "command not executable",
|
|
170
|
+
toolNotRun: "{cause} — the gate did not run.",
|
|
171
|
+
toolStub: "The gate tool itself is unavailable (npx fetched a stub package, not the real tool).",
|
|
172
|
+
policyUnresolvable: "The sandbox policy could not be resolved ({message}); the gate refuses to run under an unconfirmed sandbox boundary.",
|
|
173
|
+
sandboxUnavailable: "The sandbox is unavailable on this machine ({message}): the command was denied and no check was performed.",
|
|
174
|
+
gateStartFailed: "The gate failed to start ({message}).",
|
|
175
|
+
memoryFeedback: "[quality-gate feedback] gate failure {command} (root {root}): {failure}"
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
/** 渲染模板里的 {name} 占位符;未提供的名字留空串(宁可少一段说明,也不给模型留裸花括号)。
|
|
179
|
+
* 与 client 半官方 `@deepseek-ai/dsh-client-locale` 的 Translate 插值同语义(宿主同规则)。
|
|
180
|
+
* @param text - 字典里的整行模板。
|
|
181
|
+
* @param params - 占位符名 → 值(数字会转成字符串)。
|
|
182
|
+
* @returns 替换后的文本。
|
|
183
|
+
*/
|
|
184
|
+
function renderTemplate(text, params) {
|
|
185
|
+
return text.replaceAll(/\{(?<key>\w+)\}/gu, (_all, key) => {
|
|
186
|
+
const value = params[key];
|
|
187
|
+
return value === void 0 ? "" : String(value);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region lib/feedback-content.ts
|
|
192
|
+
/** 门禁失败 → 记忆正文:单行化(防换行噪音)。
|
|
193
|
+
* 去截断(用户拍板"不为省 token 降能力"):旧实现 400 字符截尾会把
|
|
194
|
+
* 多根错误的中间根因吃掉;记忆网关的入库限额由网关侧把关,这里送全文。
|
|
195
|
+
* 正文语言随官方 locale 偏好走(messages 由调用方按回合取出后传进来)——这条文本会被
|
|
196
|
+
* auto-recall 注入后续会话的上下文,是模型读的,不是日志。 */
|
|
197
|
+
function buildFeedbackText({ command, root, failure }, messages) {
|
|
198
|
+
const cmd = command.join(" ");
|
|
199
|
+
const tail = failure.replaceAll(/\s+/gu, " ").trim();
|
|
200
|
+
return renderTemplate(messages.memoryFeedback, {
|
|
201
|
+
command: cmd,
|
|
202
|
+
root,
|
|
203
|
+
failure: tail
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region lib/gateway-target.ts
|
|
208
|
+
/** env 值 trim 后非空则用之,否则回到 fallback(保留原 `||` 语义,
|
|
209
|
+
* 又以显式比较规避 prefer-nullish-coalescing 与 strict-boolean-expressions)。 */
|
|
210
|
+
function envOr(key, fallback) {
|
|
211
|
+
const trimmed = process.env[key]?.trim();
|
|
212
|
+
return trimmed !== void 0 && trimmed !== "" ? trimmed : fallback;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* 网关基址:设置值优先,留空时回落到 TDAI_GATEWAY_URL(逃生门),**两处都空则为空串**。
|
|
216
|
+
* 空串即"未配置"——调用方据此不启用记忆反馈。默认值不再是任何人的本机地址
|
|
217
|
+
* (旧实现把作者机器上的 http://127.0.0.1:8420 写死成默认,别人每次门禁失败都去连一个
|
|
218
|
+
* 不存在的服务)。设置优先于 env 的理由:设置卡是用户显式配置面,env 只能兜底,
|
|
219
|
+
* 否则用户改了设置卡仍被一个看不见的环境变量越权。
|
|
220
|
+
*/
|
|
221
|
+
function resolveGatewayUrl(settingUrl) {
|
|
222
|
+
const configured = settingUrl.trim();
|
|
223
|
+
return (configured === "" ? envOr("TDAI_GATEWAY_URL", "") : configured).replace(/\/+$/u, "");
|
|
224
|
+
}
|
|
225
|
+
/** x-tdai-service-id:env 可覆盖,默认是网关协议的匿名服务名(不含任何机器信息)。 */
|
|
226
|
+
function resolveServiceId() {
|
|
227
|
+
return envOr("TDAI_SERVICE_ID", "default");
|
|
228
|
+
}
|
|
229
|
+
/** cwd → agent_id:'尾目录名-<cwd 前 8 位 sha256>'(与网关插件 workspace-peer 同分桶语义,
|
|
230
|
+
* 读/写同键,保证反馈沉淀进 auto-recall 同一记忆桶)。
|
|
231
|
+
* 派生本体在 shared/lib/project-key.ts:lesson-loop 的 project 与这里同源一份实现,
|
|
232
|
+
* 此前两处各存一份,改一处漏一处就把"教训 ↔ 记忆"的跨系统对照拆断了。 */
|
|
233
|
+
function deriveAgentId(cwd) {
|
|
234
|
+
return deriveProjectKey(cwd);
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region lib/gateway-feedback.ts
|
|
238
|
+
/** 反馈尽力而为:5s 封顶,超时/失败只 warn(不吃掉门禁主流程)。 */
|
|
239
|
+
const FEEDBACK_TIMEOUT_MS = 5e3;
|
|
240
|
+
/** 沉淀门禁失败到 L0 记忆(role=assistant:这是 agent 自身行为教训,归顺 assistant 提炼面)。 */
|
|
241
|
+
async function pushGateFeedback(input, target, messages) {
|
|
242
|
+
const agentId = deriveAgentId(input.cwd);
|
|
243
|
+
const content = buildFeedbackText(input, messages);
|
|
244
|
+
const payload = JSON.stringify({
|
|
245
|
+
session_id: "dsh-agent",
|
|
246
|
+
agent_id: agentId,
|
|
247
|
+
messages: [{
|
|
248
|
+
role: "assistant",
|
|
249
|
+
content
|
|
250
|
+
}]
|
|
251
|
+
});
|
|
252
|
+
const res = await fetch(`${target.url}/v2/conversation/add`, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: {
|
|
255
|
+
Authorization: `Bearer ${target.key}`,
|
|
256
|
+
"x-tdai-service-id": resolveServiceId(),
|
|
257
|
+
"Content-Type": "application/json"
|
|
258
|
+
},
|
|
259
|
+
body: payload,
|
|
260
|
+
signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS)
|
|
261
|
+
});
|
|
262
|
+
if (!res.ok) throw new Error(`gateway feedback HTTP ${res.status}`);
|
|
263
|
+
await res.arrayBuffer().catch(() => null);
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region host.ts
|
|
267
|
+
/**
|
|
268
|
+
* 常见项目根标记文件:向上找最近的含清单目录。
|
|
269
|
+
* ruff.toml 必须在列(审计 LOW-MED 项):只有 ruff 配置、无 pyproject.toml 的 Python
|
|
270
|
+
* 工程若不在此登记,gate-detect 的 has("ruff.toml") 就是死码——那类工程根本得不到根,
|
|
271
|
+
* 也就永远没有门禁。
|
|
272
|
+
*/
|
|
273
|
+
const MANIFESTS = [
|
|
274
|
+
"package.json",
|
|
275
|
+
"Cargo.toml",
|
|
276
|
+
"pyproject.toml",
|
|
277
|
+
"pnpm-workspace.yaml",
|
|
278
|
+
"ruff.toml"
|
|
279
|
+
];
|
|
280
|
+
/** 本插件 followup 注入消息的 producer-owned source.kind(session-rescue 的
|
|
281
|
+
* RESCUE_SOURCE_KIND 同款)。0.1.7 的 V4 准入拒收退役包装
|
|
282
|
+
* `{ kind: 'plugin', plugin }`(session-format-v3-to-v4/src/message-sources.ts
|
|
283
|
+
* 对每个声明的持久消息位抛 "format v4 message requires a producer-owned source
|
|
284
|
+
* kind",followup 经 inbox 落 `agent/inbox/spliced`,正是被拒的持久位);
|
|
285
|
+
* 迁移表把未知插件名统一加 `plugin:` 前缀(sources.ts producerKind),故本插件
|
|
286
|
+
* 已迁移的历史行读回也是同一个串——发出同一串,新行与历史行才是同一个身份。 */
|
|
287
|
+
const GATE_SOURCE_KIND = "plugin:quality-gate";
|
|
288
|
+
/**
|
|
289
|
+
* "门禁跑了且报了代码错误"这一态的 kind 值(注入分流与 lesson 总线 category 都读它)。
|
|
290
|
+
* 判别联合的类型面经 `typeof` 绑同一处,避免字面量两处各自漂移。
|
|
291
|
+
*/
|
|
292
|
+
const GATE_KIND_CODE_FAILURE = "code-failure";
|
|
293
|
+
/** 设置面(= profile 条目 `quality-gate` 的可编辑表单)与 loader 行 config 共用同一份
|
|
294
|
+
* schema(单源,防漂移)。六个字段**全部** `.volatile()`:没有任何 volatile 字段的条目
|
|
295
|
+
* 会被宿主 describe() 整条跳过(packages/settings/settings/src/index.ts:308-309),
|
|
296
|
+
* 写入则抛 `has no volatile fields`(:386)。
|
|
297
|
+
* 两个策略陷阱(都锁在下面对应的行内注释里,改默认前先读):
|
|
298
|
+
* - `memoryGatewayUrl` 的默认是**空串**而不是缺省:`""` + feedback-off = 永不推送,
|
|
299
|
+
* 换成 undefined 会把"未配置"变成"字段不存在",行为不再等价;
|
|
300
|
+
* - `memoryGatewayKeyRef` 的 `.role('credential-ref')` 与 `.volatile()` **共存**:role 只
|
|
301
|
+
* 写 meta.role(fork 的 role/volatile 都是 `{...meta, k: v}` 展开,见
|
|
302
|
+
* vendor/schemastery/src/index.ts:463-482),不会抹掉 meta.volatile。 */
|
|
303
|
+
const Config = Schema.object({
|
|
304
|
+
enabled: Schema.boolean().default(true).volatile(),
|
|
305
|
+
maxInjectsPerTurn: Schema.natural().min(1).max(5).default(2).volatile(),
|
|
306
|
+
gateBudgetMs: Schema.natural().min(1e4).max(6e5).default(3e5).volatile(),
|
|
307
|
+
memoryFeedback: Schema.boolean().default(false).volatile(),
|
|
308
|
+
memoryGatewayUrl: Schema.string().default("").volatile(),
|
|
309
|
+
memoryGatewayKeyRef: Schema.string().role("credential-ref").default("TDAI_GATEWAY_KEY").volatile(),
|
|
310
|
+
/** 向上找项目根的最大层数(深嵌套 monorepo 的探测深度)。 */
|
|
311
|
+
maxRootDepth: Schema.natural().default(10),
|
|
312
|
+
/** 门禁 stdout 捕获上限(字节):超过则 harness 只保留尾部(见 CollectedOutput)。 */
|
|
313
|
+
gateStdoutMaxBytes: Schema.natural().default(65536)
|
|
314
|
+
});
|
|
315
|
+
/** 总预算尾段的最小可用门预算:低于此值再跑门禁必然超时(假失败),跳过剩余门禁。 */
|
|
316
|
+
const MIN_USEFUL_GATE_MS = 5e3;
|
|
317
|
+
/** 截断证据的头部摘录上限(字节):从 spill 文件里**有界**读回开头(根因所在)。 */
|
|
318
|
+
const EVIDENCE_HEAD_BYTES = 4096;
|
|
319
|
+
/** harness SandboxUnavailableError 的错误码(run() 抛出即"命令根本没跑")。 */
|
|
320
|
+
const SANDBOX_UNAVAILABLE = "SANDBOX_UNAVAILABLE";
|
|
321
|
+
/** 会话级 Map 的条数上限:超限按插入序淘汰最旧(防长期运行进程无界增长)。 */
|
|
322
|
+
const SESSION_BUDGET = 50;
|
|
323
|
+
/** 从 unknown 安全读字段:字面量键走变量参数,绕开 dot-notation 与
|
|
324
|
+
* noPropertyAccessFromIndexSignature(tsc 禁索引签名点访问)的互斥。 */
|
|
325
|
+
/** 错误摘要(String(error) 对非 Error 会打出 [object Object],这里显式分类)。 */
|
|
326
|
+
function messageOf(error) {
|
|
327
|
+
let message = "unknown error";
|
|
328
|
+
if (error instanceof Error) ({message} = error);
|
|
329
|
+
else if (typeof error === "string") message = error;
|
|
330
|
+
return message;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* 会话级 Map 淘汰:把 keep 先删后插移到队尾(JS Map 迭代按插入序),再淘汰队首
|
|
334
|
+
* (size - 50) 项 → keep 永不被淘汰,且总大小收敛。泛型复用:注入配额 Map(InjectState)、
|
|
335
|
+
* 事件游标 Map(number)与编辑累加器 Map(EditAccumulator)共用同一策略。
|
|
336
|
+
* keep 不在表内 → 直接返回(inbox-busy 分支的 injectState 就会命中:该会话从未失败过)。
|
|
337
|
+
*/
|
|
338
|
+
function pruneToSessionBudget(map, keep) {
|
|
339
|
+
const kept = map.get(keep);
|
|
340
|
+
if (kept === void 0) return;
|
|
341
|
+
map.delete(keep);
|
|
342
|
+
map.set(keep, kept);
|
|
343
|
+
let excess = map.size - SESSION_BUDGET;
|
|
344
|
+
for (const key of map.keys()) {
|
|
345
|
+
if (excess <= 0) break;
|
|
346
|
+
map.delete(key);
|
|
347
|
+
excess -= 1;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* 根会话判定(与 ctx-observe/lesson-loop 同款口径):子代理会话
|
|
352
|
+
* (delegationDepth>0 或 origin==='subagent')跳过门禁。
|
|
353
|
+
*
|
|
354
|
+
* agent/turn-stopping 对每个 agent(含 in-process fork/spawn 子代理)都触发,
|
|
355
|
+
* 旧实现未过滤子代理 → ①父会话与子代理对同一项目根各跑一遍门禁(token/耗时翻倍);
|
|
356
|
+
* ②失败注入文案面向用户,却 followup 进子代理——结算前到达会多拖一轮子代理回合,
|
|
357
|
+
* 结算后到达则滞留 inbox 被丢弃(门禁静默失效)。统一改为只对根会话收口。
|
|
358
|
+
*
|
|
359
|
+
* out-of-process 子代理(ACP/claude-code/codex)
|
|
360
|
+
* 在独立进程运行,不发射 dsh 的 agent 事件,其编辑天然不在任何门禁面内;in-process
|
|
361
|
+
* 子代理经本判定跳过后,其编辑由父会话下一回合的收口门禁在父侧统一核验(未来如需
|
|
362
|
+
* 精确到子代理编辑,可在父会话聚合子会话 tool/call——本插件不做,避免过度设计)。
|
|
363
|
+
*/
|
|
364
|
+
function isRootSession(agent) {
|
|
365
|
+
const { header } = agent.session;
|
|
366
|
+
return !((header.delegationDepth ?? 0) > 0 || header.origin === "subagent");
|
|
367
|
+
}
|
|
368
|
+
/** Inbox 是否有待处理消息(读面 = `InboxQueues`:两位队列都可能缺席,故 `?.length ?? 0`)。 */
|
|
369
|
+
function inboxHasPending(inbox) {
|
|
370
|
+
return (inbox.nextTurn?.length ?? 0) > 0 || (inbox.nextStep?.length ?? 0) > 0;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* 触发前二次校验:turn-stopping 时 agent.status 恒为 'running'
|
|
374
|
+
* (官方 agent.ts:turn-stopping 在 running 相内 serial 分发,转 idle 发生在回合结束之后),
|
|
375
|
+
* 故绝不能用 status 判 idle。改用数据信号:turn signal 已取消、或 inbox 有待处理
|
|
376
|
+
* (用户接管/已排队工作)→ 不注入(对齐官方 "Data decides" 语义)。
|
|
377
|
+
* 插件卸载的拦截不在这里:W4 起 disposer abort 在飞门禁批次,迟到结果在批次层就地丢弃
|
|
378
|
+
* (见 processGate),到不了注入点——卸载收口比"注入前拦一下"更强。
|
|
379
|
+
*/
|
|
380
|
+
function preInjectCheck(agent, signal) {
|
|
381
|
+
if (signal?.aborted === true) return {
|
|
382
|
+
ok: false,
|
|
383
|
+
reason: "turn aborted"
|
|
384
|
+
};
|
|
385
|
+
if (inboxHasPending(agent.inbox)) return {
|
|
386
|
+
ok: false,
|
|
387
|
+
reason: "inbox busy"
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
ok: true,
|
|
391
|
+
reason: "ready"
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* 判定门禁失败是否为"工具本身不可用"(而非代码问题)。
|
|
396
|
+
*
|
|
397
|
+
* `npx tsc` 在项目未装 typescript 时会**自动安装**已弃用的 stub 包 `tsc@2.0.4`,
|
|
398
|
+
* 它打印横幅并以 exit 1 退出——不是 126/127,所以只按退出码判定会误报为代码失败。
|
|
399
|
+
* 实测:`npx --no-install` 在本机拦不住 stub(已从 npx 缓存解析),
|
|
400
|
+
* 所以必须在输出侧识别,不能只靠加 flag。
|
|
401
|
+
*/
|
|
402
|
+
function gateToolUnavailable(stdout, stderr) {
|
|
403
|
+
const out = `${stdout}\n${stderr}`.toLowerCase();
|
|
404
|
+
if (out.includes("not the tsc command you are looking for")) return true;
|
|
405
|
+
return out.includes("npm warn deprecated tsc@");
|
|
406
|
+
}
|
|
407
|
+
/** harness run() 抛出的 SandboxUnavailableError(code 或 name 命中即可)。 */
|
|
408
|
+
function isSandboxUnavailable(error) {
|
|
409
|
+
return fieldOf(error, "code") === SANDBOX_UNAVAILABLE || fieldOf(error, "name") === "SandboxUnavailableError";
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* 有界读取截断落盘文件的头部(首个错误通常在开头,而 harness 只把尾部留在内存里;
|
|
413
|
+
* spill 文件可达数 MB,故用 openSync/readSync 只读前 EVIDENCE_HEAD_BYTES)。
|
|
414
|
+
* 不可读(无权限/已回收/非绝对路径)→ undefined:调用方只标注截断,不伪造头部。
|
|
415
|
+
*/
|
|
416
|
+
function headExcerpt(file) {
|
|
417
|
+
let excerpt;
|
|
418
|
+
if (file !== void 0 && path.isAbsolute(file)) try {
|
|
419
|
+
const fd = openSync(file, "r");
|
|
420
|
+
try {
|
|
421
|
+
const buffer = Buffer.alloc(EVIDENCE_HEAD_BYTES);
|
|
422
|
+
const bytesRead = readSync(fd, buffer, 0, EVIDENCE_HEAD_BYTES, 0);
|
|
423
|
+
excerpt = buffer.subarray(0, bytesRead).toString("utf8");
|
|
424
|
+
} finally {
|
|
425
|
+
closeSync(fd);
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
excerpt = void 0;
|
|
429
|
+
}
|
|
430
|
+
return excerpt;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* 证据文本。
|
|
434
|
+
*
|
|
435
|
+
* tsc/cargo 等错误自顶向下,**首条才是根因**,而 harness CollectedOutput 截断时保留的是
|
|
436
|
+
* **尾部**(packages/subprocess/src/types.ts)——旧实现从不查 truncated,根因错误被静默
|
|
437
|
+
* 丢掉,注入给模型的"修这些错"只剩残尾(审计 MEDIUM 项)。现在:
|
|
438
|
+
* ①显式标注截断;②从 spillPath 有界读回头部摘录放在最前;③无落盘文件时明确说明
|
|
439
|
+
* 首个错误可能已丢弃,让模型与用户都知道证据不完整。
|
|
440
|
+
* 未截断时不叠加第二层截断(用户拍板"不为省 token 降能力",上限由 shell 的
|
|
441
|
+
* stdoutMaxBytes 把关)。
|
|
442
|
+
*/
|
|
443
|
+
function evidenceText(messages, stream, label) {
|
|
444
|
+
if (!stream.truncated) return `${label}:\n${stream.text}`;
|
|
445
|
+
const head = headExcerpt(stream.spillPath);
|
|
446
|
+
const notice = `${renderTemplate(messages.evidenceTruncated, {
|
|
447
|
+
label,
|
|
448
|
+
spill: stream.spillPath ?? messages.noSpillFile
|
|
449
|
+
})}\n`;
|
|
450
|
+
let out = `${notice}${stream.text}`;
|
|
451
|
+
if (head !== void 0) out = `${notice}${renderTemplate(messages.evidenceHeadTail, {
|
|
452
|
+
head,
|
|
453
|
+
text: stream.text
|
|
454
|
+
})}`;
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
const gateText = (messages, gate, out) => renderTemplate(messages.gateFailed, {
|
|
458
|
+
command: gate.command.join(" "),
|
|
459
|
+
root: gate.root,
|
|
460
|
+
output: out
|
|
461
|
+
});
|
|
462
|
+
/**
|
|
463
|
+
* "门禁没跑成"文案:与"修这些错"严格分开——没跑成的检查不是代码错误的证据,
|
|
464
|
+
* 让模型去修一个装不上的工具/越不过去的沙箱,只会配着配额环烧 token。
|
|
465
|
+
*/
|
|
466
|
+
const notRunText = (messages, gate, reason) => renderTemplate(messages.gateNotRun, {
|
|
467
|
+
command: gate.command.join(" "),
|
|
468
|
+
root: gate.root,
|
|
469
|
+
reason
|
|
470
|
+
});
|
|
471
|
+
/** 配额耗尽后的"放行说明"(用户可见,不再静默 return)。 */
|
|
472
|
+
const giveUpText = (messages, gate, attempts) => renderTemplate(messages.gaveUp, {
|
|
473
|
+
attempts,
|
|
474
|
+
command: gate.command.join(" "),
|
|
475
|
+
root: gate.root
|
|
476
|
+
});
|
|
477
|
+
/** 未执行清单的一行(模块级:不让 renderTemplate 套进 map 再套进 renderTemplate)。 */
|
|
478
|
+
const skippedGateLine = (messages, gate) => renderTemplate(messages.skippedGateItem, {
|
|
479
|
+
command: gate.command.join(" "),
|
|
480
|
+
root: gate.root
|
|
481
|
+
});
|
|
482
|
+
/** 预算耗尽后剩余门禁的"未执行"清单(审计 MEDIUM 项:只 warn = 静默通过)。 */
|
|
483
|
+
const skippedText = (messages, gates) => renderTemplate(messages.gatesSkipped, {
|
|
484
|
+
budgetMs: MIN_USEFUL_GATE_MS,
|
|
485
|
+
list: gates.map((gate) => skippedGateLine(messages, gate)).join("\n")
|
|
486
|
+
});
|
|
487
|
+
/** 沙箱事实 → 可读的模式/完备度说明(策略拒绝与代码失败分开表述的素材)。 */
|
|
488
|
+
function sandboxNote(messages, facts) {
|
|
489
|
+
const mode = renderTemplate(messages.sandboxMode, { mode: facts.mode });
|
|
490
|
+
return facts.enforcement === void 0 ? mode : `${mode}${renderTemplate(messages.sandboxEnforcement, { enforcement: facts.enforcement })}`;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* 沙箱面拦截结论(runner 不可用 / 策略拒绝)→ not-run。
|
|
494
|
+
*
|
|
495
|
+
* 两条消息按官方事实的优先级二选一(runnerFailed 先),判定序与旧内联 if 完全一致;
|
|
496
|
+
* 没有拦截返回 undefined,交给退出码阶梯。
|
|
497
|
+
*/
|
|
498
|
+
function sandboxBlockOutcome(messages, facts, stderr) {
|
|
499
|
+
let outcome;
|
|
500
|
+
if (facts.runnerFailed === true || facts.denied) {
|
|
501
|
+
const sandbox = sandboxNote(messages, facts);
|
|
502
|
+
outcome = {
|
|
503
|
+
kind: "not-run",
|
|
504
|
+
text: `${facts.runnerFailed === true ? renderTemplate(messages.runnerFailed, { sandbox }) : renderTemplate(messages.policyDenied, { sandbox })}\n${evidenceText(messages, stderr, "stderr")}`
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return outcome;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* 进程没跑完的三种形态:超时 / 取消 / 没有退出码(被信号杀死)。
|
|
511
|
+
*
|
|
512
|
+
* 前两条按 not-run,被信号杀死按代码失败——与旧 ladder 的前三支同序同文案。
|
|
513
|
+
*/
|
|
514
|
+
function interruptedRunOutcome(messages, budgetMs, res) {
|
|
515
|
+
let outcome;
|
|
516
|
+
if (res.timedOut) outcome = {
|
|
517
|
+
kind: "not-run",
|
|
518
|
+
text: `${renderTemplate(messages.timedOut, { seconds: Math.round(budgetMs / 1e3) })}\n${evidenceText(messages, res.stdout, "stdout")}${evidenceText(messages, res.stderr, "stderr")}`
|
|
519
|
+
};
|
|
520
|
+
else if (res.aborted) outcome = {
|
|
521
|
+
kind: "not-run",
|
|
522
|
+
text: messages.aborted
|
|
523
|
+
};
|
|
524
|
+
else if (res.exitCode === null) {
|
|
525
|
+
const signalName = typeof res.signal === "string" && res.signal.length > 0 ? res.signal : "?";
|
|
526
|
+
outcome = {
|
|
527
|
+
kind: GATE_KIND_CODE_FAILURE,
|
|
528
|
+
text: `exit=signal(${signalName})\n${evidenceText(messages, res.stdout, "stdout")}${evidenceText(messages, res.stderr, "stderr")}`
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
return outcome;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* 门禁工具本身不可用(exit 126/127 = 不可执行/未找到)→ not-run,并 warn 给运维。
|
|
535
|
+
*
|
|
536
|
+
* 环境问题非代码问题:走失败注入会让模型去"修"装不了的工具(配合配额环更糟)。
|
|
537
|
+
* 但它同样是"没跑成的检查",所以按 not-run 注入"请手动确认",不再算作通过
|
|
538
|
+
* (审计:没跑成 ≠ 通过)。
|
|
539
|
+
*/
|
|
540
|
+
function unavailableToolOutcome(messages, gate, res, log) {
|
|
541
|
+
let outcome;
|
|
542
|
+
if (res.exitCode === 126 || res.exitCode === 127) {
|
|
543
|
+
log.warn(`[quality-gate] gate tool unavailable (exit ${res.exitCode}): ${gate.command.join(" ")} — reported as not-run, not as pass`);
|
|
544
|
+
const cause = res.exitCode === 127 ? messages.toolMissing : messages.toolNotExecutable;
|
|
545
|
+
outcome = {
|
|
546
|
+
kind: "not-run",
|
|
547
|
+
text: `${renderTemplate(messages.toolNotRun, { cause })}\n${evidenceText(messages, res.stderr, "stderr")}`
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
return outcome;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* 把一次 shell.execute().result() 结果分类成三态(纯函数:不进 ctx,每条分支都可测)。
|
|
554
|
+
*
|
|
555
|
+
* harness 的 ShellRunResult.sandbox 与退出码**正交**上报(packages/shell/shell/src/types.ts:
|
|
556
|
+
* "Facts are reported independently of process exit status so callers can distinguish
|
|
557
|
+
* command failures from policy denials and runner failures"),旧实现从不读它 →
|
|
558
|
+
* 策略拒绝被当成代码失败注入"修这些错"(审计 HIGH 项)。
|
|
559
|
+
*
|
|
560
|
+
* 判定序(三支各自成函数,顺序与 warn 次数都不变):沙箱面拦截 → 进程没跑完 →
|
|
561
|
+
* 工具装不上(126/127)→ exit 0(部分生效沙箱只 warn)→ npx stub 横幅 → 代码失败。
|
|
562
|
+
*/
|
|
563
|
+
function classifyRun(messages, gate, budgetMs, res, log) {
|
|
564
|
+
const facts = res.sandbox;
|
|
565
|
+
if (facts !== void 0) {
|
|
566
|
+
const blocked = sandboxBlockOutcome(messages, facts, res.stderr);
|
|
567
|
+
if (blocked !== void 0) return blocked;
|
|
568
|
+
}
|
|
569
|
+
const interrupted = interruptedRunOutcome(messages, budgetMs, res);
|
|
570
|
+
if (interrupted !== void 0) return interrupted;
|
|
571
|
+
const unusable = unavailableToolOutcome(messages, gate, res, log);
|
|
572
|
+
if (unusable !== void 0) return unusable;
|
|
573
|
+
if (res.exitCode === 0) {
|
|
574
|
+
if (facts?.enforcement === "partial") log.warn(`[quality-gate] gate passed under PARTIAL sandbox enforcement (${sandboxNote(messages, facts)}): ${gate.command.join(" ")} @ ${gate.root}`);
|
|
575
|
+
return { kind: "pass" };
|
|
576
|
+
}
|
|
577
|
+
if (gateToolUnavailable(res.stdout.text, res.stderr.text)) {
|
|
578
|
+
log.warn(`[quality-gate] gate tool unavailable (npx fetched a stub instead of the real tool): ${gate.command.join(" ")} in ${gate.root} — reported as not-run`);
|
|
579
|
+
return {
|
|
580
|
+
kind: "not-run",
|
|
581
|
+
text: `${messages.toolStub}\n${evidenceText(messages, res.stderr, "stderr")}`
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
kind: GATE_KIND_CODE_FAILURE,
|
|
586
|
+
text: `exit=${res.exitCode}\n${evidenceText(messages, res.stdout, "stdout")}${evidenceText(messages, res.stderr, "stderr")}`
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** 是否是会被记账的编辑类工具(edit/write + str_replace_editor)。 */
|
|
590
|
+
function isEditTool(name) {
|
|
591
|
+
return name === "edit" || name === "write" || name === "str_replace_editor";
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* 根 package.json 已声明的 scripts 名清单(值是非空字符串才算"声明了这条脚本")。
|
|
595
|
+
*
|
|
596
|
+
* 读不到 / JSON 非法 / 没有 scripts 字段 → **undefined("没有信息")**,由 gate-detect
|
|
597
|
+
* 按保守口径处理(不发 pnpm check)。不在这里替项目猜:猜错的代价是假失败注入。
|
|
598
|
+
*/
|
|
599
|
+
function declaredScriptsOf(root) {
|
|
600
|
+
let names;
|
|
601
|
+
try {
|
|
602
|
+
const raw = JSON.parse(readFileSync(`${root}/package.json`, "utf8"));
|
|
603
|
+
const scripts = fieldOf(raw, "scripts");
|
|
604
|
+
if (isRecord(scripts)) names = Object.entries(scripts).filter(([, body]) => typeof body === "string" && body.length > 0).map(([name]) => name);
|
|
605
|
+
} catch {
|
|
606
|
+
names = void 0;
|
|
607
|
+
}
|
|
608
|
+
return names;
|
|
609
|
+
}
|
|
610
|
+
/** 数组性判据(**不是** type predicate,故不产生类型收窄;理由见 resolveEventWindow)。 */
|
|
611
|
+
function isArrayDelivery(value) {
|
|
612
|
+
return Array.isArray(value);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* 解析事件游标窗口:fork 切点下限 vs 上次已读 seq 取大,返回起始 seq 与本批事件。
|
|
616
|
+
* 事件流取自官方 `ctx.sessionQuery.observeSession(sessionId)`(见上面 SessionQueryFace:
|
|
617
|
+
* 一次交回 `events` 与 `inheritedEventCount`)。三条降级路径一律 `undefined`(调用方直接
|
|
618
|
+
* 返回,**游标不推进**、不误报):① `sessionQuery` 未装配(`Context["get"]` 的官方语义即
|
|
619
|
+
* "or `undefined` when not (yet) provided",installed cordis/lib/types/reflect.d.ts:12);
|
|
620
|
+
* ② `observeSession` 抛错/拒绝(会话已卸载、存储读不出);③ 交回的 events 不是数组
|
|
621
|
+
* (契约外交付,由 `isArrayDelivery` 那道**布尔**判据挡——不用 `Array.isArray` 作守卫收窄,
|
|
622
|
+
* 它会把官方 `readonly SessionEvent[]` 塌成 `any[]`,类型面当场作废并把 any 传进下游记账)。
|
|
623
|
+
* 窗口仍由**本地按 from 切片**:观察面给的是自日志开头的连续切片(`seq = log.length`
|
|
624
|
+
* 连续性契约,installed dsh-session/lib/index.js:1285 的注记),而 `from` 是本包游标算出的
|
|
625
|
+
* 裸 number——这样连 `SessionLogOffset` 的品牌都不必造(官方只要 `SessionId`,那一位从
|
|
626
|
+
* `Session.id` 上拿回来就是品牌的)。
|
|
627
|
+
*/
|
|
628
|
+
async function resolveEventWindow(query, sessionId, lastSeq) {
|
|
629
|
+
let window;
|
|
630
|
+
if (query !== void 0) try {
|
|
631
|
+
const observation = await query.observeSession(sessionId);
|
|
632
|
+
const forkCut = observation.inheritedEventCount ?? 0;
|
|
633
|
+
const from = Math.max(forkCut, lastSeq.get(sessionId) ?? forkCut);
|
|
634
|
+
if (isArrayDelivery(observation.events)) window = {
|
|
635
|
+
from,
|
|
636
|
+
events: observation.events.slice(from)
|
|
637
|
+
};
|
|
638
|
+
} catch {
|
|
639
|
+
window = void 0;
|
|
640
|
+
}
|
|
641
|
+
return window;
|
|
642
|
+
}
|
|
643
|
+
/** 按有无 callId 分流:无 callId 直记(异常/旧形状/测试 mock 保守直记防漏),有则按 callId 关联。 */
|
|
644
|
+
function storeEdit(callId, record, pending, noCallId) {
|
|
645
|
+
if (callId === void 0) noCallId.push(record);
|
|
646
|
+
else pending.set(callId, record);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* 单扫描事件流 → 编辑记账三表:callId→编辑(成功关联)、无 callId 直记表、失败 callId 集。
|
|
650
|
+
* 只记成功编辑:被 danger-guard 拒绝或执行失败的 tool/result 标记失败 callId,跳过。
|
|
651
|
+
* 记账骨架复用 shared scanToolEvents(事件解析/view 判定/坏参标记),本函数只做
|
|
652
|
+
* 领域过滤(编辑工具集、路径记账语义)——与 danger-guard 不再各有 JSON.parse/view 双实现。
|
|
653
|
+
*
|
|
654
|
+
* 语义对照(与原本地实现等价):
|
|
655
|
+
* - 坏参数(JSON 非法/非对象)→ 跳过记账(原 EDIT_SKIP);
|
|
656
|
+
* - str_replace_editor view(只读)→ 跳过(原 EDIT_SKIP);
|
|
657
|
+
* - 其余编辑调用按 callId 关联,tool/result isError 回填 failed。
|
|
658
|
+
*/
|
|
659
|
+
function collectEdits(events) {
|
|
660
|
+
const pending = /* @__PURE__ */ new Map();
|
|
661
|
+
const noCallId = [];
|
|
662
|
+
const failed = /* @__PURE__ */ new Set();
|
|
663
|
+
const { calls, results } = scanToolEvents(events);
|
|
664
|
+
for (const call of calls) if (call.name !== void 0 && isEditTool(call.name) && !call.badArguments) {
|
|
665
|
+
const target = editPathOf(call);
|
|
666
|
+
if (target.kind === "write") storeEdit(call.callId, {
|
|
667
|
+
name: call.name,
|
|
668
|
+
path: target.path
|
|
669
|
+
}, pending, noCallId);
|
|
670
|
+
}
|
|
671
|
+
for (const result of results) if (result.isError && result.callId !== void 0) failed.add(result.callId);
|
|
672
|
+
return {
|
|
673
|
+
pending,
|
|
674
|
+
noCallId,
|
|
675
|
+
failed
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
/** 单文件向上找最近的清单根;hitDepthLimit 标记是否因超深而无根(供可观测 warn)。
|
|
679
|
+
* maxDepth 来自 Config 的 maxRootDepth(W4 非 volatile 部署值)。 */
|
|
680
|
+
function findManifestRoot(file, maxDepth) {
|
|
681
|
+
let dir = path.dirname(file);
|
|
682
|
+
let found;
|
|
683
|
+
let hitDepthLimit = false;
|
|
684
|
+
for (let depth = 0; depth < maxDepth; depth += 1) {
|
|
685
|
+
for (const manifest of MANIFESTS) if (existsSync(`${dir}/${manifest}`)) {
|
|
686
|
+
found = dir;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
if (found !== void 0) break;
|
|
690
|
+
const parent = path.dirname(dir);
|
|
691
|
+
if (parent === dir) break;
|
|
692
|
+
dir = parent;
|
|
693
|
+
if (depth === maxDepth - 1) hitDepthLimit = true;
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
root: found,
|
|
697
|
+
hitDepthLimit
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* 命中最近清单后继续向上探测 pnpm-workspace.yaml —— workspace 根优先。
|
|
702
|
+
* monorepo 子包(admin/src/x.ts)编辑应跑工作区全套 `pnpm check`,而非退化为子包 `tsc`。
|
|
703
|
+
*/
|
|
704
|
+
function workspaceRootUpgrade(root, maxDepth) {
|
|
705
|
+
let up = path.dirname(root);
|
|
706
|
+
for (let depth = 0; depth < maxDepth; depth += 1) {
|
|
707
|
+
if (existsSync(`${up}/pnpm-workspace.yaml`)) return up;
|
|
708
|
+
const parent = path.dirname(up);
|
|
709
|
+
if (parent === up) break;
|
|
710
|
+
up = parent;
|
|
711
|
+
}
|
|
712
|
+
return root;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* 探测一个根的真实盘上事实 → gate-detect 的入参形状(ProjectRoot)。
|
|
716
|
+
*
|
|
717
|
+
* - 清单名:MANIFESTS 里实际存在的项;
|
|
718
|
+
* - tsconfig.json 不作为"根标记"(不进 MANIFESTS,避免改动根向上探测的语义),
|
|
719
|
+
* 但要进清单集合——gate-detect 用它区分"真 TS 项目"与"只有 package.json 的空壳";
|
|
720
|
+
* - scripts:根 package.json 已声明的脚本名清单,交给 gate-detect 判定 pnpm check
|
|
721
|
+
* 是否可用。**这里只做如实登记,不做取舍**(探测策略集中在纯函数里)。
|
|
722
|
+
*/
|
|
723
|
+
function probeProjectRoot(root) {
|
|
724
|
+
const manifests = MANIFESTS.filter((manifest) => existsSync(`${root}/${manifest}`));
|
|
725
|
+
if (existsSync(`${root}/tsconfig.json`)) manifests.push("tsconfig.json");
|
|
726
|
+
const scripts = manifests.includes("package.json") ? declaredScriptsOf(root) : void 0;
|
|
727
|
+
return scripts === void 0 ? {
|
|
728
|
+
root,
|
|
729
|
+
files: manifests
|
|
730
|
+
} : {
|
|
731
|
+
root,
|
|
732
|
+
files: manifests,
|
|
733
|
+
scripts
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
/** 注入载荷组装(id 唯一,同一毫秒多次注入也不碰撞)。 */
|
|
737
|
+
const injectMessage = (text) => ({
|
|
738
|
+
id: `quality-gate-${randomUUID()}`,
|
|
739
|
+
role: "user",
|
|
740
|
+
content: [{
|
|
741
|
+
type: "text",
|
|
742
|
+
text
|
|
743
|
+
}],
|
|
744
|
+
source: { kind: GATE_SOURCE_KIND }
|
|
745
|
+
});
|
|
746
|
+
/**
|
|
747
|
+
* 总线上报(自进化闭环):门禁失败是事实,无论注入与否都要沉淀
|
|
748
|
+
* ——修复环里同一门禁反复失败会形成 violation 序列,度量"提醒有没有用"。
|
|
749
|
+
* 但"没跑成"以 gate-not-run 单独上报:环境/策略问题不得被学成代码教训(审计 HIGH 项
|
|
750
|
+
* 的第二半——假的失败指令曾经既进注入又进记忆/lesson-loop)。
|
|
751
|
+
* report 自身容错(同步抛错与异步拒绝经 settleLessonCall 落进同一条 warn);
|
|
752
|
+
* 总线缺失(未装 lesson-loop)时 get 返回 undefined 直接跳过。
|
|
753
|
+
*/
|
|
754
|
+
function reportProblemToBus(get, attribution, gate, problem, log) {
|
|
755
|
+
settleLessonCall(() => get("lessonLoop")?.report({
|
|
756
|
+
source: "quality-gate",
|
|
757
|
+
category: problem.kind === GATE_KIND_CODE_FAILURE ? "gate-failure" : "gate-not-run",
|
|
758
|
+
cwd: attribution.cwd,
|
|
759
|
+
sessionId: attribution.sessionId,
|
|
760
|
+
turn: attribution.turn,
|
|
761
|
+
signature: gate.command.join(" "),
|
|
762
|
+
detail: problem.text,
|
|
763
|
+
evidence: {
|
|
764
|
+
root: gate.root,
|
|
765
|
+
command: gate.command,
|
|
766
|
+
kind: problem.kind
|
|
767
|
+
}
|
|
768
|
+
}), (reason) => {
|
|
769
|
+
log.warn(`[quality-gate] lessonLoop report failed: ${messageOf(reason)}`);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* 设置 + dsh 凭据通道 → 一次投递目标;任一环节缺失只返回降级原因,**不抛**。
|
|
774
|
+
*
|
|
775
|
+
* 为什么不抛:记忆反馈是尽力而为的补强。别人机器上没有那台网关、没配凭据、甚至没装
|
|
776
|
+
* 凭据服务都是**常态**,抛错会把一次正常的门禁失败升级成回合错误(且缺配置本就是
|
|
777
|
+
* 用户要看见的信息,不是异常)。
|
|
778
|
+
*
|
|
779
|
+
* 密钥改走 ctx.credentials 后不再有 gateway.key 文件路径拼接:网关地址与令牌都是
|
|
780
|
+
* "某台机器上的事实",只能由使用者显式配置(设置项 + 凭据),不能被插件替所有人认定。
|
|
781
|
+
*/
|
|
782
|
+
async function resolveFeedbackTarget(cfg, credentials) {
|
|
783
|
+
const url = resolveGatewayUrl(cfg.memoryGatewayUrl);
|
|
784
|
+
if (url === "") return {
|
|
785
|
+
ok: false,
|
|
786
|
+
reason: "memoryGatewayUrl 未配置(留空 = 不启用记忆反馈)"
|
|
787
|
+
};
|
|
788
|
+
if (!isCredentialRefName(cfg.memoryGatewayKeyRef)) return {
|
|
789
|
+
ok: false,
|
|
790
|
+
reason: `memoryGatewayKeyRef "${cfg.memoryGatewayKeyRef}" 不是合法凭据引用名`
|
|
791
|
+
};
|
|
792
|
+
if (credentials === void 0) return {
|
|
793
|
+
ok: false,
|
|
794
|
+
reason: "credentials 服务未注册,网关密钥无从解析"
|
|
795
|
+
};
|
|
796
|
+
const hit = await credentials.resolve(credentialRef(cfg.memoryGatewayKeyRef));
|
|
797
|
+
if (hit === void 0) return {
|
|
798
|
+
ok: false,
|
|
799
|
+
reason: `凭据 ${cfg.memoryGatewayKeyRef} 未配置(值不落在设置里)`
|
|
800
|
+
};
|
|
801
|
+
return {
|
|
802
|
+
ok: true,
|
|
803
|
+
target: {
|
|
804
|
+
url,
|
|
805
|
+
key: hit.value
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* 记忆反馈的降级说明只说一次:默认关闭之后仍把它打开的人,多半正是"少配了一项"的状态
|
|
811
|
+
* (地址填了、凭据没配)。每个失败回合重复刷屏只会淹掉门禁日志。
|
|
812
|
+
*/
|
|
813
|
+
function warnMemorySkip(notice, reason) {
|
|
814
|
+
if (notice.warned) return;
|
|
815
|
+
notice.warned = true;
|
|
816
|
+
notice.log.warn(`[quality-gate] memory feedback skipped: ${reason}`);
|
|
817
|
+
}
|
|
818
|
+
/** 尽力而为的记忆反馈(内部消化错误,绝不阻塞门禁主流程)。 */
|
|
819
|
+
async function pushFeedbackToMemory(get, notice, cfg, messages, input) {
|
|
820
|
+
try {
|
|
821
|
+
const resolution = await resolveFeedbackTarget(cfg, get("credentials"));
|
|
822
|
+
if (!resolution.ok) {
|
|
823
|
+
warnMemorySkip(notice, resolution.reason);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
await pushGateFeedback(input, resolution.target, messages);
|
|
827
|
+
} catch (error) {
|
|
828
|
+
notice.log.warn(`[quality-gate] memory feedback skipped: ${messageOf(error)}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/** 一次读全的解析后设置快照(旧 scope.get() 的等价物):门禁按回合现读,纯值往下传,
|
|
832
|
+
* 不必让每个消费方都持有引用。官方同款读法见 harness
|
|
833
|
+
* packages/llm/llm-deepseek/src/config.ts 的 `plainOptions(config)`。 */
|
|
834
|
+
function settingsOf(config) {
|
|
835
|
+
return {
|
|
836
|
+
enabled: config.enabled.get(),
|
|
837
|
+
maxInjectsPerTurn: config.maxInjectsPerTurn.get(),
|
|
838
|
+
gateBudgetMs: config.gateBudgetMs.get(),
|
|
839
|
+
memoryFeedback: config.memoryFeedback.get(),
|
|
840
|
+
memoryGatewayUrl: config.memoryGatewayUrl.get(),
|
|
841
|
+
memoryGatewayKeyRef: config.memoryGatewayKeyRef.get()
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* ctx 的本包服务面守卫(替代 `ctx as unknown as HostCtx` 断言:严格档下豁免注释
|
|
846
|
+
* 不是合法手段)。cordis 的 ctx 是运行期代理,成员是否齐备只能按实际调用面核验。
|
|
847
|
+
* 核验的是 **0.1.7 的实际面**:`settings.configure`(页面策略)+ `settings.describe`
|
|
848
|
+
* (跨命名空间读)+ `inject` + `fiber` + `get`/`on`/`effect`。
|
|
849
|
+
* ⚠ 不能再探 `settings.register`:0.1.7 已把它连同 get/installSection 一起移除,
|
|
850
|
+
* 拿它当硬前置会让**每一条真宿主**都判成"服务面不齐",`gate not armed` 于是成为
|
|
851
|
+
* 唯一可能的失败模式(本包设置面失效就是这么静默掉下去的)。
|
|
852
|
+
* 缺任一项一律 fail-closed:不注册监听、不写游标,只记一条 error,避免带着半截服务面
|
|
853
|
+
* 在首个回合才崩(且崩溃会被折叠成 turn kind:error 直接甩给用户)。
|
|
854
|
+
*/
|
|
855
|
+
function isGateHost(value) {
|
|
856
|
+
if (!isRecord(value)) return false;
|
|
857
|
+
const { effect, fiber, get, inject, on, settings } = value;
|
|
858
|
+
return typeof get === "function" && typeof on === "function" && typeof effect === "function" && typeof inject === "function" && isRecord(fiber) && typeof fieldOf(settings, "describe") === "function" && typeof fieldOf(settings, "configure") === "function";
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* 从编辑文件路径向上找最近的清单根(层数上限 = Config 的 maxRootDepth,W4 非 volatile
|
|
862
|
+
* 部署值),每个根探一次真实盘上事实(清单名 + 已声明脚本)。找不到清单根的文件跳过
|
|
863
|
+
* (无门禁面)。
|
|
864
|
+
*/
|
|
865
|
+
function projectRootsOf(files, maxDepth, log) {
|
|
866
|
+
const roots = /* @__PURE__ */ new Map();
|
|
867
|
+
for (const file of files) {
|
|
868
|
+
const hit = findManifestRoot(file, maxDepth);
|
|
869
|
+
const useRoot = hit.root;
|
|
870
|
+
if (useRoot === void 0) {
|
|
871
|
+
if (hit.hitDepthLimit) log.warn(`[quality-gate] no manifest within ${maxDepth} levels of ${file}; gate skipped for it`);
|
|
872
|
+
} else {
|
|
873
|
+
const upgraded = workspaceRootUpgrade(useRoot, maxDepth);
|
|
874
|
+
if (!roots.has(upgraded)) roots.set(upgraded, probeProjectRoot(upgraded));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return [...roots.values()];
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* 执行一个门禁:只有 exitCode 0 才是 pass。
|
|
881
|
+
* 超时/取消/信号/策略拒绝/工具装不上都走 classifyRun 的显式三态;
|
|
882
|
+
* **catch 不再报通过**(审计 MEDIUM 项:workdir 不存在、spawn 失败、
|
|
883
|
+
* SandboxUnavailableError 都曾落进 catch 被当 PASS)。
|
|
884
|
+
*/
|
|
885
|
+
async function runGate(messages, shell, gate, budgetMs, context) {
|
|
886
|
+
const { policy, signal, stdoutMaxBytes, log } = context;
|
|
887
|
+
let outcome;
|
|
888
|
+
try {
|
|
889
|
+
const spec = shell.resolve({
|
|
890
|
+
command: gate.command.join(" "),
|
|
891
|
+
workdir: gate.root,
|
|
892
|
+
stdoutMaxBytes,
|
|
893
|
+
timeoutMs: budgetMs,
|
|
894
|
+
signal,
|
|
895
|
+
...policy === void 0 ? {} : { sandboxPolicy: policy }
|
|
896
|
+
});
|
|
897
|
+
outcome = classifyRun(messages, gate, budgetMs, await (await shell.execute(spec)).result(), log);
|
|
898
|
+
} catch (error) {
|
|
899
|
+
log.error(`[quality-gate] gate did not run: ${gate.command.join(" ")} @ ${gate.root} — ${messageOf(error)}`);
|
|
900
|
+
const cause = messageOf(error);
|
|
901
|
+
outcome = isSandboxUnavailable(error) ? {
|
|
902
|
+
kind: "not-run",
|
|
903
|
+
text: renderTemplate(messages.sandboxUnavailable, { message: cause })
|
|
904
|
+
} : {
|
|
905
|
+
kind: "not-run",
|
|
906
|
+
text: renderTemplate(messages.gateStartFailed, { message: cause })
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
return outcome;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* 软依赖 shell 的唯一入口——用 ctx.get(可选服务官方读法,未注册返回 undefined)。
|
|
913
|
+
*
|
|
914
|
+
* **不可写成 `svc.shell`**:cordis ctx 的 Proxy get 陷阱对未列入 `inject` 的属性
|
|
915
|
+
* 直接抛 `cannot get property "shell" without inject`(见 cordis reflect.ts 的
|
|
916
|
+
* `ReflectService.handler.get`),于是下面的 `=== undefined` 判定永远走不到,
|
|
917
|
+
* C9 静默放行分支形同虚设,且每回合结束都冒一个 turn 错误卡。
|
|
918
|
+
* shell 服务由 host 组合(@deepseek-ai/dsh-shell 的 Service 构造即注册),
|
|
919
|
+
* 正常环境下 get 命中;异常环境才落 C9。
|
|
920
|
+
*
|
|
921
|
+
* **也不加 `?? svc.shell` 兜底**:shell 真缺失时右值会重新触发同一个抛错,
|
|
922
|
+
* 兜底反而把 C9 再打断。mock 需实现 get() 才有意义。
|
|
923
|
+
*/
|
|
924
|
+
function getShell(rt) {
|
|
925
|
+
return rt.svc.get("shell");
|
|
926
|
+
}
|
|
927
|
+
/** 取本会话的解析后沙箱策略(审计 HIGH 项)。`session` 交官方 `Session` 类本身:
|
|
928
|
+
* 官方 `SandboxPolicyRequest.session` 就是这个类(@deepseek-ai/dsh-sandbox-policy/lib/
|
|
929
|
+
* types/index.d.ts:52),而 `Session` 带私有字段、按名义比 —— 本包那个只服务退役中同步
|
|
930
|
+
* 读面的 SessionRef 视图交不进去,编译器原话:`Type 'SessionRef' is missing the following
|
|
931
|
+
* properties from type 'Session': log, surfaceManager, surface, firstLiveSeq, and 18 more`。
|
|
932
|
+
* 这里要的是 turn-stopping 载荷里那一个真实 Session,故用官方类型而不是视图。 */
|
|
933
|
+
function resolvePolicy(rt, messages, session) {
|
|
934
|
+
const policy = rt.svc.get("sandboxPolicy");
|
|
935
|
+
let result;
|
|
936
|
+
if (policy === void 0) result = {
|
|
937
|
+
kind: "ok",
|
|
938
|
+
policy: void 0
|
|
939
|
+
};
|
|
940
|
+
else try {
|
|
941
|
+
result = {
|
|
942
|
+
kind: "ok",
|
|
943
|
+
policy: policy.resolve({ session })
|
|
944
|
+
};
|
|
945
|
+
} catch (error) {
|
|
946
|
+
result = {
|
|
947
|
+
kind: "unavailable",
|
|
948
|
+
reason: renderTemplate(messages.policyUnresolvable, { message: messageOf(error) })
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
return result;
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* 会话自己的累加器(缺失即建;建完按会话预算淘汰最旧的其他会话)。
|
|
955
|
+
* 条目**不随 drain 释放**:保留下来才让"本会话自己"这条账连续(下一回合复用同一实例,
|
|
956
|
+
* 容量淘汰计数也不会串到别的会话上),总大小由 SESSION_BUDGET 收敛。
|
|
957
|
+
*/
|
|
958
|
+
function accumulatorOf(rt, sessionId) {
|
|
959
|
+
let acc = rt.accumulators.get(sessionId);
|
|
960
|
+
if (acc === void 0) {
|
|
961
|
+
acc = new EditAccumulator();
|
|
962
|
+
rt.accumulators.set(sessionId, acc);
|
|
963
|
+
pruneToSessionBudget(rt.accumulators, sessionId);
|
|
964
|
+
}
|
|
965
|
+
return acc;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* 从事件流读本回合**成功**编辑的文件(按官方观察面给的增量窗),记进本会话的账。
|
|
969
|
+
* 低危修复(authoritative 记账):被 danger-guard 拒绝或执行失败的编辑,其 tool/call
|
|
970
|
+
* 仍在事件流里——旧实现照记,会对未真正改动的文件(若落在独立项目根)多跑一次门禁。
|
|
971
|
+
* 改为:tool/call 建 callId→{name,path},tool/result 的 isError 标记失败 callId,只记成功的。
|
|
972
|
+
* turn-stopping 时本回合 call+result 同处一个增量窗,单次扫描即可关联;无 callId 的事件
|
|
973
|
+
* (异常/旧形状/测试 mock)保守直记,避免漏门禁。
|
|
974
|
+
* fork 前缀修复(源码核对 0.1.3 后落地):子代理会话 fork 自父会话时,
|
|
975
|
+
* 事件流含父事件前缀,游标从 0 首读会把父会话的历史编辑一并计入、多跑门禁。
|
|
976
|
+
* Session.inheritedEventCount(0.1.2-rc.1 起公开只读)给出 fork 切点——首读下限
|
|
977
|
+
* 取切点而非 0,父前缀天然跳过;缺属性退回 0(旧行为,保守不漏门禁)。
|
|
978
|
+
*/
|
|
979
|
+
async function readEditedFiles(rt, agent, acc) {
|
|
980
|
+
const { session } = agent;
|
|
981
|
+
const sessionId = session.id;
|
|
982
|
+
const window = await resolveEventWindow(rt.svc.get("sessionQuery"), sessionId, rt.lastSeqBySession);
|
|
983
|
+
if (window === void 0) return;
|
|
984
|
+
rt.lastSeqBySession.set(sessionId, window.from + window.events.length);
|
|
985
|
+
pruneToSessionBudget(rt.lastSeqBySession, sessionId);
|
|
986
|
+
const edits = collectEdits(window.events);
|
|
987
|
+
const { cwd } = session.header;
|
|
988
|
+
const note = (name, pathValue) => {
|
|
989
|
+
let resolvedPath = pathValue;
|
|
990
|
+
if (resolvedPath !== void 0 && cwd !== void 0 && !path.isAbsolute(resolvedPath)) resolvedPath = path.resolve(cwd, resolvedPath);
|
|
991
|
+
acc.note(name, resolvedPath);
|
|
992
|
+
};
|
|
993
|
+
for (const entry of edits.noCallId) note(entry.name, entry.path);
|
|
994
|
+
for (const [callId, entry] of edits.pending) if (!edits.failed.has(callId)) note(entry.name, entry.path);
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* 处理一次门禁问题(代码失败或没跑成):上报 + 二次校验 + 配额 + 注入;返回是否停止剩余门禁。
|
|
998
|
+
* **同步**:整段体(bus 上报、二次校验、配额记账、followup 注入、日志)都就地完成,没有任何
|
|
999
|
+
* 该等的 await——`memoryFeedback` 那笔是刻意 detached 的补强(在下面 `void` 掉:它失败不得
|
|
1000
|
+
* 回滚一次已成功的注入,更不能拖住回合),把它 await 进来会改变语义。留着 `async` 只是给
|
|
1001
|
+
* 调用点多塞一个微任务,故按实际形状收成同步(eslint/typescript require-await 同判)。
|
|
1002
|
+
*/
|
|
1003
|
+
function handleGateFailure(scope, target, problem) {
|
|
1004
|
+
const { rt, agent, cfg, messages, sessionId, turn, cwd } = scope;
|
|
1005
|
+
const { log, injectState, memoryNotice } = rt;
|
|
1006
|
+
const maxInjects = cfg.maxInjectsPerTurn;
|
|
1007
|
+
const isCodeFailure = problem.kind === GATE_KIND_CODE_FAILURE;
|
|
1008
|
+
reportProblemToBus(rt.svc.get, {
|
|
1009
|
+
sessionId,
|
|
1010
|
+
turn,
|
|
1011
|
+
cwd
|
|
1012
|
+
}, target, problem, log);
|
|
1013
|
+
const check = preInjectCheck(agent, scope.signal);
|
|
1014
|
+
if (!check.ok) {
|
|
1015
|
+
log.warn(`[quality-gate] ${sessionId}: injection skipped (${check.reason}) at turn ${turn}`);
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
const st = injectState.get(sessionId) ?? {
|
|
1019
|
+
count: 0,
|
|
1020
|
+
gaveUp: false
|
|
1021
|
+
};
|
|
1022
|
+
if (st.count >= maxInjects) {
|
|
1023
|
+
if (!st.gaveUp) {
|
|
1024
|
+
st.gaveUp = true;
|
|
1025
|
+
injectState.set(sessionId, st);
|
|
1026
|
+
try {
|
|
1027
|
+
agent.followup(injectMessage(giveUpText(messages, target, st.count)));
|
|
1028
|
+
log.warn(`[quality-gate] ${sessionId}: giving up after ${st.count} repair attempts (gate still failing)`);
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
log.error(`[quality-gate] ${sessionId}: give-up followup failed: ${messageOf(error)}`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
st.count += 1;
|
|
1036
|
+
injectState.set(sessionId, st);
|
|
1037
|
+
pruneToSessionBudget(injectState, sessionId);
|
|
1038
|
+
try {
|
|
1039
|
+
agent.followup(injectMessage(isCodeFailure ? gateText(messages, target, problem.text) : notRunText(messages, target, problem.text)));
|
|
1040
|
+
log.info(`[quality-gate] ${sessionId}: ${isCodeFailure ? "gate failure" : "gate not-run"} injected at turn ${turn} (attempt ${st.count}/${maxInjects})`);
|
|
1041
|
+
if (isCodeFailure && cfg.memoryFeedback) pushFeedbackToMemory(rt.svc.get, memoryNotice, cfg, messages, {
|
|
1042
|
+
cwd,
|
|
1043
|
+
command: target.command,
|
|
1044
|
+
root: target.root,
|
|
1045
|
+
failure: problem.text
|
|
1046
|
+
});
|
|
1047
|
+
return true;
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
st.count -= 1;
|
|
1050
|
+
injectState.set(sessionId, st);
|
|
1051
|
+
log.error(`[quality-gate] ${sessionId}: followup failed: ${messageOf(error)}`);
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
/** 卸载收口的读法:两次判读之间隔着 `await runGate(...)`,那期间 disposer 的 abort() 是真会
|
|
1056
|
+
* 发生的(W4 收口由测试钉住);直接读 `gateAbort.signal.aborted` 会被控制流分析钉在前一次
|
|
1057
|
+
* 判读的 false 上(属性窄不跨 await 复位),后一处守卫就被判成恒假。经函数取值 ⇒ 每读都按
|
|
1058
|
+
* 声明的 boolean 算,守卫才是必要的。 */
|
|
1059
|
+
function gateAborted(rt) {
|
|
1060
|
+
return rt.gateAbort.signal.aborted;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* 顺序执行门禁(决策是顺序依赖的:预算封顶 + 每次只注入一个失败),
|
|
1064
|
+
* 用尾递归推进代替 for+await(规避 no-await-in-loop,语义不变)。
|
|
1065
|
+
*/
|
|
1066
|
+
async function processGate(state, left) {
|
|
1067
|
+
const { scope, shell, resolved, signal, budget, perGate, started, batch } = state;
|
|
1068
|
+
const { rt } = scope;
|
|
1069
|
+
const [gate] = left;
|
|
1070
|
+
if (gate === void 0) return;
|
|
1071
|
+
if (gateAborted(rt)) return;
|
|
1072
|
+
const remaining = budget - (Date.now() - started);
|
|
1073
|
+
if (remaining < MIN_USEFUL_GATE_MS) {
|
|
1074
|
+
batch.blocked = true;
|
|
1075
|
+
handleGateFailure(scope, gate, {
|
|
1076
|
+
kind: "not-run",
|
|
1077
|
+
text: skippedText(scope.messages, left)
|
|
1078
|
+
});
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const outcome = resolved.kind === "unavailable" ? {
|
|
1082
|
+
kind: "not-run",
|
|
1083
|
+
text: resolved.reason
|
|
1084
|
+
} : await runGate(scope.messages, shell, gate, Math.min(perGate, remaining), {
|
|
1085
|
+
policy: resolved.policy,
|
|
1086
|
+
signal,
|
|
1087
|
+
stdoutMaxBytes: rt.stdoutMaxBytes,
|
|
1088
|
+
log: rt.log
|
|
1089
|
+
});
|
|
1090
|
+
if (gateAborted(rt)) return;
|
|
1091
|
+
if (outcome.kind === "pass") {
|
|
1092
|
+
await processGate(state, left.slice(1));
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
batch.blocked = true;
|
|
1096
|
+
if (!handleGateFailure(scope, gate, outcome)) await processGate(state, left.slice(1));
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* 一趟门禁批次:C4 按总预算封顶(perGate 保底 10s 会超支)——跟踪已花时间,每门
|
|
1100
|
+
* min(perGate, remaining)。尾段不足最小可用预算(真实 tsc/pnpm 冷启动也要数秒,几百 ms
|
|
1101
|
+
* 必超时 → 假"超时失败"注入)时**注入未执行说明**——旧实现只 console.warn,那等于静默
|
|
1102
|
+
* 通过(审计 MEDIUM 项)。
|
|
1103
|
+
*
|
|
1104
|
+
* W4 防御收口:①整段顶层 try/catch——批次自身故障只记一条 error,绝不产生
|
|
1105
|
+
* unhandledRejection;②批次 promise 由调用方登记进在飞账本,disposer abort 后 await 收敛
|
|
1106
|
+
* (defensive-patterns.md:19-23:dispose 必须终止并等待在飞工作)。
|
|
1107
|
+
*/
|
|
1108
|
+
async function runGateBatch(input) {
|
|
1109
|
+
const { scope } = input;
|
|
1110
|
+
const { rt, sessionId } = scope;
|
|
1111
|
+
try {
|
|
1112
|
+
const perGate = Math.max(1e4, Math.floor(input.budget / input.gates.length));
|
|
1113
|
+
const started = Date.now();
|
|
1114
|
+
/** 是否有门禁未给出"通过"结论(code-failure 与 not-run 都算;只有全 pass 才重置配额)。
|
|
1115
|
+
* 放进可变持有对象而不是 `let`:赋值全在 `processGate` 里,控制流分析看不见那两次
|
|
1116
|
+
* 写,批次结束后的读点会被钉在初值 false 上、`!batch.blocked` 就被判成恒真——而「有门没给出
|
|
1117
|
+
* 通过结论」是真会发生的(配额重置与否由它决定)。属性读保留声明的 boolean,守卫才是真的。 */
|
|
1118
|
+
const batch = { blocked: false };
|
|
1119
|
+
await processGate({
|
|
1120
|
+
...input,
|
|
1121
|
+
perGate,
|
|
1122
|
+
started,
|
|
1123
|
+
batch
|
|
1124
|
+
}, input.gates);
|
|
1125
|
+
if (!batch.blocked) rt.injectState.delete(sessionId);
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
rt.log.error(`[quality-gate] ${sessionId}: gate batch failed: ${messageOf(error)}`);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* 一次回合收口(原 turn-stopping 监听器里的匿名 `run`):守卫 → 记账 → 探测门禁 →
|
|
1132
|
+
* detached 跑批次。整段仍是同一条同步/异步切分——门禁执行 detached(不拖回合收尾),
|
|
1133
|
+
* 事件读那一次 await 留在回合内(`@mode serial` 派发下与原形状等价)。
|
|
1134
|
+
*/
|
|
1135
|
+
async function handleTurnGate(rt, payload) {
|
|
1136
|
+
const { agent } = payload;
|
|
1137
|
+
if (agent === void 0) return;
|
|
1138
|
+
if (rt.disposed) return;
|
|
1139
|
+
const cfg = settingsOf(rt.config);
|
|
1140
|
+
if (!cfg.enabled) return;
|
|
1141
|
+
if (!isRootSession(agent)) return;
|
|
1142
|
+
const messages = rt.messages();
|
|
1143
|
+
const { session } = agent;
|
|
1144
|
+
const { id: sessionId } = session;
|
|
1145
|
+
/** 会话 cwd(turn-stopping 载荷 header.cwd):相对路径解析基准 + 记忆/总线归因。 */
|
|
1146
|
+
const { cwd } = session.header;
|
|
1147
|
+
if (inboxHasPending(agent.inbox)) {
|
|
1148
|
+
pruneToSessionBudget(rt.injectState, sessionId);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
if (typeof payload.turn !== "number") return;
|
|
1152
|
+
const { turn } = payload;
|
|
1153
|
+
const shell = getShell(rt);
|
|
1154
|
+
if (shell === void 0) {
|
|
1155
|
+
if (!rt.shellWarned) {
|
|
1156
|
+
rt.shellWarned = true;
|
|
1157
|
+
rt.log.warn("[quality-gate] shell service unavailable; gates skipped (pass-through)");
|
|
1158
|
+
}
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const acc = accumulatorOf(rt, sessionId);
|
|
1162
|
+
await readEditedFiles(rt, agent, acc);
|
|
1163
|
+
const dropped = acc.droppedCount();
|
|
1164
|
+
const files = acc.drain();
|
|
1165
|
+
if (dropped > 0) rt.log.warn(`[quality-gate] ${sessionId}: ${dropped} edited file(s) evicted from accumulator cap; gate may miss their roots`);
|
|
1166
|
+
if (files.length === 0) return;
|
|
1167
|
+
const { gateBudgetMs: budget } = cfg;
|
|
1168
|
+
const gates = detectGate(projectRootsOf(files, rt.maxRootDepth, rt.log));
|
|
1169
|
+
if (gates.length === 0) return;
|
|
1170
|
+
const resolved = resolvePolicy(rt, messages, session);
|
|
1171
|
+
const gateSignal = payload.signal === void 0 ? rt.gateAbort.signal : AbortSignal.any([payload.signal, rt.gateAbort.signal]);
|
|
1172
|
+
const flight = runGateBatch({
|
|
1173
|
+
scope: {
|
|
1174
|
+
rt,
|
|
1175
|
+
agent,
|
|
1176
|
+
cfg,
|
|
1177
|
+
messages,
|
|
1178
|
+
sessionId,
|
|
1179
|
+
turn,
|
|
1180
|
+
cwd,
|
|
1181
|
+
signal: payload.signal
|
|
1182
|
+
},
|
|
1183
|
+
gates,
|
|
1184
|
+
shell,
|
|
1185
|
+
resolved,
|
|
1186
|
+
signal: gateSignal,
|
|
1187
|
+
budget
|
|
1188
|
+
});
|
|
1189
|
+
rt.inFlightGates.add(flight);
|
|
1190
|
+
(async () => {
|
|
1191
|
+
await flight;
|
|
1192
|
+
rt.inFlightGates.delete(flight);
|
|
1193
|
+
})();
|
|
1194
|
+
}
|
|
1195
|
+
function apply(ctx, config) {
|
|
1196
|
+
if (!isGateHost(ctx)) {
|
|
1197
|
+
console.error("[quality-gate] ctx is missing the settings.describe/configure + inject/fiber surface; gate not armed");
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const svc = ctx;
|
|
1201
|
+
const log = svc.logger?.("quality-gate") ?? console;
|
|
1202
|
+
const { maxRootDepth, gateStdoutMaxBytes: stdoutMaxBytes } = config;
|
|
1203
|
+
svc.inject(["settings"], (child) => {
|
|
1204
|
+
child.effect(() => child.settings.configure({ auto: false }, svc.fiber));
|
|
1205
|
+
});
|
|
1206
|
+
const localeMessages = () => messagesFor(MESSAGES, resolveLocalePreference(svc.settings.describe().find((row) => row.ns === LOCALE_SETTINGS_NAMESPACE)?.value));
|
|
1207
|
+
const rt = {
|
|
1208
|
+
svc,
|
|
1209
|
+
config,
|
|
1210
|
+
log,
|
|
1211
|
+
maxRootDepth,
|
|
1212
|
+
stdoutMaxBytes,
|
|
1213
|
+
messages: localeMessages,
|
|
1214
|
+
accumulators: /* @__PURE__ */ new Map(),
|
|
1215
|
+
injectState: /* @__PURE__ */ new Map(),
|
|
1216
|
+
lastSeqBySession: /* @__PURE__ */ new Map(),
|
|
1217
|
+
memoryNotice: {
|
|
1218
|
+
warned: false,
|
|
1219
|
+
log
|
|
1220
|
+
},
|
|
1221
|
+
gateAbort: new AbortController(),
|
|
1222
|
+
inFlightGates: /* @__PURE__ */ new Set(),
|
|
1223
|
+
shellWarned: false,
|
|
1224
|
+
disposed: false
|
|
1225
|
+
};
|
|
1226
|
+
svc.on("agent/turn-stopping", async (payload) => {
|
|
1227
|
+
try {
|
|
1228
|
+
await handleTurnGate(rt, payload);
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
rt.log.error(`[quality-gate] turn-stopping handler failed (gate skipped): ${messageOf(error)}`);
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
svc.effect(() => () => {
|
|
1234
|
+
rt.disposed = true;
|
|
1235
|
+
rt.gateAbort.abort();
|
|
1236
|
+
rt.injectState.clear();
|
|
1237
|
+
rt.lastSeqBySession.clear();
|
|
1238
|
+
rt.accumulators.clear();
|
|
1239
|
+
return Promise.allSettled(rt.inFlightGates);
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
var host_default = {
|
|
1243
|
+
inject: ["settings"],
|
|
1244
|
+
Config,
|
|
1245
|
+
apply
|
|
1246
|
+
};
|
|
1247
|
+
//#endregion
|
|
1248
|
+
export { Config, apply, host_default as default };
|