@boyingliu01/xp-gate 0.31.1 → 0.31.2

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 (85) hide show
  1. package/adapter-common.sh +41 -3
  2. package/adapters/gate-3.sh +8 -1
  3. package/adapters/gate-8.sh +67 -6
  4. package/adapters/go.sh +18 -1
  5. package/adapters/iac.sh +18 -1
  6. package/adapters/powershell.sh +37 -1
  7. package/adapters/python.sh +18 -4
  8. package/gate-3.sh +10 -1
  9. package/gate-8.sh +67 -6
  10. package/hooks/adapter-common.sh +41 -3
  11. package/hooks/gate-3.sh +10 -1
  12. package/hooks/gate-8.sh +67 -6
  13. package/hooks/lib/coverage-total.sh +20 -5
  14. package/hooks/lib/gate-5c-alignment.sh +145 -0
  15. package/hooks/lib/jscpd-run.sh +40 -1
  16. package/hooks/lib/native-path.sh +106 -0
  17. package/hooks/lib/prepush-score.py +143 -0
  18. package/hooks/lib/sprint-gate-report.sh +23 -0
  19. package/hooks/lib/toolchain-path.sh +370 -0
  20. package/hooks/lib/validate-code-walkthrough.cjs +224 -23
  21. package/hooks/pre-commit +371 -163
  22. package/hooks/pre-push +192 -74
  23. package/hooks/sprint-gate.sh +14 -3
  24. package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
  25. package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
  26. package/lib/__tests__/doctor-delphi.test.js +46 -0
  27. package/lib/__tests__/doctor-exec-retry.test.js +20 -10
  28. package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
  30. package/lib/__tests__/doctor.test.js +7 -1
  31. package/lib/__tests__/hook-health-path.test.js +9 -1
  32. package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
  33. package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
  34. package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
  35. package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
  36. package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
  37. package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
  38. package/lib/arch.js +261 -44
  39. package/lib/check-alignment-runner.cjs +23 -0
  40. package/lib/check.js +4 -1
  41. package/lib/doctor.js +194 -49
  42. package/lib/gate-runner.js +265 -58
  43. package/lib/mattpocock-license-verdict.json +1 -1
  44. package/lib/principles.js +228 -6
  45. package/lib/shared-paths.js +53 -19
  46. package/mock-policy/AGENTS.md +3 -3
  47. package/mutation/AGENTS.md +3 -3
  48. package/mutation/__tests__/gremlins-runner.test.ts +3 -3
  49. package/package.json +1 -1
  50. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  51. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  52. package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
  53. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  54. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  55. package/plugins/dsh/package.json +1 -1
  56. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  57. package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
  58. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  59. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  60. package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
  61. package/plugins/dsh/src/delphi-config.ts +151 -9
  62. package/plugins/opencode/package.json +1 -1
  63. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  64. package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
  65. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  66. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  67. package/plugins/qoder/plugin.json +1 -1
  68. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  69. package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
  70. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  71. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  72. package/principles/AGENTS.md +3 -3
  73. package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
  74. package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
  75. package/principles/adapters/java.ts +127 -11
  76. package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
  77. package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
  78. package/principles/rules/solid/lsp.ts +348 -23
  79. package/scripts/__tests__/sync-package-content.test.js +32 -1
  80. package/scripts/sync-package-content.js +6 -1
  81. package/skills/delphi-review/AGENTS.md +3 -3
  82. package/skills/delphi-review/SKILL.md +50 -0
  83. package/skills/sprint-flow/AGENTS.md +3 -3
  84. package/skills/test-specification-alignment/AGENTS.md +3 -3
  85. package/sprint-gate.sh +14 -3
package/lib/doctor.js CHANGED
@@ -4,15 +4,22 @@ const { execSync, execFileSync, spawnSync } = require('child_process');
4
4
  const { exec } = require('child_process');
5
5
  const util = require('util');
6
6
  const execAsync = util.promisify(exec);
