@boyingliu01/xp-gate 0.35.12 → 0.35.14
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/lib/__tests__/delphi-channel-doctor.test.js +116 -0
- package/lib/__tests__/delphi-channel-uninstall.test.js +128 -0
- package/lib/__tests__/delphi-channel.test.js +109 -0
- package/lib/delphi-channel.js +181 -0
- package/lib/delphi-setup.js +66 -1
- package/lib/doctor.js +36 -6
- package/lib/init.js +15 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/delphi-review/SKILL.md +32 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/lib/index.d.ts +10 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -1
- package/plugins/dsh/lib/index.js +10 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/delphi-review/SKILL.md +32 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/src/index.test.ts +44 -2
- package/plugins/dsh/src/index.ts +10 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/delphi-review/SKILL.md +32 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/delphi-review/SKILL.md +32 -0
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/delphi-review/SKILL.md +32 -0
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-646
|
|
3
|
+
* @intent 通道偏好必须**真正改变行为**,而不只是被读进来。这里跑的是真实的
|
|
4
|
+
* `doctorDelphi()`,检查它输出的 Qoder agent bindings 那一行。
|
|
5
|
+
*
|
|
6
|
+
* 问题原状:外部通道用户删掉三个 agent 之后得到 `✗ Qoder Delphi agent
|
|
7
|
+
* bindings: ... missing (run: xp-gate init --global)`。那是一条**会把机制
|
|
8
|
+
* 重新装回来**的修复提示,而且判它 FAIL 等于告诉用户"你的可用配置坏了"。
|
|
9
|
+
*
|
|
10
|
+
* 所以这里钉的是两种状态的**区分**:配置关掉 -> SKIP 且理由写清来源;
|
|
11
|
+
* 没配置 -> 该 FAIL 的照旧 FAIL。只钉前者会造出一个"永远跳过"的守卫,
|
|
12
|
+
* 那和永远通过没有区别。
|
|
13
|
+
* @covers AC-646-02, AC-646-04
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
|
22
|
+
const doctor = require(path.join(REPO_ROOT, 'src/npm-package/lib/doctor.js'));
|
|
23
|
+
|
|
24
|
+
/** The single check whose behaviour #646 changes. */
|
|
25
|
+
const BINDING_CHECK = 'Qoder Delphi agent bindings';
|
|
26
|
+
|
|
27
|
+
describe('doctor 在 external-only 通道下的判定 (#646)', () => {
|
|
28
|
+
let tmpDir;
|
|
29
|
+
let originalEnv;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
originalEnv = { ...process.env };
|
|
33
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-doctor-channel-'));
|
|
34
|
+
// Point both the home dir and the XDG config root at the sandbox so the real
|
|
35
|
+
// machine configuration can never leak into these assertions.
|
|
36
|
+
process.env.HOME = tmpDir;
|
|
37
|
+
process.env.USERPROFILE = tmpDir;
|
|
38
|
+
process.env.XDG_CONFIG_HOME = path.join(tmpDir, '.config');
|
|
39
|
+
delete process.env.XP_GATE_DELPHI_CHANNEL;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
process.env = { ...originalEnv };
|
|
44
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function writeMachineConfig(body) {
|
|
48
|
+
const dir = path.join(tmpDir, '.config', 'xp-gate');
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
fs.writeFileSync(path.join(dir, '.delphi-config.json'), JSON.stringify(body), 'utf8');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run the real `doctorDelphi()` and capture the line it prints.
|
|
55
|
+
*
|
|
56
|
+
* The function prints its own report and returns an exit code, so the assertion has
|
|
57
|
+
* to go through stdout -- that is also the surface a user actually reads, which is
|
|
58
|
+
* what #646 is about. Returns `undefined` when the check is absent from the report.
|
|
59
|
+
*/
|
|
60
|
+
async function runChecks() {
|
|
61
|
+
const written = [];
|
|
62
|
+
const originalLog = console.log;
|
|
63
|
+
const originalWarn = console.warn;
|
|
64
|
+
console.log = (...args) => written.push(args.join(' '));
|
|
65
|
+
console.warn = (...args) => written.push(args.join(' '));
|
|
66
|
+
try {
|
|
67
|
+
await doctor.doctorDelphi({ projectDir: tmpDir });
|
|
68
|
+
} finally {
|
|
69
|
+
console.log = originalLog;
|
|
70
|
+
console.warn = originalWarn;
|
|
71
|
+
}
|
|
72
|
+
const line = written.find((l) => l.includes(BINDING_CHECK));
|
|
73
|
+
if (line === undefined) return undefined;
|
|
74
|
+
// doctor prints a GLYPH, never the status word: "✓" PASS, "✗" FAIL, "○" SKIP.
|
|
75
|
+
// Matching on the word "SKIP" instead would read the ○ line as PASS, since its
|
|
76
|
+
// detail sentence contains no FAIL marker -- i.e. the assertion would pass for
|
|
77
|
+
// exactly the wrong reason.
|
|
78
|
+
let status = 'PASS';
|
|
79
|
+
if (line.includes('○')) status = 'SKIP';
|
|
80
|
+
else if (line.includes('✗')) status = 'FAIL';
|
|
81
|
+
return { status, detail: line };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
it('AC-646-02: external-only reports SKIP, not FAIL, and names the source', async () => {
|
|
85
|
+
writeMachineConfig({ channels: ['external'] });
|
|
86
|
+
const check = await runChecks();
|
|
87
|
+
expect(check).toBeDefined();
|
|
88
|
+
expect(check.status).toBe('SKIP');
|
|
89
|
+
expect(check.detail).toMatch(/native channel disabled/);
|
|
90
|
+
// The reason must name where the setting came from: an unexplained SKIP is
|
|
91
|
+
// indistinguishable from a broken install.
|
|
92
|
+
expect(check.detail).toMatch(/via config/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('AC-646-02: the environment override is honoured by doctor too', async () => {
|
|
96
|
+
process.env.XP_GATE_DELPHI_CHANNEL = 'external';
|
|
97
|
+
const check = await runChecks();
|
|
98
|
+
expect(check.status).toBe('SKIP');
|
|
99
|
+
expect(check.detail).toMatch(/via env/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('AC-646-04: with no preference the check still FAILs — the guard is not a permanent skip', async () => {
|
|
103
|
+
// No agent files exist in the sandbox, and no channel preference disables the
|
|
104
|
+
// native path, so the original FAIL must survive. If this ever turns SKIP, the
|
|
105
|
+
// #646 fix has swallowed the check instead of gating it.
|
|
106
|
+
const check = await runChecks();
|
|
107
|
+
expect(check).toBeDefined();
|
|
108
|
+
expect(check.status).toBe('FAIL');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('AC-646-02: an explicit native channel keeps the check active', async () => {
|
|
112
|
+
writeMachineConfig({ channels: ['native', 'external'] });
|
|
113
|
+
const check = await runChecks();
|
|
114
|
+
expect(check.status).toBe('FAIL');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-646
|
|
3
|
+
* @intent `--uninstall` 是唯一一个**会删文件**的 Delphi 命令,所以它的测试必须
|
|
4
|
+
* 只在沙箱里跑,而且必须钉住"删掉之后 init 不会再装回来"这条闭环——
|
|
5
|
+
* 否则用户删一次、升级一次、机制又回来了,#646 原样复发。
|
|
6
|
+
*
|
|
7
|
+
* 另一个必须钉住的性质是**幂等**:第二次运行不能报错。
|
|
8
|
+
* 一个"没有东西可删"就失败的命令没法放进 bootstrap 脚本,
|
|
9
|
+
* 而 bootstrap 正是这个开关的主要用途。
|
|
10
|
+
*
|
|
11
|
+
* 写在测试里的教训:本命令默认作用于真实的 `~/.qoder/agents/`。
|
|
12
|
+
* 实现完成后我直接跑了一次冒烟测试,把本机三个 agent 文件删掉了。
|
|
13
|
+
* 所以下面每个用例都把 HOME 指向临时目录,不依赖调用者记得加沙箱。
|
|
14
|
+
* @covers AC-646-03, AC-646-05
|
|
15
|
+
*/
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
|
23
|
+
|
|
24
|
+
describe('delphi-setup --uninstall (#646)', () => {
|
|
25
|
+
let tmpHome;
|
|
26
|
+
let originalEnv;
|
|
27
|
+
let projectDir;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
originalEnv = { ...process.env };
|
|
31
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-uninstall-'));
|
|
32
|
+
projectDir = path.join(tmpHome, 'project');
|
|
33
|
+
// HOME_DIR() resolves from these; pointing them at the sandbox is what keeps
|
|
34
|
+
// this suite from deleting the developer's real agent files.
|
|
35
|
+
process.env.HOME = tmpHome;
|
|
36
|
+
process.env.USERPROFILE = tmpHome;
|
|
37
|
+
process.env.XDG_CONFIG_HOME = path.join(tmpHome, '.config');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
process.env = { ...originalEnv };
|
|
42
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function seedAgents(dir) {
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
for (const role of ['architecture', 'technical', 'feasibility']) {
|
|
48
|
+
fs.writeFileSync(path.join(dir, `delphi-${role}.md`), `model: "[x](x)"\n`, 'utf8');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function agentNames(dir) {
|
|
53
|
+
if (!fs.existsSync(dir)) return [];
|
|
54
|
+
return fs.readdirSync(dir).filter((f) => f.startsWith('delphi-')).sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it('AC-646-03: removes all three user-level agents', async () => {
|
|
58
|
+
const { delphiSetup } = require(path.join(REPO_ROOT, 'src/npm-package/lib/delphi-setup.js'));
|
|
59
|
+
const dir = path.join(tmpHome, '.qoder', 'agents');
|
|
60
|
+
seedAgents(dir);
|
|
61
|
+
expect(agentNames(dir)).toHaveLength(3);
|
|
62
|
+
|
|
63
|
+
const code = await delphiSetup(['--uninstall', '--global']);
|
|
64
|
+
|
|
65
|
+
expect(code).toBe(0);
|
|
66
|
+
expect(agentNames(dir)).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('AC-646-03: removes project-level agents with --local --project', async () => {
|
|
70
|
+
const { delphiSetup } = require(path.join(REPO_ROOT, 'src/npm-package/lib/delphi-setup.js'));
|
|
71
|
+
const dir = path.join(projectDir, '.qoder', 'agents');
|
|
72
|
+
seedAgents(dir);
|
|
73
|
+
|
|
74
|
+
const code = await delphiSetup(['--uninstall', '--local', '--project', projectDir]);
|
|
75
|
+
|
|
76
|
+
expect(code).toBe(0);
|
|
77
|
+
expect(agentNames(dir)).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('AC-646-03: a second run still succeeds -- idempotence is the point', async () => {
|
|
81
|
+
const { delphiSetup } = require(path.join(REPO_ROOT, 'src/npm-package/lib/delphi-setup.js'));
|
|
82
|
+
const dir = path.join(tmpHome, '.qoder', 'agents');
|
|
83
|
+
seedAgents(dir);
|
|
84
|
+
|
|
85
|
+
expect(await delphiSetup(['--uninstall', '--global'])).toBe(0);
|
|
86
|
+
// Nothing left to delete. "Nothing to do" is success, not failure: a command
|
|
87
|
+
// that errors here cannot be called from a bootstrap script twice.
|
|
88
|
+
expect(await delphiSetup(['--uninstall', '--global'])).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('AC-646-03: refuses to combine --uninstall with --model', async () => {
|
|
92
|
+
const { delphiSetup } = require(path.join(REPO_ROOT, 'src/npm-package/lib/delphi-setup.js'));
|
|
93
|
+
const dir = path.join(tmpHome, '.qoder', 'agents');
|
|
94
|
+
seedAgents(dir);
|
|
95
|
+
|
|
96
|
+
const code = await delphiSetup([
|
|
97
|
+
'--uninstall',
|
|
98
|
+
'--model', 'architecture=qfmodel',
|
|
99
|
+
'--model', 'technical=gfmodel',
|
|
100
|
+
'--model', 'feasibility=dfmodel',
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
// Rebinding and uninstalling are opposite verbs; guessing which one wins would
|
|
104
|
+
// silently discard the other intent.
|
|
105
|
+
expect(code).toBe(1);
|
|
106
|
+
expect(agentNames(dir)).toHaveLength(3);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('AC-646-05: doctor flips to SKIP once the channel preference is set', async () => {
|
|
110
|
+
// The closed loop: uninstall removes the files, the preference keeps `init` from
|
|
111
|
+
// putting them back, and doctor must read that as configured-not-broken.
|
|
112
|
+
const { delphiSetup } = require(path.join(REPO_ROOT, 'src/npm-package/lib/delphi-setup.js'));
|
|
113
|
+
const dir = path.join(tmpHome, '.qoder', 'agents');
|
|
114
|
+
seedAgents(dir);
|
|
115
|
+
await delphiSetup(['--uninstall', '--global']);
|
|
116
|
+
|
|
117
|
+
const cfgDir = path.join(tmpHome, '.config', 'xp-gate');
|
|
118
|
+
fs.mkdirSync(cfgDir, { recursive: true });
|
|
119
|
+
fs.writeFileSync(
|
|
120
|
+
path.join(cfgDir, '.delphi-config.json'),
|
|
121
|
+
JSON.stringify({ channels: ['external'] }),
|
|
122
|
+
'utf8',
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const channel = require(path.join(REPO_ROOT, 'src/npm-package/lib/delphi-channel.js'));
|
|
126
|
+
expect(channel.nativeAgentDeploymentIsWanted({ home: tmpHome })).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-646
|
|
3
|
+
* @intent native Delphi 通道在 Qoder 上**已被实测证伪**:subagent 派发采纳 agent 的提示词,
|
|
4
|
+
* 却丢弃 model 绑定,三席永远跑会话模型。2026-09-23 复测记录写在
|
|
5
|
+
* `skills/delphi-review/SKILL.md` 里——三份 agent 分别绑 qfmodel / dfmodel / gfmodel,
|
|
6
|
+
* 新会话后 `turn.started` / `model.request.started` / `model.response.completed`
|
|
7
|
+
* 三处的 model **全为会话模型**,dfmodel / gfmodel 出现 0 次。
|
|
8
|
+
*
|
|
9
|
+
* 结论:native 通道只能产出"同模型三角色自查",**不构成多模型交叉验证证据**,
|
|
10
|
+
* 也不能用作 Gate MW 的三模型凭据。
|
|
11
|
+
*
|
|
12
|
+
* 然而安装路径无条件部署它、doctor 强制要求它存在。于是外部通道用户删掉三个
|
|
13
|
+
* agent 换来的是一盏红灯 + 一条会把机制重新装回来的修复提示。"关掉它"在产品层面
|
|
14
|
+
* 无路可走——这是本测试要钉住的东西。
|
|
15
|
+
*
|
|
16
|
+
* 设计要点:偏好读 #541 确立的**机器级唯一事实源**,不是项目文件。通道选择是
|
|
17
|
+
* "这台机器上 Delphi 怎么跑",与具体仓库无关;放进项目 files 会让每个仓库各写一遍。
|
|
18
|
+
* @covers AC-646-01, AC-646-02, AC-646-03
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
|
27
|
+
|
|
28
|
+
describe('Delphi 通道偏好 (#646)', () => {
|
|
29
|
+
let channel;
|
|
30
|
+
let tmpHome;
|
|
31
|
+
let originalEnv;
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
channel = require(path.join(REPO_ROOT, 'src/npm-package/lib/delphi-channel.js'));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
originalEnv = { ...process.env };
|
|
39
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-channel-'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
process.env = { ...originalEnv };
|
|
44
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Writes a machine-level config file the way #541 lays it out. */
|
|
48
|
+
function writeMachineConfig(body) {
|
|
49
|
+
const dir = path.join(tmpHome, '.config', 'xp-gate');
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(path.join(dir, '.delphi-config.json'), JSON.stringify(body), 'utf8');
|
|
52
|
+
return dir;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
it('AC-646-01: an explicit channels preference is honoured', () => {
|
|
56
|
+
writeMachineConfig({ channels: ['external'] });
|
|
57
|
+
expect(channel.resolveChannels({ home: tmpHome })).toEqual({
|
|
58
|
+
channels: ['external'],
|
|
59
|
+
source: 'config',
|
|
60
|
+
nativeEnabled: false,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('AC-646-01: the environment variable wins over the config file', () => {
|
|
65
|
+
// The env var is the escape hatch for CI and one-off runs; a file cannot be
|
|
66
|
+
// changed from a test harness, so it must lose to an explicit env setting.
|
|
67
|
+
writeMachineConfig({ channels: ['native', 'external'] });
|
|
68
|
+
process.env.XP_GATE_DELPHI_CHANNEL = 'external';
|
|
69
|
+
const resolved = channel.resolveChannels({ home: tmpHome });
|
|
70
|
+
expect(resolved.channels).toEqual(['external']);
|
|
71
|
+
expect(resolved.source).toBe('env');
|
|
72
|
+
expect(resolved.nativeEnabled).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('AC-646-01: with no preference, both channels stay enabled and the source says why', () => {
|
|
76
|
+
// Defaulting to external-only would silently change behaviour for everyone who
|
|
77
|
+
// never asked. Defaulting to native-only would keep the disproved path in front.
|
|
78
|
+
const resolved = channel.resolveChannels({ home: tmpHome });
|
|
79
|
+
expect(resolved.channels).toEqual(['native', 'external']);
|
|
80
|
+
expect(resolved.source).toBe('default');
|
|
81
|
+
expect(resolved.nativeEnabled).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('AC-646-01: an unrecognised value falls back loudly instead of guessing', () => {
|
|
85
|
+
writeMachineConfig({ channels: ['nonsense'] });
|
|
86
|
+
const resolved = channel.resolveChannels({ home: tmpHome });
|
|
87
|
+
expect(resolved.source).toBe('default');
|
|
88
|
+
expect(resolved.channels).toEqual(['native', 'external']);
|
|
89
|
+
expect(resolved.warning).toMatch(/nonsense/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('AC-646-01: a corrupt config file does not crash and does not pretend to be configured', () => {
|
|
93
|
+
const dir = path.join(tmpHome, '.config', 'xp-gate');
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
fs.writeFileSync(path.join(dir, '.delphi-config.json'), '{ not json', 'utf8');
|
|
96
|
+
const resolved = channel.resolveChannels({ home: tmpHome });
|
|
97
|
+
expect(resolved.source).toBe('default');
|
|
98
|
+
expect(resolved.warning).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('AC-646-02: nativeAgentDeploymentIsWanted() is what init must consult', () => {
|
|
102
|
+
writeMachineConfig({ channels: ['external'] });
|
|
103
|
+
expect(channel.nativeAgentDeploymentIsWanted({ home: tmpHome })).toBe(false);
|
|
104
|
+
|
|
105
|
+
fs.rmSync(path.join(tmpHome, '.config'), { recursive: true, force: true });
|
|
106
|
+
delete process.env.XP_GATE_DELPHI_CHANNEL;
|
|
107
|
+
expect(channel.nativeAgentDeploymentIsWanted({ home: tmpHome })).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delphi 通道偏好 — 这台机器上 Delphi 评审走哪条路(#646)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要这个文件
|
|
5
|
+
*
|
|
6
|
+
* Qoder 的 native 通道**已被实测证伪**,证据就写在自家技能里
|
|
7
|
+
* (`skills/delphi-review/SKILL.md`):subagent 派发采纳 agent 的提示词,却丢弃
|
|
8
|
+
* model 绑定,三席永远跑会话模型。所以 native 只能产出"同模型三角色自查",
|
|
9
|
+
* 不构成多模型交叉验证证据,也不能用作 Gate MW 的三模型凭据。
|
|
10
|
+
*
|
|
11
|
+
* 但安装路径无条件部署它,doctor 强制要求它存在。于是只用外部通道的用户删掉三个
|
|
12
|
+
* agent 之后,得到的是一盏红灯 + 一条会把机制重新装回来的修复提示——"关掉它"
|
|
13
|
+
* 在产品层面无路可走。
|
|
14
|
+
*
|
|
15
|
+
* ## 为什么偏好放在机器级
|
|
16
|
+
*
|
|
17
|
+
* 通道选择是"这台机器上 Delphi 怎么跑",与具体仓库无关。放进项目文件会让每个仓库
|
|
18
|
+
* 各写一遍,而且换仓库时要重配一次。#541 已经把机器级 Delphi 配置收敛到
|
|
19
|
+
* `~/.config/xp-gate/.delphi-config.json`,偏好作为其中的 `channels` 字段。
|
|
20
|
+
*
|
|
21
|
+
* ## 为什么默认是两条都开
|
|
22
|
+
*
|
|
23
|
+
* 默认 external-only 会悄悄改掉所有没提要求的人的行为;默认 native-only 则把已被
|
|
24
|
+
* 证伪的路径摆在最前面。默认保持"两条都可用",由用户显式关掉。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const os = require('node:os');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
/** The channels this product knows about, in the order they are tried. */
|
|
34
|
+
const KNOWN_CHANNELS = ['native', 'external'];
|
|
35
|
+
|
|
36
|
+
/** Both enabled — see "为什么默认是两条都开" above. */
|
|
37
|
+
const DEFAULT_CHANNELS = ['native', 'external'];
|
|
38
|
+
|
|
39
|
+
/** Environment override. Beats the config file, for CI and one-off runs. */
|
|
40
|
+
const CHANNEL_ENV = 'XP_GATE_DELPHI_CHANNEL';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalise a user-supplied channel list.
|
|
44
|
+
*
|
|
45
|
+
* Accepts an array or a comma/space separated string (the env var spelling).
|
|
46
|
+
* Unknown names are dropped and reported rather than silently ignored: a typo that
|
|
47
|
+
* quietly leaves native enabled is exactly the failure this file exists to prevent.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} raw
|
|
50
|
+
* @returns {{ channels: string[], unknown: string[] }}
|
|
51
|
+
*/
|
|
52
|
+
function normaliseChannels(raw) {
|
|
53
|
+
let parts;
|
|
54
|
+
if (Array.isArray(raw)) {
|
|
55
|
+
parts = raw;
|
|
56
|
+
} else if (typeof raw === 'string') {
|
|
57
|
+
parts = raw.split(/[,\s]+/);
|
|
58
|
+
} else {
|
|
59
|
+
return { channels: [], unknown: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const unknown = [];
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
if (typeof part !== 'string') continue;
|
|
66
|
+
const name = part.trim().toLowerCase();
|
|
67
|
+
if (name === '') continue;
|
|
68
|
+
if (!KNOWN_CHANNELS.includes(name)) {
|
|
69
|
+
unknown.push(part);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
seen.add(name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Preserve KNOWN_CHANNELS order so resolution is deterministic regardless of how
|
|
76
|
+
// the user wrote the list.
|
|
77
|
+
return { channels: KNOWN_CHANNELS.filter((c) => seen.has(c)), unknown };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read the machine-level Delphi config, if any.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately tolerant: a hand-edited or corrupt file must not make `init` or
|
|
84
|
+
* `doctor` crash. It returns `undefined` and the caller falls back to defaults with
|
|
85
|
+
* a warning, because "I could not read your preference" and "you asked for both"
|
|
86
|
+
* look identical downstream and the message is what tells them apart.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} [home]
|
|
89
|
+
* @returns {{ config?: object, error?: string }}
|
|
90
|
+
*/
|
|
91
|
+
function readMachineConfig(home) {
|
|
92
|
+
const root = home || os.homedir();
|
|
93
|
+
const file = path.join(root, '.config', 'xp-gate', '.delphi-config.json');
|
|
94
|
+
if (!fs.existsSync(file)) return {};
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
97
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
98
|
+
return { error: `${file} is not a JSON object` };
|
|
99
|
+
}
|
|
100
|
+
return { config: parsed };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { error: `${file} is unreadable: ${err && err.message ? err.message : String(err)}` };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve which Delphi channels are enabled on this machine.
|
|
108
|
+
*
|
|
109
|
+
* @param {{ home?: string }} [opts]
|
|
110
|
+
* @returns {{ channels: string[], source: 'env'|'config'|'default', nativeEnabled: boolean, warning?: string }}
|
|
111
|
+
*/
|
|
112
|
+
function resolveChannels(opts = {}) {
|
|
113
|
+
const envRaw = process.env[CHANNEL_ENV];
|
|
114
|
+
if (envRaw !== undefined && envRaw.trim() !== '') {
|
|
115
|
+
const { channels, unknown } = normaliseChannels(envRaw);
|
|
116
|
+
if (channels.length > 0) {
|
|
117
|
+
return {
|
|
118
|
+
channels,
|
|
119
|
+
source: 'env',
|
|
120
|
+
nativeEnabled: channels.includes('native'),
|
|
121
|
+
...(unknown.length > 0
|
|
122
|
+
? { warning: `${CHANNEL_ENV} ignored unknown channel(s): ${unknown.join(', ')}` }
|
|
123
|
+
: {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Every value was unknown -> fall through to the file/default rather than
|
|
127
|
+
// resolving to "no channels at all", which would disable Delphi entirely on a typo.
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { config, error } = readMachineConfig(opts.home);
|
|
131
|
+
if (error) {
|
|
132
|
+
return { channels: DEFAULT_CHANNELS, source: 'default', nativeEnabled: true, warning: error };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (config && Object.prototype.hasOwnProperty.call(config, 'channels')) {
|
|
136
|
+
const { channels, unknown } = normaliseChannels(config.channels);
|
|
137
|
+
if (channels.length > 0) {
|
|
138
|
+
const notes = [];
|
|
139
|
+
if (unknown.length > 0) notes.push(`ignored unknown channel(s): ${unknown.join(', ')}`);
|
|
140
|
+
return {
|
|
141
|
+
channels,
|
|
142
|
+
source: 'config',
|
|
143
|
+
nativeEnabled: channels.includes('native'),
|
|
144
|
+
...(notes.length > 0 ? { warning: notes.join('; ') } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
channels: DEFAULT_CHANNELS,
|
|
149
|
+
source: 'default',
|
|
150
|
+
nativeEnabled: true,
|
|
151
|
+
warning: `config "channels" had no recognised value (got ${
|
|
152
|
+
JSON.stringify(config.channels)
|
|
153
|
+
}); known: ${KNOWN_CHANNELS.join(', ')} — using defaults`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { channels: DEFAULT_CHANNELS, source: 'default', nativeEnabled: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Should `xp-gate init` deploy the Qoder native Delphi agents?
|
|
162
|
+
*
|
|
163
|
+
* One named predicate rather than an inline `resolveChannels().nativeEnabled` at the
|
|
164
|
+
* call site, so the decision has a single testable home and reads as a question.
|
|
165
|
+
*
|
|
166
|
+
* @param {{ home?: string }} [opts]
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
function nativeAgentDeploymentIsWanted(opts = {}) {
|
|
170
|
+
return resolveChannels(opts).nativeEnabled;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = {
|
|
174
|
+
CHANNEL_ENV,
|
|
175
|
+
DEFAULT_CHANNELS,
|
|
176
|
+
KNOWN_CHANNELS,
|
|
177
|
+
nativeAgentDeploymentIsWanted,
|
|
178
|
+
normaliseChannels,
|
|
179
|
+
readMachineConfig,
|
|
180
|
+
resolveChannels,
|
|
181
|
+
};
|
package/lib/delphi-setup.js
CHANGED
|
@@ -20,8 +20,11 @@ function parseSetupArgs(args) {
|
|
|
20
20
|
const models = {};
|
|
21
21
|
let scope = 'global';
|
|
22
22
|
let projectDir;
|
|
23
|
+
let uninstall = false;
|
|
23
24
|
for (let i = 0; i < args.length; i++) {
|
|
24
|
-
if (args[i] === '--
|
|
25
|
+
if (args[i] === '--uninstall') {
|
|
26
|
+
uninstall = true;
|
|
27
|
+
} else if (args[i] === '--model') {
|
|
25
28
|
const spec = args[++i];
|
|
26
29
|
const eq = typeof spec === 'string' ? spec.indexOf('=') : -1;
|
|
27
30
|
if (eq === -1) {
|
|
@@ -55,6 +58,20 @@ function parseSetupArgs(args) {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
// #646: --uninstall is a different verb, not a modifier -- it removes the native
|
|
62
|
+
// agents instead of rebinding them, so it must not demand --model.
|
|
63
|
+
if (uninstall) {
|
|
64
|
+
if (Object.keys(models).length > 0) {
|
|
65
|
+
console.error('[delphi-setup] ERROR: --uninstall cannot be combined with --model (remove or rebind, not both).');
|
|
66
|
+
return { error: true };
|
|
67
|
+
}
|
|
68
|
+
if (scope === 'local' && projectDir === undefined) {
|
|
69
|
+
console.error('[delphi-setup] ERROR: --uninstall --local requires --project <dir>.');
|
|
70
|
+
return { error: true };
|
|
71
|
+
}
|
|
72
|
+
return { models, scope, projectDir, uninstall };
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
const missing = DELPHI_ROLES.filter((role) => !models[role]);
|
|
59
76
|
if (missing.length > 0) {
|
|
60
77
|
console.error(`[delphi-setup] ERROR: missing --model for: ${missing.join(', ')}.`);
|
|
@@ -95,9 +112,57 @@ function rebindTemplate(templateBody, modelId) {
|
|
|
95
112
|
return lines.join('\n');
|
|
96
113
|
}
|
|
97
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Remove the native Delphi agent files for one scope (#646).
|
|
117
|
+
*
|
|
118
|
+
* Idempotent by design: the point is to let a user stop the native path and KEEP it
|
|
119
|
+
* stopped across upgrades. A command that errors "nothing to remove" the second time
|
|
120
|
+
* is a command you cannot put in a bootstrap script.
|
|
121
|
+
*
|
|
122
|
+
* Reports what it actually removed rather than a bare "done" -- after this, `doctor`
|
|
123
|
+
* flips the binding check to SKIP, and the user needs to see that the state changed
|
|
124
|
+
* because of them, not because something broke.
|
|
125
|
+
*
|
|
126
|
+
* @param {'global'|'local'} scope
|
|
127
|
+
* @param {string|undefined} projectDir
|
|
128
|
+
* @returns {number} exit code
|
|
129
|
+
*/
|
|
130
|
+
function uninstallDelphiAgents(scope, projectDir) {
|
|
131
|
+
const targetDir = scope === 'local'
|
|
132
|
+
? path.join(projectDir, '.qoder', 'agents')
|
|
133
|
+
: path.join(HOME_DIR, '.qoder', 'agents');
|
|
134
|
+
|
|
135
|
+
const removed = [];
|
|
136
|
+
const absent = [];
|
|
137
|
+
for (const role of DELPHI_ROLES) {
|
|
138
|
+
const targetPath = path.join(targetDir, `delphi-${role}.md`);
|
|
139
|
+
if (fs.existsSync(targetPath)) {
|
|
140
|
+
fs.unlinkSync(targetPath);
|
|
141
|
+
removed.push(`delphi-${role}.md`);
|
|
142
|
+
} else {
|
|
143
|
+
absent.push(`delphi-${role}.md`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(` scope: ${scope === 'local' ? `project-level ${targetDir}` : `user-level ${targetDir}`}`);
|
|
148
|
+
if (removed.length > 0) console.log(` removed: ${removed.join(', ')}`);
|
|
149
|
+
if (absent.length > 0) console.log(` already absent: ${absent.join(', ')}`);
|
|
150
|
+
console.log('');
|
|
151
|
+
console.log('✓ Native Delphi agents are gone. The native channel is a same-model');
|
|
152
|
+
console.log(' three-persona self-check and never satisfied the three-distinct-model');
|
|
153
|
+
console.log(' contract, so this loses no review evidence.');
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log(' To stop `xp-gate init` from reinstalling them, set the machine-level channel');
|
|
156
|
+
console.log(' preference once:');
|
|
157
|
+
console.log(` ${path.join(CONFIG_DIR, '.delphi-config.json')}`);
|
|
158
|
+
console.log(' { "channels": ["external"] }');
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
98
162
|
async function delphiSetup(args) {
|
|
99
163
|
const parsed = parseSetupArgs(args || []);
|
|
100
164
|
if (parsed.error) return 1;
|
|
165
|
+
if (parsed.uninstall) return uninstallDelphiAgents(parsed.scope, parsed.projectDir);
|
|
101
166
|
const { models, scope, projectDir } = parsed;
|
|
102
167
|
|
|
103
168
|
const templatesDir = path.join(__dirname, '..', 'plugins', 'qoder', 'agents');
|