@boyingliu01/xp-gate 0.22.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.
Files changed (54) hide show
  1. package/bin/xp-gate.js +15 -1
  2. package/hooks/pre-push +20 -2
  3. package/lib/__tests__/delphi-setup.test.js +203 -0
  4. package/lib/__tests__/doctor-delphi.test.js +244 -0
  5. package/lib/__tests__/qoder-delphi-agents.test.js +31 -0
  6. package/lib/delphi-setup.js +155 -0
  7. package/lib/doctor.js +269 -1
  8. package/lib/init.js +4 -1
  9. package/lib/mattpocock-license-verdict.json +1 -1
  10. package/mock-policy/AGENTS.md +3 -3
  11. package/mutation/AGENTS.md +3 -3
  12. package/package.json +1 -1
  13. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  14. package/plugins/claude-code/skills/delphi-review/.delphi-config.json.example +24 -1
  15. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  16. package/plugins/claude-code/skills/delphi-review/SKILL.md +43 -0
  17. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  18. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  19. package/plugins/dsh/package.json +1 -1
  20. package/plugins/dsh/skills/delphi-review/.delphi-config.json.example +24 -1
  21. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  22. package/plugins/dsh/skills/delphi-review/SKILL.md +43 -0
  23. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  24. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  25. package/plugins/opencode/package.json +1 -1
  26. package/plugins/opencode/skills/delphi-review/.delphi-config.json.example +24 -1
  27. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  28. package/plugins/opencode/skills/delphi-review/SKILL.md +43 -0
  29. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  30. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  31. package/plugins/qoder/plugin.json +1 -1
  32. package/plugins/qoder/skills/admin-template-guidelines/SKILL.md +1 -0
  33. package/plugins/qoder/skills/batch-grill-me/SKILL.md +1 -0
  34. package/plugins/qoder/skills/clipboard-vision/SKILL.md +1 -0
  35. package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +24 -1
  36. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  37. package/plugins/qoder/skills/delphi-review/SKILL.md +43 -0
  38. package/plugins/qoder/skills/domain-modeling/SKILL.md +1 -0
  39. package/plugins/qoder/skills/grill-with-docs/SKILL.md +1 -0
  40. package/plugins/qoder/skills/grilling/SKILL.md +1 -0
  41. package/plugins/qoder/skills/improve-codebase-architecture/SKILL.md +1 -0
  42. package/plugins/qoder/skills/ralph-loop/SKILL.md +1 -0
  43. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  44. package/plugins/qoder/skills/test-driven-development/SKILL.md +1 -0
  45. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  46. package/plugins/qoder/skills/test-specification-alignment/SKILL.md +1 -0
  47. package/plugins/qoder/skills/to-issues/SKILL.md +1 -0
  48. package/principles/AGENTS.md +3 -3
  49. package/scripts/delphi-external-review.cjs +166 -14
  50. package/skills/delphi-review/.delphi-config.json.example +24 -1
  51. package/skills/delphi-review/AGENTS.md +3 -3
  52. package/skills/delphi-review/SKILL.md +43 -0
  53. package/skills/sprint-flow/AGENTS.md +3 -3
  54. 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 => doctor(subargs).then(code => process.exit(code)),
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': {
package/hooks/pre-push CHANGED
@@ -49,7 +49,7 @@ report = {
49
49
  'verdict': '$verdict',
50
50
  },
51
51
  'gates': [
52
- {'id': 'MP', 'name': 'Gate-Script Mirror Parity', 'status': '${GATE_MP_STATUS:-SKIP}', 'details': {}},
52
+ {'id': 'MP', 'name': 'Gate-Script Mirror Parity', 'status': '${GATE_MP_STATUS:-SKIP}', 'details': {'duration_ms': '${MP_DURATION_MS:-0}'}},
53
53
  {'id': '10', 'name': 'Build Integrity', 'status': '${GATE_10_STATUS:-SKIP}', 'details': {}},
54
54
  {'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': {}},
55
55
  {'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PYTHON_STATUS:-SKIP}', 'details': {}},
@@ -179,12 +179,29 @@ fi
179
179
  # guard runs here (fail-closed) and in CI's mirror-parity job. Read-only:
180
180
  # compares bytes and git index modes; heals nothing.
181
181
  GATE_MP_STATUS="SKIP"
182
+ # Duration is audited in MILLISECONDS end-to-end (#521 AC-2; the #524 lesson:
183
+ # never mix a seconds timestamp into a ms budget). The output is validated as
184
+ # bare digits before use: BSD/macOS date does not support %3N and prints the
185
+ # literal '...%3N' with exit 0, which would blow up the arithmetic and kill
186
+ # pre-push — a non-numeric read degrades to 0 (walkthrough MJ-01).
187
+ _mp_now_ms() {
188
+ local _t
189
+ _t="$(date +%s%3N 2>/dev/null || true)"
190
+ case "$_t" in
191
+ ''|*[!0-9]*) echo 0 ;;
192
+ *) echo "$_t" ;;
193
+ esac
194
+ }
195
+ MP_START_MS="$(_mp_now_ms)"
182
196
  MP_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
183
197
  if [ -f "$MP_ROOT/scripts/check-hook-mirror.sh" ]; then
184
198
  if (cd "$MP_ROOT" && bash scripts/check-hook-mirror.sh); then
185
199
  GATE_MP_STATUS="PASS"
186
- echo "✅ Gate MP: gate-script mirror parity OK"
200
+ MP_DURATION_MS=$(( $(_mp_now_ms) - MP_START_MS ))
201
+ [ "$MP_DURATION_MS" -lt 0 ] 2>/dev/null && MP_DURATION_MS=0
202
+ echo "✅ Gate MP: gate-script mirror parity OK (${MP_DURATION_MS}ms)"
187
203
  else
204
+ MP_DURATION_MS=0
188
205
  echo ""
189
206
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
190
207
  echo " ❌ GATE MP FAILED — PUSH BLOCKED"
@@ -196,6 +213,7 @@ if [ -f "$MP_ROOT/scripts/check-hook-mirror.sh" ]; then
196
213
  fi
197
214
  else
198
215
  echo "⚠️ Gate MP: check-hook-mirror.sh not found. SKIP — Gate MP."
216
+ MP_DURATION_MS=0
199
217
  fi
200
218
  echo ""
201
219
 
@@ -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
  });