@boyingliu01/xp-gate 0.19.2 → 0.20.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 (223) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +49 -5
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/typescript.sh +15 -60
  5. package/bin/xp-gate.js +15 -16
  6. package/gate-10.sh +35 -2
  7. package/gate-4.sh +110 -21
  8. package/gate-8.sh +49 -5
  9. package/hooks/gate-10.sh +35 -2
  10. package/hooks/gate-4.sh +110 -21
  11. package/hooks/gate-8.sh +49 -5
  12. package/hooks/lib/jscpd-run.sh +63 -0
  13. package/hooks/lib/sprint-gate-report.sh +58 -0
  14. package/hooks/lib/test-failure.sh +152 -0
  15. package/hooks/lib/typecheck.sh +141 -0
  16. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  17. package/hooks/post-merge +100 -0
  18. package/hooks/pre-commit +401 -155
  19. package/hooks/pre-push +75 -19
  20. package/lib/__tests__/bootstrap.test.js +23 -41
  21. package/lib/__tests__/check-version.test.js +8 -1
  22. package/lib/__tests__/detect-deps.test.js +16 -0
  23. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  24. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  25. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  26. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  27. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  28. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  30. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  31. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  32. package/lib/__tests__/doctor.test.js +155 -0
  33. package/lib/__tests__/gate-runner.test.js +19 -3
  34. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  35. package/lib/__tests__/init.test.js +44 -17
  36. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  37. package/lib/__tests__/install-skill.test.js +51 -132
  38. package/lib/__tests__/language-tools-detect.test.js +158 -0
  39. package/lib/__tests__/phase-transition.test.js +15 -1
  40. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  41. package/lib/__tests__/shared-paths.test.js +20 -0
  42. package/lib/__tests__/shared-utils.test.js +69 -0
  43. package/lib/__tests__/skill-flags.test.js +48 -0
  44. package/lib/__tests__/ui-detector.test.ts +6 -2
  45. package/lib/__tests__/uninstall.test.js +20 -0
  46. package/lib/__tests__/update-hooks.test.js +12 -0
  47. package/lib/__tests__/update-skill.test.js +7 -4
  48. package/lib/detect-deps.js +3 -20
  49. package/lib/doctor-tui.js +22 -5
  50. package/lib/doctor.js +1178 -31
  51. package/lib/init.js +184 -33
  52. package/lib/install-cmd.js +7 -4
  53. package/lib/install-skill.js +38 -95
  54. package/lib/language-tools.js +83 -4
  55. package/lib/mattpocock-license-verdict.json +1 -1
  56. package/lib/shared-paths.js +5 -2
  57. package/lib/shared-utils.js +47 -3
  58. package/lib/uninstall.js +12 -0
  59. package/lib/update-hooks.js +4 -4
  60. package/lib/update-skill.js +6 -7
  61. package/mock-policy/AGENTS.md +4 -4
  62. package/mutation/AGENTS.md +4 -4
  63. package/mutation/__tests__/gate-m.test.ts +66 -14
  64. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  65. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  66. package/mutation/gate-m.ts +21 -8
  67. package/package.json +1 -1
  68. package/plugins/claude-code/.claude-plugin/plugin.json +2 -2
  69. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  70. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  71. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  72. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  73. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  74. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  76. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  77. package/plugins/dsh/README.md +169 -4
  78. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  79. package/plugins/dsh/package.json +1 -1
  80. package/plugins/dsh/scripts/prepack.cjs +24 -19
  81. package/plugins/dsh/setup-dsh.ps1 +160 -0
  82. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  84. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  85. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  86. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  87. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  89. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  90. package/plugins/dsh/src/command.test.ts +66 -2
  91. package/plugins/dsh/src/command.ts +36 -14
  92. package/plugins/dsh/src/contract.test.ts +31 -1
  93. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  94. package/plugins/dsh/src/delphi-config.ts +223 -0
  95. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  96. package/plugins/dsh/src/delphi-run.ts +129 -0
  97. package/plugins/dsh/src/delphi.test.ts +222 -0
  98. package/plugins/dsh/src/delphi.ts +247 -0
  99. package/plugins/dsh/src/dialect.test.ts +59 -0
  100. package/plugins/dsh/src/dialect.ts +40 -0
  101. package/plugins/dsh/src/index.ts +70 -4
  102. package/plugins/dsh/src/skills.test.ts +26 -19
  103. package/plugins/opencode/package.json +1 -1
  104. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  105. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  106. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  107. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  108. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  109. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  111. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  112. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  113. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  114. package/plugins/qoder/agents/delphi-technical.md +1 -1
  115. package/plugins/qoder/plugin.json +2 -2
  116. package/plugins/qoder/skills/admin-template-guidelines/SKILL.md +6 -797
  117. package/plugins/qoder/skills/admin-template-guidelines/references/rule-1.md +91 -0
  118. package/plugins/qoder/skills/admin-template-guidelines/references/rule-2.md +155 -0
  119. package/plugins/qoder/skills/admin-template-guidelines/references/rule-3.md +70 -0
  120. package/plugins/qoder/skills/admin-template-guidelines/references/rule-4.md +154 -0
  121. package/plugins/qoder/skills/admin-template-guidelines/references/rule-5.md +126 -0
  122. package/plugins/qoder/skills/admin-template-guidelines/references/rule-6.md +180 -0
  123. package/plugins/qoder/skills/batch-grill-me/SKILL.md +15 -0
  124. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  125. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.ps1 +115 -0
  126. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.sh +161 -0
  127. package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +61 -28
  128. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  129. package/plugins/qoder/skills/delphi-review/INSTALL.md +97 -98
  130. package/plugins/qoder/skills/delphi-review/SKILL.md +170 -63
  131. package/plugins/qoder/skills/delphi-review/hooks.md +19 -0
  132. package/plugins/qoder/skills/delphi-review/opencode.json.delphi.example +6 -10
  133. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  134. package/plugins/qoder/skills/delphi-review/references/orchestrator-dispatch.md +64 -0
  135. package/plugins/qoder/skills/delphi-review/references/requirements.md +402 -0
  136. package/plugins/qoder/skills/delphi-review/references/round-templates.md +134 -0
  137. package/plugins/qoder/skills/delphi-review/scripts/verify-consensus.sh +43 -0
  138. package/plugins/qoder/skills/delphi-review/tools.md +29 -0
  139. package/plugins/qoder/skills/domain-modeling/ADR-FORMAT.md +64 -0
  140. package/plugins/qoder/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  141. package/plugins/qoder/skills/domain-modeling/SKILL.md +74 -0
  142. package/plugins/qoder/skills/grill-with-docs/SKILL.md +7 -0
  143. package/plugins/qoder/skills/grilling/SKILL.md +12 -0
  144. package/plugins/qoder/skills/ralph-loop/SKILL.md +87 -428
  145. package/plugins/qoder/skills/ralph-loop/references/components.md +113 -0
  146. package/plugins/qoder/skills/ralph-loop/references/workflow-details.md +99 -0
  147. package/plugins/qoder/skills/sprint-flow/AGENTS.md +5 -5
  148. package/plugins/qoder/skills/sprint-flow/SKILL.md +255 -454
  149. package/plugins/qoder/skills/sprint-flow/__tests__/sprint-flow.test.cjs +457 -0
  150. package/plugins/qoder/skills/sprint-flow/hooks.md +18 -0
  151. package/plugins/qoder/skills/sprint-flow/references/components/memory.md +1 -1
  152. package/plugins/qoder/skills/sprint-flow/references/components/middleware.md +1 -1
  153. package/plugins/qoder/skills/sprint-flow/references/components/skill-invocations.md +21 -20
  154. package/plugins/qoder/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
  155. package/plugins/qoder/skills/sprint-flow/references/force-levels.md +0 -1
  156. package/plugins/qoder/skills/sprint-flow/references/orchestration-rules.md +34 -27
  157. package/plugins/qoder/skills/sprint-flow/references/phase-1-prep.md +1 -1
  158. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +160 -123
  159. package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +203 -2
  160. package/plugins/qoder/skills/sprint-flow/references/phase-4-verify.md +107 -34
  161. package/plugins/qoder/skills/sprint-flow/references/phase-5-ship.md +194 -12
  162. package/plugins/qoder/skills/sprint-flow/references/phase-6-close.md +139 -7
  163. package/plugins/qoder/skills/sprint-flow/references/phase-overview.md +486 -0
  164. package/plugins/qoder/skills/sprint-flow/scripts/verify-sprint-state.sh +38 -0
  165. package/plugins/qoder/skills/sprint-flow/tools.md +29 -0
  166. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  167. package/plugins/qoder/skills/test-specification-alignment/SKILL.md +104 -601
  168. package/plugins/qoder/skills/to-issues/SKILL.md +1 -1
  169. package/principles/AGENTS.md +9 -8
  170. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  171. package/principles/__tests__/analyzer.test.ts +29 -6
  172. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  173. package/principles/__tests__/baseline-prune.test.ts +338 -0
  174. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  175. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  176. package/principles/__tests__/boy-scout.test.ts +253 -7
  177. package/principles/__tests__/config-enforcement.test.ts +469 -0
  178. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  179. package/principles/__tests__/direct-execution.test.ts +187 -0
  180. package/principles/__tests__/index.test.ts +16 -3
  181. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  182. package/principles/adapters/typescript.ts +613 -13
  183. package/principles/analyzer.ts +22 -0
  184. package/principles/baseline-prune.ts +217 -0
  185. package/principles/boy-scout.ts +481 -51
  186. package/principles/config.ts +362 -54
  187. package/principles/direct-execution.ts +60 -0
  188. package/principles/index.ts +40 -17
  189. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  190. package/principles/rules/clean-code/code-duplication.ts +8 -8
  191. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  192. package/principles/rules/clean-code/god-class.ts +8 -8
  193. package/principles/rules/clean-code/large-file.ts +15 -11
  194. package/principles/rules/clean-code/long-function.ts +8 -8
  195. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  196. package/principles/rules/clean-code/many-exports.ts +7 -7
  197. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  198. package/principles/rules/clean-code/too-many-params.ts +8 -8
  199. package/principles/rules/clean-code/unused-imports.ts +5 -5
  200. package/principles/rules/solid/dip.ts +18 -8
  201. package/principles/rules/solid/isp.ts +9 -8
  202. package/principles/rules/solid/lsp.ts +6 -6
  203. package/principles/rules/solid/ocp.ts +6 -5
  204. package/principles/rules/solid/srp.ts +7 -8
  205. package/principles/types.ts +15 -0
  206. package/scripts/__tests__/sync-package-content.test.js +28 -0
  207. package/scripts/delphi-external-review.cjs +193 -10
  208. package/scripts/sync-package-content.js +7 -4
  209. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  210. package/skills/delphi-review/AGENTS.md +4 -4
  211. package/skills/delphi-review/SKILL.md +98 -14
  212. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  213. package/skills/delphi-review/references/requirements.md +6 -6
  214. package/skills/sprint-flow/AGENTS.md +4 -4
  215. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  216. package/skills/test-specification-alignment/AGENTS.md +4 -4
  217. package/lib/__tests__/download-skill.test.js +0 -281
  218. package/lib/download-skill.js +0 -96
  219. package/plugins/qoder/skills/ralph-loop/references/components/memory.md +0 -55
  220. package/plugins/qoder/skills/ralph-loop/references/components/middleware.md +0 -54
  221. package/plugins/qoder/skills/ralph-loop/references/components/skill-invocations.md +0 -39
  222. package/plugins/qoder/skills/ralph-loop/references/components/system-prompt.md +0 -24
  223. package/plugins/qoder/skills/ralph-loop/references/components/tool-descriptions.md +0 -32
