@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.
- package/adapter-common.sh +41 -3
- package/adapters/gate-3.sh +8 -1
- package/adapters/gate-8.sh +67 -6
- package/adapters/go.sh +18 -1
- package/adapters/iac.sh +18 -1
- package/adapters/powershell.sh +37 -1
- package/adapters/python.sh +18 -4
- package/gate-3.sh +10 -1
- package/gate-8.sh +67 -6
- package/hooks/adapter-common.sh +41 -3
- package/hooks/gate-3.sh +10 -1
- package/hooks/gate-8.sh +67 -6
- package/hooks/lib/coverage-total.sh +20 -5
- package/hooks/lib/gate-5c-alignment.sh +145 -0
- package/hooks/lib/jscpd-run.sh +40 -1
- package/hooks/lib/native-path.sh +106 -0
- package/hooks/lib/prepush-score.py +143 -0
- package/hooks/lib/sprint-gate-report.sh +23 -0
- package/hooks/lib/toolchain-path.sh +370 -0
- package/hooks/lib/validate-code-walkthrough.cjs +224 -23
- package/hooks/pre-commit +371 -163
- package/hooks/pre-push +192 -74
- package/hooks/sprint-gate.sh +14 -3
- package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
- package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
- package/lib/__tests__/doctor-delphi.test.js +46 -0
- package/lib/__tests__/doctor-exec-retry.test.js +20 -10
- package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
- package/lib/__tests__/doctor.test.js +7 -1
- package/lib/__tests__/hook-health-path.test.js +9 -1
- package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
- package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
- package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
- package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
- package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
- package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
- package/lib/arch.js +261 -44
- package/lib/check-alignment-runner.cjs +23 -0
- package/lib/check.js +4 -1
- package/lib/doctor.js +194 -49
- package/lib/gate-runner.js +265 -58
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +228 -6
- package/lib/shared-paths.js +53 -19
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/gremlins-runner.test.ts +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
- package/plugins/dsh/src/delphi-config.ts +151 -9
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
- package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
- package/principles/adapters/java.ts +127 -11
- package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
- package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
- package/principles/rules/solid/lsp.ts +348 -23
- package/scripts/__tests__/sync-package-content.test.js +32 -1
- package/scripts/sync-package-content.js +6 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +50 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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 {
|