@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 +2 -2
- package/README.zh-CN.md +2 -2
- package/client.js +9 -9
- package/host.js +121 -59
- package/package.json +36 -35
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-
|
|
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-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
* `
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)→
|
|
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
|
|
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
|
|
618
|
-
* 返回,**游标不推进**、不误报):① `
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
873
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
982
|
-
if (
|
|
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.
|
|
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.
|
|
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
|
|
38
|
-
"@deepseek-ai/dsh-agent": "0.2.1-
|
|
39
|
-
"@deepseek-ai/dsh-api-remotes": "0.2.1-
|
|
40
|
-
"@deepseek-ai/dsh-client-locale": "0.2.1-
|
|
41
|
-
"@deepseek-ai/dsh-client-store": "0.2.1-
|
|
42
|
-
"@deepseek-ai/dsh-client-ui-layout": "0.2.1-
|
|
43
|
-
"@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-
|
|
44
|
-
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-
|
|
45
|
-
"@deepseek-ai/dsh-client-ui-settings": "0.2.1-
|
|
46
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-
|
|
47
|
-
"@deepseek-ai/dsh-credentials": "0.2.1-
|
|
48
|
-
"@deepseek-ai/dsh-llm": "0.2.1-
|
|
49
|
-
"@deepseek-ai/dsh-package-manifest": "0.2.1-
|
|
50
|
-
"@deepseek-ai/dsh-sandbox": "0.2.1-
|
|
51
|
-
"@deepseek-ai/dsh-sandbox-policy": "0.2.1-
|
|
52
|
-
"@deepseek-ai/dsh-scope": "0.2.1-
|
|
53
|
-
"@deepseek-ai/dsh-session": "0.2.1-
|
|
54
|
-
"@deepseek-ai/dsh-session-projection": "0.2.1-
|
|
55
|
-
"@deepseek-ai/dsh-session-query": "0.2.1-
|
|
56
|
-
"@deepseek-ai/dsh-session-title": "0.2.1-
|
|
57
|
-
"@deepseek-ai/dsh-settings": "0.2.1-
|
|
58
|
-
"@deepseek-ai/dsh-shell": "0.2.1-
|
|
59
|
-
"@deepseek-ai/dsh-subprocess": "0.2.1-
|
|
60
|
-
"@deepseek-ai/schemastery": "3.18.5
|
|
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.
|
|
64
|
+
"@vitest/coverage-v8": "5.0.5",
|
|
64
65
|
"eslint-plugin-sonarjs": "4.2.2",
|
|
65
|
-
"oxfmt": "0.
|
|
66
|
-
"oxlint": "1.
|
|
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.
|
|
70
|
+
"rolldown": "1.2.14",
|
|
70
71
|
"typescript": "7.0.2",
|
|
71
72
|
"vite": "8.3.4",
|
|
72
|
-
"vitest": "5.0.
|
|
73
|
+
"vitest": "5.0.5"
|
|
73
74
|
},
|
|
74
75
|
"peerDependencies": {
|
|
75
|
-
"@deepseek-ai/dsh": "^0.2.1-
|
|
76
|
-
"@deepseek-ai/dsh-credentials": "0.2.1-
|
|
77
|
-
"@deepseek-ai/schemastery": "3.18.5
|
|
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": "
|
|
87
|
+
"dsh": "^0.2.1-rc.2",
|
|
87
88
|
"node": ">=22.12.0"
|
|
88
89
|
},
|
|
89
90
|
"dsh": {
|