@boyingliu01/xp-gate 0.35.23 → 0.35.24

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 (36) hide show
  1. package/adapter-common.sh +24 -35
  2. package/adapters/go.sh +6 -11
  3. package/adapters/python.sh +6 -11
  4. package/hooks/adapter-common.sh +24 -35
  5. package/lib/__tests__/doctor-delphi.test.js +3 -3
  6. package/lib/__tests__/doctor-tui-skill-freshness.test.js +3 -3
  7. package/lib/__tests__/install-skill.test.js +107 -20
  8. package/lib/__tests__/uninstall-global-residue.test.js +230 -9
  9. package/lib/__tests__/update-skill.test.js +179 -55
  10. package/lib/install-skill.js +18 -3
  11. package/lib/mattpocock-license-verdict.json +1 -1
  12. package/lib/uninstall.js +47 -10
  13. package/lib/update-skill.js +96 -26
  14. package/mock-policy/AGENTS.md +3 -3
  15. package/mutation/AGENTS.md +3 -3
  16. package/package.json +1 -1
  17. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  18. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  19. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  20. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  21. package/plugins/dsh/package.json +1 -1
  22. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  23. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  24. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  25. package/plugins/opencode/package.json +1 -1
  26. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  27. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  28. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  29. package/plugins/qoder/plugin.json +1 -1
  30. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  31. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  32. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  33. package/principles/AGENTS.md +3 -3
  34. package/skills/delphi-review/AGENTS.md +3 -3
  35. package/skills/sprint-flow/AGENTS.md +3 -3
  36. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -145,51 +145,55 @@ describe('update-skill', () => {
145
145
  expect(installSkillMock).not.toHaveBeenCalled();
146
146
  });
147
147
 
