@jayyuen66/dsh-quality-gate 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/host.js CHANGED
@@ -7,10 +7,11 @@ import Schema from "@deepseek-ai/schemastery";
7
7
  import { credentialRef, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
8
8
  import { LOCALE_SETTINGS_NAMESPACE, messagesFor, resolveLocalePreference } from "@jayyuen66/dsh-plugin-shared/lib/locale";
9
9
  import { deriveProjectKey } from "@jayyuen66/dsh-plugin-shared/lib/project-key";
10
- import { editPathOf, scanToolEvents } from "@jayyuen66/dsh-plugin-shared/lib/tool-events";
10
+ import { editPathOf, scanToolEvents, toolEventRowsOf } from "@jayyuen66/dsh-plugin-shared/lib/tool-events";
11
11
  import { settleLessonCall } from "@jayyuen66/dsh-plugin-shared/lib/lesson-bus";
12
12
  import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
13
- //#region lib/accumulator.ts
13
+ import { z } from "zod";
14
+ // lib/accumulator.ts
14
15
  /**
15
16
  * 需要过门禁的扩展名(代码与配置;md/txt/图片等免检)。
16
17
  *
@@ -20,7 +21,11 @@ import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
20
21
  */
21
22
  const GATED_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|json|ya?ml|toml)$/iu;
