@boyingliu01/xp-gate 0.27.4 → 0.27.6

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 (43) hide show
  1. package/adapters/gate-3.sh +59 -3
  2. package/adapters/gate-4.sh +73 -10
  3. package/adapters/gate-7.sh +4 -0
  4. package/adapters/powershell.sh +8 -0
  5. package/gate-10.sh +11 -0
  6. package/gate-3.sh +8 -0
  7. package/gate-4.sh +8 -0
  8. package/gate-7.sh +4 -0
  9. package/hooks/gate-10.sh +11 -0
  10. package/hooks/gate-3.sh +8 -0
  11. package/hooks/gate-4.sh +8 -0
  12. package/hooks/gate-7.sh +4 -0
  13. package/hooks/pre-commit +53 -11
  14. package/lib/__tests__/doctor-delphi.test.js +293 -244
  15. package/lib/doctor.js +46 -3
  16. package/lib/mattpocock-license-verdict.json +1 -1
  17. package/mock-policy/AGENTS.md +3 -3
  18. package/mutation/AGENTS.md +3 -3
  19. package/package.json +1 -1
  20. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  21. package/plugins/claude-code/skills/batch-grill-me/SKILL.md +9 -2
  22. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  23. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  24. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  25. package/plugins/dsh/package.json +1 -1
  26. package/plugins/dsh/skills/batch-grill-me/SKILL.md +9 -2
  27. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  28. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  29. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  30. package/plugins/opencode/package.json +1 -1
  31. package/plugins/opencode/skills/batch-grill-me/SKILL.md +9 -2
  32. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  33. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  34. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  35. package/plugins/qoder/plugin.json +1 -1
  36. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  37. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  38. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  39. package/principles/AGENTS.md +3 -3
  40. package/skills/batch-grill-me/SKILL.md +9 -2
  41. package/skills/delphi-review/AGENTS.md +3 -3
  42. package/skills/sprint-flow/AGENTS.md +3 -3
  43. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -1,244 +1,293 @@
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
- });
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
+ // #541 follow-up: the loaders fall back to the machine-wide global config
143
+ // ONLY on ENOENT, so a project-local file silently shadows the global one.
144
+ // When their seats differ, the effective model mix depends on cwd — exactly
145
+ // the cross-project drift class that burned quota (2026-10-03). Doctor must
146
+ // warn about the shadow; identical seats are a legitimate override.
147
+ it('#541 a project config whose seats differ from the global one WARNs as a shadow', async () => {
148
+ healthyHome();
149
+ const globalPath = path.join(tmpHome, '.config', 'xp-gate', '.delphi-config.json');
150
+ fs.mkdirSync(path.dirname(globalPath), { recursive: true });
151
+ writeConfig(globalPath);
152
+ const configPath = path.join(tmpHome, '.delphi-config.json');
153
+ fs.writeFileSync(configPath, JSON.stringify({
154
+ active_profile: 'default',
155
+ profiles: {
156
+ default: {
157
+ providers: { gw: { base_url: 'https://gw.example/v1', api_key: 'k' } },
158
+ experts: {
159
+ architecture: { provider: 'gw', model: 'other-a' },
160
+ technical: { provider: 'gw', model: 'model-b' },
161
+ feasibility: { provider: 'gw', model: 'model-c' },
162
+ },
163
+ },
164
+ },
165
+ }));
166
+ const logs = [];
167
+ const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
168
+ const { doctorDelphi } = require('../doctor');
169
+ const code = await doctorDelphi({ projectDir: tmpHome, configPath, globalConfigPath: globalPath, npmLatest: null });
170
+ logSpy.mockRestore();
171
+ // A warning, not a failure: an intentional per-project override is legal.
172
+ expect(code).toBe(0);
173
+ expect(logs.join('\n')).toMatch(/shadow/i);
174
+ });
175
+
176
+ it('#541 a project config with seats identical to the global one does not warn', async () => {
177
+ healthyHome();
178
+ const globalPath = path.join(tmpHome, '.config', 'xp-gate', '.delphi-config.json');
179
+ fs.mkdirSync(path.dirname(globalPath), { recursive: true });
180
+ writeConfig(globalPath);
181
+ const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
182
+ const logs = [];
183
+ const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
184
+ const { doctorDelphi } = require('../doctor');
185
+ const code = await doctorDelphi({ projectDir: tmpHome, configPath, globalConfigPath: globalPath, npmLatest: null });
186
+ logSpy.mockRestore();
187
+ expect(code).toBe(0);
188
+ expect(logs.join('\n')).not.toMatch(/shadow/i);
189
+ });
190
+
191
+ it('a missing config FAILs with the copy-the-example hint', async () => {
192
+ healthyHome();
193
+ const logs = [];
194
+ const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
195
+ const { doctorDelphi } = require('../doctor');
196
+ await doctorDelphi({ projectDir: tmpHome, configPath: path.join(tmpHome, 'absent.json'), npmLatest: null });
197
+ logSpy.mockRestore();
198
+ expect(logs.join('\n')).toMatch(/\.delphi-config\.json/);
199
+ });
200
+
201
+ it('npmLatest=null skips the version check without failing the run', async () => {
202
+ healthyHome();
203
+ const { doctorDelphi } = require('../doctor');
204
+ const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
205
+ const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
206
+ expect(code).toBe(0);
207
+ });
208
+
209
+ // R1 FC-07: the switch shipping off is the platform default, not a defect —
210
+ // only enabled-but-underprovisioned is a real FAIL.
211
+ it('DSH with subagentModelSelection absent is SKIP, not FAIL', async () => {
212
+ healthyHome();
213
+ fs.mkdirSync(path.join(tmpHome, '.dsh'), { recursive: true });
214
+ fs.writeFileSync(path.join(tmpHome, '.dsh', 'settings.json'), JSON.stringify({ other: true }));
215
+ const { doctorDelphi } = require('../doctor');
216
+ const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
217
+ const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
218
+ expect(code).toBe(0);
219
+ });
220
+
221
+ // R1 FC-09: a project-level agent set (delphi-setup --local --project) is
222
+ // what the doctor reads, so the repair loop closes.
223
+ it('a complete project-level agent set takes precedence over the HOME set', async () => {
224
+ healthyHome();
225
+ const projDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'doctor-delphi-proj-'));
226
+ try {
227
+ fs.mkdirSync(path.join(projDir, '.qoder', 'agents'), { recursive: true });
228
+ for (const role of ['architecture', 'technical', 'feasibility']) {
229
+ fs.copyFileSync(
230
+ path.join(repoRoot(), 'plugins', 'qoder', 'agents', `delphi-${role}.md`),
231
+ path.join(projDir, '.qoder', 'agents', `delphi-${role}.md`),
232
+ );
233
+ }
234
+ // Corrupt the HOME set; the project set must win and the run stays green.
235
+ fs.writeFileSync(
236
+ path.join(tmpHome, '.qoder', 'agents', 'delphi-technical.md'),
237
+ '---\nname: delphi-technical\nmodel: "[Retired](retired-id)"\n---\n# body\n',
238
+ );
239
+ const { doctorDelphi } = require('../doctor');
240
+ const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
241
+ const code = await doctorDelphi({
242
+ projectDir: projDir, configPath, npmLatest: null,
243
+ });
244
+ expect(code).toBe(0);
245
+ } finally {
246
+ fs.rmSync(projDir, { recursive: true, force: true });
247
+ }
248
+ });
249
+
250
+ // R1 MI-03: parity covers the whole bundled file set, so a stale references/
251
+ // copy cannot hide behind a matching SKILL.md.
252
+ it('a drifted references file FAILs parity even when SKILL.md matches', async () => {
253
+ healthyHome();
254
+ // R2 MJ-R2-01: the assertion must be the ONLY way this test can fail. An
255
+ // absent config would exit 1 regardless of parity, so the fixture supplies
256
+ // a valid one and lets the drifted file carry the verdict.
257
+ const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
258
+ const refsDir = path.join(bundledSkillDir(), 'references');
259
+ const refFile = fs.existsSync(refsDir)
260
+ ? fs.readdirSync(refsDir).find((f) => f.endsWith('.md'))
261
+ : null;
262
+ expect(refFile).toBeTruthy(); // explicit: without a bundled reference this test has nothing to say
263
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills', 'delphi-review', 'references'), { recursive: true });
264
+ fs.appendFileSync(
265
+ path.join(tmpHome, '.qoder', 'skills', 'delphi-review', 'references', refFile),
266
+ '\ndrift\n',
267
+ );
268
+ const { doctorDelphi } = require('../doctor');
269
+ const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
270
+ expect(code).toBe(1);
271
+ });
272
+
273
+ // R3 FC-01/FC-02: the empty-bundle branch is driven directly through the
274
+ // injectable bundledSkillDir — the healthy fixture cannot exercise it, and
275
+ // an indirect output-shape assertion would pass even without the fix.
276
+ it('an empty bundled skill directory FAILs parity instead of passing vacuously', async () => {
277
+ healthyHome();
278
+ const emptyBundle = fs.mkdtempSync(path.join(require('os').tmpdir(), 'doctor-empty-bundle-'));
279
+ try {
280
+ const logs = [];
281
+ const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
282
+ const { doctorDelphi } = require('../doctor');
283
+ const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
284
+ const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null, bundledSkillDir: emptyBundle });
285
+ logSpy.mockRestore();
286
+ expect(code).toBe(1);
287
+ const parityLine = logs.find((l) => l.includes('Delphi skill install parity'));
288
+ expect(parityLine).toContain('missing or empty');
289
+ } finally {
290
+ fs.rmSync(emptyBundle, { recursive: true, force: true });
291
+ }
292
+ });
293
+ });
package/lib/doctor.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { execSync, spawnSync } = require('child_process');
3
+ const { execSync, execFileSync, spawnSync } = require('child_process');
4
4
  const { exec } = require('child_process');
