@boyingliu01/xp-gate 0.14.8 → 0.14.10
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/bin/xp-gate.js +23 -2
- package/lib/__tests__/install-skill.test.js +2 -1
- package/lib/__tests__/sprint-discovery.test.ts +6 -4
- package/lib/__tests__/sprint-state-manager.test.js +279 -0
- package/lib/__tests__/sprint-status.test.js +10 -13
- package/lib/__tests__/sprint-status.test.ts +4 -3
- package/lib/__tests__/update-hooks.test.js +12 -0
- package/lib/__tests__/upgrade-skill-sync.test.js +65 -0
- package/lib/check.js +115 -20
- package/lib/doctor.js +63 -0
- package/lib/gate-runner.js +209 -0
- package/lib/install-skill.js +14 -1
- package/lib/next-sprint.js +3 -4
- package/lib/shared-utils.js +1 -1
- package/lib/sprint-discovery.js +4 -4
- package/lib/sprint-state-manager.js +122 -0
- package/lib/sprint-state-migrator.js +153 -0
- package/lib/sprint-status.js +7 -5
- package/lib/update-hooks.js +1 -1
- package/lib/upgrade.js +8 -0
- package/mock-policy/AGENTS.md +4 -4
- package/mutation/AGENTS.md +4 -4
- package/mutation/__tests__/init-baseline.test.ts +347 -0
- package/mutation/__tests__/mutmut-runner.test.ts +561 -0
- package/mutation/__tests__/runners-index.test.ts +134 -0
- package/mutation/__tests__/stryker-runner.test.ts +455 -0
- package/mutation/__tests__/update-baseline.test.ts +393 -0
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +34 -8
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +34 -8
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +4 -4
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/sprint-flow/SKILL.md +1 -1
- package/skills/sprint-flow/references/phase-2-design.md +34 -8
- package/skills/test-specification-alignment/AGENTS.md +4 -4
package/lib/check.js
CHANGED
|
@@ -2,49 +2,144 @@
|
|
|
2
2
|
|
|
3
3
|
const { principles } = require('./principles.js');
|
|
4
4
|
const { arch } = require('./arch.js');
|
|
5
|
+
const { getGateInfo, runGate } = require('./gate-runner.js');
|
|
6
|
+
|
|
7
|
+
function parseCheckArgs(args) {
|
|
8
|
+
const flags = { all: false, list: false, gateIds: null };
|
|
9
|
+
const posArgs = [];
|
|
10
|
+
let i = 0;
|
|
11
|
+
while (i < args.length) {
|
|
12
|
+
const arg = args[i];
|
|
13
|
+
if (arg === '--all') { flags.all = true; i++; }
|
|
14
|
+
else if (arg === '--list') { flags.list = true; i++; }
|
|
15
|
+
else if (arg === '--gates' && i + 1 < args.length) {
|
|
16
|
+
flags.gateIds = args[i + 1].split(',').map(s => s.trim()).filter(Boolean);
|
|
17
|
+
i += 2;
|
|
18
|
+
}
|
|
19
|
+
else if (arg.startsWith('--gates=')) {
|
|
20
|
+
flags.gateIds = arg.split('=')[1].split(',').map(s => s.trim()).filter(Boolean);
|
|
21
|
+
i++;
|
|
22
|
+
}
|
|
23
|
+
else { posArgs.push(arg); i++; }
|
|
24
|
+
}
|
|
25
|
+
const rawTarget = posArgs[0] || null;
|
|
26
|
+
const isGateNumber = rawTarget && /^\d+$/.test(rawTarget);
|
|
27
|
+
return {
|
|
28
|
+
...flags,
|
|
29
|
+
positionals: posArgs,
|
|
30
|
+
pathArg: isGateNumber ? posArgs[1] || null : rawTarget,
|
|
31
|
+
gateNumber: isGateNumber ? rawTarget : null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
5
34
|
|
|
6
|
-
// Runs the user-invokable subset of quality gates on a path. Mirrors what the
|
|
7
|
-
// pre-commit hook does, but as a standalone CLI surface. Intentionally narrower
|
|
8
|
-
// than the full pre-commit run (no git staging context, no Boy Scout baseline).
|
|
9
|
-
// Currently delegates to: Gate 4 (Principles) + Gate 6 (Architecture).
|
|
10
35
|
async function check(args) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
36
|
+
const opts = parseCheckArgs(args);
|
|
37
|
+
|
|
38
|
+
if (opts.list) return listGates();
|
|
39
|
+
if (opts.gateNumber && !opts.all && !opts.gateIds) return runGate(opts.gateNumber, opts.pathArg);
|
|
40
|
+
if (opts.all) return checkAllGates(opts.pathArg);
|
|
41
|
+
if (opts.gateIds && opts.gateIds.length > 0) return runGatesByIds(opts.gateIds, opts.pathArg);
|
|
42
|
+
|
|
43
|
+
if (!opts.pathArg) {
|
|
44
|
+
console.error('Usage: xp-gate check <file_or_directory|gate_number> [options]');
|
|
45
|
+
console.error('');
|
|
46
|
+
console.error('Options:');
|
|
47
|
+
console.error(' --gates <ids> Run specific gates (comma-separated, e.g. "3,4,6")');
|
|
48
|
+
console.error(' --all Run all invokable gates');
|
|
49
|
+
console.error(' --list List all available gates');
|
|
50
|
+
console.error('');
|
|
51
|
+
console.error('Examples:');
|
|
52
|
+
console.error(' xp-gate check src/ # Run principles + architecture check');
|
|
53
|
+
console.error(' xp-gate check 3 # Run Gate 3 (Complexity)');
|
|
54
|
+
console.error(' xp-gate check --gates 3,4,6 # Run gates 3, 4, and 6');
|
|
55
|
+
console.error(' xp-gate check --all # Run all invokable gates');
|
|
56
|
+
console.error(' xp-gate check --list # List available gates');
|
|
14
57
|
return 1;
|
|
15
58
|
}
|
|
16
59
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
60
|
+
return runDefaultChecks(opts.pathArg);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runDefaultChecks(target) {
|
|
64
|
+
let failures = 0;
|
|
65
|
+
let ran = 0;
|
|
66
|
+
|
|
67
|
+
console.log('━━━ Gate 4: Principles ━━━');
|
|
68
|
+
const principlesCode = await principles([target]);
|
|
69
|
+
if (principlesCode !== 0) failures += 1;
|
|
70
|
+
ran += 1;
|
|
71
|
+
|
|
72
|
+
console.log('━━━ Gate 6: Architecture ━━━');
|
|
73
|
+
const archCode = await arch([]);
|
|
74
|
+
if (archCode !== 0) failures += 1;
|
|
75
|
+
ran += 1;
|
|
76
|
+
|
|
77
|
+
console.log('');
|
|
78
|
+
console.log(`[xp-gate check] ${ran - failures}/${ran} gate(s) passed`);
|
|
79
|
+
return failures === 0 ? 0 : 1;
|
|
80
|
+
}
|
|
21
81
|
|
|
82
|
+
async function checkAllGates(target) {
|
|
83
|
+
const { getAllGates } = require('./gate-runner.js');
|
|
84
|
+
const invokableGates = getAllGates().filter(g => !g.preCommitOnly);
|
|
22
85
|
let failures = 0;
|
|
23
86
|
let ran = 0;
|
|
24
87
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const code = await principles([target]);
|
|
88
|
+
for (const gate of invokableGates) {
|
|
89
|
+
const code = await runGate(gate.id, target);
|
|
28
90
|
if (code !== 0) failures += 1;
|
|
29
91
|
ran += 1;
|
|
92
|
+
console.log('');
|
|
30
93
|
}
|
|
31
94
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
95
|
+
console.log(`[xp-gate check --all] ${ran - failures}/${ran} gate(s) passed`);
|
|
96
|
+
return failures === 0 ? 0 : 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runGatesByIds(gateIds, target) {
|
|
100
|
+
let failures = 0;
|
|
101
|
+
let ran = 0;
|
|
102
|
+
|
|
103
|
+
for (const gateId of gateIds) {
|
|
104
|
+
const code = await runGate(gateId, target);
|
|
35
105
|
if (code !== 0) failures += 1;
|
|
36
106
|
ran += 1;
|
|
107
|
+
console.log('');
|
|
37
108
|
}
|
|
38
109
|
|
|
39
110
|
if (ran === 0) {
|
|
40
|
-
console.error(`[xp-gate check] No valid gates in --gates list: ${
|
|
41
|
-
console.error('
|
|
111
|
+
console.error(`[xp-gate check] No valid gates in --gates list: ${gateIds.join(',')}`);
|
|
112
|
+
console.error(' Run: xp-gate check --list to see available gates');
|
|
42
113
|
return 1;
|
|
43
114
|
}
|
|
44
115
|
|
|
45
|
-
console.log('');
|
|
46
116
|
console.log(`[xp-gate check] ${ran - failures}/${ran} gate(s) passed`);
|
|
47
117
|
return failures === 0 ? 0 : 1;
|
|
48
118
|
}
|
|
49
119
|
|
|
120
|
+
function listGates() {
|
|
121
|
+
const { getAllGates } = require('./gate-runner.js');
|
|
122
|
+
const allGates = getAllGates();
|
|
123
|
+
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log('XP-Gate Quality Gates:');
|
|
126
|
+
console.log('┌──────┬──────────────────────────────┬──────────┬──────────────────────────────────────────┐');
|
|
127
|
+
console.log('│ Gate │ Name │ CLI │ Description │');
|
|
128
|
+
console.log('├──────┼──────────────────────────────┼──────────┼──────────────────────────────────────────┤');
|
|
129
|
+
for (const gate of allGates) {
|
|
130
|
+
const id = gate.id.padEnd(4);
|
|
131
|
+
const name = gate.name.slice(0, 28).padEnd(28);
|
|
132
|
+
const cli = gate.preCommitOnly ? 'commit' : 'check';
|
|
133
|
+
const desc = gate.description.slice(0, 40).padEnd(40);
|
|
134
|
+
console.log(`│ ${id} │ ${name} │ ${cli.padEnd(8)} │ ${desc} │`);
|
|
135
|
+
}
|
|
136
|
+
console.log('└──────┴──────────────────────────────┴──────────┴──────────────────────────────────────────┘');
|
|
137
|
+
console.log('');
|
|
138
|
+
console.log('Legend:');
|
|
139
|
+
console.log(' check — invokable via xp-gate check <gate-id>');
|
|
140
|
+
console.log(' commit — only runs during git commit (pre-commit hook)');
|
|
141
|
+
console.log('');
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
50
145
|
module.exports = { check };
|
package/lib/doctor.js
CHANGED
|
@@ -263,6 +263,9 @@ function diagnose() {
|
|
|
263
263
|
// --- Check 8: TUI auto-registration ---
|
|
264
264
|
issues += diagnoseTuiRegistration(checks);
|
|
265
265
|
|
|
266
|
+
// --- Check 9: Installed skills version consistency (#332) ---
|
|
267
|
+
issues += diagnoseInstalledSkills(config, checks);
|
|
268
|
+
|
|
266
269
|
return { checks, issues };
|
|
267
270
|
}
|
|
268
271
|
|
|
@@ -689,6 +692,66 @@ function diagnoseTuiRegistration(checks) {
|
|
|
689
692
|
return 1;
|
|
690
693
|
}
|
|
691
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Check 9: Installed skills vs package-bundled skills (#332).
|
|
697
|
+
* Reports WARN for each installed skill whose SKILL.md differs from the bundled version.
|
|
698
|
+
* @param {object} config
|
|
699
|
+
* @param {Array} checks
|
|
700
|
+
* @returns {number} issue count
|
|
701
|
+
*/
|
|
702
|
+
function diagnoseInstalledSkills(config, checks) {
|
|
703
|
+
const installedSkills = config.installedSkills || {};
|
|
704
|
+
const skillNames = Object.keys(installedSkills);
|
|
705
|
+
if (skillNames.length === 0) {
|
|
706
|
+
checks.push({ name: 'Installed skills', status: 'SKIP', detail: 'No skills installed' });
|
|
707
|
+
return 0;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const bundledSkillsDir = path.join(PKG_DIR, 'skills');
|
|
711
|
+
if (!fs.existsSync(bundledSkillsDir)) {
|
|
712
|
+
checks.push({ name: 'Installed skills', status: 'SKIP', detail: 'Package skills dir not found' });
|
|
713
|
+
return 0;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const platform = detectPlatform();
|
|
717
|
+
let userSkillsDir;
|
|
718
|
+
if (platform === 'qoder') {
|
|
719
|
+
userSkillsDir = path.join(HOME_DIR, '.qoder', 'skills');
|
|
720
|
+
} else if (platform === 'claude-code') {
|
|
721
|
+
userSkillsDir = path.join(HOME_DIR, '.claude', 'skills');
|
|
722
|
+
} else {
|
|
723
|
+
userSkillsDir = path.join(HOME_DIR, '.config', 'opencode', 'skills');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
let issues = 0;
|
|
727
|
+
for (const name of skillNames) {
|
|
728
|
+
const bundledSkillMd = path.join(bundledSkillsDir, name, 'SKILL.md');
|
|
729
|
+
const userSkillMd = path.join(userSkillsDir, name, 'SKILL.md');
|
|
730
|
+
|
|
731
|
+
if (!fs.existsSync(bundledSkillMd)) continue;
|
|
732
|
+
if (!fs.existsSync(userSkillMd)) {
|
|
733
|
+
checks.push({ name: `Skill: ${name}`, status: 'FAIL', detail: 'Installed SKILL.md missing' });
|
|
734
|
+
issues++;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const bundledContent = fs.readFileSync(bundledSkillMd, 'utf8');
|
|
739
|
+
const userContent = fs.readFileSync(userSkillMd, 'utf8');
|
|
740
|
+
if (bundledContent !== userContent) {
|
|
741
|
+
checks.push({
|
|
742
|
+
name: `Skill: ${name}`,
|
|
743
|
+
status: 'WARN',
|
|
744
|
+
detail: 'Outdated — run xp-gate update-skill --all',
|
|
745
|
+
});
|
|
746
|
+
issues++;
|
|
747
|
+
} else {
|
|
748
|
+
checks.push({ name: `Skill: ${name}`, status: 'PASS', detail: 'Up to date' });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return issues;
|
|
753
|
+
}
|
|
754
|
+
|
|
692
755
|
/**
|
|
693
756
|
* Fix TUI registration: ensure @boyingliu01/opencode-plugin/tui is in tui.json.
|
|
694
757
|
* Uses atomic write (tmp + renameSync) for JSON safety.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync, execFileSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// Gate metadata registry — maps gate IDs to names, descriptions, and how to run them.
|
|
8
|
+
// Standalone gates (<gate-id>: { run }) are invokable via xp-gate gate-<id>.
|
|
9
|
+
// Pre-commit-only gates (<gate-id>: { preCommitOnly: true }) run only in git commit context.
|
|
10
|
+
const GATE_REGISTRY = {
|
|
11
|
+
'0': {
|
|
12
|
+
name: 'Version Consistency',
|
|
13
|
+
description: 'Check VERSION file matches all package.json versions',
|
|
14
|
+
preCommitOnly: true,
|
|
15
|
+
reason: 'Requires git-staged context to compare VERSION with package.json files',
|
|
16
|
+
},
|
|
17
|
+
'1': {
|
|
18
|
+
name: 'Code Quality',
|
|
19
|
+
description: 'Language-specific static analysis and linting (ESLint, Ruff, govet, etc.)',
|
|
20
|
+
preCommitOnly: true,
|
|
21
|
+
reason: 'Requires language detection from changed files and adapter routing',
|
|
22
|
+
},
|
|
23
|
+
'2': {
|
|
24
|
+
name: 'Duplicate Code',
|
|
25
|
+
description: 'jscpd duplicate code detection',
|
|
26
|
+
run: (targetPath) => {
|
|
27
|
+
const jscpdConfig = findConfig(targetPath, 'jscpd.conf.json');
|
|
28
|
+
const args = [targetPath || '.'];
|
|
29
|
+
if (jscpdConfig) args.push('--config', jscpdConfig);
|
|
30
|
+
execFileSync('npx', ['-y', 'jscpd', ...args], { stdio: 'inherit' });
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
'3': {
|
|
34
|
+
name: 'Cyclomatic Complexity',
|
|
35
|
+
description: 'lizard cyclomatic complexity analysis',
|
|
36
|
+
run: (targetPath) => {
|
|
37
|
+
const script = resolveGateScript('3');
|
|
38
|
+
if (script) {
|
|
39
|
+
execSync(`bash "${script}"`, { stdio: 'inherit' });
|
|
40
|
+
} else {
|
|
41
|
+
const target = targetPath || '.';
|
|
42
|
+
console.log(`Running lizard on ${target}...`);
|
|
43
|
+
execFileSync('lizard', [target, '--CCN', '10', '--length', '50', '--arguments', '5', '--warnings_only'], { stdio: 'inherit' });
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
'4': {
|
|
48
|
+
name: 'Clean Code + SOLID Principles',
|
|
49
|
+
description: '14 Clean Code/SOLID rules across 9 languages',
|
|
50
|
+
run: (targetPath) => {
|
|
51
|
+
const { principles } = require('./principles.js');
|
|
52
|
+
return principles([targetPath || '.']);
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
'5': {
|
|
56
|
+
name: 'Tests + Coverage',
|
|
57
|
+
description: 'Unit test execution and code coverage enforcement (≥80%)',
|
|
58
|
+
preCommitOnly: true,
|
|
59
|
+
reason: 'Requires language detection and adapter-sourced test execution with coverage reports',
|
|
60
|
+
},
|
|
61
|
+
'6': {
|
|
62
|
+
name: 'Architecture + Boy Scout Rule',
|
|
63
|
+
description: 'Architecture layer boundary validation and warning baseline enforcement',
|
|
64
|
+
run: (targetPath) => {
|
|
65
|
+
const { arch } = require('./arch.js');
|
|
66
|
+
return arch([]);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
'7': {
|
|
70
|
+
name: 'IaC Security',
|
|
71
|
+
description: 'Infrastructure-as-Code security scanning (checkov, hadolint, kube-score, tflint)',
|
|
72
|
+
run: (targetPath) => {
|
|
73
|
+
const script = resolveGateScript('7');
|
|
74
|
+
if (script) {
|
|
75
|
+
execSync(`bash "${script}"`, { stdio: 'inherit' });
|
|
76
|
+
} else {
|
|
77
|
+
console.log('IaC security scan requires git-staged context for changed files detection.');
|
|
78
|
+
console.log('Run: git commit to trigger Gate 7 automatically.');
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
'8': {
|
|
83
|
+
name: 'Secret Scanning',
|
|
84
|
+
description: 'gitleaks secret and credential detection',
|
|
85
|
+
run: (targetPath) => {
|
|
86
|
+
const script = resolveGateScript('8');
|
|
87
|
+
if (script) {
|
|
88
|
+
execSync(`bash "${script}"`, { stdio: 'inherit' });
|
|
89
|
+
} else {
|
|
90
|
+
console.log('Secret scanning requires git-staged context for changed files detection.');
|
|
91
|
+
console.log('Run: git commit to trigger Gate 8 automatically.');
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
'9': {
|
|
96
|
+
name: 'SAST Security',
|
|
97
|
+
description: 'semgrep static application security testing',
|
|
98
|
+
run: (targetPath) => {
|
|
99
|
+
const script = resolveGateScript('9');
|
|
100
|
+
if (script) {
|
|
101
|
+
execSync(`bash "${script}"`, { stdio: 'inherit' });
|
|
102
|
+
} else {
|
|
103
|
+
console.log('SAST scanning requires git-staged context for changed files detection.');
|
|
104
|
+
console.log('Run: git commit to trigger Gate 9 automatically.');
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
'10': {
|
|
109
|
+
name: 'Build Integrity',
|
|
110
|
+
description: 'TypeScript compilation, npm pack, and import validation',
|
|
111
|
+
preCommitOnly: true,
|
|
112
|
+
reason: 'Build integrity checks require language detection and build tool context',
|
|
113
|
+
},
|
|
114
|
+
'11': {
|
|
115
|
+
name: 'Sprint Flow Enforcement',
|
|
116
|
+
description: 'Sprint state and delphi-review validation',
|
|
117
|
+
preCommitOnly: true,
|
|
118
|
+
reason: 'Requires sprint state (.sprint-state/) and git branch context',
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function findConfig(basePath, fileName) {
|
|
123
|
+
const candidates = [
|
|
124
|
+
path.join(basePath || '.', fileName),
|
|
125
|
+
path.join(process.cwd(), fileName),
|
|
126
|
+
];
|
|
127
|
+
for (const c of candidates) {
|
|
128
|
+
if (fs.existsSync(c)) return c;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveGateScript(gateNum) {
|
|
134
|
+
const candidates = [
|
|
135
|
+
path.join(process.cwd(), 'githooks', 'gates', `gate-${gateNum}-*.sh`),
|
|
136
|
+
path.join(process.cwd(), 'githooks', `gate-${gateNum}.sh`),
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
// Try glob patterns
|
|
140
|
+
for (const pattern of candidates) {
|
|
141
|
+
if (pattern.includes('*')) {
|
|
142
|
+
const dir = path.dirname(pattern);
|
|
143
|
+
const prefix = path.basename(pattern).replace('*', '');
|
|
144
|
+
if (fs.existsSync(dir)) {
|
|
145
|
+
const match = fs.readdirSync(dir).find(f => f.startsWith(`gate-${gateNum}-`) && f.endsWith('.sh'));
|
|
146
|
+
if (match) return path.join(dir, match);
|
|
147
|
+
}
|
|
148
|
+
} else if (fs.existsSync(pattern)) {
|
|
149
|
+
return pattern;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check global install
|
|
154
|
+
const globalDir = path.join(require('os').homedir(), '.config', 'xp-gate', 'adapters');
|
|
155
|
+
const globalScript = path.join(globalDir, `gate-${gateNum}.sh`);
|
|
156
|
+
if (fs.existsSync(globalScript)) return globalScript;
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getGateInfo(gateId) {
|
|
162
|
+
return GATE_REGISTRY[String(gateId)] || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function getAllGates() {
|
|
166
|
+
return Object.entries(GATE_REGISTRY).map(([id, info]) => ({
|
|
167
|
+
id,
|
|
168
|
+
name: info.name,
|
|
169
|
+
description: info.description,
|
|
170
|
+
preCommitOnly: info.preCommitOnly || false,
|
|
171
|
+
reason: info.reason || null,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function runGate(gateId, targetPath) {
|
|
176
|
+
const gate = GATE_REGISTRY[String(gateId)];
|
|
177
|
+
if (!gate) {
|
|
178
|
+
console.error(`Unknown gate: ${gateId}`);
|
|
179
|
+
console.error('Available gates: ' + Object.keys(GATE_REGISTRY).join(', '));
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (gate.preCommitOnly) {
|
|
184
|
+
console.log(`━━━ Gate ${gateId}: ${gate.name} ━━━`);
|
|
185
|
+
console.log(`⏭️ SKIPPED - ${gate.reason}`);
|
|
186
|
+
console.log(' This gate runs automatically during git commit.');
|
|
187
|
+
console.log(' Run: git commit to trigger all gates.');
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (gate.run) {
|
|
192
|
+
console.log(`━━━ Gate ${gateId}: ${gate.name} ━━━`);
|
|
193
|
+
try {
|
|
194
|
+
const result = gate.run(targetPath);
|
|
195
|
+
if (result && typeof result.then === 'function') {
|
|
196
|
+
return await result;
|
|
197
|
+
}
|
|
198
|
+
return 0;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.error(` Gate ${gateId} FAILED: ${err.message}`);
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.error(`Gate ${gateId}: ${gate.name} — no execution handler available`);
|
|
206
|
+
return 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { GATE_REGISTRY, getGateInfo, getAllGates, runGate };
|
package/lib/install-skill.js
CHANGED
|
@@ -20,6 +20,15 @@ function getSkillsDir() {
|
|
|
20
20
|
return path.join(HOME_DIR, '.config', 'opencode', 'skills');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
function getCliVersion() {
|
|
24
|
+
try {
|
|
25
|
+
const versionFile = path.join(__dirname, '..', '..', '..', 'VERSION');
|
|
26
|
+
return fs.readFileSync(versionFile, 'utf8').trim();
|
|
27
|
+
} catch {
|
|
28
|
+
return '0.0.0';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
const SKILLS_REGISTRY = {
|
|
24
33
|
'sprint-flow': { repo: 'boyingliu01/xp-gate', path: 'skills/sprint-flow' },
|
|
25
34
|
'delphi-review': { repo: 'boyingliu01/xp-gate', path: 'skills/delphi-review' },
|
|
@@ -123,10 +132,14 @@ async function performInstall(skillInfo, name, targetDir, offline, verbose) {
|
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
ensureConfigDir();
|
|
135
|
+
|
|
136
|
+
// Read actual CLI version from VERSION file
|
|
137
|
+
const version = getCliVersion();
|
|
138
|
+
|
|
126
139
|
updateConfig({
|
|
127
140
|
installedSkills: {
|
|
128
141
|
...(getConfig().installedSkills || {}),
|
|
129
|
-
[name]: { version
|
|
142
|
+
[name]: { version, installedAt: new Date().toISOString() }
|
|
130
143
|
}
|
|
131
144
|
});
|
|
132
145
|
|
package/lib/next-sprint.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
const childProcess = require('child_process');
|
|
2
|
-
const fs = require('fs');
|
|
3
2
|
const path = require('path');
|
|
3
|
+
const { SprintStateManager } = require('./sprint-state-manager');
|
|
4
4
|
|
|
5
5
|
function readSprintState(dir) {
|
|
6
6
|
try {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
7
|
+
const manager = new SprintStateManager(dir);
|
|
8
|
+
return manager.read();
|
|
10
9
|
} catch {
|
|
11
10
|
return null;
|
|
12
11
|
}
|
package/lib/shared-utils.js
CHANGED
|
@@ -60,7 +60,7 @@ function copyAdapters(srcDir, destDir) {
|
|
|
60
60
|
const githooksDir = path.resolve(srcDir, '..', '..', '..', 'githooks');
|
|
61
61
|
if (fs.existsSync(githooksDir)) {
|
|
62
62
|
fs.readdirSync(githooksDir).forEach(f => {
|
|
63
|
-
if (f.startsWith('gate-') && f.endsWith('.sh')) {
|
|
63
|
+
if ((f.startsWith('gate-') || f === 'sprint-gate.sh') && f.endsWith('.sh')) {
|
|
64
64
|
fs.copyFileSync(path.join(githooksDir, f), path.join(destDir, f));
|
|
65
65
|
}
|
|
66
66
|
});
|
package/lib/sprint-discovery.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
const fs = require('fs');
|
|
15
15
|
const path = require('path');
|
|
16
|
+
const { SprintStateManager } = require('./sprint-state-manager');
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Maximum number of active sprints to return.
|
|
@@ -62,16 +63,15 @@ function findGitRoot(startDir) {
|
|
|
62
63
|
|
|
63
64
|
/**
|
|
64
65
|
* Read and parse a sprint-state.json from a given project directory.
|
|
66
|
+
* Uses SprintStateManager for schema validation + auto-migration.
|
|
65
67
|
*
|
|
66
68
|
* @param {string} dir - Directory containing .sprint-state/
|
|
67
69
|
* @returns {object|null} Parsed sprint state, or null if not found or malformed
|
|
68
70
|
*/
|
|
69
71
|
function readSprintState(dir) {
|
|
70
72
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const raw = fs.readFileSync(stateFile, 'utf8');
|
|
74
|
-
return JSON.parse(raw);
|
|
73
|
+
const manager = new SprintStateManager(dir);
|
|
74
|
+
return manager.read();
|
|
75
75
|
} catch {
|
|
76
76
|
return null;
|
|
77
77
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprint State Manager — centralized read/write/transition for sprint state.
|
|
3
|
+
*
|
|
4
|
+
* Resolves #343 (schema drift) and #338 (auto-render enforcement).
|
|
5
|
+
*
|
|
6
|
+
* @module sprint-state-manager
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { migrateState, createInitialState, rollbackState, LEGACY_PHASE_MAP } = require('./sprint-state-migrator');
|
|
14
|
+
|
|
15
|
+
class SprintStateManager {
|
|
16
|
+
constructor(projectRoot) {
|
|
17
|
+
this.projectRoot = projectRoot;
|
|
18
|
+
this.stateDir = path.join(projectRoot, '.sprint-state');
|
|
19
|
+
this.stateFile = path.join(this.stateDir, 'sprint-state.json');
|
|
20
|
+
this.backupFile = path.join(this.stateDir, 'sprint-state.json.backup');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
read() {
|
|
24
|
+
if (!fs.existsSync(this.stateFile)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const raw = fs.readFileSync(this.stateFile, 'utf8');
|
|
29
|
+
let state;
|
|
30
|
+
try {
|
|
31
|
+
state = JSON.parse(raw);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw new Error(`Failed to parse sprint-state.json: ${err.message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check if migration needed
|
|
37
|
+
if (!state._schema_version) {
|
|
38
|
+
// Create backup before first migration
|
|
39
|
+
if (!fs.existsSync(this.backupFile)) {
|
|
40
|
+
fs.copyFileSync(this.stateFile, this.backupFile);
|
|
41
|
+
}
|
|
42
|
+
state = migrateState(state, this.stateDir);
|
|
43
|
+
// Write migrated state atomically
|
|
44
|
+
this.write(state);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return state;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
write(state) {
|
|
51
|
+
// Ensure state directory exists
|
|
52
|
+
if (!fs.existsSync(this.stateDir)) {
|
|
53
|
+
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
54
|
+
}
|
|
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
|
+
}
|
|
61
|
+
|
|
62
|
+
transitionPhase(phase, status, options = {}) {
|
|
63
|
+
const state = this.read() || createInitialState(this.projectRoot);
|
|
64
|
+
|
|
65
|
+
// Update current phase
|
|
66
|
+
state.phase = phase;
|
|
67
|
+
|
|
68
|
+
// Find or create phase_history entry
|
|
69
|
+
const phaseNames = ['PREP', 'DESIGN', 'BUILD', 'VERIFY', 'SHIP', 'CLOSE'];
|
|
70
|
+
const phaseName = phaseNames[phase - 1];
|
|
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
|
+
}
|
|
81
|
+
|
|
82
|
+
// Update entry
|
|
83
|
+
entry.status = status;
|
|
84
|
+
if (status === 'in_progress' && !entry.started_at) {
|
|
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
|
+
}
|
|
95
|
+
|
|
96
|
+
// Update outputs if provided
|
|
97
|
+
if (options.outputs) {
|
|
98
|
+
state.outputs = { ...state.outputs, ...options.outputs };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Write state atomically
|
|
102
|
+
this.write(state);
|
|
103
|
+
|
|
104
|
+
// Trigger onTransition callback (for auto-render)
|
|
105
|
+
if (typeof options.onTransition === 'function') {
|
|
106
|
+
try {
|
|
107
|
+
options.onTransition(state);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// Render failure is WARNING, not BLOCK
|
|
110
|
+
console.warn(`[WARN] onTransition callback failed: ${err.message}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return state;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
rollback() {
|
|
118
|
+
return rollbackState(this.stateFile, this.backupFile);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { SprintStateManager, LEGACY_PHASE_MAP };
|