@boyingliu01/xp-gate 0.23.0 → 0.25.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 (61) hide show
  1. package/adapter-common.sh +51 -2
  2. package/adapters/python.sh +10 -2
  3. package/bin/xp-gate.js +15 -1
  4. package/hooks/adapter-common.sh +51 -2
  5. package/hooks/lib/coverage-total.sh +20 -0
  6. package/hooks/lib/python-test-selection.sh +132 -0
  7. package/hooks/pre-commit +100 -11
  8. package/lib/__tests__/delphi-setup.test.js +203 -0
  9. package/lib/__tests__/doctor-delphi.test.js +244 -0
  10. package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
  11. package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
  12. package/lib/__tests__/qoder-delphi-agents.test.js +31 -0
  13. package/lib/delphi-setup.js +155 -0
  14. package/lib/doctor.js +269 -1
  15. package/lib/init.js +49 -1
  16. package/lib/mattpocock-license-verdict.json +1 -1
  17. package/lib/phase-transition.js +61 -0
  18. package/lib/sprint-state-manager.js +86 -39
  19. package/lib/sprint-state-migrator.js +60 -22
  20. package/mock-policy/AGENTS.md +3 -3
  21. package/mutation/AGENTS.md +3 -3
  22. package/mutation/__tests__/gremlins-runner.test.ts +167 -0
  23. package/mutation/__tests__/mutmut-runner.test.ts +20 -22
  24. package/mutation/__tests__/runners-index.test.ts +31 -3
  25. package/mutation/gate-m.ts +22 -4
  26. package/mutation/runners/gremlins-runner.ts +238 -0
  27. package/mutation/runners/index.ts +9 -1
  28. package/mutation/runners/mutmut-runner.ts +18 -12
  29. package/mutation/runners/types.ts +20 -0
  30. package/package.json +1 -1
  31. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  32. package/plugins/claude-code/skills/delphi-review/.delphi-config.json.example +24 -1
  33. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  34. package/plugins/claude-code/skills/delphi-review/SKILL.md +43 -0
  35. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  36. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  37. package/plugins/dsh/package.json +1 -1
  38. package/plugins/dsh/skills/delphi-review/.delphi-config.json.example +24 -1
  39. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/dsh/skills/delphi-review/SKILL.md +43 -0
  41. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  42. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  43. package/plugins/opencode/package.json +1 -1
  44. package/plugins/opencode/skills/delphi-review/.delphi-config.json.example +24 -1
  45. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  46. package/plugins/opencode/skills/delphi-review/SKILL.md +43 -0
  47. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  48. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  49. package/plugins/qoder/plugin.json +1 -1
  50. package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +24 -1
  51. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  52. package/plugins/qoder/skills/delphi-review/SKILL.md +43 -0
  53. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  54. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  55. package/principles/AGENTS.md +3 -3
  56. package/scripts/delphi-external-review.cjs +166 -14
  57. package/skills/delphi-review/.delphi-config.json.example +24 -1
  58. package/skills/delphi-review/AGENTS.md +3 -3
  59. package/skills/delphi-review/SKILL.md +43 -0
  60. package/skills/sprint-flow/AGENTS.md +3 -3
  61. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,233 @@
