@boyingliu01/xp-gate 0.27.1 → 0.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapter-common.sh +0 -0
- package/adapters/cpp.sh +0 -0
- package/adapters/dart.sh +0 -0
- package/adapters/flutter.sh +0 -0
- package/adapters/go.sh +0 -0
- package/adapters/iac.sh +0 -0
- package/adapters/java.sh +0 -0
- package/adapters/kotlin.sh +0 -0
- package/adapters/objectivec.sh +0 -0
- package/adapters/plugins/book299-20132-python/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-20132-python/scripts/install-maven.sh +0 -0
- package/adapters/plugins/book299-4081-c/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-4081-c/scripts/install-maven.sh +0 -0
- package/adapters/plugins/book299-4083-javascriptes5/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-4083-javascriptes5/scripts/install-maven.sh +0 -0
- package/adapters/powershell.sh +0 -0
- package/adapters/python.sh +0 -0
- package/adapters/shell.sh +0 -0
- package/adapters/swift.sh +0 -0
- package/adapters/typescript.sh +0 -0
- package/bin/xp-gate.js +0 -0
- package/gate-10.sh +0 -0
- package/gate-3.sh +0 -0
- package/gate-4.sh +0 -0
- package/gate-7.sh +0 -0
- package/gate-8.sh +0 -0
- package/gate-9.sh +0 -0
- package/hooks/adapter-common.sh +0 -0
- package/hooks/gate-10.sh +0 -0
- package/hooks/gate-3.sh +0 -0
- package/hooks/gate-4.sh +0 -0
- package/hooks/gate-7.sh +0 -0
- package/hooks/gate-8.sh +0 -0
- package/hooks/gate-9.sh +0 -0
- package/hooks/post-merge +0 -0
- package/hooks/pre-commit +0 -0
- package/hooks/pre-push +0 -0
- package/hooks/sprint-gate.sh +0 -0
- package/lib/__tests__/doctor-delphi.test.js +244 -244
- package/lib/__tests__/sprint-audit.test.js +207 -207
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/sprint-audit.js +235 -235
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/bin/delphi-review-guard.sh +0 -0
- package/plugins/claude-code/bin/xp-gate-check +0 -0
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/lib/command.d.ts +32 -0
- package/plugins/dsh/lib/command.d.ts.map +1 -0
- package/plugins/dsh/lib/command.js +81 -0
- package/plugins/dsh/lib/delphi-config.d.ts +75 -0
- package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-config.js +155 -0
- package/plugins/dsh/lib/delphi-run.d.ts +24 -0
- package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-run.js +92 -0
- package/plugins/dsh/lib/delphi.d.ts +88 -0
- package/plugins/dsh/lib/delphi.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi.js +161 -0
- package/plugins/dsh/lib/dialect.d.ts +24 -0
- package/plugins/dsh/lib/dialect.d.ts.map +1 -0
- package/plugins/dsh/lib/dialect.js +36 -0
- package/plugins/dsh/lib/gate-runner.d.ts +18 -0
- package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
- package/plugins/dsh/lib/gate-runner.js +46 -0
- package/plugins/dsh/lib/index.d.ts +5 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -0
- package/plugins/dsh/lib/index.js +162 -0
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/bin/sprint-flow-guard.sh +0 -0
- package/plugins/qoder/bin/xp-gate-check +0 -0
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
- package/sprint-gate.sh +0 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from "node:path";
|
|
2
|
+
import { shellQuote } from "./dialect.js";
|
|
3
|
+
// Re-exported so existing importers of this module keep working; the dialect
|
|
4
|
+
// primitives themselves live in ./dialect.ts.
|
|
5
|
+
export { detectDialect, shellQuote, shq } from "./dialect.js";
|
|
6
|
+
/** Shown when the global `xp-gate` CLI is not on PATH. */
|
|
7
|
+
export const FALLBACK_MESSAGE = "[XP-Gate] xp-gate CLI not installed. Install it with: npm install -g @boyingliu01/xp-gate";
|
|
8
|
+
/**
|
|
9
|
+
* Allowlist of xp-gate gate aliases that are safe to invoke standalone
|
|
10
|
+
* (non-preCommitOnly gates + common aliases). Anything outside this set is
|
|
11
|
+
* rejected before reaching the shell.
|
|
12
|
+
*/
|
|
13
|
+
export const GATE_WHITELIST = [
|
|
14
|
+
"duplicates",
|
|
15
|
+
"complexity",
|
|
16
|
+
"principles",
|
|
17
|
+
"arch",
|
|
18
|
+
"architecture",
|
|
19
|
+
"iac",
|
|
20
|
+
"secrets",
|
|
21
|
+
"sast",
|
|
22
|
+
];
|
|
23
|
+
export function isGateAllowed(gate) {
|
|
24
|
+
return GATE_WHITELIST.includes(gate);
|
|
25
|
+
}
|
|
26
|
+
function buildInner(options, dialect) {
|
|
27
|
+
const { subcommand, target, gates, config } = options;
|
|
28
|
+
const q = (value) => shellQuote(value, dialect);
|
|
29
|
+
if (subcommand === "check") {
|
|
30
|
+
const tokens = ["xp-gate", "check"];
|
|
31
|
+
if (target !== undefined)
|
|
32
|
+
tokens.push(q(target));
|
|
33
|
+
if (gates !== undefined && gates.length > 0) {
|
|
34
|
+
// Defense-in-depth: the JSON-schema enum already rejects unknown gate ids,
|
|
35
|
+
// but we also whitelist here so a non-whitelisted value can never reach the shell.
|
|
36
|
+
const allowed = gates.filter(isGateAllowed);
|
|
37
|
+
if (allowed.length > 0)
|
|
38
|
+
tokens.push("--gates", q(allowed.join(",")));
|
|
39
|
+
}
|
|
40
|
+
return tokens.join(" ");
|
|
41
|
+
}
|
|
42
|
+
if (subcommand === "principles") {
|
|
43
|
+
const tokens = ["xp-gate", "principles"];
|
|
44
|
+
if (target !== undefined)
|
|
45
|
+
tokens.push(q(target));
|
|
46
|
+
return tokens.join(" ");
|
|
47
|
+
}
|
|
48
|
+
// subcommand === "arch" — the only remaining member of the union.
|
|
49
|
+
// #447: the default path runs the pinned local @archlinter/cli with its
|
|
50
|
+
// built-in rules — no config needed. Forcing `--config architecture.yaml`
|
|
51
|
+
// (the old default) hit the pre-0.16 schema that archlint itself rejects.
|
|
52
|
+
// Only forward an explicitly provided config, via the opt-in --use-config.
|
|
53
|
+
const tokens = ["xp-gate", "arch"];
|
|
54
|
+
if (config !== undefined)
|
|
55
|
+
tokens.push("--use-config", q(config));
|
|
56
|
+
return tokens.join(" ");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build a single guarded shell command: runs `xp-gate …` when the global CLI
|
|
60
|
+
* is present, otherwise prints the graceful-degradation hint. A single
|
|
61
|
+
* presence check avoids a separate probe/run race.
|
|
62
|
+
*/
|
|
63
|
+
export function buildCommand(options) {
|
|
64
|
+
const dialect = options.dialect ?? "posix";
|
|
65
|
+
const inner = buildInner(options, dialect);
|
|
66
|
+
if (dialect === "posix") {
|
|
67
|
+
return `if command -v xp-gate >/dev/null 2>&1; then ${inner}; else printf '%s\\n' ${shellQuote(FALLBACK_MESSAGE, dialect)}; fi`;
|
|
68
|
+
}
|
|
69
|
+
// PowerShell: `Get-Command` resolves the .cmd/.ps1 shims npm writes to
|
|
70
|
+
// %APPDATA%\npm. Those shims are invisible to a Git-Bash PATH lookup, so this
|
|
71
|
+
// branch is more reliable than `command -v` on Windows.
|
|
72
|
+
//
|
|
73
|
+
// The hint branch leaves $LASTEXITCODE untouched at 0, matching the POSIX
|
|
74
|
+
// branch's `printf` behaviour.
|
|
75
|
+
return `if (Get-Command xp-gate -ErrorAction SilentlyContinue) { ${inner} } else { Write-Output ${shellQuote(FALLBACK_MESSAGE, dialect)} }`;
|
|
76
|
+
}
|
|
77
|
+
/** Resolve a model-supplied path against the session cwd; absolutes pass through. */
|
|
78
|
+
export function resolveTarget(rawPath, cwd) {
|
|
79
|
+
return isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=command.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delphi configuration loading for the DSH plugin.
|
|
3
|
+
*
|
|
4
|
+
* Reads the same `.delphi-config.json` the xp-gate skill already uses, so a
|
|
5
|
+
* project configures its reviewers once and both the skill and the DSH tool
|
|
6
|
+
* agree on who they are.
|
|
7
|
+
*
|
|
8
|
+
* @test REQ-DSH-016
|
|
9
|
+
* @intent 复用项目既有的 .delphi-config.json,把 provider/api_key/专家席位解析成
|
|
10
|
+
* DSH 工具可用的结构,并对 ${ENV} 占位符与缺失密钥给出可操作的报错。
|
|
11
|
+
* @covers AC-DSH-016-02
|
|
12
|
+
*/
|
|
13
|
+
import type { ExpertSeat, DelphiOptions } from "./delphi.js";
|
|
14
|
+
/** Shape of one expert entry in .delphi-config.json. */
|
|
15
|
+
interface RawExpert {
|
|
16
|
+
id?: string;
|
|
17
|
+
role?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
requested_model?: string;
|
|
21
|
+
max_tokens?: number;
|
|
22
|
+
maxTokens?: number;
|
|
23
|
+
/** Per-expert provider override; falls back to the profile default. */
|
|
24
|
+
provider?: string;
|
|
25
|
+
}
|
|
26
|
+
interface RawProfile {
|
|
27
|
+
provider?: string;
|
|
28
|
+
providers?: Record<string, {
|
|
29
|
+
base_url?: string;
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
api_key?: string;
|
|
32
|
+
apiKey?: string;
|
|
33
|
+
}>;
|
|
34
|
+
/**
|
|
35
|
+
* Experts appear in two shapes in the wild: an object keyed by role (what
|
|
36
|
+
* xp-gate actually writes) and an array. Both are accepted.
|
|
37
|
+
*/
|
|
38
|
+
experts?: RawExpert[] | Record<string, RawExpert>;
|
|
39
|
+
consensus?: {
|
|
40
|
+
threshold_percent?: number;
|
|
41
|
+
max_review_rounds?: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export interface RawDelphiConfig extends RawProfile {
|
|
45
|
+
active_profile?: string;
|
|
46
|
+
profiles?: Record<string, RawProfile>;
|
|
47
|
+
}
|
|
48
|
+
export interface ResolvedDelphiConfig {
|
|
49
|
+
readonly seats: ExpertSeat[];
|
|
50
|
+
readonly baseUrl: string;
|
|
51
|
+
readonly apiKey: string;
|
|
52
|
+
readonly thresholdPercent: number;
|
|
53
|
+
readonly maxRounds: number;
|
|
54
|
+
readonly provider: string;
|
|
55
|
+
}
|
|
56
|
+
/** Raised when config on disk cannot be turned into a usable review setup. */
|
|
57
|
+
export declare class DelphiSetupError extends Error {
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Expand `${VAR}` and `{env:VAR}` placeholders from the environment.
|
|
61
|
+
* Supports the two spellings already present in xp-gate config files.
|
|
62
|
+
*/
|
|
63
|
+
export declare function expandEnv(value: string, env?: NodeJS.ProcessEnv): string;
|
|
64
|
+
/**
|
|
65
|
+
* Turn raw config into a ready-to-run review setup.
|
|
66
|
+
* Fails loudly and specifically: a missing key or an unexpanded placeholder is
|
|
67
|
+
* the most common way a Delphi run silently degrades.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveConfig(cfg: RawDelphiConfig, env?: NodeJS.ProcessEnv): ResolvedDelphiConfig;
|
|
70
|
+
/** Read and resolve `.delphi-config.json` from a project root. */
|
|
71
|
+
export declare function loadDelphiConfig(cwd: string, env?: NodeJS.ProcessEnv): ResolvedDelphiConfig;
|
|
72
|
+
/** Build the transport options a round needs from a resolved config. */
|
|
73
|
+
export declare function toDelphiOptions(cfg: ResolvedDelphiConfig): DelphiOptions;
|
|
74
|
+
export {};
|
|
75
|
+
//# sourceMappingURL=delphi-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"delphi-config.d.ts","sourceRoot":"","sources":["../src/delphi-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAG5D,wDAAwD;AACxD,UAAU,SAAS;IACjB,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,UAAU,UAAU;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACtG;;;OAGG;IACH,OAAO,CAAC,EAAE,SAAS,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IACjD,SAAS,CAAC,EAAE;QAAE,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAAC,iBAAiB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACvE;AAED,MAAM,WAAW,eAAgB,SAAQ,UAAU;IACjD,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;CACtC;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,UAAU,EAAE,CAAA;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AAED,8EAA8E;AAC9E,qBAAa,gBAAiB,SAAQ,KAAK;CAAG;AAE9C;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAIrF;AAkCD;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,eAAe,EACpB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,oBAAoB,CAiFtB;AAED,kEAAkE;AAClE,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,MAAM,EACX,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,oBAAoB,CAetB;AAED,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,GAAG,EAAE,oBAAoB,GAAG,aAAa,CAQxE"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delphi configuration loading for the DSH plugin.
|
|
3
|
+
*
|
|
4
|
+
* Reads the same `.delphi-config.json` the xp-gate skill already uses, so a
|
|
5
|
+
* project configures its reviewers once and both the skill and the DSH tool
|
|
6
|
+
* agree on who they are.
|
|
7
|
+
*
|
|
8
|
+
* @test REQ-DSH-016
|
|
9
|
+
* @intent 复用项目既有的 .delphi-config.json,把 provider/api_key/专家席位解析成
|
|
10
|
+
* DSH 工具可用的结构,并对 ${ENV} 占位符与缺失密钥给出可操作的报错。
|
|
11
|
+
* @covers AC-DSH-016-02
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { validateSeats } from "./delphi.js";
|
|
16
|
+
/** Raised when config on disk cannot be turned into a usable review setup. */
|
|
17
|
+
export class DelphiSetupError extends Error {
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Expand `${VAR}` and `{env:VAR}` placeholders from the environment.
|
|
21
|
+
* Supports the two spellings already present in xp-gate config files.
|
|
22
|
+
*/
|
|
23
|
+
export function expandEnv(value, env = process.env) {
|
|
24
|
+
return value
|
|
25
|
+
.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name) => env[name] ?? "")
|
|
26
|
+
.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name) => env[name] ?? "");
|
|
27
|
+
}
|
|
28
|
+
/** Pick the active profile, falling back to the top level. */
|
|
29
|
+
function selectProfile(cfg) {
|
|
30
|
+
const active = cfg.active_profile;
|
|
31
|
+
if (active && cfg.profiles?.[active])
|
|
32
|
+
return cfg.profiles[active];
|
|
33
|
+
if (cfg.profiles && Object.keys(cfg.profiles).length > 0) {
|
|
34
|
+
throw new DelphiSetupError(`No active_profile set. Available: ${Object.keys(cfg.profiles).join(", ")}`);
|
|
35
|
+
}
|
|
36
|
+
return cfg;
|
|
37
|
+
}
|
|
38
|
+
function seatFromRaw(raw, index, fallbackRole) {
|
|
39
|
+
const model = raw.model ?? raw.requested_model ?? "";
|
|
40
|
+
return {
|
|
41
|
+
id: raw.id ?? raw.name ?? `Expert ${String.fromCharCode(65 + index)}`,
|
|
42
|
+
role: raw.role ?? fallbackRole,
|
|
43
|
+
model,
|
|
44
|
+
maxTokens: raw.max_tokens ?? raw.maxTokens,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Normalize either experts shape into an ordered list with roles attached. */
|
|
48
|
+
function normalizeExperts(experts) {
|
|
49
|
+
if (!experts)
|
|
50
|
+
return [];
|
|
51
|
+
if (Array.isArray(experts)) {
|
|
52
|
+
return experts.map((raw, i) => ({ raw, role: raw.role ?? `expert${i}` }));
|
|
53
|
+
}
|
|
54
|
+
// Object form: the KEY is the role, which is how xp-gate writes it.
|
|
55
|
+
return Object.entries(experts).map(([role, raw]) => ({ raw, role }));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Turn raw config into a ready-to-run review setup.
|
|
59
|
+
* Fails loudly and specifically: a missing key or an unexpanded placeholder is
|
|
60
|
+
* the most common way a Delphi run silently degrades.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveConfig(cfg, env = process.env) {
|
|
63
|
+
const profile = selectProfile(cfg);
|
|
64
|
+
const normalized = normalizeExperts(profile.experts);
|
|
65
|
+
// An expert may name its own provider; otherwise there must be a profile-wide
|
|
66
|
+
// default. Resolve base_url/api_key per provider so mixed setups work.
|
|
67
|
+
const providerNames = new Set(normalized.map((e) => e.raw.provider ?? profile.provider).filter((p) => !!p));
|
|
68
|
+
if (providerNames.size === 0) {
|
|
69
|
+
throw new DelphiSetupError('No provider configured. Set "provider" on the profile or on each expert.');
|
|
70
|
+
}
|
|
71
|
+
const providerTable = profile.providers ?? {};
|
|
72
|
+
const resolvedProviders = new Map();
|
|
73
|
+
for (const name of providerNames) {
|
|
74
|
+
const entry = providerTable[name];
|
|
75
|
+
if (!entry) {
|
|
76
|
+
throw new DelphiSetupError(`Provider "${name}" is not defined under "providers". ` +
|
|
77
|
+
`Known: ${Object.keys(providerTable).join(", ") || "(none)"}`);
|
|
78
|
+
}
|
|
79
|
+
const baseUrl = entry.base_url ?? entry.baseUrl ?? "";
|
|
80
|
+
if (!baseUrl)
|
|
81
|
+
throw new DelphiSetupError(`Provider "${name}" has no base_url.`);
|
|
82
|
+
const rawKey = entry.api_key ?? entry.apiKey ?? "";
|
|
83
|
+
const apiKey = expandEnv(rawKey, env);
|
|
84
|
+
if (!apiKey) {
|
|
85
|
+
// Name the variable rather than echoing config, which may hold a literal key.
|
|
86
|
+
const varName = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/.exec(rawKey)?.[1] ??
|
|
87
|
+
/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/.exec(rawKey)?.[1];
|
|
88
|
+
throw new DelphiSetupError(varName
|
|
89
|
+
? `API key variable ${varName} is not set in the environment.`
|
|
90
|
+
: `Provider "${name}" has no api_key.`);
|
|
91
|
+
}
|
|
92
|
+
resolvedProviders.set(name, { baseUrl, apiKey });
|
|
93
|
+
}
|
|
94
|
+
const seats = normalized.map((e, i) => seatFromRaw(e.raw, i, e.role));
|
|
95
|
+
// Surface an unexpanded placeholder as a config error, not a provider 404.
|
|
96
|
+
for (const s of seats) {
|
|
97
|
+
if (s.model.includes("${") || s.model.includes("{env:")) {
|
|
98
|
+
throw new DelphiSetupError(`Expert "${s.id}" model "${s.model}" is an unexpanded placeholder.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
validateSeats(seats);
|
|
102
|
+
// All seats share one gateway in the supported setup; require agreement so a
|
|
103
|
+
// mixed list cannot silently send one seat's key to another host.
|
|
104
|
+
const chosen = resolvedProviders.get(normalized[0].raw.provider ?? profile.provider ?? "");
|
|
105
|
+
if (!chosen)
|
|
106
|
+
throw new DelphiSetupError("Unable to resolve a provider for the first expert.");
|
|
107
|
+
for (const e of normalized) {
|
|
108
|
+
const name = e.raw.provider ?? profile.provider ?? "";
|
|
109
|
+
const other = resolvedProviders.get(name);
|
|
110
|
+
if (!other || other.baseUrl !== chosen.baseUrl) {
|
|
111
|
+
throw new DelphiSetupError(`All experts must share one gateway base_url; "${name}" differs. ` +
|
|
112
|
+
`Split the review or align the providers.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// consensus may sit on the profile or at the document root; both occur.
|
|
116
|
+
const consensus = profile.consensus ?? cfg.consensus;
|
|
117
|
+
return {
|
|
118
|
+
seats,
|
|
119
|
+
baseUrl: chosen.baseUrl,
|
|
120
|
+
apiKey: chosen.apiKey,
|
|
121
|
+
thresholdPercent: consensus?.threshold_percent ?? 90,
|
|
122
|
+
maxRounds: consensus?.max_review_rounds ?? 5,
|
|
123
|
+
provider: normalized[0].raw.provider ?? profile.provider ?? "",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Read and resolve `.delphi-config.json` from a project root. */
|
|
127
|
+
export function loadDelphiConfig(cwd, env = process.env) {
|
|
128
|
+
const path = resolve(cwd, ".delphi-config.json");
|
|
129
|
+
let raw;
|
|
130
|
+
try {
|
|
131
|
+
raw = readFileSync(path, "utf8");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
throw new DelphiSetupError(`No .delphi-config.json found at ${path}.`);
|
|
135
|
+
}
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(raw);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
throw new DelphiSetupError(`.delphi-config.json is not valid JSON: ${e.message}`);
|
|
142
|
+
}
|
|
143
|
+
return resolveConfig(parsed, env);
|
|
144
|
+
}
|
|
145
|
+
/** Build the transport options a round needs from a resolved config. */
|
|
146
|
+
export function toDelphiOptions(cfg) {
|
|
147
|
+
return {
|
|
148
|
+
baseUrl: cfg.baseUrl,
|
|
149
|
+
apiKey: cfg.apiKey,
|
|
150
|
+
seats: cfg.seats,
|
|
151
|
+
thresholdPercent: cfg.thresholdPercent,
|
|
152
|
+
maxRounds: cfg.maxRounds,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=delphi-config.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
import type { DelphiOptions, RoundResult } from "./delphi.js";
|
|
11
|
+
export interface ReviewOutcome {
|
|
12
|
+
readonly rounds: readonly RoundResult[];
|
|
13
|
+
readonly consensus: boolean;
|
|
14
|
+
readonly finalRatio: number;
|
|
15
|
+
readonly totalRounds: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Run a full review. Round 1 is anonymous; each later round sees the aggregate
|
|
19
|
+
* plus whatever the caller says was addressed, then re-votes.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runReview(opts: DelphiOptions, subject: string, addressed?: string): Promise<ReviewOutcome>;
|
|
22
|
+
/** Human-readable transcript for the tool result. */
|
|
23
|
+
export declare function formatOutcome(outcome: ReviewOutcome): string;
|
|
24
|
+
//# sourceMappingURL=delphi-run.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"delphi-run.d.ts","sourceRoot":"","sources":["../src/delphi-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAc,WAAW,EAAE,MAAM,aAAa,CAAA;AAOzE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAA;IACvC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AA0CD;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,aAAa,EACnB,OAAO,EAAE,MAAM,EACf,SAAS,SAAK,GACb,OAAO,CAAC,aAAa,CAAC,CAoCxB;AAED,qDAAqD;AACrD,wBAAgB,aAAa,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAiB5D"}
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
import { runRound } from "./delphi.js";
|
|
11
|
+
/** Mirrors delphi.ts; declared here so orchestrators need not import internals. */
|
|
12
|
+
const DEFAULT_THRESHOLD_PERCENT = 90;
|
|
13
|
+
const DEFAULT_MAX_ROUNDS = 5;
|
|
14
|
+
/** Anonymous first round: each seat answers the same question with no peers. */
|
|
15
|
+
function roundOnePrompt(seat, subject) {
|
|
16
|
+
return (`You are the ${seat.role.toUpperCase()} expert. Review the material below INDEPENDENTLY. ` +
|
|
17
|
+
`Do not assume other reviewers exist or agree with you.\n\n${subject}\n\n` +
|
|
18
|
+
`Respond with <=300 words under the headings: "### Findings" (a markdown table of ` +
|
|
19
|
+
`ID | Severity | Description | Location | Fix, or a single NONE row), then "### VERDICT" ` +
|
|
20
|
+
`followed by exactly APPROVED or REQUEST_CHANGES, then "### CONFIDENCE" followed by an ` +
|
|
21
|
+
`integer 1-10. Approve only if you would personally ship this.`);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Later rounds: feed back the aggregate so seats can revise their position.
|
|
25
|
+
* Individual reports stay anonymous -- only the tally and the addressed items
|
|
26
|
+
* are shared, which is what keeps the method honest.
|
|
27
|
+
*/
|
|
28
|
+
function laterRoundPrompt(seat, subject, previous, addressed) {
|
|
29
|
+
const tally = previous.verdicts
|
|
30
|
+
.map((v) => `- ${v.role}: ${v.verdict}`)
|
|
31
|
+
.join("\n");
|
|
32
|
+
return (`You are the ${seat.role.toUpperCase()} expert. This is ROUND ${previous.round + 1} of a ` +
|
|
33
|
+
`Delphi consensus review. Individual peer reports are anonymous; only this aggregate is shared:\n\n` +
|
|
34
|
+
`${tally}\nApprovals: ${previous.approvals}/${previous.validCount} ` +
|
|
35
|
+
`(threshold ${DEFAULT_THRESHOLD_PERCENT}%).\n\n` +
|
|
36
|
+
`${addressed ? `What changed since the last round:\n${addressed}\n\n` : ""}` +
|
|
37
|
+
`Re-examine the material and revise or hold your position.\n\n${subject}\n\n` +
|
|
38
|
+
`Respond with <=300 words: "### Round assessment" (2-3 sentences), "### Remaining blocking ` +
|
|
39
|
+
`issues" (table, or a single NONE row), "### VERDICT" followed by exactly APPROVED or ` +
|
|
40
|
+
`REQUEST_CHANGES, then "### CONFIDENCE" and an integer 1-10.`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run a full review. Round 1 is anonymous; each later round sees the aggregate
|
|
44
|
+
* plus whatever the caller says was addressed, then re-votes.
|
|
45
|
+
*/
|
|
46
|
+
export async function runReview(opts, subject, addressed = "") {
|
|
47
|
+
const threshold = opts.thresholdPercent ?? DEFAULT_THRESHOLD_PERCENT;
|
|
48
|
+
const maxRounds = opts.maxRounds ?? DEFAULT_MAX_ROUNDS;
|
|
49
|
+
const rounds = [];
|
|
50
|
+
for (let n = 1; n <= maxRounds; n++) {
|
|
51
|
+
const previous = rounds[rounds.length - 1];
|
|
52
|
+
const result = previous
|
|
53
|
+
? await runRound(opts, n, "You are a rigorous senior code reviewer.", (seat) => laterRoundPrompt(seat, subject, previous, addressed))
|
|
54
|
+
: await runRound(opts, 1, "You are a rigorous senior code reviewer.", (seat) => roundOnePrompt(seat, subject));
|
|
55
|
+
rounds.push(result);
|
|
56
|
+
// Threshold is a percentage of the whole panel, so a round with a malformed
|
|
57
|
+
// report cannot reach consensus even if every valid seat approved.
|
|
58
|
+
if (result.approvals / opts.seats.length * 100 >= threshold) {
|
|
59
|
+
return {
|
|
60
|
+
rounds,
|
|
61
|
+
consensus: true,
|
|
62
|
+
finalRatio: result.approvals / opts.seats.length,
|
|
63
|
+
totalRounds: n,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const last = rounds[rounds.length - 1];
|
|
68
|
+
return {
|
|
69
|
+
rounds,
|
|
70
|
+
consensus: false,
|
|
71
|
+
finalRatio: last ? last.approvals / opts.seats.length : 0,
|
|
72
|
+
totalRounds: rounds.length,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Human-readable transcript for the tool result. */
|
|
76
|
+
export function formatOutcome(outcome) {
|
|
77
|
+
const lines = [];
|
|
78
|
+
for (const r of outcome.rounds) {
|
|
79
|
+
lines.push(`## Round ${r.round} — approvals ${r.approvals}/${r.validCount} valid`);
|
|
80
|
+
for (const v of r.verdicts) {
|
|
81
|
+
const model = v.resolvedModel ? `${v.requestedModel} -> ${v.resolvedModel}` : v.requestedModel;
|
|
82
|
+
const note = v.error ? ` (${v.error})` : "";
|
|
83
|
+
lines.push(`- [${v.role}] ${v.id} (${model}): ${v.verdict} confidence=${v.confidence}${note}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push("");
|
|
86
|
+
}
|
|
87
|
+
lines.push(outcome.consensus
|
|
88
|
+
? `CONSENSUS REACHED: ${(outcome.finalRatio * 100).toFixed(0)}% after ${outcome.totalRounds} round(s).`
|
|
89
|
+
: `NO CONSENSUS: ${(outcome.finalRatio * 100).toFixed(0)}% after ${outcome.totalRounds} round(s) (cap reached).`);
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=delphi-run.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delphi cross-review over an external OpenAI-compatible provider.
|
|
3
|
+
*
|
|
4
|
+
* This is the codified form of the manual run that produced PR #456: three
|
|
5
|
+
* DISTINCT models, each asked the same question independently and anonymously,
|
|
6
|
+
* iterated until the approval ratio clears the consensus threshold.
|
|
7
|
+
*
|
|
8
|
+
* Why a plugin tool rather than a shell one-liner: the DSH tool plane is where
|
|
9
|
+
* a session actually lives, so the mechanism has to be callable from there.
|
|
10
|
+
* DSH's own `tool-subagent` cannot serve this purpose -- subagents inherit one
|
|
11
|
+
* host-configured model, so they cannot guarantee three distinct models, and
|
|
12
|
+
* Delphi's whole value rests on that distinctness.
|
|
13
|
+
*
|
|
14
|
+
* @test REQ-DSH-016
|
|
15
|
+
* @intent 用外部 provider 的多个不同模型做匿名交叉评审并判定共识,
|
|
16
|
+
* 把「多模型交叉评审」固化成 DSH 可调用的机制而非一次性脚本。
|
|
17
|
+
* @covers AC-DSH-016-01
|
|
18
|
+
*/
|
|
19
|
+
/** One expert seat. `model` is the id sent to the provider. */
|
|
20
|
+
export interface ExpertSeat {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly role: string;
|
|
23
|
+
readonly model: string;
|
|
24
|
+
/** Reasoning models burn hidden tokens, so seats need independent budgets. */
|
|
25
|
+
readonly maxTokens?: number;
|
|
26
|
+
}
|
|
27
|
+
/** A single expert's parsed verdict for one round. */
|
|
28
|
+
export interface ExpertVerdict {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly role: string;
|
|
31
|
+
readonly requestedModel: string;
|
|
32
|
+
readonly resolvedModel: string | null;
|
|
33
|
+
readonly verdict: "APPROVED" | "REQUEST_CHANGES" | "INVALID_NO_VERDICT" | "ERROR";
|
|
34
|
+
readonly confidence: number;
|
|
35
|
+
/** Only well-formed verdicts may count toward consensus. */
|
|
36
|
+
readonly counted: boolean;
|
|
37
|
+
readonly report: string;
|
|
38
|
+
readonly error?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Aggregated outcome of one round. */
|
|
41
|
+
export interface RoundResult {
|
|
42
|
+
readonly round: number;
|
|
43
|
+
readonly verdicts: readonly ExpertVerdict[];
|
|
44
|
+
readonly validCount: number;
|
|
45
|
+
readonly approvals: number;
|
|
46
|
+
readonly consensusRatio: number;
|
|
47
|
+
readonly reachedConsensus: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface DelphiOptions {
|
|
50
|
+
/** OpenAI-compatible base URL, e.g. https://host/gpt-proxy */
|
|
51
|
+
readonly baseUrl: string;
|
|
52
|
+
/** Bearer token. Callers read this from the environment; never hardcode it. */
|
|
53
|
+
readonly apiKey: string;
|
|
54
|
+
readonly seats: readonly ExpertSeat[];
|
|
55
|
+
/** Percent, 0-100. Delphi default is 90. */
|
|
56
|
+
readonly thresholdPercent?: number;
|
|
57
|
+
readonly maxRounds?: number;
|
|
58
|
+
/** Per-request ceiling; the caller owns the overall timeout. */
|
|
59
|
+
readonly timeoutMs?: number;
|
|
60
|
+
readonly fetchImpl?: typeof fetch;
|
|
61
|
+
}
|
|
62
|
+
/** Raised when the configured seats cannot satisfy the Delphi invariants. */
|
|
63
|
+
export declare class DelphiConfigError extends Error {
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Reject a seat configuration that cannot produce a valid Delphi review.
|
|
67
|
+
* Checked BEFORE any network call so a misconfiguration never looks like a
|
|
68
|
+
* provider failure.
|
|
69
|
+
*/
|
|
70
|
+
export declare function validateSeats(seats: readonly ExpertSeat[]): void;
|
|
71
|
+
/**
|
|
72
|
+
* Parse an expert report into a verdict.
|
|
73
|
+
*
|
|
74
|
+
* The verdict token usually sits on the line AFTER a "### VERDICT" heading, so
|
|
75
|
+
* a same-line `VERDICT: X` pattern misses every real report. A report with no
|
|
76
|
+
* recognisable verdict is INVALID, never a guess: inferring approval from prose
|
|
77
|
+
* would let the gate pass on an assumption.
|
|
78
|
+
*/
|
|
79
|
+
export declare function parseVerdict(text: string): {
|
|
80
|
+
verdict: ExpertVerdict["verdict"];
|
|
81
|
+
confidence: number;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Run ONE round. All seats are asked concurrently and never see each other's
|
|
85
|
+
* output, which is what makes the round anonymous.
|
|
86
|
+
*/
|
|
87
|
+
export declare function runRound(opts: DelphiOptions, round: number, system: string, buildPrompt: (seat: ExpertSeat) => string): Promise<RoundResult>;
|
|
88
|
+
//# sourceMappingURL=delphi.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"delphi.d.ts","sourceRoot":"","sources":["../src/delphi.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,+DAA+D;AAC/D,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,sDAAsD;AACtD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,OAAO,EAAE,UAAU,GAAG,iBAAiB,GAAG,oBAAoB,GAAG,OAAO,CAAA;IACjF,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,4DAA4D;IAC5D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,uCAAuC;AACvC,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAA;IAC3C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAA;CACnC;AAED,MAAM,WAAW,aAAa;IAC5B,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,CAAA;IACrC,4CAA4C;IAC5C,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;IAClC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,gEAAgE;IAChE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;CAClC;AASD,6EAA6E;AAC7E,qBAAa,iBAAkB,SAAQ,KAAK;CAAG;AAE/C;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,IAAI,CAoBhE;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG;IAC1C,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,CAAA;IACjC,UAAU,EAAE,MAAM,CAAA;CACnB,CAWA;AA6DD;;;GAGG;AACH,wBAAsB,QAAQ,CAC5B,IAAI,EAAE,aAAa,EACnB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,MAAM,GACxC,OAAO,CAAC,WAAW,CAAC,CAmDtB"}
|