@boyingliu01/xp-gate 0.19.3 → 0.20.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 (173) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +49 -5
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/typescript.sh +15 -60
  5. package/bin/xp-gate.js +15 -16
  6. package/gate-10.sh +35 -2
  7. package/gate-4.sh +110 -21
  8. package/gate-8.sh +49 -5
  9. package/hooks/gate-10.sh +35 -2
  10. package/hooks/gate-4.sh +110 -21
  11. package/hooks/gate-8.sh +49 -5
  12. package/hooks/lib/jscpd-run.sh +63 -0
  13. package/hooks/lib/sprint-gate-report.sh +58 -0
  14. package/hooks/lib/test-failure.sh +152 -0
  15. package/hooks/lib/typecheck.sh +141 -0
  16. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  17. package/hooks/post-merge +100 -0
  18. package/hooks/pre-commit +389 -162
  19. package/hooks/pre-push +75 -19
  20. package/lib/__tests__/bootstrap.test.js +23 -41
  21. package/lib/__tests__/check-version.test.js +8 -1
  22. package/lib/__tests__/detect-deps.test.js +16 -0
  23. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  24. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  25. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  26. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  27. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  28. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  30. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  31. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  32. package/lib/__tests__/doctor.test.js +155 -0
  33. package/lib/__tests__/gate-runner.test.js +19 -3
  34. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  35. package/lib/__tests__/init.test.js +44 -17
  36. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  37. package/lib/__tests__/install-skill.test.js +51 -132
  38. package/lib/__tests__/language-tools-detect.test.js +158 -0
  39. package/lib/__tests__/phase-transition.test.js +15 -1
  40. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  41. package/lib/__tests__/shared-paths.test.js +20 -0
  42. package/lib/__tests__/shared-utils.test.js +69 -0
  43. package/lib/__tests__/skill-flags.test.js +48 -0
  44. package/lib/__tests__/ui-detector.test.ts +6 -2
  45. package/lib/__tests__/uninstall.test.js +20 -0
  46. package/lib/__tests__/update-hooks.test.js +12 -0
  47. package/lib/__tests__/update-skill.test.js +7 -4
  48. package/lib/detect-deps.js +3 -20
  49. package/lib/doctor-tui.js +22 -5
  50. package/lib/doctor.js +1178 -31
  51. package/lib/init.js +184 -33
  52. package/lib/install-cmd.js +7 -4
  53. package/lib/install-skill.js +38 -95
  54. package/lib/language-tools.js +83 -4
  55. package/lib/mattpocock-license-verdict.json +1 -1
  56. package/lib/shared-paths.js +5 -2
  57. package/lib/shared-utils.js +47 -3
  58. package/lib/uninstall.js +12 -0
  59. package/lib/update-hooks.js +4 -4
  60. package/lib/update-skill.js +6 -7
  61. package/mock-policy/AGENTS.md +4 -4
  62. package/mutation/AGENTS.md +4 -4
  63. package/mutation/__tests__/gate-m.test.ts +66 -14
  64. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  65. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  66. package/mutation/gate-m.ts +21 -8
  67. package/package.json +1 -1
  68. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  69. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  70. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  71. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  72. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  73. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  74. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  76. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  77. package/plugins/dsh/README.md +169 -4
  78. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  79. package/plugins/dsh/package.json +1 -1
  80. package/plugins/dsh/scripts/prepack.cjs +24 -19
  81. package/plugins/dsh/setup-dsh.ps1 +160 -0
  82. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  84. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  85. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  86. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  87. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  89. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  90. package/plugins/dsh/src/command.test.ts +66 -2
  91. package/plugins/dsh/src/command.ts +36 -14
  92. package/plugins/dsh/src/contract.test.ts +31 -1
  93. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  94. package/plugins/dsh/src/delphi-config.ts +223 -0
  95. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  96. package/plugins/dsh/src/delphi-run.ts +129 -0
  97. package/plugins/dsh/src/delphi.test.ts +222 -0
  98. package/plugins/dsh/src/delphi.ts +247 -0
  99. package/plugins/dsh/src/dialect.test.ts +59 -0
  100. package/plugins/dsh/src/dialect.ts +40 -0
  101. package/plugins/dsh/src/index.ts +70 -4
  102. package/plugins/dsh/src/skills.test.ts +26 -19
  103. package/plugins/opencode/package.json +1 -1
  104. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  105. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  106. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  107. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  108. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  109. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  111. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  112. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  113. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  114. package/plugins/qoder/agents/delphi-technical.md +1 -1
  115. package/plugins/qoder/plugin.json +1 -1
  116. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  117. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  118. package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
  119. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  120. package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  122. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
  123. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  124. package/principles/AGENTS.md +9 -8
  125. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  126. package/principles/__tests__/analyzer.test.ts +29 -6
  127. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  128. package/principles/__tests__/baseline-prune.test.ts +338 -0
  129. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  130. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  131. package/principles/__tests__/boy-scout.test.ts +253 -7
  132. package/principles/__tests__/config-enforcement.test.ts +469 -0
  133. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  134. package/principles/__tests__/direct-execution.test.ts +187 -0
  135. package/principles/__tests__/index.test.ts +16 -3
  136. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  137. package/principles/adapters/typescript.ts +613 -13
  138. package/principles/analyzer.ts +22 -0
  139. package/principles/baseline-prune.ts +217 -0
  140. package/principles/boy-scout.ts +481 -51
  141. package/principles/config.ts +362 -54
  142. package/principles/direct-execution.ts +60 -0
  143. package/principles/index.ts +40 -17
  144. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  145. package/principles/rules/clean-code/code-duplication.ts +8 -8
  146. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  147. package/principles/rules/clean-code/god-class.ts +8 -8
  148. package/principles/rules/clean-code/large-file.ts +15 -11
  149. package/principles/rules/clean-code/long-function.ts +8 -8
  150. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  151. package/principles/rules/clean-code/many-exports.ts +7 -7
  152. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  153. package/principles/rules/clean-code/too-many-params.ts +8 -8
  154. package/principles/rules/clean-code/unused-imports.ts +5 -5
  155. package/principles/rules/solid/dip.ts +18 -8
  156. package/principles/rules/solid/isp.ts +9 -8
  157. package/principles/rules/solid/lsp.ts +6 -6
  158. package/principles/rules/solid/ocp.ts +6 -5
  159. package/principles/rules/solid/srp.ts +7 -8
  160. package/principles/types.ts +15 -0
  161. package/scripts/__tests__/sync-package-content.test.js +28 -0
  162. package/scripts/delphi-external-review.cjs +193 -10
  163. package/scripts/sync-package-content.js +7 -4
  164. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  165. package/skills/delphi-review/AGENTS.md +4 -4
  166. package/skills/delphi-review/SKILL.md +98 -14
  167. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  168. package/skills/delphi-review/references/requirements.md +6 -6
  169. package/skills/sprint-flow/AGENTS.md +4 -4
  170. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  171. package/skills/test-specification-alignment/AGENTS.md +4 -4
  172. package/lib/__tests__/download-skill.test.js +0 -281
  173. package/lib/download-skill.js +0 -96
