@boyingliu01/xp-gate 0.34.0 → 0.35.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 (37) hide show
  1. package/gate-13-compiler-strictness.sh +129 -4
  2. package/gate-3.sh +46 -2
  3. package/gate-4.sh +28 -4
  4. package/hooks/gate-13-compiler-strictness.sh +129 -4
  5. package/hooks/gate-3.sh +46 -2
  6. package/hooks/gate-4.sh +28 -4
  7. package/hooks/lib/ccn-ratchet.sh +109 -0
  8. package/lib/__tests__/doctor-timeout-partial.test.js +79 -0
  9. package/lib/__tests__/foreign-hooks-global.test.js +125 -0
  10. package/lib/__tests__/uninstall-global-residue.test.js +160 -0
  11. package/lib/doctor.js +130 -3
  12. package/lib/mattpocock-license-verdict.json +1 -1
  13. package/lib/uninstall.js +225 -1
  14. package/mock-policy/AGENTS.md +2 -2
  15. package/mutation/AGENTS.md +2 -2
  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 +2 -2
  19. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  20. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  21. package/plugins/dsh/package.json +1 -1
  22. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  23. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  24. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  25. package/plugins/opencode/package.json +1 -1
  26. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  27. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  28. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  29. package/plugins/qoder/plugin.json +1 -1
  30. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  31. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  32. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  33. package/principles/AGENTS.md +2 -2
  34. package/principles/rules/solid/dip.ts +94 -14
  35. package/skills/delphi-review/AGENTS.md +2 -2
  36. package/skills/sprint-flow/AGENTS.md +2 -2
  37. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @test REQ-629
