@boyingliu01/xp-gate 0.19.3 → 0.20.0

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 (173) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +49 -5
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/typescript.sh +15 -60
  5. package/bin/xp-gate.js +15 -16
  6. package/gate-10.sh +35 -2
  7. package/gate-4.sh +110 -21
  8. package/gate-8.sh +49 -5
  9. package/hooks/gate-10.sh +35 -2
  10. package/hooks/gate-4.sh +110 -21
  11. package/hooks/gate-8.sh +49 -5
  12. package/hooks/lib/jscpd-run.sh +63 -0
  13. package/hooks/lib/sprint-gate-report.sh +58 -0
  14. package/hooks/lib/test-failure.sh +152 -0
  15. package/hooks/lib/typecheck.sh +141 -0
  16. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  17. package/hooks/post-merge +100 -0
  18. package/hooks/pre-commit +389 -162
  19. package/hooks/pre-push +75 -19
  20. package/lib/__tests__/bootstrap.test.js +23 -41
  21. package/lib/__tests__/check-version.test.js +8 -1
  22. package/lib/__tests__/detect-deps.test.js +16 -0
  23. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  24. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  25. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  26. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  27. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  28. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  30. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  31. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  32. package/lib/__tests__/doctor.test.js +155 -0
  33. package/lib/__tests__/gate-runner.test.js +19 -3
  34. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  35. package/lib/__tests__/init.test.js +44 -17
  36. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  37. package/lib/__tests__/install-skill.test.js +51 -132
  38. package/lib/__tests__/language-tools-detect.test.js +158 -0
  39. package/lib/__tests__/phase-transition.test.js +15 -1
  40. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  41. package/lib/__tests__/shared-paths.test.js +20 -0
  42. package/lib/__tests__/shared-utils.test.js +69 -0
  43. package/lib/__tests__/skill-flags.test.js +48 -0
  44. package/lib/__tests__/ui-detector.test.ts +6 -2
  45. package/lib/__tests__/uninstall.test.js +20 -0
  46. package/lib/__tests__/update-hooks.test.js +12 -0
  47. package/lib/__tests__/update-skill.test.js +7 -4
  48. package/lib/detect-deps.js +3 -20
  49. package/lib/doctor-tui.js +22 -5
  50. package/lib/doctor.js +1178 -31
  51. package/lib/init.js +184 -33
  52. package/lib/install-cmd.js +7 -4
  53. package/lib/install-skill.js +38 -95
  54. package/lib/language-tools.js +83 -4
  55. package/lib/mattpocock-license-verdict.json +1 -1
  56. package/lib/shared-paths.js +5 -2
  57. package/lib/shared-utils.js +47 -3
  58. package/lib/uninstall.js +12 -0
  59. package/lib/update-hooks.js +4 -4
  60. package/lib/update-skill.js +6 -7
  61. package/mock-policy/AGENTS.md +4 -4
  62. package/mutation/AGENTS.md +4 -4
  63. package/mutation/__tests__/gate-m.test.ts +66 -14
  64. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  65. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  66. package/mutation/gate-m.ts +21 -8
  67. package/package.json +1 -1
  68. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  69. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  70. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  71. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  72. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  73. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  74. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  76. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  77. package/plugins/dsh/README.md +169 -4
  78. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  79. package/plugins/dsh/package.json +1 -1
  80. package/plugins/dsh/scripts/prepack.cjs +24 -19
  81. package/plugins/dsh/setup-dsh.ps1 +160 -0
  82. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  84. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  85. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  86. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  87. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  89. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  90. package/plugins/dsh/src/command.test.ts +66 -2
  91. package/plugins/dsh/src/command.ts +36 -14
  92. package/plugins/dsh/src/contract.test.ts +31 -1
  93. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  94. package/plugins/dsh/src/delphi-config.ts +223 -0
  95. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  96. package/plugins/dsh/src/delphi-run.ts +129 -0
  97. package/plugins/dsh/src/delphi.test.ts +222 -0
  98. package/plugins/dsh/src/delphi.ts +247 -0
  99. package/plugins/dsh/src/dialect.test.ts +59 -0
  100. package/plugins/dsh/src/dialect.ts +40 -0
  101. package/plugins/dsh/src/index.ts +70 -4
  102. package/plugins/dsh/src/skills.test.ts +26 -19
  103. package/plugins/opencode/package.json +1 -1
  104. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  105. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  106. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  107. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  108. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  109. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  111. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  112. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  113. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  114. package/plugins/qoder/agents/delphi-technical.md +1 -1
  115. package/plugins/qoder/plugin.json +1 -1
  116. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  117. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  118. package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
  119. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  120. package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  122. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
  123. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  124. package/principles/AGENTS.md +9 -8
  125. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  126. package/principles/__tests__/analyzer.test.ts +29 -6
  127. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  128. package/principles/__tests__/baseline-prune.test.ts +338 -0
  129. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  130. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  131. package/principles/__tests__/boy-scout.test.ts +253 -7
  132. package/principles/__tests__/config-enforcement.test.ts +469 -0
  133. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  134. package/principles/__tests__/direct-execution.test.ts +187 -0
  135. package/principles/__tests__/index.test.ts +16 -3
  136. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  137. package/principles/adapters/typescript.ts +613 -13
  138. package/principles/analyzer.ts +22 -0
  139. package/principles/baseline-prune.ts +217 -0
  140. package/principles/boy-scout.ts +481 -51
  141. package/principles/config.ts +362 -54
  142. package/principles/direct-execution.ts +60 -0
  143. package/principles/index.ts +40 -17
  144. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  145. package/principles/rules/clean-code/code-duplication.ts +8 -8
  146. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  147. package/principles/rules/clean-code/god-class.ts +8 -8
  148. package/principles/rules/clean-code/large-file.ts +15 -11
  149. package/principles/rules/clean-code/long-function.ts +8 -8
  150. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  151. package/principles/rules/clean-code/many-exports.ts +7 -7
  152. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  153. package/principles/rules/clean-code/too-many-params.ts +8 -8
  154. package/principles/rules/clean-code/unused-imports.ts +5 -5
  155. package/principles/rules/solid/dip.ts +18 -8
  156. package/principles/rules/solid/isp.ts +9 -8
  157. package/principles/rules/solid/lsp.ts +6 -6
  158. package/principles/rules/solid/ocp.ts +6 -5
  159. package/principles/rules/solid/srp.ts +7 -8
  160. package/principles/types.ts +15 -0
  161. package/scripts/__tests__/sync-package-content.test.js +28 -0
  162. package/scripts/delphi-external-review.cjs +193 -10
  163. package/scripts/sync-package-content.js +7 -4
  164. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  165. package/skills/delphi-review/AGENTS.md +4 -4
  166. package/skills/delphi-review/SKILL.md +98 -14
  167. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  168. package/skills/delphi-review/references/requirements.md +6 -6
  169. package/skills/sprint-flow/AGENTS.md +4 -4
  170. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  171. package/skills/test-specification-alignment/AGENTS.md +4 -4
  172. package/lib/__tests__/download-skill.test.js +0 -281
  173. package/lib/download-skill.js +0 -96
