@boyingliu01/xp-gate 0.23.0 → 0.25.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.
- package/adapter-common.sh +51 -2
- package/adapters/python.sh +10 -2
- package/bin/xp-gate.js +15 -1
- package/hooks/adapter-common.sh +51 -2
- package/hooks/lib/coverage-total.sh +20 -0
- package/hooks/lib/python-test-selection.sh +132 -0
- package/hooks/pre-commit +100 -11
- package/lib/__tests__/delphi-setup.test.js +203 -0
- package/lib/__tests__/doctor-delphi.test.js +244 -0
- package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
- package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
- package/lib/__tests__/qoder-delphi-agents.test.js +31 -0
- package/lib/delphi-setup.js +155 -0
- package/lib/doctor.js +269 -1
- package/lib/init.js +49 -1
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/phase-transition.js +61 -0
- package/lib/sprint-state-manager.js +86 -39
- package/lib/sprint-state-migrator.js +60 -22
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/gremlins-runner.test.ts +167 -0
- package/mutation/__tests__/mutmut-runner.test.ts +20 -22
- package/mutation/__tests__/runners-index.test.ts +31 -3
- package/mutation/gate-m.ts +22 -4
- package/mutation/runners/gremlins-runner.ts +238 -0
- package/mutation/runners/index.ts +9 -1
- package/mutation/runners/mutmut-runner.ts +18 -12
- package/mutation/runners/types.ts +20 -0
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +43 -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/.delphi-config.json.example +24 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/delphi-review/SKILL.md +43 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +43 -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/.delphi-config.json.example +24 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/SKILL.md +43 -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/scripts/delphi-external-review.cjs +166 -14
- package/skills/delphi-review/.delphi-config.json.example +24 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +43 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/lib/doctor.js
CHANGED
|
@@ -2643,8 +2643,276 @@ function formatDoctorJson(checks, issues) {
|
|
|
2643
2643
|
};
|
|
2644
2644
|
}
|
|
2645
2645
|
|
|
2646
|
+
// ── doctor delphi (#414 REQ-3/REQ-6) ───────────────────────────────────
|
|
2647
|
+
// Diagnoses the Delphi review chain end to end. Each check exists because a
|
|
2648
|
+
// real failure was silent: drifted agent bindings looked like a working setup
|
|
2649
|
+
// (B2), a stale skill copy made reinstall = downgrade (B4), and a missing
|
|
2650
|
+
// runner script surfaced only as a failed review (B3).
|
|
2651
|
+
async function doctorDelphi(options = {}) {
|
|
2652
|
+
const { configPath, npmLatest } = options;
|
|
2653
|
+
const checks = [];
|
|
2654
|
+
const add = (name, status, detail) => checks.push({ name, status, detail });
|
|
2655
|
+
|
|
2656
|
+
// 1. Node >= 18: the runner gates on it and exits 1 mid-review otherwise.
|
|
2657
|
+
const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
|
|
2658
|
+
add('Node.js >= 18', nodeMajor >= 18 ? 'PASS' : 'FAIL',
|
|
2659
|
+
`current: ${process.versions.node}`);
|
|
2660
|
+
|
|
2661
|
+
// 2. Qoder agent bindings: present, parseable, three distinct ids, matching
|
|
2662
|
+
// what this version ships. A stale binding is a repair, not an error to
|
|
2663
|
+
// retype: the detail names delphi-setup. Project-level agents (from
|
|
2664
|
+
// `delphi-setup --local --project`) take precedence when a complete set
|
|
2665
|
+
// exists there, so the doctor agrees with the repair command (#414 R1 FC-09).
|
|
2666
|
+
const { readQoderModelId } = require('./init.js');
|
|
2667
|
+
const roles = ['architecture', 'technical', 'feasibility'];
|
|
2668
|
+
const homeAgentsDir = path.join(HOME_DIR, '.qoder', 'agents');
|
|
2669
|
+
// Injectable for tests; defaults to the cwd the doctor runs from.
|
|
2670
|
+
const projectAgentsDir = path.join(options.projectDir || process.cwd(), '.qoder', 'agents');
|
|
2671
|
+
const homeComplete = roles.every((r) => fs.existsSync(path.join(homeAgentsDir, `delphi-${r}.md`)));
|
|
2672
|
+
const projectComplete = roles.every((r) => fs.existsSync(path.join(projectAgentsDir, `delphi-${r}.md`)));
|
|
2673
|
+
const agentsDir = projectComplete ? projectAgentsDir : homeAgentsDir;
|
|
2674
|
+
const agentsSource = projectComplete
|
|
2675
|
+
? `project-level ${agentsDir}`
|
|
2676
|
+
: `user-level ${agentsDir}`;
|
|
2677
|
+
const agentIds = [];
|
|
2678
|
+
let agentsStatus = 'PASS';
|
|
2679
|
+
const agentDetails = [agentsSource];
|
|
2680
|
+
for (const role of roles) {
|
|
2681
|
+
const agentPath = path.join(agentsDir, `delphi-${role}.md`);
|
|
2682
|
+
if (!fs.existsSync(agentPath)) {
|
|
2683
|
+
agentsStatus = 'FAIL';
|
|
2684
|
+
agentDetails.push(`delphi-${role}.md missing (run: xp-gate init --global, or delphi-setup --local --project for project scope)`);
|
|
2685
|
+
continue;
|
|
2686
|
+
}
|
|
2687
|
+
const id = readQoderModelId(agentPath);
|
|
2688
|
+
if (id === null) {
|
|
2689
|
+
agentsStatus = 'FAIL';
|
|
2690
|
+
agentDetails.push(`delphi-${role}.md has no quoted model binding (repair: xp-gate delphi-setup)`);
|
|
2691
|
+
continue;
|
|
2692
|
+
}
|
|
2693
|
+
const bundledId = readQoderModelId(path.join(PKG_DIR, 'plugins', 'qoder', 'agents', `delphi-${role}.md`));
|
|
2694
|
+
if (bundledId === null) {
|
|
2695
|
+
// A bundle without a parseable template is install corruption: skipping
|
|
2696
|
+
// the comparison would hide it behind a healthy-looking id (#414 R1 FC-08).
|
|
2697
|
+
agentsStatus = 'FAIL';
|
|
2698
|
+
agentDetails.push(`delphi-${role}: bundled template missing/unreadable — reinstall xp-gate`);
|
|
2699
|
+
continue;
|
|
2700
|
+
}
|
|
2701
|
+
if (bundledId !== id) {
|
|
2702
|
+
agentDetails.push(`delphi-${role}: ${id} (this version ships ${bundledId} — repair: xp-gate delphi-setup)`);
|
|
2703
|
+
} else {
|
|
2704
|
+
agentDetails.push(`delphi-${role}: ${id}`);
|
|
2705
|
+
}
|
|
2706
|
+
agentIds.push(id);
|
|
2707
|
+
}
|
|
2708
|
+
// Report every problem class independently: missing files, unparseable
|
|
2709
|
+
// bindings and duplicates can coexist, and each has a different remedy
|
|
2710
|
+
// (#414 R1 MI-02).
|
|
2711
|
+
if (agentIds.length !== roles.length) agentsStatus = 'FAIL';
|
|
2712
|
+
if (new Set(agentIds).size !== agentIds.length) {
|
|
2713
|
+
agentsStatus = 'FAIL';
|
|
2714
|
+
agentDetails.push('duplicate model binding across experts — three DISTINCT ids are required');
|
|
2715
|
+
}
|
|
2716
|
+
add('Qoder Delphi agent bindings', agentsStatus, agentDetails.join('; ') || 'all three bound');
|
|
2717
|
+
|
|
2718
|
+
// 3. Skill install parity: a drifted installed copy is how "reinstall from
|
|
2719
|
+
// an old local repo" became a downgrade (#414 B4). Every bundled file is
|
|
2720
|
+
// compared, not just SKILL.md — the install script copies *.json and
|
|
2721
|
+
// references/ too, and B4-style drift hits those equally (#414 R1 MI-03).
|
|
2722
|
+
const platform = detectPlatform();
|
|
2723
|
+
const installedSkillDir = platform === 'qoder'
|
|
2724
|
+
? path.join(HOME_DIR, '.qoder', 'skills', 'delphi-review')
|
|
2725
|
+
: platform === 'claude-code'
|
|
2726
|
+
? path.join(HOME_DIR, '.claude', 'skills', 'delphi-review')
|
|
2727
|
+
: path.join(HOME_DIR, '.config', 'opencode', 'skills', 'delphi-review');
|
|
2728
|
+
// Injectable for tests (#414 R3 FC-01/FC-02): the empty-bundle branch is a
|
|
2729
|
+
// real failure mode and must be drivable without mutating the package.
|
|
2730
|
+
const bundledSkillDir = options.bundledSkillDir || path.join(PKG_DIR, 'skills', 'delphi-review');
|
|
2731
|
+
const sha256 = (p) => require('crypto').createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
|
2732
|
+
const listBundledSkillFiles = (dir, base = dir, out = []) => {
|
|
2733
|
+
if (!fs.existsSync(dir)) return out;
|
|
2734
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
2735
|
+
const p = path.join(dir, entry.name);
|
|
2736
|
+
if (entry.isDirectory()) listBundledSkillFiles(p, base, out);
|
|
2737
|
+
else out.push(path.relative(base, p));
|
|
2738
|
+
}
|
|
2739
|
+
return out;
|
|
2740
|
+
};
|
|
2741
|
+
if (!fs.existsSync(path.join(installedSkillDir, 'SKILL.md'))) {
|
|
2742
|
+
add('Delphi skill install parity', 'FAIL',
|
|
2743
|
+
`not installed at ${installedSkillDir} (install: xp-gate install-skill delphi-review)`);
|
|
2744
|
+
} else {
|
|
2745
|
+
const bundledFiles = listBundledSkillFiles(bundledSkillDir);
|
|
2746
|
+
if (bundledFiles.length === 0) {
|
|
2747
|
+
// An installed copy next to an empty bundle is install corruption —
|
|
2748
|
+
// "0 files match" must never read as healthy (#414 R2 MN-R2-01). The
|
|
2749
|
+
// bundled dir ships inside the installed package itself, so an
|
|
2750
|
+
// unreachable one is a broken install, not a transient condition
|
|
2751
|
+
// (#414 R3 FC-05): FAILing is the doctor's job here.
|
|
2752
|
+
add('Delphi skill install parity', 'FAIL',
|
|
2753
|
+
`bundled skill directory missing or empty: ${bundledSkillDir} — reinstall xp-gate`);
|
|
2754
|
+
} else {
|
|
2755
|
+
const drifted = [];
|
|
2756
|
+
for (const rel of bundledFiles) {
|
|
2757
|
+
const installedPath = path.join(installedSkillDir, rel);
|
|
2758
|
+
const bundledPath = path.join(bundledSkillDir, rel);
|
|
2759
|
+
if (!fs.existsSync(installedPath)) {
|
|
2760
|
+
drifted.push(`${rel}: missing`);
|
|
2761
|
+
} else if (sha256(installedPath) !== sha256(bundledPath)) {
|
|
2762
|
+
drifted.push(`${rel}: differs from bundle`);
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
if (drifted.length > 0) {
|
|
2766
|
+
add('Delphi skill install parity', 'FAIL',
|
|
2767
|
+
`${drifted.length} file(s) differ from the ${getCliVersionSafe()} bundle: `
|
|
2768
|
+
+ `${drifted.slice(0, 5).join('; ')}${drifted.length > 5 ? '; ...' : ''} `
|
|
2769
|
+
+ '(reinstall: xp-gate install-skill delphi-review --force)');
|
|
2770
|
+
} else {
|
|
2771
|
+
add('Delphi skill install parity', 'PASS',
|
|
2772
|
+
`${bundledFiles.length} file(s) match the bundle`);
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// 4. Runner script locate: the runner must be findable from the places a
|
|
2778
|
+
// review actually starts.
|
|
2779
|
+
const runnerCandidates = [
|
|
2780
|
+
['package bundle', path.join(PKG_DIR, 'scripts', 'delphi-external-review.cjs')],
|
|
2781
|
+
['project', path.join(process.cwd(), 'scripts', 'delphi-external-review.cjs')],
|
|
2782
|
+
['installed skill', path.join(installedSkillDir, 'scripts', 'delphi-external-review.cjs')],
|
|
2783
|
+
];
|
|
2784
|
+
const foundRunner = runnerCandidates.filter(([, p]) => fs.existsSync(p));
|
|
2785
|
+
add('delphi-external-review runner', foundRunner.length > 0 ? 'PASS' : 'FAIL',
|
|
2786
|
+
foundRunner.length > 0
|
|
2787
|
+
? foundRunner.map(([label, p]) => `${label}: ${p}`).join('; ')
|
|
2788
|
+
: 'not found in package bundle, project, or installed skill');
|
|
2789
|
+
|
|
2790
|
+
// 5. Config ping: parseable, three distinct callable models. A duplicate
|
|
2791
|
+
// model here is a review that will BLOCK later — say so now.
|
|
2792
|
+
const cfgPath = configPath || path.join(process.cwd(), '.delphi-config.json');
|
|
2793
|
+
if (!fs.existsSync(cfgPath)) {
|
|
2794
|
+
add('Delphi config (.delphi-config.json)', 'FAIL',
|
|
2795
|
+
`${cfgPath} not found (copy .delphi-config.json.example from the skill directory)`);
|
|
2796
|
+
} else {
|
|
2797
|
+
try {
|
|
2798
|
+
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
2799
|
+
const profileName = raw.active_profile || 'default';
|
|
2800
|
+
const profile = raw.profiles && raw.profiles[profileName];
|
|
2801
|
+
if (!profile) {
|
|
2802
|
+
add('Delphi config (.delphi-config.json)', 'FAIL', `profile "${profileName}" not found`);
|
|
2803
|
+
} else {
|
|
2804
|
+
const experts = profile.experts || {};
|
|
2805
|
+
const models = roles.map((r) => experts[r] && experts[r].model);
|
|
2806
|
+
const providers = profile.providers || {};
|
|
2807
|
+
const missingRoles = roles.filter((r, i) => !models[i]);
|
|
2808
|
+
const duplicates = models.filter((m, i) => m && models.indexOf(m) !== i);
|
|
2809
|
+
const badProviders = roles.filter((r) => {
|
|
2810
|
+
const e = experts[r];
|
|
2811
|
+
return !e || !providers[e.provider]
|
|
2812
|
+
|| !providers[e.provider].base_url
|
|
2813
|
+
|| String(providers[e.provider].base_url).indexOf('http') !== 0;
|
|
2814
|
+
});
|
|
2815
|
+
if (missingRoles.length > 0 || duplicates.length > 0 || badProviders.length > 0) {
|
|
2816
|
+
const problems = [];
|
|
2817
|
+
if (missingRoles.length > 0) problems.push(`missing experts: ${missingRoles.join(', ')}`);
|
|
2818
|
+
if (duplicates.length > 0) problems.push(`duplicate models: ${[...new Set(duplicates)].join(', ')}`);
|
|
2819
|
+
if (badProviders.length > 0) problems.push(`unusable provider binding: ${badProviders.join(', ')}`);
|
|
2820
|
+
add('Delphi config (.delphi-config.json)', 'FAIL', problems.join('; '));
|
|
2821
|
+
} else {
|
|
2822
|
+
add('Delphi config (.delphi-config.json)', 'PASS',
|
|
2823
|
+
`profile "${profileName}": ${models.join(' / ')}`);
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
} catch (err) {
|
|
2827
|
+
add('Delphi config (.delphi-config.json)', 'FAIL', `unparseable: ${err.message}`);
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
// 6. Version consistency (#414 REQ-6): local files first. The npm-latest
|
|
2832
|
+
// probe is opt-in (options.probeNpmLatest) or pre-supplied
|
|
2833
|
+
// (options.npmLatest) — the doctor never blocks on the network, which is
|
|
2834
|
+
// what an offline CI or restricted shell would otherwise hit (#414 R1 FC-03).
|
|
2835
|
+
const localVersion = getCliVersionSafe();
|
|
2836
|
+
let versionStatus = 'PASS';
|
|
2837
|
+
let versionDetail = `xp-gate ${localVersion}`;
|
|
2838
|
+
if (npmLatest === undefined && options.probeNpmLatest === true) {
|
|
2839
|
+
try {
|
|
2840
|
+
versionDetail += `; npm latest probe: ${execWithTimeout('npm view @boyingliu01/xp-gate version', 8000).trim()}`;
|
|
2841
|
+
} catch {
|
|
2842
|
+
versionStatus = 'SKIP';
|
|
2843
|
+
versionDetail += '; npm latest probe unavailable (offline?) — skipped';
|
|
2844
|
+
}
|
|
2845
|
+
} else if (typeof npmLatest === 'string' && npmLatest !== localVersion) {
|
|
2846
|
+
versionStatus = 'FAIL';
|
|
2847
|
+
versionDetail += `; npm latest ${npmLatest} differs from local ${localVersion} — update (npm install -g @boyingliu01/xp-gate@latest) or verify which side is stale`;
|
|
2848
|
+
} else if (npmLatest === null || options.probeNpmLatest !== true) {
|
|
2849
|
+
versionDetail += '; npm latest probe skipped (pass --npm-check or options.probeNpmLatest to run it)';
|
|
2850
|
+
}
|
|
2851
|
+
add('Delphi version consistency', versionStatus, versionDetail);
|
|
2852
|
+
|
|
2853
|
+
// 7. DSH native-channel readiness (#443 REQ-6): only meaningful when a DSH
|
|
2854
|
+
// home exists. The two-layer switch (preset capability + host setting)
|
|
2855
|
+
// defaults to disabled, and a review that assumes native will silently run
|
|
2856
|
+
// three same-model seats.
|
|
2857
|
+
const dshHome = path.join(HOME_DIR, '.dsh');
|
|
2858
|
+
if (!fs.existsSync(dshHome)) {
|
|
2859
|
+
add('DSH subagent model selection', 'SKIP', `no ${dshHome} — not a DSH environment`);
|
|
2860
|
+
} else {
|
|
2861
|
+
const settingsCandidates = [
|
|
2862
|
+
path.join(dshHome, 'settings.json'),
|
|
2863
|
+
path.join(dshHome, 'settings', 'settings.json'),
|
|
2864
|
+
path.join(dshHome, 'config.json'),
|
|
2865
|
+
];
|
|
2866
|
+
const settingsFile = settingsCandidates.find((p) => fs.existsSync(p));
|
|
2867
|
+
if (!settingsFile) {
|
|
2868
|
+
add('DSH subagent model selection', 'FAIL',
|
|
2869
|
+
`${dshHome} exists but no settings file found — subagent model selection defaults to disabled (#443)`);
|
|
2870
|
+
} else {
|
|
2871
|
+
try {
|
|
2872
|
+
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
2873
|
+
const selection = settings.subagentModelSelection;
|
|
2874
|
+
const allowed = selection && Array.isArray(selection.allowedModels) ? selection.allowedModels : [];
|
|
2875
|
+
const ids = allowed.map((m) => (typeof m === 'string' ? m : m && m.id)).filter(Boolean);
|
|
2876
|
+
if (selection && selection.enabled === true && new Set(ids).size >= 3) {
|
|
2877
|
+
add('DSH subagent model selection', 'PASS', `${settingsFile}: enabled, ${new Set(ids).size} distinct allowed models`);
|
|
2878
|
+
} else if (selection && selection.enabled === true) {
|
|
2879
|
+
add('DSH subagent model selection', 'FAIL',
|
|
2880
|
+
`${settingsFile}: enabled but only ${new Set(ids).size} distinct allowedModels (need ≥3) — native channel unusable`);
|
|
2881
|
+
} else {
|
|
2882
|
+
// The switch shipping off is the platform default, not a defect:
|
|
2883
|
+
// only an operator who explicitly enables it and under-provisions
|
|
2884
|
+
// allowedModels has a real misconfiguration (#414 R1 FC-07).
|
|
2885
|
+
add('DSH subagent model selection', 'SKIP',
|
|
2886
|
+
`${settingsFile}: native model selection disabled (platform default) — the external channel is the contract path (#443)`);
|
|
2887
|
+
}
|
|
2888
|
+
} catch (err) {
|
|
2889
|
+
add('DSH subagent model selection', 'SKIP', `${settingsFile} unparseable: ${err.message}`);
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
for (const check of checks) {
|
|
2895
|
+
const mark = check.status === 'PASS' ? '✓' : check.status === 'SKIP' ? '○' : '✗';
|
|
2896
|
+
console.log(` ${mark} ${check.name}: ${check.detail || check.status}`);
|
|
2897
|
+
}
|
|
2898
|
+
const failed = checks.filter((c) => c.status === 'FAIL').length;
|
|
2899
|
+
console.log('');
|
|
2900
|
+
console.log(failed === 0
|
|
2901
|
+
? ' Delphi review chain: healthy.'
|
|
2902
|
+
: ` Delphi review chain: ${failed} check(s) FAILED.`);
|
|
2903
|
+
return failed === 0 ? 0 : 1;
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
function getCliVersionSafe() {
|
|
2907
|
+
try {
|
|
2908
|
+
return require('../package.json').version || '0.0.0';
|
|
2909
|
+
} catch {
|
|
2910
|
+
return '0.0.0';
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2646
2914
|
module.exports = {
|
|
2647
|
-
doctor, isXpGateFile, SIGNATURES,
|
|
2915
|
+
doctor, doctorDelphi, isXpGateFile, SIGNATURES,
|
|
2648
2916
|
fixVersionMismatch,
|
|
2649
2917
|
fixTemplateDirMismatch,
|
|
2650
2918
|
fixMissingHooks,
|
package/lib/init.js
CHANGED
|
@@ -271,6 +271,46 @@ function printCliToolStatus() {
|
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Probe for gitleaks and warn when it is missing (#511).
|
|
276
|
+
*
|
|
277
|
+
* Gate 8 became fail-closed in #499: a missing gitleaks BLOCKs the commit
|
|
278
|
+
* outright (it does NOT skip, unlike the tools reported by
|
|
279
|
+
* printCliToolStatus). The user-facing installers never provisioned it, so an
|
|
280
|
+
* existing user upgrading into #499 hits a hard BLOCK on their next commit with
|
|
281
|
+
* no prior signal — this is the missing migration notice.
|
|
282
|
+
*
|
|
283
|
+
* Probe-and-prompt only: nothing is installed here (installing is the user's
|
|
284
|
+
* explicit choice, via the printed command). The fail-closed semantics of
|
|
285
|
+
* #499 are untouched.
|
|
286
|
+
*
|
|
287
|
+
* @param {(name: string) => {available: boolean, version?: string}} checkTool
|
|
288
|
+
* @param {(line: string) => void} [log]
|
|
289
|
+
* @returns {{available: boolean}} the probe result, for callers that branch on it
|
|
290
|
+
*/
|
|
291
|
+
function probeGitleaksNotice(checkTool, log = console.log) {
|
|
292
|
+
const result = checkTool('gitleaks');
|
|
293
|
+
if (result.available) {
|
|
294
|
+
log(`✓ gitleaks present${result.version ? `: ${result.version}` : ''}`);
|
|
295
|
+
return { available: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
log('');
|
|
299
|
+
log('⚠ gitleaks is NOT installed.');
|
|
300
|
+
log(' Gate 8 (secret scanning) is FAIL-CLOSED since #499: your next commit');
|
|
301
|
+
log(' will be BLOCKED until gitleaks is available.');
|
|
302
|
+
log('');
|
|
303
|
+
log(' Install it now:');
|
|
304
|
+
log(' bash scripts/install-gitleaks.sh');
|
|
305
|
+
log('');
|
|
306
|
+
log(' Per-OS alternatives:');
|
|
307
|
+
log(' macOS: brew install gitleaks');
|
|
308
|
+
log(' Linux: https://github.com/gitleaks/gitleaks/releases');
|
|
309
|
+
log(' Windows: winget install gitleaks (or: scoop install gitleaks)');
|
|
310
|
+
log('');
|
|
311
|
+
return { available: false };
|
|
312
|
+
}
|
|
313
|
+
|
|
274
314
|
/**
|
|
275
315
|
* Ask the user for confirmation via stdin.
|
|
276
316
|
* @param {string} question - The yes/no question to display
|
|
@@ -352,6 +392,11 @@ async function init(args) {
|
|
|
352
392
|
console.log('Checking CLI tools...');
|
|
353
393
|
printCliToolStatus();
|
|
354
394
|
|
|
395
|
+
// #511: gitleaks is the one tool whose absence BLOCKS rather than SKIPs
|
|
396
|
+
// (Gate 8 fail-closed, #499) — surface it separately so an upgrading user
|
|
397
|
+
// has a migration path before the first blocked commit.
|
|
398
|
+
probeGitleaksNotice(checkCliTool);
|
|
399
|
+
|
|
355
400
|
console.log('Checking platform dependencies...');
|
|
356
401
|
// Detect platform and check/auto-install dependencies
|
|
357
402
|
const platform = detectPlatform();
|
|
@@ -922,4 +967,7 @@ function injectKarpathyPrinciples(projectRoot) {
|
|
|
922
967
|
}
|
|
923
968
|
}
|
|
924
969
|
|
|
925
|
-
|
|
970
|
+
// setupGlobal is exported so the global-install wiring (agents deployed to the
|
|
971
|
+
// real HOME) can be audited under a sandboxed HOME, not just asserted to exist
|
|
972
|
+
// — the historical miss was "local deployed, global did not" (#417, #429).
|
|
973
|
+
module.exports = { init, promptBootstrap, installModuleRuntimeDeps, deployQoderDelphiAgents, configureQoderDelphiAgents, readQoderModelId, setupGlobal, probeGitleaksNotice };
|
package/lib/phase-transition.js
CHANGED
|
@@ -532,12 +532,14 @@ async function handlePhaseTransition(args = []) {
|
|
|
532
532
|
console.log(' --render Render ASCII dashboard after transition');
|
|
533
533
|
console.log(' --dir <path> Project directory (default: cwd)');
|
|
534
534
|
console.log(' --skip-evidence <reason> Skip evidence validation (requires reason)');
|
|
535
|
+
console.log(' --force Override the phase sequence guard (#519)');
|
|
535
536
|
console.log('');
|
|
536
537
|
console.log('Examples:');
|
|
537
538
|
console.log(' xp-gate phase-transition 1 completed');
|
|
538
539
|
console.log(' xp-gate phase-transition 3 completed --render');
|
|
539
540
|
console.log(' xp-gate phase-transition 2 in_progress --outputs \'{"spec":"path.yaml"}\'');
|
|
540
541
|
console.log(' xp-gate phase-transition 4 completed --skip-evidence "Emergency hotfix"');
|
|
542
|
+
console.log(' xp-gate phase-transition 3 completed --force # skip-order override');
|
|
541
543
|
return 0;
|
|
542
544
|
}
|
|
543
545
|
|
|
@@ -576,6 +578,9 @@ async function handlePhaseTransition(args = []) {
|
|
|
576
578
|
}
|
|
577
579
|
}
|
|
578
580
|
|
|
581
|
+
// #519: explicit override for the sequence guard below.
|
|
582
|
+
const forceTransition = args.includes('--force');
|
|
583
|
+
|
|
579
584
|
// Validate inputs
|
|
580
585
|
if (phase < 1 || phase > 6) {
|
|
581
586
|
console.error(`Error: Phase must be 1-6, got ${phase}`);
|
|
@@ -592,6 +597,37 @@ async function handlePhaseTransition(args = []) {
|
|
|
592
597
|
try {
|
|
593
598
|
const manager = new SprintStateManager(projectDir);
|
|
594
599
|
|
|
600
|
+
// ── #519 Layer 0: Sequence guard ──
|
|
601
|
+
// `completed` on phase n requires phase n-1 to be completed/skipped first.
|
|
602
|
+
// #507 proved that a missed transition is invisible in the state file (the
|
|
603
|
+
// pointer just looks "not yet advanced"), so out-of-order completion —
|
|
604
|
+
// skipping a phase, or completing from a stale pointer — must be refused
|
|
605
|
+
// loudly rather than written silently. `--force` overrides for the rare
|
|
606
|
+
// legitimate case (e.g. retroactively recording a skipped phase).
|
|
607
|
+
if (status === 'completed' && phase >= 2) {
|
|
608
|
+
const seqState = manager.read();
|
|
609
|
+
if (seqState && Array.isArray(seqState.phase_history)) {
|
|
610
|
+
const prevPhase = phase - 1;
|
|
611
|
+
const prevEntry = seqState.phase_history.find(e => e.phase === prevPhase);
|
|
612
|
+
const prevOk = prevEntry && (prevEntry.status === 'completed' || prevEntry.status === 'skipped');
|
|
613
|
+
if (!prevOk && !forceTransition) {
|
|
614
|
+
const summary = seqState.phase_history
|
|
615
|
+
.map(e => ` ${e.phase}:${e.phase_name || PHASE_NAMES[e.phase] || '?'} = ${e.status}`)
|
|
616
|
+
.join('\n');
|
|
617
|
+
console.error(`❌ [sprint-audit] Cannot complete phase ${phase} (${PHASE_NAMES[phase]}):`);
|
|
618
|
+
console.error(` phase ${prevPhase} (${PHASE_NAMES[prevPhase]}) is not completed/skipped.`);
|
|
619
|
+
console.error(' Current phase history:');
|
|
620
|
+
console.error(summary || ' (empty)');
|
|
621
|
+
console.error(` Advance it first: xp-gate phase-transition ${prevPhase} completed`);
|
|
622
|
+
console.error(' Or override explicitly: --force');
|
|
623
|
+
return 1;
|
|
624
|
+
}
|
|
625
|
+
if (!prevOk && forceTransition) {
|
|
626
|
+
console.warn(`⚠️ [sprint-audit] Sequence guard overridden (--force): phase ${prevPhase} is not completed/skipped.`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
595
631
|
// ── Layer 1: Pre-transition gate check ──
|
|
596
632
|
// Check BEFORE transitionPhase() to validate previous phase completion.
|
|
597
633
|
// Runs when status === 'in_progress' and phase >= 2.
|
|
@@ -640,9 +676,34 @@ async function handlePhaseTransition(args = []) {
|
|
|
640
676
|
}
|
|
641
677
|
}
|
|
642
678
|
|
|
679
|
+
// Capture the before-image for the echo below (#519). Read once here so a
|
|
680
|
+
// concurrent writer cannot make before/after disagree about their baseline.
|
|
681
|
+
const beforeSnapshot = (() => {
|
|
682
|
+
try {
|
|
683
|
+
const s = manager.read();
|
|
684
|
+
if (!s) return { phase: undefined, status: undefined, entryStatus: undefined };
|
|
685
|
+
const e = Array.isArray(s.phase_history) ? s.phase_history.find(x => x.phase === phase) : undefined;
|
|
686
|
+
return { phase: s.phase, status: s.status, entryStatus: e ? e.status : undefined };
|
|
687
|
+
} catch {
|
|
688
|
+
return { phase: undefined, status: undefined, entryStatus: undefined };
|
|
689
|
+
}
|
|
690
|
+
})();
|
|
691
|
+
|
|
643
692
|
const newState = manager.transitionPhase(phase, status, { outputs });
|
|
644
693
|
|
|
694
|
+
// #519: before/after echo. The #507 failure mode was "the command either
|
|
695
|
+
// never ran or ran without effect" — both look identical in a terminal that
|
|
696
|
+
// prints only a success line. Printing the observed state on both sides
|
|
697
|
+
// makes those two cases distinguishable in the output itself.
|
|
698
|
+
const afterEntry = Array.isArray(newState.phase_history)
|
|
699
|
+
? newState.phase_history.find(e => e.phase === phase)
|
|
700
|
+
: undefined;
|
|
701
|
+
const oldTop = `${beforeSnapshot.phase ?? '-'}/${beforeSnapshot.status ?? '-'}`;
|
|
702
|
+
const newTop = `${newState.phase ?? '-'}/${newState.status ?? '-'}`;
|
|
703
|
+
const oldEntry = beforeSnapshot.entryStatus ?? '(none)';
|
|
704
|
+
const newEntryStatus = afterEntry ? afterEntry.status : '(none)';
|
|
645
705
|
console.log(`✅ Phase ${phase} transitioned to '${status}'`);
|
|
706
|
+
console.log(` before→after phase/status: ${oldTop} → ${newTop} | phase ${phase} entry: ${oldEntry} → ${newEntryStatus}`);
|
|
646
707
|
|
|
647
708
|
// ── Layer 1.5: Auto-trigger Layer 2 reminder on Phase 6 completed ──
|
|
648
709
|
if (phase === 6 && status === 'completed') {
|
|
@@ -12,6 +12,38 @@ const fs = require('fs');
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const { migrateState, createInitialState, rollbackState, LEGACY_PHASE_MAP } = require('./sprint-state-migrator');
|
|
14
14
|
|
|
15
|
+
/** Canonical phase order; index = phase - 1. */
|
|
16
|
+
const PHASE_NAMES = ['PREP', 'DESIGN', 'BUILD', 'VERIFY', 'SHIP', 'CLOSE'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Apply a status change to the phase_history entry for `phase`, creating it on
|
|
20
|
+
* first sight, and stamp started_at/completed_at/duration_seconds.
|
|
21
|
+
*
|
|
22
|
+
* Module-level and pure (it only mutates the passed-in state), extracted from
|
|
23
|
+
* transitionPhase (#519) so that method stays within the long-function budget.
|
|
24
|
+
*/
|
|
25
|
+
function applyHistoryEntry(state, phase, phaseName, status) {
|
|
26
|
+
let entry = state.phase_history.find(e => e.phase === phase);
|
|
27
|
+
if (!entry) {
|
|
28
|
+
entry = { phase, phase_name: phaseName, status };
|
|
29
|
+
state.phase_history.push(entry);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
entry.status = status;
|
|
33
|
+
if (status === 'in_progress' && !entry.started_at) {
|
|
34
|
+
entry.started_at = new Date().toISOString();
|
|
35
|
+
}
|
|
36
|
+
if (status === 'completed' || status === 'skipped') {
|
|
37
|
+
entry.completed_at = new Date().toISOString();
|
|
38
|
+
if (entry.started_at) {
|
|
39
|
+
const startMs = new Date(entry.started_at).getTime();
|
|
40
|
+
const endMs = new Date(entry.completed_at).getTime();
|
|
41
|
+
entry.duration_seconds = Math.round((endMs - startMs) / 1000);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
|
|
15
47
|
class SprintStateManager {
|
|
16
48
|
constructor(projectRoot) {
|
|
17
49
|
this.projectRoot = projectRoot;
|
|
@@ -48,59 +80,57 @@ class SprintStateManager {
|
|
|
48
80
|
}
|
|
49
81
|
|
|
50
82
|
write(state) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
83
|
+
try {
|
|
84
|
+
if (!fs.existsSync(this.stateDir)) {
|
|
85
|
+
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
// Atomic write: write to tmp, then rename
|
|
88
|
+
const tmpFile = this.stateFile + '.tmp';
|
|
89
|
+
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
|
|
90
|
+
fs.renameSync(tmpFile, this.stateFile);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
throw new Error(`Failed to write sprint state: ${err.message}`);
|
|
54
93
|
}
|
|
55
|
-
|
|
56
|
-
// Atomic write: write to tmp, then rename
|
|
57
|
-
const tmpFile = this.stateFile + '.tmp';
|
|
58
|
-
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
|
|
59
|
-
fs.renameSync(tmpFile, this.stateFile);
|
|
60
94
|
}
|
|
61
95
|
|
|
62
96
|
transitionPhase(phase, status, options = {}) {
|
|
63
97
|
const state = this.read() || createInitialState(this.projectRoot);
|
|
64
98
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let entry = state.phase_history.find(e => e.phase === phase);
|
|
73
|
-
if (!entry) {
|
|
74
|
-
entry = {
|
|
75
|
-
phase,
|
|
76
|
-
phase_name: phaseName,
|
|
77
|
-
status,
|
|
78
|
-
};
|
|
79
|
-
state.phase_history.push(entry);
|
|
80
|
-
}
|
|
99
|
+
// Capture the before-image so the caller can echo a before/after
|
|
100
|
+
// comparison. #519: the same values feed the append-only audit log.
|
|
101
|
+
const beforePhase = state.phase;
|
|
102
|
+
const beforeEntry = Array.isArray(state.phase_history)
|
|
103
|
+
? state.phase_history.find(e => e.phase === phase)
|
|
104
|
+
: undefined;
|
|
105
|
+
const beforeStatus = beforeEntry ? beforeEntry.status : undefined;
|
|
81
106
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
entry.started_at = new Date().toISOString();
|
|
86
|
-
}
|
|
87
|
-
if (status === 'completed' || status === 'skipped') {
|
|
88
|
-
entry.completed_at = new Date().toISOString();
|
|
89
|
-
if (entry.started_at) {
|
|
90
|
-
const startMs = new Date(entry.started_at).getTime();
|
|
91
|
-
const endMs = new Date(entry.completed_at).getTime();
|
|
92
|
-
entry.duration_seconds = Math.round((endMs - startMs) / 1000);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
107
|
+
state.phase = phase;
|
|
108
|
+
const phaseName = PHASE_NAMES[phase - 1];
|
|
109
|
+
applyHistoryEntry(state, phase, phaseName, status);
|
|
95
110
|
|
|
96
|
-
// Update outputs if provided
|
|
97
111
|
if (options.outputs) {
|
|
98
112
|
state.outputs = { ...state.outputs, ...options.outputs };
|
|
99
113
|
}
|
|
100
114
|
|
|
101
|
-
// Write state atomically
|
|
102
115
|
this.write(state);
|
|
103
116
|
|
|
117
|
+
// #519: append-only transition audit. The #507 incident proved that a
|
|
118
|
+
// MISSED transition is invisible in the state file — the pointer drift
|
|
119
|
+
// looked identical to a legitimately-not-yet-run phase. A per-transition
|
|
120
|
+
// record — from/to plus statuses — lets post-mortems distinguish "never
|
|
121
|
+
// ran" from "ran and was overwritten". Best-effort: a logging failure must
|
|
122
|
+
// never break the transition, same contract as the onTransition callback.
|
|
123
|
+
this.appendTransitionAudit({
|
|
124
|
+
phase,
|
|
125
|
+
phase_name: phaseName,
|
|
126
|
+
from_phase: beforePhase,
|
|
127
|
+
// from_status === undefined together with a defined to_status is exactly
|
|
128
|
+
// what proves the phase was reached from nothing — i.e. never ran.
|
|
129
|
+
from_status: beforeStatus,
|
|
130
|
+
to_status: status,
|
|
131
|
+
timestamp: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
|
|
104
134
|
// Trigger onTransition callback (for auto-render)
|
|
105
135
|
if (typeof options.onTransition === 'function') {
|
|
106
136
|
try {
|
|
@@ -114,6 +144,23 @@ class SprintStateManager {
|
|
|
114
144
|
return state;
|
|
115
145
|
}
|
|
116
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Append one JSON line to .sprint-state/transition-audit.jsonl (#519).
|
|
149
|
+
* Never throws — audit is evidence, not a gate.
|
|
150
|
+
* @param {object} record
|
|
151
|
+
*/
|
|
152
|
+
appendTransitionAudit(record) {
|
|
153
|
+
try {
|
|
154
|
+
if (!fs.existsSync(this.stateDir)) {
|
|
155
|
+
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
const auditFile = path.join(this.stateDir, 'transition-audit.jsonl');
|
|
158
|
+
fs.appendFileSync(auditFile, JSON.stringify(record) + '\n', 'utf8');
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.warn(`[WARN] transition audit write failed: ${err.message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
117
164
|
rollback() {
|
|
118
165
|
return rollbackState(this.stateFile, this.backupFile);
|
|
119
166
|
}
|