3
+ * @intent `xp-gate doctor` is non-deterministic on Windows. Against an identical,
4
+ * freshly-installed, fully healthy installation it printed
5
+ * "All checks passed" on one run and "Timed out after 10s — some checks
6
+ * incomplete" on the next two. The timeout is a deliberate safety net
7
+ * (#348, "43s on Windows"); the defect is what happens when it fires.
8
+ *
9
+ * `Promise.race` discarded the entire `diagnoseAsync()` result and
10
+ * substituted a single placeholder check, so everything that had already
11
+ * finished was thrown away. Consequence: doctor can report LESS
12
+ * information the LONGER it runs — the opposite of what a diagnostic
13
+ * should do — and a WARN from the timeout made the skills section vanish
14
+ * entirely, so tooling that scrapes `Skill:.*Up to date` read 0/12.
15
+ *
16
+ * The fix: reach a verdict at the deadline (never hang — #348 stays fixed)
17
+ * but keep whatever finished. A diagnostic that says "these 9 checks
18
+ * passed, 2 did not report in time" is strictly more useful than one that
19
+ * says "timed out".
20
+ *
21
+ * The tests below pin the merge semantics directly rather than waiting on
22
+ * a real 10s timer: the behaviour that matters is "partial results are
23
+ * preserved and reported as partial", not the clock.
24
+ * @covers AC-670-43, AC-670-44, AC-670-45
25
+ */
26
+ 'use strict';
27
+
28
+ // describe/it/expect are vitest globals in this repo's config; requiring them from
29
+ // 'vitest' fails because this file is CommonJS. (Hooks are not needed here.)
30
+
31
+ /** A completed check, shaped like diagnoseAsync()'s output. */
32
+ function check(name, status) {
33
+ return { name, status, detail: `${name} detail` };
34
+ }
35
+
36
+ describe('doctor keeps completed checks when the global timeout fires (#629)', () => {
37
+ it('AC-670-43: completed checks are merged with the timeout notice, not replaced', () => {
38
+ const { mergeTimedOutDiagnosis } = require('../doctor');
39
+ expect(typeof mergeTimedOutDiagnosis).toBe('function');
40
+
41
+ const completed = {
42
+ checks: [check('Hooks', 'PASS'), check('Adapters', 'PASS'), check('Skills', 'WARN')],
43
+ issues: 1,
44
+ };
45
+ const merged = mergeTimedOutDiagnosis(completed, ['Gate env', 'OpenCode TUI']);
46
+
47
+ // Nothing that finished may be dropped -- that is the whole defect.
48
+ for (const name of ['Hooks', 'Adapters', 'Skills']) {
49
+ expect(merged.checks.map((c) => c.name)).toContain(name);
50
+ }
51
+ // The deadline itself is still reported, and still not a pass.
52
+ const notice = merged.checks.find((c) => c.name === 'Diagnosis');
53
+ expect(notice).toBeDefined();
54
+ expect(notice.status).toBe('WARN');
55
+ // Named individually, so the reader knows which surface is unknown.
56
+ expect(notice.detail).toContain('Gate env');
57
+ expect(notice.detail).toContain('OpenCode TUI');
58
+ expect(merged.timedOut).toBe(true);
59
+ // A partial run is never clean.
60
+ expect(merged.issues).toBeGreaterThanOrEqual(1);
61
+ });
62
+
63
+ it('AC-670-44: an empty partial result still yields the honest timeout notice', () => {
64
+ const { mergeTimedOutDiagnosis } = require('../doctor');
65
+ const merged = mergeTimedOutDiagnosis({ checks: [], issues: 0 }, ['Gate env']);
66
+ expect(merged.checks).toHaveLength(1);
67
+ expect(merged.checks[0].name).toBe('Diagnosis');
68
+ expect(merged.timedOut).toBe(true);
69
+ });
70
+
71
+ it('AC-670-45: nothing that already reported is ever lost, and a real issue count survives', () => {
72
+ const { mergeTimedOutDiagnosis } = require('../doctor');
73
+ const completed = { checks: [check('Hooks', 'FAIL'), check('Templates', 'PASS')], issues: 4 };
74
+ const merged = mergeTimedOutDiagnosis(completed, []);
75
+ // Pre-existing issues are not reset by the timeout.
76
+ expect(merged.issues).toBeGreaterThanOrEqual(4);
77
+ expect(merged.checks.map((c) => c.name)).toEqual(expect.arrayContaining(['Hooks', 'Templates']));
78
+ });
79
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @test REQ-664
3
+ * @intent `setup-global` points `core.hooksPath` at `~/.config/xp-gate/hooks`, and
4
+ * that setting is GLOBAL — it governs every repository on the machine. So
5
+ * any third-party tool that writes hooks into `core.hooksPath` writes into
6
+ * a directory xp-gate owns. `git lfs install` does exactly this, and the
7
+ * measured result was four hooks xp-gate never installs appearing there:
8
+ *
9
+ * hook-module-resolution.sh lib/ post-checkout post-commit
10
+ * post-merge pre-commit pre-push
11
+ *
12
+ * Two failures followed:
13
+ *
14
+ * 1. `doctor` only checked pre-commit / pre-push / post-merge. The extra
15
+ * files were neither reported nor explained, so the user had no way to
16
+ * learn that a global install had acquired foreign hooks.
17
+ * 2. `uninstall` did `rmSync(GLOBAL_HOOKS_DIR, {recursive: true})` — the
18
+ * whole tree, including the lfs hooks someone else put there. The
19
+ * file already had `verifyFileOwnership()`; the `type: 'dir'` branch
20
+ * simply did not use it.
21
+ *
22
+ * Deleting another tool's files is the more serious of the two: the user
23
+ * loses a working git-lfs setup and has no idea which command did it. So
24
+ * uninstall must classify before it removes, and SAY what it found.
25
+ * @covers AC-670-40, AC-670-41, AC-670-42
26
+ */
27
+ 'use strict';
28
+
29
+ const fs = require('fs');
30
+ const os = require('os');
31
+ const path = require('path');
32
+
33
+ /** Hooks xp-gate itself installs; anything else in the dir is foreign. */
34
+ const XP_GATE_HOOKS = ['pre-commit', 'pre-push', 'post-merge'];
35
+
36
+ function freshRequire(relPath) {
37
+ for (const key of Object.keys(require.cache)) {
38
+ if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
39
+ delete require.cache[key];
40
+ }
41
+ }
42
+ return require(relPath);
43
+ }
44
+
45
+ /** A hooks directory shaped like the measured post-`git lfs install` state. */
46
+ function makeHooksDir() {
47
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-hooks-'));
48
+ for (const hook of XP_GATE_HOOKS) {
49
+ fs.writeFileSync(path.join(dir, hook), '#!/bin/sh\n# xp-gate\n');
50
+ }
51
+ // Foreign: two hooks git-lfs installs, plus its support file.
52
+ fs.writeFileSync(path.join(dir, 'post-checkout'), '#!/bin/sh\ngit lfs post-checkout "$@"\n');
53
+ fs.writeFileSync(path.join(dir, 'post-commit'), '#!/bin/sh\ngit lfs post-commit "$@"\n');
54
+ fs.writeFileSync(path.join(dir, 'hook-module-resolution.sh'), '#!/bin/sh\n');
55
+ return dir;
56
+ }
57
+
58
+ describe('foreign hooks in the global hooksPath (#664)', () => {
59
+ it('AC-670-40: the classifier names xp-gate hooks and flags everything else', () => {
60
+ const { classifyHooksDir } = freshRequire('../doctor');
61
+ expect(typeof classifyHooksDir).toBe('function');
62
+
63
+ const dir = makeHooksDir();
64
+ try {
65
+ const result = classifyHooksDir(dir);
66
+ expect(result.owned.sort()).toEqual([...XP_GATE_HOOKS].sort());
67
+ // Every foreign entry is reported, not silently dropped. `foreign` holds
68
+ // { name, source } records, so compare names.
69
+ const foreignNames = result.foreign.map((f) => f.name);
70
+ expect(foreignNames).toContain('post-checkout');
71
+ expect(foreignNames).toContain('post-commit');
72
+ // lib/ is xp-gate's own shared-library directory, not foreign.
73
+ expect(foreignNames).not.toContain('lib');
74
+ } finally {
75
+ fs.rmSync(dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ it('AC-670-41: git-lfs hooks are identified by source, unknown files stay generic', () => {
80
+ const { classifyHooksDir } = freshRequire('../doctor');
81
+ const dir = makeHooksDir();
82
+ try {
83
+ const result = classifyHooksDir(dir);
84
+ // Naming the likely source is what turns a confusing listing into an
85
+ // actionable message: "git lfs" tells the user which tool to look at.
86
+ const lfs = result.foreign.filter((f) => f.source === 'git-lfs').map((f) => f.name);
87
+ expect(lfs).toContain('post-checkout');
88
+ expect(lfs).toContain('post-commit');
89
+
90
+ // A file with no recognizable signature must still be reported, but without
91
+ // guessing a source it cannot prove.
92
+ fs.writeFileSync(path.join(dir, 'post-rebase'), '#!/bin/sh\necho mine\n');
93
+ const again = classifyHooksDir(dir);
94
+ const unknown = again.foreign.find((f) => f.name === 'post-rebase');
95
+ expect(unknown).toBeDefined();
96
+ expect(unknown.source).not.toBe('git-lfs');
97
+ } finally {
98
+ fs.rmSync(dir, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ it('AC-670-42: uninstall refuses to delete a hooks dir holding foreign files', () => {
103
+ const { planHooksDirRemoval } = freshRequire('../uninstall');
104
+ expect(typeof planHooksDirRemoval).toBe('function');
105
+
106
+ const dir = makeHooksDir();
107
+ try {
108
+ const plan = planHooksDirRemoval(dir);
109
+ // Recursive removal would take git-lfs's files with it, so it must not be
110
+ // the chosen action while foreign content is present.
111
+ expect(plan.action).not.toBe('remove-all');
112
+ expect(plan.foreign.length).toBeGreaterThan(0);
113
+
114
+ // A directory holding ONLY xp-gate files is safe to remove wholesale.
115
+ const clean = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-hooks-clean-'));
116
+ for (const hook of XP_GATE_HOOKS) {
117
+ fs.writeFileSync(path.join(clean, hook), '#!/bin/sh\n# xp-gate\n');
118
+ }
119
+ expect(planHooksDirRemoval(clean).action).toBe('remove-all');
120
+ fs.rmSync(clean, { recursive: true, force: true });
121
+ } finally {
122
+ fs.rmSync(dir, { recursive: true, force: true });
123
+ }
124
+ });
125
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @test REQ-663
3
+ * @intent `xp-gate uninstall` presents itself as the inverse of `init`, but it only
4
+ * removed `hooks/` + `adapters/` + `core.hooksPath`. After
5
+ * `setup-global` then `uninstall --global --force`, the tool printed
6
+ * "Uninstall complete" while leaving behind ~1000 files:
7
+ *
8
+ * ~/.config/xp-gate/modules/ principles/ mutation/ mock-policy/
9
+ * ~/.config/xp-gate/node_modules/ js-yaml / zod / argparse ...
10
+ * ~/.config/xp-gate/package.json
11
+ * ~/.config/xp-gate/package-lock.json
12
+ * ~/.config/xp-gate/xp-gate.json manifest + installedSkills record
13
+ * ~/.xp-gate/version-cache.json
14
+ *
15
+ * And skills were never in scope at all: 12 skills stayed in
16
+ * ~/.qoder/skills/ and ~/.config/opencode/skills/, which the next `init`
17
+ * then treated as already-installed.
18
+ *
19
+ * The residue is not cosmetic. A leftover `node_modules/` makes the NEXT
20
+ * `setup-global` skip its install ("module runtime deps → ... node_modules"
21
+ * reuses whatever is there), so a stale dependency tree survives an
22
+ * upgrade; and a leftover `xp-gate.json` carries the previous version's
23
+ * `manifest` and `lastInit`, which `doctor` then reports as the CURRENT
24
+ * installation.
25
+ *
26
+ * Two rules the fix must respect, both from the issue:
27
+ * - a user config (`.delphi-config.json`) is user DATA, not residue --
28
+ * keep it, but SAY SO instead of leaving it silently;
29
+ * - never delete a skill directory the user has edited. Only remove one
30
+ * whose content still matches the bundled copy.
31
+ * @covers AC-670-37, AC-670-38, AC-670-39
32
+ */
33
+ 'use strict';
34
+
35
+ // describe/it/expect/beforeEach/afterEach are vitest globals in this repo's config;
36
+ // requiring them from 'vitest' fails because these tests are CommonJS.
37
+ const fs = require('fs');
38
+ const os = require('os');
39
+ const path = require('path');
40
+
41
+ /** Build a throwaway HOME so the plan is computed against a controlled tree. */
42
+ function makeHome() {
43
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-uninst-'));
44
+ return home;
45
+ }
46
+
47
+ /**
48
+ * Reproduce the on-disk shape `xp-gate setup-global` leaves behind, including the
49
+ * parts that were measured as residue in #663.
50
+ */
51
+ function seedGlobalInstall() {
52
+ const configDir = path.join(process.env.HOME, '.config', 'xp-gate');
53
+ for (const rel of ['hooks', 'adapters', 'modules/principles', 'node_modules/js-yaml']) {
54
+ fs.mkdirSync(path.join(configDir, rel), { recursive: true });
55
+ fs.writeFileSync(path.join(configDir, rel, 'index.js'), 'module.exports = {};\n');
56
+ }
57
+ for (const file of ['package.json', 'package-lock.json', 'xp-gate.json']) {
58
+ fs.writeFileSync(path.join(configDir, file), '{}\n');
59
+ }
60
+ // User data: must survive, and must be reported.
61
+ fs.writeFileSync(path.join(configDir, '.delphi-config.json'), '{}\n');
62
+ }
63
+
64
+
65
+ describe('uninstall --global plans the whole installation, not just hooks (#663)', () => {
66
+ let home;
67
+ const savedEnv = {};
68
+
69
+ beforeEach(() => {
70
+ home = makeHome();
71
+ savedEnv.HOME = process.env.HOME;
72
+ savedEnv.USERPROFILE = process.env.USERPROFILE;
73
+ process.env.HOME = home;
74
+ process.env.USERPROFILE = home;
75
+ });
76
+
77
+ afterEach(() => {
78
+ process.env.HOME = savedEnv.HOME;
79
+ process.env.USERPROFILE = savedEnv.USERPROFILE;
80
+ fs.rmSync(home, { recursive: true, force: true });
81
+ });
82
+
83
+ /**
84
+ * Load a module fresh so it re-reads HOME. Every lib reads os.homedir() at call
85
+ * time, but a cached module still holds `GLOBAL_DIRS` values captured earlier.
86
+ */
87
+ function freshRequire(relPath) {
88
+ for (const key of Object.keys(require.cache)) {
89
+ if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
90
+ delete require.cache[key];
91
+ }
92
+ }
93
+ return require(relPath);
94
+ }
95
+
96
+ it('AC-670-37: modules, node_modules and the manifest files are all in scope', () => {
97
+ const { buildPlan } = freshRequire('../uninstall');
98
+ expect(typeof buildPlan).toBe('function');
99
+
100
+ // buildPlan only schedules what actually exists, so the fixture has to look
101
+ // like a real `setup-global` install -- otherwise this asserts nothing.
102
+ seedGlobalInstall();
103
+
104
+ const plan = buildPlan('global');
105
+ const paths = plan.filter((step) => step.path).map((step) => step.path.split(path.sep).join('/'));
106
+
107
+ // Each of these was measured residue. A plan that omits any of them reports
108
+ // "complete" over an installation that is still there.
109
+ for (const required of ['modules', 'node_modules', 'package.json', 'package-lock.json', 'xp-gate.json']) {
110
+ expect(
111
+ paths.some((p) => p.endsWith(required)),
112
+ `${required} is missing from the uninstall plan (measured residue, #663)`,
113
+ ).toBe(true);
114
+ }
115
+ });
116
+
117
+ it('AC-670-38: user config is retained and reported, never silently kept', () => {
118
+ const { buildPlan } = freshRequire('../uninstall');
119
+ seedGlobalInstall();
120
+ const plan = buildPlan('global');
121
+ // Deletion steps only: a RETAIN step names the file precisely because it is
122
+ // being kept, so including it here would assert the opposite of the intent.
123
+ const deletedPaths = plan
124
+ .filter((step) => step.path && step.retain !== true && step.type !== 'retain')
125
+ .map((step) => step.path.split(path.sep).join('/'));
126
+
127
+ // `.delphi-config.json` holds the user's expert/model choice -- it is data, so
128
+ // it must appear in no deletion step. (The --purge branch is a separate,
129
+ // explicitly destructive path and is not what this plan describes.)
130
+ expect(deletedPaths.some((p) => p.endsWith('.delphi-config.json'))).toBe(false);
131
+ // Not deleting it is only half the fix: the summary has to name it.
132
+ const retained = plan.filter((step) => step.retain === true);
133
+ expect(retained.length, 'no step declares a retention, so the summary cannot report one').toBeGreaterThan(0);
134
+ });
135
+
136
+ it('AC-670-39: an edited skill directory is never scheduled for deletion', () => {
137
+ const { planSkillRemovals } = freshRequire('../uninstall');
138
+ expect(typeof planSkillRemovals).toBe('function');
139
+
140
+ const bundled = path.join(home, 'bundle', 'skills');
141
+ const installedRoot = path.join(home, '.qoder', 'skills');
142
+ for (const [dir, body] of [
143
+ [path.join(bundled, 'clean-skill'), 'bundled body\n'],
144
+ [path.join(installedRoot, 'clean-skill'), 'bundled body\n'],
145
+ [path.join(bundled, 'edited-skill'), 'bundled body\n'],
146
+ [path.join(installedRoot, 'edited-skill'), 'bundled body\n// user edit\n'],
147
+ ]) {
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), body);
150
+ }
151
+
152
+ const removals = planSkillRemovals(['clean-skill', 'edited-skill'], installedRoot, bundled);
153
+ const names = removals.map((r) => path.basename(r.path));
154
+
155
+ // Untouched -> remove. This is the residue the issue is about.
156
+ expect(names).toContain('clean-skill');
157
+ // Edited -> keep. Deleting a user's modifications is worse than residue.
158
+ expect(names).not.toContain('edited-skill');
159
+ });
160
+ });
package/lib/doctor.js CHANGED
@@ -80,6 +80,38 @@ const EXEC_TIMEOUT_MS = 1500;
80
80
  */
81
81
  const GLOBAL_DIAGNOSIS_TIMEOUT_MS = 10000;
82
82
 
83
+ /**
84
+ * Merge a timed-out partial diagnosis into one honest verdict (#629).
85
+ *
86
+ * `doctor`'s global timeout (#348) exists so the command never hangs, but the
87
+ * original handler threw away the entire `diagnoseAsync()` result and substituted
88
+ * a single placeholder check. Against an identical healthy install that made the
89
+ * SAME command print "All checks passed" or "Timed out after 10s" depending on
90
+ * machine load, and a timed-out run reported 0 installed skills — doctor giving
91
+ * LESS information the LONGER it runs is the opposite of a diagnostic.
92
+ *
93
+ * Keeps every completed check, adds ONE Diagnosis notice naming what did not
94
+ * report, and never lets a partial run read as clean (#495).
95
+ *
96
+ * @param {{checks?: Array, issues?: number}} partial checks finished before the deadline
97
+ * @param {string[]} pending names of surfaces that did not report in time
98
+ */
99
+ function mergeTimedOutDiagnosis(partial, pending) {
100
+ const completed = (partial && Array.isArray(partial.checks)) ? partial.checks : [];
101
+ const inherited = (partial && Number.isFinite(partial.issues)) ? partial.issues : 0;
102
+ const pendingNames = Array.isArray(pending) ? pending.filter(Boolean) : [];
103
+ const detail = pendingNames.length > 0
104
+ ? `Timed out after ${GLOBAL_DIAGNOSIS_TIMEOUT_MS / 1000}s — did not report in time: ${pendingNames.join(', ')}`
105
+ : `Timed out after ${GLOBAL_DIAGNOSIS_TIMEOUT_MS / 1000}s — some checks incomplete`;
106
+ return {
107
+ checks: [...completed, { name: 'Diagnosis', status: 'WARN', detail }],
108
+ // A partial run is never clean, and a real issue count is never reset.
109
+ issues: Math.max(inherited, 1),
110
+ timedOut: true,
111
+ completedCheckCount: completed.length,
112
+ };
113
+ }
114
+
83
115
  /**
84
116
  * Fixed attribution text for the WorkBuddy-desktop sandbox restriction (#525).
85
117
  *
@@ -416,6 +448,75 @@ function pathsEquivalent(a, b) {
416
448
  */
417
449
  const DRIFT_COMPARED_HOOKS = ['pre-commit', 'pre-push', 'post-merge'];
418
450
 
451
+ /**
452
+ * Classify the contents of an installed hooks directory (#664).
453
+ *
454
+ * `setup-global` sets `core.hooksPath` GLOBALLY, so this directory governs every
455
+ * repository on the machine — and any tool that installs hooks by asking git where
456
+ * hooks live writes into it. `git lfs install` is the one that actually does this;
457
+ * the measured result was four entries xp-gate never installs appearing beside its
458
+ * own hooks. Nothing reported them, so the user had no way to learn their global
459
+ * install had acquired foreign hooks.
460
+ *
461
+ * Returns:
462
+ * owned — hook names xp-gate installs (the drift-compared set)
463
+ * support — xp-gate's own non-hook support entries (lib/, generated scripts)
464
+ * foreign — [{ name, source }] for everything else; `source` is 'git-lfs' when
465
+ * the file's content proves it, otherwise null. Guessing a source we
466
+ * cannot prove would send the user to the wrong tool.
467
+ */
468
+ function classifyHooksDir(hooksDir) {
469
+ const result = { owned: [], support: [], foreign: [] };
470
+ let entries;
471
+ try {
472
+ entries = fs.readdirSync(hooksDir, { withFileTypes: true });
473
+ } catch {
474
+ return result;
475
+ }
476
+
477
+ for (const entry of entries) {
478
+ const name = entry.name;
479
+ if (entry.isDirectory()) {
480
+ // lib/ is xp-gate's shared-library directory; any other subdirectory belongs
481
+ // to somebody else and is reported as foreign for the same reason a file is.
482
+ if (name === 'lib') {
483
+ result.support.push(name);
484
+ } else {
485
+ result.foreign.push({ name, source: null });
486
+ }
487
+ continue;
488
+ }
489
+ if (DRIFT_COMPARED_HOOKS.includes(name)) {
490
+ result.owned.push(name);
491
+ continue;
492
+ }
493
+ if (name.endsWith('.sh')) {
494
+ // Generated support scripts shipped beside the hooks (#505).
495
+ result.support.push(name);
496
+ continue;
497
+ }
498
+ result.foreign.push({ name, source: gitLfsHookSource(path.join(hooksDir, name)) });
499
+ }
500
+ return result;
501
+ }
502
+
503
+ /**
504
+ * 'git-lfs' when the file is a git-lfs hook, else null.
505
+ *
506
+ * Detected from content rather than filename: git-lfs installs several hook names
507
+ * and which ones depends on its version, so a name list would silently miss newer
508
+ * ones. The `git lfs <hook>` invocation is the signature.
509
+ */
510
+ function gitLfsHookSource(filePath) {
511
+ let body;
512
+ try {
513
+ body = fs.readFileSync(filePath, 'utf8');
514
+ } catch {
515
+ return null;
516
+ }
517
+ return /\bgit\s+lfs\b/.test(body) ? 'git-lfs' : null;
518
+ }
519
+
419
520
  /**
420
521
  * Non-hook files that must sit beside the installed hooks.
421
522
  *
@@ -1536,9 +1637,17 @@ async function checkAdapters(checks, mode, gitDirPromise) {
1536
1637
  * D) Env + CLI tools — fully independent, run IN PARALLEL
1537
1638
  * E) TUI + Skills — independent, run IN PARALLEL
1538
1639
  */
1539
- async function diagnoseAsync(refreshEnv = false) {
1640
+ async function diagnoseAsync(refreshEnv = false, progress = null) {
1641
+ // #629: `progress` is a live view of the checks completed so far. The global
1642
+ // timeout used to discard everything diagnoseAsync had already produced; with a
1643
+ // shared sink, a deadline can report "these finished, those did not" instead of
1644
+ // replacing the whole diagnosis with a placeholder.
1540
1645
  const checks = [];
1541
1646
  let issues = 0;
1647
+ if (progress) {
1648
+ progress.checks = checks;
1649
+ progress.getIssues = () => issues;
1650
+ }
1542
1651
 
1543
1652
  // Spawn capability probe FIRST (sync, no config dependency) — #525: it must
1544
1653
  // report even when the config early-exits below, and its WARN (environmental,
@@ -2800,10 +2909,26 @@ async function doctor(args) {
2800
2909
 
2801
2910
  // Global timeout wrapper — ensures doctor completes within GLOBAL_DIAGNOSIS_TIMEOUT_MS
2802
2911
  // even if network/subprocess checks stack up (fix #348: 43s on Windows).
2803
- const diagnosisPromise = diagnoseAsync(refreshEnv);
2912
+ // #629: give diagnoseAsync a live sink so a deadline can keep what finished.
2913
+ // Without it, `Promise.race` discarded the entire diagnosis -- so the SAME
2914
+ // healthy install reported different results run to run, and a timed-out run
2915
+ // lost the skills section entirely (scrapers read 0/12 installed skills).
2916
+ const progress = { checks: [], getIssues: () => 0 };
2917
+ const diagnosisPromise = diagnoseAsync(refreshEnv, progress);
2804
2918
  let diagnosisTimer = null;
2805
2919
  const timeoutPromise = new Promise((resolve) => {
2806
- diagnosisTimer = setTimeout(() => resolve({ checks: [{ name: 'Diagnosis', status: 'WARN', detail: `Timed out after ${GLOBAL_DIAGNOSIS_TIMEOUT_MS / 1000}s — some checks incomplete` }], issues: 0, timedOut: true }), GLOBAL_DIAGNOSIS_TIMEOUT_MS);
2920
+ diagnosisTimer = setTimeout(() => {
2921
+ const completed = Array.isArray(progress.checks) ? progress.checks : [];
2922
+ const reported = new Set(completed.map((c) => c && c.name));
2923
+ // Name the surfaces that did NOT report, so the reader knows exactly which
2924
+ // conclusions are missing rather than just "something timed out".
2925
+ const pending = ['Hooks', 'Adapters', 'Skills', 'Gate env', 'OpenCode TUI']
2926
+ .filter((name) => !reported.has(name));
2927
+ resolve(mergeTimedOutDiagnosis(
2928
+ { checks: completed, issues: progress.getIssues() },
2929
+ pending,
2930
+ ));
2931
+ }, GLOBAL_DIAGNOSIS_TIMEOUT_MS);
2807
2932
  });
2808
2933
  const { checks, issues: diagnosedIssues, timedOut } = await Promise.race([
2809
2934
  diagnosisPromise.then(r => ({ ...r, timedOut: false })),
@@ -3280,6 +3405,8 @@ module.exports = {
3280
3405
  readTuiJson,
3281
3406
  formatDoctorJson,
3282
3407
  diagnoseHookDrift,
3408
+ mergeTimedOutDiagnosis,
3409
+ classifyHooksDir,
3283
3410
  syncGlobalHooksFromRepo,
3284
3411
  diagnoseModuleDrift,
3285
3412
  diagnoseAuditLedger,
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-30T14:08:10.171Z",
5
+ "checked_at": "2026-10-30T15:31:30.899Z",
6
6
  "source": "network"
7
7
  }