@@ -1,4 +1,9 @@
1
1
  import { isAbsolute, resolve } from "node:path"
2
+ import { type ShellDialect, shellQuote } from "./dialect.js"
3
+
4
+ // Re-exported so existing importers of this module keep working; the dialect
5
+ // primitives themselves live in ./dialect.ts.
6
+ export { type ShellDialect, detectDialect, shellQuote, shq } from "./dialect.js"
2
7
 
3
8
  /** Shown when the global `xp-gate` CLI is not on PATH. */
4
9
  export const FALLBACK_MESSAGE =
@@ -22,11 +27,6 @@ export const GATE_WHITELIST = [
22
27
 
23
28
  export type GateId = (typeof GATE_WHITELIST)[number]
24
29
 
25
- /** POSIX single-quote shell escaping: `'` becomes `'\''`. */
26
- export function shq(value: string): string {
27
- return `'${value.replace(/'/g, "'\\''")}'`
28
- }
29
-
30
30
  export function isGateAllowed(gate: string): boolean {
31
31
  return (GATE_WHITELIST as readonly string[]).includes(gate)
32
32
  }
@@ -36,40 +36,62 @@ export interface BuildCommandOptions {
36
36
  target?: string
37
37
  gates?: readonly string[]
38
38
  config?: string
39
+ /**
40
+ * Executor dialect. Defaults to `"posix"` so existing callers keep working;
41
+ * callers running under DSH on Windows must pass `"powershell"`.
42
+ */
43
+ dialect?: ShellDialect
39
44
  }
40
45
 
41
- function buildInner(options: BuildCommandOptions): string {
46
+ function buildInner(options: BuildCommandOptions, dialect: ShellDialect): string {
42
47
  const { subcommand, target, gates, config } = options
48
+ const q = (value: string) => shellQuote(value, dialect)
43
49
  if (subcommand === "check") {
44
50
  const tokens = ["xp-gate", "check"]
45
- if (target !== undefined) tokens.push(shq(target))
51
+ if (target !== undefined) tokens.push(q(target))
46
52
  if (gates !== undefined && gates.length > 0) {
47
53
  // Defense-in-depth: the JSON-schema enum already rejects unknown gate ids,
48
54
  // but we also whitelist here so a non-whitelisted value can never reach the shell.
49
55
  const allowed = gates.filter(isGateAllowed)
50
- if (allowed.length > 0) tokens.push("--gates", shq(allowed.join(",")))
56
+ if (allowed.length > 0) tokens.push("--gates", q(allowed.join(",")))
51
57
  }
52
58
  return tokens.join(" ")
53
59
  }
54
60
  if (subcommand === "principles") {
55
61
  const tokens = ["xp-gate", "principles"]
56
- if (target !== undefined) tokens.push(shq(target))
62
+ if (target !== undefined) tokens.push(q(target))
57
63
  return tokens.join(" ")
58
64
  }
59
- return ["xp-gate", "arch", "--config", shq(config ?? "architecture.yaml")].join(" ")
65
+ return ["xp-gate", "arch", "--config", q(config ?? "architecture.yaml")].join(" ")
60
66
  }
61
67
 
62
68
  /**
63
69
  * Build a single guarded shell command: runs `xp-gate …` when the global CLI
64
70
  * is present, otherwise prints the graceful-degradation hint. A single
65
- * `if command -v` avoids a separate probe/run race.
71
+ * presence check avoids a separate probe/run race.
66
72
  */
67
73
  export function buildCommand(options: BuildCommandOptions): string {
68
- const inner = buildInner(options)
69
- return `if command -v xp-gate >/dev/null 2>&1; then ${inner}; else printf '%s\\n' ${shq(FALLBACK_MESSAGE)}; fi`
74
+ const dialect = options.dialect ?? "posix"
75
+ const inner = buildInner(options, dialect)
76
+ if (dialect === "posix") {
77
+ return `if command -v xp-gate >/dev/null 2>&1; then ${inner}; else printf '%s\\n' ${shellQuote(
78
+ FALLBACK_MESSAGE,
79
+ dialect,
80
+ )}; fi`
81
+ }
82
+ // PowerShell: `Get-Command` resolves the .cmd/.ps1 shims npm writes to
83
+ // %APPDATA%\npm. Those shims are invisible to a Git-Bash PATH lookup, so this
84
+ // branch is more reliable than `command -v` on Windows.
85
+ //
86
+ // The hint branch leaves $LASTEXITCODE untouched at 0, matching the POSIX
87
+ // branch's `printf` behaviour.
88
+ return `if (Get-Command xp-gate -ErrorAction SilentlyContinue) { ${inner} } else { Write-Output ${shellQuote(
89
+ FALLBACK_MESSAGE,
90
+ dialect,
91
+ )} }`
70
92
  }
71
93
 
72
94
  /** Resolve a model-supplied path against the session cwd; absolutes pass through. */
73
95
  export function resolveTarget(rawPath: string, cwd: string): string {
74
96
  return isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath)
75
- }
97
+ }
@@ -51,7 +51,16 @@ describe("packaging contract", () => {
51
51
  * @covers AC-DSH-008-01
52
52
  */
53
53
  it("every test file carries @test/@intent/@covers annotations", () => {
54
- const files = ["command.test.ts", "gate-runner.test.ts", "index.test.ts", "skills.test.ts"]
54
+ const files = [
55
+ "command.test.ts",
56
+ "gate-runner.test.ts",
57
+ "index.test.ts",
58
+ "skills.test.ts",
59
+ "dialect.test.ts",
60
+ "delphi.test.ts",
61
+ "delphi-config.test.ts",
62
+ "delphi-run.test.ts",
63
+ ]
55
64
  for (const f of files) {
56
65
  const content = readFileSync(join(PKG_ROOT, "src", f), "utf8")
57
66
  expect(content, `${f}: @test`).toMatch(/@test\s+REQ-\S+/)
@@ -59,4 +68,25 @@ describe("packaging contract", () => {
59
68
  expect(content, `${f}: @covers`).toMatch(/@covers\s+AC-\S+/)
60
69
  }
61
70
  })
71
+
72
+ /**
73
+ * @test REQ-DSH-016
74
+ * @intent 验证多模型交叉评审已在 DSH 中固化:插件注册 delphi-review 工具,
75
+ * 且实现强制三条 Delphi 不变量(3 位专家、模型两两不同、90% 阈值)。
76
+ * @covers AC-DSH-016-01
77
+ */
78
+ it("registers the delphi-review tool and enforces Delphi invariants", () => {
79
+ const entry = readFileSync(join(PKG_ROOT, "src", "index.ts"), "utf8")
80
+ expect(entry).toContain('name: "delphi-review"')
81
+ expect(entry).toContain("loadDelphiConfig")
82
+ expect(entry).toContain("runReview")
83
+
84
+ // The invariants must live in code, not only in prose.
85
+ const core = readFileSync(join(PKG_ROOT, "src", "delphi.ts"), "utf8")
86
+ expect(core).toMatch(/REQUIRED_EXPERTS\s*=\s*3/)
87
+ expect(core).toMatch(/DEFAULT_THRESHOLD_PERCENT\s*=\s*90/)
88
+ expect(core).toMatch(/distinct/)
89
+ // A report with no explicit verdict must not be counted toward consensus.
90
+ expect(core).toContain("INVALID_NO_VERDICT")
91
+ })
62
92
  })
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { DelphiSetupError, expandEnv, resolveConfig } from "./delphi-config.js"
3
+
4
+ /**
5
+ * @test REQ-DSH-016
6
+ * @intent 验证 .delphi-config.json 的解析:active_profile 选择、${VAR} 与 {env:VAR}
7
+ * 两种占位符展开、缺密钥/缺 provider 时给出可操作的错误,
8
+ * 以及未展开的模型占位符在校验期被拦住(而非变成 provider 404)。
9
+ * @covers AC-DSH-016-02
10
+ */
11
+
12
+ const baseConfig = {
13
+ active_profile: "whalecloud",
14
+ profiles: {
15
+ whalecloud: {
16
+ provider: "whalecloud",
17
+ providers: {
18
+ whalecloud: {
19
+ base_url: "https://lab.example.com/gpt-proxy",
20
+ api_key: "${WHALECLOUD_API_KEY}",
21
+ },
22
+ },
23
+ experts: [
24
+ { id: "Expert A", role: "architecture", model: "g-glm-5.3-flash", max_tokens: 32000 },
25
+ { id: "Expert B", role: "technical", model: "g-qwen3.8-flash" },
26
+ { id: "Expert C", role: "feasibility", model: "g-deepseek-flash" },
27
+ ],
28
+ consensus: { threshold_percent: 90, max_review_rounds: 5 },
29
+ },
30
+ },
31
+ }
32
+
33
+ describe("expandEnv", () => {
34
+ it("expands both ${VAR} and {env:VAR} spellings", () => {
35
+ const env = { TOK: "secret" }
36
+ expect(expandEnv("${TOK}", env)).toBe("secret")
37
+ expect(expandEnv("{env:TOK}", env)).toBe("secret")
38
+ expect(expandEnv("Bearer ${TOK}!", env)).toBe("Bearer secret!")
39
+ })
40
+
41
+ it("expands an unset variable to empty so the caller can detect it", () => {
42
+ expect(expandEnv("${NOPE}", {})).toBe("")
43
+ })
44
+ })
45
+
46
+ describe("resolveConfig", () => {
47
+ it("resolves the active profile into seats and transport settings", () => {
48
+ const cfg = resolveConfig(baseConfig, { WHALECLOUD_API_KEY: "k123" })
49
+ expect(cfg.provider).toBe("whalecloud")
50
+ expect(cfg.baseUrl).toBe("https://lab.example.com/gpt-proxy")
51
+ expect(cfg.apiKey).toBe("k123")
52
+ expect(cfg.seats).toHaveLength(3)
53
+ expect(cfg.seats.map((s) => s.model)).toEqual([
54
+ "g-glm-5.3-flash",
55
+ "g-qwen3.8-flash",
56
+ "g-deepseek-flash",
57
+ ])
58
+ // Per-seat budget is preserved: reasoning models need a larger one.
59
+ expect(cfg.seats[0].maxTokens).toBe(32000)
60
+ expect(cfg.thresholdPercent).toBe(90)
61
+ expect(cfg.maxRounds).toBe(5)
62
+ })
63
+
64
+ it("names the missing environment variable instead of echoing the config", () => {
65
+ // The config may hold a literal key, so the error must not interpolate it.
66
+ let message = ""
67
+ try {
68
+ resolveConfig(baseConfig, {})
69
+ } catch (e) {
70
+ message = (e as Error).message
71
+ }
72
+ expect(message).toMatch(/WHALECLOUD_API_KEY/)
73
+ expect(message).toMatch(/not set/)
74
+ })
75
+
76
+ it("reports an unknown provider with the known alternatives", () => {
77
+ const bad = structuredClone(baseConfig)
78
+ bad.profiles.whalecloud.provider = "nope"
79
+ expect(() => resolveConfig(bad, { WHALECLOUD_API_KEY: "k" })).toThrow(/not defined under "providers"/)
80
+ })
81
+
82
+ it("rejects an unexpanded model placeholder at config time", () => {
83
+ const bad = structuredClone(baseConfig)
84
+ bad.profiles.whalecloud.experts[1].model = "${QWEN_MODEL}"
85
+ expect(() => resolveConfig(bad, { WHALECLOUD_API_KEY: "k" })).toThrow(/unexpanded/)
86
+ })
87
+
88
+ it("refuses to guess when several profiles exist and none is active", () => {
89
+ const multi = {
90
+ profiles: {
91
+ a: { provider: "p", providers: { p: { base_url: "u", api_key: "k" } }, experts: [] },
92
+ b: { provider: "p", providers: { p: { base_url: "u", api_key: "k" } }, experts: [] },
93
+ },
94
+ }
95
+ expect(() => resolveConfig(multi, {})).toThrow(/No active_profile/)
96
+ })
97
+
98
+ it("enforces the three-distinct-model invariant through the config path too", () => {
99
+ const dup = structuredClone(baseConfig)
100
+ dup.profiles.whalecloud.experts[2].model = "g-qwen3.8-flash"
101
+ expect(() => resolveConfig(dup, { WHALECLOUD_API_KEY: "k" })).toThrow(/distinct/)
102
+ })
103
+
104
+ it("raises DelphiSetupError so callers can present guidance, not a stack trace", () => {
105
+ expect(() => resolveConfig(baseConfig, {})).toThrow(DelphiSetupError)
106
+ })
107
+ })
108
+
109
+ /**
110
+ * The shape xp-gate actually writes differs from the tidy array form: `experts`
111
+ * is an OBJECT keyed by role, each expert names its own provider, and
112
+ * `consensus` sits at the document root. This was found by running the loader
113
+ * against the repo's real .delphi-config.json and is locked in here.
114
+ */
115
+ describe("resolveConfig with the real on-disk schema", () => {
116
+ const realWorld = {
117
+ active_profile: "whalecloud",
118
+ profiles: {
119
+ whalecloud: {
120
+ providers: {
121
+ whalecloud: {
122
+ base_url: "https://lab.iwhalecloud.com/gpt-proxy",
123
+ api_key: "${WHALECLOUD_API_KEY}",
124
+ },
125
+ },
126
+ experts: {
127
+ architecture: { provider: "whalecloud", model: "g-glm-5.3-flash" },
128
+ technical: { provider: "whalecloud", model: "g-qwen3.8-flash" },
129
+ feasibility: { provider: "whalecloud", model: "g-deepseek-flash" },
130
+ },
131
+ },
132
+ },
133
+ consensus: { threshold_percent: 90, max_review_rounds: 5, distinct_models_required: true },
134
+ }
135
+
136
+ it("reads experts from the object form and uses the key as the role", () => {
137
+ const cfg = resolveConfig(realWorld, { WHALECLOUD_API_KEY: "k" })
138
+ expect(cfg.seats).toHaveLength(3)
139
+ expect(cfg.seats.map((s) => s.role)).toEqual(["architecture", "technical", "feasibility"])
140
+ expect(cfg.seats.map((s) => s.model)).toEqual([
141
+ "g-glm-5.3-flash",
142
+ "g-qwen3.8-flash",
143
+ "g-deepseek-flash",
144
+ ])
145
+ })
146
+
147
+ it("resolves the provider named per expert when the profile has no default", () => {
148
+ // No profile-level "provider" key at all -- each seat carries its own.
149
+ const cfg = resolveConfig(realWorld, { WHALECLOUD_API_KEY: "k" })
150
+ expect(cfg.provider).toBe("whalecloud")
151
+ expect(cfg.baseUrl).toBe("https://lab.iwhalecloud.com/gpt-proxy")
152
+ expect(cfg.apiKey).toBe("k")
153
+ })
154
+
155
+ it("reads consensus from the document root", () => {
156
+ const cfg = resolveConfig(realWorld, { WHALECLOUD_API_KEY: "k" })
157
+ expect(cfg.thresholdPercent).toBe(90)
158
+ expect(cfg.maxRounds).toBe(5)
159
+ })
160
+
161
+ it("still enforces three distinct models in the object form", () => {
162
+ const dup = structuredClone(realWorld)
163
+ dup.profiles.whalecloud.experts.feasibility.model = "g-qwen3.8-flash"
164
+ expect(() => resolveConfig(dup, { WHALECLOUD_API_KEY: "k" })).toThrow(/distinct/)
165
+ })
166
+
167
+ it("rejects experts spread across different gateways", () => {
168
+ // One shared base_url is required so a seat's key is never sent elsewhere.
169
+ const split = structuredClone(realWorld) as {
170
+ active_profile: string
171
+ profiles: Record<
172
+ string,
173
+ {
174
+ providers: Record<string, { base_url: string; api_key: string }>
175
+ experts: Record<string, { provider: string; model: string }>
176
+ }
177
+ >
178
+ consensus: { threshold_percent: number; max_review_rounds: number }
179
+ }
180
+ split.profiles.whalecloud.providers.other = {
181
+ base_url: "https://elsewhere.example/v1",
182
+ api_key: "k2",
183
+ }
184
+ split.profiles.whalecloud.experts.technical = {
185
+ provider: "other",
186
+ model: "g-qwen3.8-flash",
187
+ }
188
+ expect(() => resolveConfig(split, { WHALECLOUD_API_KEY: "k" })).toThrow(/share one gateway/)
189
+ })
190
+ })
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Delphi configuration loading for the DSH plugin.
3
+ *
4
+ * Reads the same `.delphi-config.json` the xp-gate skill already uses, so a
5
+ * project configures its reviewers once and both the skill and the DSH tool
6
+ * agree on who they are.
7
+ *
8
+ * @test REQ-DSH-016
9
+ * @intent 复用项目既有的 .delphi-config.json,把 provider/api_key/专家席位解析成
10
+ * DSH 工具可用的结构,并对 ${ENV} 占位符与缺失密钥给出可操作的报错。
11
+ * @covers AC-DSH-016-02
12
+ */
13
+
14
+ import { readFileSync } from "node:fs"
15
+ import { resolve } from "node:path"
16
+ import type { ExpertSeat, DelphiOptions } from "./delphi.js"
17
+ import { validateSeats } from "./delphi.js"
18
+
19
+ /** Shape of one expert entry in .delphi-config.json. */
20
+ interface RawExpert {
21
+ id?: string
22
+ role?: string
23
+ name?: string
24
+ model?: string
25
+ requested_model?: string
26
+ max_tokens?: number
27
+ maxTokens?: number
28
+ /** Per-expert provider override; falls back to the profile default. */
29
+ provider?: string
30
+ }
31
+
32
+ interface RawProfile {
33
+ provider?: string
34
+ providers?: Record<string, { base_url?: string; baseUrl?: string; api_key?: string; apiKey?: string }>
35
+ /**
36
+ * Experts appear in two shapes in the wild: an object keyed by role (what
37
+ * xp-gate actually writes) and an array. Both are accepted.
38
+ */
39
+ experts?: RawExpert[] | Record<string, RawExpert>
40
+ consensus?: { threshold_percent?: number; max_review_rounds?: number }
41
+ }
42
+
43
+ export interface RawDelphiConfig extends RawProfile {
44
+ active_profile?: string
45
+ profiles?: Record<string, RawProfile>
46
+ }
47
+
48
+ export interface ResolvedDelphiConfig {
49
+ readonly seats: ExpertSeat[]
50
+ readonly baseUrl: string
51
+ readonly apiKey: string
52
+ readonly thresholdPercent: number
53
+ readonly maxRounds: number
54
+ readonly provider: string
55
+ }
56
+
57
+ /** Raised when config on disk cannot be turned into a usable review setup. */
58
+ export class DelphiSetupError extends Error {}
59
+
60
+ /**
61
+ * Expand `${VAR}` and `{env:VAR}` placeholders from the environment.
62
+ * Supports the two spellings already present in xp-gate config files.
63
+ */
64
+ export function expandEnv(value: string, env: NodeJS.ProcessEnv = process.env): string {
65
+ return value
66
+ .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name: string) => env[name] ?? "")
67
+ .replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name: string) => env[name] ?? "")
68
+ }
69
+
70
+ /** Pick the active profile, falling back to the top level. */
71
+ function selectProfile(cfg: RawDelphiConfig): RawProfile {
72
+ const active = cfg.active_profile
73
+ if (active && cfg.profiles?.[active]) return cfg.profiles[active]
74
+ if (cfg.profiles && Object.keys(cfg.profiles).length > 0) {
75
+ throw new DelphiSetupError(
76
+ `No active_profile set. Available: ${Object.keys(cfg.profiles).join(", ")}`,
77
+ )
78
+ }
79
+ return cfg
80
+ }
81
+
82
+ function seatFromRaw(raw: RawExpert, index: number, fallbackRole: string): ExpertSeat {
83
+ const model = raw.model ?? raw.requested_model ?? ""
84
+ return {
85
+ id: raw.id ?? raw.name ?? `Expert ${String.fromCharCode(65 + index)}`,
86
+ role: raw.role ?? fallbackRole,
87
+ model,
88
+ maxTokens: raw.max_tokens ?? raw.maxTokens,
89
+ }
90
+ }
91
+
92
+ /** Normalize either experts shape into an ordered list with roles attached. */
93
+ function normalizeExperts(experts: RawProfile["experts"]): Array<{ raw: RawExpert; role: string }> {
94
+ if (!experts) return []
95
+ if (Array.isArray(experts)) {
96
+ return experts.map((raw, i) => ({ raw, role: raw.role ?? `expert${i}` }))
97
+ }
98
+ // Object form: the KEY is the role, which is how xp-gate writes it.
99
+ return Object.entries(experts).map(([role, raw]) => ({ raw, role }))
100
+ }
101
+
102
+ /**
103
+ * Turn raw config into a ready-to-run review setup.
104
+ * Fails loudly and specifically: a missing key or an unexpanded placeholder is
105
+ * the most common way a Delphi run silently degrades.
106
+ */
107
+ export function resolveConfig(
108
+ cfg: RawDelphiConfig,
109
+ env: NodeJS.ProcessEnv = process.env,
110
+ ): ResolvedDelphiConfig {
111
+ const profile = selectProfile(cfg)
112
+ const normalized = normalizeExperts(profile.experts)
113
+
114
+ // An expert may name its own provider; otherwise there must be a profile-wide
115
+ // default. Resolve base_url/api_key per provider so mixed setups work.
116
+ const providerNames = new Set(
117
+ normalized.map((e) => e.raw.provider ?? profile.provider).filter((p): p is string => !!p),
118
+ )
119
+ if (providerNames.size === 0) {
120
+ throw new DelphiSetupError(
121
+ 'No provider configured. Set "provider" on the profile or on each expert.',
122
+ )
123
+ }
124
+
125
+ const providerTable = profile.providers ?? {}
126
+ const resolvedProviders = new Map<string, { baseUrl: string; apiKey: string }>()
127
+ for (const name of providerNames) {
128
+ const entry = providerTable[name]
129
+ if (!entry) {
130
+ throw new DelphiSetupError(
131
+ `Provider "${name}" is not defined under "providers". ` +
132
+ `Known: ${Object.keys(providerTable).join(", ") || "(none)"}`,
133
+ )
134
+ }
135
+ const baseUrl = entry.base_url ?? entry.baseUrl ?? ""
136
+ if (!baseUrl) throw new DelphiSetupError(`Provider "${name}" has no base_url.`)
137
+
138
+ const rawKey = entry.api_key ?? entry.apiKey ?? ""
139
+ const apiKey = expandEnv(rawKey, env)
140
+ if (!apiKey) {
141
+ // Name the variable rather than echoing config, which may hold a literal key.
142
+ const varName =
143
+ /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/.exec(rawKey)?.[1] ??
144
+ /\{env:([A-Za-z_][A-Za-z0-9_]*)\}/.exec(rawKey)?.[1]
145
+ throw new DelphiSetupError(
146
+ varName
147
+ ? `API key variable ${varName} is not set in the environment.`
148
+ : `Provider "${name}" has no api_key.`,
149
+ )
150
+ }
151
+ resolvedProviders.set(name, { baseUrl, apiKey })
152
+ }
153
+
154
+ const seats = normalized.map((e, i) => seatFromRaw(e.raw, i, e.role))
155
+ // Surface an unexpanded placeholder as a config error, not a provider 404.
156
+ for (const s of seats) {
157
+ if (s.model.includes("${") || s.model.includes("{env:")) {
158
+ throw new DelphiSetupError(
159
+ `Expert "${s.id}" model "${s.model}" is an unexpanded placeholder.`,
160
+ )
161
+ }
162
+ }
163
+ validateSeats(seats)
164
+
165
+ // All seats share one gateway in the supported setup; require agreement so a
166
+ // mixed list cannot silently send one seat's key to another host.
167
+ const chosen = resolvedProviders.get(normalized[0].raw.provider ?? profile.provider ?? "")
168
+ if (!chosen) throw new DelphiSetupError("Unable to resolve a provider for the first expert.")
169
+ for (const e of normalized) {
170
+ const name = e.raw.provider ?? profile.provider ?? ""
171
+ const other = resolvedProviders.get(name)
172
+ if (!other || other.baseUrl !== chosen.baseUrl) {
173
+ throw new DelphiSetupError(
174
+ `All experts must share one gateway base_url; "${name}" differs. ` +
175
+ `Split the review or align the providers.`,
176
+ )
177
+ }
178
+ }
179
+
180
+ // consensus may sit on the profile or at the document root; both occur.
181
+ const consensus = profile.consensus ?? cfg.consensus
182
+
183
+ return {
184
+ seats,
185
+ baseUrl: chosen.baseUrl,
186
+ apiKey: chosen.apiKey,
187
+ thresholdPercent: consensus?.threshold_percent ?? 90,
188
+ maxRounds: consensus?.max_review_rounds ?? 5,
189
+ provider: normalized[0].raw.provider ?? profile.provider ?? "",
190
+ }
191
+ }
192
+
193
+ /** Read and resolve `.delphi-config.json` from a project root. */
194
+ export function loadDelphiConfig(
195
+ cwd: string,
196
+ env: NodeJS.ProcessEnv = process.env,
197
+ ): ResolvedDelphiConfig {
198
+ const path = resolve(cwd, ".delphi-config.json")
199
+ let raw: string
200
+ try {
201
+ raw = readFileSync(path, "utf8")
202
+ } catch {
203
+ throw new DelphiSetupError(`No .delphi-config.json found at ${path}.`)
204
+ }
205
+ let parsed: RawDelphiConfig
206
+ try {
207
+ parsed = JSON.parse(raw) as RawDelphiConfig
208
+ } catch (e) {
209
+ throw new DelphiSetupError(`.delphi-config.json is not valid JSON: ${(e as Error).message}`)
210
+ }
211
+ return resolveConfig(parsed, env)
212
+ }
213
+
214
+ /** Build the transport options a round needs from a resolved config. */
215
+ export function toDelphiOptions(cfg: ResolvedDelphiConfig): DelphiOptions {
216
+ return {
217
+ baseUrl: cfg.baseUrl,
218
+ apiKey: cfg.apiKey,
219
+ seats: cfg.seats,
220
+ thresholdPercent: cfg.thresholdPercent,
221
+ maxRounds: cfg.maxRounds,
222
+ }
223
+ }