148
+ // These编排 tests use names the bundle really carries: since #713 `--all`
149
+ // only claims skills xp-gate ships, so a placeholder like 'foo' is filtered
150
+ // out before it can exercise the loop. The orchestration behaviour under test
151
+ // (fan-out, error propagation, verbose passthrough) is orthogonal to ownership.
148
152
  it('all=true updates every installed skill (calls installSkill each), returns 0 on success', async () => {
149
153
  writeConfig({
150
- foo: { version: '1.0.0' },
151
- bar: { version: '1.0.0' },
154
+ 'delphi-review': { version: '1.0.0' },
155
+ 'grilling': { version: '1.0.0' },
152
156
  });
153
157
  const { updateSkill } = require('../update-skill');
154
158
  const result = await updateSkill(null, { all: true });
155
159
  expect(result).toBe(0);
156
160
  expect(installSkillMock).toHaveBeenCalledTimes(2);
157
- expect(installSkillMock).toHaveBeenCalledWith('foo', {
161
+ expect(installSkillMock).toHaveBeenCalledWith('delphi-review', {
158
162
  force: true,
159
163
  verbose: false,
160
164
  });
161
- expect(installSkillMock).toHaveBeenCalledWith('bar', {
165
+ expect(installSkillMock).toHaveBeenCalledWith('grilling', {
162
166
  force: true,
163
167
  verbose: false,
164
168
  });
165
- expect(logSpy).toHaveBeenCalledWith('Updating foo...');
166
- expect(logSpy).toHaveBeenCalledWith('Updating bar...');
167
- expect(logSpy).toHaveBeenCalledWith('✓ foo updated');
168
- expect(logSpy).toHaveBeenCalledWith('✓ bar updated');
169
+ expect(logSpy).toHaveBeenCalledWith('Updating delphi-review...');
170
+ expect(logSpy).toHaveBeenCalledWith('Updating grilling...');
171
+ expect(logSpy).toHaveBeenCalledWith('✓ delphi-review updated');
172
+ expect(logSpy).toHaveBeenCalledWith('✓ grilling updated');
169
173
  });
170
174
 
171
175
  it('all=true returns 1 when at least one installSkill rejects', async () => {
172
176
  writeConfig({
173
- good: { version: '1.0.0' },
174
- bad: { version: '1.0.0' },
177
+ 'delphi-review': { version: '1.0.0' },
178
+ 'grilling': { version: '1.0.0' },
175
179
  });
176
180
  installSkillMock.mockImplementation(async (name) => {
177
- if (name === 'bad') throw new Error('boom');
181
+ if (name === 'grilling') throw new Error('boom');
178
182
  return 0;
179
183
  });
180
184
  const { updateSkill } = require('../update-skill');
181
185
  const result = await updateSkill(null, { all: true });
182
186
  expect(result).toBe(1);
183
- expect(errorSpy).toHaveBeenCalledWith('Failed to update bad: boom');
184
- expect(logSpy).toHaveBeenCalledWith('✓ good updated');
187
+ expect(errorSpy).toHaveBeenCalledWith('Failed to update grilling: boom');
188
+ expect(logSpy).toHaveBeenCalledWith('✓ delphi-review updated');
185
189
  });
186
190
 
187
191
  it('all=true propagates verbose flag into installSkill options', async () => {
188
- writeConfig({ foo: { version: '1.0.0' } });
192
+ writeConfig({ 'delphi-review': { version: '1.0.0' } });
189
193
  const { updateSkill } = require('../update-skill');
190
194
  const result = await updateSkill(null, { all: true, verbose: true });
191
195
  expect(result).toBe(0);
192
- expect(installSkillMock).toHaveBeenCalledWith('foo', {
196
+ expect(installSkillMock).toHaveBeenCalledWith('delphi-review', {
193
197
  force: true,
194
198
  verbose: true,
195
199
  });
@@ -289,79 +293,199 @@ describe('update-skill', () => {
289
293
  });
290
294
  });
291
295
 
292
- // ── #682: `--all` must enumerate what is on disk, not the config registry ────
293
- // `xp-gate init` / `setup-global` deploy skills to disk but never write
294
- // `config.installedSkills`, so `--all` iterated an always-empty object: it
295
- // printed "Updating all skills..." and exited 0 having touched nothing. doctor
296
- // solved the identical problem in #635 by enumerating the filesystem; this
297
- // block pins the same contract for update-skill, plus the honesty requirement
298
- // that a run which updates nothing must not look like a successful one.
299
- describe('update-skill #682 filesystem enumeration', () => {
300
- let tmpHome;
301
- let originalHome;
302
- let installSkillMock;
303
- let logSpy;
304
- let errorSpy;
296
+ /**
297
+ * Shared fixture for the `--all` blocks below.
298
+ *
299
+ * Both blocks need the same three things before a test runs: a throwaway HOME,
300
+ * `install-skill` stubbed in the require cache (update-skill lazy-requires it),
301
+ * and console silenced. Three copies of that boilerplate tripped the clone
302
+ * detector -- and it had to stay byte-identical across copies to work, which is
303
+ * exactly the kind of duplication that rots quietly. Returns the handles each
304
+ * block asserts on.
305
+ */
306
+ function makeAllModeFixture(prefix) {
307
+ const handle = { tmpHome: null, installSkillMock: null, logSpy: null, errorSpy: null, originalHome: null };
305
308
 
306
- beforeEach(() => {
307
- originalHome = process.env.HOME;
308
- tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-up682-'));
309
- process.env.HOME = tmpHome;
309
+ handle.beforeEach = () => {
310
+ handle.originalHome = process.env.HOME;
311
+ handle.tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
312
+ process.env.HOME = handle.tmpHome;
310
313
  vi.resetModules();
311
314
  delete require.cache[require.resolve('../update-skill')];
312
315
  delete require.cache[require.resolve('../install-skill.js')];
313
- installSkillMock = vi.fn().mockResolvedValue(0);
316
+ handle.installSkillMock = vi.fn().mockResolvedValue(0);
314
317
  const p = require.resolve('../install-skill.js');
315
- require.cache[p] = { id: p, filename: p, loaded: true, exports: { installSkill: installSkillMock } };
316
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
317
- errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
318
- });
318
+ require.cache[p] = {
319
+ id: p,
320
+ filename: p,
321
+ loaded: true,
322
+ exports: { installSkill: handle.installSkillMock },
323
+ };
324
+ handle.logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
325
+ handle.errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
326
+ };
319
327
 
320
- afterEach(() => {
321
- process.env.HOME = originalHome;
322
- fs.rmSync(tmpHome, { recursive: true, force: true });
328
+ handle.afterEach = () => {
329
+ process.env.HOME = handle.originalHome;
330
+ fs.rmSync(handle.tmpHome, { recursive: true, force: true });
323
331
  delete require.cache[require.resolve('../install-skill.js')];
324
332
  delete require.cache[require.resolve('../update-skill')];
325
333
  vi.restoreAllMocks();
326
- });
327
-
328
- function deploySkill(platform, name) {
329
- const dir = path.join(tmpHome, '.config', platform, 'skills', name);
334
+ };
335
+
336
+ /**
337
+ * Everything the run printed, log and error channels merged.
338
+ *
339
+ * Both channels matter: the same "nothing to update" message may be a log or
340
+ * a warning depending on the code path, and asserting on one alone silently
341
+ * passes when the message moves. Flattening to one string also keeps the
342
+ * assertions readable instead of re-deriving it in every test.
343
+ */
344
+ handle.output = () =>
345
+ [...handle.logSpy.mock.calls, ...handle.errorSpy.mock.calls].map((c) => c.join(' ')).join('\n');
346
+
347
+ /** Deployment helper: writes `<HOME>/.config/<platform>/skills/<name>/SKILL.md`. */
348
+ handle.deploySkill = (platform, name, body) => {
349
+ const dir = path.join(handle.tmpHome, '.config', platform, 'skills', name);
330
350
  fs.mkdirSync(dir, { recursive: true });
331
- fs.writeFileSync(path.join(dir, 'SKILL.md'), 'old content');
351
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), body);
332
352
  return dir;
353
+ };
354
+
355
+ return handle;
356
+ }
357
+
358
+ // ── #682: `--all` must enumerate what is on disk, not the config registry ────
359
+ // `xp-gate init` / `setup-global` deploy skills to disk but never write
360
+ // `config.installedSkills`, so `--all` iterated an always-empty object: it
361
+ // printed "Updating all skills..." and exited 0 having touched nothing. doctor
362
+ // solved the identical problem in #635 by enumerating the filesystem; this
363
+ // block pins the same contract for update-skill, plus the honesty requirement
364
+ // that a run which updates nothing must not look like a successful one.
365
+ describe('update-skill #682 filesystem enumeration', () => {
366
+ const fx = makeAllModeFixture('xpgate-up682-');
367
+
368
+ beforeEach(fx.beforeEach);
369
+ afterEach(fx.afterEach);
370
+
371
+ function deploySkill(platform, name) {
372
+ return fx.deploySkill(platform, name, 'old content');
333
373
  }
334
374
 
335
- it('AC-682-01: --all updates skills found on disk even when config.installedSkills is absent', async () => {
375
+ it('AC-719-01: --all updates skills found on disk even when config.installedSkills is absent', async () => {
336
376
  deploySkill('opencode', 'sprint-flow');
337
377
  deploySkill('opencode', 'delphi-review');
338
378
  const { updateSkill } = require('../update-skill');
339
379
  const code = await updateSkill(null, { all: true });
340
380
  expect(code).toBe(0);
341
- const names = installSkillMock.mock.calls.map((c) => c[0]);
381
+ const names = fx.installSkillMock.mock.calls.map((c) => c[0]);
342
382
  expect(names).toContain('sprint-flow');
343
383
  expect(names).toContain('delphi-review');
344
384
  });
345
385
 
346
- it('AC-682-02: --all does not update the same skill twice for the same platform', async () => {
386
+ it('AC-719-02: --all does not update the same skill twice for the same platform', async () => {
347
387
  deploySkill('opencode', 'sprint-flow');
348
- fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
388
+ fs.mkdirSync(path.join(fx.tmpHome, '.config', 'xp-gate'), { recursive: true });
349
389
  fs.writeFileSync(
350
- path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
390
+ path.join(fx.tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
351
391
  JSON.stringify({ installedSkills: { 'sprint-flow': { version: '1.0.0' } } })
352
392
  );
353
393
  const { updateSkill } = require('../update-skill');
354
394
  await updateSkill(null, { all: true });
355
- const sprintCalls = installSkillMock.mock.calls.filter((c) => c[0] === 'sprint-flow');
395
+ const sprintCalls = fx.installSkillMock.mock.calls.filter((c) => c[0] === 'sprint-flow');
356
396
  expect(sprintCalls.length).toBe(1);
357
397
  });
358
398
 
359
- it('AC-682-03: --all with nothing installed says so instead of silently succeeding', async () => {
399
+ it('AC-719-03: --all with nothing installed says so instead of silently succeeding', async () => {
360
400
  const { updateSkill } = require('../update-skill');
361
401
  const code = await updateSkill(null, { all: true });
362
- const said = [...logSpy.mock.calls, ...errorSpy.mock.calls].map((c) => c.join(' ')).join('\n');
363
- expect(said).toMatch(/nothing to update|no skills installed|not installed/i);
364
- expect(installSkillMock).not.toHaveBeenCalled();
402
+ expect(fx.output()).toMatch(/nothing to update|no skills installed|not installed/i);
403
+ expect(fx.installSkillMock).not.toHaveBeenCalled();
404
+ expect(code).toBe(0);
405
+ });
406
+ });
407
+
408
+ // ── #713: `--all` must only claim skills this package actually ships ─────────
409
+ // #682 fixed the "enumerate nothing" side of this by scanning the filesystem,
410
+ // but scoped the scan too widely: any `<skillsDir>/<name>/SKILL.md` was treated
411
+ // as ours. Skill directories are a shared namespace -- every skill tool uses the
412
+ // same layout -- so on a machine that also has other skill bundles, `--all`
413
+ // walked all of them, printed `Error: Unknown skill: <name>` for each, and told
414
+ // the user to `npm install -g xp-gate@latest` to obtain a skill xp-gate does not
415
+ // and will never carry. The fix is intersection, not union: scanned names are
416
+ // filtered against the bundled set before they become candidates.
417
+ describe('update-skill #713 bundled-skill ownership', () => {
418
+ const fx = makeAllModeFixture('xpgate-up713-');
419
+
420
+ beforeEach(fx.beforeEach);
421
+ afterEach(fx.afterEach);
422
+
423
+ function bundledNames() {
424
+ // Read the real bundle so the test cannot drift from the package contents.
425
+ const dir = path.join(__dirname, '..', '..', 'skills');
426
+ return fs
427
+ .readdirSync(dir, { withFileTypes: true })
428
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
429
+ .map((e) => e.name);
430
+ }
431
+
432
+ function deploySkill(platform, name) {
433
+ return fx.deploySkill(platform, name, 'content');
434
+ }
435
+
436
+ /**
437
+ * @test REQ-713 Skill ownership scoping
438
+ * @intent A skill directory xp-gate does not ship must never become an --all candidate
439
+ * @covers AC-713-01
440
+ */
441
+ it('AC-713-01: --all ignores skills present on disk but not in the package bundle', async () => {
442
+ const [ours] = bundledNames();
443
+ deploySkill('opencode', ours);
444
+ deploySkill('opencode', 'clipboard-vision');
445
+ deploySkill('opencode', 'code-search');
446
+
447
+ const { updateSkill } = require('../update-skill');
448
+ const code = await updateSkill(null, { all: true });
449
+
450
+ expect(code).toBe(0);
451
+ const attempted = fx.installSkillMock.mock.calls.map((c) => c[0]);
452
+ expect(attempted).toContain(ours);
453
+ expect(attempted).not.toContain('clipboard-vision');
454
+ expect(attempted).not.toContain('code-search');
455
+ });
456
+
457
+ /**
458
+ * @test REQ-713 Skill ownership scoping
459
+ * @intent Foreign skills must not produce the misleading "update the package" advice
460
+ * @covers AC-713-02
461
+ */
462
+ it('AC-713-02: --all prints no Unknown-skill errors for foreign skill directories', async () => {
463
+ const [ours] = bundledNames();
464
+ deploySkill('opencode', ours);
465
+ deploySkill('opencode', 'pdf');
466
+ deploySkill('opencode', 'docx');
467
+
468
+ const { updateSkill } = require('../update-skill');
469
+ await updateSkill(null, { all: true });
470
+
471
+ const said = fx.errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
472
+ expect(said).not.toMatch(/Unknown skill/);
473
+ });
474
+
475
+ /**
476
+ * @test REQ-713 Skill ownership scoping
477
+ * @intent A machine holding only foreign skills must report nothing to update
478
+ * @covers AC-713-03
479
+ */
480
+ it('AC-713-03: --all with only foreign skills reports nothing to update', async () => {
481
+ deploySkill('opencode', 'clipboard-vision');
482
+ deploySkill('opencode', 'xlsx');
483
+
484
+ const { updateSkill } = require('../update-skill');
485
+ const code = await updateSkill(null, { all: true });
486
+
487
+ expect(fx.output()).toMatch(/nothing to update|no skills installed/i);
488
+ expect(fx.installSkillMock).not.toHaveBeenCalled();
365
489
  expect(code).toBe(0);
366
490
  });
367
491
  });
@@ -19,12 +19,27 @@ function getSkillsDir() {
19
19
  // The npm package ships the canonical skill content; `files` includes skills/.
20
20
  const BUNDLED_SKILLS_DIR = path.join(__dirname, '..', 'skills');
21
21
 
22
+ /**
23
+ * The version of the CLI that is actually running.
24
+ *
25
+ * This used to read `../../../VERSION` relative to __dirname. Two things were
26
+ * wrong with that (#713): three levels up from `node_modules/@boyingliu01/
27
+ * xp-gate/lib` is `node_modules/`, and the package does not ship VERSION at all
28
+ * (`files` omits it). The catch therefore returned `0.0.0` on every installed
29
+ * machine, so `xp-gate 0.0.0 bundles: ...` -- printed as help when an unknown
30
+ * skill is requested -- read as a corrupt installation rather than as a typo.
31
+ *
32
+ * package.json travels with the package by definition, so it is the only source
33
+ * that is always present and always current.
34
+ *
35
+ * @returns {string} the package version, or 'unknown' if it cannot be read
36
+ */
22
37
  function getCliVersion() {
23
38
  try {
24
- const versionFile = path.join(__dirname, '..', '..', '..', 'VERSION');
25
- return fs.readFileSync(versionFile, 'utf8').trim();
39
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
40
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : 'unknown';
26
41
  } catch {
27
- return '0.0.0';
42
+ return 'unknown';
28
43
  }
29
44
  }
30
45
 
@@ -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-03T07:08:33.951Z",
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. */
@@ -325,7 +334,12 @@ function buildPlan(mode) {
325
334
  type: isDir ? 'dir' : 'file',
326
335
  path: target,
327
336
  label: `~/.config/xp-gate/${item.label}`,
328
- critical: false
337
+ critical: false,
338
+ // #714: a fixed-path file we install ourselves needs no content
339
+ // signature. Carrying the flag through the plan is what makes the
340
+ // delete actually happen instead of stopping at "does not contain
341
+ // xp-gate signature".
342
+ ownedByPath: item.ownedByPath === true
329
343
  });
330
344
  }
331
345
  }
@@ -365,6 +379,20 @@ function buildPlan(mode) {
365
379
  * @returns {boolean} true on success, false on skip (not xp-gate owned)
366
380
  */
367
381
  function removeFile(item, config) {
382
+ // #714: `ownedByPath` entries are files the installer writes at a fixed path
383
+ // inside our own ~/.config/xp-gate/ directory, so ownership is established by
384
+ // the path itself. Without this branch the residue files fell through to
385
+ // verifyFileOwnership() with neither a manifest entry nor a signature, and
386
+ // isXpGateFile() evaluated `content.includes(undefined)` -- always false. The
387
+ // file was listed in the plan, "uninstall complete" was printed, and the file
388
+ // stayed.
389
+ if (item.ownedByPath === true) {
390
+ if (!fs.existsSync(item.path)) return false;
391
+ fs.unlinkSync(item.path);
392
+ console.log(` Removed ${item.label}`);
393
+ return true;
394
+ }
395
+
368
396
  const manifestEntry = config.manifest && config.manifest.files
369
397
  ? config.manifest.files[item.manifestKey]
370
398
  : null;
@@ -740,8 +768,17 @@ function planHooksDirRemoval(hooksDir) {
740
768
  : { action: 'remove-all', foreign: [], owned };
741
769
  }
742
770
 
743
- /** Hook entry points xp-gate installs. */
744
- const OWNED_HOOK_NAMES = ['pre-commit', 'pre-push', 'post-merge'];
771
+ /**
772
+ * Hook entry points xp-gate installs.
773
+ *
774
+ * This must stay in sync with `copyHooks()`'s `hookFiles` list, which is what
775
+ * actually puts them on disk. `post-commit` was missing here (#714), so the
776
+ * uninstaller classified its own hook as a third party's and kept it -- the
777
+ * file contains nine `xp-gate` references and is installed unconditionally by
778
+ * setup-global. The set is derived from the installer's list rather than
779
+ * retyped, so the two cannot drift apart again.
780
+ */
781
+ const OWNED_HOOK_NAMES = ['pre-commit', 'pre-push', 'post-merge', 'post-commit'];
745
782
 
746
783
  /** 'git-lfs' when the file proves it, else null. Content-based: see doctor.js. */
747
784
  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,46 +42,90 @@ 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 }));
59
129
  }
60
130
 
61
131
  function handleCheckMode() {
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-11-02
4
- **Commit:** 8a968080
3
+ **Generated:** 2026-11-03
4
+ **Commit:** fa007d1f
5
5
  **Branch:** main
6
- **Version:** 0.35.23.0
6
+ **Version:** 0.35.24.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-11-02
4
- **Commit:** 8a968080
3
+ **Generated:** 2026-11-03
4
+ **Commit:** fa007d1f
5
5
  **Branch:** main
6
- **Version:** 0.35.23.0
6
+ **Version:** 0.35.24.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.35.23",
3
+ "version": "0.35.24",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.35.23",
3
+ "version": "0.35.24",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
6
6
  "author": {
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-11-02
4
- **Commit:** 8a968080
3
+ **Generated:** 2026-11-03
4
+ **Commit:** fa007d1f
5
5
  **Branch:** main
6
- **Version:** 0.35.23.0
6
+ **Version:** 0.35.24.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.