@jayyuen66/dsh-quality-gate 0.1.3 → 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/CHANGELOG.md +12 -0
- package/README.md +7 -6
- package/README.zh-CN.md +6 -5
- package/THIRD_PARTY_NOTICES.md +4 -1
- package/client.js +501 -254
- package/docs/DETAIL.md +24 -0
- package/docs/DETAIL.zh.md +24 -0
- package/host.js +171 -20
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +40 -32
package/docs/DETAIL.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Quality gate
|
|
2
|
+
|
|
3
|
+
Runs the project's own checks when a turn ends and hands the failure back to the model, so a turn does not close with a red gate.
|
|
4
|
+
|
|
5
|
+
## What the card changes
|
|
6
|
+
|
|
7
|
+
- **Gate** — `enabled` (on) off means the gate never runs and the model may close a broken turn. `maxInjectsPerTurn` (1–5, default 2) caps how many repair instructions one session receives across turns, which is the runaway brake. `gateBudgetMs` (10000–600000, default 300000, shown in seconds) is the whole-gate budget: exceeding it is injected as "could not run", never treated as a pass.
|
|
8
|
+
- **Memory feedback** — `memoryFeedback` (off by default) writes failing-gate evidence to the memory gateway's L0 layer; lesson-bus reporting is unaffected by it. `memoryGatewayUrl` (empty) has no shipped default — with it empty the plugin falls back to the `TDAI_GATEWAY_URL` environment variable, and if both are empty the feedback is simply unconfigured. `memoryGatewayKeyRef` (default `TDAI_GATEWAY_KEY`) stores only the credential **name**; the secret lives in the host's credential service and never in settings. Clearing the field restores the default name rather than turning feedback off.
|
|
9
|
+
|
|
10
|
+
These are read per turn, so a change applies on the next turn without restarting the host. Feedback reaches the gateway only when the switch is on, the URL resolves, and the credential service holds a value under the referenced name.
|
|
11
|
+
|
|
12
|
+
## Restart-only values
|
|
13
|
+
|
|
14
|
+
`maxRootDepth` (default 10, how far up a file's directory tree the plugin looks for the project root) and `gateStdoutMaxBytes` (65536) are deployment assumptions kept off the card. Set them in the `config:` block of this plugin's row in `cordis.patch.yml`; they take effect on restart.
|
|
15
|
+
|
|
16
|
+
Also worth knowing: after a host restart the turn cursor resets to the fork point, so the first turn after a restart re-runs the gates.
|
|
17
|
+
|
|
18
|
+
## What it needs from outside
|
|
19
|
+
|
|
20
|
+
The gate commands are resolved per project root and never invented: `pnpm check` only when that root's `package.json` declares a `check` script, otherwise `cargo check`, `ruff check .` only with a `ruff.toml` or a `[tool.ruff]` section, otherwise `tsc --noEmit`. They run through the host sandbox, so `SANDBOX_UNAVAILABLE` means the command never ran rather than that it passed. Talking to a memory gateway needs a reachable URL and a credential under the referenced name.
|
|
21
|
+
|
|
22
|
+
## Further detail
|
|
23
|
+
|
|
24
|
+
The detection precedence and why a wrong pick looks like a false failure, the edit ledger and spill mechanics are in the package README (`README.md` / `README.zh-CN.md`).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# 回合收口质量门禁
|
|
2
|
+
|
|
3
|
+
回合结束时跑项目自己的检查,把失败证据交回模型,让回合不会带着红灯收口。
|
|
4
|
+
|
|
5
|
+
## 设置卡上改的是什么
|
|
6
|
+
|
|
7
|
+
- **门禁** — `enabled`(默认开)关掉后门禁完全不跑,模型可能带着坏检查收回合。`maxInjectsPerTurn`(1–5,默认 2)限制一个会话跨回合最多收到几次修复指令,这是防失控的闸。`gateBudgetMs`(10000–600000,默认 300000,卡上按秒显示)是整趟门禁的预算:超时按「未能执行」注入,绝不当成通过。
|
|
8
|
+
- **记忆反馈** — `memoryFeedback`(默认关)开了才会把失败证据写进记忆网关的 L0 层;教训总线的上报不受这一位影响。`memoryGatewayUrl` 没有随包默认值:留空时回落到环境变量 `TDAI_GATEWAY_URL`,两者都空就是未配置,功能不跑。`memoryGatewayKeyRef`(默认 `TDAI_GATEWAY_KEY`)存的只是凭据**引用名**,密钥留在宿主凭据服务里,不进设置。清空这一格是恢复默认引用名,不是关掉反馈。
|
|
9
|
+
|
|
10
|
+
这些值每回合现读,改完下一回合生效,不用重启宿主。反馈要真正送出去,需要开关打开、地址能解析出值,并且凭据服务里确实存着引用名指向的那份值。
|
|
11
|
+
|
|
12
|
+
## 要重启的值
|
|
13
|
+
|
|
14
|
+
`maxRootDepth`(默认 10,决定从被改文件往上找项目根的最深层数)与 `gateStdoutMaxBytes`(65536)属于部署假定值,刻意不进设置卡:写在本插件注册行的 `config:` 块,改值随重启生效。
|
|
15
|
+
|
|
16
|
+
还有一条要提前知道:宿主重启后回合游标回到 fork 点,所以重启后的第一个回合会把门禁重跑一遍。
|
|
17
|
+
|
|
18
|
+
## 需要外部提供什么
|
|
19
|
+
|
|
20
|
+
门禁命令按项目根现场探测,绝不虚构:该根 `package.json` 声明了 `check` 脚本才用 `pnpm check`,否则依次试 `cargo check`、只有存在 `ruff.toml` 或 `[tool.ruff]` 时才用 `ruff check .`、再否则 `tsc --noEmit`。命令经宿主沙箱执行,所以 `SANDBOX_UNAVAILABLE` 说的是「根本没跑成」,不是「跑过了」。要连记忆网关还得有可达地址与被引用名指向的凭据。
|
|
21
|
+
|
|
22
|
+
## 更完整的说明
|
|
23
|
+
|
|
24
|
+
探测优先次序与它为什么会造成假失败、编辑台账与溢出落盘机制,都写在随包的 README(`README.md` / `README.zh-CN.md`)。
|
package/host.js
CHANGED
|
@@ -7,9 +7,10 @@ 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
|
+
import { z } from "zod";
|
|
13
14
|
// lib/accumulator.ts
|
|
14
15
|
/**
|
|
15
16
|
* 需要过门禁的扩展名(代码与配置;md/txt/图片等免检)。
|
|
@@ -226,6 +227,9 @@ function buildFeedbackText({ command, root, failure }, messages) {
|
|
|
226
227
|
});
|
|
227
228
|
}
|
|
228
229
|
// lib/gateway-target.ts
|
|
230
|
+
/** 基址末尾斜杠与 IPv4 单段。 */
|
|
231
|
+
const TRAILING_SLASHES$1 = /\/+$/u;
|
|
232
|
+
const IPV4_OCTET = /^\d{1,3}$/u;
|
|
229
233
|
/** env 值 trim 后非空则用之,否则回到 fallback(保留原 `||` 语义,
|
|
230
234
|
* 又以显式比较规避 prefer-nullish-coalescing 与 strict-boolean-expressions)。 */
|
|
231
235
|
function envOr(key, fallback) {
|
|
@@ -247,7 +251,7 @@ function resolveGatewayUrl(settingUrl) {
|
|
|
247
251
|
const configured = settingUrl.trim();
|
|
248
252
|
const candidate = configured === "" ? envOr("TDAI_GATEWAY_URL", "") : configured;
|
|
249
253
|
if (candidate === "") return "";
|
|
250
|
-
const normalized = candidate.replace(
|
|
254
|
+
const normalized = candidate.replace(TRAILING_SLASHES$1, "");
|
|
251
255
|
let parsed;
|
|
252
256
|
try {
|
|
253
257
|
parsed = new URL(normalized);
|
|
@@ -261,7 +265,7 @@ function resolveGatewayUrl(settingUrl) {
|
|
|
261
265
|
function isIpv4Loopback(host) {
|
|
262
266
|
const parts = host.split(".");
|
|
263
267
|
if (parts.length !== 4 || parts[0] !== "127") return false;
|
|
264
|
-
return parts.every((part) =>
|
|
268
|
+
return parts.every((part) => IPV4_OCTET.test(part) && Number(part) <= 255);
|
|
265
269
|
}
|
|
266
270
|
/**
|
|
267
271
|
* 凭据只允许发往 https 或回环地址:明文 http 到远端会把 Bearer 令牌暴露给同网段
|
|
@@ -292,7 +296,7 @@ function isCredentialSafeUrl(url) {
|
|
|
292
296
|
/** `x-tdai-service-id` 的合法形状:网关侧按服务名路由,空白与控制字符会让请求头
|
|
293
297
|
* 在 fetch 处直接抛 TypeError(undici 拒收含换行的头值),把一次投递变成一条异常。
|
|
294
298
|
* 不合规的值一律回落到默认服务名,而不是把用户的笔误送上网关。 */
|
|
295
|
-
const SERVICE_ID_RE = /^[
|
|
299
|
+
const SERVICE_ID_RE = /^[\w.-]{1,64}$/u;
|
|
296
300
|
/** 默认服务名:网关协议的匿名服务名(不含任何机器信息)。 */
|
|
297
301
|
const DEFAULT_SERVICE_ID = "default";
|
|
298
302
|
/** x-tdai-service-id:env 可覆盖,形状不合规时回落默认值。 */
|
|
@@ -324,11 +328,13 @@ function deriveAgentId(cwd) {
|
|
|
324
328
|
return deriveProjectKey(cwd);
|
|
325
329
|
}
|
|
326
330
|
// lib/gateway-feedback.ts
|
|
331
|
+
/** 基址末尾斜杠。 */
|
|
332
|
+
const TRAILING_SLASHES = /\/+$/u;
|
|
327
333
|
/** 反馈尽力而为:5s 封顶,超时/失败只 warn(不吃掉门禁主流程)。 */
|
|
328
334
|
const FEEDBACK_TIMEOUT_MS = 5e3;
|
|
329
335
|
/** 沉淀门禁失败到 L0 记忆(role=assistant:这是 agent 自身行为教训,归顺 assistant 提炼面)。 */
|
|
330
336
|
async function pushGateFeedback(input, target, messages) {
|
|
331
|
-
const base = target.url.trim().replace(
|
|
337
|
+
const base = target.url.trim().replace(TRAILING_SLASHES, "");
|
|
332
338
|
if (base === "" || target.key.trim() === "") throw new Error("投递目标不完整:url 与 key 都必须非空(目标面应由调用方解析后传入)");
|
|
333
339
|
const agentId = deriveAgentId(input.cwd);
|
|
334
340
|
const content = buildFeedbackText(input, messages);
|
|
@@ -356,6 +362,91 @@ async function pushGateFeedback(input, target, messages) {
|
|
|
356
362
|
throw new Error(detail === "" ? `gateway feedback HTTP ${res.status}` : `gateway feedback HTTP ${res.status}:${detail}`, { cause: drained });
|
|
357
363
|
}
|
|
358
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
|
+
});
|
|
449
|
+
}
|
|
359
450
|
// host.ts
|
|
360
451
|
/**
|
|
361
452
|
* 常见项目根标记文件:向上找最近的含清单目录。
|
|
@@ -508,10 +599,12 @@ function gateCommandArgs(gate) {
|
|
|
508
599
|
正常安装不可达,而那一路由 126/127 报成未执行。 */
|
|
509
600
|
return [tscEntry(gate.root) ?? "tsc", "--noEmit"];
|
|
510
601
|
}
|
|
602
|
+
/** shell 里无需加引号的参数形状。 */
|
|
603
|
+
const SHELL_SAFE_ARG = /^[\w./:=@+-]+$/u;
|
|
511
604
|
/** 拼 shell 串:路径可能带空格,含空白或元字符的参数加单引号。
|
|
512
605
|
* 导出只为直接测这段引号处理:tsc 入口是绝对路径,它所在目录未必没有空格。 */
|
|
513
606
|
function shellArg(part) {
|
|
514
|
-
return
|
|
607
|
+
return SHELL_SAFE_ARG.test(part) ? part : `'${part.replaceAll("'", `'\\''`)}'`;
|
|
515
608
|
}
|
|
516
609
|
/** 门禁命令的展示/执行串(与 gateCommandArgs 同源)。 */
|
|
517
610
|
function gateCommandText(gate) {
|
|
@@ -643,16 +736,26 @@ function interruptedRunOutcome(messages, budgetMs, res) {
|
|
|
643
736
|
}
|
|
644
737
|
return outcome;
|
|
645
738
|
}
|
|
739
|
+
/** 门禁工具不可用的 stderr 措辞。 */
|
|
740
|
+
const TOOL_UNAVAILABLE_TEXT = /command not found|not recognised|not recognized|permission denied|not executable/u;
|
|
646
741
|
/**
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
|
|
651
|
-
|
|
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),按代码失败。
|
|
652
755
|
*/
|
|
653
756
|
function unavailableToolOutcome(messages, gate, res, log) {
|
|
654
757
|
let outcome;
|
|
655
|
-
if (
|
|
758
|
+
if (gateToolUnavailable(gate, res)) {
|
|
656
759
|
log.warn(`[quality-gate] gate tool unavailable (exit ${res.exitCode}): ${gateCommandText(gate)} — reported as not-run, not as pass`);
|
|
657
760
|
const cause = res.exitCode === 127 ? messages.toolMissing : messages.toolNotExecutable;
|
|
658
761
|
outcome = {
|
|
@@ -763,8 +866,9 @@ function ruffConfiguredOf(root) {
|
|
|
763
866
|
*/
|
|
764
867
|
async function resolveEventWindow(query, sessionId, lastSeq) {
|
|
765
868
|
let window;
|
|
869
|
+
let observation;
|
|
766
870
|
try {
|
|
767
|
-
|
|
871
|
+
observation = await query.observeSession(sessionId, { projectionMode: "none" });
|
|
768
872
|
const forkCut = observation.inheritedEventCount ?? 0;
|
|
769
873
|
const from = Math.max(forkCut, lastSeq.get(sessionId) ?? forkCut);
|
|
770
874
|
if (isArrayDelivery(observation.events)) window = {
|
|
@@ -773,6 +877,8 @@ async function resolveEventWindow(query, sessionId, lastSeq) {
|
|
|
773
877
|
};
|
|
774
878
|
} catch {
|
|
775
879
|
window = void 0;
|
|
880
|
+
} finally {
|
|
881
|
+
observation?.[Symbol.dispose]();
|
|
776
882
|
}
|
|
777
883
|
return window;
|
|
778
884
|
}
|
|
@@ -1000,14 +1106,26 @@ function isGateHost(value) {
|
|
|
1000
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";
|
|
1001
1107
|
}
|
|
1002
1108
|
/**
|
|
1003
|
-
*
|
|
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 根优先,**但只在外层真能产出门禁时**才升级。
|
|
1004
1118
|
*
|
|
1005
1119
|
* 只判「上面有没有 pnpm-workspace.yaml」就升级是漏门禁的一条路:外层若是"有 workspace 清单、
|
|
1006
1120
|
* 却没有 check 脚本也没有 tsconfig"的根,gate-detect 对它返回空表,子包本来有的
|
|
1007
1121
|
* `npx tsc --noEmit` / `ruff check` 会被这次上溯抹掉,且没有任何输出。
|
|
1122
|
+
*
|
|
1123
|
+
* 反方向也一样:子包本身就是 workspace 根且能出门禁时不再上溯——否则「本仓自己的门禁」
|
|
1124
|
+
* 会被外层伞工作区的全套 `pnpm check` 顶掉,单仓改动要等着跑九个仓。
|
|
1008
1125
|
*/
|
|
1009
1126
|
function rootForFile(useRoot, maxDepth) {
|
|
1010
1127
|
const inner = probeProjectRoot(useRoot);
|
|
1128
|
+
if (hasPnpmCheckGate(inner)) return inner;
|
|
1011
1129
|
const upgraded = workspaceRootUpgrade(useRoot, maxDepth);
|
|
1012
1130
|
if (upgraded === useRoot) return inner;
|
|
1013
1131
|
const outer = probeProjectRoot(upgraded);
|
|
@@ -1138,6 +1256,26 @@ function accumulatorOf(rt, sessionId) {
|
|
|
1138
1256
|
* Session.inheritedEventCount(0.1.2-rc.1 起公开只读)给出 fork 切点——首读下限
|
|
1139
1257
|
* 取切点而非 0,父前缀天然跳过;缺属性退回 0(旧行为,保守不漏门禁)。
|
|
1140
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
|
+
}
|
|
1141
1279
|
async function readEditedFiles(rt, agent, acc) {
|
|
1142
1280
|
const { session } = agent;
|
|
1143
1281
|
const sessionId = session.id;
|
|
@@ -1149,6 +1287,13 @@ async function readEditedFiles(rt, agent, acc) {
|
|
|
1149
1287
|
}
|
|
1150
1288
|
return;
|
|
1151
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;
|
|
1152
1297
|
const window = await resolveEventWindow(query, sessionId, rt.lastSeqBySession);
|
|
1153
1298
|
if (window === void 0) {
|
|
1154
1299
|
if (!rt.queryReadWarned) {
|
|
@@ -1160,12 +1305,6 @@ async function readEditedFiles(rt, agent, acc) {
|
|
|
1160
1305
|
rt.lastSeqBySession.set(sessionId, window.from + window.events.length);
|
|
1161
1306
|
pruneToSessionBudget(rt.lastSeqBySession, sessionId);
|
|
1162
1307
|
const edits = collectEdits(window.events);
|
|
1163
|
-
const { cwd } = session.header;
|
|
1164
|
-
const note = (name, pathValue) => {
|
|
1165
|
-
let resolvedPath = pathValue;
|
|
1166
|
-
if (resolvedPath !== void 0 && cwd !== void 0 && !path.isAbsolute(resolvedPath)) resolvedPath = path.resolve(cwd, resolvedPath);
|
|
1167
|
-
acc.note(name, resolvedPath);
|
|
1168
|
-
};
|
|
1169
1308
|
for (const entry of edits.noCallId) note(entry.name, entry.path);
|
|
1170
1309
|
for (const [callId, entry] of edits.pending) if (!edits.failed.has(callId)) note(entry.name, entry.path);
|
|
1171
1310
|
}
|
|
@@ -1398,8 +1537,20 @@ function apply(ctx, config) {
|
|
|
1398
1537
|
shellWarned: false,
|
|
1399
1538
|
queryMissingWarned: false,
|
|
1400
1539
|
queryReadWarned: false,
|
|
1540
|
+
editLedger: { value: void 0 },
|
|
1401
1541
|
disposed: false
|
|
1402
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
|
+
});
|
|
1403
1554
|
svc.on("agent/turn-stopping", async (payload) => {
|
|
1404
1555
|
try {
|
|
1405
1556
|
await handleTurnGate(rt, payload);
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
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.4",
|
|
4
4
|
"description": "dsh 插件:回合收口质量门禁(在 agent/turn-stopping 按项目形态跑 pnpm check / cargo check / ruff check / tsc,未过则注入修复指令开新轮)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ci",
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
"host.js",
|
|
22
22
|
"client.js",
|
|
23
23
|
"cordis.patch.yml",
|
|
24
|
+
"locale/zh.json",
|
|
25
|
+
"locale/en.json",
|
|
26
|
+
"docs",
|
|
24
27
|
"README.md",
|
|
25
28
|
"README.zh-CN.md",
|
|
26
29
|
"CHANGELOG.md",
|
|
@@ -32,6 +35,7 @@
|
|
|
32
35
|
".": "./host.js",
|
|
33
36
|
"./client": "./client.js",
|
|
34
37
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
38
|
+
"./locale/*.json": "./locale/*.json",
|
|
35
39
|
"./package.json": "./package.json"
|
|
36
40
|
},
|
|
37
41
|
"publishConfig": {
|
|
@@ -39,40 +43,44 @@
|
|
|
39
43
|
"registry": "https://registry.npmjs.org"
|
|
40
44
|
},
|
|
41
45
|
"dependencies": {
|
|
42
|
-
"@jayyuen66/dsh-plugin-shared": "0.1
|
|
43
|
-
"typescript": "7.0.2"
|
|
46
|
+
"@jayyuen66/dsh-plugin-shared": "0.2.1",
|
|
47
|
+
"typescript": "7.0.2",
|
|
48
|
+
"zod": "4.6.7"
|
|
44
49
|
},
|
|
45
50
|
"devDependencies": {
|
|
46
|
-
"@deepseek-ai/cordis": "4.0.
|
|
47
|
-
"@deepseek-ai/dsh-agent": "0.2.
|
|
48
|
-
"@deepseek-ai/dsh-api-remotes": "0.2.
|
|
49
|
-
"@deepseek-ai/dsh-client-locale": "0.2.
|
|
50
|
-
"@deepseek-ai/dsh-client-store": "0.2.
|
|
51
|
-
"@deepseek-ai/dsh-client-ui-layout": "0.2.
|
|
52
|
-
"@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.
|
|
53
|
-
"@deepseek-ai/dsh-client-ui-renderer": "0.2.
|
|
54
|
-
"@deepseek-ai/dsh-client-ui-settings": "0.2.
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.2.
|
|
56
|
-
"@deepseek-ai/dsh-credentials": "0.2.
|
|
57
|
-
"@deepseek-ai/dsh-llm": "0.2.
|
|
58
|
-
"@deepseek-ai/dsh-package-manifest": "0.2.
|
|
59
|
-
"@deepseek-ai/dsh-sandbox": "0.2.
|
|
60
|
-
"@deepseek-ai/dsh-sandbox-policy": "0.2.
|
|
61
|
-
"@deepseek-ai/dsh-scope": "0.2.
|
|
62
|
-
"@deepseek-ai/dsh-session": "0.2.
|
|
63
|
-
"@deepseek-ai/dsh-session-projection": "0.2.
|
|
64
|
-
"@deepseek-ai/dsh-session-query": "0.2.
|
|
65
|
-
"@deepseek-ai/dsh-session-title": "0.2.
|
|
66
|
-
"@deepseek-ai/dsh-settings": "0.2.
|
|
67
|
-
"@deepseek-ai/dsh-shell": "0.2.
|
|
68
|
-
"@deepseek-ai/dsh-subprocess": "0.2.
|
|
69
|
-
"@deepseek-ai/schemastery": "3.18.
|
|
70
|
-
"@
|
|
51
|
+
"@deepseek-ai/cordis": "4.0.6-alpha.1",
|
|
52
|
+
"@deepseek-ai/dsh-agent": "0.2.2-alpha.2",
|
|
53
|
+
"@deepseek-ai/dsh-api-remotes": "0.2.2-alpha.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-locale": "0.2.2-alpha.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-store": "0.2.2-alpha.2",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-layout": "0.2.2-alpha.2",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.2-alpha.2",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.2.2-alpha.2",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.2.2-alpha.2",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.2-alpha.2",
|
|
61
|
+
"@deepseek-ai/dsh-credentials": "0.2.2-alpha.2",
|
|
62
|
+
"@deepseek-ai/dsh-llm": "0.2.2-alpha.2",
|
|
63
|
+
"@deepseek-ai/dsh-package-manifest": "0.2.2-alpha.2",
|
|
64
|
+
"@deepseek-ai/dsh-sandbox": "0.2.2-alpha.2",
|
|
65
|
+
"@deepseek-ai/dsh-sandbox-policy": "0.2.2-alpha.2",
|
|
66
|
+
"@deepseek-ai/dsh-scope": "0.2.2-alpha.2",
|
|
67
|
+
"@deepseek-ai/dsh-session": "0.2.2-alpha.2",
|
|
68
|
+
"@deepseek-ai/dsh-session-projection": "0.2.2-alpha.2",
|
|
69
|
+
"@deepseek-ai/dsh-session-query": "0.2.2-alpha.2",
|
|
70
|
+
"@deepseek-ai/dsh-session-title": "0.2.2-alpha.2",
|
|
71
|
+
"@deepseek-ai/dsh-settings": "0.2.2-alpha.2",
|
|
72
|
+
"@deepseek-ai/dsh-shell": "0.2.2-alpha.2",
|
|
73
|
+
"@deepseek-ai/dsh-subprocess": "0.2.2-alpha.2",
|
|
74
|
+
"@deepseek-ai/schemastery": "3.18.6-alpha.1",
|
|
75
|
+
"@e18e/eslint-plugin": "0.8.1",
|
|
76
|
+
"@types/node": "26.6.6",
|
|
71
77
|
"@types/react": "19.3.0",
|
|
72
78
|
"@vitest/coverage-v8": "5.0.5",
|
|
79
|
+
"eslint-plugin-regexp": "3.3.2",
|
|
73
80
|
"eslint-plugin-sonarjs": "4.2.2",
|
|
74
|
-
"
|
|
75
|
-
"
|
|
81
|
+
"eslint-plugin-zod": "5.2.2",
|
|
82
|
+
"oxfmt": "0.75.0",
|
|
83
|
+
"oxlint": "1.90.0",
|
|
76
84
|
"oxlint-tsgolint": "7.0.2005",
|
|
77
85
|
"react": "^19.3.0",
|
|
78
86
|
"rolldown": "1.2.15",
|
|
@@ -82,8 +90,8 @@
|
|
|
82
90
|
},
|
|
83
91
|
"peerDependencies": {
|
|
84
92
|
"@deepseek-ai/dsh": "^0.2.1-rc.3",
|
|
85
|
-
"@deepseek-ai/dsh-credentials": "0.2.1-rc.3",
|
|
86
|
-
"@deepseek-ai/schemastery": "3.18.
|
|
93
|
+
"@deepseek-ai/dsh-credentials": "^0.2.1-rc.3",
|
|
94
|
+
"@deepseek-ai/schemastery": "3.18.6-alpha.1"
|
|
87
95
|
},
|
|
88
96
|
"peerDependenciesMeta": {
|
|
89
97
|
"@deepseek-ai/dsh": {
|