@jayyuen66/dsh-quality-gate 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  - Trigger: the host event `agent/turn-stopping`. Flow: read the incremental event window → take this session's **successful** edits → walk each file up to the nearest manifest root → detect the gate → run it.
14
14
  - Edits counted: `edit` / `write` / `str_replace_editor` writes; `view` is read-only and does not count.
15
- - Manifest root: searched up from each file, max 10 levels; a monorepo package is upgraded to the workspace root holding `pnpm-workspace.yaml`.
15
+ - Manifest root: searched up from each file, max 10 levels; a monorepo package is upgraded to the workspace root holding `pnpm-workspace.yaml` only when that root yields a gate — otherwise the package keeps its own root (an outer workspace without a `check` script must not swallow the package's `tsc`/`ruff` gate).
16
16
  - Skipped when:
17
17
  - `enabled: false`; the turn has no checkable edit (only md/txt/images/`.sh`, or edits that were denied or failed).
18
18
  - Subagent sessions (`origin === "subagent"` or `delegationDepth > 0`); the inbox already has pending items (the gate does not talk over the user).
@@ -26,7 +26,7 @@ dsh plugin --profile web add @jayyuen66/dsh-quality-gate
26
26
  ```
27
27
 
28
28
  - The packages live on the public npm registry, so installs need no credentials.
29
- - Requires a host at `@deepseek-ai/dsh` 0.2.1-alpha.2 or newer (`engines.dsh`); the plugin is compiled against that host's type surface.
29
+ - Requires a host at `@deepseek-ai/dsh` 0.2.1-rc.2 or newer (`engines.dsh`); the plugin is compiled against that host's type surface.
30
30
  - The runtime dependency `@jayyuen66/dsh-plugin-shared` must already be on the same registry, otherwise the whole set 404s.
31
31
  - `@deepseek-ai/schemastery` is resolved from the host, not installed by this package: the host's own fork is the only one whose `resolve` wraps `.volatile()` fields into live references, so a public `schemastery` would turn "edit a setting" into "restart to apply".
32
32
  - Installing a local directory needs pnpm ≥ 12.3.0 (12.0.0 has a known bug in that path).
package/README.zh-CN.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  - 触发点:宿主事件 `agent/turn-stopping`。流程是读增量事件窗 → 取本会话**成功**的编辑 → 每个文件向上找最近清单根 → 探测门禁 → 执行。
14
14
  - 编辑口径:`edit` / `write` / `str_replace_editor` 的写入;`view` 只读不算。
15
- - 清单根:最多向上找 10 层;monorepo 子包升到 `pnpm-workspace.yaml` 所在的工作区根。
15
+ - 清单根:最多向上找 10 层;monorepo 子包升到 `pnpm-workspace.yaml` 所在的工作区根——但只在该根**产得出门禁**时升级,否则保留子包自己的根(外层没有 `check` 脚本的 workspace 不得吃掉子包的 `tsc` / `ruff` 门禁)。
16
16
  - 不触发:
17
17
  - `enabled: false`;本回合没有可检编辑(只改 md/txt/图片/`.sh`,或被拒、执行失败的编辑)。
18
18
  - 子代理会话(`origin === "subagent"` 或 `delegationDepth > 0`);inbox 已有待处理消息(不抢话)。
@@ -26,7 +26,7 @@ dsh plugin --profile web add @jayyuen66/dsh-quality-gate
26
26
  ```
27
27
 
28
28
  - 包在公共 npm(`registry.npmjs.org`)上,安装侧不需要凭据。
29
- - 宿主需 `@deepseek-ai/dsh` 0.2.1-alpha.2 起(`engines.dsh`):本包按这一版宿主的类型面编译。
29
+ - 宿主需 `@deepseek-ai/dsh` 0.2.1-rc.2 起(`engines.dsh`):本包按这一版宿主的类型面编译。
30
30
  - 运行时依赖 `@jayyuen66/dsh-plugin-shared` 必须已在同一 registry 上,否则整组 404。
31
31
  - `@deepseek-ai/schemastery` 由宿主解析、不由本包安装:只有宿主 fork 的那一枚能把 `.volatile()` 字段解析成活引用,换成公共 `schemastery` 就变成改设置要重启才生效。
32
32
  - 本地目录形态安装需 pnpm ≥ 12.3.0(12.0.0 的本地目录 `add` 有已知缺陷)。
package/client.js CHANGED
@@ -6,6 +6,13 @@ window.__ModuleLoader__.load({
6
6
  var exports = module.exports;
7
7
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
8
8
  let react = require("react");
9
+ //#region node_modules/@jayyuen66/dsh-plugin-shared/dist/card-diff.js
10
+ function e(e, t) {
11
+ let n = [];
12
+ for (let r of Object.keys(e)) JSON.stringify(e[r] ?? null) !== JSON.stringify(t[r] ?? null) && n.push(r);
13
+ return n;
14
+ }
15
+ //#endregion
9
16
  //#region src/ui-messages.ts
10
17
  const UI_MESSAGES = {
11
18
  zh: {
@@ -171,12 +178,6 @@ function TextInputRow(props) {
171
178
  }
172
179
  })));
173
180
  }
