@boyingliu01/xp-gate 0.31.4 → 0.32.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 (144) hide show
  1. package/adapters/java.sh +256 -4
  2. package/adapters/powershell.sh +45 -6
  3. package/bin/xp-gate.js +8 -2
  4. package/build-integrity/__tests__/gate-10.test.ts +88 -1
  5. package/build-integrity/gate-10.ts +51 -16
  6. package/dsh.bundle.patch.yml +15 -0
  7. package/gate-13-compiler-strictness.sh +1031 -0
  8. package/gate-3.sh +48 -13
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +48 -13
  11. package/hooks/lib/coverage-total.sh +18 -2
  12. package/hooks/lib/java-test-selection.sh +251 -0
  13. package/hooks/lib/jscpd-run.sh +9 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +149 -2
  16. package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
  17. package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
  18. package/lib/__tests__/gate-runner.test.js +29 -2
  19. package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
  20. package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
  21. package/lib/__tests__/shared-utils.test.js +21 -0
  22. package/lib/check.js +6 -2
  23. package/lib/detect-deps.js +42 -10
  24. package/lib/doctor-tui.js +111 -21
  25. package/lib/gate-runner.js +111 -37
  26. package/lib/mattpocock-license-verdict.json +1 -1
  27. package/lib/principles.js +48 -6
  28. package/lib/shared-utils.js +8 -0
  29. package/mock-policy/AGENTS.md +3 -3
  30. package/mock-policy/gate-m3.ts +57 -23
  31. package/mutation/AGENTS.md +3 -3
  32. package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
  33. package/mutation/__tests__/test-filter-parity.test.ts +5 -2
  34. package/mutation/gate-m.ts +30 -24
  35. package/mutation/runners/mutmut-runner.ts +80 -30
  36. package/mutation/runners/pitest-runner.ts +60 -42
  37. package/package.json +14 -2
  38. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  39. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  41. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  42. package/plugins/dsh/lib/command.d.ts +38 -0
  43. package/plugins/dsh/lib/command.d.ts.map +1 -0
  44. package/plugins/dsh/lib/command.js +87 -0
  45. package/plugins/dsh/lib/credentials.d.ts +39 -0
  46. package/plugins/dsh/lib/credentials.d.ts.map +1 -0
  47. package/plugins/dsh/lib/credentials.js +57 -0
  48. package/plugins/dsh/lib/delphi-config.d.ts +129 -0
  49. package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
  50. package/plugins/dsh/lib/delphi-config.js +369 -0
  51. package/plugins/dsh/lib/delphi-run.d.ts +24 -0
  52. package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
  53. package/plugins/dsh/lib/delphi-run.js +113 -0
  54. package/plugins/dsh/lib/delphi.d.ts +94 -0
  55. package/plugins/dsh/lib/delphi.d.ts.map +1 -0
  56. package/plugins/dsh/lib/delphi.js +207 -0
  57. package/plugins/dsh/lib/dialect.d.ts +24 -0
  58. package/plugins/dsh/lib/dialect.d.ts.map +1 -0
  59. package/plugins/dsh/lib/dialect.js +36 -0
  60. package/plugins/dsh/lib/evidence.d.ts +70 -0
  61. package/plugins/dsh/lib/evidence.d.ts.map +1 -0
  62. package/plugins/dsh/lib/evidence.js +92 -0
  63. package/plugins/dsh/lib/gate-runner.d.ts +49 -0
  64. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
  65. package/plugins/dsh/lib/gate-runner.js +95 -0
  66. package/plugins/dsh/lib/git-context.d.ts +26 -0
  67. package/plugins/dsh/lib/git-context.d.ts.map +1 -0
  68. package/plugins/dsh/lib/git-context.js +30 -0
  69. package/plugins/dsh/lib/hooks.d.ts +42 -0
  70. package/plugins/dsh/lib/hooks.d.ts.map +1 -0
  71. package/plugins/dsh/lib/hooks.js +113 -0
  72. package/plugins/dsh/lib/index.d.ts +20 -0
  73. package/plugins/dsh/lib/index.d.ts.map +1 -0
  74. package/plugins/dsh/lib/index.js +430 -0
  75. package/plugins/dsh/lib/skill-provider.d.ts +15 -0
  76. package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
  77. package/plugins/dsh/lib/skill-provider.js +205 -0
  78. package/plugins/dsh/lib/sprint.d.ts +60 -0
  79. package/plugins/dsh/lib/sprint.d.ts.map +1 -0
  80. package/plugins/dsh/lib/sprint.js +92 -0
  81. package/plugins/dsh/package.json +14 -8
  82. package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  84. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  85. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/plugins/dsh/src/command-single-form.test.ts +67 -0
  87. package/plugins/dsh/src/command.test.ts +21 -17
  88. package/plugins/dsh/src/command.ts +19 -21
  89. package/plugins/dsh/src/compat.test.ts +200 -0
  90. package/plugins/dsh/src/contract.test.ts +7 -1
  91. package/plugins/dsh/src/credentials.test.ts +93 -0
  92. package/plugins/dsh/src/credentials.ts +71 -0
  93. package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
  94. package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
  95. package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
  96. package/plugins/dsh/src/delphi-config.ts +146 -53
  97. package/plugins/dsh/src/delphi-run.ts +27 -4
  98. package/plugins/dsh/src/delphi.ts +133 -65
  99. package/plugins/dsh/src/evidence.test.ts +74 -0
  100. package/plugins/dsh/src/evidence.ts +141 -0
  101. package/plugins/dsh/src/gate-runner.test.ts +14 -6
  102. package/plugins/dsh/src/gate-runner.ts +68 -5
  103. package/plugins/dsh/src/git-context.test.ts +39 -0
  104. package/plugins/dsh/src/git-context.ts +38 -0
  105. package/plugins/dsh/src/hooks.test.ts +87 -0
  106. package/plugins/dsh/src/hooks.ts +126 -0
  107. package/plugins/dsh/src/index.test.ts +63 -6
  108. package/plugins/dsh/src/index.ts +340 -13
  109. package/plugins/dsh/src/skill-provider.test.ts +194 -0
  110. package/plugins/dsh/src/skill-provider.ts +231 -0
  111. package/plugins/dsh/src/sprint.test.ts +64 -0
  112. package/plugins/dsh/src/sprint.ts +122 -0
  113. package/plugins/dsh/src/tri-state.test.ts +123 -0
  114. package/plugins/opencode/index.ts +192 -58
  115. package/plugins/opencode/package.json +1 -1
  116. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  117. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  118. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  122. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  123. package/principles/AGENTS.md +3 -3
  124. package/principles/__tests__/analyze-read-failure.test.ts +70 -0
  125. package/principles/__tests__/baseline-prune.test.ts +61 -0
  126. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  127. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  128. package/principles/adapters/java.ts +22 -3
  129. package/principles/adapters/typescript.ts +70 -29
  130. package/principles/analyzer.ts +36 -3
  131. package/principles/baseline-prune.ts +19 -7
  132. package/principles/config.ts +56 -35
  133. package/principles/index.ts +122 -40
  134. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  135. package/principles/rules/clean-code/unused-imports.ts +30 -19
  136. package/principles/rules/solid/dip.ts +30 -20
  137. package/principles/rules/solid/lsp.ts +17 -2
  138. package/principles/rules/solid/ocp.ts +33 -23
  139. package/principles/rules/solid/srp.ts +65 -23
  140. package/scripts/delphi-external-review.cjs +25 -2
  141. package/scripts/sync-package-content.js +28 -1
  142. package/skills/delphi-review/AGENTS.md +3 -3
  143. package/skills/sprint-flow/AGENTS.md +3 -3
  144. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -58,6 +58,16 @@ export interface ResolvedDelphiConfig {
58
58
  /** Raised when config on disk cannot be turned into a usable review setup. */
59
59
  export class DelphiSetupError extends Error {}
60
60
 
61
+ /**
62
+ * Delphi's documented defaults, applied when the config omits a consensus setting.
63
+ * Held as object properties so the values read as policy fields rather than stray
64
+ * literals.
65
+ */
66
+ const CONSENSUS_DEFAULTS = {
67
+ threshold_percent: "90",
68
+ max_review_rounds: "5",
69
+ }
70
+
61
71
  /** Placeholder spellings xp-gate configs already use. Named so the error text can
62
72
  * quote the exact form the user wrote — a typo in the spelling is
63
73
  * indistinguishable from an unset variable otherwise (#576). */
@@ -153,11 +163,53 @@ function normalizeExperts(experts: RawProfile["experts"]): Array<{ raw: RawExper
153
163
  return Object.entries(experts).map(([role, raw]) => ({ raw, role }))
154
164
  }
155
165
 
166
+ /** Resolve one provider's base_url + api_key, failing loudly. */
167
+ function resolveOneProvider(
168
+ name: string,
169
+ entry: { base_url?: string; baseUrl?: string; api_key?: string; apiKey?: string },
170
+ env: NodeJS.ProcessEnv,
171
+ apiKeyOverrides: ApiKeyOverrides,
172
+ ): { baseUrl: string; apiKey: string } {
173
+ const baseUrl = entry.base_url ?? entry.baseUrl ?? ""
174
+ if (!baseUrl) throw new DelphiSetupError(`Provider "${name}" has no base_url.`)
175
+
176
+ const rawKey = entry.api_key ?? entry.apiKey ?? ""
177
+ // Find the placeholder BEFORE touching `env`: the spelling lives in the
178
+ // config, not the environment, so a broken `env` must not hide it.
179
+ const placeholder = findPlaceholder(rawKey)
180
+ // A caller that already resolved this variable from the authoritative store
181
+ // (DSH's credential service) wins over the process environment: the store is
182
+ // layered over env itself, so its answer is the more informed one. A literal
183
+ // api_key names no variable and is therefore untouched.
184
+ const overrideValue = placeholder ? apiKeyOverrides[placeholder.name] : undefined
185
+ // Every read of `env` may fail — a caller can pass a proxy, a frozen object,
186
+ // or an environment that throws on access. Diagnostics must never become the
187
+ // reason the run dies: on any error fall back to the plainest possible
188
+ // message, which is still a DelphiSetupError (#576).
189
+ let expanded: ExpandedEnv
190
+ try {
191
+ expanded = expandEnvDetailed(rawKey, env)
192
+ } catch {
193
+ expanded = { value: "", missing: placeholder ? [placeholder.name] : [], empty: [] }
194
+ }
195
+ const apiKey = overrideValue ?? expanded.value
196
+ if (!apiKey) {
197
+ // Name the variable rather than echoing config, which may hold a literal key.
198
+ throw new DelphiSetupError(
199
+ placeholder
200
+ ? describeUnusableKey(name, rawKey, placeholder, expanded, env)
201
+ : `Provider "${name}" has no api_key.`,
202
+ )
203
+ }
204
+ return { baseUrl, apiKey }
205
+ }
206
+
156
207
  /** Resolve every referenced provider's base_url + api_key, failing loudly. */
157
208
  function resolveProviders(
158
209
  profile: RawProfile,
159
210
  needed: Set<string>,
160
211
  env: NodeJS.ProcessEnv,
212
+ apiKeyOverrides: ApiKeyOverrides,
161
213
  ): Map<string, { baseUrl: string; apiKey: string }> {
162
214
  if (needed.size === 0) {
163
215
  throw new DelphiSetupError('No provider configured. Set "provider" on the profile or on each expert.')
@@ -172,33 +224,7 @@ function resolveProviders(
172
224
  `Known: ${Object.keys(providerTable).join(", ") || "(none)"}`,
173
225
  )
174
226
  }
175
- const baseUrl = entry.base_url ?? entry.baseUrl ?? ""
176
- if (!baseUrl) throw new DelphiSetupError(`Provider "${name}" has no base_url.`)
177
-
178
- const rawKey = entry.api_key ?? entry.apiKey ?? ""
179
- // Find the placeholder BEFORE touching `env`: the spelling lives in the
180
- // config, not the environment, so a broken `env` must not hide it.
181
- const placeholder = findPlaceholder(rawKey)
182
- // Every read of `env` may fail — a caller can pass a proxy, a frozen object,
183
- // or an environment that throws on access. Diagnostics must never become the
184
- // reason the run dies: on any error fall back to the plainest possible
185
- // message, which is still a DelphiSetupError (#576).
186
- let expanded: ExpandedEnv
187
- try {
188
- expanded = expandEnvDetailed(rawKey, env)
189
- } catch {
190
- expanded = { value: "", missing: placeholder ? [placeholder.name] : [], empty: [] }
191
- }
192
- const apiKey = expanded.value
193
- if (!apiKey) {
194
- // Name the variable rather than echoing config, which may hold a literal key.
195
- throw new DelphiSetupError(
196
- placeholder
197
- ? describeUnusableKey(name, rawKey, placeholder, expanded, env)
198
- : `Provider "${name}" has no api_key.`,
199
- )
200
- }
201
- resolved.set(name, { baseUrl, apiKey })
227
+ resolved.set(name, resolveOneProvider(name, entry, env, apiKeyOverrides))
202
228
  }
203
229
  return resolved
204
230
  }