5
5
  const util = require('util');
6
6
  const execAsync = util.promisify(exec);
@@ -1959,7 +1959,11 @@ function fixMissingHooks(mode, srcDir, hooksDir) {
1959
1959
  */
1960
1960
  function fixCoreHooksPath(globalHooksDir) {
1961
1961
  try {
1962
- execSync(`git config --global core.hooksPath "${globalHooksDir}"`, {
1962
+ // Args array instead of shell string interpolation: the value is a path
1963
+ // that must never be re-parsed by a shell. Also keeps semgrep's
1964
+ // detect-child-process rule from flagging this audited-safe call
1965
+ // (same baseline as .semgrepignore).
1966
+ execFileSync('git', ['config', '--global', 'core.hooksPath', globalHooksDir], {
1963
1967
  stdio: ['pipe', 'pipe', 'pipe'],
1964
1968
  timeout: EXEC_TIMEOUT_MS,
1965
1969
  });
@@ -2684,6 +2688,11 @@ function formatDoctorJson(checks, issues) {
2684
2688
  // runner script surfaced only as a failed review (B3).
2685
2689
  async function doctorDelphi(options = {}) {
2686
2690
  const { configPath, npmLatest } = options;
2691
+ // #541: the shadow check needs the loader's global fallback path. It is
2692
+ // injectable for tests; the default mirrors delphi-config.ts /
2693
+ // delphi-external-review.cjs resolution (XDG_CONFIG_HOME honored).
2694
+ const globalConfigPath = options.globalConfigPath
2695
+ || path.join(process.env.XDG_CONFIG_HOME || path.join(require('os').homedir(), '.config'), 'xp-gate', '.delphi-config.json');
2687
2696
  const checks = [];
2688
2697
  const add = (name, status, detail) => checks.push({ name, status, detail });
2689
2698
 
@@ -2856,6 +2865,40 @@ async function doctorDelphi(options = {}) {
2856
2865
  add('Delphi config (.delphi-config.json)', 'PASS',
2857
2866
  `profile "${profileName}": ${models.join(' / ')}`);
2858
2867
  }
2868
+ // #541 follow-up: shadow detection. Loaders fall back to the global
2869
+ // config ONLY on ENOENT, so this project file always wins here. If the
2870
+ // machine-wide config exists AND seats differ, the effective model mix
2871
+ // depends on cwd — the cross-project drift class behind the 2026-10-03
2872
+ // quota burn. Warn (intentional overrides are legal), never fail.
2873
+ try {
2874
+ if (fs.existsSync(globalConfigPath) && path.resolve(globalConfigPath) !== path.resolve(cfgPath)) {
2875
+ const gRaw = JSON.parse(fs.readFileSync(globalConfigPath, 'utf8'));
2876
+ const gProfileName = gRaw.active_profile || 'default';
2877
+ const gProfile = gRaw.profiles && gRaw.profiles[gProfileName];
2878
+ const gExperts = (gProfile && gProfile.experts) || {};
2879
+ // Seat shape is object-keyed OR array in the wild (delphi-config.ts
2880
+ // accepts both); normalize to a role->model map before comparing.
2881
+ const pickExpert = (experts, role, idx) => {
2882
+ if (!experts) return null;
2883
+ if (Array.isArray(experts)) {
2884
+ const e = experts.find((x) => x && x.role === role) || experts[idx];
2885
+ return (e && e.model) || null;
2886
+ }
2887
+ return (experts[role] && experts[role].model) || null;
2888
+ };
2889
+ const gModels = roles.map((r, i) => pickExpert(gExperts, r, i));
2890
+ // Compare only roles BOTH sides define: a role missing on one side
2891
+ // is an incomplete config (check 5 already FAILs it), not drift.
2892
+ const differing = roles.filter((r, i) => gModels[i] && models[i] && gModels[i] !== models[i]);
2893
+ if (differing.length > 0) {
2894
+ add('Delphi config shadow (#541)', 'WARN',
2895
+ `project config shadows ${globalConfigPath}; seats differ (${differing.join(', ')}) — reviews run from other directories will use different models`);
2896
+ }
2897
+ }
2898
+ } catch {
2899
+ // A broken/unreadable global config is not this check's failure mode;
2900
+ // silence here must not mask it elsewhere, so nothing is claimed.
2901
+ }
2859
2902
  }
2860
2903
  } catch (err) {
2861
2904
  add('Delphi config (.delphi-config.json)', 'FAIL', `unparseable: ${err.message}`);
@@ -2926,7 +2969,7 @@ async function doctorDelphi(options = {}) {
2926
2969
  }
2927
2970
 
2928
2971
  for (const check of checks) {
2929
- const mark = check.status === 'PASS' ? '✓' : check.status === 'SKIP' ? '○' : '✗';
2972
+ const mark = check.status === 'PASS' ? '✓' : check.status === 'SKIP' ? '○' : check.status === 'WARN' ? '⚠' : '✗';
2930
2973
  console.log(` ${mark} ${check.name}: ${check.detail || check.status}`);
2931
2974
  }
2932
2975
  const failed = checks.filter((c) => c.status === 'FAIL').length;
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-17T15:37:03.841Z",
5
+ "checked_at": "2026-10-18T12:56:33.155Z",
6
6
  "source": "network"
7
7
  }