1
+ /**
2
+ * @intent #519: A real pointer-drift incident (#507) showed that a MISSED
3
+ * phase transition is invisible — the state file cannot distinguish
4
+ * "never ran" from "not yet advanced". Guards under test:
5
+ * (1) sequence guard: `completed` on phase n is refused unless phase
6
+ * n-1 is completed/skipped, with `--force` as the explicit escape;
7
+ * (2) before/after echo so "command didn't run" and "ran without
8
+ * effect" are distinguishable in the output;
9
+ * (3) an append-only transition audit log;
10
+ * (4) phase_history field normalization (`name` → `phase_name`).
11
+ * @covers handlePhaseTransition guard/echo, appendTransitionAudit, migrateState
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import os from 'node:os';
18
+ import { handlePhaseTransition } from '../phase-transition.js';
19
+ import { SprintStateManager } from '../sprint-state-manager.js';
20
+ import { migrateState } from '../sprint-state-migrator.js';
21
+
22
+ function writeState(dir, state) {
23
+ try {
24
+ fs.mkdirSync(path.join(dir, '.sprint-state'), { recursive: true });
25
+ fs.writeFileSync(
26
+ path.join(dir, '.sprint-state', 'sprint-state.json'),
27
+ JSON.stringify(state, null, 2),
28
+ );
29
+ } catch (err) {
30
+ throw new Error(`writeState failed for ${dir}: ${err.message}`);
31
+ }
32
+ }
33
+
34
+ function baseState(overrides = {}) {
35
+ return {
36
+ _schema_version: 1,
37
+ id: 'sprint-519',
38
+ task_description: 'test',
39
+ phase: 2,
40
+ status: 'in_progress',
41
+ started_at: '2026-10-14T00:00:00Z',
42
+ phase_history: [],
43
+ isolation: { worktree_path: null, branch: null },
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ describe('#519 phase-transition sequence guard', () => {
49
+ let dir;
50
+
51
+ beforeEach(() => {
52
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519-'));
53
+ });
54
+
55
+ afterEach(() => {
56
+ fs.rmSync(dir, { recursive: true, force: true });
57
+ });
58
+
59
+ it('refuses `completed` on phase 3 when phase 2 is not completed/skipped', async () => {
60
+ writeState(dir, baseState({
61
+ phase: 3,
62
+ phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'in_progress' }],
63
+ }));
64
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
65
+ const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
66
+ const stderr = errSpy.mock.calls.map(c => c.join(' ')).join('\n');
67
+ errSpy.mockRestore();
68
+ expect(code).toBe(1);
69
+ expect(stderr).toContain('Cannot complete phase 3');
70
+ expect(stderr).toContain('phase 2');
71
+ expect(stderr).toContain('phase history');
72
+ });
73
+
74
+ it('refuses the jump and names --force as the override', async () => {
75
+ writeState(dir, baseState({ phase: 3, phase_history: [] }));
76
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
77
+ await handlePhaseTransition(['5', 'completed', '--dir', dir, '--skip-evidence', 'x']);
78
+ const stderr = errSpy.mock.calls.map(c => c.join(' ')).join('\n');
79
+ errSpy.mockRestore();
80
+ expect(stderr).toContain('--force');
81
+ });
82
+
83
+ it('allows `completed` when the previous phase is completed', async () => {
84
+ writeState(dir, baseState({
85
+ phase: 2,
86
+ phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'completed' }],
87
+ }));
88
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
89
+ const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
90
+ outSpy.mockRestore();
91
+ expect(code).toBe(0);
92
+ });
93
+
94
+ it('treats a skipped previous phase as valid', async () => {
95
+ writeState(dir, baseState({
96
+ phase: 2,
97
+ phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'skipped' }],
98
+ }));
99
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
100
+ const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
101
+ outSpy.mockRestore();
102
+ expect(code).toBe(0);
103
+ });
104
+
105
+ it('--force overrides the guard and warns', async () => {
106
+ writeState(dir, baseState({ phase: 3, phase_history: [] }));
107
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
108
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
109
+ const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x', '--force']);
110
+ const warned = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
111
+ outSpy.mockRestore();
112
+ warnSpy.mockRestore();
113
+ expect(code).toBe(0);
114
+ expect(warned).toContain('overridden');
115
+ });
116
+
117
+ it('does not guard phase 1 (no predecessor)', async () => {
118
+ writeState(dir, baseState({ phase: 1, phase_history: [] }));
119
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
120
+ const code = await handlePhaseTransition(['1', 'completed', '--dir', dir, '--skip-evidence', 'x']);
121
+ outSpy.mockRestore();
122
+ expect(code).toBe(0);
123
+ });
124
+
125
+ it('does not guard non-completed statuses', async () => {
126
+ writeState(dir, baseState({ phase: 3, phase_history: [] }));
127
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
128
+ const code = await handlePhaseTransition(['3', 'in_progress', '--dir', dir]);
129
+ outSpy.mockRestore();
130
+ expect(code).toBe(0);
131
+ });
132
+ });
133
+
134
+ describe('#519 before/after echo', () => {
135
+ let dir;
136
+ beforeEach(() => {
137
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519echo-'));
138
+ });
139
+ afterEach(() => {
140
+ fs.rmSync(dir, { recursive: true, force: true });
141
+ });
142
+
143
+ it('prints before→after for the top-level phase/status and the entry', async () => {
144
+ // Phase 1 must be completed for the phase-2 completion to pass the
145
+ // sequence guard (#519) — otherwise the echo is never reached.
146
+ writeState(dir, baseState({
147
+ phase: 2,
148
+ status: 'in_progress',
149
+ phase_history: [
150
+ { phase: 1, name: 'PREP', status: 'completed' },
151
+ { phase: 2, phase_name: 'DESIGN', status: 'in_progress' },
152
+ ],
153
+ }));
154
+ const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
155
+ const code = await handlePhaseTransition(['2', 'completed', '--dir', dir, '--skip-evidence', 'x']);
156
+ const output = outSpy.mock.calls.map(c => c.join(' ')).join('\n');
157
+ outSpy.mockRestore();
158
+ expect(code).toBe(0);
159
+ expect(output).toContain('before→after');
160
+ expect(output).toContain('in_progress → completed');
161
+ });
162
+ });
163
+
164
+ describe('#519 transition audit log + field normalization', () => {
165
+ let dir;
166
+ beforeEach(() => {
167
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519audit-'));
168
+ });
169
+ afterEach(() => {
170
+ fs.rmSync(dir, { recursive: true, force: true });
171
+ });
172
+
173
+ it('appends one JSON line per transition with from/to', () => {
174
+ writeState(dir, baseState({ phase: 2, phase_history: [] }));
175
+ const manager = new SprintStateManager(dir);
176
+ manager.transitionPhase(2, 'in_progress');
177
+ manager.transitionPhase(2, 'completed');
178
+ const auditFile = path.join(dir, '.sprint-state', 'transition-audit.jsonl');
179
+ expect(fs.existsSync(auditFile)).toBe(true);
180
+ const lines = fs.readFileSync(auditFile, 'utf8').trim().split('\n');
181
+ expect(lines.length).toBe(2);
182
+ const first = JSON.parse(lines[0]);
183
+ expect(first.phase).toBe(2);
184
+ expect(first.phase_name).toBe('DESIGN');
185
+ expect(first.to_status).toBe('in_progress');
186
+ expect(first.timestamp).toBeTruthy();
187
+ const second = JSON.parse(lines[1]);
188
+ expect(second.to_status).toBe('completed');
189
+ });
190
+
191
+ it('audit is best-effort: a write failure does not break the transition', () => {
192
+ writeState(dir, baseState({ phase: 2, phase_history: [] }));
193
+ const manager = new SprintStateManager(dir);
194
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
195
+ // Break the real appendFileSync so appendTransitionAudit's OWN try/catch is
196
+ // exercised (mocking the method itself would bypass the production guard).
197
+ const appendSpy = vi.spyOn(fs, 'appendFileSync').mockImplementation(() => {
198
+ throw new Error('disk full');
199
+ });
200
+ expect(() => manager.transitionPhase(2, 'in_progress')).not.toThrow();
201
+ const warned = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
202
+ appendSpy.mockRestore();
203
+ warnSpy.mockRestore();
204
+ expect(warned).toContain('transition audit write failed');
205
+ });
206
+
207
+ it('migrateState normalizes legacy `name` into `phase_name`', () => {
208
+ // A legacy file (no _schema_version) whose phase 1 entry carries `name`
209
+ // rather than `phase_name` — the exact shape #507 left behind.
210
+ const legacy = baseState({
211
+ _schema_version: undefined,
212
+ phase_history: [{ phase: 1, name: 'PREP', status: 'completed' }],
213
+ });
214
+ const migrated = migrateState(legacy, dir);
215
+ const entry = migrated.phase_history[0];
216
+ // The canonical field is populated (via the legacy remap and/or the
217
+ // name→phase_name normalization) and the legacy field is preserved.
218
+ expect(entry.phase_name).toBeTruthy();
219
+ expect(entry.name).toBe('PREP');
220
+ });
221
+
222
+ it('migrateState backfills `name` from `phase_name` (both directions)', () => {
223
+ const legacy = baseState({
224
+ _schema_version: undefined,
225
+ phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'in_progress' }],
226
+ });
227
+ const migrated = migrateState(legacy, dir);
228
+ const entry = migrated.phase_history[0];
229
+ expect(entry.phase_name).toBeTruthy();
230
+ // `name` is backfilled so a reader consulting either field agrees.
231
+ expect(entry.name).toBe(entry.phase_name);
232
+ });
233
+ });
@@ -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 };