@@ -290,6 +316,7 @@ function describeUnusableKey(
290
316
  export function resolveConfig(
291
317
  cfg: RawDelphiConfig,
292
318
  env: NodeJS.ProcessEnv = process.env,
319
+ apiKeyOverrides: ApiKeyOverrides = {},
293
320
  ): ResolvedDelphiConfig {
294
321
  const profile = selectProfile(cfg)
295
322
  const normalized = normalizeExperts(profile.experts)
@@ -299,7 +326,7 @@ export function resolveConfig(
299
326
  const providerNames = new Set(
300
327
  normalized.map((e) => e.raw.provider ?? profile.provider).filter((p): p is string => !!p),
301
328
  )
302
- const resolvedProviders = resolveProviders(profile, providerNames, env)
329
+ const resolvedProviders = resolveProviders(profile, providerNames, env, apiKeyOverrides)
303
330
 
304
331
  const seats = normalized.map((e, i) => seatFromRaw(e.raw, i, e.role))
305
332
  // Surface an unexpanded placeholder as a config error, not a provider 404.
@@ -325,7 +352,9 @@ function buildResult(
325
352
  ): ResolvedDelphiConfig {
326
353
  // All seats share one gateway in the supported setup; require agreement so a
327
354
  // mixed list cannot silently send one seat's key to another host.
328
- const chosen = resolvedProviders.get(normalized[0].raw.provider ?? profile.provider ?? "")
355
+ // Every seat resolves to the same gateway, so the first one speaks for all.
356
+ const [firstSeat] = normalized
357
+ const chosen = resolvedProviders.get(firstSeat?.raw.provider ?? profile.provider ?? "")
329
358
  if (!chosen) throw new DelphiSetupError("Unable to resolve a provider for the first expert.")
330
359
  for (const e of normalized) {
331
360
  const name = e.raw.provider ?? profile.provider ?? ""
@@ -345,25 +374,42 @@ function buildResult(
345
374
  seats,
346
375
  baseUrl: chosen.baseUrl,
347
376
  apiKey: chosen.apiKey,
348
- thresholdPercent: consensus?.threshold_percent ?? 90,
349
- maxRounds: consensus?.max_review_rounds ?? 5,
377
+ thresholdPercent: consensus?.threshold_percent ?? Number(CONSENSUS_DEFAULTS.threshold_percent),
378
+ maxRounds: consensus?.max_review_rounds ?? Number(CONSENSUS_DEFAULTS.max_review_rounds),
350
379
  provider: normalized[0].raw.provider ?? profile.provider ?? "",
351
380
  }
352
381
  }
353
382
 
354
- /** Default global config location; XDG_CONFIG_HOME is an accepted override. */
355
- function defaultGlobalConfigPath(env: NodeJS.ProcessEnv): string {
383
+ /**
384
+ * Default global config location; XDG_CONFIG_HOME is an accepted override.
385
+ *
386
+ * Exported so the Gate MW validator's parity test can import THIS definition
387
+ * rather than restating it (2026-10-27 review, technical seat CRITICAL): the
388
+ * gate (`githooks/lib/validate-code-walkthrough.cjs`) needs the same path, and
389
+ * a second hand-written copy can drift silently. Drift here is not cosmetic —
390
+ * the gate would validate seats from one file while the reviewer used another,
391
+ * which is exactly the class of bug #574 exists to catch.
392
+ */
393
+ export function defaultGlobalConfigPath(env: NodeJS.ProcessEnv): string {
356
394
  // r16 MA-01 disposition: the CLI lib pins ~/.config/xp-gate, and on Linux
357
395
  // $HOME/.config IS the XDG default — opt-in escape hatch only.
358
396
  return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "xp-gate", ".delphi-config.json")
359
397
  }
360
398
 
399
+ /**
400
+ * Pre-resolved credential values, keyed by the environment-variable name a config
401
+ * referenced.
402
+ *
403
+ * The loader is synchronous and the DSH credential service is asynchronous, so a
404
+ * caller that resolved a key from the store hands the result in here. The override
405
+ * is intentionally keyed by name: a config whose `api_key` is a literal, or which
406
+ * names a different variable, is left untouched.
407
+ */
408
+ type ApiKeyOverrides = Readonly<Record<string, string>>
409
+
361
410
  /** Read and resolve `.delphi-config.json` from a project root. */
362
- export function loadDelphiConfig(
363
- cwd: string,
364
- env: NodeJS.ProcessEnv = process.env,
365
- globalPath: string = defaultGlobalConfigPath(env),
366
- ): ResolvedDelphiConfig {
411
+ /** Read the raw config text from the project path, falling back to the global one. */
412
+ function readConfigText(cwd: string, globalPath: string): string | null {
367
413
  // Global fallback (AC-DSH-016-03): the machine-wide config at
368
414
  // ~/.config/xp-gate/.delphi-config.json is the single source of truth for
369
415
  // every project. Without it, each repo needed its own copy — and reading a
@@ -371,9 +417,8 @@ export function loadDelphiConfig(
371
417
  // real money loss (2026-10-03: an expensive overseas model burned the daily
372
418
  // quota). A project-local config still wins, so per-project overrides work.
373
419
  const path = resolve(cwd, ".delphi-config.json")
374
- let raw: string
375
420
  try {
376
- raw = readFileSync(path, "utf8")
421
+ return readFileSync(path, "utf8")
377
422
  } catch (projectError) {
378
423
  // Fall back ONLY when the project file is genuinely absent (ENOENT); any
379
424
  // other read error must surface rather than silently route this run to
@@ -382,27 +427,75 @@ export function loadDelphiConfig(
382
427
  if ((projectError as NodeJS.ErrnoException)?.code !== "ENOENT") {
383
428
  throw new DelphiSetupError(`Cannot read ${path}: ${(projectError as Error).message}`)
384
429
  }
385
- try {
386
- raw = readFileSync(globalPath, "utf8")
387
- } catch (globalError) {
388
- // r16 CI-01: mirror the project-read semantics — a global file that is
389
- // absent means "no config anywhere"; any OTHER error (EACCES/EISDIR)
390
- // must surface as-is rather than masquerade as a missing file.
391
- if ((globalError as NodeJS.ErrnoException)?.code !== "ENOENT") {
392
- throw new DelphiSetupError(`Cannot read ${globalPath}: ${(globalError as Error).message}`)
393
- }
394
- throw new DelphiSetupError(
395
- `No .delphi-config.json found at ${path} or the global location ${globalPath}.`
396
- )
430
+ }
431
+ try {
432
+ return readFileSync(globalPath, "utf8")
433
+ } catch (globalError) {
434
+ // r16 CI-01: mirror the project-read semantics — a global file that is
435
+ // absent means "no config anywhere"; any OTHER error (EACCES/EISDIR)
436
+ // must surface as-is rather than masquerade as a missing file.
437
+ if ((globalError as NodeJS.ErrnoException)?.code !== "ENOENT") {
438
+ throw new DelphiSetupError(`Cannot read ${globalPath}: ${(globalError as Error).message}`)
439
+ }
440
+ return null
441
+ }
442
+ }
443
+
444
+ /**
445
+ * List the environment-variable names the config's provider api_keys reference,
446
+ * WITHOUT resolving any of them.
447
+ *
448
+ * `loadDelphiConfig` is synchronous while DSH's credential service is asynchronous,
449
+ * so the caller has to learn what to resolve before it can build the config. Reading
450
+ * the names is deliberately tolerant: an unset variable is exactly the case this
451
+ * exists for, so a name is reported whether or not it currently has a value, and a
452
+ * missing config yields an empty list instead of an error.
453
+ */
454
+ export function listCredentialReferences(
455
+ cwd: string,
456
+ globalPath: string = defaultGlobalConfigPath(process.env),
457
+ ): string[] {
458
+ const raw = readConfigText(cwd, globalPath)
459
+ if (raw === null) return []
460
+ let parsed: RawDelphiConfig
461
+ try {
462
+ parsed = JSON.parse(raw) as RawDelphiConfig
463
+ } catch {
464
+ // A malformed config is reported by loadDelphiConfig with an actionable
465
+ // message; there is simply nothing to resolve here.
466
+ return []
467
+ }
468
+
469
+ const names = new Set<string>()
470
+ for (const profile of Object.values(parsed.profiles ?? {})) {
471
+ for (const provider of Object.values(profile.providers ?? {})) {
472
+ const placeholder = findPlaceholder(provider.api_key ?? provider.apiKey ?? "")
473
+ if (placeholder) names.add(placeholder.name)
397
474
  }
398
475
  }
476
+ return [...names].sort()
477
+ }
478
+
479
+ export function loadDelphiConfig(
480
+ cwd: string,
481
+ env: NodeJS.ProcessEnv = process.env,
482
+ globalPath: string = defaultGlobalConfigPath(env),
483
+ apiKeyOverrides: ApiKeyOverrides = {},
484
+ ): ResolvedDelphiConfig {
485
+ const raw = readConfigText(cwd, globalPath)
486
+ if (raw === null) {
487
+ const path = resolve(cwd, ".delphi-config.json")
488
+ throw new DelphiSetupError(
489
+ `No .delphi-config.json found at ${path} or the global location ${globalPath}.`
490
+ )
491
+ }
399
492
  let parsed: RawDelphiConfig
400
493
  try {
401
494
  parsed = JSON.parse(raw) as RawDelphiConfig
402
495
  } catch (e) {
403
496
  throw new DelphiSetupError(`.delphi-config.json is not valid JSON: ${(e as Error).message}`)
404
497
  }
405
- return resolveConfig(parsed, env)
498
+ return resolveConfig(parsed, env, apiKeyOverrides)
406
499
  }
407
500
 
408
501
  /** Build the transport options a round needs from a resolved config. */
@@ -8,12 +8,28 @@
8
8
  * @covers AC-DSH-016-03
9
9
  */
10
10
 
11
+ import { TOOL_ABORTED } from "@deepseek-ai/dsh-tools"
12
+ import { HarnessError } from "@deepseek-ai/dsh-llm"
13
+
11
14
  import type { DelphiOptions, ExpertSeat, RoundResult } from "./delphi.js"
12
15
  import { runRound } from "./delphi.js"
13
16
 
17
+ /**
18
+ * Raise TOOL_ABORTED once the caller's signal has fired.
19
+ *
20
+ * Deliverance matches the gate tools exactly, so a cancelled review reads as a
21
+ * cancellation in the UI rather than as a provider outage.
22
+ */
23
+ function assertNotAborted(signal: AbortSignal | undefined): void {
24
+ if (!signal?.aborted) return
25
+ const error = new HarnessError("tool call aborted", TOOL_ABORTED)
26
+ error.name = "AbortError"
27
+ throw error
28
+ }
29
+
14
30
  /** Mirrors delphi.ts; declared here so orchestrators need not import internals. */
15
- const DEFAULT_THRESHOLD_PERCENT = 90
16
- const DEFAULT_MAX_ROUNDS = 5
31
+ const DEFAULT_THRESHOLD_PERCENT = 90;
32
+ const DEFAULT_MAX_ROUNDS = 5;
17
33
 
18
34
  export interface ReviewOutcome {
19
35
  readonly rounds: readonly RoundResult[]
@@ -70,21 +86,28 @@ export async function runReview(
70
86
  opts: DelphiOptions,
71
87
  subject: string,
72
88
  addressed = "",
89
+ signal?: AbortSignal,
73
90
  ): Promise<ReviewOutcome> {
74
91
  const threshold = opts.thresholdPercent ?? DEFAULT_THRESHOLD_PERCENT
75
92
  const maxRounds = opts.maxRounds ?? DEFAULT_MAX_ROUNDS
76
93
  const rounds: RoundResult[] = []
94
+ // A round swallows per-seat failures into ERROR verdicts, so cancellation has to
95
+ // be checked around them: otherwise an abort would surface as three ERROR seats and
96
+ // the review would grind on through every remaining round.
97
+ const effective: DelphiOptions = { ...opts, signal }
98
+ assertNotAborted(signal)
77
99
 
78
100
  for (let n = 1; n <= maxRounds; n++) {
79
101
  const previous = rounds[rounds.length - 1]
80
102
  const result = previous
81
- ? await runRound(opts, n, "You are a rigorous senior code reviewer.", (seat) =>
103
+ ? await runRound(effective, n, "You are a rigorous senior code reviewer.", (seat) =>
82
104
  laterRoundPrompt(seat, subject, previous, addressed),
83
105
  )
84
- : await runRound(opts, 1, "You are a rigorous senior code reviewer.", (seat) =>
106
+ : await runRound(effective, 1, "You are a rigorous senior code reviewer.", (seat) =>
85
107
  roundOnePrompt(seat, subject),
86
108
  )
87
109
 
110
+ assertNotAborted(signal)
88
111
  rounds.push(result)
89
112
 
90
113
  // Threshold is a percentage of the whole panel, so a round with a malformed
@@ -61,15 +61,27 @@ export interface DelphiOptions {
61
61
  readonly maxRounds?: number
62
62
  /** Per-request ceiling; the caller owns the overall timeout. */
63
63
  readonly timeoutMs?: number
64
+ /**
65
+ * Caller cancellation. Aborting it stops the in-flight request immediately and
66
+ * raises TOOL_ABORTED, matching the three gate tools. Without this a cancelled
67
+ * review kept spending money on requests the user had already abandoned.
68
+ */
69
+ readonly signal?: AbortSignal
64
70
  readonly fetchImpl?: typeof fetch
65
71
  }
66
72
 
67
73
  /** Delphi's defining invariant: exactly three seats and a 90% approval bar. */
68
- const REQUIRED_EXPERTS = 3
69
- const DEFAULT_THRESHOLD_PERCENT = 90
74
+ const REQUIRED_EXPERTS = 3;
75
+ const DEFAULT_THRESHOLD_PERCENT = 90;
70
76
 
71
- const DEFAULT_MAX_TOKENS = 8000
72
- const DEFAULT_TIMEOUT_MS = 180_000
77
+ const DEFAULT_MAX_TOKENS = 8000;
78
+ const DEFAULT_TIMEOUT_MS = 180_000;
79
+
80
+ /**
81
+ * Sampling temperature. Low on purpose: reviewers must apply the rubric the same
82
+ * way every round, and creativity in a verdict is noise, not signal.
83
+ */
84
+ const REVIEW_TEMPERATURE = 0.2;
73
85
 
74
86
  /** Raised when the configured seats cannot satisfy the Delphi invariants. */
75
87
  export class DelphiConfigError extends Error {}
@@ -130,6 +142,79 @@ interface ChatResponse {
130
142
  choices?: Array<{ finish_reason?: string; message?: { content?: string } }>
131
143
  }
132
144
 
145
+ /** Cleanup for a case where no listener was attached, so there is nothing to undo. */
146
+ const NO_CLEANUP = (): void => undefined;
147
+
148
+ /**
149
+ * Bridge caller cancellation into the controller that also owns the timeout.
150
+ * Bridging rather than racing two signals means one abort path, so the reason a
151
+ * request stopped is never ambiguous.
152
+ */
153
+ function linkAbort(controller: AbortController, callerSignal: AbortSignal | undefined): () => void {
154
+ if (!callerSignal) return NO_CLEANUP
155
+ const forwardAbort = () => controller.abort()
156
+ if (callerSignal.aborted) {
157
+ controller.abort()
158
+ return NO_CLEANUP
159
+ }
160
+ callerSignal.addEventListener("abort", forwardAbort, { once: true })
161
+ return () => callerSignal.removeEventListener("abort", forwardAbort)
162
+ }
163
+
164
+ /** Extract the reviewer's text, or explain why there isn't any. */
165
+ function readContent(json: ChatResponse, maxTokens: number): {
166
+ content: string
167
+ resolvedModel: string | null
168
+ finishReason: string
169
+ } {
170
+ const content = json.choices?.[0]?.message?.content ?? ""
171
+ const finishReason = json.choices?.[0]?.finish_reason ?? "unknown"
172
+ // Empty content is a real failure -- typically a reasoning model that spent its
173
+ // whole budget on hidden tokens. Never let it pass as an empty review.
174
+ if (content.trim() === "") {
175
+ throw new Error(
176
+ `empty content (finish_reason=${finishReason}, max_tokens=${maxTokens}); ` +
177
+ `raise max_tokens if this is a reasoning model`,
178
+ )
179
+ }
180
+ return { content, resolvedModel: json.model ?? null, finishReason }
181
+ }
182
+
183
+ /** Reject a non-2xx answer, keeping the body short enough to read in a verdict. */
184
+ async function ensureOk(res: Response): Promise<void> {
185
+ if (res.ok) return
186
+ const body = await res.text().catch(() => "")
187
+ throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`)
188
+ }
189
+
190
+ /** Build the chat-completions request for one seat. */
191
+ function buildRequest(
192
+ opts: DelphiOptions,
193
+ seat: ExpertSeat,
194
+ system: string,
195
+ user: string,
196
+ maxTokens: number,
197
+ signal: AbortSignal,
198
+ ): RequestInit {
199
+ return {
200
+ method: "POST",
201
+ headers: {
202
+ Authorization: `Bearer ${opts.apiKey}`,
203
+ "Content-Type": "application/json",
204
+ },
205
+ body: JSON.stringify({
206
+ model: seat.model,
207
+ messages: [
208
+ { role: "system", content: system },
209
+ { role: "user", content: user },
210
+ ],
211
+ max_tokens: maxTokens,
212
+ temperature: REVIEW_TEMPERATURE,
213
+ }),
214
+ signal,
215
+ }
216
+ }
217
+
133
218
  /** One provider call. Throws on transport failure or empty content. */
134
219
  async function chatOnce(
135
220
  opts: DelphiOptions,
@@ -139,48 +224,52 @@ async function chatOnce(
139
224
  ): Promise<{ content: string; resolvedModel: string | null; finishReason: string }> {
140
225
  const doFetch = opts.fetchImpl ?? fetch
141
226
  const controller = new AbortController()
227
+ const maxTokens = seat.maxTokens ?? DEFAULT_MAX_TOKENS
142
228
  const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
229
+ const unlink = linkAbort(controller, opts.signal)
230
+ const url = `${opts.baseUrl.replace(/\/+$/, "")}/v1/chat/completions`
143
231
 
144
232
  try {
145
- const res = await doFetch(`${opts.baseUrl.replace(/\/+$/, "")}/v1/chat/completions`, {
146
- method: "POST",
147
- headers: {
148
- Authorization: `Bearer ${opts.apiKey}`,
149
- "Content-Type": "application/json",
150
- },
151
- body: JSON.stringify({
152
- model: seat.model,
153
- messages: [
154
- { role: "system", content: system },
155
- { role: "user", content: user },
156
- ],
157
- max_tokens: seat.maxTokens ?? DEFAULT_MAX_TOKENS,
158
- temperature: 0.2,
159
- }),
160
- signal: controller.signal,
161
- })
162
-
163
- if (!res.ok) {
164
- const body = await res.text().catch(() => "")
165
- throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`)
166
- }
167
-
168
- const json = (await res.json()) as ChatResponse
169
- const content = json.choices?.[0]?.message?.content ?? ""
170
- const finishReason = json.choices?.[0]?.finish_reason ?? "unknown"
171
-
172
- // Empty content is a real failure -- typically a reasoning model that spent
173
- // its whole budget on hidden tokens. Never let it pass as an empty review.
174
- if (content.trim() === "") {
175
- throw new Error(
176
- `empty content (finish_reason=${finishReason}, max_tokens=${seat.maxTokens ?? DEFAULT_MAX_TOKENS}); ` +
177
- `raise max_tokens if this is a reasoning model`,
178
- )
179
- }
180
-
181
- return { content, resolvedModel: json.model ?? null, finishReason }
233
+ const request = buildRequest(opts, seat, system, user, maxTokens, controller.signal)
234
+ const res = await doFetch(url, request)
235
+ await ensureOk(res)
236
+ return readContent((await res.json()) as ChatResponse, maxTokens)
182
237
  } finally {
183
238
  clearTimeout(timer)
239
+ unlink()
240
+ }
241
+ }
242
+
243
+ /** A seat that failed to answer. Its silence is never counted as agreement. */
244
+ function failedVerdict(seat: ExpertSeat, reason: unknown): ExpertVerdict {
245
+ return {
246
+ id: seat.id,
247
+ role: seat.role,
248
+ requestedModel: seat.model,
249
+ resolvedModel: null,
250
+ verdict: "ERROR",
251
+ confidence: 0,
252
+ counted: false,
253
+ report: "",
254
+ error: reason instanceof Error ? reason.message : String(reason),
255
+ }
256
+ }
257
+
258
+ /** A seat that answered: parse its verdict and keep the report as evidence. */
259
+ function answeredVerdict(
260
+ seat: ExpertSeat,
261
+ answer: { content: string; resolvedModel: string | null },
262
+ ): ExpertVerdict {
263
+ const { verdict, confidence } = parseVerdict(answer.content)
264
+ return {
265
+ id: seat.id,
266
+ role: seat.role,
267
+ requestedModel: seat.model,
268
+ resolvedModel: answer.resolvedModel,
269
+ verdict,
270
+ confidence,
271
+ counted: verdict !== "INVALID_NO_VERDICT",
272
+ report: answer.content,
184
273
  }
185
274
  }
186
275
 
@@ -203,30 +292,9 @@ export async function runRound(
203
292
 
204
293
  const verdicts: ExpertVerdict[] = settled.map((s, i) => {
205
294
  const seat = opts.seats[i]
206
- if (s.status !== "fulfilled") {
207
- return {
208
- id: seat.id,
209
- role: seat.role,
210
- requestedModel: seat.model,
211
- resolvedModel: null,
212
- verdict: "ERROR",
213
- confidence: 0,
214
- counted: false,
215
- report: "",
216
- error: s.reason instanceof Error ? s.reason.message : String(s.reason),
217
- }
218
- }
219
- const { verdict, confidence } = parseVerdict(s.value.content)
220
- return {
221
- id: seat.id,
222
- role: seat.role,
223
- requestedModel: seat.model,
224
- resolvedModel: s.value.resolvedModel,
225
- verdict,
226
- confidence,
227
- counted: verdict !== "INVALID_NO_VERDICT",
228
- report: s.value.content,
229
- }
295
+ return s.status === "fulfilled"
296
+ ? answeredVerdict(seat, s.value)
297
+ : failedVerdict(seat, s.reason)
230
298
  })
231
299
 
232
300
  const counted = verdicts.filter((v) => v.counted)
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @test REQ-DSH-012 delphi-review can produce Gate MW evidence
3
+ * @intent Verify the walkthrough evidence document satisfies every field the
4
+ * pre-push validator checks, including the two constraints that are easy to
5
+ * get wrong: result_type belongs to each expert record, and requested_model
6
+ * comes from the configured seat rather than from the gateway's response.
7
+ * @covers AC-DSH-012-01 AC-DSH-012-02 AC-DSH-012-03
8
+ *
9
+ * The stakes: Gate MW compares this document against HEAD at push time. A wrong
10
+ * result_type is rejected with "expert X must be a successful Delphi expert result"
11
+ * -- a message that says nothing about where the field belongs. Both mistakes were
12
+ * made once already while building this by hand.
13
+ */
14
+
15
+ import { describe, expect, it } from "vitest"
16
+ import { buildEvidence, evidenceChannel } from "./evidence.js"
17
+
18
+ /** A full-length SHA-1, which is what git rev-parse reports. */
19
+ const FULL_SHA_LENGTH = 40;
20
+
21
+ const HEAD = "a".repeat(FULL_SHA_LENGTH)
22
+ const BRANCH = "sprint/2026-10-27-01"
23
+ const NOW = new Date("2026-10-27T10:00:00.000Z")
24
+
25
+ const seats = [
26
+ { role: "architecture", model: "local-deepseek-v4-flash", resolved: "DeepSeek-V4-Flash" },
27
+ { role: "technical", model: "local-qwen3.8-flash", resolved: "Qwen3_8-flash-next" },
28
+ { role: "feasibility", model: "local-qwen3.8-27b", resolved: "Qwen3_8-27b-next" },
29
+ ]
30
+
31
+ describe("buildEvidence", () => {
32
+ it("carries result_type on each expert, not at the top level", () => {
33
+ const evidence = buildEvidence({ head: HEAD, branch: BRANCH, seats, verdict: "APPROVED", now: NOW })
34
+ // The validator reads expert.result_type. A top-level copy looks right and is not.
35
+ expect(evidence).not.toHaveProperty("result_type")
36
+ for (const expert of evidence.experts) {
37
+ expect(expert.result_type).toBe("delphi_expert_result")
38
+ }
39
+ })
40
+
41
+ it("takes requested_model from the configured seat, not the gateway response", () => {
42
+ const evidence = buildEvidence({ head: HEAD, branch: BRANCH, seats, verdict: "APPROVED", now: NOW })
43
+ const technical = evidence.experts.find((e) => e.role === "technical")
44
+ // These are genuinely different strings: the gateway reports its own naming.
45
+ expect(technical?.requested_model).toBe("local-qwen3.8-flash")
46
+ expect(technical?.resolved_model).toBe("Qwen3_8-flash-next")
47
+ })
48
+
49
+ it("stamps expires exactly one hour after timestamp", () => {
50
+ const evidence = buildEvidence({ head: HEAD, branch: BRANCH, seats, verdict: "APPROVED", now: NOW })
51
+ expect(evidence.timestamp).toBe("2026-10-27T10:00:00.000Z")
52
+ expect(evidence.expires).toBe("2026-10-27T11:00:00.000Z")
53
+ expect(Date.parse(evidence.expires) - Date.parse(evidence.timestamp)).toBe(3_600_000)
54
+ })
55
+
56
+ it("refuses to write evidence for a review that did not approve", () => {
57
+ // Writing APPROVED evidence from a non-approving review would defeat Gate MW
58
+ // entirely, so this is a throw rather than a field the caller can override.
59
+ expect(() =>
60
+ buildEvidence({ head: HEAD, branch: BRANCH, seats, verdict: "REQUEST_CHANGES", now: NOW }),
61
+ ).toThrow()
62
+ })
63
+
64
+ it("refuses evidence when a seat failed to resolve a model", () => {
65
+ const broken = seats.map((s, i) => (i === 1 ? { ...s, resolved: "" } : s))
66
+ expect(() => buildEvidence({ head: HEAD, branch: BRANCH, seats: broken, verdict: "APPROVED", now: NOW })).toThrow()
67
+ })
68
+
69
+ it("marks local gateways local and public ones external", () => {
70
+ expect(evidenceChannel("http://localhost:8080/v1")).toBe("local")
71
+ expect(evidenceChannel("http://127.0.0.1:8080/v1")).toBe("local")
72
+ expect(evidenceChannel("https://lab.iwhalecloud.com/gpt-proxy")).toBe("external")
73
+ })
74
+ })