@@ -0,0 +1,285 @@
1
+ /**
2
+ * @test qoder-delphi-agents
3
+ * @intent Guard the invariants behind Issue #417: Qoder Custom Agents must bind a
4
+ * built-in model as "[DisplayName](modelId)", stale or diverged deployments must
5
+ * surface a warning instead of silently falling back to the session model, and
6
+ * agent deployment must never abort an in-flight setup.
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const childProcess = require('child_process');
11
+ const {
12
+ resetLibModules, muteConsole, makeTempDirs, removeTempDirs,
13
+ } = require('./helpers/lib-sandbox.cjs');
14
+
15
+ const LIB_MODULES = ['../init', '../detect-deps.js', '../shared-paths'].map(name => require.resolve(name));
16
+
17
+ describe('Qoder Delphi agent templates (#417)', () => {
18
+ let tmpDirs;
19
+ let tmpHome;
20
+ let logSpy;
21
+ let warnSpy;
22
+ let originalHome;
23
+
24
+ beforeEach(() => {
25
+ tmpDirs = makeTempDirs(['xpgate-agents-home']);
26
+ tmpHome = tmpDirs['xpgate-agents-home'];
27
+ originalHome = process.env.HOME;
28
+ process.env.HOME = tmpHome;
29
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
30
+ resetLibModules(vi, LIB_MODULES);
31
+ ({ log: logSpy, warn: warnSpy } = muteConsole(vi));
32
+ vi.spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from(''));
33
+ vi.spyOn(childProcess, 'spawnSync').mockReturnValue({ status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') });
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 repoRoot() {
43
+ // __dirname = src/npm-package/lib/__tests__
44
+ return path.join(__dirname, '..', '..', '..', '..');
45
+ }
46
+
47
+ function warned() {
48
+ return warnSpy.mock.calls.map(c => String(c[0])).join('\n');
49
+ }
50
+
51
+ function logged() {
52
+ return logSpy.mock.calls.map(c => String(c[0])).join('\n');
53
+ }
54
+
55
+ function agentTemplate(modelLine, name) {
56
+ return `---\nname: ${name}\n${modelLine}\n---\n# ${name}\n`;
57
+ }
58
+
59
+ function tempAgentPair(filename, srcBody, destBody) {
60
+ const dirs = makeTempDirs(['xpgate-agentsrc', 'xpgate-agentdst']);
61
+ const srcDir = dirs['xpgate-agentsrc'];
62
+ const targetRoot = dirs['xpgate-agentdst'];
63
+ const srcAgents = path.join(srcDir, 'plugins', 'qoder', 'agents');
64
+ const destAgents = path.join(targetRoot, '.qoder', 'agents');
65
+ fs.mkdirSync(srcAgents, { recursive: true });
66
+ fs.mkdirSync(destAgents, { recursive: true });
67
+ if (srcBody !== null) fs.writeFileSync(path.join(srcAgents, filename), srcBody);
68
+ if (destBody !== null) fs.writeFileSync(path.join(destAgents, filename), destBody);
69
+ return { dirs, srcDir, targetRoot, srcAgents, destAgents };
70
+ }
71
+
72
+ function withDirs(dirs, fn) {
73
+ try {
74
+ return fn();
75
+ } finally {
76
+ removeTempDirs(dirs.dirs);
77
+ }
78
+ }
79
+
80
+ it('binds every expert template mirror to three distinct, identical built-in models', () => {
81
+ const { readQoderModelId } = require('../init');
82
+ const mirrors = [
83
+ path.join(repoRoot(), 'plugins', 'qoder', 'agents'),
84
+ path.join(repoRoot(), '.qoder', 'agents'),
85
+ path.join(repoRoot(), 'src', 'npm-package', 'plugins', 'qoder', 'agents'),
86
+ ];
87
+ const names = ['delphi-architecture', 'delphi-technical', 'delphi-feasibility'];
88
+
89
+ for (const dir of mirrors) {
90
+ const models = names.map(name => {
91
+ const file = path.join(dir, `${name}.md`);
92
+ expect(fs.existsSync(file)).toBe(true);
93
+ const modelId = readQoderModelId(file);
94
+ expect(modelId).not.toBeNull();
95
+ return modelId;
96
+ });
97
+ expect(new Set(models).size).toBe(3);
98
+ // Bodies may diverge (`.qoder/agents/` is this repo's own install), but the
99
+ // model each expert pins must not.
100
+ for (const [index, name] of names.entries()) {
101
+ const ids = mirrors.map(dir => readQoderModelId(path.join(dir, `${name}.md`)));
102
+ expect(new Set(ids).size).toBe(1);
103
+ expect(ids[0]).toBe(models[index]);
104
+ }
105
+ }
106
+ });
107
+
108
+ it('reads the modelId only from a quoted frontmatter binding', () => {
109
+ const { readQoderModelId } = require('../init');
110
+ const dirs = tempAgentPair('probe.md', null, null);
111
+ return withDirs(dirs, () => {
112
+ const write = body => {
113
+ const file = path.join(dirs.destAgents, 'probe.md');
114
+ fs.writeFileSync(file, body);
115
+ return readQoderModelId(file);
116
+ };
117
+ expect(write('---\nmodel: "[Qwen3.8-Flash](qfmodel)"\n---\n')).toBe('qfmodel');
118
+ expect(write("---\nmodel: '[Qwen3.8-Flash](qfmodel)'\n---\n")).toBe('qfmodel');
119
+ // Unquoted is not a legal Custom Agent binding: the docs require
120
+ // "[Name](modelId)", and accepting the bare form lets a hand-edited file
121
+ // pass the audit while Qoder reads something else entirely.
122
+ expect(write('---\nmodel: [Qwen3.8-Flash](qfmodel)\n---\n')).toBeNull();
123
+ expect(write('---\nmodel: "[Qwen3.8-Flash](qfmodel)\n---\n')).toBeNull(); // unbalanced quote
124
+ expect(write('---\nmodel: Qwen3.7-Max\n---\n')).toBeNull(); // bare name
125
+ expect(write('---\nname: x\n---\n\nExample:\n```md\nmodel: "[Doc](docmodel)"\n```\n')).toBeNull();
126
+ });
127
+ });
128
+
129
+ it('warns about a preserved existing agent whose model binding is stale', () => {
130
+ const dirs = tempAgentPair(
131
+ 'delphi-technical.md',
132
+ agentTemplate('model: "[DeepSeek-Flash](dfmodel)"', 'delphi-technical'),
133
+ agentTemplate('model: GLM-5.2', 'delphi-technical')
134
+ );
135
+ withDirs(dirs, () => {
136
+ const { configureQoderDelphiAgents } = require('../init');
137
+ configureQoderDelphiAgents(dirs.srcDir, dirs.targetRoot);
138
+
139
+ expect(warned()).toContain('delphi-technical.md');
140
+ expect(warned()).toContain('session model');
141
+ expect(fs.readFileSync(path.join(dirs.destAgents, 'delphi-technical.md'), 'utf8')).toContain('GLM-5.2');
142
+ });
143
+ });
144
+
145
+ it('warns when a preserved agent binds a different model than this version ships', () => {
146
+ const dirs = tempAgentPair(
147
+ 'delphi-feasibility.md',
148
+ agentTemplate('model: "[DeepSeek-Flash](dfmodel)"', 'delphi-feasibility'),
149
+ agentTemplate('model: "[GLM-5.3-Flash](gfmodel)"', 'delphi-feasibility')
150
+ );
151
+ withDirs(dirs, () => {
152
+ const { configureQoderDelphiAgents } = require('../init');
153
+ configureQoderDelphiAgents(dirs.srcDir, dirs.targetRoot);
154
+
155
+ expect(warned()).toContain('binds "gfmodel"');
156
+ expect(warned()).toContain('pins "dfmodel"');
157
+ expect(fs.existsSync(path.join(dirs.destAgents, 'delphi-feasibility.md'))).toBe(true);
158
+ });
159
+ });
160
+
161
+ it('audits deployed agents even when the bundled templates are missing', () => {
162
+ const dirs = tempAgentPair('delphi-architecture.md', null, agentTemplate('model: Qwen3.7-Max', 'delphi-architecture'));
163
+ fs.rmSync(dirs.srcAgents, { recursive: true, force: true });
164
+ withDirs(dirs, () => {
165
+ const { configureQoderDelphiAgents } = require('../init');
166
+ configureQoderDelphiAgents(dirs.srcDir, dirs.targetRoot);
167
+
168
+ expect(warned()).toContain('templates not bundled');
169
+ expect(warned()).toContain('delphi-architecture.md');
170
+ // Nothing came from this install, so the summary must not claim there were
171
+ // simply no templates, nor lecture about a binding we never deployed.
172
+ expect(logged()).not.toContain('no agent templates found');
173
+ expect(warned()).not.toContain('does not adopt');
174
+ });
175
+ });
176
+
177
+ it('refuses a bundled template whose binding is unquoted', () => {
178
+ const dirs = tempAgentPair(
179
+ 'delphi-technical.md',
180
+ agentTemplate('model: [DeepSeek-Flash](dfmodel)', 'delphi-technical'),
181
+ null
182
+ );
183
+ withDirs(dirs, () => {
184
+ const { configureQoderDelphiAgents } = require('../init');
185
+ configureQoderDelphiAgents(dirs.srcDir, dirs.targetRoot);
186
+
187
+ expect(fs.existsSync(path.join(dirs.destAgents, 'delphi-technical.md'))).toBe(false);
188
+ expect(warned()).toContain('1 bundled template(s) rejected');
189
+ });
190
+ });
191
+
192
+ it('leaves unrelated user agents alone', () => {
193
+ const dirs = tempAgentPair(
194
+ 'delphi-technical.md',
195
+ agentTemplate('model: "[DeepSeek-Flash](dfmodel)"', 'delphi-technical'),
196
+ null
197
+ );
198
+ fs.writeFileSync(path.join(dirs.destAgents, 'my-other-agent.md'), '# no frontmatter at all\n');
199
+ withDirs(dirs, () => {
200
+ const { configureQoderDelphiAgents } = require('../init');
201
+ configureQoderDelphiAgents(dirs.srcDir, dirs.targetRoot);
202
+
203
+ expect(warned()).not.toContain('my-other-agent.md');
204
+ });
205
+ });
206
+
207
+ it('refuses to deploy a bundled template without a valid model binding', () => {
208
+ const dirs = tempAgentPair('delphi-architecture.md', agentTemplate('model: Qwen3.7-Max', 'delphi-architecture'), null);
209
+ withDirs(dirs, () => {
210
+ const { configureQoderDelphiAgents } = require('../init');
211
+ configureQoderDelphiAgents(dirs.srcDir, dirs.targetRoot);
212
+
213
+ expect(fs.existsSync(path.join(dirs.destAgents, 'delphi-architecture.md'))).toBe(false);
214
+ expect(warned()).toContain('no valid model binding');
215
+ expect(warned()).toContain('1 bundled template(s) rejected');
216
+ expect(logged()).not.toContain('no agent templates found');
217
+ expect(warned()).not.toContain('does not adopt');
218
+ });
219
+ });
220
+
221
+ it('swallows deployment failures instead of aborting setup half-installed', () => {
222
+ const dirs = tempAgentPair(
223
+ 'delphi-technical.md',
224
+ agentTemplate('model: "[DeepSeek-Flash](dfmodel)"', 'delphi-technical'),
225
+ null
226
+ );
227
+ const originalCopy = fs.copyFileSync;
228
+ try {
229
+ fs.copyFileSync = () => { throw 'EPERM: operation not permitted'; };
230
+ const { deployQoderDelphiAgents } = require('../init');
231
+ expect(() => deployQoderDelphiAgents(dirs.srcDir, dirs.targetRoot)).not.toThrow();
232
+ expect(warned()).toContain('deployment failed: EPERM: operation not permitted');
233
+ } finally {
234
+ fs.copyFileSync = originalCopy;
235
+ fs.rmSync(dirs.srcDir, { recursive: true, force: true });
236
+ fs.rmSync(dirs.targetRoot, { recursive: true, force: true });
237
+ }
238
+ });
239
+
240
+ it('states the platform boundary even when every deployed binding is valid', () => {
241
+ const dirs = tempAgentPair(
242
+ 'delphi-architecture.md',
243
+ agentTemplate('model: "[Qwen3.8-Flash](qfmodel)"', 'delphi-architecture'),
244
+ null
245
+ );
246
+ withDirs(dirs, () => {
247
+ const { configureQoderDelphiAgents } = require('../init');
248
+ configureQoderDelphiAgents(dirs.srcDir, dirs.targetRoot);
249
+
250
+ // A legal binding is not an executed binding. Without this line the user
251
+ // leaves `init` believing three experts now run on three models.
252
+ expect(fs.existsSync(path.join(dirs.destAgents, 'delphi-architecture.md'))).toBe(true);
253
+ expect(warned()).toContain('does not adopt');
254
+ expect(warned()).toContain('.delphi-config.json');
255
+ });
256
+ });
257
+
258
+ it('does not state the boundary on a non-Qoder platform', () => {
259
+ // Remove the whole ~/.qoder dir (#424): any ~/.qoder content now means Qoder.
260
+ fs.rmSync(path.join(tmpHome, '.qoder'), { recursive: true, force: true });
261
+ const dirs = tempAgentPair(
262
+ 'delphi-architecture.md',
263
+ agentTemplate('model: "[Qwen3.8-Flash](qfmodel)"', 'delphi-architecture'),
264
+ null
265
+ );
266
+ withDirs(dirs, () => {
267
+ const { configureQoderDelphiAgents } = require('../init');
268
+ configureQoderDelphiAgents(dirs.srcDir, dirs.targetRoot);
269
+
270
+ expect(warned()).not.toContain('does not adopt');
271
+ });
272
+ });
273
+
274
+ it('reports the detected platform when skipping agent deployment', () => {
275
+ fs.rmSync(path.join(tmpHome, '.qoder'), { recursive: true, force: true });
276
+ const dirs = tempAgentPair('delphi-technical.md', null, null);
277
+ withDirs(dirs, () => {
278
+ const { configureQoderDelphiAgents } = require('../init');
279
+ configureQoderDelphiAgents(dirs.srcDir, dirs.targetRoot);
280
+
281
+ expect(logged()).toContain('platform detected:');
282
+ expect(warned()).not.toContain('templates not bundled');
283
+ });
284
+ });
285
+ });
@@ -86,6 +86,26 @@ describe('shared-paths', () => {
86
86
  expect(detectPlatform()).toBe('claude-code');
87
87
  });
88
88
 
89
+ // ── #424: brand-new Qoder installs have no skills/ subdirectory yet ──
90
+
91
+ it('detectPlatform returns qoder when only ~/.qoder exists (brand-new install, no skills dir)', () => {
92
+ fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
93
+ const { detectPlatform } = require('../shared-paths');
94
+ expect(detectPlatform()).toBe('qoder');
95
+ });
96
+
97
+ it('detectPlatform returns qoder when only ~/.qoder/agents exists (no skills dir)', () => {
98
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'agents'), { recursive: true });
99
+ const { detectPlatform } = require('../shared-paths');
100
+ expect(detectPlatform()).toBe('qoder');
101
+ });
102
+
103
+ it('detect-deps re-exports the SAME detectPlatform as shared-paths (no divergent copies)', () => {
104
+ const fromShared = require('../shared-paths').detectPlatform;
105
+ const fromDetectDeps = require('../detect-deps').detectPlatform;
106
+ expect(fromDetectDeps).toBe(fromShared);
107
+ });
108
+
89
109
  it('getTemplateDir returns correct path for each platform', () => {
90
110
  const { getTemplateDir } = require('../shared-paths');
91
111
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @test shared-utils
3
+ * @intent Verify copyAdapters deploys shipped gate scripts (gate-*.sh, sprint-gate.sh) from the package root so npm-installed init/setup-global produce working gates
4
+ * @covers gate-script deployment into ADAPTER_DIR / PROJECT_GITHOOKS for init and setup-global
5
+ */
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const os = require('node:os');
9
+
10
+ describe('shared-utils', () => {
11
+ let tmpRoot;
12
+
13
+ beforeEach(() => {
14
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-shared-utils-'));
15
+ vi.resetModules();
16
+ });
17
+
18
+ afterEach(() => {
19
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ describe('copyAdapters', () => {
24
+ it('copies gate-*.sh and sprint-gate.sh from the package root (npm install layout)', () => {
25
+ // npm install layout: the package root carries the shipped copies of
26
+ // gate-*.sh + sprint-gate.sh (placed by sync-package-content.js) and the
27
+ // repo-level githooks/ directory does not exist.
28
+ const pkgRoot = path.join(tmpRoot, 'node_modules', '@boyingliu01', 'xp-gate');
29
+ fs.mkdirSync(pkgRoot, { recursive: true });
30
+ fs.writeFileSync(path.join(pkgRoot, 'adapter-common.sh'), '# adapter-common');
31
+ fs.writeFileSync(path.join(pkgRoot, 'gate-3.sh'), '# gate-3');
32
+ fs.writeFileSync(path.join(pkgRoot, 'gate-12-file-hygiene.sh'), '# gate-12');
33
+ fs.writeFileSync(path.join(pkgRoot, 'sprint-gate.sh'), '# sprint-gate');
34
+ fs.mkdirSync(path.join(pkgRoot, 'adapters'), { recursive: true });
35
+ fs.writeFileSync(path.join(pkgRoot, 'adapters', 'typescript.sh'), '# ts');
36
+
37
+ const destDir = path.join(tmpRoot, 'project-githooks');
38
+ fs.mkdirSync(destDir, { recursive: true });
39
+
40
+ const { copyAdapters } = require('../shared-utils');
41
+ copyAdapters(pkgRoot, destDir);
42
+
43
+ expect(fs.existsSync(path.join(destDir, 'adapter-common.sh'))).toBe(true);
44
+ expect(fs.existsSync(path.join(destDir, 'typescript.sh'))).toBe(true);
45
+ expect(fs.existsSync(path.join(destDir, 'gate-3.sh'))).toBe(true);
46
+ expect(fs.existsSync(path.join(destDir, 'gate-12-file-hygiene.sh'))).toBe(true);
47
+ expect(fs.existsSync(path.join(destDir, 'sprint-gate.sh'))).toBe(true);
48
+ });
49
+
50
+ it('copies only gate-*.sh and sprint-gate.sh from the package root (filters other files)', () => {
51
+ const pkgRoot = path.join(tmpRoot, 'pkg');
52
+ fs.mkdirSync(path.join(pkgRoot, 'adapters'), { recursive: true });
53
+ fs.writeFileSync(path.join(pkgRoot, 'adapter-common.sh'), '# adapter-common');
54
+ fs.writeFileSync(path.join(pkgRoot, 'gate-3.sh'), '# gate-3');
55
+ fs.writeFileSync(path.join(pkgRoot, 'verify.sh'), '# verify');
56
+ fs.writeFileSync(path.join(pkgRoot, 'VERSION'), '1.0.0.0');
57
+
58
+ const destDir = path.join(tmpRoot, 'dest');
59
+ fs.mkdirSync(destDir, { recursive: true });
60
+
61
+ const { copyAdapters } = require('../shared-utils');
62
+ copyAdapters(pkgRoot, destDir);
63
+
64
+ expect(fs.existsSync(path.join(destDir, 'gate-3.sh'))).toBe(true);
65
+ expect(fs.existsSync(path.join(destDir, 'verify.sh'))).toBe(false);
66
+ expect(fs.existsSync(path.join(destDir, 'VERSION'))).toBe(false);
67
+ });
68
+ });
69
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @test REQ-416
3
+ * @intent install-skill 的用法字符串删掉了 --offline,但 parseOptions 对认不出的 flag
4
+ * 照旧静默忽略 —— 这正是 #488 给 doctor 修掉的同一类缺陷:用户执行了已不存在的
5
+ * flag,得到 exit 0 与「安装成功」,而 flag 从未生效(Round 1 架构席位 MA-02)
6
+ * @covers AC-416-06
7
+ */
8
+ const { parseSkillFlags } = require('../shared-utils');
9
+
10
+ describe('parseSkillFlags (#416)', () => {
11
+ it('parses the flags the skill commands implement', () => {
12
+ const { options, unknown } = parseSkillFlags(['--verbose', '--force'], 'install-skill');
13
+
14
+ expect(options).toMatchObject({ verbose: true, force: true, all: false, check: false });
15
+ expect(unknown).toEqual([]);
16
+ });
17
+
18
+ it('names a flag it does not implement instead of swallowing it', () => {
19
+ const { unknown } = parseSkillFlags(['--offline', '--verbose'], 'install-skill');
20
+
21
+ expect(unknown).toEqual(['--offline']);
22
+ });
23
+
24
+ it('treats a value argument as unknown rather than silently dropping it', () => {
25
+ expect(parseSkillFlags(['main'], 'install-skill').unknown).toEqual(['main']);
26
+ });
27
+
28
+ it('reports no unknown flag for an empty argument list', () => {
29
+ expect(parseSkillFlags([], 'install-skill')).toEqual({
30
+ options: { verbose: false, force: false, all: false, check: false },
31
+ unknown: [],
32
+ });
33
+ });
34
+
35
+ // Round 2 architecture MI-04: one global table meant `install-skill x --all`
36
+ // parsed clean and then dropped options.all on the floor -- parsed-but-not-
37
+ // consumed is the same silent no-op this branch exists to remove.
38
+ it('accepts a flag only for the command that consumes it', () => {
39
+ expect(parseSkillFlags(['--all'], 'install-skill').unknown).toEqual(['--all']);
40
+ expect(parseSkillFlags(['--check'], 'uninstall-skill').unknown).toEqual(['--check']);
41
+ expect(parseSkillFlags(['--verbose'], 'uninstall-skill').unknown).toEqual(['--verbose']);
42
+
43
+ const update = parseSkillFlags(['--all', '--check', '--verbose'], 'update-skill');
44
+ expect(update.unknown).toEqual([]);
45
+ expect(update.options).toMatchObject({ all: true, check: true, verbose: true });
46
+ expect(parseSkillFlags(['--force'], 'uninstall-skill').unknown).toEqual([]);
47
+ });
48
+ });
@@ -372,13 +372,17 @@ describe('ui-detector', () => {
372
372
 
373
373
  const UI_DETECTOR_PATH = path.resolve(__dirname, '../ui-detector.ts');
374
374
 
375
+ // Resolve the local tsx CLI once: spawning `npx tsx` per test re-resolves
376
+ // the package each call and needs shell:true on Windows; running the
377
+ // resolved CLI through process.execPath is far faster and shell-free.
378
+ const tsxCli = require.resolve('tsx/cli');
379
+
375
380
  function runCli(args: string[], stdin?: string, cwd?: string): { code: number; stdout: string; stderr: string } {
376
381
  const coverageDir = process.env.NODE_V8_COVERAGE ?? path.join(process.cwd(), 'coverage', '.tmp');
377
- const result = realSpawnSync('npx', ['tsx', UI_DETECTOR_PATH, ...args], {
382
+ const result = realSpawnSync(process.execPath, [tsxCli, UI_DETECTOR_PATH, ...args], {
378
383
  cwd: cwd ?? process.cwd(),
379
384
  encoding: 'utf-8',
380
385
  input: stdin,
381
- shell: true, // Required for npx.cmd resolution on Windows
382
386
  env: { ...process.env, NODE_V8_COVERAGE: coverageDir },
383
387
  });
384
388
  return {
@@ -97,6 +97,14 @@ describe('uninstall', () => {
97
97
  );
98
98
  }
99
99
 
100
+ function createXpGatePostMerge(dir) {
101
+ fs.mkdirSync(dir, { recursive: true });
102
+ fs.writeFileSync(
103
+ path.join(dir, 'post-merge'),
104
+ '#!/usr/bin/env bash\n# post-merge - XP-Gate VERSION sync hook\n'
105
+ );
106
+ }
107
+
100
108
  function createXpGateAdapterCommon(dir) {
101
109
  fs.mkdirSync(dir, { recursive: true });
102
110
  fs.writeFileSync(
@@ -131,6 +139,7 @@ describe('uninstall', () => {
131
139
  // Create project .git/hooks with xp-gate hooks
132
140
  createXpGatePreCommit(projectHooksDir());
133
141
  createXpGatePrePush(projectHooksDir());
142
+ createXpGatePostMerge(projectHooksDir());
134
143
 
135
144
  // Create project githooks/ with adapters
136
145
  createXpGateAdapterCommon(projectGithooksDir());
@@ -248,6 +257,17 @@ describe('uninstall', () => {
248
257
  expect(cfg.uninstalled).toBeDefined();
249
258
  });
250
259
 
260
+ it('AC-01: uninstall in local mode removes the deployed post-merge hook', async () => {
261
+ setupLocalInstall();
262
+ mockExecSuccess();
263
+ const { uninstall } = require('../uninstall');
264
+
265
+ const result = await uninstall([]);
266
+
267
+ expect(result).toBe(0);
268
+ expect(fs.existsSync(path.join(projectHooksDir(), 'post-merge'))).toBe(false);
269
+ });
270
+
251
271
  it('AC-01: uninstall returns 0 even when no files exist (graceful)', async () => {
252
272
  fs.mkdirSync(path.dirname(configFile()), { recursive: true });
253
273
  fs.writeFileSync(configFile(), JSON.stringify({ mode: 'local', lastInit: '2025-01-01' }, null, 2));
@@ -44,6 +44,7 @@ describe('updateHooks', () => {
44
44
 
45
45
  fs.writeFileSync(path.join(hooksDir, 'pre-commit'), overrides['hooks/pre-commit'] || '#!/bin/bash\necho "hook-v2"');
46
46
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), overrides['hooks/pre-push'] || '#!/bin/bash\necho "push-v2"');
47
+ fs.writeFileSync(path.join(hooksDir, 'post-merge'), overrides['hooks/post-merge'] || '#!/bin/bash\necho "merge-v2"');
47
48
  fs.writeFileSync(path.join(hooksLibDir, 'now-ms.sh'), overrides['hooks/lib/now-ms.sh'] || 'now_ms() { echo 123; }');
48
49
  fs.writeFileSync(path.join(tmpPackage, 'adapter-common.sh'), overrides['adapter-common.sh'] || '#!/bin/bash\necho "adapter-common-v2"');
49
50
  fs.writeFileSync(path.join(adaptersDir, 'typescript.sh'), overrides['adapters/typescript.sh'] || '#!/bin/bash\necho "ts-v2"');
@@ -81,6 +82,17 @@ describe('updateHooks', () => {
81
82
  expect(fs.readFileSync(path.join(dest, 'pre-push'), 'utf8')).toContain('push-v2');
82
83
  });
83
84
 
85
+ it('copies post-merge to destination (VERSION sync hook)', () => {
86
+ createPackageSource();
87
+ const mod = getModule();
88
+ const dest = path.join(tmpProject, 'hooks-postmerge');
89
+ fs.mkdirSync(dest, { recursive: true });
90
+
91
+ mod.copyHooks(tmpPackage, dest, false, true);
92
+
93
+ expect(fs.readFileSync(path.join(dest, 'post-merge'), 'utf8')).toContain('merge-v2');
94
+ });
95
+
84
96
  it('creates destination directory if it does not exist', () => {
85
97
  createPackageSource();
86
98
  const mod = getModule();
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @test update-skill
3
3
  * @intent Verify updateSkill() handles check/all/single-name modes, config parsing, and delegates to installSkill correctly
4
+ * @covers AC-416-04
4
5
  */
5
6
  const fs = require('fs');
6
7
  const path = require('path');
@@ -218,21 +219,23 @@ describe('update-skill', () => {
218
219
  expect(logSpy).not.toHaveBeenCalledWith('✓ foo updated');
219
220
  });
220
221
 
221
- it('removes existing targetDir before invoking installSkill', async () => {
222
+ it('leaves the target directory for installSkill to back up and replace', async () => {
222
223
  writeConfig({ foo: { version: '1.0.0' } });
223
224
  const dir = makeSkillDir('foo');
224
225
  expect(fs.existsSync(dir)).toBe(true);
225
226
 
226
- let dirExistedDuringInstall = true;
227
+ let dirPresentDuringInstall = false;
227
228
  installSkillMock.mockImplementation(async () => {
228
- dirExistedDuringInstall = fs.existsSync(dir);
229
+ dirPresentDuringInstall = fs.existsSync(dir);
229
230
  return 0;
230
231
  });
231
232
 
232
233
  const { updateSkill } = require('../update-skill');
233
234
  const result = await updateSkill('foo');
234
235
  expect(result).toBe(0);
235
- expect(dirExistedDuringInstall).toBe(false); // rm'd before installSkill called
236
+ // update-skill has no delete of its own: installSkill backs the directory up
237
+ // before replacing it (#416).
238
+ expect(dirPresentDuringInstall).toBe(true);
236
239
  });
237
240
 
238
241
  it('does not crash when targetDir does not exist', async () => {
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const os = require('os');
4
4
  const { execSync } = require('child_process');
5
5
 
6
- const { HOME_DIR } = require('./shared-paths.js');
6
+ const { HOME_DIR, detectPlatform } = require('./shared-paths.js');
7
7
 
8
8
  const REQUIRED_DEPS = [
9
9
  { name: 'superpowers', repo: 'obra/superpowers', minVersion: '1.0.0' },
@@ -257,25 +257,8 @@ const PLATFORM_PROFILES = {
257
257
  },
258
258
  };
259
259
 
260
- /**
261
- * Detect which AI agent platform is currently in use.
262
- * Checks for platform-specific directories in the user's home.
263
- * Falls back to 'opencode' if no platform is detected.
264
- *
265
- * @returns {'opencode' | 'claude-code' | 'qoder'}
266
- */
267
- function detectPlatform() {
268
- // Check for Qoder-specific marker
269
- if (fs.existsSync(path.join(HOME_DIR, '.qoder', 'skills'))) {
270
- return 'qoder';
271
- }
272
- // Check for Claude Code-specific marker
273
- if (fs.existsSync(path.join(HOME_DIR, '.claude', 'skills'))) {
274
- return 'claude-code';
275
- }
276
- // Default to opencode (most common, backward compatible)
277
- return 'opencode';
278
- }
260
+ // detectPlatform() is re-exported from shared-paths.js — the single
261
+ // implementation lives there (#424 removed the divergent copy).
279
262
 
280
263
  /**
281
264
  * Get the skills directories for a given platform.
package/lib/doctor-tui.js CHANGED
@@ -64,13 +64,29 @@ function diagnoseTuiRegistration(checks) {
64
64
  * @returns {string|undefined}
65
65
  */
66
66
  function extractSkillVersion(content) {
67
- const match = content.match(/^---\n([\s\S]*?)\n---/);
67
+ // A line terminator, not "LF": a working tree checked out with
68
+ // core.autocrlf=true ships CRLF SKILL.md files, and the LF-only pattern
69
+ // returned undefined for every one of them -- pushing each skill into the
70
+ // byte-comparison branch and reporting it Outdated (#439).
71
+ const match = content.match(/^---(?:\r\n|\r|\n)([\s\S]*?)(?:\r\n|\r|\n)---/);
68
72
  if (!match) return undefined;
69
73
  const frontmatter = match[1];
70
74
  const versionMatch = frontmatter.match(/version:\s*(\S+)/);
71
75
  return versionMatch ? versionMatch[1] : undefined;
72
76
  }
73
77
 
78
+ /**
79
+ * Compare two SKILL.md bodies ignoring line-ending style. Bundled copies come from
80
+ * whichever checkout packed them, installed copies come from whatever the platform's
81
+ * writer chose, so endings are never evidence of staleness (#439). Every convention
82
+ * normalizes: a lone CR is as much a line ending as CRLF, which is what this name
83
+ * already promises.
84
+ */
85
+ function sameContentIgnoringLineEndings(a, b) {
86
+ const normalize = (s) => s.replace(/\r\n|\r|\n/g, '\n');
87
+ return normalize(a) === normalize(b);
88
+ }
89
+
74
90
  /**
75
91
  * Compare two semver strings. Returns:
76
92
  * -1 if a < b, 0 if a == b, 1 if a > b, undefined if either is not valid semver.
@@ -145,8 +161,8 @@ function diagnoseInstalledSkills(config, checks) {
145
161
  if (bundledVersion !== undefined && userVersion !== undefined) {
146
162
  const cmp = compareSemver(userVersion, bundledVersion);
147
163
  if (cmp === undefined) {
148
- // Version strings not valid semver — fall back to byte comparison
149
- if (bundledContent !== userContent) {
164
+ // Version strings not valid semver — fall back to content comparison
165
+ if (!sameContentIgnoringLineEndings(bundledContent, userContent)) {
150
166
  checks.push({
151
167
  name: `Skill: ${name}`,
152
168
  status: 'WARN',
@@ -168,8 +184,9 @@ function diagnoseInstalledSkills(config, checks) {
168
184
  checks.push({ name: `Skill: ${name}`, status: 'PASS', detail: 'Up to date' });
169
185
  }
170
186
  } else {
171
- // No version in at least one file — fall back to byte-for-byte comparison
172
- if (bundledContent !== userContent) {
187
+ // No version in at least one file — compare content, but not byte-for-byte:
188
+ // line endings alone are not evidence of staleness (#439).
189
+ if (!sameContentIgnoringLineEndings(bundledContent, userContent)) {
173
190
  checks.push({
174
191
  name: `Skill: ${name}`,
175
192
  status: 'WARN',