@boyingliu01/xp-gate 0.19.2 → 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 (223) 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 +401 -155
  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 +2 -2
  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 +2 -2
  116. package/plugins/qoder/skills/admin-template-guidelines/SKILL.md +6 -797
  117. package/plugins/qoder/skills/admin-template-guidelines/references/rule-1.md +91 -0
  118. package/plugins/qoder/skills/admin-template-guidelines/references/rule-2.md +155 -0
  119. package/plugins/qoder/skills/admin-template-guidelines/references/rule-3.md +70 -0
  120. package/plugins/qoder/skills/admin-template-guidelines/references/rule-4.md +154 -0
  121. package/plugins/qoder/skills/admin-template-guidelines/references/rule-5.md +126 -0
  122. package/plugins/qoder/skills/admin-template-guidelines/references/rule-6.md +180 -0
  123. package/plugins/qoder/skills/batch-grill-me/SKILL.md +15 -0
  124. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  125. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.ps1 +115 -0
  126. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.sh +161 -0
  127. package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +61 -28
  128. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  129. package/plugins/qoder/skills/delphi-review/INSTALL.md +97 -98
  130. package/plugins/qoder/skills/delphi-review/SKILL.md +170 -63
  131. package/plugins/qoder/skills/delphi-review/hooks.md +19 -0
  132. package/plugins/qoder/skills/delphi-review/opencode.json.delphi.example +6 -10
  133. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  134. package/plugins/qoder/skills/delphi-review/references/orchestrator-dispatch.md +64 -0
  135. package/plugins/qoder/skills/delphi-review/references/requirements.md +402 -0
  136. package/plugins/qoder/skills/delphi-review/references/round-templates.md +134 -0
  137. package/plugins/qoder/skills/delphi-review/scripts/verify-consensus.sh +43 -0
  138. package/plugins/qoder/skills/delphi-review/tools.md +29 -0
  139. package/plugins/qoder/skills/domain-modeling/ADR-FORMAT.md +64 -0
  140. package/plugins/qoder/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  141. package/plugins/qoder/skills/domain-modeling/SKILL.md +74 -0
  142. package/plugins/qoder/skills/grill-with-docs/SKILL.md +7 -0
  143. package/plugins/qoder/skills/grilling/SKILL.md +12 -0
  144. package/plugins/qoder/skills/ralph-loop/SKILL.md +87 -428
  145. package/plugins/qoder/skills/ralph-loop/references/components.md +113 -0
  146. package/plugins/qoder/skills/ralph-loop/references/workflow-details.md +99 -0
  147. package/plugins/qoder/skills/sprint-flow/AGENTS.md +5 -5
  148. package/plugins/qoder/skills/sprint-flow/SKILL.md +255 -454
  149. package/plugins/qoder/skills/sprint-flow/__tests__/sprint-flow.test.cjs +457 -0
  150. package/plugins/qoder/skills/sprint-flow/hooks.md +18 -0
  151. package/plugins/qoder/skills/sprint-flow/references/components/memory.md +1 -1
  152. package/plugins/qoder/skills/sprint-flow/references/components/middleware.md +1 -1
  153. package/plugins/qoder/skills/sprint-flow/references/components/skill-invocations.md +21 -20
  154. package/plugins/qoder/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
  155. package/plugins/qoder/skills/sprint-flow/references/force-levels.md +0 -1
  156. package/plugins/qoder/skills/sprint-flow/references/orchestration-rules.md +34 -27
  157. package/plugins/qoder/skills/sprint-flow/references/phase-1-prep.md +1 -1
  158. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +160 -123
  159. package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +203 -2
  160. package/plugins/qoder/skills/sprint-flow/references/phase-4-verify.md +107 -34
  161. package/plugins/qoder/skills/sprint-flow/references/phase-5-ship.md +194 -12
  162. package/plugins/qoder/skills/sprint-flow/references/phase-6-close.md +139 -7
  163. package/plugins/qoder/skills/sprint-flow/references/phase-overview.md +486 -0
  164. package/plugins/qoder/skills/sprint-flow/scripts/verify-sprint-state.sh +38 -0
  165. package/plugins/qoder/skills/sprint-flow/tools.md +29 -0
  166. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  167. package/plugins/qoder/skills/test-specification-alignment/SKILL.md +104 -601
  168. package/plugins/qoder/skills/to-issues/SKILL.md +1 -1
  169. package/principles/AGENTS.md +9 -8
  170. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  171. package/principles/__tests__/analyzer.test.ts +29 -6
  172. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  173. package/principles/__tests__/baseline-prune.test.ts +338 -0
  174. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  175. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  176. package/principles/__tests__/boy-scout.test.ts +253 -7
  177. package/principles/__tests__/config-enforcement.test.ts +469 -0
  178. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  179. package/principles/__tests__/direct-execution.test.ts +187 -0
  180. package/principles/__tests__/index.test.ts +16 -3
  181. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  182. package/principles/adapters/typescript.ts +613 -13
  183. package/principles/analyzer.ts +22 -0
  184. package/principles/baseline-prune.ts +217 -0
  185. package/principles/boy-scout.ts +481 -51
  186. package/principles/config.ts +362 -54
  187. package/principles/direct-execution.ts +60 -0
  188. package/principles/index.ts +40 -17
  189. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  190. package/principles/rules/clean-code/code-duplication.ts +8 -8
  191. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  192. package/principles/rules/clean-code/god-class.ts +8 -8
  193. package/principles/rules/clean-code/large-file.ts +15 -11
  194. package/principles/rules/clean-code/long-function.ts +8 -8
  195. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  196. package/principles/rules/clean-code/many-exports.ts +7 -7
  197. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  198. package/principles/rules/clean-code/too-many-params.ts +8 -8
  199. package/principles/rules/clean-code/unused-imports.ts +5 -5
  200. package/principles/rules/solid/dip.ts +18 -8
  201. package/principles/rules/solid/isp.ts +9 -8
  202. package/principles/rules/solid/lsp.ts +6 -6
  203. package/principles/rules/solid/ocp.ts +6 -5
  204. package/principles/rules/solid/srp.ts +7 -8
  205. package/principles/types.ts +15 -0
  206. package/scripts/__tests__/sync-package-content.test.js +28 -0
  207. package/scripts/delphi-external-review.cjs +193 -10
  208. package/scripts/sync-package-content.js +7 -4
  209. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  210. package/skills/delphi-review/AGENTS.md +4 -4
  211. package/skills/delphi-review/SKILL.md +98 -14
  212. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  213. package/skills/delphi-review/references/requirements.md +6 -6
  214. package/skills/sprint-flow/AGENTS.md +4 -4
  215. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  216. package/skills/test-specification-alignment/AGENTS.md +4 -4
  217. package/lib/__tests__/download-skill.test.js +0 -281
  218. package/lib/download-skill.js +0 -96
  219. package/plugins/qoder/skills/ralph-loop/references/components/memory.md +0 -55
  220. package/plugins/qoder/skills/ralph-loop/references/components/middleware.md +0 -54
  221. package/plugins/qoder/skills/ralph-loop/references/components/skill-invocations.md +0 -39
  222. package/plugins/qoder/skills/ralph-loop/references/components/system-prompt.md +0 -24
  223. package/plugins/qoder/skills/ralph-loop/references/components/tool-descriptions.md +0 -32
@@ -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
+ })