7
- const {
8
- HOME_DIR,
9
- CONFIG_DIR,
10
- CONFIG_FILE,
11
- GLOBAL_HOOKS_DIR,
12
- GLOBAL_ADAPTERS_DIR,
13
- detectPlatform,
14
- getTemplateDir,
15
- } = require('./shared-paths.js');
7
+ const sharedPaths = require('./shared-paths.js');
8
+ const { detectPlatform, getTemplateDir } = sharedPaths;
9
+
10
+ // #599: the filesystem roots below are DERIVED FROM process.env.HOME, so they
11
+ // must not be captured at require() time. doctor.js is routinely loaded by a
12
+ // test run BEFORE that run sets HOME -- doctor.test.js loads detect-deps.js
13
+ // (which loads shared-paths.js) in beforeAll, before any test assigns HOME --
14
+ // and a require-time capture froze CONFIG_FILE to the REAL home directory. That
15
+ // made `doctor` report `Config file: Not found` for fixtures that had in fact
16
+ // written the config, and `--fix` returned 1 on a healthy installation.
17
+ //
18
+ // These are therefore read through the module at each use, not destructured.
19
+ const HOME_DIR = () => sharedPaths.HOME_DIR;
20
+ const CONFIG_FILE = () => sharedPaths.CONFIG_FILE;
21
+ const GLOBAL_HOOKS_DIR = () => sharedPaths.GLOBAL_HOOKS_DIR;
22
+ const GLOBAL_ADAPTERS_DIR = () => sharedPaths.GLOBAL_ADAPTERS_DIR;
16
23
  const { checkUpgrade, formatUpgradeMsg } = require('./check-version.js');
17
24
  const { GATE_CLI_TOOLS, checkCliTool, getToolInstallCmd, checkBash } = require('./detect-deps.js');
18
25
  // #505: the single source of truth for module resolution order. The hook reads a
@@ -214,16 +221,16 @@ function isXpGateFile(filePath, signature) {
214
221
  }
215
222
 
216
223
  function getConfig() {
217
- if (!fs.existsSync(CONFIG_FILE)) return null;
224
+ if (!fs.existsSync(CONFIG_FILE())) return null;
218
225
  try {
219
- return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
226
+ return JSON.parse(fs.readFileSync(CONFIG_FILE(), 'utf8'));
220
227
  } catch {
221
228
  return 'corrupt';
222
229
  }
223
230
  }
224
231
 
225
232
  function saveConfig(config) {
226
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
233
+ fs.writeFileSync(CONFIG_FILE(), JSON.stringify(config, null, 2));
227
234
  }
228
235
 
229
236
  async function getGitDir() {
@@ -339,12 +346,12 @@ function checkConfig() {
339
346
  }
340
347
  if (config.mode !== 'local' && config.mode !== 'global') {
341
348
  return { config: null, checks: [
342
- { name: 'Config file', status: 'PASS', detail: CONFIG_FILE },
349
+ { name: 'Config file', status: 'PASS', detail: CONFIG_FILE() },
343
350
  { name: 'Install mode', status: 'FAIL', detail: `Unknown: ${config.mode}` }
344
351
  ], issues: 1 };
345
352
  }
346
353
  return { config, checks: [
347
- { name: 'Config file', status: 'PASS', detail: CONFIG_FILE },
354
+ { name: 'Config file', status: 'PASS', detail: CONFIG_FILE() },
348
355
  { name: 'Install mode', status: 'PASS', detail: config.mode }
349
356
  ], issues: 0 };
350
357
  }