22
23
  /** 编辑类工具名。str_replace_editor 是 dsh-base 并存的另一套编辑器
23
- * (host 侧只对 create/str_replace/insert 记账,view 只读不入这里)。 */
24
+ * (host 侧只对 create/str_replace/insert 记账,view 只读不入这里)。
25
+ *
26
+ * 这是**唯一真源**:host 侧的同名判定(isEditTool)也用这一份,两边各写一份时新增
27
+ * 编辑器工具只改一侧就会静默分叉——只改 host 则累加器静默丢弃记账,只改这里则 host
28
+ * 根本不会调用 note。 */
24
29
  const EDIT_TOOLS = /* @__PURE__ */ new Set([
25
30
  "edit",
26
31
  "write",
@@ -34,24 +39,26 @@ var EditAccumulator = class {
34
39
  /** 容量淘汰累计丢弃数:drain 时清零(静默淘汰要可观测)。 */
35
40
  dropped = 0;
36
41
  constructor(options = {}) {
37
- this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
42
+ const configured = options.maxFiles;
43
+ this.maxFiles = configured !== void 0 && Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_MAX_FILES;
38
44
  }
39
45
  /** 记一笔:编辑类工具 + 代码/配置扩展名 + 非空路径才收;容量淘汰时计数。 */
40
46
  note(name, filePath) {
41
47
  if (name === void 0 || !EDIT_TOOLS.has(name)) return;
42
48
  if (typeof filePath !== "string" || filePath.length === 0) return;
43
49
  if (!GATED_EXT.test(filePath)) return;
44
- if (this.files.size >= this.maxFiles && !this.files.has(filePath)) this.evictOldest();
50
+ this.files.delete(filePath);
51
+ if (this.files.size >= this.maxFiles) this.evictOldest();
45
52
  this.files.add(filePath);
46
53
  }
47
54
  /**
48
- * 淘汰插入序最旧的一项并计数。`maxFiles` 配成 0 时调用点对空集也成立,所以取到的首项
49
- * 可以不存在——那一支什么都不做,由该退化配置的用例钉住。
50
- * 这里只需要**取首项这一次动作**,不需要迭代:`for…of` + 无条件 `break` 正是
51
- * eslint(no-unreachable-loop) 判的「循环体只允许一次迭代」——循环结构在这里不承载任何语义。
55
+ * 淘汰顺序最旧的一项并计数。
56
+ *
57
+ * 归一化后 `maxFiles >= 1`,而调用点只在 `size >= maxFiles` 时进来,所以集合必有可取首项。
52
58
  */
53
59
  evictOldest() {
54
60
  const [oldest] = this.files;
61
+ /* v8 ignore next 3 —— 上面那条不变量的直接推论:这一支只为类型收窄而写,正常配置下不可达。 */
55
62
  if (oldest === void 0) return;
56
63
  this.files.delete(oldest);
57
64
  this.dropped += 1;
@@ -68,8 +75,7 @@ var EditAccumulator = class {
68
75
  return out;
69
76
  }
70
77
  };
71
- //#endregion
72
- //#region lib/gate-detect.ts
78
+ // lib/gate-detect.ts
73
79
  /**
74
80
  * `pnpm check` 是否可用:必须有已声明的 check 脚本,**信息缺失时一律判否**。
75
81
  *
@@ -83,9 +89,25 @@ var EditAccumulator = class {
83
89
  function pnpmCheckReady(scripts) {
84
90
  return scripts?.includes("check") === true;
85
91
  }
86
- /** 从 ProjectRoot(根目录相对名清单 + 已声明脚本)判定单根门禁。 */
92
+ /**
93
+ * `ruff check .` 是否可用:必须有 ruff 自己的配置面,**信息缺失时一律判否**。
94
+ *
95
+ * 为什么 pyproject.toml 单独不算证据:它不是 ruff 的配置标记,而是 Python 工程最常见的
96
+ * 元数据文件——只写 `[project]`、lint 交给别的工具(flake8 / pylint / ruff 的编辑器集成)
97
+ * 的工程大量存在。对这类工程发 `ruff check .`,一旦宿主 PATH 上有 ruff,就会拿 ruff 的
98
+ * 默认规则集去判一个从未声明用 ruff 的项目,产出与使用者意图无关的告警,再经同一个注入
99
+ * 通道被当成真实缺陷。这正是本模块要压住的假失败,方向与 pnpmCheckReady 一致。
100
+ *
101
+ * 判据收在两处**声明**上:`ruff.toml`(ruff 专属文件名,在场即声明)与 pyproject 里真有
102
+ * 的 `[tool.ruff]` 一族段(由 host 读盘后经 ruffConfigured 传入)。二者都缺=没有信息,
103
+ * 按保守口径不给门禁——代价只是这一轮少一次自动核验,用户可以自己跑。
104
+ */
105
+ function ruffGateReady(files, ruffConfigured) {
106
+ return files.includes("ruff.toml") || ruffConfigured === true;
107
+ }
108
+ /** 从 ProjectRoot(根目录相对名清单 + 已声明脚本 + ruff 配置事实)判定单根门禁。 */
87
109
  function gateForRoot(entry) {
88
- const { root, files, scripts } = entry;
110
+ const { root, files, scripts, ruffConfigured } = entry;
89
111
  const has = (name) => files.includes(name);
90
112
  let gate;
91
113
  if (has("pnpm-workspace.yaml") && has("package.json") && pnpmCheckReady(scripts)) gate = {
@@ -96,7 +118,7 @@ function gateForRoot(entry) {
96
118
  root,
97
119
  command: ["cargo", "check"]
98
120
  };
99
- else if (has("pyproject.toml") || has("ruff.toml")) gate = {
121
+ else if (ruffGateReady(files, ruffConfigured)) gate = {
100
122
  root,
101
123
  command: [
102
124
  "ruff",
@@ -119,8 +141,7 @@ function detectGate(roots) {
119
141
  }
120
142
  return out;
121
143
  }
122
- //#endregion
123
- //#region lib/messages.ts
144
+ // lib/messages.ts
124
145
  const MESSAGES = {
125
146
  zh: {
126
147
  evidenceTruncated: "[证据截断] {label} 内存里只保留尾部(首个错误可能在被丢弃的头部)。完整输出:{spill}",
@@ -171,33 +192,44 @@ const MESSAGES = {
171
192
  };
172
193
  /** 渲染模板里的 {name} 占位符;未提供的名字留空串(宁可少一段说明,也不给模型留裸花括号)。
173
194
  * 与 client 半官方 `@deepseek-ai/dsh-client-locale` 的 Translate 插值同语义(宿主同规则)。
195
+ *
196
+ * 查参数用 `Object.hasOwn` 而不是直接下标:模板里若出现 `{constructor}` / `{toString}` 这类被
197
+ * Object.prototype 覆盖的名字,裸下标取到的是函数对象、`String()` 之后会渲染成源码串,
198
+ * 与"未提供的名字留空串"这条契约相反。
174
199
  * @param text - 字典里的整行模板。
175
200
  * @param params - 占位符名 → 值(数字会转成字符串)。
176
201
  * @returns 替换后的文本。
177
202
  */
178
203
  function renderTemplate(text, params) {
179
- return text.replaceAll(/\{(?<key>\w+)\}/gu, (_all, key) => {
180
- const value = params[key];
181
- return value === void 0 ? "" : String(value);
182
- });
204
+ const provided = new Map(Object.entries(params));
205
+ return text.replaceAll(/\{(?<key>\w+)\}/gu, (_all, key) => provided.get(key)?.toString() ?? "");
206
+ }
207
+ // lib/feedback-content.ts
208
+ /** 空白折叠成单个空格并去首尾:让副本身处"一行正文"的契约之内。
209
+ * 参数收 `unknown` 而不是 `string`:这是运行时边界(门禁执行结果由 spawn 与上游透传拼出来),
210
+ * 类型注释拦不住 undefined/null。原先直接 `text.replaceAll` 时,异常会冒到
211
+ * pushFeedbackToMemory 的 catch 里 → "门禁失败却一条记忆都没写",且日志里只有一句
212
+ * "memory feedback skipped",看起来像网关挂了。 */
213
+ function oneLine(text) {
214
+ return typeof text === "string" ? text.replaceAll(/\s+/gu, " ").trim() : "";
183
215
  }
184
- //#endregion
185
- //#region lib/feedback-content.ts
186
216
  /** 门禁失败 → 记忆正文:单行化(防换行噪音)。
217
+ * 三处插值都要折叠:`failure` 来自被检查仓库的自由文本,`command` 来自配置里的自定义命令,
218
+ * `root` 可以是任何路径——任一处带换行/制表符,这条"一条记忆正文"就会被 auto-recall 拆成多段。
187
219
  * 不截断:截尾会吃掉多根错误里的中间根因;记忆网关的入库限额由网关侧把关,这里送全文。
188
220
  * 正文语言随官方 locale 偏好走(messages 由调用方按回合取出后传进来)——这条文本会被
189
221
  * auto-recall 注入后续会话的上下文,是模型读的,不是日志。 */
190
222
  function buildFeedbackText({ command, root, failure }, messages) {
191
- const cmd = command.join(" ");
192
- const tail = failure.replaceAll(/\s+/gu, " ").trim();
193
223
  return renderTemplate(messages.memoryFeedback, {
194
- command: cmd,
195
- root,
196
- failure: tail
224
+ command: oneLine(command.join(" ")),
225
+ root: oneLine(root),
226
+ failure: oneLine(failure)
197
227
  });
198
228
  }
199
- //#endregion
200
- //#region lib/gateway-target.ts
229
+ // lib/gateway-target.ts
230
+ /** 基址末尾斜杠与 IPv4 单段。 */
231
+ const TRAILING_SLASHES$1 = /\/+$/u;
232
+ const IPV4_OCTET = /^\d{1,3}$/u;
201
233
  /** env 值 trim 后非空则用之,否则回到 fallback(保留原 `||` 语义,
202
234
  * 又以显式比较规避 prefer-nullish-coalescing 与 strict-boolean-expressions)。 */
203
235
  function envOr(key, fallback) {
@@ -210,15 +242,44 @@ function envOr(key, fallback) {
210
242
  * 端口写死成默认,等于让别人的每次门禁失败都去连一个不存在的服务。设置优先于 env 的理由:
211
243
  * 设置卡是用户显式配置面,env 只能兜底,
212
244
  * 否则用户改了设置卡仍被一个看不见的环境变量越权。
245
+ *
246
+ * 解析不出合法 URL 时同样返回空串(按"未配置"处理):`http://` 这类半截串裁掉斜杠后剩
247
+ * `http:`,如果原样透给调用方,凭据安全判据会把它报成"明文 http 且非回环"——把配置写错
248
+ * 误报成协议不安全,用户照提示改成 https 也修不好。
213
249
  */
214
250
  function resolveGatewayUrl(settingUrl) {
215
251
  const configured = settingUrl.trim();
216
- return (configured === "" ? envOr("TDAI_GATEWAY_URL", "") : configured).replace(/\/+$/u, "");
252
+ const candidate = configured === "" ? envOr("TDAI_GATEWAY_URL", "") : configured;
253
+ if (candidate === "") return "";
254
+ const normalized = candidate.replace(TRAILING_SLASHES$1, "");
255
+ let parsed;
256
+ try {
257
+ parsed = new URL(normalized);
258
+ } catch {
259
+ return "";
260
+ }
261
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
262
+ return normalized;
263
+ }
264
+ /** 是否为 IPv4 回环(127.0.0.0/8 整段,不只是 127.0.0.1)。 */
265
+ function isIpv4Loopback(host) {
266
+ const parts = host.split(".");
267
+ if (parts.length !== 4 || parts[0] !== "127") return false;
268
+ return parts.every((part) => IPV4_OCTET.test(part) && Number(part) <= 255);
217
269
  }
218
270
  /**
219
271
  * 凭据只允许发往 https 或回环地址:明文 http 到远端会把 Bearer 令牌暴露给同网段
220
272
  * (设置卡默认值就是本机 http,属有意用法)。返回 false 时调用方按"未配置"处理,
221
273
  * 不解析、不发送凭据。
274
+ *
275
+ * 回环按整段判定而不是三个字面量:IPv4 回环是 127.0.0.0/8,网关绑在 127.0.0.2 /
276
+ * 127.0.1.1 时同样只在本机,判成"不安全"会让记忆反馈静默停掉、而用户从提示里看不出
277
+ * 自己其实就在本机;IPv6 的 IPv4-mapped 写法(URL 把 `[::ffff:127.0.0.1]` 归一成
278
+ * `[::ffff:7f00:1]`)同理。
279
+ *
280
+ * `localhost` 这一支是**有意保留的 fail-open**:它依赖 hosts/DNS 解析,hosts 被指向远端时
281
+ * 本函数仍判安全。保留它是为了让设置卡的默认写法可用;要彻底关掉这个口子只能改成只认 IP
282
+ * 字面量,那会让"本机网关"这条最常见用法需要用户自己去查 IP。
222
283
  */
223
284
  function isCredentialSafeUrl(url) {
224
285
  let parsed;
@@ -230,25 +291,51 @@ function isCredentialSafeUrl(url) {
230
291
  if (parsed.protocol === "https:") return true;
231
292
  if (parsed.protocol !== "http:") return false;
232
293
  const host = parsed.hostname.replaceAll(/^\[|\]$/gu, "");
233
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
294
+ return host === "localhost" || isIpv4Loopback(host) || host === "::1" || host.startsWith("::ffff:7f");
234
295
  }
235
- /** x-tdai-service-id:env 可覆盖,默认是网关协议的匿名服务名(不含任何机器信息)。 */
296
+ /** `x-tdai-service-id` 的合法形状:网关侧按服务名路由,空白与控制字符会让请求头
297
+ * 在 fetch 处直接抛 TypeError(undici 拒收含换行的头值),把一次投递变成一条异常。
298
+ * 不合规的值一律回落到默认服务名,而不是把用户的笔误送上网关。 */
299
+ const SERVICE_ID_RE = /^[\w.-]{1,64}$/u;
300
+ /** 默认服务名:网关协议的匿名服务名(不含任何机器信息)。 */
301
+ const DEFAULT_SERVICE_ID = "default";
302
+ /** x-tdai-service-id:env 可覆盖,形状不合规时回落默认值。 */
236
303
  function resolveServiceId() {
237
- return envOr("TDAI_SERVICE_ID", "default");
304
+ const configured = envOr("TDAI_SERVICE_ID", "");
305
+ return SERVICE_ID_RE.test(configured) ? configured : DEFAULT_SERVICE_ID;
238
306
  }
239
- /** cwd → agent_id:'尾目录名-<cwd 前 8 位 sha256>'(与网关插件 workspace-peer 同分桶语义,
240
- * 读/写同键,保证反馈沉淀进 auto-recall 同一记忆桶)。
241
- * 派生本体在 shared/lib/project-key.ts:lesson-loop 的 project 与这里同源一份实现,
242
- * 此前两处各存一份,改一处漏一处就把"教训 ↔ 记忆"的跨系统对照拆断了。 */
307
+ /**
308
+ * 作用:cwd → agent_id,'尾目录名-<cwd 前 8 位 sha256>'(与网关插件 workspace-peer 同分桶
309
+ * 语义,读/写同键,保证反馈沉淀进 auto-recall 同一记忆桶)。
310
+ *
311
+ * 条件:派生本体在 shared/lib/project-key.ts —— lesson-loop 的 project 与这里同源一份实现,
312
+ * 两处各存一份时改一处漏一处,把"教训 ↔ 记忆"的跨系统对照拆断而不报错。
313
+ *
314
+ * 子功能:参数保留 `unknown`(调用方的 cwd 来自会话事件,形状不由本包保证);非字符串、
315
+ * 空串、根路径一律回落 'default'。
316
+ *
317
+ * 执行:直接委托 deriveProjectKey。
318
+ *
319
+ * 验证方式:test/gateway-feedback.test.ts 的 deriveAgentId 组逐条钉住回落面,并有一例
320
+ * 断言它与 deriveProjectKey 逐值同源。
321
+ *
322
+ * 不适用条件:'default' 是所有项目共享的记忆桶 —— 回落因此**可能**把反馈混进别人的桶。
323
+ * 这里不为此加日志:本模块被 host 的 catch 包着、失败只 warn 一次,再往 lib 里塞一条
324
+ * console 会污染测试日志账本(test/setup-logs.ts 要求每条日志都有模板认领);而实际
325
+ * 调用点传的是会话 cwd(host.ts 里由事件流给出,必为字符串),回落是给未来调用方的护栏。
326
+ */
243
327
  function deriveAgentId(cwd) {
244
328
  return deriveProjectKey(cwd);
245
329
  }
246
- //#endregion
247
- //#region lib/gateway-feedback.ts
330
+ // lib/gateway-feedback.ts
331
+ /** 基址末尾斜杠。 */
332
+ const TRAILING_SLASHES = /\/+$/u;
248
333
  /** 反馈尽力而为:5s 封顶,超时/失败只 warn(不吃掉门禁主流程)。 */
249
334
  const FEEDBACK_TIMEOUT_MS = 5e3;
250
335
  /** 沉淀门禁失败到 L0 记忆(role=assistant:这是 agent 自身行为教训,归顺 assistant 提炼面)。 */
251
336
  async function pushGateFeedback(input, target, messages) {
337
+ const base = target.url.trim().replace(TRAILING_SLASHES, "");
338
+ if (base === "" || target.key.trim() === "") throw new Error("投递目标不完整:url 与 key 都必须非空(目标面应由调用方解析后传入)");
252
339
  const agentId = deriveAgentId(input.cwd);
253
340
  const content = buildFeedbackText(input, messages);
254
341
  const payload = JSON.stringify({
@@ -259,21 +346,108 @@ async function pushGateFeedback(input, target, messages) {
259
346
  content
260
347
  }]
261
348
  });
262
- const res = await fetch(`${target.url}/v2/conversation/add`, {
349
+ const res = await fetch(`${base}/v2/conversation/add`, {
263
350
  method: "POST",
264
351
  headers: {
265
352
  Authorization: `Bearer ${target.key}`,
266
- "x-tdai-service-id": resolveServiceId(),
353
+ "x-tdai-service-id": target.serviceId,
267
354
  "Content-Type": "application/json"
268
355
  },
269
356
  body: payload,
270
357
  signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS)
271
358
  });
272
- if (!res.ok) throw new Error(`gateway feedback HTTP ${res.status}`);
273
- await res.arrayBuffer().catch(() => null);
359
+ const drained = await res.arrayBuffer().catch(() => null);
360
+ if (!res.ok) {
361
+ const detail = drained === null ? "" : new TextDecoder().decode(drained).trim().slice(0, 200);
362
+ throw new Error(detail === "" ? `gateway feedback HTTP ${res.status}` : `gateway feedback HTTP ${res.status}:${detail}`, { cause: drained });
363
+ }
364
+ }
365
+ // lib/edit-ledger.ts
366
+ /** 会话编辑台账投影:折叠与回退扫描共用 `toolEventRowsOf`,两处不会各自演化。 */
367
+ const EDIT_LEDGER_KEY = "quality-gate.edit-ledger";
368
+ /** 台账上限:超过即丢最旧的(门禁游标每回合推到末端;防的是从没跑成门禁的长会话)。 */
369
+ const EDIT_LEDGER_MAX = 1024;
370
+ const entrySchema = z.strictObject({
371
+ seq: z.int().nonnegative(),
372
+ callId: z.string(),
373
+ name: z.string(),
374
+ path: z.string(),
375
+ failed: z.boolean()
376
+ });
377
+ const editLedgerSchema = z.object({ edits: z.array(entrySchema) });
378
+ /** 把最近一条同 callId 的记录标记为失败(tool/result 的 isError);找不到就不动。 */
379
+ function failEntry(state, callId) {
380
+ let index = -1;
381
+ for (let at = state.length - 1; at >= 0; at -= 1) if (state[at]?.callId === callId) {
382
+ index = at;
383
+ break;
384
+ }
385
+ return index < 0 ? state : state.map((entry, at) => at === index ? {
386
+ ...entry,
387
+ failed: true
388
+ } : entry);
389
+ }
390
+ const editLedgerProjection = {
391
+ key: EDIT_LEDGER_KEY,
392
+ stateVersion: 1,
393
+ stateSchema: editLedgerSchema,
394
+ init: () => ({ edits: [] }),
395
+ apply: (state, event) => {
396
+ const { call, result } = toolEventRowsOf(event, event.seq);
397
+ let { edits } = state;
398
+ if (call !== void 0 && EDIT_TOOLS.has(call.name) && !call.badArguments) {
399
+ const target = editPathOf(call);
400
+ if (target.kind === "write") edits = [...edits, {
401
+ seq: call.seq,
402
+ callId: call.callId ?? "",
403
+ name: call.name,
404
+ path: target.path ?? "",
405
+ failed: false
406
+ }];
407
+ }
408
+ if (result !== void 0 && result.isError && result.callId !== void 0) edits = failEntry(edits, result.callId);
409
+ const bounded = edits.length > 1024 ? edits.slice(edits.length - EDIT_LEDGER_MAX) : edits;
410
+ return bounded === state.edits ? state : { edits: bounded };
411
+ }
412
+ };
413
+ // lib/edit-ledger-reader.ts
414
+ const isDisposer = (value) => typeof value === "function";
415
+ const isProjectionHandle = (value) => value !== null && typeof value === "object" && typeof value.stateOf === "function" && typeof value.dispose === "function";
416
+ /** 把原始状态校验成台账:形状不合约定的持久行按「不可读」处理。 */
417
+ function asLedger(raw) {
418
+ const parsed = editLedgerProjection.stateSchema.safeParse(raw);
419
+ return parsed.success ? parsed.data.edits : void 0;
420
+ }
421
+ /**
422
+ * 注册投影并发布读口;失败(同步抛错或异步拒绝)只报告、不发布。
423
+ * @param registry - 官方 sessionProjections 服务
424
+ * @param face - 读口发布位与释放登记位
425
+ */
426
+ async function mountEditLedgerReader(registry, face) {
427
+ let read;
428
+ let release;
429
+ try {
430
+ const ready = await registry.register(editLedgerProjection);
431
+ if (isDisposer(ready)) {
432
+ read = (session) => asLedger(registry.stateOf(session, EDIT_LEDGER_KEY));
433
+ release = ready;
434
+ } else if (isProjectionHandle(ready)) {
435
+ read = (session) => asLedger(ready.stateOf(session));
436
+ release = () => {
437
+ ready.dispose();
438
+ };
439
+ } else throw new TypeError("sessionProjections.register returned neither a disposer nor a handle");
440
+ } catch (error) {
441
+ face.onError(error);
442
+ return;
443
+ }
444
+ face.publish(read);
445
+ face.effect(() => () => {
446
+ face.publish(void 0);
447
+ release();
448
+ });
274
449
  }
275
- //#endregion
276
- //#region host.ts
450
+ // host.ts
277
451
  /**
278
452
  * 常见项目根标记文件:向上找最近的含清单目录。
279
453
  * ruff.toml 必须在列:只有 ruff 配置、无 pyproject.toml 的 Python
@@ -281,10 +455,12 @@ async function pushGateFeedback(input, target, messages) {
281
455
  * 也就永远没有门禁。
282
456
  */
283
457
  const PKG_MANIFEST = "package.json";
458
+ const TSCONFIG_MANIFEST = "tsconfig.json";
459
+ const PYPROJECT_MANIFEST = "pyproject.toml";
284
460
  const MANIFESTS = [
285
461
  PKG_MANIFEST,
286
462
  "Cargo.toml",
287
- "pyproject.toml",
463
+ PYPROJECT_MANIFEST,
288
464
  "pnpm-workspace.yaml",
289
465
  "ruff.toml"
290
466
  ];
@@ -423,10 +599,12 @@ function gateCommandArgs(gate) {
423
599
  正常安装不可达,而那一路由 126/127 报成未执行。 */
424
600
  return [tscEntry(gate.root) ?? "tsc", "--noEmit"];
425
601
  }
602
+ /** shell 里无需加引号的参数形状。 */
603
+ const SHELL_SAFE_ARG = /^[\w./:=@+-]+$/u;
426
604
  /** 拼 shell 串:路径可能带空格,含空白或元字符的参数加单引号。
427
605
  * 导出只为直接测这段引号处理:tsc 入口是绝对路径,它所在目录未必没有空格。 */
428
606
  function shellArg(part) {
429
- return /^[\w./:=@+-]+$/u.test(part) ? part : `'${part.replaceAll("'", `'\\''`)}'`;
607
+ return SHELL_SAFE_ARG.test(part) ? part : `'${part.replaceAll("'", `'\\''`)}'`;
430
608
  }
431
609
  /** 门禁命令的展示/执行串(与 gateCommandArgs 同源)。 */
432
610
  function gateCommandText(gate) {
@@ -558,16 +736,26 @@ function interruptedRunOutcome(messages, budgetMs, res) {
558
736
  }
559
737
  return outcome;
560
738
  }
739
+ /** 门禁工具不可用的 stderr 措辞。 */
740
+ const TOOL_UNAVAILABLE_TEXT = /command not found|not recognised|not recognized|permission denied|not executable/u;
561
741
  /**
562
- * 门禁工具本身不可用(exit 126/127 = 不可执行/未找到)→ not-run,并 warn 给运维。
563
- *
564
- * 环境问题非代码问题:走失败注入会让模型去"修"装不了的工具(配合配额环更糟)。
565
- * 但它同样是"没跑成的检查",所以按 not-run 注入"请手动确认",不再算作通过
566
- * ——没跑成 ≠ 通过。
742
+ * exit 126/127 只有在 stderr 点名门禁命令自己的首 token 时才算「工具不可用」:
743
+ * `pnpm check` 的脚本里缺命令 pnpm 同样回 127(`[ELIFECYCLE] Command failed with exit code 127`),
744
+ * 混判会把项目自己的坏脚本当成环境问题放掉。判据按行:同一行既有「不可用」措辞又出现该 token。
745
+ */
746
+ function gateToolUnavailable(gate, res) {
747
+ if (res.exitCode !== 126 && res.exitCode !== 127) return false;
748
+ const token = gate.command[0].toLowerCase();
749
+ return res.stderr.text.toLowerCase().split("\n").some((line) => line.includes(token) && TOOL_UNAVAILABLE_TEXT.test(line));
750
+ }
751
+ /**
752
+ * 门禁工具本身不可用(exit 126/127 且 stderr 点名门禁命令)→ not-run,并 warn 给运维。
753
+ * 环境问题非代码问题:失败注入会逼模型去修装不了的工具;但没跑成 ≠ 通过。
754
+ * 脚本内部的 126/127 不走这支(见 gateToolUnavailable),按代码失败。
567
755
  */
568
756
  function unavailableToolOutcome(messages, gate, res, log) {
569
757
  let outcome;
570
- if (res.exitCode === 126 || res.exitCode === 127) {
758
+ if (gateToolUnavailable(gate, res)) {
571
759
  log.warn(`[quality-gate] gate tool unavailable (exit ${res.exitCode}): ${gateCommandText(gate)} — reported as not-run, not as pass`);
572
760
  const cause = res.exitCode === 127 ? messages.toolMissing : messages.toolNotExecutable;
573
761
  outcome = {
@@ -608,9 +796,11 @@ function classifyRun(messages, gate, budgetMs, res, log) {
608
796
  text: `exit=${res.exitCode}\n${evidenceText(messages, res.stdout, "stdout")}${evidenceText(messages, res.stderr, "stderr")}`
609
797
  };
610
798
  }
611
- /** 是否是会被记账的编辑类工具(edit/write + str_replace_editor)。 */
799
+ /** 是否是会被记账的编辑类工具(edit/write + str_replace_editor)。名单的真源在
800
+ * lib/accumulator.ts 的 EDIT_TOOLS:host 先用它过滤、累加器再用同一集合过滤一次,
801
+ * 两边各写一份时新增编辑器工具只改一侧就会静默分叉。 */
612
802
  function isEditTool(name) {
613
- return name === "edit" || name === "write" || name === "str_replace_editor";
803
+ return EDIT_TOOLS.has(name);
614
804
  }
615
805
  /**
616
806
  * 根 package.json 已声明的 scripts 名清单(值是非空字符串才算"声明了这条脚本")。
@@ -621,7 +811,7 @@ function isEditTool(name) {
621
811
  function declaredScriptsOf(root) {
622
812
  let names;
623
813
  try {
624
- const raw = JSON.parse(readFileSync(`${root}/package.json`, "utf8"));
814
+ const raw = JSON.parse(readFileSync(path.join(root, PKG_MANIFEST), "utf8"));
625
815
  const scripts = fieldOf(raw, "scripts");
626
816
  if (isRecord(scripts)) names = Object.entries(scripts).filter(([, body]) => typeof body === "string" && body.length > 0).map(([name]) => name);
627
817
  } catch {
@@ -634,6 +824,33 @@ function isArrayDelivery(value) {
634
824
  return Array.isArray(value);
635
825
  }
636
826
  /**
827
+ * pyproject 里 ruff 段头的字面形状:`[tool.ruff]` 或它的子表 `[tool.ruff.lint]` 等。
828
+ *
829
+ * `^` 加 `m` 旗标限定为行首;`[tool.ruff]` 与 `[tool.ruff.<子表>]` 都要认——只写子表、
830
+ * 不写父表的 pyproject 是常见写法。数组表(`[[tool.ruff...]]`)同样命中段名,一并算声明。
831
+ */
832
+ const RUFF_SECTION = /^\[\[?tool\.(?:"ruff"|ruff)[\].]/mu;
833
+ /**
834
+ * 根 pyproject.toml 里是否声明了 ruff 配置段(段头形状见 RUFF_SECTION)。
835
+ *
836
+ * 只按行首段头字面匹配,不引 TOML 解析器:判据只需要回答"有没有这一段",而段头在
837
+ * TOML 里必须独占一行(行内写法 `[a.b] c = 1` 非法),故行首匹配与解析器同解。带引号的
838
+ * 键形式(`[tool."ruff"]`)在 TOML 里合法且等价,一并认。
839
+ *
840
+ * 读不到 / 解码失败 → undefined("没有信息"),由 gate-detect 按保守口径处理:不发 ruff
841
+ * 门禁。这里与 declaredScriptsOf 同一方向,理由见 gate-detect 的 ruffGateReady。
842
+ */
843
+ function ruffConfiguredOf(root) {
844
+ let configured;
845
+ try {
846
+ const text = readFileSync(path.join(root, PYPROJECT_MANIFEST), "utf8");
847
+ configured = RUFF_SECTION.test(text);
848
+ } catch {
849
+ configured = void 0;
850
+ }
851
+ return configured;
852
+ }
853
+ /**
637
854
  * 解析事件游标窗口:fork 切点下限 vs 上次已读 seq 取大,返回起始 seq 与本批事件。
638
855
  * 事件流取自官方 `ctx.sessionQuery.observeSession(sessionId)`(见上面 SessionQueryFace:
639
856
  * 一次交回 `events` 与 `inheritedEventCount`)。两条降级路径一律 `undefined`(调用方直接
@@ -649,8 +866,9 @@ function isArrayDelivery(value) {
649
866
  */
650
867
  async function resolveEventWindow(query, sessionId, lastSeq) {
651
868
  let window;
869
+ let observation;
652
870
  try {
653
- const observation = await query.observeSession(sessionId, { projectionMode: "none" });
871
+ observation = await query.observeSession(sessionId, { projectionMode: "none" });
654
872
  const forkCut = observation.inheritedEventCount ?? 0;
655
873
  const from = Math.max(forkCut, lastSeq.get(sessionId) ?? forkCut);
656
874
  if (isArrayDelivery(observation.events)) window = {
@@ -659,6 +877,8 @@ async function resolveEventWindow(query, sessionId, lastSeq) {
659
877
  };
660
878
  } catch {
661
879
  window = void 0;
880
+ } finally {
881
+ observation?.[Symbol.dispose]();
662
882
  }
663
883
  return window;
664
884
  }
@@ -704,7 +924,7 @@ function findManifestRoot(file, maxDepth) {
704
924
  let found;
705
925
  let hitDepthLimit = false;
706
926
  for (let depth = 0; depth < maxDepth; depth += 1) {
707
- for (const manifest of MANIFESTS) if (existsSync(`${dir}/${manifest}`)) {
927
+ for (const manifest of MANIFESTS) if (existsSync(path.join(dir, manifest))) {
708
928
  found = dir;
709
929
  break;
710
930
  }
@@ -726,7 +946,7 @@ function findManifestRoot(file, maxDepth) {
726
946
  function workspaceRootUpgrade(root, maxDepth) {
727
947
  let up = path.dirname(root);
728
948
  for (let depth = 0; depth < maxDepth; depth += 1) {
729
- if (existsSync(`${up}/pnpm-workspace.yaml`)) return up;
949
+ if (existsSync(path.join(up, "pnpm-workspace.yaml"))) return up;
730
950
  const parent = path.dirname(up);
731
951
  if (parent === up) break;
732
952
  up = parent;
@@ -740,20 +960,22 @@ function workspaceRootUpgrade(root, maxDepth) {
740
960
  * - tsconfig.json 不作为"根标记"(不进 MANIFESTS,避免改动根向上探测的语义),
741
961
  * 但要进清单集合——gate-detect 用它区分"真 TS 项目"与"只有 package.json 的空壳";
742
962
  * - scripts:根 package.json 已声明的脚本名清单,交给 gate-detect 判定 pnpm check
743
- * 是否可用。**这里只做如实登记,不做取舍**(探测策略集中在纯函数里)。
963
+ * 是否可用。**这里只做如实登记,不做取舍**(探测策略集中在纯函数里);
964
+ * - ruffConfigured:根 pyproject.toml 里是否真有 `[tool.ruff]` 一族段,交给 gate-detect
965
+ * 判定 ruff 门禁是否可用。理由同上——只登记,不取舍。
744
966
  */
745
967
  function probeProjectRoot(root) {
746
- const manifests = MANIFESTS.filter((manifest) => existsSync(`${root}/${manifest}`));
747
- if (existsSync(`${root}/tsconfig.json`)) manifests.push("tsconfig.json");
968
+ const manifests = MANIFESTS.filter((manifest) => existsSync(path.join(root, manifest)));
969
+ if (existsSync(path.join(root, TSCONFIG_MANIFEST))) manifests.push(TSCONFIG_MANIFEST);
748
970
  const scripts = manifests.includes("package.json") ? declaredScriptsOf(root) : void 0;
749
- return scripts === void 0 ? {
971
+ const ruffConfigured = manifests.includes(PYPROJECT_MANIFEST) ? ruffConfiguredOf(root) : void 0;
972
+ const entry = {
750
973
  root,
751
974
  files: manifests
752
- } : {
753
- root,
754
- files: manifests,
755
- scripts
756
975
  };
976
+ if (scripts !== void 0) entry.scripts = scripts;
977
+ if (ruffConfigured !== void 0) entry.ruffConfigured = ruffConfigured;
978
+ return entry;
757
979
  }
758
980
  /** 注入载荷组装(id 唯一,同一毫秒多次注入也不碰撞)。 */
759
981
  const injectMessage = (text) => ({
@@ -828,7 +1050,8 @@ async function resolveFeedbackTarget(cfg, credentials) {
828
1050
  ok: true,
829
1051
  target: {
830
1052
  url,
831
- key: hit.value
1053
+ key: hit.value,
1054
+ serviceId: resolveServiceId()
832
1055
  }
833
1056
  };
834
1057
  }
@@ -883,14 +1106,26 @@ function isGateHost(value) {
883
1106
  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";
884
1107
  }
885
1108
  /**
886
- * 一个文件的最近清单根 → 实际要跑的根:workspace 根优先,**但只在外层真能产出门禁时**才升级。
1109
+ * 子包自己就产得出 `pnpm check`(自带 `pnpm-workspace.yaml` 且声明了 check)时,它就是离
1110
+ * 文件最近、又真能跑的那一层门禁;再上溯外层工作区只会把「改一个子包」放大成「跑整棵
1111
+ * workspace 的全套检查」——门禁的作用域应当止步于最近的 workspace 根。
1112
+ */
1113
+ function hasPnpmCheckGate(root) {
1114
+ return detectGate([root]).some((gate) => gate.command[0] === "pnpm");
1115
+ }
1116
+ /**
1117
+ * 一个文件的最近清单根 → 实际要跑的根:最近的 workspace 根优先,**但只在外层真能产出门禁时**才升级。
887
1118
  *
888
1119
  * 只判「上面有没有 pnpm-workspace.yaml」就升级是漏门禁的一条路:外层若是"有 workspace 清单、
889
1120
  * 却没有 check 脚本也没有 tsconfig"的根,gate-detect 对它返回空表,子包本来有的
890
1121
  * `npx tsc --noEmit` / `ruff check` 会被这次上溯抹掉,且没有任何输出。
1122
+ *
1123
+ * 反方向也一样:子包本身就是 workspace 根且能出门禁时不再上溯——否则「本仓自己的门禁」
1124
+ * 会被外层伞工作区的全套 `pnpm check` 顶掉,单仓改动要等着跑九个仓。
891
1125
  */
892
1126
  function rootForFile(useRoot, maxDepth) {
893
1127
  const inner = probeProjectRoot(useRoot);
1128
+ if (hasPnpmCheckGate(inner)) return inner;
894
1129
  const upgraded = workspaceRootUpgrade(useRoot, maxDepth);
895
1130
  if (upgraded === useRoot) return inner;
896
1131
  const outer = probeProjectRoot(upgraded);
@@ -1021,6 +1256,26 @@ function accumulatorOf(rt, sessionId) {
1021
1256
  * Session.inheritedEventCount(0.1.2-rc.1 起公开只读)给出 fork 切点——首读下限
1022
1257
  * 取切点而非 0,父前缀天然跳过;缺属性退回 0(旧行为,保守不漏门禁)。
1023
1258
  */
1259
+ /**
1260
+ * 只用投影台账记本回合编辑。返回 false = 走不了投影(读口缺席、fork 会话、读数不可信、
1261
+ * 或会话交不出日志长度),调用方回退观察面。
1262
+ *
1263
+ * fork 会话(isSeeded)不进这条路:继承前缀的精确长度只有观察面的 `inheritedEventCount`
1264
+ * 给得出,投影的 `apply` 看不到那条边界。读口交回 undefined 同样回退,且**不推游标**——
1265
+ * 把一次不可读当成「没有编辑」会静默跳过本回合的门禁。
1266
+ */
1267
+ function noteLedgerEdits(rt, session, note) {
1268
+ const reader = rt.editLedger.value;
1269
+ const directSeq = session.seq;
1270
+ if (reader === void 0 || session.header.isSeeded || typeof directSeq !== "number") return false;
1271
+ const ledger = reader(session);
1272
+ if (ledger === void 0) return false;
1273
+ const from = rt.lastSeqBySession.get(session.id) ?? 0;
1274
+ for (const entry of ledger) if (entry.seq >= from && !entry.failed) note(entry.name, entry.path === "" ? void 0 : entry.path);
1275
+ rt.lastSeqBySession.set(session.id, directSeq);
1276
+ pruneToSessionBudget(rt.lastSeqBySession, session.id);
1277
+ return true;
1278
+ }
1024
1279
  async function readEditedFiles(rt, agent, acc) {
1025
1280
  const { session } = agent;
1026
1281
  const sessionId = session.id;
@@ -1032,6 +1287,13 @@ async function readEditedFiles(rt, agent, acc) {
1032
1287
  }
1033
1288
  return;
1034
1289
  }
1290
+ const { cwd } = session.header;
1291
+ const note = (name, pathValue) => {
1292
+ let resolvedPath = pathValue;
1293
+ if (resolvedPath !== void 0 && cwd !== void 0 && !path.isAbsolute(resolvedPath)) resolvedPath = path.resolve(cwd, resolvedPath);
1294
+ acc.note(name, resolvedPath);
1295
+ };
1296
+ if (noteLedgerEdits(rt, session, note)) return;
1035
1297
  const window = await resolveEventWindow(query, sessionId, rt.lastSeqBySession);
1036
1298
  if (window === void 0) {
1037
1299
  if (!rt.queryReadWarned) {
@@ -1043,12 +1305,6 @@ async function readEditedFiles(rt, agent, acc) {
1043
1305
  rt.lastSeqBySession.set(sessionId, window.from + window.events.length);
1044
1306
  pruneToSessionBudget(rt.lastSeqBySession, sessionId);
1045
1307
  const edits = collectEdits(window.events);
1046
- const { cwd } = session.header;
1047
- const note = (name, pathValue) => {
1048
- let resolvedPath = pathValue;
1049
- if (resolvedPath !== void 0 && cwd !== void 0 && !path.isAbsolute(resolvedPath)) resolvedPath = path.resolve(cwd, resolvedPath);
1050
- acc.note(name, resolvedPath);
1051
- };
1052
1308
  for (const entry of edits.noCallId) note(entry.name, entry.path);
1053
1309
  for (const [callId, entry] of edits.pending) if (!edits.failed.has(callId)) note(entry.name, entry.path);
1054
1310
  }
@@ -1281,8 +1537,20 @@ function apply(ctx, config) {
1281
1537
  shellWarned: false,
1282
1538
  queryMissingWarned: false,
1283
1539
  queryReadWarned: false,
1540
+ editLedger: { value: void 0 },
1284
1541
  disposed: false
1285
1542
  };
1543
+ svc.inject(["sessionProjections"], (child) => {
1544
+ mountEditLedgerReader(child.sessionProjections, {
1545
+ publish: (reader) => {
1546
+ rt.editLedger.value = reader;
1547
+ },
1548
+ effect: (factory) => child.effect(factory),
1549
+ onError: (error) => {
1550
+ log.warn(`[quality-gate] sessionProjections register failed, falling back to full scan: ${String(error)}`);
1551
+ }
1552
+ });
1553
+ });
1286
1554
  svc.on("agent/turn-stopping", async (payload) => {
1287
1555
  try {
1288
1556
  await handleTurnGate(rt, payload);
@@ -1304,5 +1572,4 @@ var host_default = {
1304
1572
  Config,
1305
1573
  apply
1306
1574
  };
1307
- //#endregion
1308
1575
  export { Config, apply, host_default as default, shellArg };