package/lib/doctor.js CHANGED
@@ -42,6 +42,7 @@ function getPackageVersion() {
42
42
  const SIGNATURES = {
43
43
  'pre-commit': 'OpenCode Quality Gates - Pre-Commit Hook',
44
44
  'pre-push': 'Pre-push Hook — 8 gates:',
45
+ 'post-merge': 'XP-Gate VERSION sync hook',
45
46
  'adapter-common.sh': 'detect_project_lang()'
46
47
  };
47
48
 
@@ -231,6 +232,7 @@ async function checkLocalHooks(checks) {
231
232
  const hooksDir = path.join(gitDir, 'hooks');
232
233
  issues += checkSingleHook(hooksDir, 'pre-commit', SIGNATURES['pre-commit'], 'Hooks', checks);
233
234
  issues += checkSingleHook(hooksDir, 'pre-push', SIGNATURES['pre-push'], 'Hooks', checks);
235
+ issues += checkSingleHook(hooksDir, 'post-merge', SIGNATURES['post-merge'], 'Hooks', checks);
234
236
  return issues;
235
237
  }
236
238
 
@@ -238,12 +240,13 @@ async function checkGlobalHooks(checks) {
238
240
  let issues = 0;
239
241
  issues += checkSingleHook(GLOBAL_HOOKS_DIR, 'pre-commit', SIGNATURES['pre-commit'], 'Global hooks', checks);
240
242
  issues += checkSingleHook(GLOBAL_HOOKS_DIR, 'pre-push', SIGNATURES['pre-push'], 'Global hooks', checks);
243
+ issues += checkSingleHook(GLOBAL_HOOKS_DIR, 'post-merge', SIGNATURES['post-merge'], 'Global hooks', checks);
241
244
 
242
245
  const hooksPath = await getCurrentHooksPath();
243
246
  if (hooksPath === null || hooksPath === '') {
244
247
  checks.push({ name: 'Git core.hooksPath', status: 'FAIL', detail: 'Not set' });
245
248
  issues++;
246
- } else if (hooksPath !== GLOBAL_HOOKS_DIR) {
249
+ } else if (!pathsEquivalent(hooksPath, GLOBAL_HOOKS_DIR)) {
247
250
  checks.push({ name: 'Git core.hooksPath', status: 'FAIL', detail: `Expected ${GLOBAL_HOOKS_DIR}, got ${hooksPath}` });
248
251
  issues++;
249
252
  } else {
@@ -252,6 +255,845 @@ async function checkGlobalHooks(checks) {
252
255
  return issues;
253
256
  }
254
257
 
258
+ /**
259
+ * Compare two paths that must denote the same directory.
260
+ *
261
+ * `git config` echoes core.hooksPath with forward slashes on Windows while
262
+ * path.join builds GLOBAL_HOOKS_DIR with backslashes, and drive letters come
263
+ * back lower-cased depending on who wrote them. A byte comparison therefore
264
+ * reported every correctly configured Windows machine as broken, and --fix
265
+ * could never clear it because the value it wrote read back differently (#496).
266
+ */
267
+ function pathsEquivalent(a, b) {
268
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
269
+ const normalize = (p) => {
270
+ let s = p.replace(/[\\/]+/g, '/');
271
+ if (s.length > 1) s = s.replace(/\/+$/, '');
272
+ return s.replace(/^([A-Za-z]):/, (m, drive) => `${drive.toUpperCase()}:`);
273
+ };
274
+ return normalize(a) === normalize(b);
275
+ }
276
+
277
+ /**
278
+ * Hook files eligible for repo-vs-installed drift comparison. Only hooks that
279
+ * exist on both sides are compared, so a repo without post-merge is not flagged.
280
+ * The lib/ shared libraries (sourced by pre-commit at runtime) are covered
281
+ * separately by diagnoseHookDrift/syncGlobalHooksFromRepo (#488).
282
+ */
283
+ const DRIFT_COMPARED_HOOKS = ['pre-commit', 'pre-push', 'post-merge'];
284
+
285
+ /**
286
+ * Compare the hooks that actually execute (the installed copy under
287
+ * core.hooksPath) against the repository's source-of-truth `githooks/` (#451).
288
+ *
289
+ * Editing `githooks/pre-commit` in a repo has no local effect when
290
+ * `core.hooksPath` points at an installed copy, and nothing used to report how
291
+ * far the two had diverged or in which direction. A stale installed copy makes
292
+ * the local gate *looser* than the repo (silent under-enforcement); a newer one
293
+ * makes it *stricter* (spurious local failures). Both need to be visible.
294
+ *
295
+ * Direction uses mtime, which is the only ordering signal available without a
296
+ * shared version marker. It is reported as a hint, not as proof of content order.
297
+ *
298
+ * @param {{repoRoot?: string, repoHooks?: string, installedHooks?: string}} [ctx]
299
+ * @returns {{checks: Array<{name: string, status: string, detail: string}>, issues: number}}
300
+ */
301
+ function diagnoseHookDrift(ctx = {}) {
302
+ const checks = [];
303
+ let issues = 0;
304
+
305
+ // XP_GATE_REPO_ROOT lets a caller (or a test) name the canonical repo
306
+ // explicitly instead of inferring it from the process cwd.
307
+ const repoRoot = ctx.repoRoot || process.env.XP_GATE_REPO_ROOT || process.cwd();
308
+ const repoHooks = ctx.repoHooks || path.join(repoRoot, 'githooks');
309
+ const installedHooks = ctx.installedHooks || GLOBAL_HOOKS_DIR;
310
+
311
+ // Nothing to compare when the project carries no canonical *hook* source, or
312
+ // when no global copy exists at all. Both must stay completely silent.
313
+ //
314
+ // A `githooks/` directory holding only `adapters/` is NOT a hook source of
315
+ // truth -- consumer projects get that layout from `xp-gate init`, and the
316
+ // adapters there are unrelated to which hook file executes.
317
+ if (!hasCanonicalHooks(repoHooks) || !fs.existsSync(installedHooks)) {
318
+ return { checks, issues };
319
+ }
320
+
321
+ for (const name of DRIFT_COMPARED_HOOKS) {
322
+ const repoFile = path.join(repoHooks, name);
323
+ const installedFile = path.join(installedHooks, name);
324
+ if (!fs.existsSync(repoFile) || !fs.existsSync(installedFile)) continue;
325
+
326
+ const label = `Hook drift: ${name}`;
327
+ let repoStat;
328
+ let installedStat;
329
+ try {
330
+ repoStat = fs.statSync(repoFile);
331
+ installedStat = fs.statSync(installedFile);
332
+ } catch {
333
+ continue;
334
+ }
335
+
336
+ if (filesAreIdentical(repoFile, installedFile)) {
337
+ checks.push({ name: label, status: 'PASS', detail: 'Installed copy matches githooks/' });
338
+ continue;
339
+ }
340
+
341
+ const direction = installedStat.mtimeMs > repoStat.mtimeMs
342
+ ? 'installed copy is newer than githooks/'
343
+ : 'githooks/ is newer than the installed copy (installed copy is older)';
344
+ checks.push({
345
+ name: label,
346
+ status: 'FAIL',
347
+ detail: `${direction} — local commits run githooks/${name} only after syncing`,
348
+ });
349
+ issues++;
350
+ }
351
+
352
+ // lib/ shared libraries: pre-commit sources lib/*.sh at runtime, so a missing
353
+ // or stale installed lib/ copy breaks the hook entirely after a sync (#488).
354
+ const repoLib = path.join(repoHooks, 'lib');
355
+ const installedLib = path.join(installedHooks, 'lib');
356
+ if (fs.existsSync(repoLib) && fs.statSync(repoLib).isDirectory()) {
357
+ let libEntries = [];
358
+ try {
359
+ libEntries = fs.readdirSync(repoLib, { withFileTypes: true });
360
+ } catch {
361
+ libEntries = [];
362
+ }
363
+ for (const entry of libEntries) {
364
+ if (!entry.isFile()) continue;
365
+ const rel = `lib/${entry.name}`;
366
+ const repoFile = path.join(repoLib, entry.name);
367
+ const installedFile = path.join(installedLib, entry.name);
368
+ const label = `Hook drift: ${rel}`;
369
+ if (!fs.existsSync(installedFile)) {
370
+ checks.push({
371
+ name: label,
372
+ status: 'FAIL',
373
+ detail: 'installed lib/ copy is missing (hooks source lib/ at runtime)',
374
+ });
375
+ issues++;
376
+ } else if (!filesAreIdentical(repoFile, installedFile)) {
377
+ checks.push({
378
+ name: label,
379
+ status: 'FAIL',
380
+ detail: 'installed lib/ copy differs from githooks/lib/',
381
+ });
382
+ issues++;
383
+ } else {
384
+ checks.push({ name: label, status: 'PASS', detail: 'Installed copy matches githooks/' });
385
+ }
386
+ }
387
+ }
388
+
389
+ return { checks, issues };
390
+ }
391
+
392
+ /** Compare two files by content length first, then by bytes. */
393
+ function filesAreIdentical(fileA, fileB) {
394
+ try {
395
+ const a = fs.readFileSync(fileA);
396
+ const b = fs.readFileSync(fileB);
397
+ return a.length === b.length && a.equals(b);
398
+ } catch {
399
+ return false;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Does this directory actually hold canonical hook source?
405
+ *
406
+ * Requires at least one of the compared hook files to exist. A directory with
407
+ * only `adapters/` (what `xp-gate init` writes into consumer projects) does not
408
+ * qualify: there is no hook there to be the source of truth.
409
+ *
410
+ * @param {string} repoHooks
411
+ * @returns {boolean}
412
+ */
413
+ function hasCanonicalHooks(repoHooks) {
414
+ if (!fs.existsSync(repoHooks)) return false;
415
+ return DRIFT_COMPARED_HOOKS.some((name) => fs.existsSync(path.join(repoHooks, name)));
416
+ }
417
+
418
+ /**
419
+ * Module names installed where the hook will look for them.
420
+ *
421
+ * pre-commit resolves an adapter flat first (`adapters/typescript.sh`) and only
422
+ * then nested (`adapters/adapters/typescript.sh`), so the file that executes is
423
+ * whichever of those two exists -- and only that one. Comparing the other copy
424
+ * would report drift in a file git never runs.
425
+ *
426
+ * @param {string} installedAdapterDir
427
+ * @returns {Array<{name: string, file: string}>}
428
+ */
429
+ function listExecutedModules(installedAdapterDir) {
430
+ const found = [];
431
+ const seen = new Set();
432
+
433
+ let entries;
434
+ try {
435
+ entries = fs.readdirSync(installedAdapterDir, { withFileTypes: true });
436
+ } catch {
437
+ return found;
438
+ }
439
+
440
+ for (const entry of entries) {
441
+ if (!entry.isFile() || !entry.name.endsWith('.sh')) continue;
442
+ const file = path.join(installedAdapterDir, entry.name);
443
+ if (seen.has(entry.name)) continue;
444
+ seen.add(entry.name);
445
+ found.push({ name: entry.name, file });
446
+ }
447
+
448
+ // Nested copies only count for names with no flat counterpart, because that
449
+ // is exactly the fallback resolve_adapter_path() takes.
450
+ const nestedDir = path.join(installedAdapterDir, 'adapters');
451
+ let nestedEntries = [];
452
+ try {
453
+ nestedEntries = fs.readdirSync(nestedDir, { withFileTypes: true });
454
+ } catch {
455
+ nestedEntries = [];
456
+ }
457
+ for (const entry of nestedEntries) {
458
+ if (!entry.isFile() || !entry.name.endsWith('.sh')) continue;
459
+ if (seen.has(entry.name)) continue;
460
+ seen.add(entry.name);
461
+ found.push({ name: entry.name, file: path.join(nestedDir, entry.name) });
462
+ }
463
+
464
+ return found;
465
+ }
466
+
467
+ /**
468
+ * Where the repository keeps the source of truth for one executed module.
469
+ *
470
+ * Mirrors the installer's own split: `adapter-common.sh` and `gate-*.sh` are
471
+ * copied from the githooks root, language adapters from `githooks/adapters/`.
472
+ *
473
+ * @param {string} repoHooks
474
+ * @param {string} name
475
+ * @returns {string|null}
476
+ */
477
+ function repoModuleSource(repoHooks, name) {
478
+ const rootLevel = name === 'adapter-common.sh' || /^gate-.+\.sh$/.test(name);
479
+ const candidates = rootLevel
480
+ ? [path.join(repoHooks, name)]
481
+ : [path.join(repoHooks, 'adapters', name), path.join(repoHooks, name)];
482
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
483
+ }
484
+
485
+ /**
486
+ * Which lines each side carries that the other does not.
487
+ *
488
+ * Content equality alone cannot tell a plain stale copy from a bidirectional
489
+ * divergence, and the difference decides whether overwriting is safe: a stale
490
+ * copy is a subset of the repo, while a machine-hardened copy holds lines the
491
+ * repo has never had. Blank/indentation noise is folded away so a reformatted
492
+ * file does not read as divergence.
493
+ *
494
+ * @param {string} repoText
495
+ * @param {string} installedText
496
+ * @returns {{repoOnly: number, installedOnly: number}}
497
+ */
498
+ function contentDelta(repoText, installedText) {
499
+ const lineSet = (text) => new Set(
500
+ text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
501
+ );
502
+ const repo = lineSet(repoText);
503
+ const installed = lineSet(installedText);
504
+ let repoOnly = 0;
505
+ let installedOnly = 0;
506
+ for (const line of repo) if (!installed.has(line)) repoOnly++;
507
+ for (const line of installed) if (!repo.has(line)) installedOnly++;
508
+ return { repoOnly, installedOnly };
509
+ }
510
+
511
+ /**
512
+ * Read two files as text, returning null when either side is unreadable.
513
+ */
514
+ function readPair(repoFile, installedFile) {
515
+ try {
516
+ return { repoText: fs.readFileSync(repoFile, 'utf8'), installedText: fs.readFileSync(installedFile, 'utf8') };
517
+ } catch {
518
+ return null;
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Compare the gate modules that actually execute against the repository's
524
+ * `githooks/` source (#495).
525
+ *
526
+ * `checkAdapters` only proves a gate script is *present*, and the hook drift
527
+ * check stops at the hook files plus `lib/`. The modules pre-commit sources for
528
+ * Gates 3/4/7/8/9/10 live in the global adapters directory, so a repo fix to
529
+ * `githooks/gate-8.sh` was invisible here and inert locally at the same time --
530
+ * which is why #493 stayed unfixed for so long.
531
+ *
532
+ * @param {{repoRoot?: string, repoHooks?: string, installedAdapters?: string}} [ctx]
533
+ * @returns {{checks: Array<{name: string, status: string, detail: string}>, issues: number}}
534
+ */
535
+ function diagnoseModuleDrift(ctx = {}) {
536
+ const checks = [];
537
+ let issues = 0;
538
+
539
+ const repoRoot = ctx.repoRoot || process.env.XP_GATE_REPO_ROOT || process.cwd();
540
+ const repoHooks = ctx.repoHooks || path.join(repoRoot, 'githooks');
541
+
542
+ // Silent without a canonical repo source to compare against: consumer
543
+ // projects have no `githooks/` root of truth, only what was installed.
544
+ if (!hasCanonicalHooks(repoHooks)) {
545
+ return { checks, issues };
546
+ }
547
+
548
+ const dirs = resolveExecutedModuleDirs({
549
+ globalAdapterDir: ctx.globalAdapterDir || ctx.installedAdapters,
550
+ projectGithooks: ctx.projectGithooks || repoHooks,
551
+ scriptDir: ctx.scriptDir,
552
+ });
553
+
554
+ // Copies the hook never resolves to, occupying the lookup positions it does
555
+ // resolve through. They are not drift -- nothing executes them -- but they are
556
+ // how #500 reproduces: the next install order shift, or a move to project
557
+ // mode, promotes one silently. Report with the absolute path so the developer
558
+ // can judge; do not count, because no shipped command removes them (#495 R2).
559
+ const executedFor = new Map();
560
+ for (const name of repoModuleNames(repoHooks)) {
561
+ const source = repoModuleSource(repoHooks, name);
562
+ if (!source) continue;
563
+ const executed = executedModulePath(name, dirs);
564
+ executedFor.set(name, executed);
565
+
566
+ if (!executed) continue; // not installed at all: nothing executes, nothing to judge
567
+ if (pathsEquivalent(executed, source)) continue; // the repo's own copy runs
568
+
569
+ const label = `${tierFor(executed, dirs).prefix}: ${name}`;
570
+ if (filesAreIdentical(source, executed)) {
571
+ checks.push({ name: label, status: 'PASS', detail: 'Executed copy matches githooks/' });
572
+ continue;
573
+ }
574
+
575
+ const texts = readPair(source, executed);
576
+ if (!texts) {
577
+ checks.push({ name: label, status: 'FAIL', detail: 'executed copy could not be read for comparison' });
578
+ issues++;
579
+ continue;
580
+ }
581
+ const { repoOnly, installedOnly } = contentDelta(texts.repoText, texts.installedText);
582
+ // contentDelta folds whitespace and line endings away, so an all-zero delta
583
+ // means the two copies say the same thing differently. Calling that drift
584
+ // would report FAIL while claiming zero unique lines (#495 Round 2).
585
+ if (repoOnly === 0 && installedOnly === 0) {
586
+ checks.push({ name: label, status: 'PASS', detail: 'Same content as githooks/, differing only in line endings or indentation' });
587
+ continue;
588
+ }
589
+ const hint = TIER_HINTS[tierFor(executed, dirs).tier] || '';
590
+ checks.push({ name: label, status: 'FAIL', detail: `${moduleDriftDetail(repoOnly, installedOnly)}${hint}` });
591
+ issues++;
592
+ }
593
+
594
+ for (const [name, file] of nonExecutedCopies(dirs, executedFor)) {
595
+ checks.push({
596
+ name: `Non-executed copy: ${name}`,
597
+ status: 'WARN',
598
+ detail: `${file} is not executed (the hook resolves this module elsewhere), so it is installer residue and NOT counted as drift; `
599
+ + 'it shadows the tracked file the moment ADAPTER_DIR resolves to its tier (#500). Remove it by hand -- no doctor command deletes installed copies.',
600
+ });
601
+ }
602
+
603
+ return { checks, issues };
604
+ }
605
+
606
+ /**
607
+ * Every `.sh` copy the repository knows about that the resolution did NOT pick.
608
+ *
609
+ * @param {ReturnType<typeof resolveExecutedModuleDirs>} dirs
610
+ * @param {Map<string, string|null>} executedFor
611
+ * @returns {Array<[string, string]>}
612
+ */
613
+ function nonExecutedCopies(dirs, executedFor) {
614
+ const found = [];
615
+ const seenFiles = new Set();
616
+ const tiers = [
617
+ dirs.globalAdapterDir,
618
+ path.join(dirs.globalAdapterDir, 'adapters'),
619
+ dirs.adapterDir,
620
+ path.join(dirs.adapterDir, 'adapters'),
621
+ dirs.projectGithooks,
622
+ path.join(dirs.projectGithooks, 'adapters'),
623
+ dirs.scriptDir,
624
+ path.join(dirs.scriptDir, 'adapters'),
625
+ ];
626
+ for (const dir of tiers) {
627
+ for (const mod of listExecutedModules(dir)) {
628
+ if (seenFiles.has(path.resolve(mod.file))) continue;
629
+ if (!executedFor.has(mod.name)) continue; // not a module the repo ships
630
+ const executed = executedFor.get(mod.name);
631
+ if (executed && pathsEquivalent(executed, mod.file)) continue;
632
+ if (pathsEquivalent(repoModuleSource(dirs.projectGithooks, mod.name) || '', mod.file)) continue;
633
+ seenFiles.add(path.resolve(mod.file));
634
+ found.push([mod.name, mod.file]);
635
+ }
636
+ }
637
+ return found;
638
+ }
639
+
640
+ /**
641
+ * Describe one drifted module by the direction of its drift.
642
+ *
643
+ * Only a module the *repo* is ahead of is safe to overwrite; anything where the
644
+ * installed copy holds its own lines needs an explicit decision, because syncing
645
+ * would delete work that exists nowhere else.
646
+ */
647
+ function moduleDriftDetail(repoOnly, installedOnly) {
648
+ if (repoOnly > 0 && installedOnly > 0) {
649
+ return `diverged both ways — githooks/ has ${repoOnly} line(s) the install lacks, `
650
+ + `the install has ${installedOnly} line(s) githooks/ lacks; sync refuses without an explicit decision`;
651
+ }
652
+ if (repoOnly > 0) {
653
+ return `installed copy is stale (${repoOnly} line(s) in githooks/ absent from the install)`;
654
+ }
655
+ return `installed copy is newer than githooks/ (${installedOnly} line(s) absent from the repo)`;
656
+ }
657
+
658
+ /**
659
+ * Copy the repository's executed gate modules over the installed ones (#495).
660
+ *
661
+ * Deliberately refuses to overwrite a module whose installed copy carries lines
662
+ * the repo does not. That is the machine-only-hardening case (Gate 8's
663
+ * detect-secrets fallback here), and a silent `--fix` destroying it would be
664
+ * worse than the drift it repairs.
665
+ *
666
+ * @param {{repoRoot?: string, repoHooks?: string, installedAdapters?: string, force?: boolean}} [ctx]
667
+ * @returns {{synced: string[], skipped: string[], refused: Array<{name: string, repoOnly: number, installedOnly: number}>, errors: string[], installedAdapters: string}}
668
+ */
669
+ function syncModulesFromRepo(ctx = {}) {
670
+ const synced = [];
671
+ const skipped = [];
672
+ const refused = [];
673
+ const errors = [];
674
+
675
+ // The diagnosis and the repair must agree on which repository they are talking
676
+ // about, or `XP_GATE_REPO_ROOT doctor --sync-hooks` reports drift it never
677
+ // clears (#495 Round 2; the same silent-no-op class #488/#416 outlawed).
678
+ const repoRoot = ctx.repoRoot || process.env.XP_GATE_REPO_ROOT || process.cwd();
679
+ const repoHooks = ctx.repoHooks || path.join(repoRoot, 'githooks');
680
+
681
+ if (!hasCanonicalHooks(repoHooks)) {
682
+ errors.push(`no canonical githooks/ source at ${repoHooks} -- nothing was synced`);
683
+ return { synced, skipped, refused, errors, installedAdapters: ctx.installedAdapters || ctx.globalAdapterDir || GLOBAL_ADAPTERS_DIR, sourceMissing: true };
684
+ }
685
+
686
+ // Same resolution the diagnosis used, so a FAIL the diagnosis can produce is a
687
+ // FAIL this repair can clear -- in whichever tier it lives (#495 Round 3; the
688
+ // Round-2 pair diagnosed two tiers and synced one, leaving a permanent exit 1).
689
+ const dirs = resolveExecutedModuleDirs({
690
+ globalAdapterDir: ctx.globalAdapterDir || ctx.installedAdapters,
691
+ projectGithooks: ctx.projectGithooks || repoHooks,
692
+ scriptDir: ctx.scriptDir,
693
+ });
694
+ const installedAdapters = dirs.globalAdapterDir;
695
+
696
+ for (const name of repoModuleNames(repoHooks)) {
697
+ const source = repoModuleSource(repoHooks, name);
698
+ if (!source) continue;
699
+ const target = executedModulePath(name, dirs);
700
+ if (!target) continue; // nothing executes this module; residue is not this command's business
701
+ if (pathsEquivalent(source, target)) {
702
+ skipped.push(name);
703
+ continue;
704
+ }
705
+ if (filesAreIdentical(source, target)) {
706
+ skipped.push(name);
707
+ continue;
708
+ }
709
+
710
+ const decision = copyGuardDecision(source, target, !!ctx.force);
711
+ // Formatting-only: nothing to repair, and rewriting the file would churn it.
712
+ if (!decision.refuse && !decision.forced && decision.repoOnly === 0 && decision.installedOnly === 0) {
713
+ skipped.push(name);
714
+ continue;
715
+ }
716
+ if (decision.refuse) {
717
+ refused.push({ name, repoOnly: decision.repoOnly, installedOnly: decision.installedOnly, unknown: decision.unknown, target });
718
+ continue;
719
+ }
720
+
721
+ try {
722
+ fs.copyFileSync(source, target);
723
+ fs.chmodSync(target, 0o755);
724
+ synced.push(name);
725
+ } catch (err) {
726
+ errors.push(`${name}: ${err.message}`);
727
+ }
728
+ }
729
+
730
+ return { synced, skipped, refused, errors, installedAdapters, dirs, sourceMissing: false };
731
+ }
732
+
733
+ /**
734
+ * Resolve the directories pre-commit will source its gate modules from, using
735
+ * the same 3-tier order as the hook itself (#495). Without this, doctor's output
736
+ * cannot answer "which copy is running".
737
+ *
738
+ * This is the single resolution model the printout, the drift diagnosis and the
739
+ * repair all read from. Round 2 had a second tier list inside
740
+ * `diagnoseModuleDrift()` that walked the project directory unconditionally and
741
+ * labelled whatever it found "executed" -- but the hook only reaches
742
+ * `$PROJECT_GITHOOKS/adapters/<lang>.sh` (githooks/pre-commit:67-69), never a
743
+ * flat project copy, so that label asserted a direction that was false and
744
+ * produced a FAIL no repair path could clear (#495 Round 3).
745
+ *
746
+ * @param {{globalAdapterDir?: string, installedAdapters?: string, projectGithooks?: string, scriptDir?: string}} [ctx]
747
+ * @returns {{adapterDir: string, gateDir: string, globalAdapterDir: string, projectGithooks: string, scriptDir: string}}
748
+ */
749
+ function resolveExecutedModuleDirs(ctx = {}) {
750
+ const globalAdapterDir = ctx.globalAdapterDir || ctx.installedAdapters || GLOBAL_ADAPTERS_DIR;
751
+ const projectGithooks = ctx.projectGithooks || defaultProjectGithooks();
752
+ const scriptDir = ctx.scriptDir || GLOBAL_HOOKS_DIR;
753
+
754
+ const has = (dir, file) => fs.existsSync(path.join(dir, file));
755
+
756
+ let adapterDir = scriptDir;
757
+ if (has(globalAdapterDir, 'adapter-common.sh')) {
758
+ adapterDir = globalAdapterDir;
759
+ } else if (has(projectGithooks, 'adapter-common.sh')) {
760
+ adapterDir = projectGithooks;
761
+ }
762
+
763
+ let gateDir = adapterDir;
764
+ if (!has(adapterDir, 'gate-3.sh')) {
765
+ if (has(projectGithooks, 'gate-3.sh')) gateDir = projectGithooks;
766
+ else if (has(scriptDir, 'gate-3.sh')) gateDir = scriptDir;
767
+ }
768
+
769
+ return { adapterDir, gateDir, globalAdapterDir, projectGithooks, scriptDir };
770
+ }
771
+
772
+ /**
773
+ * The repository's `githooks/` as the hook itself derives it -- `git rev-parse
774
+ * --show-toplevel` (githooks/pre-commit:31), not the current directory. Run
775
+ * from a subdirectory, a cwd-based answer finds no `githooks/` and the whole
776
+ * drift surface goes silent (#495 Round 3).
777
+ */
778
+ function defaultProjectGithooks() {
779
+ if (process.env.XP_GATE_REPO_ROOT) {
780
+ return path.join(process.env.XP_GATE_REPO_ROOT, 'githooks');
781
+ }
782
+ try {
783
+ const toplevel = execSync('git rev-parse --show-toplevel', {
784
+ encoding: 'utf8',
785
+ timeout: EXEC_TIMEOUT_MS,
786
+ stdio: ['pipe', 'pipe', 'pipe'],
787
+ }).trim();
788
+ if (toplevel) return path.join(toplevel, 'githooks');
789
+ } catch {
790
+ // Not a git repository -- fall back to the directory we were called in.
791
+ }
792
+ return path.join(process.cwd(), 'githooks');
793
+ }
794
+
795
+ /**
796
+ * Module names the repository carries as source of truth.
797
+ *
798
+ * Enumerated from the repo rather than from what happens to be installed: the
799
+ * question the surface answers is "which copy of the file I changed is running",
800
+ * and a name nobody installed has no executed copy to judge.
801
+ *
802
+ * @param {string} repoHooks
803
+ * @returns {string[]}
804
+ */
805
+ function repoModuleNames(repoHooks) {
806
+ const names = new Set();
807
+ const collect = (dir) => {
808
+ let entries;
809
+ try {
810
+ entries = fs.readdirSync(dir, { withFileTypes: true });
811
+ } catch {
812
+ return;
813
+ }
814
+ for (const entry of entries) {
815
+ if (entry.isFile() && entry.name.endsWith('.sh')) names.add(entry.name);
816
+ }
817
+ };
818
+ collect(repoHooks);
819
+ collect(path.join(repoHooks, 'adapters'));
820
+ return [...names].sort();
821
+ }
822
+
823
+ /**
824
+ * Gate modules live flat next to `adapter-common.sh`; language adapters are
825
+ * looked up flat first, then nested (githooks/pre-commit:54-77).
826
+ */
827
+ function isGateModuleFile(name) {
828
+ return name === 'adapter-common.sh'
829
+ || name === 'sprint-gate.sh'
830
+ || /^gate-.+\.sh$/.test(name);
831
+ }
832
+
833
+ /**
834
+ * Which file pre-commit will actually read for one module name (#495 Round 3).
835
+ *
836
+ * @param {string} name
837
+ * @param {ReturnType<typeof resolveExecutedModuleDirs>} dirs
838
+ * @returns {string|null} null when nothing executes (the module is not installed)
839
+ */
840
+ function executedModulePath(name, dirs) {
841
+ // `adapter-common.sh` is sourced from ADAPTER_DIR (githooks/pre-commit:41)
842
+ // before GATE_DIR is ever resolved, so it does not follow the gate-module chain.
843
+ if (name === 'adapter-common.sh') {
844
+ const sourced = path.join(dirs.adapterDir, name);
845
+ return fs.existsSync(sourced) ? sourced : null;
846
+ }
847
+ const candidates = isGateModuleFile(name)
848
+ ? [path.join(dirs.gateDir, name)]
849
+ : [
850
+ path.join(dirs.adapterDir, name),
851
+ path.join(dirs.adapterDir, 'adapters', name),
852
+ path.join(dirs.projectGithooks, 'adapters', name),
853
+ path.join(dirs.scriptDir, 'adapters', name),
854
+ ];
855
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
856
+ }
857
+
858
+ /**
859
+ * Which tier an executed copy came from, so a FAIL says where it ran from.
860
+ */
861
+ function tierFor(executedPath, dirs) {
862
+ const under = (dir) => {
863
+ const base = path.resolve(dir);
864
+ const file = path.resolve(executedPath, '..');
865
+ return pathsEquivalent(base, file) || file.startsWith(`${base}${path.sep}`) || file.startsWith(`${base}/`);
866
+ };
867
+ if (under(dirs.projectGithooks)) return { prefix: 'Module drift (project)', tier: 'project' };
868
+ if (under(dirs.scriptDir)) return { prefix: 'Module drift (script dir)', tier: 'script dir' };
869
+ return { prefix: 'Module drift', tier: 'global' };
870
+ }
871
+
872
+ const TIER_HINTS = {
873
+ project: ' -- executed from the project tier: the hook resolves ADAPTER_DIR to <repo>/githooks and reads a flat copy before the nested source (#500)',
874
+ 'script dir': ' -- executed from the installed hooks dir: GATE_DIR fell through to $SCRIPT_DIR (githooks/pre-commit:84-85)',
875
+ };
876
+
877
+ /**
878
+ * Decide whether one copy may be overwritten (#495 Round 2/3).
879
+ *
880
+ * Refuses when the destination holds lines the source lacks (machine-only
881
+ * hardening) and ALSO when its content cannot be read: an unreadable
882
+ * destination is unknown content, and unknown content is exactly what the
883
+ * refusal exists to protect.
884
+ *
885
+ * @param {string} source
886
+ * @param {string} target
887
+ * @param {boolean} force
888
+ * @returns {{refuse: boolean, forced: boolean, unknown: boolean, repoOnly: number, installedOnly: number}}
889
+ */
890
+ function copyGuardDecision(source, target, force) {
891
+ if (!fs.existsSync(target)) return { refuse: false, forced: false, unknown: false, repoOnly: 0, installedOnly: 0 };
892
+ if (force) return { refuse: false, forced: true, unknown: false, repoOnly: 0, installedOnly: 0 };
893
+ const texts = readPair(source, target);
894
+ if (!texts) return { refuse: true, forced: false, unknown: true, repoOnly: 0, installedOnly: 0 };
895
+ const { repoOnly, installedOnly } = contentDelta(texts.repoText, texts.installedText);
896
+ return { refuse: installedOnly > 0, forced: false, unknown: false, repoOnly, installedOnly };
897
+ }
898
+
899
+ /**
900
+ * Split a list of already-installed files into the ones a refresh may overwrite
901
+ * and the ones a human must judge first (#495 Round 3).
902
+ *
903
+ * `fixStaleHooks()` used to hand the whole list to a forced, backup-less
904
+ * `updateHooks()` call, which destroyed machine-only content inside the same
905
+ * command whose module path refuses to do it.
906
+ *
907
+ * @param {Array<{name: string, source: string, target: string}>} entries
908
+ * @returns {{safe: Array, diverged: Array, unknown: Array, hasBlocking: boolean}}
909
+ */
910
+ function planStaleHookSync(entries) {
911
+ const safe = [];
912
+ const diverged = [];
913
+ const unknown = [];
914
+ for (const entry of entries) {
915
+ if (!fs.existsSync(entry.target)) {
916
+ safe.push(entry);
917
+ continue;
918
+ }
919
+ const texts = readPair(entry.source, entry.target);
920
+ if (!texts) {
921
+ unknown.push(entry);
922
+ continue;
923
+ }
924
+ const { repoOnly, installedOnly } = contentDelta(texts.repoText, texts.installedText);
925
+ if (repoOnly === 0 && installedOnly === 0) continue; // same content, different line endings
926
+ if (installedOnly > 0) diverged.push({ ...entry, repoOnly, installedOnly });
927
+ else safe.push({ ...entry, repoOnly, installedOnly: 0 });
928
+ }
929
+ return { safe, diverged, unknown, hasBlocking: diverged.length > 0 || unknown.length > 0 };
930
+ }
931
+
932
+ /**
933
+ * Print the resolved module directories so drift output is actionable (#495).
934
+ */
935
+ function printEffectiveModules() {
936
+ const { adapterDir, gateDir } = resolveExecutedModuleDirs();
937
+ console.log('');
938
+ console.log('Effective gate modules (sourced by pre-commit):');
939
+ console.log('---------------------------------------------');
940
+ console.log(` ADAPTER_DIR: ${adapterDir}`);
941
+ console.log(` GATE_DIR: ${gateDir}`);
942
+ }
943
+
944
+ /**
945
+ * Print which hook files git will actually execute, and where they come from
946
+ * (#451 REQ-3). A developer who edits `githooks/pre-commit` and sees no change
947
+ * has no way to discover that a different copy is in charge; this makes the
948
+ * effective path and its origin explicit.
949
+ */
950
+ function printEffectiveHooks() {
951
+ const projectHooksPath = path.join(process.cwd(), '.git', 'hooks');
952
+
953
+ let configured = null;
954
+ try {
955
+ configured = execSync('git config --get core.hooksPath', {
956
+ encoding: 'utf8',
957
+ timeout: EXEC_TIMEOUT_MS,
958
+ stdio: ['pipe', 'pipe', 'pipe'],
959
+ }).trim();
960
+ } catch {
961
+ // No explicit core.hooksPath -> git falls back to .git/hooks.
962
+ configured = '';
963
+ }
964
+
965
+ const effective = configured || projectHooksPath;
966
+ const source = configured
967
+ ? 'core.hooksPath (global install)'
968
+ : '.git/hooks (project default)';
969
+
970
+ console.log('');
971
+ console.log('Effective hooks:');
972
+ console.log('----------------');
973
+ console.log(` ${effective}`);
974
+ console.log(` source: ${source}`);
975
+ }
976
+
977
+ /**
978
+ * Copy the repository's `githooks/` over the installed global hooks (#451 REQ-2).
979
+ *
980
+ * This is the one-command escape from "I edited githooks/ and nothing changed".
981
+ * Only hooks that exist in the repo are copied; unrelated files in the installed
982
+ * directory are left alone.
983
+ *
984
+ * @param {{repoRoot?: string, repoHooks?: string, installedHooks?: string}} [ctx]
985
+ * @returns {{synced: string[], skipped: string[], refused: Array<{name: string, repoOnly: number, installedOnly: number}>, errors: string[], installedHooks: string}}
986
+ */
987
+ function syncGlobalHooksFromRepo(ctx = {}) {
988
+ const repoRoot = ctx.repoRoot || process.env.XP_GATE_REPO_ROOT || process.cwd();
989
+ const repoHooks = ctx.repoHooks || path.join(repoRoot, 'githooks');
990
+ const installedHooks = ctx.installedHooks || GLOBAL_HOOKS_DIR;
991
+
992
+ const synced = [];
993
+ const skipped = [];
994
+ const refused = [];
995
+ const errors = [];
996
+
997
+ if (!fs.existsSync(repoHooks)) {
998
+ errors.push(`no githooks/ directory at ${repoHooks}`);
999
+ return { synced, skipped, refused, errors, installedHooks };
1000
+ }
1001
+
1002
+ if (!hasCanonicalHooks(repoHooks)) {
1003
+ errors.push(`no hook files in ${repoHooks} (only adapters?) -- nothing to sync from`);
1004
+ return { synced, skipped, refused, errors, installedHooks };
1005
+ }
1006
+
1007
+ try {
1008
+ fs.mkdirSync(installedHooks, { recursive: true });
1009
+ } catch (err) {
1010
+ errors.push(`cannot create ${installedHooks}: ${err.message}`);
1011
+ return { synced, skipped, refused, errors, installedHooks };
1012
+ }
1013
+
1014
+ for (const name of DRIFT_COMPARED_HOOKS) {
1015
+ const source = path.join(repoHooks, name);
1016
+ if (!fs.existsSync(source)) {
1017
+ skipped.push(name);
1018
+ continue;
1019
+ }
1020
+ const target = path.join(installedHooks, name);
1021
+ try {
1022
+ if (fs.existsSync(target) && filesAreIdentical(source, target)) {
1023
+ skipped.push(name);
1024
+ continue;
1025
+ }
1026
+ // copyFileSync preserves content; the exec bit is re-applied explicitly
1027
+ // because a copied hook that is not executable silently never runs.
1028
+ fs.copyFileSync(source, target);
1029
+ fs.chmodSync(target, 0o755);
1030
+ synced.push(name);
1031
+ } catch (err) {
1032
+ errors.push(`${name}: ${err.message}`);
1033
+ }
1034
+ }
1035
+
1036
+ // lib/ shared libraries: pre-commit sources lib/*.sh, so a synced hook file
1037
+ // without its libraries dies at the source line on the machine (#488).
1038
+ const repoLib = path.join(repoHooks, 'lib');
1039
+ const installedLib = path.join(installedHooks, 'lib');
1040
+ if (fs.existsSync(repoLib) && fs.statSync(repoLib).isDirectory()) {
1041
+ try {
1042
+ fs.mkdirSync(installedLib, { recursive: true });
1043
+ } catch (err) {
1044
+ errors.push(`cannot create ${installedLib}: ${err.message}`);
1045
+ return { synced, skipped, refused, errors, installedHooks };
1046
+ }
1047
+ let libEntries = [];
1048
+ try {
1049
+ libEntries = fs.readdirSync(repoLib, { withFileTypes: true });
1050
+ } catch (err) {
1051
+ errors.push(`cannot read ${repoLib}: ${err.message}`);
1052
+ return { synced, skipped, refused, errors, installedHooks };
1053
+ }
1054
+ for (const entry of libEntries) {
1055
+ if (!entry.isFile()) continue;
1056
+ const rel = `lib/${entry.name}`;
1057
+ const source = path.join(repoLib, entry.name);
1058
+ const target = path.join(installedLib, entry.name);
1059
+ try {
1060
+ if (fs.existsSync(target) && filesAreIdentical(source, target)) {
1061
+ skipped.push(rel);
1062
+ continue;
1063
+ }
1064
+ // A shared library can carry machine-only hardening just like a gate
1065
+ // module does, so it gets the same refusal instead of an unconditional
1066
+ // overwrite (#488 Round 2 -- the module guard and the lib guard must not
1067
+ // disagree about how destructive a sync is allowed to be). Unreadable
1068
+ // content refuses too: unknown is not the same as safe (#495 Round 3).
1069
+ if (fs.existsSync(target)) {
1070
+ const decision = copyGuardDecision(source, target, !!ctx.force);
1071
+ if (decision.refuse) {
1072
+ refused.push({
1073
+ name: rel,
1074
+ repoOnly: decision.repoOnly,
1075
+ installedOnly: decision.installedOnly,
1076
+ unknown: decision.unknown,
1077
+ });
1078
+ continue;
1079
+ }
1080
+ if (decision.forced === false && decision.repoOnly === 0 && decision.installedOnly === 0) {
1081
+ skipped.push(rel);
1082
+ continue;
1083
+ }
1084
+ }
1085
+ fs.copyFileSync(source, target);
1086
+ fs.chmodSync(target, 0o755);
1087
+ synced.push(rel);
1088
+ } catch (err) {
1089
+ errors.push(`${rel}: ${err.message}`);
1090
+ }
1091
+ }
1092
+ }
1093
+
1094
+ return { synced, skipped, refused, errors, installedHooks };
1095
+ }
1096
+
255
1097
  /**
256
1098
  * Check a single hook file exists and is an xp-gate file.
257
1099
  */
@@ -306,7 +1148,7 @@ async function checkAdapters(checks, mode, gitDirPromise) {
306
1148
  });
307
1149
  issues++;
308
1150
  } else {
309
- checks.push({ name: 'Gate scripts', status: 'PASS', detail: `${EXPECTED_GATE_SCRIPTS.length} gate script(s)` });
1151
+ checks.push({ name: 'Gate scripts', status: 'PASS', detail: `${EXPECTED_GATE_SCRIPTS.length} gate script(s) present (presence only; content is covered by the Module drift checks)` });
310
1152
  }
311
1153
 
312
1154
  return issues;
@@ -365,6 +1207,19 @@ async function diagnoseAsync() {
365
1207
  const tuiPromise = diagnoseTuiRegistration(checks);
366
1208
  const skillsPromise = diagnoseInstalledSkills(config, checks);
367
1209
 
1210
+ // Group E: repo-vs-installed hook drift (#451). Independent of config.mode:
1211
+ // the drift matters exactly because the installed copy, not githooks/, is
1212
+ // what executes. Detect if this is the xp-gate repo itself.
1213
+ const driftPromise = Promise.resolve().then(() => {
1214
+ const drift = diagnoseHookDrift();
1215
+ checks.push(...drift.checks);
1216
+ // The gate modules pre-commit sources at runtime drift independently of the
1217
+ // hook files and live in a different directory (#495).
1218
+ const moduleDrift = diagnoseModuleDrift();
1219
+ checks.push(...moduleDrift.checks);
1220
+ return drift.issues + moduleDrift.issues;
1221
+ });
1222
+
368
1223
  // Wait for ALL parallel groups
369
1224
  const results = await Promise.allSettled([
370
1225
  hooksPromise,
@@ -373,6 +1228,7 @@ async function diagnoseAsync() {
373
1228
  cliToolsPromise,
374
1229
  tuiPromise,
375
1230
  skillsPromise,
1231
+ driftPromise,
376
1232
  ]);
377
1233
 
378
1234
  // Collect issue counts from settled promises
@@ -665,8 +1521,10 @@ function fixMissingHooks(mode, srcDir, hooksDir) {
665
1521
  const label = mode === 'local' ? '' : 'global ';
666
1522
  const preCommitLabel = `${label}pre-commit hook`;
667
1523
  const prePushLabel = `${label}pre-push hook`;
1524
+ const postMergeLabel = `${label}post-merge hook`;
668
1525
  fixed = restoreHook(path.join(srcDir, 'hooks', 'pre-commit'), path.join(hooksDir, 'pre-commit'), preCommitLabel) || fixed;
669
1526
  fixed = restoreHook(path.join(srcDir, 'hooks', 'pre-push'), path.join(hooksDir, 'pre-push'), prePushLabel) || fixed;
1527
+ fixed = restoreHook(path.join(srcDir, 'hooks', 'post-merge'), path.join(hooksDir, 'post-merge'), postMergeLabel) || fixed;
670
1528
  return fixed;
671
1529
  }
672
1530
 
@@ -767,7 +1625,7 @@ function fixHooksByMode(config, srcDir) {
767
1625
  function fixGlobalHooksPath(config) {
768
1626
  if (config.mode !== 'global') return false;
769
1627
  const hooksPath = getCurrentHooksPathSync();
770
- if (hooksPath !== GLOBAL_HOOKS_DIR) {
1628
+ if (!pathsEquivalent(hooksPath, GLOBAL_HOOKS_DIR)) {
771
1629
  return fixCoreHooksPath(GLOBAL_HOOKS_DIR);
772
1630
  }
773
1631
  return false;
@@ -783,7 +1641,7 @@ function getAdaptersDirByMode(config) {
783
1641
  * Attempt to fix known issues.
784
1642
  * Only operates when mode === 'active' (local or global).
785
1643
  */
786
- function fixIssues(checks, config) {
1644
+ function fixIssues(checks, config, installToolsFlag = false) {
787
1645
  console.log('');
788
1646
  console.log('Attempting fixes...');
789
1647
  console.log('-------------------');
@@ -797,7 +1655,15 @@ function fixIssues(checks, config) {
797
1655
  fixed = fixMissingAdapters(config.mode, srcDir, getAdaptersDirByMode(config)) || fixed;
798
1656
  fixed = fixMissingGateScripts(srcDir, getAdaptersDirByMode(config)) || fixed;
799
1657
  fixed = fixStaleHooks(config) || fixed;
800
- fixed = fixMissingCliTools() || fixed;
1658
+ fixed = fixHookDriftFromRepo() || fixed;
1659
+ // Installing a gate CLI (gitleaks/semgrep/lizard) changes the machine the same
1660
+ // way a language toolchain does, so it goes behind the same opt-in: `xp-gate
1661
+ // install` ends by running `doctor --fix`, which made an unguarded call here a
1662
+ // post-install network write nobody asked for (#502 Round 2). Guidance below
1663
+ // stays ungated, because reporting is not a mutation.
1664
+ if (installToolsFlag) {
1665
+ fixed = fixMissingCliTools() || fixed;
1666
+ }
801
1667
  fixed = fixTuiRegistration() || fixed;
802
1668
  fixed = printCliToolGuidance() || fixed;
803
1669
 
@@ -806,16 +1672,43 @@ function fixIssues(checks, config) {
806
1672
  }
807
1673
  }
808
1674
 
1675
+ /**
1676
+ * Repair repo-vs-installed hook drift (#451).
1677
+ *
1678
+ * Only acts when the current project carries its own `githooks/` -- i.e. the
1679
+ * xp-gate repo (or a fork) rather than a consumer project, where githooks/ is
1680
+ * absent and the installed copy is the only source of truth.
1681
+ *
1682
+ * @returns {boolean} whether anything was synced
1683
+ */
1684
+ function fixHookDriftFromRepo() {
1685
+ const repoHooks = path.join(process.env.XP_GATE_REPO_ROOT || process.cwd(), 'githooks');
1686
+ if (!fs.existsSync(repoHooks)) return false;
1687
+
1688
+ const { synced, refused, errors } = syncGlobalHooksFromRepo();
1689
+ if (errors.length > 0) {
1690
+ for (const err of errors) console.log(` ✗ ${err}`);
1691
+ return false;
1692
+ }
1693
+ for (const r of refused) {
1694
+ console.log(` ⚠️ ${r.name} NOT synced — install has ${r.installedOnly} unique line(s); judge it, then use --sync-hooks --force, which overwrites with no backup, so keep a copy of the installed file first`);
1695
+ }
1696
+ if (synced.length === 0) return false;
1697
+
1698
+ console.log(` ✓ Synced ${synced.join(', ')} from githooks/ to ${GLOBAL_HOOKS_DIR}`);
1699
+ return true;
1700
+ }
1701
+
809
1702
  /**
810
1703
  * Sync hooks/adapters from the installed package to the project/global dir.
811
1704
  * Catches cases where xp-gate was upgraded (npm update) but hooks are stale.
812
- * Uses update-hooks logic with --force to overwrite outdated files.
1705
+ * Refreshes only the files a content judgement has called safe to overwrite.
813
1706
  *
814
1707
  * @returns {boolean} Whether any hooks were updated
815
1708
  */
816
1709
  function fixStaleHooks(config) {
817
1710
  try {
818
- const { updateHooks, detectLocalModifications, getPackageRoot } = require('./update-hooks.js');
1711
+ const { detectLocalModifications, getPackageRoot } = require('./update-hooks.js');
819
1712
  const isGlobal = config.mode === 'global';
820
1713
  const srcDir = getPackageRoot();
821
1714
 
@@ -835,14 +1728,80 @@ function fixStaleHooks(config) {
835
1728
  const modified = detectLocalModifications(srcDir, hooksDestDir, adaptersDestDir);
836
1729
  if (modified.length === 0) return false;
837
1730
 
838
- console.log(` Syncing ${modified.length} outdated hook/adapter file(s)...`);
839
- updateHooks({ global: isGlobal, force: true, dryRun: false, noBackup: true, scope: 'all' });
840
- return true;
1731
+ // Round 2 (architecture) caught the second overwrite standard inside this one
1732
+ // command: it handed every stale label to a forced updateHooks(), destroying
1733
+ // machine-only content the module path of the same release refuses to clobber.
1734
+ // `copyAdapters` writes flat AND nested, so both destinations are judged before
1735
+ // anything is written, and the refresh then copies file by file instead of
1736
+ // running a blanket force -- a backup-less blanket force deletes work, while the
1737
+ // backed-up alternative litters the user's work tree with .bak (#495 Round 3).
1738
+ const plan = planStaleHookSync(
1739
+ modified.flatMap((label) => staleSyncEntries(label, srcDir, hooksDestDir, adaptersDestDir)),
1740
+ );
1741
+
1742
+ if (plan.hasBlocking) {
1743
+ console.log(` ⚠️ ${plan.diverged.length + plan.unknown.length} hook/adapter file(s) NOT refreshed —`);
1744
+ console.log(' the installed copy carries content the package does not, and overwriting');
1745
+ console.log(' it would delete work that exists nowhere else:');
1746
+ for (const d of plan.diverged) {
1747
+ console.log(` - ${d.name}: install has ${d.installedOnly} unique line(s), package has ${d.repoOnly}`);
1748
+ }
1749
+ for (const u of plan.unknown) {
1750
+ console.log(` - ${u.name}: installed copy could not be read`);
1751
+ }
1752
+ console.log(' Judge them (fold the hardening into the package, or move the file aside),');
1753
+ console.log(' then re-run --fix.');
1754
+ console.log(' There is no automatic backup of an overwritten hook/adapter file and nothing');
1755
+ console.log(' in this tool can undo one — judge against a copy you kept, not against a rollback.');
1756
+ }
1757
+
1758
+ if (plan.safe.length === 0) return plan.hasBlocking;
1759
+
1760
+ console.log(` Refreshing ${plan.safe.length} outdated hook/adapter file(s)...`);
1761
+ let refreshed = 0;
1762
+ for (const entry of plan.safe) {
1763
+ if (!fs.existsSync(entry.source)) continue;
1764
+ try {
1765
+ fs.mkdirSync(path.dirname(entry.target), { recursive: true });
1766
+ fs.copyFileSync(entry.source, entry.target);
1767
+ fs.chmodSync(entry.target, 0o755);
1768
+ refreshed++;
1769
+ } catch (err) {
1770
+ console.log(` ✗ Could not refresh ${entry.name}: ${err.message}`);
1771
+ }
1772
+ }
1773
+ return refreshed > 0;
841
1774
  } catch {
842
1775
  return false;
843
1776
  }
844
1777
  }
845
1778
 
1779
+ /**
1780
+ * Where one `detectLocalModifications()` label lives on both sides.
1781
+ *
1782
+ * `adapters/<f>.sh` is listed once but written twice by `copyAdapters` (flat and
1783
+ * nested), so a machine edit to either copy has to block the refresh. The flat
1784
+ * copy is only judged where it already exists -- creating one here would plant
1785
+ * the very installer residue #500 is about (#495 Round 3).
1786
+ */
1787
+ function staleSyncEntries(label, srcDir, hooksDestDir, adaptersDestDir) {
1788
+ if (['pre-commit', 'pre-push', 'post-merge', 'sprint-gate.sh'].includes(label)) {
1789
+ const hooksSource = label === 'sprint-gate.sh'
1790
+ ? path.join(srcDir, label)
1791
+ : path.join(srcDir, 'hooks', label);
1792
+ return [{ name: label, source: hooksSource, target: path.join(hooksDestDir, label) }];
1793
+ }
1794
+ if (label.startsWith('adapters/')) {
1795
+ const file = label.slice('adapters/'.length);
1796
+ const source = path.join(srcDir, 'adapters', file);
1797
+ const entries = [{ name: label, source, target: path.join(adaptersDestDir, 'adapters', file) }];
1798
+ const flat = path.join(adaptersDestDir, file);
1799
+ if (fs.existsSync(flat)) entries.push({ name: `${label} (flat)`, source, target: flat });
1800
+ return entries;
1801
+ }
1802
+ return [{ name: label, source: path.join(srcDir, label), target: path.join(adaptersDestDir, label) }];
1803
+ }
1804
+
846
1805
  /**
847
1806
  * Auto-install missing CLI tools via bootstrap.
848
1807
  * @returns {boolean} Whether any tools were installed
@@ -1005,55 +1964,210 @@ function diagnoseLanguageTools() {
1005
1964
  }
1006
1965
  }
1007
1966
 
1008
- async function doctor(args) {
1967
+ // #488: an unrecognised flag used to be silently ignored — the npm release
1968
+ // that predates --sync-hooks made "doctor --sync-hooks" a silent no-op and the
1969
+ // user believed a sync had happened. Fail loudly instead.
1970
+ const KNOWN_FLAGS = ['--fix', '--sync-hooks', '--force', '--install-tools', '--json', '--format'];
1971
+
1972
+ /**
1973
+ * The accepted flag surface of `doctor`, as a pure function.
1974
+ *
1975
+ * The reason this is not inline in `doctor()`: this contract is about which
1976
+ * combinations are accepted, and answering that by running the real command
1977
+ * would exercise the repair surface (`--sync-hooks` copies repo files over the
1978
+ * installed ones). A contract that can only be tested by mutating the machine
1979
+ * is a contract nobody tests.
1980
+ *
1981
+ * @returns {{errors: string[], fixMode: boolean, syncHooks: boolean, force: boolean, installToolsFlag: boolean, jsonMode: boolean}}
1982
+ */
1983
+ function doctorFlagContract(args) {
1984
+ const errors = [];
1985
+ // `--format json` carries a value; without this the value itself would be
1986
+ // reported as an unknown flag and the documented machine-readable mode could
1987
+ // never be reached (#304 promised it, nothing ever implemented it).
1988
+ const formatValue = args[args.indexOf('--format') + 1] || null;
1989
+ const flagTokens = args.filter((a, i) => !(i > 0 && args[i - 1] === '--format'));
1990
+ const unknownFlags = flagTokens.filter(a => !KNOWN_FLAGS.includes(a));
1991
+ if (unknownFlags.length > 0) {
1992
+ errors.push(`Unknown flag(s) for doctor: ${unknownFlags.join(', ')}`);
1993
+ errors.push(`Supported flags: ${KNOWN_FLAGS.join(', ')}`);
1994
+ }
1009
1995
  const fixMode = args.includes('--fix');
1996
+ const syncHooks = args.includes('--sync-hooks');
1997
+ // #495: overwriting a module whose installed copy holds lines the repo lacks
1998
+ // destroys machine-only hardening, so it needs this explicit opt-in.
1999
+ const force = args.includes('--force');
2000
+ // Installing a toolchain changes the machine, not the repository, so it is
2001
+ // never a side effect of --fix (#502). --fix reports the missing tools.
2002
+ const installToolsFlag = args.includes('--install-tools');
2003
+ const jsonMode = args.includes('--json') || (args.includes('--format') && formatValue === 'json');
2004
+ if (args.includes('--format') && !['json', 'text'].includes(formatValue)) {
2005
+ errors.push(`Unknown --format value: ${formatValue || '(none)'} — expected json or text`);
2006
+ }
2007
+ if (jsonMode && (fixMode || syncHooks)) {
2008
+ // A JSON report is a read; mixing it with the repair surface would put
2009
+ // progress prose into the stream a script is parsing.
2010
+ errors.push('--json/--format json cannot be combined with --fix or --sync-hooks');
2011
+ }
2012
+ // Round 4 (feasibility) caught the original #488 defect in a new place:
2013
+ // `--force` is read only by the sync path, so `doctor --force` and
2014
+ // `doctor --fix --force` were accepted, printed nothing and changed nothing.
2015
+ if (force && !syncHooks) {
2016
+ errors.push('--force is only read by --sync-hooks, where it overrides the divergence refusal and overwrites without a backup');
2017
+ errors.push('Pass --sync-hooks --force, or drop --force');
2018
+ }
2019
+ // Same class: --install-tools installs only inside the --fix repair surface.
2020
+ if (installToolsFlag && !fixMode) {
2021
+ errors.push('--install-tools is only read by --fix, and it is the only flag that installs toolchains onto this machine');
2022
+ errors.push('Pass --fix --install-tools, or drop --install-tools');
2023
+ }
2024
+ return { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode };
2025
+ }
2026
+
2027
+ async function doctor(args) {
2028
+ const { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode } = doctorFlagContract(args);
2029
+ if (errors.length > 0) {
2030
+ for (const error of errors) console.error(error);
2031
+ return 1;
2032
+ }
2033
+
2034
+ if (!jsonMode) {
2035
+ console.log('XP-Gate Doctor');
2036
+ console.log('==============');
2037
+ }
1010
2038
 
1011
- console.log('XP-Gate Doctor');
1012
- console.log('==============');
2039
+ // §4.13 (#451 REQ-3): state which hook files actually execute, so "I edited
2040
+ // githooks/ but nothing changed" is answerable without reading git config.
2041
+ if (!jsonMode) printEffectiveHooks();
2042
+ // #495: the same for the gate modules the hook sources at runtime.
2043
+ if (!jsonMode) printEffectiveModules();
1013
2044
 
1014
2045
  const config = getConfig();
1015
2046
 
1016
2047
  // §4.8: mode === "uninstalled" → print "xp-gate is not installed"
1017
2048
  if (isUninstalledMode(config)) {
2049
+ if (jsonMode) {
2050
+ console.log(JSON.stringify(formatDoctorJson([{ name: 'Install', status: 'SKIP', detail: 'xp-gate is not installed' }], 0)));
2051
+ return 0;
2052
+ }
1018
2053
  console.log('xp-gate is not installed.');
1019
2054
  console.log('Run xp-gate init to install.');
1020
2055
  return 0;
1021
2056
  }
1022
2057
 
2058
+ // §4.13 (#451 REQ-2): explicit one-command sync from the repo's githooks/.
2059
+ if (syncHooks) {
2060
+ const { synced, skipped, refused, errors, installedHooks } = syncGlobalHooksFromRepo({ force });
2061
+ if (errors.length > 0) {
2062
+ for (const err of errors) console.log(` ✗ ${err}`);
2063
+ return 1;
2064
+ }
2065
+ if (synced.length > 0) {
2066
+ console.log(` ✓ Synced ${synced.join(', ')} from githooks/ to ${installedHooks}`);
2067
+ console.log(' The updated hooks take effect on the next commit.');
2068
+ } else {
2069
+ console.log(` ✓ Global hooks already match githooks/ (${skipped.join(', ') || 'nothing to sync'})`);
2070
+ }
2071
+ if (refused.length > 0) {
2072
+ console.log('');
2073
+ console.log(` ⚠️ ${refused.length} shared library file(s) NOT synced — the installed copy`);
2074
+ console.log(' carries content githooks/ does not, so overwriting would delete work:');
2075
+ for (const r of refused) {
2076
+ console.log(` - ${r.name}: install has ${r.installedOnly} unique line(s), githooks/ has ${r.repoOnly}`);
2077
+ }
2078
+ console.log(' Review them, then either fold the hardening into githooks/ or re-run --sync-hooks --force.');
2079
+ console.log(' Forcing overwrites the installed file with no backup: copy it aside first.');
2080
+ }
2081
+
2082
+ // #495: the gate modules are what the hook sources for Gates 3/4/7/8/9/10,
2083
+ // so syncing only the hook files left the executed surface untouched.
2084
+ const moduleSync = syncModulesFromRepo({ force });
2085
+ if (moduleSync.errors.length > 0) {
2086
+ for (const err of moduleSync.errors) console.log(` ✗ ${err}`);
2087
+ return 1;
2088
+ }
2089
+ if (moduleSync.synced.length > 0) {
2090
+ console.log(` ✓ Synced ${moduleSync.synced.length} gate module(s) to ${moduleSync.installedAdapters}`);
2091
+ console.log(` ${moduleSync.synced.join(', ')}`);
2092
+ }
2093
+ if (moduleSync.refused.length > 0) {
2094
+ console.log('');
2095
+ console.log(` ⚠️ ${moduleSync.refused.length} module(s) NOT synced — the installed copy carries`);
2096
+ console.log(' content the repo does not, so overwriting would delete work (#495):');
2097
+ for (const r of moduleSync.refused) {
2098
+ console.log(` - ${r.name}: install has ${r.installedOnly} unique line(s), githooks/ has ${r.repoOnly}`);
2099
+ }
2100
+ console.log(' Review them, then either fold the hardening into githooks/ or re-run --sync-hooks --force.');
2101
+ console.log(' Forcing overwrites the installed file with no backup: copy it aside first.');
2102
+ }
2103
+ }
2104
+
1023
2105
  // §4.13: --fix only when mode === "active"
1024
2106
  if (fixMode && isActiveMode(config)) {
1025
- fixIssues(null, config);
2107
+ fixIssues(null, config, installToolsFlag);
1026
2108
  }
1027
2109
 
1028
2110
  // Global timeout wrapper — ensures doctor completes within GLOBAL_DIAGNOSIS_TIMEOUT_MS
1029
2111
  // even if network/subprocess checks stack up (fix #348: 43s on Windows).
1030
2112
  const diagnosisPromise = diagnoseAsync();
2113
+ let diagnosisTimer = null;
1031
2114
  const timeoutPromise = new Promise((resolve) => {
1032
- 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);
2115
+ 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);
1033
2116
  });
1034
- const { checks, issues: diagnosedIssues } = await Promise.race([
2117
+ const { checks, issues: diagnosedIssues, timedOut } = await Promise.race([
1035
2118
  diagnosisPromise.then(r => ({ ...r, timedOut: false })),
1036
2119
  timeoutPromise,
1037
2120
  ]);
2121
+ clearTimeout(diagnosisTimer);
1038
2122
  let issues = diagnosedIssues;
1039
2123
 
1040
- printReport(checks);
2124
+ // A timeout discards every check that had not reported yet, including the
2125
+ // drift surface, so the raced result must never be allowed to read as clean
2126
+ // (#495 Round 2 -- this is the "stale modules, doctor green" outcome itself).
2127
+ if (timedOut) {
2128
+ issues = Math.max(issues, 1);
2129
+ const hookDrift = diagnoseHookDrift();
2130
+ const moduleDrift = diagnoseModuleDrift();
2131
+ checks.push(...hookDrift.checks, ...moduleDrift.checks);
2132
+ issues += hookDrift.issues + moduleDrift.issues;
2133
+ }
1041
2134
 
1042
- // Upgrade check with its own short timeout (3s) — non-blocking
1043
- try {
1044
- const upgradePromise = diagnoseUpgrade();
1045
- const upgradeTimeout = new Promise((resolve) => setTimeout(() => resolve(), 3000));
1046
- await Promise.race([upgradePromise, upgradeTimeout]);
1047
- } catch { /* non-blocking */ }
2135
+ let langIssues = 0;
2136
+ // Upgrade + plugin + language-tool status, kept as one callable because a
2137
+ // --format json run still has to execute all of it (#488).
2138
+ const collectTrailingChecks = async () => {
2139
+ try {
2140
+ const upgradePromise = diagnoseUpgrade();
2141
+ const upgradeTimeout = new Promise((resolve) => setTimeout(() => resolve(), 3000));
2142
+ await Promise.race([upgradePromise, upgradeTimeout]);
2143
+ } catch { /* non-blocking */ }
2144
+ const trailing = diagnoseOpenCodePlugin(checks);
2145
+ langIssues = diagnoseLanguageTools();
2146
+ return trailing + langIssues;
2147
+ };
2148
+
2149
+ if (jsonMode) {
2150
+ // Machine-readable output means EXACTLY one JSON document on stdout, so the
2151
+ // human report and the trailing checks' chatter are silenced while their
2152
+ // results are still collected into `checks`.
2153
+ const say = console.log;
2154
+ console.log = () => {};
2155
+ try {
2156
+ issues += await collectTrailingChecks();
2157
+ } finally {
2158
+ console.log = say;
2159
+ }
2160
+ console.log(JSON.stringify(formatDoctorJson(checks, issues)));
2161
+ return issues > 0 ? 1 : 0;
2162
+ }
1048
2163
 
1049
- issues += diagnoseOpenCodePlugin(checks);
2164
+ printReport(checks);
1050
2165
 
1051
- // Language-specific tool status
1052
- const langIssues = diagnoseLanguageTools();
1053
- issues += langIssues;
2166
+ issues += await collectTrailingChecks();
1054
2167
 
1055
- // In --fix mode, auto-install missing language tools
1056
- if (fixMode && langIssues > 0) {
2168
+ // In --fix mode, auto-install missing language tools — only on explicit
2169
+ // --install-tools (#502: a diagnosis command must not run package managers).
2170
+ if (fixMode && installToolsFlag && langIssues > 0) {
1057
2171
  try {
1058
2172
  const { detectProjectLanguages, getToolsForLanguages, installTool, generateProjectConfig, LANGUAGE_REGISTRY } = require('./language-tools.js');
1059
2173
  const projectRoot = process.cwd();
@@ -1080,6 +2194,11 @@ async function doctor(args) {
1080
2194
  } catch { /* non-blocking */ }
1081
2195
  }
1082
2196
 
2197
+ if (fixMode && !installToolsFlag && langIssues > 0) {
2198
+ console.log(`\n ${langIssues} language tool(s) are missing. --fix did not install them —`);
2199
+ console.log(' installing a toolchain changes the machine, so opt in with --install-tools (#502).');
2200
+ }
2201
+
1083
2202
  if (issues === 0) {
1084
2203
  console.log('\n✓ All checks passed');
1085
2204
  return 0;
@@ -1091,10 +2210,24 @@ async function doctor(args) {
1091
2210
  if (fixMode && isActiveMode(config)) {
1092
2211
  console.log('\nRe-running diagnosis after fix...');
1093
2212
  const postDiagPromise = diagnoseAsync();
2213
+ let postTimer = null;
1094
2214
  const postTimeout = new Promise((resolve) => {
1095
- setTimeout(() => resolve({ checks: [{ name: 'Post-fix diagnosis', status: 'WARN', detail: 'Timed out' }] }), GLOBAL_DIAGNOSIS_TIMEOUT_MS);
2215
+ postTimer = setTimeout(() => resolve({
2216
+ checks: [{ name: 'Post-fix diagnosis', status: 'WARN', detail: `Timed out after ${GLOBAL_DIAGNOSIS_TIMEOUT_MS / 1000}s — the fix result is unconfirmed, not clean` }],
2217
+ issues: 1,
2218
+ timedOut: true,
2219
+ }), GLOBAL_DIAGNOSIS_TIMEOUT_MS);
1096
2220
  });
1097
- const { checks: postChecks } = await Promise.race([postDiagPromise, postTimeout]);
2221
+ const { checks: postChecks, timedOut: postTimedOut } = await Promise.race([
2222
+ postDiagPromise.then(r => ({ ...r, timedOut: false })),
2223
+ postTimeout,
2224
+ ]);
2225
+ clearTimeout(postTimer);
2226
+ // Symmetry with the pre-fix race above: a timeout here discards the drift
2227
+ // surface too, so the post-fix report must not read as "all clear".
2228
+ if (postTimedOut) {
2229
+ postChecks.push(...diagnoseHookDrift().checks, ...diagnoseModuleDrift().checks);
2230
+ }
1098
2231
  printReport(postChecks);
1099
2232
  }
1100
2233
 
@@ -1125,4 +2258,18 @@ module.exports = {
1125
2258
  ensureTuiRegistration,
1126
2259
  readTuiJson,
1127
2260
  formatDoctorJson,
2261
+ diagnoseHookDrift,
2262
+ syncGlobalHooksFromRepo,
2263
+ diagnoseModuleDrift,
2264
+ syncModulesFromRepo,
2265
+ resolveExecutedModuleDirs,
2266
+ executedModulePath,
2267
+ repoModuleNames,
2268
+ copyGuardDecision,
2269
+ planStaleHookSync,
2270
+ doctorFlagContract,
2271
+ listExecutedModules,
2272
+ repoModuleSource,
2273
+ contentDelta,
2274
+ pathsEquivalent,
1128
2275
  };