@boyingliu01/xp-gate 0.32.0 → 0.32.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.
Files changed (49) hide show
  1. package/adapters/powershell.sh +33 -7
  2. package/gate-3.sh +54 -5
  3. package/hooks/gate-3.sh +54 -5
  4. package/hooks/lib/jscpd-run.sh +92 -0
  5. package/hooks/pre-commit +111 -56
  6. package/lib/mattpocock-license-verdict.json +1 -1
  7. package/mock-policy/AGENTS.md +3 -3
  8. package/mutation/AGENTS.md +3 -3
  9. package/package.json +1 -1
  10. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  11. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  12. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  13. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  14. package/plugins/dsh/README.md +51 -2
  15. package/plugins/dsh/client/client.js +760 -0
  16. package/plugins/dsh/lib/client.js +760 -0
  17. package/plugins/dsh/lib/delphi-settings.d.ts +52 -0
  18. package/plugins/dsh/lib/delphi-settings.d.ts.map +1 -0
  19. package/plugins/dsh/lib/delphi-settings.js +76 -0
  20. package/plugins/dsh/lib/hooks.d.ts +14 -1
  21. package/plugins/dsh/lib/hooks.d.ts.map +1 -1
  22. package/plugins/dsh/lib/hooks.js +44 -5
  23. package/plugins/dsh/lib/index.d.ts +63 -0
  24. package/plugins/dsh/lib/index.d.ts.map +1 -1
  25. package/plugins/dsh/lib/index.js +126 -21
  26. package/plugins/dsh/package.json +13 -3
  27. package/plugins/dsh/scripts/copy-client.cjs +32 -0
  28. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  29. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  30. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  31. package/plugins/dsh/src/client.test.ts +231 -0
  32. package/plugins/dsh/src/delphi-settings.test.ts +101 -0
  33. package/plugins/dsh/src/delphi-settings.ts +111 -0
  34. package/plugins/dsh/src/hooks.test.ts +48 -0
  35. package/plugins/dsh/src/hooks.ts +45 -5
  36. package/plugins/dsh/src/index.test.ts +3 -2
  37. package/plugins/dsh/src/index.ts +147 -25
  38. package/plugins/opencode/package.json +1 -1
  39. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  41. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  42. package/plugins/qoder/plugin.json +1 -1
  43. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  44. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  45. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  46. package/principles/AGENTS.md +3 -3
  47. package/skills/delphi-review/AGENTS.md +3 -3
  48. package/skills/sprint-flow/AGENTS.md +3 -3
  49. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -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
+ })
@@ -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
+ })
@@ -22,8 +22,32 @@
22
22
  import { existsSync, readFileSync } from "node:fs"
23
23
  import { join } from "node:path"
24
24
 
25
- /** Tools that modify files. Read-only tools are never gated. */
26
- const EDIT_TOOLS: ReadonlySet<string> = new Set(["write", "edit", "str_replace_editor"])
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
- /** Does this tool modify files? */
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 EDIT_TOOLS.has(name)
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 (!isEditTool(toolName)) return undefined
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: (name: string) => void invalidations.push(name),
103
+ invalidate: () => void invalidations.push("invalidated"),
103
104
  })
104
105
  expect(provider.name).toBe("xp-gate")
105
106