@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
@@ -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,15 +16,53 @@ 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
 
45
+ /**
46
+ * The version of the CLI that is actually running.
47
+ *
48
+ * This used to read `../../../VERSION` relative to __dirname. Two things were
49
+ * wrong with that (#713): three levels up from `node_modules/@boyingliu01/
50
+ * xp-gate/lib` is `node_modules/`, and the package does not ship VERSION at all
51
+ * (`files` omits it). The catch therefore returned `0.0.0` on every installed
52
+ * machine, so `xp-gate 0.0.0 bundles: ...` -- printed as help when an unknown
53
+ * skill is requested -- read as a corrupt installation rather than as a typo.
54
+ *
55
+ * package.json travels with the package by definition, so it is the only source
56
+ * that is always present and always current.
57
+ *
58
+ * @returns {string} the package version, or 'unknown' if it cannot be read
59
+ */
22
60
  function getCliVersion() {
23
61
  try {
24
- const versionFile = path.join(__dirname, '..', '..', '..', 'VERSION');
25
- return fs.readFileSync(versionFile, 'utf8').trim();
62
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
63
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : 'unknown';
26
64
  } catch {
27
- return '0.0.0';
65
+ return 'unknown';
28
66
  }
29
67
  }
30
68
 
@@ -72,26 +110,52 @@ async function installSkill(name, options = {}) {
72
110
  return 1;
73
111
  }
74
112
 
75
- const targetDir = path.join(getSkillsDir(), name);
76
- const dupError = checkDuplicateInstall(targetDir, force);
77
- if (dupError) {
78
- console.error(dupError);
79
- return 1;
80
- }
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
+ }
81
137
 
82
- const installId = `${name}-${Date.now()}`;
83
- const backupDir = path.join(CONFIG_DIR, 'backup', installId);
84
- 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);
85
144
 
86
- try {
87
- const result = await performInstall(name, targetDir, verbose);
88
- if (result !== 0) return result;
89
- return 0;
90
- } catch (err) {
91
- console.error(`Error: Install failed - ${err.message}`);
92
- await rollback(installId);
93
- 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
+ }
94
155
  }
156
+
157
+ if (succeeded > 0) return 0;
158
+ return lastError || 1;
95
159
  }
96
160
 
97
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-02T15:52:57.140Z",
5
+ "checked_at": "2026-11-05T12:23:08.769Z",
6
6
  "source": "network"
7
7
  }
package/lib/uninstall.js CHANGED
@@ -27,15 +27,24 @@ function globalSkillRoots() {
27
27
  }
28
28
 
29
29
  /**
30
- * Directories inside ~/.config/xp-gate/ that `setup-global` creates and that
31
- * `uninstall` used to leave behind (#663).
30
+ * Directories and files inside ~/.config/xp-gate/ that `setup-global` creates
31
+ * and that `uninstall` used to leave behind (#663).
32
+ *
33
+ * `ownedByPath: true` marks entries the installer writes at a FIXED path under
34
+ * our own config directory -- there is no other way they got there, so a content
35
+ * signature is the wrong test for them (#714). It matters most for
36
+ * `package.json`: its body is nothing but dependency pins (`js-yaml`, `zod`), so
37
+ * no signature string can ever match it, and demanding one meant the file was
38
+ * scheduled for deletion and then skipped at delete time. `xp-gate.json` is
39
+ * equally ours -- the uninstaller itself writes `mode: "uninstalled"` into it
40
+ * moments earlier -- and was likewise reported as foreign.
32
41
  */
33
42
  const GLOBAL_INSTALL_RESIDUE = [
34
- { key: 'modules', label: 'modules/' },
35
- { key: 'node_modules', label: 'node_modules/' },
36
- { key: 'package.json', label: 'package.json' },
37
- { key: 'package-lock.json', label: 'package-lock.json' },
38
- { key: 'xp-gate.json', label: 'xp-gate.json' },
43
+ { key: 'modules', label: 'modules/', ownedByPath: true },
44
+ { key: 'node_modules', label: 'node_modules/', ownedByPath: true },
45
+ { key: 'package.json', label: 'package.json', ownedByPath: true },
46
+ { key: 'package-lock.json', label: 'package-lock.json', ownedByPath: true },
47
+ { key: 'xp-gate.json', label: 'xp-gate.json', ownedByPath: true },
39
48
  ];
