@boyingliu01/xp-gate 0.32.1 → 0.32.3
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/adapters/gate-3.sh +483 -28
- package/adapters/gate-4.sh +20 -0
- package/adapters/gate-7.sh +4 -0
- package/adapters/powershell.sh +33 -7
- package/bin/xp-gate.js +48 -2
- package/gate-3.sh +37 -2
- package/gate-4.sh +8 -0
- package/hooks/gate-3.sh +37 -2
- package/hooks/gate-4.sh +8 -0
- package/hooks/post-commit +82 -0
- package/hooks/pre-commit +19 -0
- package/hooks/pre-push +21 -2
- package/lib/__tests__/doctor-audit-ledger.test.js +61 -0
- package/lib/__tests__/gate-audit.test.ts +144 -0
- package/lib/__tests__/shared-utils.test.js +22 -0
- package/lib/doctor.js +59 -1
- package/lib/gate-audit.ts +215 -7
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-utils.js +4 -1
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +21 -0
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/README.md +51 -2
- package/plugins/dsh/client/client.js +760 -0
- package/plugins/dsh/lib/client.js +760 -0
- package/plugins/dsh/lib/delphi-settings.d.ts +52 -0
- package/plugins/dsh/lib/delphi-settings.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-settings.js +76 -0
- package/plugins/dsh/lib/hooks.d.ts +14 -1
- package/plugins/dsh/lib/hooks.d.ts.map +1 -1
- package/plugins/dsh/lib/hooks.js +44 -5
- package/plugins/dsh/lib/index.d.ts +63 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -1
- package/plugins/dsh/lib/index.js +126 -21
- package/plugins/dsh/package.json +13 -3
- package/plugins/dsh/scripts/copy-client.cjs +37 -0
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +21 -0
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/client.test.ts +231 -0
- package/plugins/dsh/src/contract.test.ts +25 -1
- package/plugins/dsh/src/delphi-settings.test.ts +101 -0
- package/plugins/dsh/src/delphi-settings.ts +111 -0
- package/plugins/dsh/src/hooks.test.ts +48 -0
- package/plugins/dsh/src/hooks.ts +45 -5
- package/plugins/dsh/src/index.test.ts +3 -2
- package/plugins/dsh/src/index.ts +147 -25
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +21 -0
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +21 -0
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/sync-package-content.js +1 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/sprint-flow/references/phase-3-build.md +21 -0
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -236,6 +236,27 @@ Without these events, the Harness can only infer changes from file diffs and has
|
|
|
236
236
|
|
|
237
237
|
---
|
|
238
238
|
|
|
239
|
+
## 工具调用效率自检(P2 硬性 — 2026-10-30 诊断)
|
|
240
|
+
|
|
241
|
+
> BUILD 阶段是工具调用最密集的阶段,也是"等待时间长"的主要来源。依据
|
|
242
|
+
> `docs/plans/2026-10-30-dev-speed-diagnosis.md`:工具延迟占总耗时 58%,其中 32.7% 的
|
|
243
|
+
> shell 调用是纯读取/搜索、calls/step 仅 1.03、`job_output` 阻塞 66.5 h。以下四条在 BUILD
|
|
244
|
+
> 期间是硬性自检项,不满足不得宣称 REQ 完成。
|
|
245
|
+
|
|
246
|
+
| ❌ 低效做法 | ✅ 正确做法 |
|
|
247
|
+
|------------|------------|
|
|
248
|
+
| `cat` / `Get-Content` / `sed -n` / `head` 读文件 | 用 `read` 原生工具(毫秒级,可并发) |
|
|
249
|
+
| `grep` / `rg` / `Select-String` / `findstr` 搜内容 | 用 `grep` 原生工具 |
|
|
250
|
+
| `find` / `ls` / `Get-ChildItem` 找文件 | 用 `glob` 原生工具 |
|
|
251
|
+
| 一个一个发独立调用(改完再想下一个) | 多个互不依赖的调用放进**同一步**并发发出 |
|
|
252
|
+
| 用 `job_output wait:true` / `sleep` 反复阻塞等长任务 | `run_in_background` + 完成通知,需要时短超时取一次 |
|
|
253
|
+
| 打包/发布/插件改动只靠 CI 发现契约破坏 | 先写契约测试(`plugins/dsh/src/contract.test.ts` 范式),本地先红 |
|
|
254
|
+
|
|
255
|
+
**验收目标**:纯读取 shell 占比 <5%(现 32.7%);calls/step ≥2.0(现 1.03);`job_output`
|
|
256
|
+
调用数下降 ≥50%。这三项在 `xp-gate retro` 的 activity 统计里可复查。
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
239
260
|
## Blind Review — Read-Only Subagent Convention (v0.18.0+, 证据型标准步骤)
|
|
240
261
|
|
|
241
262
|
**Purpose**: Code review by an isolated subagent with read-only tool access. Replaces the former `freeze/unfreeze` mechanism.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-30
|
|
4
|
+
**Commit:** e5ca7d9d
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.32.
|
|
6
|
+
**Version:** 0.32.3.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-DSH-018 The Delphi model configuration page registers and saves correctly
|
|
3
|
+
* @intent Verify the browser half of the plugin against the contracts DSH actually
|
|
4
|
+
* enforces: the `window.__ModuleLoader__` bundle shape, the `plugins.row.config`
|
|
5
|
+
* key, and — most importantly — the settings write op format
|
|
6
|
+
* `{ op: 'set', path: [...], value }`, which a shipped third-party plugin got
|
|
7
|
+
* wrong (it sent `{ type, key, value }`, so every save failed silently).
|
|
8
|
+
* @covers AC-DSH-018-01 AC-DSH-018-02 AC-DSH-018-03
|
|
9
|
+
*
|
|
10
|
+
* The bundle is plain CommonJS executed by DSH's own module loader, not by Node, so
|
|
11
|
+
* these tests evaluate it with a minimal `window.__ModuleLoader__` and a stub
|
|
12
|
+
* `require` that stands in for the shared platform modules.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs"
|
|
16
|
+
import { join, resolve } from "node:path"
|
|
17
|
+
import { fileURLToPath } from "node:url"
|
|
18
|
+
import { describe, expect, it } from "vitest"
|
|
19
|
+
|
|
20
|
+
const PKG_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)))
|
|
21
|
+
|
|
22
|
+
/** The package name and loader row id the page must key itself by. */
|
|
23
|
+
const PKG = "@boyingliu01/dsh-plugin-xp-gate"
|
|
24
|
+
const ROW_ID = "tool-xp-gate"
|
|
25
|
+
|
|
26
|
+
/** What a module loader hands the bundle: the React surface it may rely on. */
|
|
27
|
+
function stubRequire(id: string): unknown {
|
|
28
|
+
if (id === "react") {
|
|
29
|
+
return {
|
|
30
|
+
createElement: () => null,
|
|
31
|
+
useState: () => [undefined, () => undefined],
|
|
32
|
+
useEffect: () => undefined,
|
|
33
|
+
useMemo: (fn: () => unknown) => fn(),
|
|
34
|
+
useId: () => "id",
|
|
35
|
+
useRef: (v: unknown) => ({ current: v }),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (id === "react/jsx-runtime") return {}
|
|
39
|
+
// Any primitives the page imports must degrade rather than throw.
|
|
40
|
+
return new Proxy({}, { get: () => undefined })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Evaluate the client bundle and return its exports plus what it registered. */
|
|
44
|
+
function loadClient(): {
|
|
45
|
+
exports: Record<string, unknown>
|
|
46
|
+
registrations: Array<{ name: string; options: Record<string, unknown> }>
|
|
47
|
+
} {
|
|
48
|
+
const source = readFileSync(join(PKG_ROOT, "lib", "client.js"), "utf8")
|
|
49
|
+
const registrations: Array<{ name: string; options: Record<string, unknown> }> = []
|
|
50
|
+
let loaded: Record<string, unknown> | undefined
|
|
51
|
+
|
|
52
|
+
const window = {
|
|
53
|
+
__ModuleLoader__: {
|
|
54
|
+
load: (def: { id: string; factory: (require: typeof stubRequire) => Record<string, unknown> }) => {
|
|
55
|
+
expect(def.id).toBe(PKG)
|
|
56
|
+
loaded = def.factory(stubRequire)
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The bundle is CommonJS for DSH's own loader, so it cannot be imported; it has
|
|
62
|
+
// to be evaluated. `new Function` is the sandbox here, not a hazard.
|
|
63
|
+
const run = new Function("window", "document", "console", source)
|
|
64
|
+
run(window, { querySelector: () => null, head: { appendChild: () => undefined }, createElement: () => ({ dataset: {}, style: {} }) }, console)
|
|
65
|
+
|
|
66
|
+
expect(loaded, "bundle must register through window.__ModuleLoader__.load").toBeDefined()
|
|
67
|
+
return { exports: loaded as Record<string, unknown>, registrations }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The slot service: records each registration and runs each injected body once. */
|
|
71
|
+
function recordingSlots(registrations: Array<{ name: string; options: Record<string, unknown> }>) {
|
|
72
|
+
return {
|
|
73
|
+
register: (options: Record<string, unknown>) => {
|
|
74
|
+
registrations.push({ name: String(options.name), options })
|
|
75
|
+
return () => undefined
|
|
76
|
+
},
|
|
77
|
+
inject: (_name: string, register: () => unknown) => {
|
|
78
|
+
register()
|
|
79
|
+
return () => undefined
|
|
80
|
+
},
|
|
81
|
+
entries: () => [],
|
|
82
|
+
getVersion: () => 0,
|
|
83
|
+
subscribe: () => () => undefined,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The locale service, answering in English so the assertions can read the text. */
|
|
88
|
+
function stubLocale() {
|
|
89
|
+
return {
|
|
90
|
+
register: () => () => undefined,
|
|
91
|
+
bind: () => (key: string) => key,
|
|
92
|
+
getSnapshot: () => ({ active: "en", revision: 0 }),
|
|
93
|
+
subscribe: () => () => undefined,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The config-forms service: a writable scope that accepts every mutation. */
|
|
98
|
+
function stubConfigForms() {
|
|
99
|
+
return {
|
|
100
|
+
get: () => ({ getSnapshot: () => ({}), subscribe: () => () => undefined, mutate: async () => true }),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A client context recording every slot registration the page makes. */
|
|
105
|
+
function makeCtx(registrations: Array<{ name: string; options: Record<string, unknown> }>) {
|
|
106
|
+
return {
|
|
107
|
+
slots: recordingSlots(registrations),
|
|
108
|
+
locale: stubLocale(),
|
|
109
|
+
configForms: stubConfigForms(),
|
|
110
|
+
effect: (fn: () => unknown) => {
|
|
111
|
+
const result = fn()
|
|
112
|
+
if (typeof result === "function") (result as () => void)()
|
|
113
|
+
},
|
|
114
|
+
inject: [] as string[],
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("delphi config client bundle", () => {
|
|
119
|
+
it("declares the services the page needs", () => {
|
|
120
|
+
const { exports } = loadClient()
|
|
121
|
+
expect(exports.apply).toBeTypeOf("function")
|
|
122
|
+
const inject = exports.inject
|
|
123
|
+
expect(Array.isArray(inject), "inject must be an array of service names").toBe(true)
|
|
124
|
+
expect(inject).toContain("slots")
|
|
125
|
+
expect(inject).toContain("configForms")
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("registers a configuration page under the row the bundle patch declares", () => {
|
|
129
|
+
const { exports, registrations } = loadClient()
|
|
130
|
+
const ctx = makeCtx(registrations)
|
|
131
|
+
;(exports.apply as (ctx: unknown) => void)(ctx)
|
|
132
|
+
|
|
133
|
+
const page = registrations.find((r) => r.name === "plugins.row.config")
|
|
134
|
+
expect(page, "the page must land on plugins.row.config so the row shows Configure").toBeDefined()
|
|
135
|
+
// DSH's config ledger keys a row page by `<package name>#<row id>`.
|
|
136
|
+
expect(page?.options.key).toBe(`${PKG}#${ROW_ID}`)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("does not claim plugins.item, which the official settings pages own", () => {
|
|
140
|
+
// A third-party plugin that registered plugins.item displaced nothing but could
|
|
141
|
+
// collide with an official page id; row.config is the supported seat.
|
|
142
|
+
const { exports, registrations } = loadClient()
|
|
143
|
+
;(exports.apply as (ctx: unknown) => void)(makeCtx(registrations))
|
|
144
|
+
expect(registrations.some((r) => r.name === "plugins.item")).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The operation shape is the one contract a settings page cannot get wrong
|
|
150
|
+
* silently: DSH's `SettingsPathOpView` is `{ op, path, value }`. The shipped
|
|
151
|
+
* third-party plugin in this very profile sends `{ type, key, value }`, which the
|
|
152
|
+
* Host rejects, so its Save button does nothing. Pin ours.
|
|
153
|
+
*/
|
|
154
|
+
describe("delphi config save operations", () => {
|
|
155
|
+
/** A filled draft, shaped like the page's internal state. */
|
|
156
|
+
function draft() {
|
|
157
|
+
return {
|
|
158
|
+
seats: [
|
|
159
|
+
{ role: "architecture", model: " g-glm-5.3-flash " },
|
|
160
|
+
{ role: "technical", model: "g-qwen3.8-flash" },
|
|
161
|
+
{ role: "feasibility", model: "g-deepseek-flash" },
|
|
162
|
+
],
|
|
163
|
+
baseUrl: " https://gw.example/gpt-proxy ",
|
|
164
|
+
apiKey: "${WHALECLOUD_API_KEY}",
|
|
165
|
+
thresholdPercent: "90",
|
|
166
|
+
maxRounds: "5",
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
it("emits path-addressed ops, not the {type,key} shape", () => {
|
|
171
|
+
const { exports } = loadClient()
|
|
172
|
+
const ops = (exports.saveOperations as (d: unknown) => Array<Record<string, unknown>>)(draft())
|
|
173
|
+
expect(ops).toHaveLength(1)
|
|
174
|
+
expect(ops[0].op).toBe("set")
|
|
175
|
+
expect(Array.isArray(ops[0].path)).toBe(true)
|
|
176
|
+
expect(ops[0].path).toEqual(["delphi"])
|
|
177
|
+
// Guards against a regression to the broken third-party shape.
|
|
178
|
+
expect(ops[0]).not.toHaveProperty("type")
|
|
179
|
+
expect(ops[0]).not.toHaveProperty("key")
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("trims model ids and the gateway before writing them", () => {
|
|
183
|
+
const { exports } = loadClient()
|
|
184
|
+
const ops = (exports.saveOperations as (d: unknown) => Array<Record<string, unknown>>)(draft())
|
|
185
|
+
const delphi = ops[0].value as { baseUrl: string; seats: Array<{ model: string }> }
|
|
186
|
+
expect(delphi.baseUrl).toBe("https://gw.example/gpt-proxy")
|
|
187
|
+
expect(delphi.seats.map((s) => s.model)).toEqual([
|
|
188
|
+
"g-glm-5.3-flash",
|
|
189
|
+
"g-qwen3.8-flash",
|
|
190
|
+
"g-deepseek-flash",
|
|
191
|
+
])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("rejects a draft whose seats share a model, matching the host rule", () => {
|
|
195
|
+
const { exports } = loadClient()
|
|
196
|
+
const validate = exports.validationError as (s: unknown, b: string) => string
|
|
197
|
+
const d = draft()
|
|
198
|
+
d.seats[2].model = d.seats[0].model
|
|
199
|
+
expect(validate(d.seats, d.baseUrl)).toBe("duplicate")
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("rejects a draft with a blank seat or no gateway", () => {
|
|
203
|
+
const { exports } = loadClient()
|
|
204
|
+
const validate = exports.validationError as (s: unknown, b: string) => string
|
|
205
|
+
const blank = draft()
|
|
206
|
+
blank.seats[1].model = " "
|
|
207
|
+
expect(validate(blank.seats, blank.baseUrl)).toBe("empty")
|
|
208
|
+
const noGateway = draft()
|
|
209
|
+
expect(validate(noGateway.seats, " ")).toBe("gateway")
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("accepts a fully specified draft", () => {
|
|
213
|
+
const { exports } = loadClient()
|
|
214
|
+
const validate = exports.validationError as (s: unknown, b: string) => string
|
|
215
|
+
const d = draft()
|
|
216
|
+
expect(validate(d.seats, d.baseUrl)).toBe("")
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("pads a short stored seat list to the three seats the page edits", () => {
|
|
220
|
+
// A profile that stored one seat must still render three editors, or the user
|
|
221
|
+
// can never reach a valid panel from the page.
|
|
222
|
+
const { exports } = loadClient()
|
|
223
|
+
const seats = (exports.seatsOf as (v: unknown) => Array<{ role: string; model: string }>)({
|
|
224
|
+
seats: [{ role: "architecture", model: "only-one" }],
|
|
225
|
+
})
|
|
226
|
+
expect(seats).toHaveLength(3)
|
|
227
|
+
expect(seats[1].role).toBe("technical")
|
|
228
|
+
expect(seats[2].role).toBe("feasibility")
|
|
229
|
+
expect(seats[1].model).toBe("")
|
|
230
|
+
})
|
|
231
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { readFileSync } from "node:fs"
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
5
|
|
|
@@ -95,4 +95,28 @@ describe("packaging contract", () => {
|
|
|
95
95
|
// A report with no explicit verdict must not be counted toward consensus.
|
|
96
96
|
expect(core).toContain("INVALID_NO_VERDICT")
|
|
97
97
|
})
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @test REQ-DSH-019 prepack scripts keep stdout clean for `npm pack --json`
|
|
101
|
+
* @intent 发布流水线用 `npm pack --dry-run --json | jq ...` 量 tarball 大小。
|
|
102
|
+
* `prepack` 里任何一句 console.log 都会混进那个 JSON 流,jq 报
|
|
103
|
+
* "Invalid numeric literal" 并以 exit 5 结束——0.32.2 的 dsh 插件正是
|
|
104
|
+
* 因此没发出去,而 create-release 依赖三个 publish job,于是 tag 与
|
|
105
|
+
* release 一起被跳过。日志一律走 stderr。
|
|
106
|
+
* @covers AC-DSH-019-01
|
|
107
|
+
*/
|
|
108
|
+
it("prepack-time scripts log to stderr, never stdout", () => {
|
|
109
|
+
const scriptsDir = join(PKG_ROOT, "scripts")
|
|
110
|
+
const files = readdirSync(scriptsDir).filter((f) => f.endsWith(".cjs"))
|
|
111
|
+
expect(files.length, "no prepack scripts found").toBeGreaterThan(0)
|
|
112
|
+
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
const source = readFileSync(join(scriptsDir, f), "utf8")
|
|
115
|
+
// Strip comments so an explanatory "console.log" in prose does not fail it.
|
|
116
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "")
|
|
117
|
+
expect(code, `${f} writes to stdout and would corrupt npm pack --json`).not.toMatch(
|
|
118
|
+
/console\.log\s*\(/,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
98
122
|
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-DSH-017 Delphi seats are configurable from the DSH settings document
|
|
3
|
+
* @intent Verify the plugin's own `delphi` configuration layer, when present, wins
|
|
4
|
+
* over `.delphi-config.json` AND is held to the same three-seat invariants —
|
|
5
|
+
* three distinct models, no unexpanded placeholder — so a settings page
|
|
6
|
+
* cannot introduce a panel the skill would have rejected.
|
|
7
|
+
* @covers AC-DSH-017-01 AC-DSH-017-02 AC-DSH-017-03
|
|
8
|
+
*
|
|
9
|
+
* Why this exists: the settings page is a new, user-editable surface for the one
|
|
10
|
+
* setting that costs money per run. The invariants were previously enforced only
|
|
11
|
+
* inside `loadDelphiConfig`'s validation of a JSON file; a value arriving through
|
|
12
|
+
* DSH's settings document bypasses that file entirely, so the same checks have to
|
|
13
|
+
* run on this path too. Enforcing them host-side (not only in the page) is the
|
|
14
|
+
* point: the page is a convenience, the host is the authority.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest"
|
|
18
|
+
import { seatsFromSettings, type DelphiSettingsLayer } from "./delphi-settings.js"
|
|
19
|
+
import { DelphiConfigError } from "./delphi.js"
|
|
20
|
+
import { DelphiSetupError } from "./delphi-config.js"
|
|
21
|
+
|
|
22
|
+
/** A settings layer whose seats are present, so tests may index them. */
|
|
23
|
+
interface SeatedLayer extends DelphiSettingsLayer {
|
|
24
|
+
seats: NonNullable<DelphiSettingsLayer["seats"]>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A complete, valid three-seat settings layer. */
|
|
28
|
+
function valid(): SeatedLayer {
|
|
29
|
+
return {
|
|
30
|
+
baseUrl: "https://gw.example/v1",
|
|
31
|
+
apiKey: "${WHALECLOUD_API_KEY}",
|
|
32
|
+
seats: [
|
|
33
|
+
{ role: "architecture", model: "g-glm-5.3-flash" },
|
|
34
|
+
{ role: "technical", model: "g-qwen3.8-flash" },
|
|
35
|
+
{ role: "feasibility", model: "g-deepseek-flash" },
|
|
36
|
+
],
|
|
37
|
+
thresholdPercent: 90,
|
|
38
|
+
maxRounds: 5,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("seatsFromSettings", () => {
|
|
43
|
+
it("returns undefined when the settings layer is absent, so the JSON file still applies", () => {
|
|
44
|
+
// The migration path: an existing .delphi-config.json user keeps working
|
|
45
|
+
// untouched until they open the page and save something.
|
|
46
|
+
expect(seatsFromSettings(undefined)).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("carries three distinct seats and the gateway through", () => {
|
|
50
|
+
const resolved = seatsFromSettings(valid())
|
|
51
|
+
expect(resolved?.seats.map((s) => s.model)).toEqual([
|
|
52
|
+
"g-glm-5.3-flash",
|
|
53
|
+
"g-qwen3.8-flash",
|
|
54
|
+
"g-deepseek-flash",
|
|
55
|
+
])
|
|
56
|
+
expect(resolved?.baseUrl).toBe("https://gw.example/v1")
|
|
57
|
+
expect(resolved?.thresholdPercent).toBe(90)
|
|
58
|
+
expect(resolved?.maxRounds).toBe(5)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("keeps a configured api key expression for the caller to resolve", () => {
|
|
62
|
+
// Resolution belongs to the caller: only it can reach the credential store.
|
|
63
|
+
expect(seatsFromSettings(valid())?.apiKeyExpression).toBe("${WHALECLOUD_API_KEY}")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("rejects a panel whose models are not all distinct", () => {
|
|
67
|
+
const layer = valid()
|
|
68
|
+
layer.seats[2].model = layer.seats[0].model
|
|
69
|
+
expect(() => seatsFromSettings(layer)).toThrow(DelphiConfigError)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("rejects a panel that does not have exactly three seats", () => {
|
|
73
|
+
const layer = valid()
|
|
74
|
+
layer.seats = layer.seats.slice(0, 2)
|
|
75
|
+
expect(() => seatsFromSettings(layer)).toThrow(DelphiConfigError)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("rejects a blank model rather than treating it as an unpinned seat", () => {
|
|
79
|
+
const layer = valid()
|
|
80
|
+
layer.seats[1].model = " "
|
|
81
|
+
expect(() => seatsFromSettings(layer)).toThrow(DelphiConfigError)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("rejects an unexpanded placeholder in a model id", () => {
|
|
85
|
+
const layer = valid()
|
|
86
|
+
layer.seats[0].model = "${MODEL_VAR}"
|
|
87
|
+
expect(() => seatsFromSettings(layer)).toThrow(DelphiConfigError)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("refuses a layer with no gateway instead of silently using a default", () => {
|
|
91
|
+
const layer = valid()
|
|
92
|
+
layer.baseUrl = ""
|
|
93
|
+
expect(() => seatsFromSettings(layer)).toThrow(DelphiSetupError)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("applies documented defaults for the consensus settings", () => {
|
|
97
|
+
const resolved = seatsFromSettings({ ...valid(), thresholdPercent: undefined, maxRounds: undefined })
|
|
98
|
+
expect(resolved?.thresholdPercent).toBe(90)
|
|
99
|
+
expect(resolved?.maxRounds).toBe(5)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delphi seats supplied through DSH's own settings document.
|
|
3
|
+
*
|
|
4
|
+
* `.delphi-config.json` remains the portable, cross-platform source that the skill,
|
|
5
|
+
* the CI validator, and the other plugins share. This module adds a SECOND source —
|
|
6
|
+
* the `delphi` section of the plugin's Config, editable from the Plugins page — and
|
|
7
|
+
* the rules that keep the two from disagreeing in a way that costs money:
|
|
8
|
+
*
|
|
9
|
+
* - the settings layer, when it has any seats, WINS (it is the more recent edit)
|
|
10
|
+
* - it must still satisfy every Delphi invariant (`validateSeats`) before a single
|
|
11
|
+
* request is made, because a form is a convenience and the host is the authority
|
|
12
|
+
* - an absent settings layer resolves to `undefined`, so an existing JSON user is
|
|
13
|
+
* never silently migrated to an empty panel
|
|
14
|
+
*
|
|
15
|
+
* @test REQ-DSH-017 Delphi seats are configurable from the DSH settings document
|
|
16
|
+
* @intent 把 DSH settings 里的 delphi 段解析成与 JSON 同构的席位,并在这条新路径上
|
|
17
|
+
* 重新执行三席/三模型互异/非占位符等不变量,避免表单绕过校验。
|
|
18
|
+
* @covers AC-DSH-017-01 AC-DSH-017-02 AC-DSH-017-03
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExpertSeat } from "./delphi.js"
|
|
22
|
+
import { validateSeats } from "./delphi.js"
|
|
23
|
+
import { DelphiSetupError } from "./delphi-config.js"
|
|
24
|
+
|
|
25
|
+
/** One seat as the settings document stores it. */
|
|
26
|
+
export interface DelphiSettingsSeat {
|
|
27
|
+
role: string
|
|
28
|
+
model: string
|
|
29
|
+
maxTokens?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The `delphi` section of the plugin Config. */
|
|
33
|
+
export interface DelphiSettingsLayer {
|
|
34
|
+
baseUrl?: string
|
|
35
|
+
apiKey?: string
|
|
36
|
+
seats?: DelphiSettingsSeat[]
|
|
37
|
+
thresholdPercent?: number
|
|
38
|
+
maxRounds?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What {@link seatsFromSettings} produces: seats plus the transport they need. */
|
|
42
|
+
export interface SettingsReviewSetup {
|
|
43
|
+
readonly seats: readonly ExpertSeat[]
|
|
44
|
+
readonly baseUrl: string
|
|
45
|
+
/** The still-unexpanded `api_key` expression; the caller resolves it. */
|
|
46
|
+
readonly apiKeyExpression: string
|
|
47
|
+
readonly thresholdPercent: number
|
|
48
|
+
readonly maxRounds: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Delphi's documented defaults, applied when the settings omit a consensus value. */
|
|
52
|
+
const DEFAULT_THRESHOLD_PERCENT = 90
|
|
53
|
+
const DEFAULT_MAX_ROUNDS = 5
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Is the settings layer actually configured?
|
|
57
|
+
*
|
|
58
|
+
* Judged by seats alone: a half-filled form (a gateway typed but no seats yet) must
|
|
59
|
+
* not pre-empt the JSON file, or opening the page would quietly disable every
|
|
60
|
+
* existing `.delphi-config.json` review.
|
|
61
|
+
*/
|
|
62
|
+
function isConfigured(layer: DelphiSettingsLayer | undefined): boolean {
|
|
63
|
+
return Array.isArray(layer?.seats) && layer.seats.length > 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reject a layer that names no gateway, rather than dialling a guess. */
|
|
67
|
+
function requireBaseUrl(layer: DelphiSettingsLayer): string {
|
|
68
|
+
const baseUrl = (layer.baseUrl ?? "").trim()
|
|
69
|
+
if (baseUrl === "") {
|
|
70
|
+
throw new DelphiSetupError(
|
|
71
|
+
"The Delphi settings have seats but no base_url. Set the gateway URL, " +
|
|
72
|
+
"or clear the seats to fall back to .delphi-config.json.",
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return baseUrl
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Turn the settings layer into a review setup, or `undefined` when it holds no seats.
|
|
80
|
+
*
|
|
81
|
+
* Throws the same errors the JSON path throws, so a caller can report either source
|
|
82
|
+
* through one handler. `validateSeats` runs before any network call, which is what
|
|
83
|
+
* makes a misconfigured panel a configuration error rather than a provider failure.
|
|
84
|
+
*/
|
|
85
|
+
export function seatsFromSettings(layer: DelphiSettingsLayer | undefined): SettingsReviewSetup | undefined {
|
|
86
|
+
if (!isConfigured(layer)) return undefined
|
|
87
|
+
// Narrowed by isConfigured; non-null assertions are avoided to keep strict TS honest.
|
|
88
|
+
const configured = layer as DelphiSettingsLayer
|
|
89
|
+
const rawSeats = configured.seats ?? []
|
|
90
|
+
|
|
91
|
+
const seats: ExpertSeat[] = rawSeats.map((seat, index) => ({
|
|
92
|
+
id: seat.role || `Expert ${String.fromCharCode(65 + index)}`,
|
|
93
|
+
role: seat.role || `expert${index}`,
|
|
94
|
+
// Trim: a model id pasted from a catalog picker can carry padding, and
|
|
95
|
+
// `" model-a"` is not the model that was intended.
|
|
96
|
+
model: (seat.model ?? "").trim(),
|
|
97
|
+
maxTokens: seat.maxTokens,
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
// The invariants are the reason this module exists; they run before the
|
|
101
|
+
// transport is even built.
|
|
102
|
+
validateSeats(seats)
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
seats,
|
|
106
|
+
baseUrl: requireBaseUrl(configured),
|
|
107
|
+
apiKeyExpression: configured.apiKey ?? "",
|
|
108
|
+
thresholdPercent: configured.thresholdPercent ?? DEFAULT_THRESHOLD_PERCENT,
|
|
109
|
+
maxRounds: configured.maxRounds ?? DEFAULT_MAX_ROUNDS,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -85,3 +85,51 @@ describe("delphiGuardDecision", () => {
|
|
|
85
85
|
expect(delphiGuardDecision(root, "edit")).toBeDefined()
|
|
86
86
|
})
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @test REQ-DSH-015 sprint-phase writes are gated the way the IDE plugin gates them
|
|
91
|
+
* @intent 验证守卫用「只读工具白名单」判定,而不是「已知写工具黑名单」:
|
|
92
|
+
* 黑名单漏掉了 bash,而 shell 一条命令就能改文件,等于这道 HARD-GATE 可绕过。
|
|
93
|
+
* @covers AC-DSH-015-01
|
|
94
|
+
*
|
|
95
|
+
* The listed names are the tools a DSH session actually exposes; `bash` is the
|
|
96
|
+
* one that made the old blacklist bypassable, and the unlisted future tool is
|
|
97
|
+
* the case a blacklist loses by construction.
|
|
98
|
+
*/
|
|
99
|
+
describe("delphiGuardDecision tool classification", () => {
|
|
100
|
+
/** Read-only: none of these can modify a file, so none may be gated. */
|
|
101
|
+
const READ_ONLY = ["read", "grep", "glob", "read_image", "list_agents"]
|
|
102
|
+
|
|
103
|
+
/** Anything that can reach a shell or a file write, plus an unknown future tool. */
|
|
104
|
+
const CAN_WRITE = ["write", "edit", "bash", "pwsh", "str_replace_editor", "some_future_tool"]
|
|
105
|
+
|
|
106
|
+
it("never gates the read-only tools", () => {
|
|
107
|
+
const root = project("rejected")
|
|
108
|
+
for (const tool of READ_ONLY) {
|
|
109
|
+
expect(delphiGuardDecision(root, tool), `${tool} must not be gated`).toBeUndefined()
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("gates every tool that can modify code, including bash", () => {
|
|
114
|
+
const root = project("rejected")
|
|
115
|
+
for (const tool of CAN_WRITE) {
|
|
116
|
+
expect(delphiGuardDecision(root, tool), `${tool} must be gated`).toBeDefined()
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("still allows everything once the review is APPROVED", () => {
|
|
121
|
+
const root = project("approved")
|
|
122
|
+
for (const tool of CAN_WRITE) {
|
|
123
|
+
expect(delphiGuardDecision(root, tool), `${tool} must pass`).toBeUndefined()
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("still allows an ordinary repository that has no sprint state", () => {
|
|
128
|
+
const root = project("none")
|
|
129
|
+
// Unknown tools stay ungated outside a sprint: the guard protects the sprint
|
|
130
|
+
// phase, not every repository that installed the plugin.
|
|
131
|
+
for (const tool of [...CAN_WRITE, "some_future_tool"]) {
|
|
132
|
+
expect(delphiGuardDecision(root, tool), `${tool} must pass`).toBeUndefined()
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
})
|
package/plugins/dsh/src/hooks.ts
CHANGED
|
@@ -22,8 +22,32 @@
|
|
|
22
22
|
import { existsSync, readFileSync } from "node:fs"
|
|
23
23
|
import { join } from "node:path"
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Tools that provably cannot modify a file. Everything else is gated.
|
|
27
|
+
*
|
|
28
|
+
* This is an ALLOWLIST on purpose. The guard used to name the write tools
|
|
29
|
+
* instead (`write`, `edit`, `str_replace_editor`), which let `bash` through —
|
|
30
|
+
* one shell command writes a file, so the guard was bypassable by the very tool
|
|
31
|
+
* a model reaches for first. A write-blacklist loses again on every new
|
|
32
|
+
* file-touching tool; a read-only allowlist loses only by being too strict,
|
|
33
|
+
* which surfaces as an over-eager denial rather than a silent hole.
|
|
34
|
+
*/
|
|
35
|
+
const READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
|
|
36
|
+
"read",
|
|
37
|
+
"read_image",
|
|
38
|
+
"grep",
|
|
39
|
+
"glob",
|
|
40
|
+
"todo_write",
|
|
41
|
+
"ask_user_question",
|
|
42
|
+
"list_agents",
|
|
43
|
+
"present",
|
|
44
|
+
// XP-Gate's report tools only read: `xp-gate check/principles/arch` report to
|
|
45
|
+
// stdout, and the one file they ever write is a temp file list under the OS
|
|
46
|
+
// temp directory (gate-runner's --all path), never the workspace.
|
|
47
|
+
"gate-check",
|
|
48
|
+
"gate-principles",
|
|
49
|
+
"gate-arch",
|
|
50
|
+
])
|
|
27
51
|
|
|
28
52
|
/** Where Sprint Flow records the review verdict. */
|
|
29
53
|
const SPRINT_STATE_DIR = ".sprint-state"
|
|
@@ -55,9 +79,25 @@ const CHECKABLE_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
|
55
79
|
".ps1",
|
|
56
80
|
])
|
|
57
81
|
|
|
58
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Can this tool modify the workspace? Anything not proven read-only can.
|
|
84
|
+
*
|
|
85
|
+
* Used by the save-time principles check, which must fire only for the tools that
|
|
86
|
+
* actually write a source file — `bash` is excluded here even though the guard
|
|
87
|
+
* gates it, because the check needs a `file_path` argument to inspect.
|
|
88
|
+
*/
|
|
59
89
|
export function isEditTool(name: string): boolean {
|
|
60
|
-
return
|
|
90
|
+
return name === "write" || name === "edit" || name === "str_replace_editor"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Must this tool be gated by the sprint review guard?
|
|
95
|
+
*
|
|
96
|
+
* The inverse of {@link READ_ONLY_TOOLS}: an unrecognised tool is treated as
|
|
97
|
+
* capable of writing, which is what closes the `bash` hole.
|
|
98
|
+
*/
|
|
99
|
+
export function mayModifyFiles(name: string): boolean {
|
|
100
|
+
return !READ_ONLY_TOOLS.has(name)
|
|
61
101
|
}
|
|
62
102
|
|
|
63
103
|
/**
|
|
@@ -94,7 +134,7 @@ function readVerdict(root: string): { verdict?: string; unreadable?: string } {
|
|
|
94
134
|
* Returns undefined to mean "allow" so the caller can `if (denial) return denial`.
|
|
95
135
|
*/
|
|
96
136
|
export function delphiGuardDecision(root: string, toolName: string): GuardDenial | undefined {
|
|
97
|
-
if (!
|
|
137
|
+
if (!mayModifyFiles(toolName)) return undefined
|
|
98
138
|
if (!existsSync(join(root, SPRINT_STATE_DIR))) return undefined
|
|
99
139
|
|
|
100
140
|
const verdictFile = join(root, SPRINT_STATE_DIR, VERDICT_FILE)
|
|
@@ -95,11 +95,12 @@ describe("apply", () => {
|
|
|
95
95
|
expect(create).toBeDefined()
|
|
96
96
|
if (!create) return
|
|
97
97
|
// Recording invalidations rather than no-oping: listing must not invalidate, and
|
|
98
|
-
// an empty stub would hide it if it started to.
|
|
98
|
+
// an empty stub would hide it if it started to. DSH's `invalidate` takes no
|
|
99
|
+
// arguments, so the count is the whole signal.
|
|
99
100
|
const invalidations: string[] = []
|
|
100
101
|
const provider = create({
|
|
101
102
|
signal: new AbortController().signal,
|
|
102
|
-
invalidate: (
|
|
103
|
+
invalidate: () => void invalidations.push("invalidated"),
|
|
103
104
|
})
|
|
104
105
|
expect(provider.name).toBe("xp-gate")
|
|
105
106
|
|