@@ -365,19 +372,19 @@ async function checkLocalHooks(checks) {
365
372
 
366
373
  async function checkGlobalHooks(checks) {
367
374
  let issues = 0;
368
- issues += checkSingleHook(GLOBAL_HOOKS_DIR, 'pre-commit', SIGNATURES['pre-commit'], 'Global hooks', checks);
369
- issues += checkSingleHook(GLOBAL_HOOKS_DIR, 'pre-push', SIGNATURES['pre-push'], 'Global hooks', checks);
370
- issues += checkSingleHook(GLOBAL_HOOKS_DIR, 'post-merge', SIGNATURES['post-merge'], 'Global hooks', checks);
375
+ issues += checkSingleHook(GLOBAL_HOOKS_DIR(), 'pre-commit', SIGNATURES['pre-commit'], 'Global hooks', checks);
376
+ issues += checkSingleHook(GLOBAL_HOOKS_DIR(), 'pre-push', SIGNATURES['pre-push'], 'Global hooks', checks);
377
+ issues += checkSingleHook(GLOBAL_HOOKS_DIR(), 'post-merge', SIGNATURES['post-merge'], 'Global hooks', checks);
371
378
 
372
379
  const hooksPath = await getCurrentHooksPath();
373
380
  if (hooksPath === null || hooksPath === '') {
374
381
  checks.push({ name: 'Git core.hooksPath', status: 'FAIL', detail: 'Not set' });
375
382
  issues++;
376
- } else if (!pathsEquivalent(hooksPath, GLOBAL_HOOKS_DIR)) {
377
- checks.push({ name: 'Git core.hooksPath', status: 'FAIL', detail: `Expected ${GLOBAL_HOOKS_DIR}, got ${hooksPath}` });
383
+ } else if (!pathsEquivalent(hooksPath, GLOBAL_HOOKS_DIR())) {
384
+ checks.push({ name: 'Git core.hooksPath', status: 'FAIL', detail: `Expected ${GLOBAL_HOOKS_DIR()}, got ${hooksPath}` });
378
385
  issues++;
379
386
  } else {
380
- checks.push({ name: 'Git core.hooksPath', status: 'PASS', detail: GLOBAL_HOOKS_DIR });
387
+ checks.push({ name: 'Git core.hooksPath', status: 'PASS', detail: GLOBAL_HOOKS_DIR() });
381
388
  }
382
389
  return issues;
383
390
  }
@@ -443,7 +450,7 @@ function diagnoseHookDrift(ctx = {}) {
443
450
  // explicitly instead of inferring it from the process cwd.
444
451
  const repoRoot = ctx.repoRoot || process.env.XP_GATE_REPO_ROOT || process.cwd();
445
452
  const repoHooks = ctx.repoHooks || path.join(repoRoot, 'githooks');
446
- const installedHooks = ctx.installedHooks || GLOBAL_HOOKS_DIR;
453
+ const installedHooks = ctx.installedHooks || GLOBAL_HOOKS_DIR();
447
454
 
448
455
  // Nothing to compare when the project carries no canonical *hook* source, or
449
456
  // when no global copy exists at all. Both must stay completely silent.
@@ -817,7 +824,7 @@ function syncModulesFromRepo(ctx = {}) {
817
824
 
818
825
  if (!hasCanonicalHooks(repoHooks)) {
819
826
  errors.push(`no canonical githooks/ source at ${repoHooks} -- nothing was synced`);
820
- return { synced, skipped, refused, errors, installedAdapters: ctx.installedAdapters || ctx.globalAdapterDir || GLOBAL_ADAPTERS_DIR, sourceMissing: true };
827
+ return { synced, skipped, refused, errors, installedAdapters: ctx.installedAdapters || ctx.globalAdapterDir || GLOBAL_ADAPTERS_DIR(), sourceMissing: true };
821
828
  }
822
829
 
823
830
  // Same resolution the diagnosis used, so a FAIL the diagnosis can produce is a
@@ -884,9 +891,9 @@ function syncModulesFromRepo(ctx = {}) {
884
891
  * @returns {{adapterDir: string, gateDir: string, globalAdapterDir: string, projectGithooks: string, scriptDir: string}}
885
892
  */
886
893
  function resolveExecutedModuleDirs(ctx = {}) {
887
- const globalAdapterDir = ctx.globalAdapterDir || ctx.installedAdapters || GLOBAL_ADAPTERS_DIR;
894
+ const globalAdapterDir = ctx.globalAdapterDir || ctx.installedAdapters || GLOBAL_ADAPTERS_DIR();
888
895
  const projectGithooks = ctx.projectGithooks || defaultProjectGithooks();
889
- const scriptDir = ctx.scriptDir || GLOBAL_HOOKS_DIR;
896
+ const scriptDir = ctx.scriptDir || GLOBAL_HOOKS_DIR();
890
897
 
891
898
  const has = (dir, file) => fs.existsSync(path.join(dir, file));
892
899
 
@@ -1115,6 +1122,94 @@ function printEffectiveHooks() {
1115
1122
  console.log(` source: ${source}`);
1116
1123
  }
1117
1124
 
1125
+ /**
1126
+ * Keep a copy of an installed file before `--sync-hooks` overwrites it (#566).
1127
+ *
1128
+ * #566 was a hook that existed on exactly one machine and in no commit: a local
1129
+ * edit to the installed `pre-push` that `--sync-hooks` replaced silently. The
1130
+ * repo side then looked correct and the work was gone -- unrecoverable, because
1131
+ * nothing had recorded that the installed copy held anything more.
1132
+ *
1133
+ * The refusal path already protects files whose content diverges. This covers the
1134
+ * remaining cases, where the overwrite is legitimate but still destroys the
1135
+ * previous version: `--force`, and files that differ only in whitespace.
1136
+ *
1137
+ * Copies land in a sibling `backups/` directory with a timestamp, so repeated
1138
+ * syncs never overwrite an older backup. The target's own directory is used
1139
+ * rather than the user's work tree, so this never litters a repository.
1140
+ *
1141
+ * @param {string} target existing file about to be overwritten
1142
+ * @param {string} backupsDir directory to place the copy in
1143
+ * @returns {string|null} path of the backup, or null when there was nothing to back up
1144
+ */
1145
+ function backupBeforeOverwrite(target, backupsDir) {
1146
+ if (!fs.existsSync(target)) return null;
1147
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1148
+ try {
1149
+ fs.mkdirSync(backupsDir, { recursive: true });
1150
+ // The stamp has millisecond resolution, so two syncs inside the same
1151
+ // millisecond produce the same name and the second copy destroys the first —
1152
+ // the backup is worth nothing exactly when it is needed most (a rapid
1153
+ // "edit, sync, edit, sync" repair loop). Disambiguate instead of overwriting:
1154
+ // the first backup of a burst must survive the rest.
1155
+ let backup = path.join(backupsDir, `${path.basename(target)}.${stamp}.bak`);
1156
+ for (let n = 1; fs.existsSync(backup); n += 1) {
1157
+ backup = path.join(backupsDir, `${path.basename(target)}.${stamp}-${n}.bak`);
1158
+ }
1159
+ fs.copyFileSync(target, backup);
1160
+ return backup;
1161
+ } catch {
1162
+ // A backup that cannot be written must not abort the sync; the caller
1163
+ // reports the file it overwrote either way.
1164
+ return null;
1165
+ }
1166
+ }
1167
+
1168
+ /**
1169
+ * Read a VERSION file, returning '' when it is missing or empty.
1170
+ *
1171
+ * Kept local and silent: the deploy log's version field must never be the reason
1172
+ * a sync fails, and "unknown" is a truthful value.
1173
+ *
1174
+ * @param {string} versionPath
1175
+ * @returns {string}
1176
+ */
1177
+ function readVersionFile(versionPath) {
1178
+ try {
1179
+ return fs.readFileSync(versionPath, 'utf8').trim();
1180
+ } catch {
1181
+ return '';
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * Append one line to the deploy log beside the installed hooks (#583).
1187
+ *
1188
+ * The backup (#566) makes a damaged file recoverable; it does not answer the
1189
+ * question a downstream actually has — "was my patch eaten, and by which
1190
+ * version?". A directory of timestamped `.bak` files is readable only by someone
1191
+ * who already suspects an overwrite. #583 records three silent overwrites in one
1192
+ * day, one of which reverted a fail-open fix that had already been filed, fixed
1193
+ * and tested.
1194
+ *
1195
+ * One line per overwritten file, appended — never rewritten, because "three times
1196
+ * today" is exactly what the log exists to show.
1197
+ *
1198
+ * @param {string} logPath deploy log path
1199
+ * @param {{file: string, version: string, backup: string|null}} entry
1200
+ */
1201
+ function appendDeployLogEntry(logPath, entry) {
1202
+ const stamp = new Date().toISOString();
1203
+ const backup = entry.backup ? path.basename(entry.backup) : '(no previous content)';
1204
+ const line = `${stamp} overwrote ${entry.file} from xp-gate ${entry.version} (previous saved as ${backup})\n`;
1205
+ try {
1206
+ fs.appendFileSync(logPath, line);
1207
+ } catch {
1208
+ // Same rule as the backup itself: a log that cannot be written must not
1209
+ // abort the sync. The overwrite still happens and still reports its name.
1210
+ }
1211
+ }
1212
+
1118
1213
  /**
1119
1214
  * Copy the repository's `githooks/` over the installed global hooks (#451 REQ-2).
1120
1215
  *
@@ -1123,33 +1218,45 @@ function printEffectiveHooks() {
1123
1218
  * directory are left alone.
1124
1219
  *
1125
1220
  * @param {{repoRoot?: string, repoHooks?: string, installedHooks?: string}} [ctx]
1126
- * @returns {{synced: string[], skipped: string[], refused: Array<{name: string, repoOnly: number, installedOnly: number}>, errors: string[], installedHooks: string}}
1221
+ * Every overwrite is preceded by a backup (#566): the previous version is copied
1222
+ * to `backups/` beside the installed hooks before it is replaced.
1223
+ *
1224
+ * @returns {{synced: string[], skipped: string[], refused: Array<{name: string, repoOnly: number, installedOnly: number}>, errors: string[], installedHooks: string, backups: string[]}}
1127
1225
  */
1128
1226
  function syncGlobalHooksFromRepo(ctx = {}) {
1129
1227
  const repoRoot = ctx.repoRoot || process.env.XP_GATE_REPO_ROOT || process.cwd();
1130
1228
  const repoHooks = ctx.repoHooks || path.join(repoRoot, 'githooks');
1131
- const installedHooks = ctx.installedHooks || GLOBAL_HOOKS_DIR;
1229
+ const installedHooks = ctx.installedHooks || GLOBAL_HOOKS_DIR();
1230
+ // #583: the version that performs the overwrite is part of the record. Read the
1231
+ // repo's VERSION beside the hooks, so the log answers "which release did this"
1232
+ // without the caller having to remember to pass it.
1233
+ const version = ctx.version
1234
+ || readVersionFile(path.join(repoRoot, 'VERSION'))
1235
+ || 'unknown';
1132
1236
 
1133
1237
  const synced = [];
1134
1238
  const skipped = [];
1135
1239
  const refused = [];
1136
1240
  const errors = [];
1241
+ const backups = [];
1242
+ const backupsDir = path.join(installedHooks, 'backups');
1243
+ const deployLog = path.join(installedHooks, 'deploy.log');
1137
1244
 
1138
1245
  if (!fs.existsSync(repoHooks)) {
1139
1246
  errors.push(`no githooks/ directory at ${repoHooks}`);
1140
- return { synced, skipped, refused, errors, installedHooks };
1247
+ return { synced, skipped, refused, errors, installedHooks, backups };
1141
1248
  }
1142
1249
 
1143
1250
  if (!hasCanonicalHooks(repoHooks)) {
1144
1251
  errors.push(`no hook files in ${repoHooks} (only adapters?) -- nothing to sync from`);
1145
- return { synced, skipped, refused, errors, installedHooks };
1252
+ return { synced, skipped, refused, errors, installedHooks, backups };
1146
1253
  }
1147
1254
 
1148
1255
  try {
1149
1256
  fs.mkdirSync(installedHooks, { recursive: true });
1150
1257
  } catch (err) {
1151
1258
  errors.push(`cannot create ${installedHooks}: ${err.message}`);
1152
- return { synced, skipped, refused, errors, installedHooks };
1259
+ return { synced, skipped, refused, errors, installedHooks, backups };
1153
1260
  }
1154
1261
 
1155
1262
  for (const name of DRIFT_COMPARED_HOOKS) {
@@ -1164,10 +1271,17 @@ function syncGlobalHooksFromRepo(ctx = {}) {
1164
1271
  skipped.push(name);
1165
1272
  continue;
1166
1273
  }
1274
+ // #566: the previous version is kept, because this overwrite is the moment
1275
+ // machine-only work is lost.
1276
+ const backup = backupBeforeOverwrite(target, backupsDir);
1277
+ if (backup) backups.push(backup);
1167
1278
  // copyFileSync preserves content; the exec bit is re-applied explicitly
1168
1279
  // because a copied hook that is not executable silently never runs.
1169
1280
  fs.copyFileSync(source, target);
1170
1281
  fs.chmodSync(target, 0o755);
1282
+ // #583: record it, so a downstream can tell an overwrite from a fresh
1283
+ // install without having to diff checksums first.
1284
+ appendDeployLogEntry(deployLog, { file: name, version, backup });
1171
1285
  synced.push(name);
1172
1286
  } catch (err) {
1173
1287
  errors.push(`${name}: ${err.message}`);
@@ -1187,7 +1301,10 @@ function syncGlobalHooksFromRepo(ctx = {}) {
1187
1301
  skipped.push(name);
1188
1302
  continue;
1189
1303
  }
1304
+ const backup = backupBeforeOverwrite(target, backupsDir);
1305
+ if (backup) backups.push(backup);
1190
1306
  fs.copyFileSync(source, target);
1307
+ appendDeployLogEntry(deployLog, { file: name, version, backup });
1191
1308
  synced.push(name);
1192
1309
  } catch (err) {
1193
1310
  errors.push(`${name}: ${err.message}`);
@@ -1203,14 +1320,14 @@ function syncGlobalHooksFromRepo(ctx = {}) {
1203
1320
  fs.mkdirSync(installedLib, { recursive: true });
1204
1321
  } catch (err) {
1205
1322
  errors.push(`cannot create ${installedLib}: ${err.message}`);
1206
- return { synced, skipped, refused, errors, installedHooks };
1323
+ return { synced, skipped, refused, errors, installedHooks, backups };
1207
1324
  }
1208
1325
  let libEntries = [];
1209
1326
  try {
1210
1327
  libEntries = fs.readdirSync(repoLib, { withFileTypes: true });
1211
1328
  } catch (err) {
1212
1329
  errors.push(`cannot read ${repoLib}: ${err.message}`);
1213
- return { synced, skipped, refused, errors, installedHooks };
1330
+ return { synced, skipped, refused, errors, installedHooks, backups };
1214
1331
  }
1215
1332
  for (const entry of libEntries) {
1216
1333
  if (!entry.isFile()) continue;
@@ -1243,8 +1360,11 @@ function syncGlobalHooksFromRepo(ctx = {}) {
1243
1360
  continue;
1244
1361
  }
1245
1362
  }
1363
+ const backup = backupBeforeOverwrite(target, backupsDir);
1364
+ if (backup) backups.push(backup);
1246
1365
  fs.copyFileSync(source, target);
1247
1366
  fs.chmodSync(target, 0o755);
1367
+ appendDeployLogEntry(deployLog, { file: rel, version, backup });
1248
1368
  synced.push(rel);
1249
1369
  } catch (err) {
1250
1370
  errors.push(`${rel}: ${err.message}`);
@@ -1252,7 +1372,7 @@ function syncGlobalHooksFromRepo(ctx = {}) {
1252
1372
  }
1253
1373
  }
1254
1374
 
1255
- return { synced, skipped, refused, errors, installedHooks };
1375
+ return { synced, skipped, refused, errors, installedHooks, backups };
1256
1376
  }
1257
1377
 
1258
1378
  /**
@@ -1322,7 +1442,7 @@ async function checkAdapters(checks, mode, gitDirPromise) {
1322
1442
  let issues = 0;
1323
1443
  const adaptersDir = mode === 'local'
1324
1444
  ? path.join(path.dirname(gitDir || ''), 'githooks', 'adapters')
1325
- : GLOBAL_ADAPTERS_DIR;
1445
+ : GLOBAL_ADAPTERS_DIR();
1326
1446
 
1327
1447
  if (!adaptersDir || !fs.existsSync(adaptersDir)) {
1328
1448
  checks.push({ name: 'Adapters directory', status: 'FAIL', detail: 'Missing' });
@@ -2122,7 +2242,7 @@ function fixHooksByMode(config, srcDir) {
2122
2242
  fixed = fixMissingHooks('local', srcDir, hooksDir) || fixed;
2123
2243
  }
2124
2244
  } else {
2125
- fixed = fixMissingHooks('global', srcDir, GLOBAL_HOOKS_DIR) || fixed;
2245
+ fixed = fixMissingHooks('global', srcDir, GLOBAL_HOOKS_DIR()) || fixed;
2126
2246
  }
2127
2247
  return fixed;
2128
2248
  }
@@ -2130,8 +2250,8 @@ function fixHooksByMode(config, srcDir) {
2130
2250
  function fixGlobalHooksPath(config) {
2131
2251
  if (config.mode !== 'global') return false;
2132
2252
  const hooksPath = getCurrentHooksPathSync();
2133
- if (!pathsEquivalent(hooksPath, GLOBAL_HOOKS_DIR)) {
2134
- return fixCoreHooksPath(GLOBAL_HOOKS_DIR);
2253
+ if (!pathsEquivalent(hooksPath, GLOBAL_HOOKS_DIR())) {
2254
+ return fixCoreHooksPath(GLOBAL_HOOKS_DIR());
2135
2255
  }
2136
2256
  return false;
2137
2257
  }
@@ -2139,7 +2259,7 @@ function fixGlobalHooksPath(config) {
2139
2259
  function getAdaptersDirByMode(config) {
2140
2260
  return config.mode === 'local'
2141
2261
  ? path.join(path.dirname(getGitDirSync() || ''), 'githooks', 'adapters')
2142
- : GLOBAL_ADAPTERS_DIR;
2262
+ : GLOBAL_ADAPTERS_DIR();
2143
2263
  }
2144
2264
 
2145
2265
  /**
@@ -2200,7 +2320,7 @@ function fixHookDriftFromRepo() {
2200
2320
  }
2201
2321
  if (synced.length === 0) return false;
2202
2322
 
2203
- console.log(` ✓ Synced ${synced.join(', ')} from githooks/ to ${GLOBAL_HOOKS_DIR}`);
2323
+ console.log(` ✓ Synced ${synced.join(', ')} from githooks/ to ${GLOBAL_HOOKS_DIR()}`);
2204
2324
  return true;
2205
2325
  }
2206
2326
 
@@ -2219,8 +2339,8 @@ function fixStaleHooks(config) {
2219
2339
 
2220
2340
  let hooksDestDir, adaptersDestDir;
2221
2341
  if (isGlobal) {
2222
- hooksDestDir = GLOBAL_HOOKS_DIR;
2223
- adaptersDestDir = GLOBAL_ADAPTERS_DIR;
2342
+ hooksDestDir = GLOBAL_HOOKS_DIR();
2343
+ adaptersDestDir = GLOBAL_ADAPTERS_DIR();
2224
2344
  } else {
2225
2345
  const gitDir = getGitDirSync();
2226
2346
  if (!gitDir) return false;
@@ -2397,7 +2517,7 @@ async function diagnoseUpgrade() {
2397
2517
  * @returns {number} issue count
2398
2518
  */
2399
2519
  function diagnoseOpenCodePlugin(checks) {
2400
- const pluginPath = path.join(HOME_DIR, '.config', 'opencode', 'node_modules', '@boyingliu01', 'opencode-plugin', 'package.json');
2520
+ const pluginPath = path.join(HOME_DIR(), '.config', 'opencode', 'node_modules', '@boyingliu01', 'opencode-plugin', 'package.json');
2401
2521
  if (fs.existsSync(pluginPath)) {
2402
2522
  try {
2403
2523
  const pluginPkg = JSON.parse(fs.readFileSync(pluginPath, 'utf8'));
@@ -2566,7 +2686,7 @@ async function doctor(args) {
2566
2686
 
2567
2687
  // §4.13 (#451 REQ-2): explicit one-command sync from the repo's githooks/.
2568
2688
  if (syncHooks) {
2569
- const { synced, skipped, refused, errors, installedHooks } = syncGlobalHooksFromRepo({ force });
2689
+ const { synced, skipped, refused, errors, installedHooks, backups } = syncGlobalHooksFromRepo({ force });
2570
2690
  if (errors.length > 0) {
2571
2691
  for (const err of errors) console.log(` ✗ ${err}`);
2572
2692
  return 1;
@@ -2577,6 +2697,11 @@ async function doctor(args) {
2577
2697
  } else {
2578
2698
  console.log(` ✓ Global hooks already match githooks/ (${skipped.join(', ') || 'nothing to sync'})`);
2579
2699
  }
2700
+ // #566: replacing an installed hook discards whatever was in it. Say where
2701
+ // the previous version went, so a machine-only fix stays recoverable.
2702
+ if (backups.length > 0) {
2703
+ console.log(` Previous version(s) saved under ${path.join(installedHooks, 'backups')}/ (${backups.length} file(s)).`);
2704
+ }
2580
2705
  if (refused.length > 0) {
2581
2706
  console.log('');
2582
2707
  console.log(` ⚠️ ${refused.length} shared library file(s) NOT synced — the installed copy`);
@@ -2585,7 +2710,7 @@ async function doctor(args) {
2585
2710
  console.log(` - ${r.name}: install has ${r.installedOnly} unique line(s), githooks/ has ${r.repoOnly}`);
2586
2711
  }
2587
2712
  console.log(' Review them, then either fold the hardening into githooks/ or re-run --sync-hooks --force.');
2588
- console.log(' Forcing overwrites the installed file with no backup: copy it aside first.');
2713
+ console.log(' Forcing overwrites the installed file, whose previous version is saved under backups/.');
2589
2714
  }
2590
2715
 
2591
2716
  // #495: the gate modules are what the hook sources for Gates 3/4/7/8/9/10,
@@ -2607,7 +2732,7 @@ async function doctor(args) {
2607
2732
  console.log(` - ${r.name}: install has ${r.installedOnly} unique line(s), githooks/ has ${r.repoOnly}`);
2608
2733
  }
2609
2734
  console.log(' Review them, then either fold the hardening into githooks/ or re-run --sync-hooks --force.');
2610
- console.log(' Forcing overwrites the installed file with no backup: copy it aside first.');
2735
+ console.log(' Forcing overwrites the installed file, whose previous version is saved under backups/.');
2611
2736
  }
2612
2737
  }
2613
2738
 
@@ -2785,7 +2910,7 @@ async function doctorDelphi(options = {}) {
2785
2910
  // exists there, so the doctor agrees with the repair command (#414 R1 FC-09).
2786
2911
  const { readQoderModelId } = require('./init.js');
2787
2912
  const roles = ['architecture', 'technical', 'feasibility'];
2788
- const homeAgentsDir = path.join(HOME_DIR, '.qoder', 'agents');
2913
+ const homeAgentsDir = path.join(HOME_DIR(), '.qoder', 'agents');
2789
2914
  // Injectable for tests; defaults to the cwd the doctor runs from.
2790
2915
  const projectAgentsDir = path.join(options.projectDir || process.cwd(), '.qoder', 'agents');
2791
2916
  const homeComplete = roles.every((r) => fs.existsSync(path.join(homeAgentsDir, `delphi-${r}.md`)));
@@ -2841,10 +2966,10 @@ async function doctorDelphi(options = {}) {
2841
2966
  // references/ too, and B4-style drift hits those equally (#414 R1 MI-03).
2842
2967
  const platform = detectPlatform();
2843
2968
  const installedSkillDir = platform === 'qoder'
2844
- ? path.join(HOME_DIR, '.qoder', 'skills', 'delphi-review')
2969
+ ? path.join(HOME_DIR(), '.qoder', 'skills', 'delphi-review')
2845
2970
  : platform === 'claude-code'
2846
- ? path.join(HOME_DIR, '.claude', 'skills', 'delphi-review')
2847
- : path.join(HOME_DIR, '.config', 'opencode', 'skills', 'delphi-review');
2971
+ ? path.join(HOME_DIR(), '.claude', 'skills', 'delphi-review')
2972
+ : path.join(HOME_DIR(), '.config', 'opencode', 'skills', 'delphi-review');
2848
2973
  // Injectable for tests (#414 R3 FC-01/FC-02): the empty-bundle branch is a
2849
2974
  // real failure mode and must be drivable without mutating the package.
2850
2975
  const bundledSkillDir = options.bundledSkillDir || path.join(PKG_DIR, 'skills', 'delphi-review');
@@ -2941,6 +3066,26 @@ async function doctorDelphi(options = {}) {
2941
3066
  } else {
2942
3067
  add('Delphi config (.delphi-config.json)', 'PASS',
2943
3068
  `profile "${profileName}": ${models.join(' / ')}`);
3069
+ // #574: non-local seats are reported, not blocked. The local-only rule
3070
+ // is an operator preference (the 2026-10-03 quota burn), so doctor
3071
+ // surfaces the cost exposure without failing a deliberately hosted
3072
+ // seat. The HARD enforcement lives in Gate MW, which binds evidence to
3073
+ // whatever this file declares rather than to a local- prefix.
3074
+ // `scripts/check_delphi_local_models.py` holds the same rule with the
3075
+ // same allowlist and exit code; doctor reports it here so the guard
3076
+ // finally has a caller (`xp-gate doctor delphi`) instead of existing
3077
+ // only in its own docstring.
3078
+ const hostedSeats = roles.filter((r) => {
3079
+ const m = models[roles.indexOf(r)];
3080
+ return m && !String(m).toLowerCase().startsWith('local-');
3081
+ });
3082
+ if (hostedSeats.length > 0) {
3083
+ add('Delphi local-model guard (#574)', 'WARN',
3084
+ `hosted (non local-*) seats: ${hostedSeats.map((r) => `${r}=${models[roles.indexOf(r)]}`).join(', ')} — `
3085
+ + 'local-only review is the standing rule; run: python scripts/check_delphi_local_models.py --probe');
3086
+ } else {
3087
+ add('Delphi local-model guard (#574)', 'PASS', `all ${roles.length} seats are local-*`);
3088
+ }
2944
3089
  }
2945
3090
  // #541 follow-up: shadow detection. Loaders fall back to the global
2946
3091
  // config ONLY on ENOENT, so this project file always wins here. If the
@@ -3008,7 +3153,7 @@ async function doctorDelphi(options = {}) {
3008
3153
  // home exists. The two-layer switch (preset capability + host setting)
3009
3154
  // defaults to disabled, and a review that assumes native will silently run
3010
3155
  // three same-model seats.
3011
- const dshHome = path.join(HOME_DIR, '.dsh');
3156
+ const dshHome = path.join(HOME_DIR(), '.dsh');
3012
3157
  if (!fs.existsSync(dshHome)) {
3013
3158
  add('DSH subagent model selection', 'SKIP', `no ${dshHome} — not a DSH environment`);
3014
3159
  } else {