@boyingliu01/xp-gate 0.23.0 → 0.24.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.
- package/bin/xp-gate.js +15 -1
- package/lib/__tests__/delphi-setup.test.js +203 -0
- package/lib/__tests__/doctor-delphi.test.js +244 -0
- package/lib/__tests__/qoder-delphi-agents.test.js +31 -0
- package/lib/delphi-setup.js +155 -0
- package/lib/doctor.js +269 -1
- package/lib/init.js +4 -1
- package/lib/mattpocock-license-verdict.json +1 -1
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +43 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/delphi-review/SKILL.md +43 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +43 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/SKILL.md +43 -0
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/delphi-external-review.cjs +166 -14
- package/skills/delphi-review/.delphi-config.json.example +24 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +43 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/bin/xp-gate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { init } = require('../lib/init.js');
|
|
4
|
+
const { delphiSetup } = require('../lib/delphi-setup.js');
|
|
4
5
|
const { installSkill } = require('../lib/install-skill.js');
|
|
5
6
|
const { updateSkill } = require('../lib/update-skill.js');
|
|
6
7
|
const { uninstallSkill } = require('../lib/uninstall-skill.js');
|
|
@@ -80,6 +81,11 @@ const COMMANDS = {
|
|
|
80
81
|
run: () => init(['--global']).then(code => process.exit(code)),
|
|
81
82
|
usage: 'xp-gate setup-global'
|
|
82
83
|
},
|
|
84
|
+
'delphi-setup': {
|
|
85
|
+
description: 'Rebind the three Qoder Delphi expert agents to distinct built-in models (#414)',
|
|
86
|
+
run: subargs => delphiSetup(subargs).then(code => process.exit(code)),
|
|
87
|
+
usage: 'xp-gate delphi-setup --model architecture=<id> --model technical=<id> --model feasibility=<id> [--global|--local --project <dir>]'
|
|
88
|
+
},
|
|
83
89
|
'install-skill': {
|
|
84
90
|
description: 'Install a xp-gate skill from the package bundle',
|
|
85
91
|
run: subargs => {
|
|
@@ -139,7 +145,15 @@ const COMMANDS = {
|
|
|
139
145
|
},
|
|
140
146
|
'doctor': {
|
|
141
147
|
description: 'Diagnose xp-gate installation health',
|
|
142
|
-
run: subargs =>
|
|
148
|
+
run: subargs => {
|
|
149
|
+
// `xp-gate doctor delphi` is a dedicated chain diagnosis (#414 REQ-3),
|
|
150
|
+
// not a flag of the generic doctor run.
|
|
151
|
+
if (subargs[0] === 'delphi') {
|
|
152
|
+
const { doctorDelphi } = require('../lib/doctor.js');
|
|
153
|
+
return doctorDelphi({}).then(code => process.exit(code));
|
|
154
|
+
}
|
|
155
|
+
return doctor(subargs).then(code => process.exit(code));
|
|
156
|
+
},
|
|
143
157
|
usage: 'xp-gate doctor [--fix [--install-tools] | --sync-hooks [--force]] | --json | --format json (the two machine-readable modes print one JSON document and refuse --fix/--sync-hooks)'
|
|
144
158
|
},
|
|
145
159
|
'ui-review': {
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test delphi-setup
|
|
3
|
+
* @intent Guard #414 REQ-4: `xp-gate delphi-setup` is the repair path for
|
|
4
|
+
* drifted Qoder agent bindings. `xp-gate init` deliberately never overwrites
|
|
5
|
+
* an existing agent file, so a stale binding could previously never be fixed
|
|
6
|
+
* without hand-editing. delphi-setup must write three distinct, quoted model
|
|
7
|
+
* bindings, refuse duplicates and junk, and always demand an IDE reload —
|
|
8
|
+
* Qoder caches agent definitions at session start, so a skipped reload looks
|
|
9
|
+
* exactly like a fix that did not work.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { resetLibModules, muteConsole, makeTempDirs, removeTempDirs } = require('./helpers/lib-sandbox.cjs');
|
|
14
|
+
|
|
15
|
+
const LIB_MODULES = ['../init', '../detect-deps.js', '../shared-paths', '../delphi-setup'].map(name => require.resolve(name));
|
|
16
|
+
|
|
17
|
+
function repoRoot() {
|
|
18
|
+
return path.join(__dirname, '..', '..', '..', '..');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('delphi-setup (#414 REQ-4)', () => {
|
|
22
|
+
let tmpDirs;
|
|
23
|
+
let tmpHome;
|
|
24
|
+
let originalHome;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmpDirs = makeTempDirs(['delphi-setup-home']);
|
|
28
|
+
tmpHome = tmpDirs['delphi-setup-home'];
|
|
29
|
+
originalHome = process.env.HOME;
|
|
30
|
+
process.env.HOME = tmpHome;
|
|
31
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
|
|
32
|
+
resetLibModules(vi, LIB_MODULES);
|
|
33
|
+
muteConsole(vi);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
|
|
38
|
+
removeTempDirs(tmpDirs);
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function agentsDir() {
|
|
43
|
+
return path.join(tmpHome, '.qoder', 'agents');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('writes three agent files with the requested distinct bindings', async () => {
|
|
47
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
48
|
+
const code = await delphiSetup([
|
|
49
|
+
'--model', 'architecture=qwen-max',
|
|
50
|
+
'--model', 'technical=deepseek-v3.2',
|
|
51
|
+
'--model', 'feasibility=glm-5.2',
|
|
52
|
+
]);
|
|
53
|
+
expect(code).toBe(0);
|
|
54
|
+
const { readQoderModelId } = require('../init');
|
|
55
|
+
expect(readQoderModelId(path.join(agentsDir(), 'delphi-architecture.md'))).toBe('qwen-max');
|
|
56
|
+
expect(readQoderModelId(path.join(agentsDir(), 'delphi-technical.md'))).toBe('deepseek-v3.2');
|
|
57
|
+
expect(readQoderModelId(path.join(agentsDir(), 'delphi-feasibility.md'))).toBe('glm-5.2');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('preserves the rest of the bundled template body when rebinding', async () => {
|
|
61
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
62
|
+
await delphiSetup([
|
|
63
|
+
'--model', 'architecture=qwen-max',
|
|
64
|
+
'--model', 'technical=deepseek-v3.2',
|
|
65
|
+
'--model', 'feasibility=glm-5.2',
|
|
66
|
+
]);
|
|
67
|
+
const body = fs.readFileSync(path.join(agentsDir(), 'delphi-architecture.md'), 'utf8');
|
|
68
|
+
const shippedBody = fs.readFileSync(path.join(repoRoot(), 'plugins', 'qoder', 'agents', 'delphi-architecture.md'), 'utf8');
|
|
69
|
+
// The name line is template identity; only the binding may change.
|
|
70
|
+
expect(body).toMatch(/^name: delphi-architecture$/m);
|
|
71
|
+
expect(body).not.toBe('');
|
|
72
|
+
expect(shippedBody).toMatch(/^name: delphi-architecture$/m);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('repairs a stale binding by overwriting it (this is the point of the command)', async () => {
|
|
76
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
77
|
+
fs.mkdirSync(agentsDir(), { recursive: true });
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
path.join(agentsDir(), 'delphi-architecture.md'),
|
|
80
|
+
'---\nname: delphi-architecture\nmodel: "[Retired-Model](dead-model-id)"\n---\n# body\n',
|
|
81
|
+
);
|
|
82
|
+
const code = await delphiSetup([
|
|
83
|
+
'--model', 'architecture=qwen-max',
|
|
84
|
+
'--model', 'technical=deepseek-v3.2',
|
|
85
|
+
'--model', 'feasibility=glm-5.2',
|
|
86
|
+
]);
|
|
87
|
+
expect(code).toBe(0);
|
|
88
|
+
const { readQoderModelId } = require('../init');
|
|
89
|
+
expect(readQoderModelId(path.join(agentsDir(), 'delphi-architecture.md'))).toBe('qwen-max');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// R1 FC-04: a repair without a rollback is a second way to lose a working
|
|
93
|
+
// binding — existing files are backed up before the first write.
|
|
94
|
+
it('backs up existing agent files before overwriting them', async () => {
|
|
95
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
96
|
+
fs.mkdirSync(agentsDir(), { recursive: true });
|
|
97
|
+
const staleBody = '---\nname: delphi-architecture\nmodel: "[Retired-Model](dead-model-id)"\n---\n# body\n';
|
|
98
|
+
fs.writeFileSync(path.join(agentsDir(), 'delphi-architecture.md'), staleBody);
|
|
99
|
+
const code = await delphiSetup([
|
|
100
|
+
'--model', 'architecture=qwen-max',
|
|
101
|
+
'--model', 'technical=deepseek-v3.2',
|
|
102
|
+
'--model', 'feasibility=glm-5.2',
|
|
103
|
+
]);
|
|
104
|
+
expect(code).toBe(0);
|
|
105
|
+
// Some backup copy of the stale file must exist somewhere under the
|
|
106
|
+
// sandbox HOME config dir; the exact timestamped dir is intentional.
|
|
107
|
+
const backupRoot = path.join(tmpHome, '.config', 'xp-gate', 'backup');
|
|
108
|
+
const found = (function find(dir) {
|
|
109
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
110
|
+
const p = path.join(dir, e.name);
|
|
111
|
+
if (e.isDirectory()) {
|
|
112
|
+
const hit = find(p);
|
|
113
|
+
if (hit) return hit;
|
|
114
|
+
} else if (e.name === 'delphi-architecture.md') {
|
|
115
|
+
return p;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
})(backupRoot);
|
|
120
|
+
expect(found).not.toBeNull();
|
|
121
|
+
expect(fs.readFileSync(found, 'utf8')).toBe(staleBody);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// R1 MN-05: a BOM in front of '---' must not read as a missing frontmatter.
|
|
125
|
+
it('rebinds a template carrying a UTF-8 BOM', async () => {
|
|
126
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
127
|
+
fs.mkdirSync(agentsDir(), { recursive: true });
|
|
128
|
+
fs.writeFileSync(
|
|
129
|
+
path.join(agentsDir(), 'delphi-architecture.md'),
|
|
130
|
+
'\uFEFF---\nname: delphi-architecture\nmodel: "[Old](old-id)"\n---\n# body\n',
|
|
131
|
+
);
|
|
132
|
+
const code = await delphiSetup([
|
|
133
|
+
'--model', 'architecture=qwen-max',
|
|
134
|
+
'--model', 'technical=deepseek-v3.2',
|
|
135
|
+
'--model', 'feasibility=glm-5.2',
|
|
136
|
+
]);
|
|
137
|
+
expect(code).toBe(0);
|
|
138
|
+
const { readQoderModelId } = require('../init');
|
|
139
|
+
expect(readQoderModelId(path.join(agentsDir(), 'delphi-architecture.md'))).toBe('qwen-max');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects duplicate model IDs — three distinct models are the invariant being set up', async () => {
|
|
143
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
144
|
+
const code = await delphiSetup([
|
|
145
|
+
'--model', 'architecture=same-model',
|
|
146
|
+
'--model', 'technical=same-model',
|
|
147
|
+
'--model', 'feasibility=other',
|
|
148
|
+
]);
|
|
149
|
+
expect(code).toBe(1);
|
|
150
|
+
expect(fs.existsSync(agentsDir())).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('rejects an unknown role and a bare model id without a role=', async () => {
|
|
154
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
155
|
+
expect(await delphiSetup(['--model', 'critic=x'])).toBe(1);
|
|
156
|
+
expect(await delphiSetup(['--model', 'just-a-model'])).toBe(1);
|
|
157
|
+
expect(fs.existsSync(agentsDir())).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('rejects a model id outside the platform binding charset', async () => {
|
|
161
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
162
|
+
const code = await delphiSetup([
|
|
163
|
+
'--model', 'architecture=has space',
|
|
164
|
+
'--model', 'technical=deepseek-v3.2',
|
|
165
|
+
'--model', 'feasibility=glm-5.2',
|
|
166
|
+
]);
|
|
167
|
+
expect(code).toBe(1);
|
|
168
|
+
expect(fs.existsSync(agentsDir())).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('demands an IDE reload on success — the registry only loads at session start', async () => {
|
|
172
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
173
|
+
const logs = [];
|
|
174
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
|
|
175
|
+
await delphiSetup([
|
|
176
|
+
'--model', 'architecture=qwen-max',
|
|
177
|
+
'--model', 'technical=deepseek-v3.2',
|
|
178
|
+
'--model', 'feasibility=glm-5.2',
|
|
179
|
+
]);
|
|
180
|
+
logSpy.mockRestore();
|
|
181
|
+
const out = logs.join('\n');
|
|
182
|
+
expect(out).toMatch(/重载|reload|新会话|restart/i);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('--local --project targets the project .qoder/agents instead of HOME', async () => {
|
|
186
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
187
|
+
const projectDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'delphi-setup-proj-'));
|
|
188
|
+
try {
|
|
189
|
+
const code = await delphiSetup([
|
|
190
|
+
'--local', '--project', projectDir,
|
|
191
|
+
'--model', 'architecture=qwen-max',
|
|
192
|
+
'--model', 'technical=deepseek-v3.2',
|
|
193
|
+
'--model', 'feasibility=glm-5.2',
|
|
194
|
+
]);
|
|
195
|
+
expect(code).toBe(0);
|
|
196
|
+
const { readQoderModelId } = require('../init');
|
|
197
|
+
expect(readQoderModelId(path.join(projectDir, '.qoder', 'agents', 'delphi-architecture.md'))).toBe('qwen-max');
|
|
198
|
+
expect(fs.existsSync(path.join(tmpHome, '.qoder', 'agents'))).toBe(false);
|
|
199
|
+
} finally {
|
|
200
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test doctor-delphi
|
|
3
|
+
* @intent Guard #414 REQ-3/REQ-6: `xp-gate doctor delphi` turns the failure
|
|
4
|
+
* modes measured on real machines into diagnosable checks — drifted agent
|
|
5
|
+
* bindings (B2), a skill install that silently lagged the published package
|
|
6
|
+
* (B4), a runner script nobody can find (B3), and a config that cannot serve
|
|
7
|
+
* three distinct models. Every check must be assertable in isolation, and the
|
|
8
|
+
* npm-latest probe must be skippable so the doctor never needs the network.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { resetLibModules, muteConsole, makeTempDirs, removeTempDirs } = require('./helpers/lib-sandbox.cjs');
|
|
13
|
+
|
|
14
|
+
const LIB_MODULES = ['../init', '../detect-deps.js', '../shared-paths', '../doctor', '../delphi-setup'].map(name => require.resolve(name));
|
|
15
|
+
|
|
16
|
+
function repoRoot() {
|
|
17
|
+
return path.join(__dirname, '..', '..', '..', '..');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function bundledSkillDir() {
|
|
21
|
+
return path.join(repoRoot(), 'src', 'npm-package', 'skills', 'delphi-review');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('doctor delphi (#414 REQ-3/REQ-6)', () => {
|
|
25
|
+
let tmpDirs;
|
|
26
|
+
let tmpHome;
|
|
27
|
+
let originalHome;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
tmpDirs = makeTempDirs(['doctor-delphi-home']);
|
|
31
|
+
tmpHome = tmpDirs['doctor-delphi-home'];
|
|
32
|
+
originalHome = process.env.HOME;
|
|
33
|
+
process.env.HOME = tmpHome;
|
|
34
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
|
|
35
|
+
resetLibModules(vi, LIB_MODULES);
|
|
36
|
+
muteConsole(vi);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
|
|
41
|
+
removeTempDirs(tmpDirs);
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function healthyHome() {
|
|
46
|
+
// A home that would pass the agent + skill-parity checks.
|
|
47
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'agents'), { recursive: true });
|
|
48
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills', 'delphi-review'), { recursive: true });
|
|
49
|
+
for (const role of ['architecture', 'technical', 'feasibility']) {
|
|
50
|
+
fs.copyFileSync(
|
|
51
|
+
path.join(repoRoot(), 'plugins', 'qoder', 'agents', `delphi-${role}.md`),
|
|
52
|
+
path.join(tmpHome, '.qoder', 'agents', `delphi-${role}.md`),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
for (const f of fs.readdirSync(bundledSkillDir(), { withFileTypes: true })) {
|
|
56
|
+
const dest = path.join(tmpHome, '.qoder', 'skills', 'delphi-review', f.name);
|
|
57
|
+
if (f.isDirectory()) {
|
|
58
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
59
|
+
for (const inner of fs.readdirSync(path.join(bundledSkillDir(), f.name))) {
|
|
60
|
+
if (fs.statSync(path.join(bundledSkillDir(), f.name, inner)).isFile()) {
|
|
61
|
+
fs.copyFileSync(path.join(bundledSkillDir(), f.name, inner), path.join(dest, inner));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} else if (f.isFile()) {
|
|
65
|
+
fs.copyFileSync(path.join(bundledSkillDir(), f.name), dest);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeConfig(configPath) {
|
|
71
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
72
|
+
active_profile: 'default',
|
|
73
|
+
profiles: {
|
|
74
|
+
default: {
|
|
75
|
+
providers: {
|
|
76
|
+
gw: { base_url: 'https://gw.example/v1', api_key: 'k' },
|
|
77
|
+
},
|
|
78
|
+
experts: {
|
|
79
|
+
architecture: { provider: 'gw', model: 'model-a' },
|
|
80
|
+
technical: { provider: 'gw', model: 'model-b' },
|
|
81
|
+
feasibility: { provider: 'gw', model: 'model-c' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
}));
|
|
86
|
+
return configPath;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
it('a fully healthy environment PASSes every check and exits 0', async () => {
|
|
90
|
+
healthyHome();
|
|
91
|
+
const { doctorDelphi } = require('../doctor');
|
|
92
|
+
const configPath = writeConfig(path.join(tmpDirs['doctor-delphi-home'], '.delphi-config.json'));
|
|
93
|
+
const localVersion = require('../../package.json').version;
|
|
94
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: localVersion });
|
|
95
|
+
expect(code).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('a stale agent binding FAILs and names delphi-setup as the repair path', async () => {
|
|
99
|
+
healthyHome();
|
|
100
|
+
fs.writeFileSync(
|
|
101
|
+
path.join(tmpHome, '.qoder', 'agents', 'delphi-technical.md'),
|
|
102
|
+
'---\nname: delphi-technical\nmodel: "[Retired](retired-id)"\n---\n# body\n',
|
|
103
|
+
);
|
|
104
|
+
const { doctorDelphi } = require('../doctor');
|
|
105
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath: path.join(tmpHome, 'absent.json'), npmLatest: null });
|
|
106
|
+
expect(code).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('a skill install that drifted from the bundled copy FAILs with a reinstall hint', async () => {
|
|
110
|
+
healthyHome();
|
|
111
|
+
const installed = path.join(tmpHome, '.qoder', 'skills', 'delphi-review', 'SKILL.md');
|
|
112
|
+
fs.appendFileSync(installed, '\ndrifted line\n');
|
|
113
|
+
const logs = [];
|
|
114
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
|
|
115
|
+
const { doctorDelphi } = require('../doctor');
|
|
116
|
+
await doctorDelphi({ projectDir: tmpHome, configPath: path.join(tmpHome, 'absent.json'), npmLatest: null });
|
|
117
|
+
logSpy.mockRestore();
|
|
118
|
+
expect(logs.join('\n')).toMatch(/install-skill|reinstall/i);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('a duplicate-model config FAILs instead of promising a review that will BLOCK', async () => {
|
|
122
|
+
healthyHome();
|
|
123
|
+
const configPath = path.join(tmpHome, '.delphi-config.json');
|
|
124
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
125
|
+
active_profile: 'default',
|
|
126
|
+
profiles: {
|
|
127
|
+
default: {
|
|
128
|
+
providers: { gw: { base_url: 'https://gw.example/v1', api_key: 'k' } },
|
|
129
|
+
experts: {
|
|
130
|
+
architecture: { provider: 'gw', model: 'same' },
|
|
131
|
+
technical: { provider: 'gw', model: 'same' },
|
|
132
|
+
feasibility: { provider: 'gw', model: 'model-c' },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
137
|
+
const { doctorDelphi } = require('../doctor');
|
|
138
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
|
|
139
|
+
expect(code).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('a missing config FAILs with the copy-the-example hint', async () => {
|
|
143
|
+
healthyHome();
|
|
144
|
+
const logs = [];
|
|
145
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
|
|
146
|
+
const { doctorDelphi } = require('../doctor');
|
|
147
|
+
await doctorDelphi({ projectDir: tmpHome, configPath: path.join(tmpHome, 'absent.json'), npmLatest: null });
|
|
148
|
+
logSpy.mockRestore();
|
|
149
|
+
expect(logs.join('\n')).toMatch(/\.delphi-config\.json/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('npmLatest=null skips the version check without failing the run', async () => {
|
|
153
|
+
healthyHome();
|
|
154
|
+
const { doctorDelphi } = require('../doctor');
|
|
155
|
+
const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
|
|
156
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
|
|
157
|
+
expect(code).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// R1 FC-07: the switch shipping off is the platform default, not a defect —
|
|
161
|
+
// only enabled-but-underprovisioned is a real FAIL.
|
|
162
|
+
it('DSH with subagentModelSelection absent is SKIP, not FAIL', async () => {
|
|
163
|
+
healthyHome();
|
|
164
|
+
fs.mkdirSync(path.join(tmpHome, '.dsh'), { recursive: true });
|
|
165
|
+
fs.writeFileSync(path.join(tmpHome, '.dsh', 'settings.json'), JSON.stringify({ other: true }));
|
|
166
|
+
const { doctorDelphi } = require('../doctor');
|
|
167
|
+
const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
|
|
168
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
|
|
169
|
+
expect(code).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// R1 FC-09: a project-level agent set (delphi-setup --local --project) is
|
|
173
|
+
// what the doctor reads, so the repair loop closes.
|
|
174
|
+
it('a complete project-level agent set takes precedence over the HOME set', async () => {
|
|
175
|
+
healthyHome();
|
|
176
|
+
const projDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'doctor-delphi-proj-'));
|
|
177
|
+
try {
|
|
178
|
+
fs.mkdirSync(path.join(projDir, '.qoder', 'agents'), { recursive: true });
|
|
179
|
+
for (const role of ['architecture', 'technical', 'feasibility']) {
|
|
180
|
+
fs.copyFileSync(
|
|
181
|
+
path.join(repoRoot(), 'plugins', 'qoder', 'agents', `delphi-${role}.md`),
|
|
182
|
+
path.join(projDir, '.qoder', 'agents', `delphi-${role}.md`),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
// Corrupt the HOME set; the project set must win and the run stays green.
|
|
186
|
+
fs.writeFileSync(
|
|
187
|
+
path.join(tmpHome, '.qoder', 'agents', 'delphi-technical.md'),
|
|
188
|
+
'---\nname: delphi-technical\nmodel: "[Retired](retired-id)"\n---\n# body\n',
|
|
189
|
+
);
|
|
190
|
+
const { doctorDelphi } = require('../doctor');
|
|
191
|
+
const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
|
|
192
|
+
const code = await doctorDelphi({
|
|
193
|
+
projectDir: projDir, configPath, npmLatest: null,
|
|
194
|
+
});
|
|
195
|
+
expect(code).toBe(0);
|
|
196
|
+
} finally {
|
|
197
|
+
fs.rmSync(projDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// R1 MI-03: parity covers the whole bundled file set, so a stale references/
|
|
202
|
+
// copy cannot hide behind a matching SKILL.md.
|
|
203
|
+
it('a drifted references file FAILs parity even when SKILL.md matches', async () => {
|
|
204
|
+
healthyHome();
|
|
205
|
+
// R2 MJ-R2-01: the assertion must be the ONLY way this test can fail. An
|
|
206
|
+
// absent config would exit 1 regardless of parity, so the fixture supplies
|
|
207
|
+
// a valid one and lets the drifted file carry the verdict.
|
|
208
|
+
const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
|
|
209
|
+
const refsDir = path.join(bundledSkillDir(), 'references');
|
|
210
|
+
const refFile = fs.existsSync(refsDir)
|
|
211
|
+
? fs.readdirSync(refsDir).find((f) => f.endsWith('.md'))
|
|
212
|
+
: null;
|
|
213
|
+
expect(refFile).toBeTruthy(); // explicit: without a bundled reference this test has nothing to say
|
|
214
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills', 'delphi-review', 'references'), { recursive: true });
|
|
215
|
+
fs.appendFileSync(
|
|
216
|
+
path.join(tmpHome, '.qoder', 'skills', 'delphi-review', 'references', refFile),
|
|
217
|
+
'\ndrift\n',
|
|
218
|
+
);
|
|
219
|
+
const { doctorDelphi } = require('../doctor');
|
|
220
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
|
|
221
|
+
expect(code).toBe(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// R3 FC-01/FC-02: the empty-bundle branch is driven directly through the
|
|
225
|
+
// injectable bundledSkillDir — the healthy fixture cannot exercise it, and
|
|
226
|
+
// an indirect output-shape assertion would pass even without the fix.
|
|
227
|
+
it('an empty bundled skill directory FAILs parity instead of passing vacuously', async () => {
|
|
228
|
+
healthyHome();
|
|
229
|
+
const emptyBundle = fs.mkdtempSync(path.join(require('os').tmpdir(), 'doctor-empty-bundle-'));
|
|
230
|
+
try {
|
|
231
|
+
const logs = [];
|
|
232
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
|
|
233
|
+
const { doctorDelphi } = require('../doctor');
|
|
234
|
+
const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
|
|
235
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null, bundledSkillDir: emptyBundle });
|
|
236
|
+
logSpy.mockRestore();
|
|
237
|
+
expect(code).toBe(1);
|
|
238
|
+
const parityLine = logs.find((l) => l.includes('Delphi skill install parity'));
|
|
239
|
+
expect(parityLine).toContain('missing or empty');
|
|
240
|
+
} finally {
|
|
241
|
+
fs.rmSync(emptyBundle, { recursive: true, force: true });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -282,4 +282,35 @@ describe('Qoder Delphi agent templates (#417)', () => {
|
|
|
282
282
|
expect(warned()).not.toContain('templates not bundled');
|
|
283
283
|
});
|
|
284
284
|
});
|
|
285
|
+
|
|
286
|
+
// #429 item 5: the local-install path had direct test coverage, but the
|
|
287
|
+
// global wiring (`setupGlobal` -> `deployQoderDelphiAgents(srcDir, HOME_DIR)`)
|
|
288
|
+
// was only audited at "file exists" level — and the historical miss (#417) was
|
|
289
|
+
// exactly "local deployed, global did not". This test drives the real
|
|
290
|
+
// `setupGlobal` under a sandboxed HOME and asserts all three agents land in
|
|
291
|
+
// `~/.qoder/agents/` bound to the models this version ships. The missing-env
|
|
292
|
+
// regression it guards: someone re-pointing setupGlobal at a literal path, or
|
|
293
|
+
// dropping the call entirely, fails here instead of shipping silently.
|
|
294
|
+
it('deploys all three agents to the real HOME with this version\'s bindings after global setup', async () => {
|
|
295
|
+
// doctor.js/doctor-tui.js derive their HOME-dependent paths at module load;
|
|
296
|
+
// dropping them from the cache makes ensureTuiRegistration write inside the
|
|
297
|
+
// sandbox instead of the developer's real ~/.config/opencode/tui.json.
|
|
298
|
+
for (const name of ['../doctor.js', '../doctor-tui.js']) {
|
|
299
|
+
delete require.cache[require.resolve(name)];
|
|
300
|
+
}
|
|
301
|
+
const { setupGlobal, readQoderModelId } = require('../init');
|
|
302
|
+
// setupGlobal consumes the CLI argv array (flag checks downstream).
|
|
303
|
+
await setupGlobal([]);
|
|
304
|
+
|
|
305
|
+
const agentsDir = path.join(tmpHome, '.qoder', 'agents');
|
|
306
|
+
const shipped = path.join(repoRoot(), 'plugins', 'qoder', 'agents');
|
|
307
|
+
for (const name of ['delphi-architecture', 'delphi-technical', 'delphi-feasibility']) {
|
|
308
|
+
const deployed = path.join(agentsDir, `${name}.md`);
|
|
309
|
+
expect(fs.existsSync(deployed)).toBe(true);
|
|
310
|
+
// Identical to the bundled template's binding, not merely present: a
|
|
311
|
+
// stale global copy is the exact failure mode this audit exists for.
|
|
312
|
+
expect(readQoderModelId(deployed))
|
|
313
|
+
.toBe(readQoderModelId(path.join(shipped, `${name}.md`)));
|
|
314
|
+
}
|
|
315
|
+
});
|
|
285
316
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// xp-gate delphi-setup (#414 REQ-4) — the repair path for Qoder Delphi agent
|
|
4
|
+
// bindings. `xp-gate init` deploys the bundled templates but deliberately
|
|
5
|
+
// never overwrites an existing file, which is the right safety default for
|
|
6
|
+
// installs and exactly the wrong behavior once a binding has drifted or the
|
|
7
|
+
// platform retired a model id. This command exists to rebind on purpose.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { HOME_DIR, CONFIG_DIR } = require('./shared-paths.js');
|
|
12
|
+
|
|
13
|
+
const DELPHI_ROLES = ['architecture', 'technical', 'feasibility'];
|
|
14
|
+
// Mirrors QODER_MODEL_BINDING in init.js: the model id must survive
|
|
15
|
+
// `"[DisplayName](modelId)"`, so the platform-visible charset is enforced here
|
|
16
|
+
// rather than discovered as a silently-ignored binding later.
|
|
17
|
+
const MODEL_ID_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
18
|
+
|
|
19
|
+
function parseSetupArgs(args) {
|
|
20
|
+
const models = {};
|
|
21
|
+
let scope = 'global';
|
|
22
|
+
let projectDir;
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
if (args[i] === '--model') {
|
|
25
|
+
const spec = args[++i];
|
|
26
|
+
const eq = typeof spec === 'string' ? spec.indexOf('=') : -1;
|
|
27
|
+
if (eq === -1) {
|
|
28
|
+
console.error(`[delphi-setup] ERROR: --model expects <role>=<modelId>, got "${spec}".`);
|
|
29
|
+
return { error: true };
|
|
30
|
+
}
|
|
31
|
+
const role = spec.slice(0, eq).trim();
|
|
32
|
+
const modelId = spec.slice(eq + 1).trim();
|
|
33
|
+
if (!DELPHI_ROLES.includes(role)) {
|
|
34
|
+
console.error(`[delphi-setup] ERROR: unknown expert role "${role}". Must be one of: ${DELPHI_ROLES.join(', ')}.`);
|
|
35
|
+
return { error: true };
|
|
36
|
+
}
|
|
37
|
+
if (modelId === '' || !MODEL_ID_PATTERN.test(modelId)) {
|
|
38
|
+
console.error(`[delphi-setup] ERROR: model id for ${role} must match [A-Za-z0-9_.-]+ (it becomes "[id](id)" in the agent frontmatter).`);
|
|
39
|
+
return { error: true };
|
|
40
|
+
}
|
|
41
|
+
models[role] = modelId;
|
|
42
|
+
} else if (args[i] === '--local') {
|
|
43
|
+
scope = 'local';
|
|
44
|
+
} else if (args[i] === '--global') {
|
|
45
|
+
scope = 'global';
|
|
46
|
+
} else if (args[i] === '--project') {
|
|
47
|
+
projectDir = args[++i];
|
|
48
|
+
if (typeof projectDir !== 'string' || projectDir.trim() === '') {
|
|
49
|
+
console.error('[delphi-setup] ERROR: --project expects a directory path.');
|
|
50
|
+
return { error: true };
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
console.error(`[delphi-setup] ERROR: unknown argument "${args[i]}".`);
|
|
54
|
+
return { error: true };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const missing = DELPHI_ROLES.filter((role) => !models[role]);
|
|
59
|
+
if (missing.length > 0) {
|
|
60
|
+
console.error(`[delphi-setup] ERROR: missing --model for: ${missing.join(', ')}.`);
|
|
61
|
+
console.error('Usage: xp-gate delphi-setup --model architecture=<id> --model technical=<id> --model feasibility=<id> [--global|--local [--project <dir>]]');
|
|
62
|
+
return { error: true };
|
|
63
|
+
}
|
|
64
|
+
if (scope === 'local' && projectDir === undefined) {
|
|
65
|
+
console.error('[delphi-setup] ERROR: --local requires --project <dir> (the CLI process may not run inside the project).');
|
|
66
|
+
return { error: true };
|
|
67
|
+
}
|
|
68
|
+
const duplicates = (() => {
|
|
69
|
+
// Set-based check (#414 R1 FC-16): readable at any future role count.
|
|
70
|
+
const ids = Object.values(models);
|
|
71
|
+
return ids.length !== new Set(ids).size;
|
|
72
|
+
})();
|
|
73
|
+
if (duplicates) {
|
|
74
|
+
console.error('[delphi-setup] ERROR: the three experts must bind three DISTINCT model ids — a duplicate defeats the multi-model invariant before any review starts.');
|
|
75
|
+
return { error: true };
|
|
76
|
+
}
|
|
77
|
+
return { models, scope, projectDir };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Rebind only the model line; the rest of the bundled template (persona,
|
|
81
|
+
// tools, skills) stays authoritative from this version of xp-gate.
|
|
82
|
+
function rebindTemplate(templateBody, modelId) {
|
|
83
|
+
// Strip a UTF-8 BOM if present: lines[0] would otherwise be "\uFEFF---" and
|
|
84
|
+
// the frontmatter would read as missing, producing a misleading error.
|
|
85
|
+
if (templateBody.charCodeAt(0) === 0xfeff) templateBody = templateBody.slice(1);
|
|
86
|
+
const bindingLine = `model: "[${modelId}](${modelId})"`;
|
|
87
|
+
const lines = templateBody.split(/\r?\n/);
|
|
88
|
+
const start = lines.findIndex((l) => l.trim() === '---');
|
|
89
|
+
if (start !== 0) return null;
|
|
90
|
+
const end = lines.indexOf('---', 1);
|
|
91
|
+
if (end === -1) return null;
|
|
92
|
+
const bindingIndex = lines.findIndex((l, idx) => idx > start && idx < end && /^model:[ \t]/.test(l));
|
|
93
|
+
if (bindingIndex === -1) return null;
|
|
94
|
+
lines[bindingIndex] = bindingLine;
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function delphiSetup(args) {
|
|
99
|
+
const parsed = parseSetupArgs(args || []);
|
|
100
|
+
if (parsed.error) return 1;
|
|
101
|
+
const { models, scope, projectDir } = parsed;
|
|
102
|
+
|
|
103
|
+
const templatesDir = path.join(__dirname, '..', 'plugins', 'qoder', 'agents');
|
|
104
|
+
const targetDir = scope === 'local'
|
|
105
|
+
? path.join(projectDir, '.qoder', 'agents')
|
|
106
|
+
: path.join(HOME_DIR, '.qoder', 'agents');
|
|
107
|
+
|
|
108
|
+
// Validate every template BEFORE writing anything: a half-rebound set would
|
|
109
|
+
// leave the registry with a mix of old and new bindings on next session load.
|
|
110
|
+
const rebound = {};
|
|
111
|
+
for (const role of DELPHI_ROLES) {
|
|
112
|
+
const templatePath = path.join(templatesDir, `delphi-${role}.md`);
|
|
113
|
+
if (!fs.existsSync(templatePath)) {
|
|
114
|
+
console.error(`[delphi-setup] ERROR: bundled template missing: ${templatePath} — reinstall xp-gate.`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
const body = rebindTemplate(fs.readFileSync(templatePath, 'utf8'), models[role]);
|
|
118
|
+
if (body === null) {
|
|
119
|
+
console.error(`[delphi-setup] ERROR: bundled template for ${role} has no model binding line to replace.`);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
rebound[role] = body;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Back up existing agents before the first write (#414 R1 FC-04): this is
|
|
126
|
+
// the repair path, and a repair without a rollback is just a second way to
|
|
127
|
+
// lose a working binding.
|
|
128
|
+
const backupDir = path.join(CONFIG_DIR, 'backup', `delphi-setup-${Date.now()}`);
|
|
129
|
+
const backedUp = [];
|
|
130
|
+
for (const role of DELPHI_ROLES) {
|
|
131
|
+
const targetPath = path.join(targetDir, `delphi-${role}.md`);
|
|
132
|
+
if (fs.existsSync(targetPath)) {
|
|
133
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
134
|
+
fs.copyFileSync(targetPath, path.join(backupDir, `delphi-${role}.md`));
|
|
135
|
+
backedUp.push(role);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (backedUp.length > 0) {
|
|
139
|
+
console.log(` backed up existing agents -> ${backupDir} (${backedUp.join(', ')})`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
143
|
+
for (const role of DELPHI_ROLES) {
|
|
144
|
+
const targetPath = path.join(targetDir, `delphi-${role}.md`);
|
|
145
|
+
fs.writeFileSync(targetPath, rebound[role]);
|
|
146
|
+
console.log(` delphi-${role} -> ${targetPath} (model: ${models[role]})`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log('');
|
|
150
|
+
console.log('✓ Delphi agents rebound. NOW RELOAD: Qoder loads agent definitions only at');
|
|
151
|
+
console.log(' session start — 重开 Qoder 会话(或重启 IDE)后新绑定才生效,否则看起来像没修好。');
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { delphiSetup, parseSetupArgs, rebindTemplate };
|