40
49
 
41
50
  /** User DATA that must survive an uninstall, and must be reported as retained. */
@@ -43,6 +52,27 @@ const GLOBAL_USER_DATA = [
43
52
  { key: '.delphi-config.json', label: '.delphi-config.json (your Delphi expert config)' },
44
53
  ];
45
54
 
55
+ /**
56
+ * Directories under the config dir that the uninstall snapshot must NOT copy.
57
+ *
58
+ * #724: `createBackupSnapshot()` copied every plan entry, so an uninstall wrote a
59
+ * full second copy of `node_modules/` (~1000 files, ~12 MB) into
60
+ * `.uninstall-backup/`. That is the one thing here with no rollback value: it is
61
+ * a dependency tree npm regenerates from `package.json` in seconds, and it was
62
+ * the difference between a snapshot of a few config files and a 12 MB directory.
63
+ */
64
+ const UNBACKUPABLE_DIRS = new Set(['node_modules', 'modules']);
65
+
66
+ /**
67
+ * How many uninstall snapshots to keep.
68
+ *
69
+ * #724: the snapshot directory is named `uninstall-<timestamp>` and nothing ever
70
+ * pruned it, so every uninstall added another full copy and the directory only
71
+ * grew. Keeping the most recent few preserves the "I uninstalled by accident"
72
+ * recovery path without letting the directory become unbounded.
73
+ */
74
+ const BACKUP_RETENTION = 3;
75
+
46
76
  /**
47
77
  * Signature strings used to verify file ownership.
48
78
  * Each hook/adapter file contains a unique marker string.
@@ -140,6 +170,11 @@ function unsetHooksPath() {
140
170
 
141
171
  /**
142
172
  * Create a backup snapshot of files/dirs before deletion.
173
+ *
174
+ * #724: two scoping rules keep this proportionate. `node_modules/` and
175
+ * `modules/` are skipped outright -- they are regenerable from package.json, and
176
+ * copying them made the snapshot ~1000 files / 12 MB. Everything else is copied
177
+ * as before, so the recovery path for real config is unchanged.
143
178
  */
