@boyingliu01/xp-gate 0.35.23 → 0.36.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 (71) hide show
  1. package/adapter-common.sh +78 -36
  2. package/adapters/csharp.sh +245 -0
  3. package/adapters/gate-3.sh +1 -1
  4. package/adapters/gate-4.sh +1 -1
  5. package/adapters/gate-9.sh +37 -2
  6. package/adapters/go.sh +6 -11
  7. package/adapters/iac.sh +45 -5
  8. package/adapters/python.sh +6 -11
  9. package/bin/xp-gate.js +18 -11
  10. package/gate-10.sh +18 -3
  11. package/gate-13-compiler-strictness.sh +181 -0
  12. package/gate-3.sh +1 -1
  13. package/gate-4.sh +1 -1
  14. package/gate-9.sh +37 -2
  15. package/gates/common.ts +9 -2
  16. package/hooks/adapter-common.sh +78 -36
  17. package/hooks/gate-10.sh +18 -3
  18. package/hooks/gate-13-compiler-strictness.sh +181 -0
  19. package/hooks/gate-3.sh +1 -1
  20. package/hooks/gate-4.sh +1 -1
  21. package/hooks/gate-9.sh +37 -2
  22. package/hooks/gate-registry.sh +161 -0
  23. package/hooks/lib/test-failure.sh +29 -4
  24. package/hooks/pre-commit +122 -3
  25. package/lib/__tests__/agent-runtime-journal.test.js +42 -18
  26. package/lib/__tests__/check-single-file.test.js +55 -4
  27. package/lib/__tests__/detect-deps-dsh-profile.test.js +43 -0
  28. package/lib/__tests__/doctor-delphi.test.js +178 -3
  29. package/lib/__tests__/doctor-tui-skill-freshness.test.js +3 -3
  30. package/lib/__tests__/install-skill-full-dir.test.js +45 -2
  31. package/lib/__tests__/install-skill.test.js +191 -23
  32. package/lib/__tests__/mirror-sync.test.js +23 -5
  33. package/lib/__tests__/uninstall-global-residue.test.js +394 -9
  34. package/lib/__tests__/update-skill-flags.test.js +78 -0
  35. package/lib/__tests__/update-skill.test.js +334 -57
  36. package/lib/detect-deps.js +24 -0
  37. package/lib/doctor.js +80 -12
  38. package/lib/install-skill.js +85 -21
  39. package/lib/mattpocock-license-verdict.json +1 -1
  40. package/lib/uninstall.js +143 -12
  41. package/lib/update-skill.js +239 -28
  42. package/mock-policy/AGENTS.md +4 -4
  43. package/mutation/AGENTS.md +4 -4
  44. package/package.json +1 -1
  45. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  46. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  47. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  48. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  49. package/plugins/dsh/lib/index.d.ts.map +1 -1
  50. package/plugins/dsh/lib/index.js +7 -4
  51. package/plugins/dsh/package.json +1 -1
  52. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  53. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  54. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  55. package/plugins/dsh/src/index.test.ts +25 -0
  56. package/plugins/dsh/src/index.ts +7 -4
  57. package/plugins/opencode/package.json +1 -1
  58. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  59. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  60. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  61. package/plugins/qoder/plugin.json +1 -1
  62. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  63. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  64. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  65. package/principles/AGENTS.md +4 -4
  66. package/principles/adapters/csharp.ts +309 -0
  67. package/principles/analyzer.ts +5 -0
  68. package/scripts/delphi-external-review.cjs +130 -101
  69. package/skills/delphi-review/AGENTS.md +4 -4
  70. package/skills/sprint-flow/AGENTS.md +4 -4
  71. package/skills/test-specification-alignment/AGENTS.md +4 -4
