@boyingliu01/xp-gate 0.35.24 → 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 (68) hide show
  1. package/adapter-common.sh +55 -2
  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/iac.sh +45 -5
  7. package/bin/xp-gate.js +18 -11
  8. package/gate-10.sh +18 -3
  9. package/gate-13-compiler-strictness.sh +181 -0
  10. package/gate-3.sh +1 -1
  11. package/gate-4.sh +1 -1
  12. package/gate-9.sh +37 -2
  13. package/gates/common.ts +9 -2
  14. package/hooks/adapter-common.sh +55 -2
  15. package/hooks/gate-10.sh +18 -3
  16. package/hooks/gate-13-compiler-strictness.sh +181 -0
  17. package/hooks/gate-3.sh +1 -1
  18. package/hooks/gate-4.sh +1 -1
  19. package/hooks/gate-9.sh +37 -2
  20. package/hooks/gate-registry.sh +161 -0
  21. package/hooks/lib/test-failure.sh +29 -4
  22. package/hooks/pre-commit +122 -3
  23. package/lib/__tests__/agent-runtime-journal.test.js +42 -18
  24. package/lib/__tests__/check-single-file.test.js +55 -4
  25. package/lib/__tests__/detect-deps-dsh-profile.test.js +43 -0
  26. package/lib/__tests__/doctor-delphi.test.js +175 -0
  27. package/lib/__tests__/install-skill-full-dir.test.js +45 -2
  28. package/lib/__tests__/install-skill.test.js +84 -3
  29. package/lib/__tests__/mirror-sync.test.js +23 -5
  30. package/lib/__tests__/uninstall-global-residue.test.js +192 -28
  31. package/lib/__tests__/update-skill-flags.test.js +78 -0
  32. package/lib/__tests__/update-skill.test.js +156 -3
  33. package/lib/detect-deps.js +24 -0
  34. package/lib/doctor.js +80 -12
  35. package/lib/install-skill.js +67 -18
  36. package/lib/mattpocock-license-verdict.json +1 -1
  37. package/lib/uninstall.js +96 -2
  38. package/lib/update-skill.js +143 -2
  39. package/mock-policy/AGENTS.md +4 -4
  40. package/mutation/AGENTS.md +4 -4
  41. package/package.json +1 -1
  42. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  43. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  44. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  45. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  46. package/plugins/dsh/lib/index.d.ts.map +1 -1
  47. package/plugins/dsh/lib/index.js +7 -4
  48. package/plugins/dsh/package.json +1 -1
  49. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  50. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  51. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  52. package/plugins/dsh/src/index.test.ts +25 -0
  53. package/plugins/dsh/src/index.ts +7 -4
  54. package/plugins/opencode/package.json +1 -1
  55. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  56. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  57. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  58. package/plugins/qoder/plugin.json +1 -1
  59. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  60. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  61. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  62. package/principles/AGENTS.md +4 -4
  63. package/principles/adapters/csharp.ts +309 -0
  64. package/principles/analyzer.ts +5 -0
  65. package/scripts/delphi-external-review.cjs +130 -101
  66. package/skills/delphi-review/AGENTS.md +4 -4
  67. package/skills/sprint-flow/AGENTS.md +4 -4
  68. package/skills/test-specification-alignment/AGENTS.md +4 -4
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @test REQ-725
3
+ * @intent `xp-gate update-skill --check` treated the flag itself as a skill name
4
+ * and failed with "--check is not installed". #347 fixed exactly this for
5
+ * --all and the sibling flag was left broken, so the regression is pinned
6
+ * here at the CLI boundary -- where the bug was -- rather than at the
7
+ * parser, which was never wrong.
8
+ * @covers AC-725-03
9
+ */
10
+ const { spawnSync } = require('child_process');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const BIN = path.join(__dirname, '..', '..', 'bin', 'xp-gate.js');
16
+
17
+ /** Run the real CLI against a throwaway HOME so no real install is touched. */
18
+ function runCli(args, home) {
19
+ const env = { ...process.env, HOME: home, USERPROFILE: home };
20
+ return spawnSync(process.execPath, [BIN, ...args], {
21
+ encoding: 'utf8',
22
+ env,
23
+ timeout: 120000,
24
+ });
25
+ }
26
+
27
+ describe('update-skill flag parsing (#725)', () => {
28
+ let home;
29
+
30
+ beforeEach(() => {
31
+ home = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-725-cli-'));
32
+ // An empty but present config dir keeps the command on the update path
33
+ // instead of bailing out with "not installed" for the wrong reason.
34
+ fs.mkdirSync(path.join(home, '.config', 'xp-gate'), { recursive: true });
35
+ fs.writeFileSync(
36
+ path.join(home, '.config', 'xp-gate', 'xp-gate.json'),
37
+ JSON.stringify({ installedSkills: {} })
38
+ );
39
+ });
40
+
41
+ afterEach(() => {
42
+ fs.rmSync(home, { recursive: true, force: true });
43
+ });
44
+
45
+ /**
46
+ * @covers AC-725-03
47
+ */
48
+ it('AC-725-03: --check is not mistaken for a skill name', () => {
49
+ const result = runCli(['update-skill', '--check'], home);
50
+ const said = `${result.stdout}\n${result.stderr}`;
51
+
52
+ // The bug printed exactly this. Anything mentioning it means the flag went
53
+ // through the name slot again.
54
+ expect(said, '--check must not be read as a skill name').not.toMatch(/--check is not installed/i);
55
+ // It should reach check mode instead: a listing pass, exit 0.
56
+ expect(result.status).toBe(0);
57
+ });
58
+
59
+ /**
60
+ * @covers AC-725-03
61
+ */
62
+ it('AC-725-03: --all --check keeps working (the combination #347 fixed)', () => {
63
+ const result = runCli(['update-skill', '--all', '--check'], home);
64
+ expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/--all is not installed/i);
65
+ expect(result.status).toBe(0);
66
+ });
67
+
68
+ /**
69
+ * @covers AC-725-03
70
+ */
71
+ it('AC-725-03: a bare name still routes to the single-skill path', () => {
72
+ // A name that is not installed must be rejected BY NAME -- proof the name
73
+ // slot still works after the flag extraction.
74
+ const result = runCli(['update-skill', 'definitely-not-a-skill'], home);
75
+ const said = `${result.stdout}\n${result.stderr}`;
76
+ expect(said).toMatch(/definitely-not-a-skill/);
77
+ });
78
+ });
@@ -114,8 +114,14 @@ describe('update-skill', () => {
114
114
  const result = await updateSkill(null, { check: true });
115
115
  expect(result).toBe(0);
116
116
  expect(logSpy).toHaveBeenCalledWith('Checking for updates...');
117
- expect(logSpy).toHaveBeenCalledWith(' foo: 1.2.3');
118
- expect(logSpy).toHaveBeenCalledWith(' bar: 2.0.0');
117
+
118
+ // #725: the check reads the DISK now, not the registry. These skills have no
119
+ // copy anywhere on this fixture HOME, so the honest answer is "registry only"
120
+ // -- printing the recorded version as though it were verified is what let a
121
+ // stale multi-platform copy look up to date.
122
+ const lines = logSpy.mock.calls.map((c) => c[0]);
123
+ expect(lines.some((l) => /foo: 1\.2\.3 \(registry only/.test(l))).toBe(true);
124
+ expect(lines.some((l) => /bar: 2\.0\.0 \(registry only/.test(l))).toBe(true);
119
125
  expect(logSpy).toHaveBeenCalledWith('Update check complete');
120
126
  });
121
127
 
@@ -124,7 +130,8 @@ describe('update-skill', () => {
124
130
  const { updateSkill } = require('../update-skill');
125
131
  const result = await updateSkill(null, { check: true });
126
132
  expect(result).toBe(0);
127
- expect(logSpy).toHaveBeenCalledWith(' baz: unknown');
133
+ const lines = logSpy.mock.calls.map((c) => c[0]);
134
+ expect(lines.some((l) => /baz: unknown/.test(l))).toBe(true);
128
135
  });
129
136
 
130
137
  it('check=true with empty config returns 0 and prints only headers', async () => {
@@ -489,3 +496,149 @@ describe('update-skill #713 bundled-skill ownership', () => {
489
496
  expect(code).toBe(0);
490
497
  });
491
498
  });
499
+
500
+ describe('update-skill #725 --check reads the disk, not the registry', () => {
501
+ let tmpHome;
502
+ let originalHome;
503
+ let logSpy;
504
+
505
+ beforeEach(() => {
506
+ originalHome = process.env.HOME;
507
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-725-'));
508
+ process.env.HOME = tmpHome;
509
+ vi.resetModules();
510
+ delete require.cache[require.resolve('../update-skill')];
511
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
512
+ });
513
+
514
+ afterEach(() => {
515
+ if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
516
+ logSpy.mockRestore();
517
+ fs.rmSync(tmpHome, { recursive: true, force: true });
518
+ });
519
+
520
+ function writeConfig(installedSkills) {
521
+ const dir = path.join(tmpHome, '.config', 'xp-gate');
522
+ fs.mkdirSync(dir, { recursive: true });
523
+ fs.writeFileSync(path.join(dir, 'xp-gate.json'), JSON.stringify({ installedSkills }));
524
+ }
525
+
526
+ // The bundled version these tests compare against is whatever the package under
527
+ // test actually declares. Reading it keeps the fixtures honest across a release
528
+ // bump: hardcoded stamps silently turn "up to date" fixtures into "behind" ones
529
+ // the moment VERSION moves, and the failure then names /behind/i rather than the
530
+ // stale literal that caused it.
531
+ function bundledVersion() {
532
+ return JSON.parse(
533
+ fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'),
534
+ ).version;
535
+ }
536
+
537
+ /** The same release in the 4-part stamp form AGENTS.md files carry. */
538
+ function bundledStamp() {
539
+ const v = bundledVersion();
540
+ return /^\d+\.\d+\.\d+$/.test(v) ? `${v}.0` : v;
541
+ }
542
+
543
+ /** Deploy a copy of a shipped skill into one platform, with a chosen stamp. */
544
+ function deploy(platformRel, name, version) {
545
+ const dir = path.join(tmpHome, platformRel, name);
546
+ fs.mkdirSync(dir, { recursive: true });
547
+ // SKILL.md is copied verbatim so a content comparison would call it current;
548
+ // the version stamp is what must decide.
549
+ const bundled = path.join(__dirname, '..', '..', 'skills', name, 'SKILL.md');
550
+ fs.copyFileSync(bundled, path.join(dir, 'SKILL.md'));
551
+ if (version) {
552
+ fs.writeFileSync(
553
+ path.join(dir, 'AGENTS.md'),
554
+ `# KB\n\n**Version:** ${version}\n`
555
+ );
556
+ }
557
+ return dir;
558
+ }
559
+
560
+ function lines() {
561
+ return logSpy.mock.calls.map((c) => String(c[0]));
562
+ }
563
+
564
+ /**
565
+ * @test REQ-725
566
+ * @intent A copy stamped with an older version must be reported even when the registry says it is current
567
+ * @covers AC-725-04
568
+ */
569
+ it('AC-725-04: reports a stale on-disk copy the registry calls current', async () => {
570
+ writeConfig({ 'delphi-review': { version: bundledVersion() } });
571
+ deploy(path.join('.qoder', 'skills'), 'delphi-review', bundledStamp());
572
+ deploy(path.join('.config', 'opencode', 'skills'), 'delphi-review', '0.32.0.0');
573
+
574
+ const { updateSkill } = require('../update-skill');
575
+ await updateSkill(null, { check: true });
576
+
577
+ const said = lines().join('\n');
578
+ // The registry claims the bundled version for this skill, and it is right about Qoder.
579
+ // It is wrong about OpenCode, and only reading the disk can say so.
580
+ expect(said).toMatch(/delphi-review \[opencode\]: 0\.32\.0\.0/);
581
+ expect(said).toMatch(/behind/i);
582
+ });
583
+
584
+ /**
585
+ * @test REQ-725
586
+ * @intent Two matching copies must report as up to date
587
+ * @covers AC-725-04
588
+ */
589
+ it('AC-725-04: both copies current reports up to date and no shortfall', async () => {
590
+ writeConfig({ 'delphi-review': { version: bundledVersion() } });
591
+ deploy(path.join('.qoder', 'skills'), 'delphi-review', bundledStamp());
592
+ deploy(path.join('.config', 'opencode', 'skills'), 'delphi-review', bundledStamp());
593
+
594
+ const { updateSkill } = require('../update-skill');
595
+ await updateSkill(null, { check: true });
596
+
597
+ const said = lines().join('\n');
598
+ expect(said).not.toMatch(/behind/i);
599
+ expect(said).toMatch(/up to date on 2 platform/i);
600
+ });
601
+
602
+ /**
603
+ * @test REQ-725
604
+ * @intent A release whose micro digit is 0 must not be read as a different release
605
+ * @covers AC-725-04
606
+ */
607
+ it('AC-725-04: a micro .0 stamp matches its own 3-part version', async () => {
608
+ // The stamp and the packaged version name the SAME release; only their digit
609
+ // count differs. This regressed once: normalization stripped every trailing
610
+ // zero instead of the last segment, so `X.Y.0.0` became `X.Y.0` while
611
+ // `X.Y.0` became `X.Y`, and a current copy was reported as behind. It was
612
+ // invisible while releases ended in a non-zero micro digit, which is why the
613
+ // assertion is built from the live packaged version rather than a literal:
614
+ // it only has teeth on releases whose micro digit is 0.
615
+ const three = bundledVersion();
616
+ const four = bundledStamp();
617
+
618
+ writeConfig({ 'delphi-review': { version: three } });
619
+ deploy(path.join('.qoder', 'skills'), 'delphi-review', four);
620
+
621
+ const { updateSkill } = require('../update-skill');
622
+ await updateSkill(null, { check: true });
623
+
624
+ const said = lines().join('\n');
625
+ expect(said, `${four} and ${three} name the same release`).not.toMatch(/behind/i);
626
+ });
627
+
628
+ /**
629
+ * @test REQ-725
630
+ * @intent A skill with no version stamp must not be flagged merely for lacking one
631
+ * @covers AC-725-05
632
+ */
633
+ it('AC-725-05: an unstamped copy identical to the bundle is not reported as behind', async () => {
634
+ // Most skills ship no AGENTS.md, so "unknown" is their normal state. Measuring
635
+ // staleness by absence of a stamp flagged every skill on a healthy machine.
636
+ writeConfig({ 'admin-template-guidelines': { version: bundledVersion() } });
637
+ deploy(path.join('.qoder', 'skills'), 'admin-template-guidelines', null);
638
+
639
+ const { updateSkill } = require('../update-skill');
640
+ await updateSkill(null, { check: true });
641
+
642
+ expect(lines().join('\n')).not.toMatch(/behind/i);
643
+ });
644
+ });
@@ -302,6 +302,29 @@ function getSkillsDirs(platform) {
302
302
  return profile.skillsDirs.map((segments) => path.join(home, ...segments));
303
303
  }
304
304
 
305
+ /**
306
+ * Every AI-agent platform whose directory actually exists in the home dir.
307
+ *
308
+ * `detectPlatform()` short-circuits to a single platform (opencode is the
309
+ * fallback), which is right for "which one do I target by default" but wrong for
310
+ * "which agents does this machine actually run" -- a migration period, or a user
311
+ * who keeps two tools, has several live trees, and an update must reach all of
312
+ * them. Each profile's `skillsDirs` lists home-relative path segments; a platform
313
+ * counts as installed when any of its directories exists.
314
+ *
315
+ * @returns {string[]} installed platform names, in PLATFORM_PROFILES order
316
+ */
317
+ function detectInstalledPlatforms() {
318
+ const home = require('./shared-paths.js').HOME_DIR;
319
+ const installed = [];
320
+ for (const [platform, profile] of Object.entries(PLATFORM_PROFILES)) {
321
+ if (profile.skillsDirs.some((segments) => fs.existsSync(path.join(home, ...segments)))) {
322
+ installed.push(platform);
323
+ }
324
+ }
325
+ return installed;
326
+ }
327
+
305
328
  /**
306
329
  * Resolve a bash that can actually run the hooks.
307
330
  *
@@ -659,6 +682,7 @@ module.exports = {
659
682
  autoInstallDeps,
660
683
  detectPlatform,
661
684
  getSkillsDirs,
685
+ detectInstalledPlatforms,
662
686
  PLATFORM_PROFILES,
663
687
  REQUIRED_DEPS,
664
688
  GATE_CLI_TOOLS,
package/lib/doctor.js CHANGED
@@ -21,7 +21,7 @@ const CONFIG_FILE = () => sharedPaths.CONFIG_FILE;
21
21
  const GLOBAL_HOOKS_DIR = () => sharedPaths.GLOBAL_HOOKS_DIR;
22
22
  const GLOBAL_ADAPTERS_DIR = () => sharedPaths.GLOBAL_ADAPTERS_DIR;
23
23
  const { checkUpgrade, formatUpgradeMsg } = require('./check-version.js');
24
- const { GATE_CLI_TOOLS, checkCliTool, getToolInstallCmd, checkBash } = require('./detect-deps.js');
24
+ const { GATE_CLI_TOOLS, checkCliTool, getToolInstallCmd, checkBash, detectInstalledPlatforms, getSkillsDirs } = require('./detect-deps.js');
25
25
  // #505: the single source of truth for module resolution order. The hook reads a
26
26
  // generated shell projection of this same module.
27
27
  const { RESOLUTION_ORDER } = require('./hook-module-resolution.js');
@@ -3343,7 +3343,13 @@ async function doctorDelphi(options = {}) {
3343
3343
  }
3344
3344
  return out;
3345
3345
  };
3346
- if (!fs.existsSync(path.join(installedSkillDir, 'SKILL.md'))) {
3346
+ // #725 follow-up: decide "installed anywhere?" from the full platform set, not
3347
+ // just the directory `detectPlatform()` selects — a dsh-only machine has no
3348
+ // marker detectPlatform() keys on, so the opencode fallback directory never
3349
+ // exists and the old check falsely reported "not installed" even though the
3350
+ // copy IS deployed under ~/.dsh/skills.
3351
+ const deployedDirs = otherDeployedSkillDirs(installedSkillDir);
3352
+ if (deployedDirs.length === 0) {
3347
3353
  add('Delphi skill install parity', 'FAIL',
3348
3354
  `not installed at ${installedSkillDir} (install: xp-gate install-skill delphi-review)`);
3349
3355
  } else {
@@ -3357,24 +3363,26 @@ async function doctorDelphi(options = {}) {
3357
3363
  add('Delphi skill install parity', 'FAIL',
3358
3364
  `bundled skill directory missing or empty: ${bundledSkillDir} — reinstall xp-gate`);
3359
3365
  } else {
3366
+ // #725: check every platform that has the skill deployed, not only the one
3367
+ // `detectPlatform()` selects. On a machine with both Qoder and OpenCode the
3368
+ // OpenCode copy was frozen at an old version and this check reported PASS,
3369
+ // because it never looked there -- the drifted copy was invisible to both
3370
+ // `update-skill` and `doctor`.
3360
3371
  const drifted = [];
3361
- for (const rel of bundledFiles) {
3362
- const installedPath = path.join(installedSkillDir, rel);
3363
- const bundledPath = path.join(bundledSkillDir, rel);
3364
- if (!fs.existsSync(installedPath)) {
3365
- drifted.push(`${rel}: missing`);
3366
- } else if (sha256(installedPath) !== sha256(bundledPath)) {
3367
- drifted.push(`${rel}: differs from bundle`);
3372
+ for (const dir of deployedDirs) {
3373
+ const dirDrift = collectSkillDrift(dir, bundledFiles, bundledSkillDir, sha256);
3374
+ if (dirDrift.length > 0) {
3375
+ drifted.push(`${platformTag(dir)}: ${dirDrift.join(', ')}`);
3368
3376
  }
3369
3377
  }
3370
3378
  if (drifted.length > 0) {
3371
3379
  add('Delphi skill install parity', 'FAIL',
3372
- `${drifted.length} file(s) differ from the ${getCliVersionSafe()} bundle: `
3373
- + `${drifted.slice(0, 5).join('; ')}${drifted.length > 5 ? '; ...' : ''} `
3380
+ `installed copy differs from the ${getCliVersionSafe()} bundle — `
3381
+ + `${drifted.slice(0, 3).join('; ')}${drifted.length > 3 ? '; ...' : ''} `
3374
3382
  + '(reinstall: xp-gate install-skill delphi-review --force)');
3375
3383
  } else {
3376
3384
  add('Delphi skill install parity', 'PASS',
3377
- `${bundledFiles.length} file(s) match the bundle`);
3385
+ `${bundledFiles.length} file(s) match the bundle across ${deployedDirs.length} platform(s)`);
3378
3386
  }
3379
3387
  }
3380
3388
  }
@@ -3591,6 +3599,66 @@ function getCliVersionSafe() {
3591
3599
  }
3592
3600
  }
3593
3601
 
3602
+ /**
3603
+ * Skills directories that hold the delphi-review skill, including the detected one.
3604
+ *
3605
+ * #725: `detectPlatform()` returns a single platform, so on a machine carrying
3606
+ * both Qoder and OpenCode the OpenCode copy of the skill was never examined —
3607
+ * `update-skill` did not refresh it and `doctor` reported PASS anyway. Both were
3608
+ * looking at the same one directory. A migration period (user moved between AI
3609
+ * tools, both trees still live) is the normal case that produces this, and the
3610
+ * frozen copy is exactly what a parity check exists to catch.
3611
+ *
3612
+ * The original fix still hardcoded the three pre-dsh platforms, so the DSH copy
3613
+ * (`~/.dsh/skills/delphi-review`) — which `detectPlatform()` never returns and
3614
+ * `PLATFORM_PROFILES` does carry — stayed invisible. Derive the candidates from
3615
+ * the profiles so every installed platform is examined.
3616
+ *
3617
+ * @param {string} detectedDir the platform `detectPlatform()` selected
3618
+ * @returns {string[]} directories that actually contain the skill
3619
+ */
3620
+ function otherDeployedSkillDirs(detectedDir) {
3621
+ const candidates = [detectedDir];
3622
+ for (const platform of detectInstalledPlatforms()) {
3623
+ candidates.push(path.join(getSkillsDirs(platform)[0], 'delphi-review'));
3624
+ }
3625
+ const seen = new Set();
3626
+ const found = [];
3627
+ for (const dir of candidates) {
3628
+ if (seen.has(dir)) continue;
3629
+ seen.add(dir);
3630
+ if (fs.existsSync(path.join(dir, 'SKILL.md'))) found.push(dir);
3631
+ }
3632
+ return found;
3633
+ }
3634
+
3635
+ /**
3636
+ * A short label for a skills directory: the platform segment, not the full path.
3637
+ * Keeps the doctor line readable when two platforms disagree.
3638
+ */
3639
+ function platformTag(skillDir) {
3640
+ const parent = path.basename(path.dirname(path.dirname(skillDir)));
3641
+ return parent === 'opencode' ? 'opencode' : parent;
3642
+ }
3643
+
3644
+ /**
3645
+ * Files in `skillDir` that are missing or differ from the bundle.
3646
+ * @returns {string[]} `rel: reason` entries; empty when the copy is clean
3647
+ */
3648
+ function collectSkillDrift(skillDir, bundledFiles, bundledSkillDir, sha256) {
3649
+ const drifted = [];
3650
+ for (const rel of bundledFiles) {
3651
+ const installedPath = path.join(skillDir, rel);
3652
+ const bundledPath = path.join(bundledSkillDir, rel);
3653
+ if (!fs.existsSync(installedPath)) {
3654
+ drifted.push(`${rel}: missing`);
3655
+ } else if (sha256(installedPath) !== sha256(bundledPath)) {
3656
+ drifted.push(`${rel}: differs`);
3657
+ }
3658
+ }
3659
+ return drifted;
3660
+ }
3661
+
3594
3662
  module.exports = {
3595
3663
  doctor, doctorDelphi, isXpGateFile, SIGNATURES,
3596
3664
  fixVersionMismatch,
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { checkDeps } = require('./detect-deps.js');
3
+ const { checkDeps, detectInstalledPlatforms, getSkillsDirs } = require('./detect-deps.js');
4
4
  const { rollback } = require('./rollback.js');
5
5
  const { HOME_DIR, CONFIG_DIR, detectPlatform } = require('./shared-paths.js');
6
6
  const { copyDirRecursive } = require('./shared-utils');
@@ -16,6 +16,29 @@ function getSkillsDir() {
16
16
  return path.join(HOME_DIR, '.config', 'opencode', 'skills');
17
17
  }
18
18
 
19
+ /**
20
+ * Every skills directory that belongs to an installed platform.
21
+ *
22
+ * #725 fixed the single-platform assumption, but the enumeration still hardcoded
23
+ * the three pre-dsh platforms (`.qoder`, `.config/opencode`, `.claude`) and so
24
+ * missed `~/.dsh/skills` -- the DSH platform `detectPlatform()` never returns and
25
+ * `PLATFORM_PROFILES` does carry. Derive the target set from the profiles instead
26
+ * of a parallel list: a machine with Qoder, OpenCode and DSH all present must
27
+ * have all three updated, and a migration period (two tools live) must not leave
28
+ * one silently frozen.
29
+ *
30
+ * A machine with no agent installed yet still needs one target, so fall back to
31
+ * the platform `detectPlatform()` selects and keep plain `install-skill` working.
32
+ *
33
+ * @returns {string[]} home-relative skills directories, de-duplicated
34
+ */
35
+ function deployedSkillsDirs() {
36
+ const installed = detectInstalledPlatforms();
37
+ if (installed.length === 0) return [getSkillsDir()];
38
+ const dirs = installed.map((platform) => getSkillsDirs(platform)[0]);
39
+ return [...new Set(dirs)];
40
+ }
41
+
19
42
  // The npm package ships the canonical skill content; `files` includes skills/.
20
43
  const BUNDLED_SKILLS_DIR = path.join(__dirname, '..', 'skills');
21
44
 
@@ -87,26 +110,52 @@ async function installSkill(name, options = {}) {
87
110
  return 1;
88
111
  }
89
112
 
90
- const targetDir = path.join(getSkillsDir(), name);
91
- const dupError = checkDuplicateInstall(targetDir, force);
92
- if (dupError) {
93
- console.error(dupError);
94
- return 1;
95
- }
113
+ // #725: update EVERY platform that already carries this skill, not just the
114
+ // one `detectPlatform()` picks. A machine with both Qoder and OpenCode used to
115
+ // get one silently-frozen copy. Each target is installed independently so a
116
+ // failure on one platform does not abort the others -- a half-updated machine
117
+ // that reports success is exactly the bug being fixed.
118
+ //
119
+ // deployedSkillsDirs() already falls back to the detected platform when nothing
120
+ // is deployed anywhere, so it never returns an empty list.
121
+ const finalTargets = deployedSkillsDirs().map((dir) => path.join(dir, name));
122
+
123
+ // `lastError` alone was wrong: an unreadable platform (e.g. ~/.qoder/skills is
124
+ // a file, so readdir throws ENOTDIR) set it to 1 and nothing reset it when the
125
+ // other platform installed fine -- the CLI then reported failure for a machine
126
+ // that had in fact been updated. Track successes instead: the command failed
127
+ // only when EVERY target failed.
128
+ let succeeded = 0;
129
+ let lastError = 0;
130
+ for (const targetDir of finalTargets) {
131
+ const dupError = checkDuplicateInstall(targetDir, force);
132
+ if (dupError) {
133
+ console.error(dupError);
134
+ lastError = 1;
135
+ continue;
136
+ }
96
137
 
97
- const installId = `${name}-${Date.now()}`;
98
- const backupDir = path.join(CONFIG_DIR, 'backup', installId);
99
- backupExisting(targetDir, installId, backupDir);
138
+ // The platform segment keeps concurrent per-platform backups distinct; a bare
139
+ // `Date.now()` collided when both platforms were written in the same tick.
140
+ const platformTag = path.basename(path.dirname(targetDir));
141
+ const installId = `${name}-${Date.now()}-${platformTag}`;
142
+ const backupDir = path.join(CONFIG_DIR, 'backup', installId);
143
+ backupExisting(targetDir, installId, backupDir);
100
144
 
101
- try {
102
- const result = await performInstall(name, targetDir, verbose);
103
- if (result !== 0) return result;
104
- return 0;
105
- } catch (err) {
106
- console.error(`Error: Install failed - ${err.message}`);
107
- await rollback(installId);
108
- return 1;
145
+ try {
146
+ if (verbose && finalTargets.length > 1) console.log(`Target: ${targetDir}`);
147
+ const result = await performInstall(name, targetDir, verbose);
148
+ if (result !== 0) lastError = result;
149
+ else succeeded += 1;
150
+ } catch (err) {
151
+ console.error(`Error: Install failed - ${err.message}`);
152
+ await rollback(installId);
153
+ lastError = 1;
154
+ }
109
155
  }
156
+
157
+ if (succeeded > 0) return 0;
158
+ return lastError || 1;
110
159
  }
111
160
 
112
161
  function checkDuplicateInstall(targetDir, force) {
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-11-03T07:08:33.951Z",
5
+ "checked_at": "2026-11-05T12:23:08.769Z",
6
6
  "source": "network"
7
7
  }