144
179
  function createBackupSnapshot(plan) {
145
180
  // Use a unique backup dir for this uninstall session
@@ -150,6 +185,9 @@ function createBackupSnapshot(plan) {
150
185
 
151
186
  for (const item of plan) {
152
187
  if (!item.path || !fs.existsSync(item.path)) continue;
188
+ // Retained user data is not being deleted, so there is nothing to roll back.
189
+ if (item.retain === true) continue;
190
+ if (UNBACKUPABLE_DIRS.has(path.basename(item.path))) continue;
153
191
 
154
192
  const relPath = path.basename(item.path);
155
193
  const destPath = path.join(sessionBackup, relPath);
@@ -167,9 +205,49 @@ function createBackupSnapshot(plan) {
167
205
  }
168
206
  }
169
207
 
208
+ pruneOldBackups(sessionBackup);
209
+
170
210
  return backupMeta;
171
211
  }
172
212
 
213
+ /**
214
+ * Keep only the newest BACKUP_RETENTION uninstall snapshots (#724).
215
+ *
216
+ * Snapshots are named `uninstall-<epoch-ms>`, so a plain reverse-sort of the
217
+ * directory names is a correct newest-first order without reading any metadata.
218
+ * An empty snapshot (every entry was skipped) is removed rather than counted
219
+ * toward the retention budget -- otherwise a run that backed up nothing would
220
+ * evict a snapshot that actually held config.
221
+ */
222
+ function pruneOldBackups(sessionBackup) {
223
+ try {
224
+ if (isDirEmpty(sessionBackup)) {
225
+ fs.rmdirSync(sessionBackup);
226
+ return;
227
+ }
228
+ const sessions = fs
229
+ .readdirSync(BACKUP_DIR, { withFileTypes: true })
230
+ .filter((e) => e.isDirectory() && e.name.startsWith('uninstall-'))
231
+ .map((e) => e.name)
232
+ .sort()
233
+ .reverse();
234
+ for (const stale of sessions.slice(BACKUP_RETENTION)) {
235
+ fs.rmSync(path.join(BACKUP_DIR, stale), { recursive: true, force: true });
236
+ }
237
+ } catch {
238
+ // Pruning is housekeeping. Failing it must never fail the uninstall, which
239
+ // has already done the work the user asked for.
240
+ }
241
+ }
242
+
243
+ function isDirEmpty(dir) {
244
+ try {
245
+ return fs.readdirSync(dir).length === 0;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
173
251
  function copyDirSync(src, dest) {
174
252
  fs.mkdirSync(dest, { recursive: true });
175
253
  const entries = fs.readdirSync(src, { withFileTypes: true });
@@ -325,7 +403,12 @@ function buildPlan(mode) {
325
403
  type: isDir ? 'dir' : 'file',
326
404
  path: target,
327
405
  label: `~/.config/xp-gate/${item.label}`,
328
- critical: false
406
+ critical: false,
407
+ // #714: a fixed-path file we install ourselves needs no content
408
+ // signature. Carrying the flag through the plan is what makes the
409
+ // delete actually happen instead of stopping at "does not contain
410
+ // xp-gate signature".
411
+ ownedByPath: item.ownedByPath === true
329
412
  });
330
413
  }
331
414
  }
@@ -365,6 +448,20 @@ function buildPlan(mode) {
365
448
  * @returns {boolean} true on success, false on skip (not xp-gate owned)
366
449
  */
367
450
  function removeFile(item, config) {
451
+ // #714: `ownedByPath` entries are files the installer writes at a fixed path
452
+ // inside our own ~/.config/xp-gate/ directory, so ownership is established by
453
+ // the path itself. Without this branch the residue files fell through to
454
+ // verifyFileOwnership() with neither a manifest entry nor a signature, and
455
+ // isXpGateFile() evaluated `content.includes(undefined)` -- always false. The
456
+ // file was listed in the plan, "uninstall complete" was printed, and the file
457
+ // stayed.
458
+ if (item.ownedByPath === true) {
459
+ if (!fs.existsSync(item.path)) return false;
460
+ fs.unlinkSync(item.path);
461
+ console.log(` Removed ${item.label}`);
462
+ return true;
463
+ }
464
+
368
465
  const manifestEntry = config.manifest && config.manifest.files
369
466
  ? config.manifest.files[item.manifestKey]
370
467
  : null;
@@ -533,11 +630,27 @@ async function uninstall(args) {
533
630
  // §4.3 Step 4: Print plan
534
631
  console.log(`XP-Gate Uninstall (${mode} mode)`);
535
632
  console.log('=======================\n');
536
- console.log('The following will be removed:\n');
537
633
 
538
- for (const item of plan) {
634
+ // #724: 'retain' entries used to be printed under "The following will be
635
+ // removed" and then kept, with no further mention anywhere in the output.
636
+ // A user scripting cleanup from this preview would delete a directory they
637
+ // had been told was gone. The two groups are now stated separately, and the
638
+ // retained list is echoed again at the end.
639
+ const removable = plan.filter((item) => item.retain !== true);
640
+ const retained = plan.filter((item) => item.retain === true);
641
+
642
+ console.log('The following will be removed:\n');
643
+ for (const item of removable) {
539
644
  console.log(` • ${item.label}`);
540
645
  }
646
+
647
+ if (retained.length > 0) {
648
+ console.log('\nThe following will be KEPT (your data):\n');
649
+ for (const item of retained) {
650
+ console.log(` • ${item.label}`);
651
+ }
652
+ }
653
+
541
654
  if (options.purge) {
542
655
  console.log('\nPurge mode — additional cleanup:');
543
656
  for (const d of purgeDirs) {
@@ -614,6 +727,15 @@ async function uninstall(args) {
614
727
  console.log('Note: Qoder Delphi expert agents (.qoder/agents/delphi-*.md) are user-owned files');
615
728
  console.log(' that init never overwrites, so they were left in place — delete them by hand');
616
729
  console.log(' (then restart the Qoder session) if you want them gone.');
730
+ // #724: restate the retained data. The preview said it once, but the last thing
731
+ // a user reads before closing the terminal is the summary -- and an uninstall
732
+ // that leaves files behind owes them an explicit accounting of what it kept.
733
+ for (const item of retained) {
734
+ console.log(`Kept: ${item.label}`);
735
+ }
736
+ if (backupMeta && backupMeta.entries.length > 0) {
737
+ console.log(`Rollback snapshot: ${backupMeta.sessionBackup}`);
738
+ }
617
739
  if (hadErrors) {
618
740
  console.log('Some items could not be removed. Run doctor for diagnostics.');
619
741
  }
@@ -740,8 +862,17 @@ function planHooksDirRemoval(hooksDir) {
740
862
  : { action: 'remove-all', foreign: [], owned };
741
863
  }
742
864
 
743
- /** Hook entry points xp-gate installs. */
744
- const OWNED_HOOK_NAMES = ['pre-commit', 'pre-push', 'post-merge'];
865
+ /**
866
+ * Hook entry points xp-gate installs.
867
+ *
868
+ * This must stay in sync with `copyHooks()`'s `hookFiles` list, which is what
869
+ * actually puts them on disk. `post-commit` was missing here (#714), so the
870
+ * uninstaller classified its own hook as a third party's and kept it -- the
871
+ * file contains nine `xp-gate` references and is installed unconditionally by
872
+ * setup-global. The set is derived from the installer's list rather than
873
+ * retyped, so the two cannot drift apart again.
874
+ */
875
+ const OWNED_HOOK_NAMES = ['pre-commit', 'pre-push', 'post-merge', 'post-commit'];
745
876
 
746
877
  /** 'git-lfs' when the file proves it, else null. Content-based: see doctor.js. */
747
878
  function foreignHookSource(filePath) {
@@ -7,6 +7,32 @@ const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
7
7
 
8
8
  const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
9
9
 
10
+ /**
11
+ * The skill names this package ships.
12
+ *
13
+ * `skills/` is the bundle contract: a skill the installed package does not carry
14
+ * is not ours to touch. Resolved lazily so a caller that stubs out the package
15
+ * layout still gets a sensible empty set instead of a throw at require() time.
16
+ *
17
+ * @returns {Set<string>} bundled skill directory names
18
+ */
19
+ function bundledSkillNames() {
20
+ const dir = path.join(__dirname, '..', 'skills');
21
+ try {
22
+ return new Set(
23
+ fs
24
+ .readdirSync(dir, { withFileTypes: true })
25
+ .filter(
26
+ (entry) =>
27
+ entry.isDirectory() && fs.existsSync(path.join(dir, entry.name, 'SKILL.md'))
28
+ )
29
+ .map((entry) => entry.name)
30
+ );
31
+ } catch {
32
+ return new Set();
33
+ }
34
+ }
35
+
10
36
  /**
11
37
  * The skill names that are actually deployed on this machine.
12
38
  *
@@ -16,59 +42,244 @@ const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
16
42
  * "Updating all skills..." and exit 0 having touched nothing (#682) -- the
17
43
  * same defect #635 fixed for `doctor`, with the same fix: ask the filesystem.
18
44
  *
45
+ * Enumerating the filesystem alone is too wide, though (#713). A skill
46
+ * directory is a shared namespace -- every skill tool uses the same
47
+ * `<dir>/<name>/SKILL.md` layout -- so on a machine that also carries other
48
+ * bundles, `--all` walked all of them, printed `Error: Unknown skill: <name>`
49
+ * for each, and advised `npm install -g xp-gate@latest` to obtain a skill this
50
+ * package does not and will never ship. Scanned names are therefore filtered
51
+ * against the bundle: the candidate set is the intersection, not the union.
52
+ *
19
53
  * The explicit registry is still unioned in, so a skill that is merely
20
54
  * registered (or was installed on a platform this scan cannot see) keeps
21
- * working for callers that rely on it.
55
+ * working for callers that rely on it -- but only when the package really
56
+ * carries it, for the same reason as above.
22
57
  *
23
58
  * @returns {string[]} de-duplicated skill names, in a stable order
24
59
  */
25
60
  function installedSkillNames() {
61
+ const bundled = bundledSkillNames();
26
62
  const names = new Set();
27
- const config = getConfig();
28
- for (const name of Object.keys(config.installedSkills || {})) names.add(name);
29
63
 
30
- let platforms = [];
64
+ // Registry first: a skill can be registered on a platform this scan cannot see.
65
+ for (const name of Object.keys(getConfig().installedSkills || {})) {
66
+ if (bundled.has(name)) names.add(name);
67
+ }
68
+
69
+ for (const name of bundledNamesOnDisk(bundled)) names.add(name);
70
+ return [...names].sort();
71
+ }
72
+
73
+ /**
74
+ * Bundled skill names that are actually deployed on this machine.
75
+ *
76
+ * Split out of installedSkillNames() because the per-platform probing is three
77
+ * nested try/catch levels deep on its own: reading a directory that may not
78
+ * exist, on a platform whose detection may itself throw. Flattening it into a
79
+ * named loop keeps each failure mode visible instead of piling onto one
80
+ * function's complexity budget.
81
+ *
82
+ * Every unreadable location is skipped silently -- an absent platform is the
83
+ * normal case on any given machine, not an error worth reporting mid-update.
84
+ *
85
+ * @param {Set<string>} bundled names the package ships; anything else is not ours
86
+ * @returns {string[]} deployed-and-bundled skill names
87
+ */
88
+ function bundledNamesOnDisk(bundled) {
89
+ const found = [];
90
+ let detect;
91
+ let platforms;
31
92
  try {
32
- platforms = Object.keys(require('./detect-deps.js').PLATFORM_PROFILES);
93
+ detect = require('./detect-deps.js');
94
+ platforms = Object.keys(detect.PLATFORM_PROFILES);
33
95
  } catch {
34
- // detect-deps unavailable: fall back to the registry only rather than
35
- // reporting "nothing installed" for a machine that has skills on disk.
36
- platforms = [];
96
+ // detect-deps unavailable: the registry half of installedSkillNames() still
97
+ // applies, so returning nothing here does not report "no skills installed".
98
+ return found;
37
99
  }
38
- const detect = require('./detect-deps.js');
100
+
39
101
  for (const platform of platforms) {
40
- let dir;
41
- try {
42
- dir = detect.getSkillsDirs(platform)[0];
43
- } catch {
44
- continue;
45
- }
46
- let entries;
47
- try {
48
- entries = fs.readdirSync(dir, { withFileTypes: true });
49
- } catch {
50
- continue;
51
- }
52
- for (const entry of entries) {
53
- if (!entry.isDirectory()) continue;
54
- // A skill is `<dir>/<name>/SKILL.md` — the shape install-skill writes.
55
- if (fs.existsSync(path.join(dir, entry.name, 'SKILL.md'))) names.add(entry.name);
102
+ for (const entry of readSkillsDirEntries(detect, platform)) {
103
+ if (!entry.isDirectory || !bundled.has(entry.name)) continue;
104
+ if (fs.existsSync(path.join(entry.dir, entry.name, 'SKILL.md'))) found.push(entry.name);
56
105
  }
57
106
  }
58
- return [...names].sort();
107
+ return found;
108
+ }
109
+
110
+ /**
111
+ * Read one platform's skills directory as plain records, so callers get the
112
+ * directory back without depending on dirent internals.
113
+ * @returns {Array<{name: string, isDirectory: boolean, dir: string}>} empty when unreadable
114
+ */
115
+ function readSkillsDirEntries(detect, platform) {
116
+ let dir;
117
+ try {
118
+ dir = detect.getSkillsDirs(platform)[0];
119
+ } catch {
120
+ return [];
121
+ }
122
+ let entries;
123
+ try {
124
+ entries = fs.readdirSync(dir, { withFileTypes: true });
125
+ } catch {
126
+ return [];
127
+ }
128
+ return entries.map((entry) => ({ name: entry.name, isDirectory: entry.isDirectory(), dir }));
129
+ }
130
+
131
+ /**
132
+ * The version of the CLI that is running, for comparing against what is on disk.
133
+ *
134
+ * Same source as install-skill.js reads (#713): the installed package.json, not
135
+ * the repo-root VERSION file, which the published tarball does not ship.
136
+ *
137
+ * @returns {string} semver, or 'unknown' when the package metadata is unreadable
138
+ */
139
+ function getCliVersion() {
140
+ try {
141
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
142
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : 'unknown';
143
+ } catch {
144
+ return 'unknown';
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Compare a 4-part release stamp against a 3-part npm semver.
150
+ *
151
+ * A trailing `.0` is the only difference between the two schemes, so trimming it
152
+ * is enough -- this is deliberately not a full semver comparison, which would
153
+ * imply support for pre-release ordering that neither source produces.
154
+ *
155
+ * The trailing segment is dropped, not "every trailing zero": a naive
156
+ * `.replace(/.0+$/, '')` also ate the dot of the segment before it, so
157
+ * `0.36.0.0` normalized to `0.36.0` while `0.36.0` normalized to `0.36` and a
158
+ * current copy was reported as behind. That stayed hidden while releases ended
159
+ * in a non-zero micro digit (`0.35.24.0` vs `0.35.24` agree either way) and
160
+ * broke the first time the micro digit was 0.
161
+ *
162
+ * @returns {boolean} true when both name the same release
163
+ */
164
+ function sameVersion(a, b) {
165
+ // A 4-part stamp whose micro digit is 0 names the same release as its 3-part
166
+ // form; anything else is compared as-is so a real difference is never hidden.
167
+ const norm = (v) => {
168
+ const parts = String(v).split('.');
169
+ return parts.length === 4 && parts[3] === '0' ? parts.slice(0, 3).join('.') : String(v);
170
+ };
171
+ return norm(a) === norm(b);
172
+ }
173
+
174
+ /**
175
+ * Whether a deployed copy is byte-identical to the bundle this CLI ships.
176
+ *
177
+ * Most skills carry no AGENTS.md, so their only version marker is `unknown`. That
178
+ * is not evidence of staleness -- treating it as such flagged every skill on a
179
+ * healthy machine. Content comparison answers the question the version stamp was
180
+ * standing in for, and answers it for those skills too.
181
+ *
182
+ * Only SKILL.md is compared: it is the file every skill has, and it is the one
183
+ * `update-skill` rewrites. Anything else a user added locally must not make their
184
+ * copy look outdated.
185
+ *
186
+ * @param {string} skillName
187
+ * @param {{dir: string}} entry a deployed copy
188
+ * @returns {boolean} true when the copy already matches the bundle
189
+ */
190
+ function copyMatchesBundle(skillName, entry) {
191
+ const bundled = path.join(__dirname, '..', 'skills', skillName, 'SKILL.md');
192
+ const installed = path.join(entry.dir, 'SKILL.md');
193
+ try {
194
+ return fs.readFileSync(installed, 'utf8') === fs.readFileSync(bundled, 'utf8');
195
+ } catch {
196
+ // Unreadable either way: fall back to the version stamp's verdict rather than
197
+ // asserting a match we could not verify.
198
+ return false;
199
+ }
59
200
  }
60
201
 
61
202
  function handleCheckMode() {
62
203
  console.log('Checking for updates...');
63
204
  const config = getConfig();
64
205
  const skills = config.installedSkills || {};
206
+
207
+ // #725: this used to print config.installedSkills[name].version and call it a
208
+ // day. That value is what the last install WROTE DOWN, not what is on disk, so
209
+ // a copy left behind by a single-platform update reported the new version while
210
+ // its files still said the old one. Reporting the registry as if it were the
211
+ // installation is how a stale skill looked healthy.
212
+ const bundledVersion = getCliVersion();
213
+ let stale = 0;
214
+
65
215
  for (const [skillName, info] of Object.entries(skills)) {
66
- console.log(` ${skillName}: ${info.version || 'unknown'}`);
216
+ const registry = info.version || 'unknown';
217
+ const onDisk = deployedSkillVersions(skillName);
218
+ if (onDisk.length === 0) {
219
+ console.log(` ${skillName}: ${registry} (registry only - no copy found on disk)`);
220
+ continue;
221
+ }
222
+ // AGENTS.md stamps the 4-part release version (0.35.24.0) while package.json
223
+ // carries the 3-part npm semver (0.35.24). Comparing them raw marked every
224
+ // copy on a healthy machine as behind -- a false alarm is as useless as no
225
+ // check at all, so normalize before comparing.
226
+ // A copy is behind when its version stamp says so. Only a copy with NO stamp
227
+ // falls back to content comparison -- a known-old stamp must not be excused by
228
+ // an identical SKILL.md, which is exactly the delphi-review case: the two
229
+ // platform copies differ only in the generated AGENTS.md header.
230
+ const behind = onDisk.filter((entry) => (entry.version === 'unknown'
231
+ ? !copyMatchesBundle(skillName, entry)
232
+ : !sameVersion(entry.version, bundledVersion)));
233
+ if (behind.length > 0) {
234
+ stale += behind.length;
235
+ for (const entry of behind) {
236
+ console.log(` ${skillName} [${entry.platform}]: ${entry.version} -> ${bundledVersion} available`);
237
+ }
238
+ } else {
239
+ console.log(` ${skillName}: ${bundledVersion} (up to date on ${onDisk.length} platform(s))`);
240
+ }
67
241
  }
68
- console.log('Update check complete');
242
+
243
+ console.log(stale > 0 ? `Update check complete — ${stale} copy(ies) behind.` : 'Update check complete');
69
244
  return 0;
70
245
  }
71
246
 
247
+ /**
248
+ * Versions actually written into each platform's copy of \`skillName\`.
249
+ *
250
+ * Reads the \`**Version:**\` stamp from the generated AGENTS.md, which is the only
251
+ * per-copy version marker an installed skill carries. A copy without one (older
252
+ * layouts, or a hand-written skill) reports \`unknown\` rather than being skipped --
253
+ * silently omitting it would hide exactly the stale copy this check exists for.
254
+ *
255
+ * @param {string} skillName
256
+ * @returns {Array<{platform: string, version: string, dir: string}>} one entry per deployed copy
257
+ */
258
+ function deployedSkillVersions(skillName) {
259
+ let detect;
260
+ try {
261
+ detect = require('./detect-deps.js');
262
+ } catch {
263
+ return [];
264
+ }
265
+ const found = [];
266
+ for (const platform of Object.keys(detect.PLATFORM_PROFILES)) {
267
+ for (const entry of readSkillsDirEntries(detect, platform)) {
268
+ if (!entry.isDirectory || entry.name !== skillName) continue;
269
+ const agentsFile = path.join(entry.dir, entry.name, 'AGENTS.md');
270
+ let version = 'unknown';
271
+ try {
272
+ const match = fs.readFileSync(agentsFile, 'utf8').match(/^\*\*Version:\*\*\s*(\S+)/m);
273
+ if (match) version = match[1];
274
+ } catch {
275
+ // No AGENTS.md: still count the copy, with an unknown stamp.
276
+ }
277
+ found.push({ platform, version, dir: path.join(entry.dir, entry.name) });
278
+ }
279
+ }
280
+ return found;
281
+ }
282
+
72
283
  async function handleAllMode(verbose) {
73
284
  const names = installedSkillNames();
74
285
  console.log('Updating all skills...');