@@ -58,27 +58,41 @@ function journalText(events) {
58
58
  function toolPair(callId, name, args, startT, endT, result = {}, pos = {}) {
59
59
  const turn = pos.turn ?? 1;
60
60
  const step = pos.step ?? 1;
61
- return [
62
- { type: 'tool/call', time: startT, data: { turn, step, callId, name, arguments: JSON.stringify(args) } },
63
- {
64
- type: 'tool/result',
65
- time: endT,
66
- data: {
67
- turn,
68
- step,
69
- message: {
70
- role: 'tool',
71
- toolCallId: callId,
72
- isError: !!result.isError,
73
- content: [{ type: 'text', text: result.text ?? 'ok' }],
74
- },
75
- },
76
- },
77
- ];
61
+ const call = {
62
+ type: 'tool/call',
63
+ time: startT,
64
+ data: { turn, step, callId, name, arguments: JSON.stringify(args) },
65
+ };
66
+ // Built separately so the pair stays flat: inlining it put the event five
67
+ // objects deep, which trips clean-code.deep-nesting (max 4) on every run.
68
+ const reply = {
69
+ type: 'tool/result',
70
+ time: endT,
71
+ data: { turn, step, message: toolResultMessage(callId, result) },
72
+ };
73
+ return [call, reply];
74
+ }
75
+
76
+ /** The `message` payload of a tool/result event. */
77
+ function toolResultMessage(callId, result) {
78
+ return {
79
+ role: 'tool',
80
+ toolCallId: callId,
81
+ isError: !!result.isError,
82
+ content: [{ type: 'text', text: result.text ?? 'ok' }],
83
+ };
78
84
  }
79
85
 
80
86
  describe('parseSessionBuffer', () => {
81
- it('reads a zstd-compressed session journal', () => {
87
+ // `zlib.zstdCompressSync` / `zstdDecompressSync` landed in Node 22. CI pins
88
+ // Node 20.x, so without this guard the test throws "zstdCompressSync is not a
89
+ // function" there and turns Gate 5 red on a machine difference rather than a
90
+ // code defect. Locally (Node 24) it passed, which is exactly why the gap
91
+ // survived. Feature-detected rather than version-sniffed, so a backport or a
92
+ // future rename cannot silently disable the coverage.
93
+ const hasZstd = typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function';
94
+
95
+ it.skipIf(!hasZstd)('reads a zstd-compressed session journal', () => {
82
96
  const events = toolPair('c1', 'read', { file_path: '/a.ts' }, 1000, 1010);
83
97
  const buf = zlib.zstdCompressSync(Buffer.from(journalText(events), 'utf8'));
84
98
  const parsed = parseSessionBuffer(buf);
@@ -86,6 +100,16 @@ describe('parseSessionBuffer', () => {
86
100
  expect(parsed[0].type).toBe('tool/call');
87
101
  });
88
102
 
103
+ it('skips a zstd-magic buffer it cannot decode, rather than throwing', () => {
104
+ // The counterpart to the test above: on a runtime without zstd the magic
105
+ // number still gets recognised, `zstdDecompressSync` is missing, and the
106
+ // catch must degrade to "no events" instead of crashing the whole journal.
107
+ // This runs on every Node version, so the degraded path is never untested.
108
+ const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 0x00, 0x00, 0x00, 0x00]);
109
+ expect(() => parseSessionBuffer(magic)).not.toThrow();
110
+ expect(parseSessionBuffer(magic)).toEqual([]);
111
+ });
112
+
89
113
  it('reads an uncompressed journal too, so fixtures need no encoder', () => {
90
114
  const events = toolPair('c1', 'read', { file_path: '/a.ts' }, 1000, 1010);
91
115
  const parsed = parseSessionBuffer(Buffer.from(journalText(events), 'utf8'));
@@ -43,6 +43,32 @@ function runCheck(relPath) {
43
43
  return { stdout, status, elapsed: Date.now() - started };
44
44
  }
45
45
 
46
+ /**
47
+ * 量一次「同一 CLI 做最少的事」要多久——只解析参数并打印版本,不跑任何 gate。
48
+ * 用作 AC-653-05 的相对基线:Node 启动 + bin 模块加载 + 依赖解析的固有成本,
49
+ * 这部分与机器负载同步伸缩,减掉它才能量出 check 自己的开销。
50
+ *
51
+ * 取三次的最小值:最小值受负载尖峰影响最小,比平均值稳定得多。
52
+ */
53
+ function measure(args) {
54
+ let best = Infinity;
55
+ for (let i = 0; i < 3; i += 1) {
56
+ const started = Date.now();
57
+ try {
58
+ execFileSync(process.execPath, [CLI, ...args], {
59
+ cwd: REPO,
60
+ encoding: 'utf8',
61
+ timeout: 120000,
62
+ stdio: 'pipe',
63
+ });
64
+ } catch {
65
+ // --version 不该失败;真失败时这一次不计入,别把错误吞成 0ms。
66
+ }
67
+ best = Math.min(best, Date.now() - started);
68
+ }
69
+ return best;
70
+ }
71
+
46
72
  describe('check --gates 单文件自检 (#653)', () => {
47
73
  it('AC-653-04: 传文件路径时 gate 真的执行,而不是报 spawn 失败', () => {
48
74
  const target = 'src/npm-package/lib/doctor-brief.js';
@@ -54,10 +80,35 @@ describe('check --gates 单文件自检 (#653)', () => {
54
80
  expect(stdout).toMatch(/principles/i);
55
81
  });
56
82
 
57
- it('AC-653-05: 单文件自检在 2 秒内给出结论', () => {
83
+ it('AC-653-05: 单文件自检的增量开销远小于一次完整 pre-commit', () => {
84
+ // #653 的价值主张:提交前自检要快过等一轮完整 pre-commit(几分钟)。
85
+ // "快路径"如果慢就不叫快路径。
86
+ //
87
+ // 但这里不能用绝对墙钟阈值。原实现写 `elapsed < 2000`,实测同一台机器:
88
+ // 裸跑 `node bin/xp-gate.js check ...` 654–822ms(远快于阈值)
89
+ // 在 vitest 里跑同一命令 2191–2352ms(偶尔超阈值)
90
+ // 它测的是「机器此刻有多空」,不是「这个功能够不够快」——并行跑测试时翻红、
91
+ // 空闲时变绿,红绿与功能无关(#722)。一个随负载漂移的断言,既不能证明快,
92
+ // 也不能证明慢。
93
+ //
94
+ // 改成相对判定:先用 `--version` 量出「同一 CLI、同一 Node 启动、同一份模块
95
+ // 加载」的固有成本,再看 check 是它的几倍。两者对机器快慢同步伸缩,比值因此
96
+ // 稳定。实测:
97
+ // 空闲 check 852ms / base 172ms = 4.95x
98
+ // 重负载 check 2563ms / base 445ms = 5.76x (4 个 CPU 密集进程并行)
99
+ // 比值只涨 16%,而绝对时间涨 3 倍——所以比值才是与负载无关的那个量。
100
+ //
101
+ // 门槛取 12x:它意味着 check 花的时间不超过「启动 CLI 本身」的一打倍——
102
+ // 一次完整 pre-commit 是分钟级,12x 启动成本仍是亚秒到一秒级,离「慢」很远;
103
+ // 同时留足余量,不会因为机器一时抖动就翻红。
104
+ const baseline = measure(['--version']);
58
105
  const { elapsed } = runCheck('src/npm-package/lib/doctor-brief.js');
59
- // #653's whole value proposition: self-check before committing beats waiting
60
- // for a 3-minute pre-commit. A slow "fast path" is not a fast path.
61
- expect(elapsed).toBeLessThan(2000);
106
+ const ratio = elapsed / baseline;
107
+
108
+ expect(ratio).toBeLessThan(12);
109
+ // 翻红时把三个数字一起打出来,能立刻分辨是「基线被拖慢」还是「check 真变慢」。
110
+ expect(
111
+ `${elapsed}ms / ${baseline}ms = ${ratio.toFixed(1)}x`,
112
+ ).toMatch(/^\d+ms \/ \d+ms = [\d.]+x$/);
62
113
  });
63
114
  });
@@ -104,3 +104,46 @@ describe('PLATFORM_PROFILES covers dsh (#635)', () => {
104
104
  expect(getSkillsDirs('claude-code')[0]).toBe(path.join(tmpHome, '.claude', 'skills'));
105
105
  });
106
106
  });
107
+
108
+ /**
109
+ * @test REQ-725
110
+ * @intent 多平台共存时 detectPlatform() 按优先级短路返回单个平台,install-skill/
111
+ * update-skill 与 doctor 都只拿它选中的目录,其余平台(含 detectPlatform()
112
+ * 从不返回的 dsh)被永久冻结。detectInstalledPlatforms() 是「检测所有已安装
113
+ * agent」的原始能力:按 PLATFORM_PROFILES 里目录真实存在的平台返回全集。
114
+ * @covers AC-725-01, AC-725-06
115
+ */
116
+ describe('detectInstalledPlatforms (#725 detect-all)', () => {
117
+ let tmpHome;
118
+ let originalHome;
119
+
120
+ beforeEach(() => {
121
+ originalHome = process.env.HOME;
122
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-detectall-'));
123
+ process.env.HOME = tmpHome;
124
+ vi.resetModules();
125
+ delete require.cache[require.resolve('../detect-deps')];
126
+ delete require.cache[require.resolve('../shared-paths')];
127
+ });
128
+
129
+ afterEach(() => {
130
+ process.env.HOME = originalHome;
131
+ fs.rmSync(tmpHome, { recursive: true, force: true });
132
+ });
133
+
134
+ it('returns every platform whose directory exists, including dsh', () => {
135
+ fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
136
+ fs.mkdirSync(path.join(tmpHome, '.config', 'opencode'), { recursive: true });
137
+ fs.mkdirSync(path.join(tmpHome, '.dsh'), { recursive: true });
138
+
139
+ const { detectInstalledPlatforms } = require('../detect-deps');
140
+ const installed = detectInstalledPlatforms();
141
+ expect(installed).toEqual(expect.arrayContaining(['opencode', 'qoder', 'dsh']));
142
+ expect(installed).not.toContain('claude-code');
143
+ });
144
+
145
+ it('returns an empty list when no platform directory exists', () => {
146
+ const { detectInstalledPlatforms } = require('../detect-deps');
147
+ expect(detectInstalledPlatforms()).toEqual([]);
148
+ });
149
+ });
@@ -403,7 +403,7 @@ describe('doctor delphi #684 global config fallback', () => {
403
403
  return { logs, restore: () => spy.mockRestore() };
404
404
  }
405
405
 
406
- it('AC-684-01: a valid global config with no project copy PASSes instead of failing', async () => {
406
+ it('AC-721-01: a valid global config with no project copy PASSes instead of failing', async () => {
407
407
  const globalPath = writeGlobalConfig();
408
408
  const project = path.join(tmpHome, 'a-project-with-no-config');
409
409
  fs.mkdirSync(project, { recursive: true });
@@ -417,7 +417,7 @@ describe('doctor delphi #684 global config fallback', () => {
417
417
  expect(cfgLine).toMatch(/global/i);
418
418
  });
419
419
 
420
- it('AC-684-02: no config in either scope still FAILs (fallback must not mask a broken install)', async () => {
420
+ it('AC-721-02: no config in either scope still FAILs (fallback must not mask a broken install)', async () => {
421
421
  const project = path.join(tmpHome, 'bare-project');
422
422
  fs.mkdirSync(project, { recursive: true });
423
423
  const { logs, restore } = captureLogs();
@@ -436,7 +436,7 @@ describe('doctor delphi #684 global config fallback', () => {
436
436
  expect(cfgLine).toMatch(/not found/);
437
437
  });
438
438
 
439
- it('AC-684-03: a project config still wins over the global one', async () => {
439
+ it('AC-721-03: a project config still wins over the global one', async () => {
440
440
  const globalPath = writeGlobalConfig();
441
441
  const project = path.join(tmpHome, 'project-with-own-config');
442
442
  fs.mkdirSync(project, { recursive: true });
@@ -451,3 +451,178 @@ describe('doctor delphi #684 global config fallback', () => {
451
451
  expect(cfgLine).not.toMatch(/global/i);
452
452
  });
453
453
  });
454
+
455
+ describe('doctor delphi #725 multi-platform skill parity', () => {
456
+ let tmpDirs;
457
+ let tmpHome;
458
+ let originalHome;
459
+ let originalUserProfile;
460
+
461
+ beforeEach(() => {
462
+ tmpDirs = makeTempDirs(['doctor-725-home']);
463
+ tmpHome = tmpDirs['doctor-725-home'];
464
+ originalHome = process.env.HOME;
465
+ originalUserProfile = process.env.USERPROFILE;
466
+ process.env.HOME = tmpHome;
467
+ process.env.USERPROFILE = tmpHome;
468
+ resetLibModules(vi, LIB_MODULES);
469
+ muteConsole(vi);
470
+ });
471
+
472
+ afterEach(() => {
473
+ if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
474
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
475
+ else process.env.USERPROFILE = originalUserProfile;
476
+ removeTempDirs(tmpDirs);
477
+ vi.restoreAllMocks();
478
+ });
479
+
480
+ /** Copy the shipped bundle into \`<home>/<rel>/delphi-review\`. */
481
+ function deploySkillAt(rel, mutate) {
482
+ const dest = path.join(tmpHome, rel, 'delphi-review');
483
+ fs.mkdirSync(dest, { recursive: true });
484
+ const src = bundledSkillDir();
485
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
486
+ const from = path.join(src, entry.name);
487
+ const to = path.join(dest, entry.name);
488
+ if (entry.isDirectory()) {
489
+ fs.mkdirSync(to, { recursive: true });
490
+ for (const inner of fs.readdirSync(from)) {
491
+ if (fs.statSync(path.join(from, inner)).isFile()) {
492
+ fs.copyFileSync(path.join(from, inner), path.join(to, inner));
493
+ }
494
+ }
495
+ } else {
496
+ fs.copyFileSync(from, to);
497
+ }
498
+ }
499
+ if (mutate) mutate(dest);
500
+ return dest;
501
+ }
502
+
503
+ /** Minimal agent set so only skill parity is under test. */
504
+ function seedAgents() {
505
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'agents'), { recursive: true });
506
+ for (const role of ['architecture', 'technical', 'feasibility']) {
507
+ fs.copyFileSync(
508
+ path.join(repoRoot(), 'plugins', 'qoder', 'agents', `delphi-${role}.md`),
509
+ path.join(tmpHome, '.qoder', 'agents', `delphi-${role}.md`),
510
+ );
511
+ }
512
+ }
513
+
514
+ function captureResults() {
515
+ const lines = [];
516
+ const spy = vi.spyOn(console, 'log').mockImplementation((...args) => lines.push(args.join(' ')));
517
+ return { lines, spy };
518
+ }
519
+
520
+ /**
521
+ * @test REQ-725 Multi-platform skill parity
522
+ * @intent A second platform whose copy drifted must fail the parity check instead of being invisible
523
+ * @covers AC-725-01
524
+ */
525
+ it('AC-725-01: a drifted OpenCode copy FAILs parity even though Qoder is clean', async () => {
526
+ seedAgents();
527
+ deploySkillAt(path.join('.qoder', 'skills'));
528
+ // Qoder is selected by detectPlatform(), so this is the copy the check used
529
+ // to look at -- and it is clean. The OpenCode copy is the one that rotted.
530
+ deploySkillAt(path.join('.config', 'opencode', 'skills'), (dest) => {
531
+ fs.writeFileSync(path.join(dest, 'AGENTS.md'), '# stale copy from 0.32.0.0\n');
532
+ });
533
+ fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
534
+ fs.writeFileSync(
535
+ path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
536
+ JSON.stringify({ installedSkills: {} }),
537
+ );
538
+
539
+ const { doctorDelphi } = require('../doctor');
540
+ const { lines, spy } = captureResults();
541
+ await doctorDelphi({ skipLatestProbe: true });
542
+ spy.mockRestore();
543
+
544
+ const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
545
+ expect(parity, 'a stale second-platform copy must be reported').toMatch(/✗/);
546
+ expect(parity).toMatch(/opencode/i);
547
+ });
548
+
549
+ /**
550
+ * @test REQ-725 Multi-platform skill parity
551
+ * @intent Both platforms matching must still PASS, and say how many were compared
552
+ * @covers AC-725-02
553
+ */
554
+ it('AC-725-02: two clean platform copies PASS together', async () => {
555
+ seedAgents();
556
+ deploySkillAt(path.join('.qoder', 'skills'));
557
+ deploySkillAt(path.join('.config', 'opencode', 'skills'));
558
+ fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
559
+ fs.writeFileSync(
560
+ path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
561
+ JSON.stringify({ installedSkills: {} }),
562
+ );
563
+
564
+ const { doctorDelphi } = require('../doctor');
565
+ const { lines, spy } = captureResults();
566
+ await doctorDelphi({ skipLatestProbe: true });
567
+ spy.mockRestore();
568
+
569
+ const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
570
+ expect(parity).toMatch(/✓/);
571
+ expect(parity).toMatch(/2 platform/i);
572
+ });
573
+
574
+ /**
575
+ * @test REQ-725 Multi-platform skill parity
576
+ * @intent dsh is a first-class deploy target but detectPlatform() never returns
577
+ * it, so a drifted dsh copy must fail parity exactly like opencode
578
+ * @covers AC-725-01
579
+ */
580
+ it('AC-725-01: a drifted dsh copy FAILs parity even though Qoder is clean', async () => {
581
+ seedAgents();
582
+ deploySkillAt(path.join('.qoder', 'skills'));
583
+ deploySkillAt(path.join('.dsh', 'skills'), (dest) => {
584
+ fs.writeFileSync(path.join(dest, 'AGENTS.md'), '# stale dsh copy\n');
585
+ });
586
+ fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
587
+ fs.writeFileSync(
588
+ path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
589
+ JSON.stringify({ installedSkills: {} }),
590
+ );
591
+
592
+ const { doctorDelphi } = require('../doctor');
593
+ const { lines, spy } = captureResults();
594
+ await doctorDelphi({ skipLatestProbe: true });
595
+ spy.mockRestore();
596
+
597
+ const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
598
+ expect(parity, 'a stale dsh copy must be reported').toMatch(/✗/);
599
+ expect(parity).toMatch(/dsh/i);
600
+ });
601
+
602
+ /**
603
+ * @test REQ-725 Multi-platform skill parity
604
+ * @intent a machine whose only installed agent is dsh has no marker detectPlatform()
605
+ * keys on, so the opencode fallback falsely FAILs "not installed" even though
606
+ * the skill IS deployed under ~/.dsh/skills
607
+ * @covers AC-725-01
608
+ */
609
+ it('AC-725-01: a dsh-only machine PASSes parity instead of "not installed at opencode"', async () => {
610
+ // No seedAgents(): keep .qoder/.claude/.config-opencode absent so detectPlatform()
611
+ // falls back to opencode while dsh is the only live tree.
612
+ deploySkillAt(path.join('.dsh', 'skills'));
613
+ fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
614
+ fs.writeFileSync(
615
+ path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
616
+ JSON.stringify({ installedSkills: {} }),
617
+ );
618
+
619
+ const { doctorDelphi } = require('../doctor');
620
+ const { lines, spy } = captureResults();
621
+ await doctorDelphi({ skipLatestProbe: true });
622
+ spy.mockRestore();
623
+
624
+ const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
625
+ expect(parity, 'a deployed dsh copy must be found and verified').toMatch(/✓/);
626
+ expect(parity).not.toMatch(/not installed/i);
627
+ });
628
+ });
@@ -336,7 +336,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
336
336
  return { issues, check: checks.find((c) => c.name === `Skill: ${name}`) };
337
337
  }
338
338
 
339
- it('AC-681-01: same version but a missing chapter is WARN, not "Up to date"', () => {
339
+ it('AC-718-01: same version but a missing chapter is WARN, not "Up to date"', () => {
340
340
  const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
341
341
  // Keep the frontmatter (version included), drop a large slice of the body.
342
342
  const idx = bundled.indexOf('\n---', 4);
@@ -352,7 +352,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
352
352
  expect(issues).toBe(1);
353
353
  });
354
354
 
355
- it('AC-681-02: same version and same content is still "Up to date" (no false alarm)', () => {
355
+ it('AC-718-02: same version and same content is still "Up to date" (no false alarm)', () => {
356
356
  const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
357
357
  installSkill('delphi-review', bundled);
358
358
  const { issues, check } = runFreshness('delphi-review');
@@ -360,7 +360,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
360
360
  expect(issues).toBe(0);
361
361
  });
362
362
 
363
- it('AC-681-03: line endings alone still do not count as a difference', () => {
363
+ it('AC-718-03: line endings alone still do not count as a difference', () => {
364
364
  const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
365
365
  installSkill('delphi-review', bundled.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'));
366
366
  const { issues, check } = runFreshness('delphi-review');
@@ -102,6 +102,11 @@ describe('install-skill full skill directory (#416)', () => {
102
102
  it('updates the skills directory of the platform actually in use', async () => {
103
103
  // update-skill used to hardcode the opencode path while install-skill wrote
104
104
  // to the platform dir, so on Qoder it deleted nothing (or the wrong copy).
105
+ //
106
+ // #725 changed the other half of this: a platform that ALSO has the skill
107
+ // deployed is updated too, because leaving it behind froze it at an old
108
+ // version while the command reported success. Both trees below are live, so
109
+ // both are updated -- "untouched" is no longer the expected end state.
105
110
  for (const name of ['superpowers', 'gstack']) {
106
111
  const dir = path.join(tmpHome, '.qoder', 'skills', name);
107
112
  fs.mkdirSync(dir, { recursive: true });
@@ -125,9 +130,47 @@ describe('install-skill full skill directory (#416)', () => {
125
130
  const { updateSkill } = require('../update-skill');
126
131
  expect(await updateSkill('ralph-loop', {})).toBe(0);
127
132
 
133
+ const bundled = listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop'));
134
+
135
+ // qoder keeps its #416 guarantee: stale files removed, full bundle written.
128
136
  expect(fs.existsSync(path.join(qoderSkills, 'obsolete.md'))).toBe(false);
129
- expect(listFiles(qoderSkills)).toEqual(listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop')));
130
- expect(fs.readFileSync(path.join(opencodeSkills, 'SKILL.md'), 'utf8')).toBe('untouched');
137
+ expect(listFiles(qoderSkills)).toEqual(bundled);
138
+
139
+ // #725: the opencode copy is a second live deployment, so it is updated too
140
+ // rather than silently frozen at `untouched`.
141
+ expect(listFiles(opencodeSkills)).toEqual(bundled);
142
+ });
143
+
144
+ it('updates a skill deployed under ~/.dsh too (#725 AC-725-06)', async () => {
145
+ // Qoder keeps the dep gate green AND is the platform detectPlatform() picks.
146
+ for (const name of ['superpowers', 'gstack']) {
147
+ const dir = path.join(tmpHome, '.qoder', 'skills', name);
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '2.0.0' }));
150
+ }
151
+ const qoderSkills = path.join(tmpHome, '.qoder', 'skills', 'ralph-loop');
152
+ fs.mkdirSync(qoderSkills, { recursive: true });
153
+ fs.writeFileSync(path.join(qoderSkills, 'SKILL.md'), '# old qoder copy');
154
+
155
+ // DSH is a live second deployment that detectPlatform() never returns.
156
+ const dshSkills = path.join(tmpHome, '.dsh', 'skills', 'ralph-loop');
157
+ fs.mkdirSync(dshSkills, { recursive: true });
158
+ fs.writeFileSync(path.join(dshSkills, 'SKILL.md'), '# old dsh copy');
159
+ fs.writeFileSync(path.join(dshSkills, 'obsolete.md'), 'stale');
160
+
161
+ const configDir = path.join(tmpHome, '.config', 'xp-gate');
162
+ fs.mkdirSync(configDir, { recursive: true });
163
+ fs.writeFileSync(
164
+ path.join(configDir, 'xp-gate.json'),
165
+ JSON.stringify({ installedSkills: { 'ralph-loop': { version: '0.0.1' } } })
166
+ );
167
+
168
+ const { updateSkill } = require('../update-skill');
169
+ expect(await updateSkill('ralph-loop', {})).toBe(0);
170
+
171
+ const bundled = listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop'));
172
+ expect(listFiles(dshSkills)).toEqual(bundled);
173
+ expect(fs.existsSync(path.join(dshSkills, 'obsolete.md'))).toBe(false);
131
174
  });
132
175
 
133
176
  it('keeps the installed directory when the package no longer bundles the skill', async () => {