174
- /** touched 层与快照的差异字段(值语义比较;undefined 与缺失等价)。 */
175
- function diffTouched(touched, value) {
176
- const out = [];
177
- for (const key of Object.keys(touched)) if (JSON.stringify(touched[key] ?? null) !== JSON.stringify(value[key] ?? null)) out.push(key);
178
- return out;
179
- }
180
181
  function SaveBar(props) {
181
182
  const { t } = props;
182
183
  const dis = !props.writable || props.busy;
@@ -358,7 +359,7 @@ function DgCard(props) {
358
359
  const [saveError, setSaveError] = (0, react.useState)(null);
359
360
  /** 渲染取值:touched 优先,快照兜底(未触碰字段实时跟随外部快照)。 */
360
361
  const eff = (field) => field in touched ? touched[field] : value[field];
361
- const dirty = diffTouched(touched, value).length > 0;
362
+ const dirty = e(touched, value).length > 0;
362
363
  const setField = (field, val) => {
363
364
  setTouched((prev) => ({
364
365
  ...prev,
@@ -372,7 +373,7 @@ function DgCard(props) {
372
373
  }));
373
374
  };
374
375
  const save = () => {
375
- const keys = diffTouched(touched, value);
376
+ const keys = e(touched, value);
376
377
  if (keys.length === 0) return;
377
378
  setBusy(true);
378
379
  setSaveError(null);
@@ -472,7 +473,6 @@ function apply(ctx) {
472
473
  }
473
474
  //#endregion
474
475
  exports.apply = apply;
475
- exports.diffTouched = diffTouched;
476
476
  exports.inject = inject;
477
477
  return module.exports;
478
478
  },
package/host.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // GENERATED by build-host.mjs — edit host.ts / lib/*.ts, then run `node build-host.mjs`.
2
+ import { createRequire } from "node:module";
2
3
  import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import path from "node:path";
@@ -30,7 +31,7 @@ const DEFAULT_MAX_FILES = 500;
30
31
  var EditAccumulator = class {
31
32
  files = /* @__PURE__ */ new Set();
32
33
  maxFiles;
33
- /** 容量淘汰累计丢弃数:drain 时清零(500-cap 静默淘汰要可观测)。 */
34
+ /** 容量淘汰累计丢弃数:drain 时清零(静默淘汰要可观测)。 */
34
35
  dropped = 0;
35
36
  constructor(options = {}) {
36
37
  this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
@@ -44,11 +45,10 @@ var EditAccumulator = class {
44
45
  this.files.add(filePath);
45
46
  }
46
47
  /**
47
- * 淘汰插入序最旧的一项并计数。调用点已保证集合非空(`size >= maxFiles >= 1`,本包
48
- * maxFiles 只取默认 500 或测试里的 2),所以原意是**取首项这一次动作**,不是迭代——
49
- * 旧写法拿 `for…of` + 无条件 `break` 伪装单次取值,那正是 eslint(no-unreachable-loop)
50
- * 判的「循环体只允许一次迭代」:循环结构在这里没有承载任何语义。改成直接取首个元素,
51
- * 单次淘汰的行为(删最旧 + 计数)与原样一致。
48
+ * 淘汰插入序最旧的一项并计数。`maxFiles` 配成 0 时调用点对空集也成立,所以取到的首项
49
+ * 可以不存在——那一支什么都不做,由该退化配置的用例钉住。
50
+ * 这里只需要**取首项这一次动作**,不需要迭代:`for…of` + 无条件 `break` 正是
51
+ * eslint(no-unreachable-loop) 判的「循环体只允许一次迭代」——循环结构在这里不承载任何语义。
52
52
  */
53
53
  evictOldest() {
54
54
  const [oldest] = this.files;
@@ -106,11 +106,7 @@ function gateForRoot(entry) {
106
106
  };
107
107
  else if (has("package.json") && has("tsconfig.json")) gate = {
108
108
  root,
109
- command: [
110
- "npx",
111
- "tsc",
112
- "--noEmit"
113
- ]
109
+ command: ["tsc", "--noEmit"]
114
110
  };
115
111
  return gate;
116
112
  }
@@ -144,7 +140,6 @@ const MESSAGES = {
144
140
  toolMissing: "命令不存在(工具未安装)",
145
141
  toolNotExecutable: "命令不可执行",
146
142
  toolNotRun: "{cause},门禁没有运行。",
147
- toolStub: "门禁工具本身不可用(npx 抓到的是 stub 包,不是真工具)。",
148
143
  policyUnresolvable: "沙箱策略无法解析({message}),门禁拒绝在未确认的沙箱边界下执行。",
149
144
  sandboxUnavailable: "沙箱在本机不可用({message}):命令被拒绝执行,未做任何检查。",
150
145
  gateStartFailed: "门禁未能启动({message})。",
@@ -168,7 +163,6 @@ const MESSAGES = {
168
163
  toolMissing: "command not found (tool not installed)",
169
164
  toolNotExecutable: "command not executable",
170
165
  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
166
  policyUnresolvable: "The sandbox policy could not be resolved ({message}); the gate refuses to run under an unconfirmed sandbox boundary.",
173
167
  sandboxUnavailable: "The sandbox is unavailable on this machine ({message}): the command was denied and no check was performed.",
174
168
  gateStartFailed: "The gate failed to start ({message}).",
@@ -221,6 +215,23 @@ function resolveGatewayUrl(settingUrl) {
221
215
  const configured = settingUrl.trim();
222
216
  return (configured === "" ? envOr("TDAI_GATEWAY_URL", "") : configured).replace(/\/+$/u, "");
223
217
  }
218
+ /**
219
+ * 凭据只允许发往 https 或回环地址:明文 http 到远端会把 Bearer 令牌暴露给同网段
220
+ * (设置卡默认值就是本机 http,属有意用法)。返回 false 时调用方按"未配置"处理,
221
+ * 不解析、不发送凭据。
222
+ */
223
+ function isCredentialSafeUrl(url) {
224
+ let parsed;
225
+ try {
226
+ parsed = new URL(url);
227
+ } catch {
228
+ return false;
229
+ }
230
+ if (parsed.protocol === "https:") return true;
231
+ if (parsed.protocol !== "http:") return false;
232
+ const host = parsed.hostname.replaceAll(/^\[|\]$/gu, "");
233
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
234
+ }
224
235
  /** x-tdai-service-id:env 可覆盖,默认是网关协议的匿名服务名(不含任何机器信息)。 */
225
236
  function resolveServiceId() {
226
237
  return envOr("TDAI_SERVICE_ID", "default");
@@ -269,8 +280,9 @@ async function pushGateFeedback(input, target, messages) {
269
280
  * 工程若不在此登记,gate-detect 的 has("ruff.toml") 就是死码——那类工程根本得不到根,
270
281
  * 也就永远没有门禁。
271
282
  */
283
+ const PKG_MANIFEST = "package.json";
272
284
  const MANIFESTS = [
273
- "package.json",
285
+ PKG_MANIFEST,
274
286
  "Cargo.toml",
275
287
  "pyproject.toml",
276
288
  "pnpm-workspace.yaml",
@@ -330,8 +342,8 @@ function messageOf(error) {
330
342
  return message;
331
343
  }
332
344
  /**
333
- * 会话级 Map 淘汰:把 keep 先删后插移到队尾(JS Map 迭代按插入序),再淘汰队首
334
- * (size - 50) 项 → keep 永不被淘汰,且总大小收敛。泛型复用:注入配额 Map(InjectState)、
345
+ * 会话级 Map 淘汰:把 keep 先删后插移到队尾(JS Map 迭代按插入序),再淘汰队首超出
346
+ * `SESSION_BUDGET` 的项 → keep 永不被淘汰,且总大小收敛。泛型复用:注入配额 Map(InjectState)、
335
347
  * 事件游标 Map(number)与编辑累加器 Map(EditAccumulator)共用同一策略。
336
348
  * keep 不在表内 → 直接返回(inbox-busy 分支的 injectState 就会命中:该会话从未失败过)。
337
349
  */
@@ -391,17 +403,34 @@ function preInjectCheck(agent, signal) {
391
403
  };
392
404
  }
393
405
  /**
394
- * 判定门禁失败是否为"工具本身不可用"(而非代码问题)。
395
- *
396
- * `npx tsc` 在项目未装 typescript 时会**自动安装**已弃用的 stub 包 `tsc@2.0.4`,
397
- * 它打印横幅并以 exit 1 退出——不是 126/127,所以只按退出码判定会误报为代码失败。
398
- * 实测:`npx --no-install` 在本机拦不住 stub(已从 npx 缓存解析),
399
- * 所以必须在输出侧识别,不能只靠加 flag。
406
+ * 本次门禁要跑的 tsc 入口:优先工程自己装的 typescript(与工程惯用的编译器版本一致),
407
+ * 装不到时用本包依赖的那份(随包安装,运行期无网络动作)。两处都解析不到时回落到 PATH 上的
408
+ * `tsc`,由 126/127 那条「工具不可用」出口报成未执行——绝不走 `npx` 的自动下载。
400
409
  */
401
- function gateToolUnavailable(stdout, stderr) {
402
- const out = `${stdout}\n${stderr}`.toLowerCase();
403
- if (out.includes("not the tsc command you are looking for")) return true;
404
- return out.includes("npm warn deprecated tsc@");
410
+ function tscEntry(root) {
411
+ let entry;
412
+ for (const from of [path.join(root, PKG_MANIFEST), import.meta.url]) try {
413
+ const manifest = createRequire(from).resolve("typescript/package.json");
414
+ entry = path.join(path.dirname(manifest), "bin", "tsc");
415
+ break;
416
+ } catch {}
417
+ return entry;
418
+ }
419
+ /** 门禁命令的实际 argv:tsc 的门禁只带逻辑名,入口在运行时按工程根解析。 */
420
+ function gateCommandArgs(gate) {
421
+ if (gate.command[0] !== "tsc") return gate.command;
422
+ /* v8 ignore next 1 —— 本包自己的 typescript 解析不到(安装面坏了)时才走 PATH 回落;
423
+ 正常安装不可达,而那一路由 126/127 报成未执行。 */
424
+ return [tscEntry(gate.root) ?? "tsc", "--noEmit"];
425
+ }
426
+ /** 拼 shell 串:路径可能带空格,含空白或元字符的参数加单引号。
427
+ * 导出只为直接测这段引号处理:tsc 入口是绝对路径,它所在目录未必没有空格。 */
428
+ function shellArg(part) {
429
+ return /^[\w./:=@+-]+$/u.test(part) ? part : `'${part.replaceAll("'", `'\\''`)}'`;
430
+ }
431
+ /** 门禁命令的展示/执行串(与 gateCommandArgs 同源)。 */
432
+ function gateCommandText(gate) {
433
+ return gateCommandArgs(gate).map((part) => shellArg(part)).join(" ");
405
434
  }
406
435
  /** harness run() 抛出的 SandboxUnavailableError(code 或 name 命中即可)。 */
407
436
  function isSandboxUnavailable(error) {
@@ -454,7 +483,7 @@ function evidenceText(messages, stream, label) {
454
483
  return out;
455
484
  }
456
485
  const gateText = (messages, gate, out) => renderTemplate(messages.gateFailed, {
457
- command: gate.command.join(" "),
486
+ command: gateCommandText(gate),
458
487
  root: gate.root,
459
488
  output: out
460
489
  });
@@ -463,19 +492,19 @@ const gateText = (messages, gate, out) => renderTemplate(messages.gateFailed, {
463
492
  * 让模型去修一个装不上的工具/越不过去的沙箱,只会配着配额环烧 token。
464
493
  */
465
494
  const notRunText = (messages, gate, reason) => renderTemplate(messages.gateNotRun, {
466
- command: gate.command.join(" "),
495
+ command: gateCommandText(gate),
467
496
  root: gate.root,
468
497
  reason
469
498
  });
470
499
  /** 配额耗尽后的"放行说明"(用户可见,不再静默 return)。 */
471
500
  const giveUpText = (messages, gate, attempts) => renderTemplate(messages.gaveUp, {
472
501
  attempts,
473
- command: gate.command.join(" "),
502
+ command: gateCommandText(gate),
474
503
  root: gate.root
475
504
  });
476
505
  /** 未执行清单的一行(模块级:不让 renderTemplate 套进 map 再套进 renderTemplate)。 */
477
506
  const skippedGateLine = (messages, gate) => renderTemplate(messages.skippedGateItem, {
478
- command: gate.command.join(" "),
507
+ command: gateCommandText(gate),
479
508
  root: gate.root
480
509
  });
481
510
  /** 预算耗尽后剩余门禁的"未执行"清单:只 warn 等于静默通过,所以要注入给模型与用户。 */
@@ -539,7 +568,7 @@ function interruptedRunOutcome(messages, budgetMs, res) {
539
568
  function unavailableToolOutcome(messages, gate, res, log) {
540
569
  let outcome;
541
570
  if (res.exitCode === 126 || res.exitCode === 127) {
542
- log.warn(`[quality-gate] gate tool unavailable (exit ${res.exitCode}): ${gate.command.join(" ")} — reported as not-run, not as pass`);
571
+ log.warn(`[quality-gate] gate tool unavailable (exit ${res.exitCode}): ${gateCommandText(gate)} — reported as not-run, not as pass`);
543
572
  const cause = res.exitCode === 127 ? messages.toolMissing : messages.toolNotExecutable;
544
573
  outcome = {
545
574
  kind: "not-run",
@@ -558,7 +587,7 @@ function unavailableToolOutcome(messages, gate, res, log) {
558
587
  * 策略拒绝当成代码失败注入"修这些错"。
559
588
  *
560
589
  * 判定序(三支各自成函数,顺序与 warn 次数都不变):沙箱面拦截 → 进程没跑完 →
561
- * 工具装不上(126/127)→ exit 0(部分生效沙箱只 warn)→ npx stub 横幅 → 代码失败。
590
+ * 工具装不上(126/127)→ exit 0(部分生效沙箱只 warn)→ 代码失败。
562
591
  */
563
592
  function classifyRun(messages, gate, budgetMs, res, log) {
564
593
  const facts = res.sandbox;
@@ -571,16 +600,9 @@ function classifyRun(messages, gate, budgetMs, res, log) {
571
600
  const unusable = unavailableToolOutcome(messages, gate, res, log);
572
601
  if (unusable !== void 0) return unusable;
573
602
  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}`);
603
+ if (facts?.enforcement === "partial") log.warn(`[quality-gate] gate passed under PARTIAL sandbox enforcement (${sandboxNote(messages, facts)}): ${gateCommandText(gate)} @ ${gate.root}`);
575
604
  return { kind: "pass" };
576
605
  }
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
606
  return {
585
607
  kind: GATE_KIND_CODE_FAILURE,
586
608
  text: `exit=${res.exitCode}\n${evidenceText(messages, res.stdout, "stdout")}${evidenceText(messages, res.stderr, "stderr")}`
@@ -614,21 +636,21 @@ function isArrayDelivery(value) {
614
636
  /**
615
637
  * 解析事件游标窗口:fork 切点下限 vs 上次已读 seq 取大,返回起始 seq 与本批事件。
616
638
  * 事件流取自官方 `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 传进下游记账)。
639
+ * 一次交回 `events` 与 `inheritedEventCount`)。两条降级路径一律 `undefined`(调用方直接
640
+ * 返回,**游标不推进**、不误报):① `observeSession` 抛错/拒绝(会话已卸载、存储读不出);
641
+ * ② 交回的 events 不是数组(契约外交付,由 `isArrayDelivery` 那道**布尔**判据挡——不用
642
+ * `Array.isArray` 作守卫收窄,它会把官方 `readonly SessionEvent[]` 塌成 `any[]`,类型面
643
+ * 当场作废并把 any 传进下游记账)。`sessionQuery` 服务本身缺席是第三条降级,判定在
644
+ * `readEditedFiles`(那里才能发 warn-once)。
623
645
  * 窗口仍由**本地按 from 切片**:观察面给的是自日志开头的连续切片(`seq = log.length`
624
- * 连续性契约,installed dsh-session/lib/index.js:1285 的注记),而 `from` 是本包游标算出的
646
+ * 连续性契约,installed dsh-session/lib/index.js:1476 的注记),而 `from` 是本包游标算出的
625
647
  * 裸 number——这样连 `SessionLogOffset` 的品牌都不必造(官方只要 `SessionId`,那一位从
626
648
  * `Session.id` 上拿回来就是品牌的)。
627
649
  */
628
650
  async function resolveEventWindow(query, sessionId, lastSeq) {
629
651
  let window;
630
- if (query !== void 0) try {
631
- const observation = await query.observeSession(sessionId);
652
+ try {
653
+ const observation = await query.observeSession(sessionId, { projectionMode: "none" });
632
654
  const forkCut = observation.inheritedEventCount ?? 0;
633
655
  const from = Math.max(forkCut, lastSeq.get(sessionId) ?? forkCut);
634
656
  if (isArrayDelivery(observation.events)) window = {
@@ -758,7 +780,7 @@ function reportProblemToBus(get, attribution, gate, problem, log) {
758
780
  cwd: attribution.cwd,
759
781
  sessionId: attribution.sessionId,
760
782
  turn: attribution.turn,
761
- signature: gate.command.join(" "),
783
+ signature: gateCommandText(gate),
762
784
  detail: problem.text,
763
785
  evidence: {
764
786
  root: gate.root,
@@ -785,6 +807,10 @@ async function resolveFeedbackTarget(cfg, credentials) {
785
807
  ok: false,
786
808
  reason: "memoryGatewayUrl 未配置(留空 = 不启用记忆反馈)"
787
809
  };
810
+ if (!isCredentialSafeUrl(url)) return {
811
+ ok: false,
812
+ reason: `memoryGatewayUrl ${url} 是明文 http 且非回环:Bearer 凭据只发往 https 或本机`
813
+ };
788
814
  if (!isCredentialRefName(cfg.memoryGatewayKeyRef)) return {
789
815
  ok: false,
790
816
  reason: `memoryGatewayKeyRef "${cfg.memoryGatewayKeyRef}" 不是合法凭据引用名`
@@ -857,20 +883,40 @@ function isGateHost(value) {
857
883
  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";
858
884
  }
859
885
  /**
886
+ * 一个文件的最近清单根 → 实际要跑的根:workspace 根优先,**但只在外层真能产出门禁时**才升级。
887
+ *
888
+ * 只判「上面有没有 pnpm-workspace.yaml」就升级是漏门禁的一条路:外层若是"有 workspace 清单、
889
+ * 却没有 check 脚本也没有 tsconfig"的根,gate-detect 对它返回空表,子包本来有的
890
+ * `npx tsc --noEmit` / `ruff check` 会被这次上溯抹掉,且没有任何输出。
891
+ */
892
+ function rootForFile(useRoot, maxDepth) {
893
+ const inner = probeProjectRoot(useRoot);
894
+ const upgraded = workspaceRootUpgrade(useRoot, maxDepth);
895
+ if (upgraded === useRoot) return inner;
896
+ const outer = probeProjectRoot(upgraded);
897
+ if (detectGate([outer]).length > 0) return outer;
898
+ return detectGate([inner]).length > 0 ? inner : outer;
899
+ }
900
+ /**
860
901
  * 从编辑文件路径向上找最近的清单根(层数上限 = Config 的 maxRootDepth,非 volatile
861
902
  * 部署值),每个根探一次真实盘上事实(清单名 + 已声明脚本)。找不到清单根的文件跳过
862
- * (无门禁面)。
903
+ * (无门禁面)。同一内层根只判一次盘:一轮改几百个文件时逐文件重复探盘没有意义。
863
904
  */
864
905
  function projectRootsOf(files, maxDepth, log) {
865
906
  const roots = /* @__PURE__ */ new Map();
907
+ const chosenByInner = /* @__PURE__ */ new Map();
866
908
  for (const file of files) {
867
909
  const hit = findManifestRoot(file, maxDepth);
868
910
  const useRoot = hit.root;
869
911
  if (useRoot === void 0) {
870
912
  if (hit.hitDepthLimit) log.warn(`[quality-gate] no manifest within ${maxDepth} levels of ${file}; gate skipped for it`);
871
913
  } else {
872
- const upgraded = workspaceRootUpgrade(useRoot, maxDepth);
873
- if (!roots.has(upgraded)) roots.set(upgraded, probeProjectRoot(upgraded));
914
+ let chosen = chosenByInner.get(useRoot);
915
+ if (chosen === void 0) {
916
+ chosen = rootForFile(useRoot, maxDepth);
917
+ chosenByInner.set(useRoot, chosen);
918
+ }
919
+ if (!roots.has(chosen.root)) roots.set(chosen.root, chosen);
874
920
  }
875
921
  }
876
922
  return [...roots.values()];
@@ -886,7 +932,7 @@ async function runGate(messages, shell, gate, budgetMs, context) {
886
932
  let outcome;
887
933
  try {
888
934
  const spec = shell.resolve({
889
- command: gate.command.join(" "),
935
+ command: gateCommandText(gate),
890
936
  workdir: gate.root,
891
937
  stdoutMaxBytes,
892
938
  timeoutMs: budgetMs,
@@ -895,7 +941,7 @@ async function runGate(messages, shell, gate, budgetMs, context) {
895
941
  });
896
942
  outcome = classifyRun(messages, gate, budgetMs, await (await shell.execute(spec)).result(), log);
897
943
  } catch (error) {
898
- log.error(`[quality-gate] gate did not run: ${gate.command.join(" ")} @ ${gate.root} — ${messageOf(error)}`);
944
+ log.error(`[quality-gate] gate did not run: ${gateCommandText(gate)} @ ${gate.root} — ${messageOf(error)}`);
899
945
  const cause = messageOf(error);
900
946
  outcome = isSandboxUnavailable(error) ? {
901
947
  kind: "not-run",
@@ -970,7 +1016,7 @@ function accumulatorOf(rt, sessionId) {
970
1016
  * tool/result 的 isError 标记失败 callId,只记成功的。
971
1017
  * turn-stopping 时本回合 call+result 同处一个增量窗,单次扫描即可关联;无 callId 的事件
972
1018
  * (异常/旧形状/测试 mock)保守直记,避免漏门禁。
973
- * fork 前缀修复(源码核对 0.1.3 后落地):子代理会话 fork 自父会话时,
1019
+ * fork 前缀:子代理会话 fork 自父会话时,
974
1020
  * 事件流含父事件前缀,游标从 0 首读会把父会话的历史编辑一并计入、多跑门禁。
975
1021
  * Session.inheritedEventCount(0.1.2-rc.1 起公开只读)给出 fork 切点——首读下限
976
1022
  * 取切点而非 0,父前缀天然跳过;缺属性退回 0(旧行为,保守不漏门禁)。
@@ -978,8 +1024,22 @@ function accumulatorOf(rt, sessionId) {
978
1024
  async function readEditedFiles(rt, agent, acc) {
979
1025
  const { session } = agent;
980
1026
  const sessionId = session.id;
981
- const window = await resolveEventWindow(rt.svc.get("sessionQuery"), sessionId, rt.lastSeqBySession);
982
- if (window === void 0) return;
1027
+ const query = rt.svc.get("sessionQuery");
1028
+ if (query === void 0) {
1029
+ if (!rt.queryMissingWarned) {
1030
+ rt.queryMissingWarned = true;
1031
+ rt.log.warn("[quality-gate] sessionQuery service unavailable; edit accounting is off, so no gate will run");
1032
+ }
1033
+ return;
1034
+ }
1035
+ const window = await resolveEventWindow(query, sessionId, rt.lastSeqBySession);
1036
+ if (window === void 0) {
1037
+ if (!rt.queryReadWarned) {
1038
+ rt.queryReadWarned = true;
1039
+ rt.log.warn("[quality-gate] session event read returned no window; this turn's gate is skipped (further read failures stay silent)");
1040
+ }
1041
+ return;
1042
+ }
983
1043
  rt.lastSeqBySession.set(sessionId, window.from + window.events.length);
984
1044
  pruneToSessionBudget(rt.lastSeqBySession, sessionId);
985
1045
  const edits = collectEdits(window.events);
@@ -1219,6 +1279,8 @@ function apply(ctx, config) {
1219
1279
  gateAbort: new AbortController(),
1220
1280
  inFlightGates: /* @__PURE__ */ new Set(),
1221
1281
  shellWarned: false,
1282
+ queryMissingWarned: false,
1283
+ queryReadWarned: false,
1222
1284
  disposed: false
1223
1285
  };
1224
1286
  svc.on("agent/turn-stopping", async (payload) => {
@@ -1243,4 +1305,4 @@ var host_default = {
1243
1305
  apply
1244
1306
  };
1245
1307
  //#endregion
1246
- export { Config, apply, host_default as default };
1308
+ export { Config, apply, host_default as default, shellArg };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jayyuen66/dsh-quality-gate",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "dsh 插件:回合收口质量门禁(在 agent/turn-stopping 按项目形态跑 pnpm check / cargo check / ruff check / tsc,未过则注入修复指令开新轮)",
5
5
  "license": "MIT",
6
6
  "author": "JayYuen",
@@ -31,50 +31,51 @@
31
31
  "registry": "https://registry.npmjs.org"
32
32
  },
33
33
  "dependencies": {
34
- "@jayyuen66/dsh-plugin-shared": "^0.1.4"
34
+ "@jayyuen66/dsh-plugin-shared": "^0.1.6",
35
+ "typescript": "7.0.2"
35
36
  },
36
37
  "devDependencies": {
37
- "@deepseek-ai/cordis": "4.0.5-alpha.1",
38
- "@deepseek-ai/dsh-agent": "0.2.1-alpha.2",
39
- "@deepseek-ai/dsh-api-remotes": "0.2.1-alpha.2",
40
- "@deepseek-ai/dsh-client-locale": "0.2.1-alpha.2",
41
- "@deepseek-ai/dsh-client-store": "0.2.1-alpha.2",
42
- "@deepseek-ai/dsh-client-ui-layout": "0.2.1-alpha.2",
43
- "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-alpha.2",
44
- "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-alpha.2",
45
- "@deepseek-ai/dsh-client-ui-settings": "0.2.1-alpha.2",
46
- "@deepseek-ai/dsh-client-ui-slots": "0.2.1-alpha.2",
47
- "@deepseek-ai/dsh-credentials": "0.2.1-alpha.2",
48
- "@deepseek-ai/dsh-llm": "0.2.1-alpha.2",
49
- "@deepseek-ai/dsh-package-manifest": "0.2.1-alpha.2",
50
- "@deepseek-ai/dsh-sandbox": "0.2.1-alpha.2",
51
- "@deepseek-ai/dsh-sandbox-policy": "0.2.1-alpha.2",
52
- "@deepseek-ai/dsh-scope": "0.2.1-alpha.2",
53
- "@deepseek-ai/dsh-session": "0.2.1-alpha.2",
54
- "@deepseek-ai/dsh-session-projection": "0.2.1-alpha.2",
55
- "@deepseek-ai/dsh-session-query": "0.2.1-alpha.2",
56
- "@deepseek-ai/dsh-session-title": "0.2.1-alpha.2",
57
- "@deepseek-ai/dsh-settings": "0.2.1-alpha.2",
58
- "@deepseek-ai/dsh-shell": "0.2.1-alpha.2",
59
- "@deepseek-ai/dsh-subprocess": "0.2.1-alpha.2",
60
- "@deepseek-ai/schemastery": "3.18.5-alpha.1",
38
+ "@deepseek-ai/cordis": "4.0.5",
39
+ "@deepseek-ai/dsh-agent": "0.2.1-rc.2",
40
+ "@deepseek-ai/dsh-api-remotes": "0.2.1-rc.2",
41
+ "@deepseek-ai/dsh-client-locale": "0.2.1-rc.2",
42
+ "@deepseek-ai/dsh-client-store": "0.2.1-rc.2",
43
+ "@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.2",
44
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-rc.2",
45
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.2",
46
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.1-rc.2",
47
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.2",
48
+ "@deepseek-ai/dsh-credentials": "0.2.1-rc.2",
49
+ "@deepseek-ai/dsh-llm": "0.2.1-rc.2",
50
+ "@deepseek-ai/dsh-package-manifest": "0.2.1-rc.2",
51
+ "@deepseek-ai/dsh-sandbox": "0.2.1-rc.2",
52
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.2",
53
+ "@deepseek-ai/dsh-scope": "0.2.1-rc.2",
54
+ "@deepseek-ai/dsh-session": "0.2.1-rc.2",
55
+ "@deepseek-ai/dsh-session-projection": "0.2.1-rc.2",
56
+ "@deepseek-ai/dsh-session-query": "0.2.1-rc.2",
57
+ "@deepseek-ai/dsh-session-title": "0.2.1-rc.2",
58
+ "@deepseek-ai/dsh-settings": "0.2.1-rc.2",
59
+ "@deepseek-ai/dsh-shell": "0.2.1-rc.2",
60
+ "@deepseek-ai/dsh-subprocess": "0.2.1-rc.2",
61
+ "@deepseek-ai/schemastery": "3.18.5",
61
62
  "@types/node": "26.6.5",
62
63
  "@types/react": "19.3.0",
63
- "@vitest/coverage-v8": "5.0.3",
64
+ "@vitest/coverage-v8": "5.0.5",
64
65
  "eslint-plugin-sonarjs": "4.2.2",
65
- "oxfmt": "0.72.0",
66
- "oxlint": "1.87.0",
66
+ "oxfmt": "0.73.0",
67
+ "oxlint": "1.88.0",
67
68
  "oxlint-tsgolint": "7.0.2004",
68
69
  "react": "^19.3.0",
69
- "rolldown": "1.2.13",
70
+ "rolldown": "1.2.14",
70
71
  "typescript": "7.0.2",
71
72
  "vite": "8.3.4",
72
- "vitest": "5.0.3"
73
+ "vitest": "5.0.5"
73
74
  },
74
75
  "peerDependencies": {
75
- "@deepseek-ai/dsh": "^0.2.1-alpha.2",
76
- "@deepseek-ai/dsh-credentials": "0.2.1-alpha.2",
77
- "@deepseek-ai/schemastery": "3.18.5-alpha.1"
76
+ "@deepseek-ai/dsh": "^0.2.1-rc.2",
77
+ "@deepseek-ai/dsh-credentials": "0.2.1-rc.2",
78
+ "@deepseek-ai/schemastery": "3.18.5"
78
79
  },
79
80
  "peerDependenciesMeta": {
80
81
  "@deepseek-ai/dsh": {
@@ -83,7 +84,7 @@
83
84
  },
84
85
  "icon": "icon.svg",
85
86
  "engines": {
86
- "dsh": ">=0.2.1-alpha.2",
87
+ "dsh": "^0.2.1-rc.2",
87
88
  "node": ">=22.12.0"
88
89
  },
89
90
  "dsh": {