@boyingliu01/xp-gate 0.19.3 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/gate-4.sh +83 -18
- package/adapters/gate-8.sh +115 -29
- package/adapters/iac.sh +1 -1
- package/adapters/powershell.sh +60 -5
- package/adapters/python.sh +38 -4
- package/adapters/typescript.sh +15 -60
- package/bin/xp-gate.js +15 -16
- package/build-integrity/__tests__/import-resolver.test.ts +59 -0
- package/build-integrity/gate-10.ts +17 -2
- package/gate-10.sh +35 -2
- package/gate-4.sh +110 -21
- package/gate-8.sh +115 -29
- package/hooks/gate-10.sh +35 -2
- package/hooks/gate-4.sh +110 -21
- package/hooks/gate-8.sh +115 -29
- package/hooks/lib/jscpd-run.sh +63 -0
- package/hooks/lib/sprint-gate-report.sh +58 -0
- package/hooks/lib/test-failure.sh +152 -0
- package/hooks/lib/typecheck.sh +141 -0
- package/hooks/lib/validate-code-walkthrough.cjs +43 -5
- package/hooks/post-merge +100 -0
- package/hooks/pre-commit +514 -184
- package/hooks/pre-push +75 -19
- package/lib/__tests__/bootstrap.test.js +23 -41
- package/lib/__tests__/check-version.test.js +8 -1
- package/lib/__tests__/detect-deps.test.js +16 -0
- package/lib/__tests__/doctor-drift-round2.test.js +212 -0
- package/lib/__tests__/doctor-executed-surface.test.js +209 -0
- package/lib/__tests__/doctor-hook-drift.test.js +329 -0
- package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
- package/lib/__tests__/doctor-module-drift.test.js +206 -0
- package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
- package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
- package/lib/__tests__/doctor.test.js +155 -0
- package/lib/__tests__/gate-runner.test.js +19 -3
- package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
- package/lib/__tests__/init.test.js +44 -17
- package/lib/__tests__/install-skill-full-dir.test.js +151 -0
- package/lib/__tests__/install-skill.test.js +51 -132
- package/lib/__tests__/language-tools-detect.test.js +158 -0
- package/lib/__tests__/phase-transition.test.js +15 -1
- package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
- package/lib/__tests__/shared-paths.test.js +20 -0
- package/lib/__tests__/shared-utils.test.js +69 -0
- package/lib/__tests__/skill-flags.test.js +48 -0
- package/lib/__tests__/ui-detector.test.ts +6 -2
- package/lib/__tests__/uninstall.test.js +20 -0
- package/lib/__tests__/update-hooks.test.js +12 -0
- package/lib/__tests__/update-skill.test.js +7 -4
- package/lib/detect-deps.js +3 -20
- package/lib/doctor-tui.js +22 -5
- package/lib/doctor.js +1178 -31
- package/lib/init.js +184 -33
- package/lib/install-cmd.js +7 -4
- package/lib/install-skill.js +38 -95
- package/lib/language-tools.js +83 -4
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-paths.js +5 -2
- package/lib/shared-utils.js +47 -3
- package/lib/uninstall.js +12 -0
- package/lib/update-hooks.js +4 -4
- package/lib/update-skill.js +6 -7
- package/mock-policy/AGENTS.md +4 -4
- package/mutation/AGENTS.md +4 -4
- package/mutation/__tests__/gate-m.test.ts +66 -14
- package/mutation/__tests__/mutmut-runner.test.ts +91 -42
- package/mutation/__tests__/test-filter-parity.test.ts +37 -0
- package/mutation/gate-m.ts +21 -8
- package/package.json +2 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
- package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/README.md +169 -4
- package/plugins/dsh/dsh-overlay.patch.yml +67 -0
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/scripts/prepack.cjs +24 -19
- package/plugins/dsh/setup-dsh.ps1 +160 -0
- package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
- package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/src/command.test.ts +66 -2
- package/plugins/dsh/src/command.ts +36 -14
- package/plugins/dsh/src/contract.test.ts +31 -1
- package/plugins/dsh/src/delphi-config.test.ts +190 -0
- package/plugins/dsh/src/delphi-config.ts +223 -0
- package/plugins/dsh/src/delphi-run.test.ts +181 -0
- package/plugins/dsh/src/delphi-run.ts +129 -0
- package/plugins/dsh/src/delphi.test.ts +222 -0
- package/plugins/dsh/src/delphi.ts +247 -0
- package/plugins/dsh/src/dialect.test.ts +59 -0
- package/plugins/dsh/src/dialect.ts +40 -0
- package/plugins/dsh/src/index.ts +70 -4
- package/plugins/dsh/src/skills.test.ts +26 -19
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
- package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/agents/delphi-architecture.md +1 -1
- package/plugins/qoder/agents/delphi-feasibility.md +1 -1
- package/plugins/qoder/agents/delphi-technical.md +1 -1
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
- package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +9 -8
- package/principles/__tests__/adapter-metrics.test.ts +292 -0
- package/principles/__tests__/analyzer.test.ts +29 -6
- package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
- package/principles/__tests__/baseline-prune.test.ts +338 -0
- package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
- package/principles/__tests__/boy-scout-integration.test.ts +97 -6
- package/principles/__tests__/boy-scout.test.ts +253 -7
- package/principles/__tests__/config-enforcement.test.ts +469 -0
- package/principles/__tests__/config-git-timeout.test.ts +66 -0
- package/principles/__tests__/direct-execution.test.ts +187 -0
- package/principles/__tests__/index.test.ts +16 -3
- package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
- package/principles/adapters/typescript.ts +613 -13
- package/principles/analyzer.ts +22 -0
- package/principles/baseline-prune.ts +217 -0
- package/principles/boy-scout.ts +516 -56
- package/principles/config.ts +362 -54
- package/principles/direct-execution.ts +60 -0
- package/principles/index.ts +40 -17
- package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
- package/principles/rules/clean-code/code-duplication.ts +8 -8
- package/principles/rules/clean-code/deep-nesting.ts +8 -8
- package/principles/rules/clean-code/god-class.ts +8 -8
- package/principles/rules/clean-code/large-file.ts +15 -11
- package/principles/rules/clean-code/long-function.ts +8 -8
- package/principles/rules/clean-code/magic-numbers.ts +10 -7
- package/principles/rules/clean-code/many-exports.ts +7 -7
- package/principles/rules/clean-code/missing-error-handling.ts +5 -5
- package/principles/rules/clean-code/too-many-params.ts +8 -8
- package/principles/rules/clean-code/unused-imports.ts +5 -5
- package/principles/rules/solid/dip.ts +18 -8
- package/principles/rules/solid/isp.ts +9 -8
- package/principles/rules/solid/lsp.ts +6 -6
- package/principles/rules/solid/ocp.ts +6 -5
- package/principles/rules/solid/srp.ts +7 -8
- package/principles/types.ts +15 -0
- package/scripts/__tests__/sync-package-content.test.js +28 -0
- package/scripts/delphi-external-review.cjs +193 -10
- package/scripts/sync-package-content.js +20 -10
- package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/delphi-review/SKILL.md +98 -14
- package/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/skills/delphi-review/references/requirements.md +6 -6
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +4 -4
- package/lib/__tests__/download-skill.test.js +0 -281
- package/lib/download-skill.js +0 -96
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { formatOutcome, runReview } from "./delphi-run.js"
|
|
3
|
+
import type { ExpertSeat } from "./delphi.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @test REQ-DSH-016
|
|
7
|
+
* @intent 验证多轮编排的收敛语义:首轮匿名、达阈值即停、未达阈值继续、
|
|
8
|
+
* 触及轮次上限则如实返回 NO CONSENSUS;并验证一份畸形报告
|
|
9
|
+
* 无法靠其余全票凑出共识(分母是全体席位)。
|
|
10
|
+
* @covers AC-DSH-016-03
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const seats: ExpertSeat[] = [
|
|
14
|
+
{ id: "A", role: "architecture", model: "m-a" },
|
|
15
|
+
{ id: "B", role: "technical", model: "m-b" },
|
|
16
|
+
{ id: "C", role: "feasibility", model: "m-c" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fetch stub that answers per (model, call index). `plan[n]` is the verdict map
|
|
21
|
+
* used on the n-th round, so a sequence of rounds can be scripted.
|
|
22
|
+
*/
|
|
23
|
+
function scriptedFetch(plan: Array<Record<string, string>>) {
|
|
24
|
+
let round = 0
|
|
25
|
+
let seen = 0
|
|
26
|
+
return (async (_url: string | URL | Request, init?: RequestInit) => {
|
|
27
|
+
const model = JSON.parse(String(init?.body)).model as string
|
|
28
|
+
// Three seats per round, collected in seat order.
|
|
29
|
+
const idx = round
|
|
30
|
+
const table = plan[Math.min(idx, plan.length - 1)]
|
|
31
|
+
seen += 1
|
|
32
|
+
if (seen % 3 === 0) round += 1
|
|
33
|
+
const body = table[model]
|
|
34
|
+
if (body === undefined) return new Response("nope", { status: 404 })
|
|
35
|
+
return new Response(
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
model: `resolved-${model}`,
|
|
38
|
+
choices: [{ finish_reason: "stop", message: { content: body } }],
|
|
39
|
+
}),
|
|
40
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
41
|
+
)
|
|
42
|
+
}) as unknown as typeof fetch
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const APPROVE = "### VERDICT\nAPPROVED\n### CONFIDENCE\n8"
|
|
46
|
+
const REJECT = "### VERDICT\nREQUEST_CHANGES\n### CONFIDENCE\n7"
|
|
47
|
+
|
|
48
|
+
describe("runReview", () => {
|
|
49
|
+
it("stops at round 1 when the whole panel approves", async () => {
|
|
50
|
+
const out = await runReview(
|
|
51
|
+
{
|
|
52
|
+
baseUrl: "https://x.test/gw",
|
|
53
|
+
apiKey: "k",
|
|
54
|
+
seats,
|
|
55
|
+
fetchImpl: scriptedFetch([{ "m-a": APPROVE, "m-b": APPROVE, "m-c": APPROVE }]),
|
|
56
|
+
},
|
|
57
|
+
"subject",
|
|
58
|
+
)
|
|
59
|
+
expect(out.consensus).toBe(true)
|
|
60
|
+
expect(out.totalRounds).toBe(1)
|
|
61
|
+
expect(out.finalRatio).toBe(1)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("iterates when the first round disagrees, then converges", async () => {
|
|
65
|
+
const out = await runReview(
|
|
66
|
+
{
|
|
67
|
+
baseUrl: "https://x.test/gw",
|
|
68
|
+
apiKey: "k",
|
|
69
|
+
seats,
|
|
70
|
+
fetchImpl: scriptedFetch([
|
|
71
|
+
{ "m-a": REJECT, "m-b": APPROVE, "m-c": REJECT },
|
|
72
|
+
{ "m-a": APPROVE, "m-b": APPROVE, "m-c": APPROVE },
|
|
73
|
+
]),
|
|
74
|
+
},
|
|
75
|
+
"subject",
|
|
76
|
+
)
|
|
77
|
+
expect(out.totalRounds).toBe(2)
|
|
78
|
+
expect(out.consensus).toBe(true)
|
|
79
|
+
// Round 1 must be recorded even though it failed.
|
|
80
|
+
expect(out.rounds[0].approvals).toBe(1)
|
|
81
|
+
expect(out.rounds[1].approvals).toBe(3)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("reports NO CONSENSUS after hitting the round cap", async () => {
|
|
85
|
+
const out = await runReview(
|
|
86
|
+
{
|
|
87
|
+
baseUrl: "https://x.test/gw",
|
|
88
|
+
apiKey: "k",
|
|
89
|
+
seats,
|
|
90
|
+
maxRounds: 3,
|
|
91
|
+
fetchImpl: scriptedFetch([{ "m-a": APPROVE, "m-b": REJECT, "m-c": REJECT }]),
|
|
92
|
+
},
|
|
93
|
+
"subject",
|
|
94
|
+
)
|
|
95
|
+
expect(out.consensus).toBe(false)
|
|
96
|
+
expect(out.totalRounds).toBe(3)
|
|
97
|
+
expect(out.finalRatio).toBeCloseTo(1 / 3, 5)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("never reaches consensus when a report lacks a verdict, even if the rest approve", async () => {
|
|
101
|
+
// 2/3 approvals with one malformed report. A naive implementation that
|
|
102
|
+
// divides by valid responses would call this 100%.
|
|
103
|
+
const out = await runReview(
|
|
104
|
+
{
|
|
105
|
+
baseUrl: "https://x.test/gw",
|
|
106
|
+
apiKey: "k",
|
|
107
|
+
seats,
|
|
108
|
+
maxRounds: 2,
|
|
109
|
+
fetchImpl: scriptedFetch([
|
|
110
|
+
{ "m-a": APPROVE, "m-b": APPROVE, "m-c": "I reviewed it but will not say." },
|
|
111
|
+
]),
|
|
112
|
+
},
|
|
113
|
+
"subject",
|
|
114
|
+
)
|
|
115
|
+
expect(out.consensus).toBe(false)
|
|
116
|
+
expect(out.finalRatio).toBeCloseTo(2 / 3, 5)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("uses the configured threshold rather than the default", async () => {
|
|
120
|
+
// 2/3 = 67%. Reachable at a 60% threshold, not at 90%.
|
|
121
|
+
const mk = () =>
|
|
122
|
+
scriptedFetch([{ "m-a": APPROVE, "m-b": APPROVE, "m-c": REJECT }])
|
|
123
|
+
const lax = await runReview(
|
|
124
|
+
{ baseUrl: "u", apiKey: "k", seats, thresholdPercent: 60, fetchImpl: mk() },
|
|
125
|
+
"s",
|
|
126
|
+
)
|
|
127
|
+
expect(lax.consensus).toBe(true)
|
|
128
|
+
|
|
129
|
+
const strict = await runReview(
|
|
130
|
+
{ baseUrl: "u", apiKey: "k", seats, thresholdPercent: 90, fetchImpl: mk() },
|
|
131
|
+
"s",
|
|
132
|
+
)
|
|
133
|
+
expect(strict.consensus).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("defaults to at most 5 rounds", async () => {
|
|
137
|
+
const out = await runReview(
|
|
138
|
+
{
|
|
139
|
+
baseUrl: "u",
|
|
140
|
+
apiKey: "k",
|
|
141
|
+
seats,
|
|
142
|
+
fetchImpl: scriptedFetch([{ "m-a": REJECT, "m-b": REJECT, "m-c": REJECT }]),
|
|
143
|
+
},
|
|
144
|
+
"s",
|
|
145
|
+
)
|
|
146
|
+
expect(out.totalRounds).toBe(5)
|
|
147
|
+
expect(out.consensus).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe("formatOutcome", () => {
|
|
152
|
+
it("names each seat, its resolved model, and the final ratio", async () => {
|
|
153
|
+
const out = await runReview(
|
|
154
|
+
{
|
|
155
|
+
baseUrl: "u",
|
|
156
|
+
apiKey: "k",
|
|
157
|
+
seats,
|
|
158
|
+
fetchImpl: scriptedFetch([{ "m-a": APPROVE, "m-b": APPROVE, "m-c": APPROVE }]),
|
|
159
|
+
},
|
|
160
|
+
"s",
|
|
161
|
+
)
|
|
162
|
+
const text = formatOutcome(out)
|
|
163
|
+
expect(text).toContain("resolved-m-a")
|
|
164
|
+
expect(text).toContain("architecture")
|
|
165
|
+
expect(text).toMatch(/CONSENSUS REACHED/)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("says NO CONSENSUS explicitly when the cap is reached", async () => {
|
|
169
|
+
const out = await runReview(
|
|
170
|
+
{
|
|
171
|
+
baseUrl: "u",
|
|
172
|
+
apiKey: "k",
|
|
173
|
+
seats,
|
|
174
|
+
maxRounds: 1,
|
|
175
|
+
fetchImpl: scriptedFetch([{ "m-a": REJECT, "m-b": REJECT, "m-c": REJECT }]),
|
|
176
|
+
},
|
|
177
|
+
"s",
|
|
178
|
+
)
|
|
179
|
+
expect(formatOutcome(out)).toMatch(/NO CONSENSUS/)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delphi review orchestration: iterate rounds until consensus or the cap.
|
|
3
|
+
*
|
|
4
|
+
* @test REQ-DSH-016
|
|
5
|
+
* @intent 编排多轮交叉评审:首轮匿名独立评审,后续轮次反馈上一轮汇总,
|
|
6
|
+
* 直到达到共识阈值(默认 90%)或触及轮次上限(默认 5),
|
|
7
|
+
* 并把结果整理成可写成 .code-walkthrough-result.json 的证据。
|
|
8
|
+
* @covers AC-DSH-016-03
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { DelphiOptions, ExpertSeat, RoundResult } from "./delphi.js"
|
|
12
|
+
import { runRound } from "./delphi.js"
|
|
13
|
+
|
|
14
|
+
/** Mirrors delphi.ts; declared here so orchestrators need not import internals. */
|
|
15
|
+
const DEFAULT_THRESHOLD_PERCENT = 90
|
|
16
|
+
const DEFAULT_MAX_ROUNDS = 5
|
|
17
|
+
|
|
18
|
+
export interface ReviewOutcome {
|
|
19
|
+
readonly rounds: readonly RoundResult[]
|
|
20
|
+
readonly consensus: boolean
|
|
21
|
+
readonly finalRatio: number
|
|
22
|
+
readonly totalRounds: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Anonymous first round: each seat answers the same question with no peers. */
|
|
26
|
+
function roundOnePrompt(seat: ExpertSeat, subject: string): string {
|
|
27
|
+
return (
|
|
28
|
+
`You are the ${seat.role.toUpperCase()} expert. Review the material below INDEPENDENTLY. ` +
|
|
29
|
+
`Do not assume other reviewers exist or agree with you.\n\n${subject}\n\n` +
|
|
30
|
+
`Respond with <=300 words under the headings: "### Findings" (a markdown table of ` +
|
|
31
|
+
`ID | Severity | Description | Location | Fix, or a single NONE row), then "### VERDICT" ` +
|
|
32
|
+
`followed by exactly APPROVED or REQUEST_CHANGES, then "### CONFIDENCE" followed by an ` +
|
|
33
|
+
`integer 1-10. Approve only if you would personally ship this.`
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Later rounds: feed back the aggregate so seats can revise their position.
|
|
39
|
+
* Individual reports stay anonymous -- only the tally and the addressed items
|
|
40
|
+
* are shared, which is what keeps the method honest.
|
|
41
|
+
*/
|
|
42
|
+
function laterRoundPrompt(
|
|
43
|
+
seat: ExpertSeat,
|
|
44
|
+
subject: string,
|
|
45
|
+
previous: RoundResult,
|
|
46
|
+
addressed: string,
|
|
47
|
+
): string {
|
|
48
|
+
const tally = previous.verdicts
|
|
49
|
+
.map((v) => `- ${v.role}: ${v.verdict}`)
|
|
50
|
+
.join("\n")
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
`You are the ${seat.role.toUpperCase()} expert. This is ROUND ${previous.round + 1} of a ` +
|
|
54
|
+
`Delphi consensus review. Individual peer reports are anonymous; only this aggregate is shared:\n\n` +
|
|
55
|
+
`${tally}\nApprovals: ${previous.approvals}/${previous.validCount} ` +
|
|
56
|
+
`(threshold ${DEFAULT_THRESHOLD_PERCENT}%).\n\n` +
|
|
57
|
+
`${addressed ? `What changed since the last round:\n${addressed}\n\n` : ""}` +
|
|
58
|
+
`Re-examine the material and revise or hold your position.\n\n${subject}\n\n` +
|
|
59
|
+
`Respond with <=300 words: "### Round assessment" (2-3 sentences), "### Remaining blocking ` +
|
|
60
|
+
`issues" (table, or a single NONE row), "### VERDICT" followed by exactly APPROVED or ` +
|
|
61
|
+
`REQUEST_CHANGES, then "### CONFIDENCE" and an integer 1-10.`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run a full review. Round 1 is anonymous; each later round sees the aggregate
|
|
67
|
+
* plus whatever the caller says was addressed, then re-votes.
|
|
68
|
+
*/
|
|
69
|
+
export async function runReview(
|
|
70
|
+
opts: DelphiOptions,
|
|
71
|
+
subject: string,
|
|
72
|
+
addressed = "",
|
|
73
|
+
): Promise<ReviewOutcome> {
|
|
74
|
+
const threshold = opts.thresholdPercent ?? DEFAULT_THRESHOLD_PERCENT
|
|
75
|
+
const maxRounds = opts.maxRounds ?? DEFAULT_MAX_ROUNDS
|
|
76
|
+
const rounds: RoundResult[] = []
|
|
77
|
+
|
|
78
|
+
for (let n = 1; n <= maxRounds; n++) {
|
|
79
|
+
const previous = rounds[rounds.length - 1]
|
|
80
|
+
const result = previous
|
|
81
|
+
? await runRound(opts, n, "You are a rigorous senior code reviewer.", (seat) =>
|
|
82
|
+
laterRoundPrompt(seat, subject, previous, addressed),
|
|
83
|
+
)
|
|
84
|
+
: await runRound(opts, 1, "You are a rigorous senior code reviewer.", (seat) =>
|
|
85
|
+
roundOnePrompt(seat, subject),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
rounds.push(result)
|
|
89
|
+
|
|
90
|
+
// Threshold is a percentage of the whole panel, so a round with a malformed
|
|
91
|
+
// report cannot reach consensus even if every valid seat approved.
|
|
92
|
+
if (result.approvals / opts.seats.length * 100 >= threshold) {
|
|
93
|
+
return {
|
|
94
|
+
rounds,
|
|
95
|
+
consensus: true,
|
|
96
|
+
finalRatio: result.approvals / opts.seats.length,
|
|
97
|
+
totalRounds: n,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const last = rounds[rounds.length - 1]
|
|
103
|
+
return {
|
|
104
|
+
rounds,
|
|
105
|
+
consensus: false,
|
|
106
|
+
finalRatio: last ? last.approvals / opts.seats.length : 0,
|
|
107
|
+
totalRounds: rounds.length,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Human-readable transcript for the tool result. */
|
|
112
|
+
export function formatOutcome(outcome: ReviewOutcome): string {
|
|
113
|
+
const lines: string[] = []
|
|
114
|
+
for (const r of outcome.rounds) {
|
|
115
|
+
lines.push(`## Round ${r.round} — approvals ${r.approvals}/${r.validCount} valid`)
|
|
116
|
+
for (const v of r.verdicts) {
|
|
117
|
+
const model = v.resolvedModel ? `${v.requestedModel} -> ${v.resolvedModel}` : v.requestedModel
|
|
118
|
+
const note = v.error ? ` (${v.error})` : ""
|
|
119
|
+
lines.push(`- [${v.role}] ${v.id} (${model}): ${v.verdict} confidence=${v.confidence}${note}`)
|
|
120
|
+
}
|
|
121
|
+
lines.push("")
|
|
122
|
+
}
|
|
123
|
+
lines.push(
|
|
124
|
+
outcome.consensus
|
|
125
|
+
? `CONSENSUS REACHED: ${(outcome.finalRatio * 100).toFixed(0)}% after ${outcome.totalRounds} round(s).`
|
|
126
|
+
: `NO CONSENSUS: ${(outcome.finalRatio * 100).toFixed(0)}% after ${outcome.totalRounds} round(s) (cap reached).`,
|
|
127
|
+
)
|
|
128
|
+
return lines.join("\n")
|
|
129
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { DelphiConfigError, parseVerdict, runRound, validateSeats } from "./delphi.js"
|
|
3
|
+
import type { ExpertSeat } from "./delphi.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @test REQ-DSH-016
|
|
7
|
+
* @intent 验证多模型交叉评审的核心不变量:恰好 3 位专家、requested_model 两两不同、
|
|
8
|
+
* 缺少模型或未展开的 ${ENV} 占位符必须在校验期而非网络期报错;
|
|
9
|
+
* 并验证裁决解析与共识计算不会被畸形报告污染。
|
|
10
|
+
* @covers AC-DSH-016-01
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const seats: ExpertSeat[] = [
|
|
14
|
+
{ id: "A", role: "architecture", model: "g-glm-5.3-flash", maxTokens: 32000 },
|
|
15
|
+
{ id: "B", role: "technical", model: "g-qwen3.8-flash" },
|
|
16
|
+
{ id: "C", role: "feasibility", model: "g-deepseek-flash" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
describe("validateSeats", () => {
|
|
20
|
+
it("accepts exactly three seats with distinct models", () => {
|
|
21
|
+
expect(() => validateSeats(seats)).not.toThrow()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("rejects a panel that is not exactly three", () => {
|
|
25
|
+
expect(() => validateSeats(seats.slice(0, 2))).toThrow(DelphiConfigError)
|
|
26
|
+
expect(() => validateSeats([...seats, { id: "D", role: "x", model: "m4" }])).toThrow(
|
|
27
|
+
/exactly 3/,
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("rejects duplicate models, since Delphi rests on reviewers differing", () => {
|
|
32
|
+
const dup = [seats[0], seats[1], { ...seats[2], model: "g-qwen3.8-flash" }]
|
|
33
|
+
expect(() => validateSeats(dup)).toThrow(/distinct/)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("treats models differing only by whitespace as duplicates", () => {
|
|
37
|
+
const padded = [seats[0], seats[1], { ...seats[2], model: " g-qwen3.8-flash " }]
|
|
38
|
+
expect(() => validateSeats(padded)).toThrow(/distinct/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("rejects an empty model (a local fallback cannot count)", () => {
|
|
42
|
+
expect(() => validateSeats([seats[0], seats[1], { ...seats[2], model: " " }])).toThrow(
|
|
43
|
+
/fallback|no model/i,
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("rejects an unexpanded env placeholder before any network call", () => {
|
|
48
|
+
expect(() => validateSeats([seats[0], seats[1], { ...seats[2], model: "${MODEL_ID}" }])).toThrow(
|
|
49
|
+
/unexpanded/,
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("parseVerdict", () => {
|
|
55
|
+
it("reads the verdict on the line after the heading", () => {
|
|
56
|
+
// The real reports put the token on the next line; a same-line
|
|
57
|
+
// `VERDICT: X` pattern misses every one of them.
|
|
58
|
+
expect(parseVerdict("### VERDICT\nAPPROVED\n\n### CONFIDENCE\n8").verdict).toBe("APPROVED")
|
|
59
|
+
expect(parseVerdict("### VERDICT\nREQUEST_CHANGES\n### CONFIDENCE\n7").verdict).toBe(
|
|
60
|
+
"REQUEST_CHANGES",
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("also accepts a same-line form", () => {
|
|
65
|
+
expect(parseVerdict("VERDICT: APPROVED\nCONFIDENCE: 9").verdict).toBe("APPROVED")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("maps PASS_WITH_CAVEATS and BLOCKED to non-approval", () => {
|
|
69
|
+
// Neither reaches the 90% bar as an approval.
|
|
70
|
+
expect(parseVerdict("### VERDICT\nPASS_WITH_CAVEATS\nCONFIDENCE 6").verdict).toBe(
|
|
71
|
+
"REQUEST_CHANGES",
|
|
72
|
+
)
|
|
73
|
+
expect(parseVerdict("### VERDICT\nBLOCKED\nCONFIDENCE 2").verdict).toBe("REQUEST_CHANGES")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("marks a report with no verdict as INVALID rather than guessing", () => {
|
|
77
|
+
// Observed in practice: a reviewer analysed everything but omitted the
|
|
78
|
+
// verdict. Inferring approval from prose would pass a gate on a guess.
|
|
79
|
+
const proseOnly = "The fixes look correct. Non-blocking concerns only. No critical defects."
|
|
80
|
+
expect(parseVerdict(proseOnly).verdict).toBe("INVALID_NO_VERDICT")
|
|
81
|
+
expect(parseVerdict(proseOnly).confidence).toBe(0)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("clamps confidence into 1-10 and defaults to 0 when absent", () => {
|
|
85
|
+
expect(parseVerdict("VERDICT APPROVED CONFIDENCE 99").confidence).toBe(10)
|
|
86
|
+
expect(parseVerdict("VERDICT APPROVED").confidence).toBe(0)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe("runRound", () => {
|
|
91
|
+
function fakeFetch(bodies: Record<string, string>, status = 200) {
|
|
92
|
+
return (async (_url: string | URL | Request, init?: RequestInit) => {
|
|
93
|
+
const model = JSON.parse(String(init?.body)).model as string
|
|
94
|
+
if (!(model in bodies)) {
|
|
95
|
+
return new Response("no such model", { status: 404 })
|
|
96
|
+
}
|
|
97
|
+
return new Response(
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
model: `resolved-${model}`,
|
|
100
|
+
choices: [{ finish_reason: "stop", message: { content: bodies[model] } }],
|
|
101
|
+
}),
|
|
102
|
+
{ status, headers: { "Content-Type": "application/json" } },
|
|
103
|
+
)
|
|
104
|
+
}) as unknown as typeof fetch
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const opts = {
|
|
108
|
+
baseUrl: "https://example.test/gw",
|
|
109
|
+
apiKey: "k",
|
|
110
|
+
seats,
|
|
111
|
+
fetchImpl: fakeFetch({
|
|
112
|
+
"g-glm-5.3-flash": "### VERDICT\nAPPROVED\n### CONFIDENCE\n8",
|
|
113
|
+
"g-qwen3.8-flash": "### VERDICT\nAPPROVED\n### CONFIDENCE\n9",
|
|
114
|
+
"g-deepseek-flash": "### VERDICT\nREQUEST_CHANGES\n### CONFIDENCE\n7",
|
|
115
|
+
}),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it("runs every seat and records the resolved model per seat", async () => {
|
|
119
|
+
const r = await runRound(opts, 1, "sys", () => "task")
|
|
120
|
+
expect(r.verdicts).toHaveLength(3)
|
|
121
|
+
expect(r.verdicts.map((v) => v.requestedModel)).toEqual([
|
|
122
|
+
"g-glm-5.3-flash",
|
|
123
|
+
"g-qwen3.8-flash",
|
|
124
|
+
"g-deepseek-flash",
|
|
125
|
+
])
|
|
126
|
+
expect(r.verdicts[0].resolvedModel).toBe("resolved-g-glm-5.3-flash")
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("computes the ratio over valid responses and does not call 2/3 consensus", async () => {
|
|
130
|
+
const r = await runRound(opts, 1, "sys", () => "task")
|
|
131
|
+
expect(r.approvals).toBe(2)
|
|
132
|
+
expect(r.validCount).toBe(3)
|
|
133
|
+
expect(r.consensusRatio).toBeCloseTo(2 / 3, 5)
|
|
134
|
+
// 67% < 90%
|
|
135
|
+
expect(r.reachedConsensus).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("cannot reach consensus when a report is malformed, even if all valid seats approve", async () => {
|
|
139
|
+
const r = await runRound(
|
|
140
|
+
{
|
|
141
|
+
...opts,
|
|
142
|
+
fetchImpl: fakeFetch({
|
|
143
|
+
"g-glm-5.3-flash": "### VERDICT\nAPPROVED\nCONFIDENCE 8",
|
|
144
|
+
"g-qwen3.8-flash": "### VERDICT\nAPPROVED\nCONFIDENCE 8",
|
|
145
|
+
// No verdict section at all -- must not count as agreement.
|
|
146
|
+
"g-deepseek-flash": "Everything looks fine to me.",
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
2,
|
|
150
|
+
"sys",
|
|
151
|
+
() => "task",
|
|
152
|
+
)
|
|
153
|
+
expect(r.verdicts[2].verdict).toBe("INVALID_NO_VERDICT")
|
|
154
|
+
expect(r.verdicts[2].counted).toBe(false)
|
|
155
|
+
expect(r.validCount).toBe(2)
|
|
156
|
+
expect(r.reachedConsensus).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("records a transport failure as ERROR without aborting the round", async () => {
|
|
160
|
+
const r = await runRound(
|
|
161
|
+
{
|
|
162
|
+
...opts,
|
|
163
|
+
fetchImpl: fakeFetch({
|
|
164
|
+
"g-glm-5.3-flash": "### VERDICT\nAPPROVED\nCONFIDENCE 8",
|
|
165
|
+
"g-qwen3.8-flash": "### VERDICT\nAPPROVED\nCONFIDENCE 8",
|
|
166
|
+
// g-deepseek-flash is absent from the map, so it 404s.
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
1,
|
|
170
|
+
"sys",
|
|
171
|
+
() => "task",
|
|
172
|
+
)
|
|
173
|
+
const errored = r.verdicts.filter((v) => v.verdict === "ERROR")
|
|
174
|
+
expect(errored).toHaveLength(1)
|
|
175
|
+
expect(errored[0].counted).toBe(false)
|
|
176
|
+
expect(errored[0].error).toMatch(/404/)
|
|
177
|
+
// The two healthy seats still produced counts; the failure is isolated.
|
|
178
|
+
expect(r.validCount).toBe(2)
|
|
179
|
+
// A dead seat means the panel is incomplete, so no consensus.
|
|
180
|
+
expect(r.reachedConsensus).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("treats empty content as an error rather than an empty review", async () => {
|
|
184
|
+
// Reasoning models can spend their entire budget on hidden tokens and
|
|
185
|
+
// return nothing; that must surface as a failure, not a blank approval.
|
|
186
|
+
const r = await runRound(
|
|
187
|
+
{
|
|
188
|
+
...opts,
|
|
189
|
+
fetchImpl: fakeFetch({
|
|
190
|
+
"g-glm-5.3-flash": "",
|
|
191
|
+
"g-qwen3.8-flash": "### VERDICT\nAPPROVED\nCONFIDENCE 8",
|
|
192
|
+
"g-deepseek-flash": "### VERDICT\nAPPROVED\nCONFIDENCE 8",
|
|
193
|
+
}),
|
|
194
|
+
},
|
|
195
|
+
1,
|
|
196
|
+
"sys",
|
|
197
|
+
() => "task",
|
|
198
|
+
)
|
|
199
|
+
const blank = r.verdicts.find((v) => v.requestedModel === "g-glm-5.3-flash")
|
|
200
|
+
expect(blank?.verdict).toBe("ERROR")
|
|
201
|
+
expect(blank?.error).toMatch(/empty content/)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("reaches consensus when all three approve", async () => {
|
|
205
|
+
const r = await runRound(
|
|
206
|
+
{
|
|
207
|
+
...opts,
|
|
208
|
+
fetchImpl: fakeFetch({
|
|
209
|
+
"g-glm-5.3-flash": "### VERDICT\nAPPROVED\nCONFIDENCE 8",
|
|
210
|
+
"g-qwen3.8-flash": "### VERDICT\nAPPROVED\nCONFIDENCE 9",
|
|
211
|
+
"g-deepseek-flash": "### VERDICT\nAPPROVED\nCONFIDENCE 8",
|
|
212
|
+
}),
|
|
213
|
+
},
|
|
214
|
+
3,
|
|
215
|
+
"sys",
|
|
216
|
+
() => "task",
|
|
217
|
+
)
|
|
218
|
+
expect(r.approvals).toBe(3)
|
|
219
|
+
expect(r.consensusRatio).toBe(1)
|
|
220
|
+
expect(r.reachedConsensus).toBe(true)
|
|
221
|
+
})
|
|
222
|
+
})
|