@boyingliu01/xp-gate 0.8.16 → 0.8.17
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 +8 -0
- package/lib/__tests__/check-version.test.js +27 -35
- package/lib/__tests__/sprint-status.test.js +196 -0
- package/lib/__tests__/sprint-status.test.ts +220 -0
- package/lib/check-version.js +1 -0
- package/lib/sprint-status.js +295 -0
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/index.ts +84 -15
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js
CHANGED
|
@@ -121,6 +121,14 @@ const COMMANDS = {
|
|
|
121
121
|
description: 'Check for xp-gate updates (--preview for JSON, --apply to upgrade)',
|
|
122
122
|
run: subargs => upgrade(subargs).then(code => process.exit(code)),
|
|
123
123
|
usage: 'xp-gate upgrade [--preview] [--apply]'
|
|
124
|
+
},
|
|
125
|
+
'sprint-status': {
|
|
126
|
+
description: 'Show Sprint Flow progress (reads .sprint-state/sprint-state.json)',
|
|
127
|
+
run: subargs => {
|
|
128
|
+
const { handleSprintStatus } = require('../lib/sprint-status.js');
|
|
129
|
+
handleSprintStatus(subargs).then(code => process.exit(code));
|
|
130
|
+
},
|
|
131
|
+
usage: 'xp-gate sprint-status [--json] [--watch] [--dir <path>]'
|
|
124
132
|
}
|
|
125
133
|
};
|
|
126
134
|
|
|
@@ -74,8 +74,9 @@ describe('check-version.js — REQ-001-01', () => {
|
|
|
74
74
|
// ──────────────────────────────────────────
|
|
75
75
|
describe('getLocalVersion() — AC-001-01', () => {
|
|
76
76
|
it('returns version from package.json', () => {
|
|
77
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8'));
|
|
77
78
|
mod = require('../check-version');
|
|
78
|
-
expect(mod.getLocalVersion()).toBe(
|
|
79
|
+
expect(mod.getLocalVersion()).toBe(pkg.version);
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
it('returns null when read fails', () => {
|
|
@@ -104,13 +105,14 @@ describe('check-version.js — REQ-001-01', () => {
|
|
|
104
105
|
beforeEach(() => { mod = require('../check-version'); });
|
|
105
106
|
|
|
106
107
|
it('a < b → negative', () => {
|
|
107
|
-
expect(mod.compareVersions('0.8.
|
|
108
|
+
expect(mod.compareVersions('0.8.15', '0.8.17')).toBeLessThan(0);
|
|
108
109
|
});
|
|
109
110
|
it('a > b → positive', () => {
|
|
110
|
-
expect(mod.compareVersions('0.8.
|
|
111
|
+
expect(mod.compareVersions('0.8.17', '0.8.15')).toBeGreaterThan(0);
|
|
111
112
|
});
|
|
112
113
|
it('equal → 0', () => {
|
|
113
|
-
expect(mod.compareVersions('0.8.
|
|
114
|
+
expect(mod.compareVersions('0.8.17', '0.8.17')).toBe(0);
|
|
115
|
+
|
|
114
116
|
});
|
|
115
117
|
it('handles different segment counts', () => {
|
|
116
118
|
expect(mod.compareVersions('1.0', '1.0.1')).toBeLessThan(0);
|
|
@@ -121,34 +123,24 @@ describe('check-version.js — REQ-001-01', () => {
|
|
|
121
123
|
});
|
|
122
124
|
|
|
123
125
|
// ──────────────────────────────────────────
|
|
124
|
-
// AC-001-08: calcLagDays()
|
|
126
|
+
// AC-001-08: calcLagDays() — directly tested, no network/cache dependency
|
|
125
127
|
// ──────────────────────────────────────────
|
|
126
|
-
describe('calcLagDays()
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
expect(
|
|
128
|
+
describe('calcLagDays() — AC-001-08', () => {
|
|
129
|
+
beforeEach(() => { mod = require('../check-version'); });
|
|
130
|
+
|
|
131
|
+
it('returns 0 when no publishedAt', () => {
|
|
132
|
+
expect(mod.calcLagDays('')).toBe(0);
|
|
133
|
+
expect(mod.calcLagDays(null)).toBe(0);
|
|
134
|
+
expect(mod.calcLagDays(undefined)).toBe(0);
|
|
131
135
|
});
|
|
132
136
|
|
|
133
|
-
it('
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const mockRes = {
|
|
141
|
-
statusCode: 200,
|
|
142
|
-
on: (evt, handler) => { if (evt === 'end') handler(); return mockRes; },
|
|
143
|
-
};
|
|
144
|
-
callback(mockRes);
|
|
145
|
-
return { on: () => this, destroy: () => {} };
|
|
146
|
-
};
|
|
147
|
-
vi.resetModules();
|
|
148
|
-
mod = require('../check-version');
|
|
149
|
-
const r = await mod.checkUpgrade('@nonexistent/pkg-test-only');
|
|
150
|
-
expect(r.lagDays).toBe(0);
|
|
151
|
-
https.get = origGet;
|
|
137
|
+
it('returns 0 when publishedAt is unparseable', () => {
|
|
138
|
+
expect(mod.calcLagDays('not-a-date')).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('returns >= 0 for a past date', () => {
|
|
142
|
+
const lag = mod.calcLagDays('2026-06-15T00:00:00.000Z');
|
|
143
|
+
expect(lag).toBeGreaterThanOrEqual(0);
|
|
152
144
|
});
|
|
153
145
|
});
|
|
154
146
|
|
|
@@ -222,17 +214,17 @@ describe('check-version.js — REQ-001-01', () => {
|
|
|
222
214
|
|
|
223
215
|
it('returns safe defaults (outdated:false) without network', async () => {
|
|
224
216
|
// Mock https to return the SAME version as local → outdated=false, no network dependency
|
|
225
|
-
const result = await withMockedHttps('0.8.
|
|
217
|
+
const result = await withMockedHttps('0.8.17', async (m) => m.checkUpgrade('@nonexistent/pkg-test-only'));
|
|
226
218
|
expect(result.outdated).toBe(false);
|
|
227
|
-
expect(result.local).toBe('0.8.
|
|
228
|
-
expect(result.remote).toBe('0.8.
|
|
219
|
+
expect(result.local).toBe('0.8.17');
|
|
220
|
+
expect(result.remote).toBe('0.8.17');
|
|
229
221
|
expect(result.lagDays).toBe(0);
|
|
230
222
|
});
|
|
231
223
|
|
|
232
224
|
it('returns outdated=true when remote > local', async () => {
|
|
233
225
|
const result = await withMockedHttps('99.99.99', async (m) => m.checkUpgrade('@nonexistent/pkg-test-only'));
|
|
234
226
|
expect(result.outdated).toBe(true);
|
|
235
|
-
expect(result.local).toBe('0.8.
|
|
227
|
+
expect(result.local).toBe('0.8.17');
|
|
236
228
|
expect(result.remote).toBe('99.99.99');
|
|
237
229
|
});
|
|
238
230
|
});
|
|
@@ -254,7 +246,7 @@ describe('check-version.js — REQ-001-01', () => {
|
|
|
254
246
|
// ──────────────────────────────────────────
|
|
255
247
|
describe('formatUpgradeMsg() — up to date', () => {
|
|
256
248
|
beforeEach(() => { mod = require('../check-version'); });
|
|
257
|
-
const r = { outdated: false, local: '0.8.
|
|
249
|
+
const r = { outdated: false, local: '0.8.17', remote: '0.8.17', lagDays: 0 };
|
|
258
250
|
|
|
259
251
|
it('cli shows checkmark', () => {
|
|
260
252
|
expect(mod.formatUpgradeMsg(r, 'cli')).toContain('up to date');
|
|
@@ -272,7 +264,7 @@ describe('check-version.js — REQ-001-01', () => {
|
|
|
272
264
|
// ──────────────────────────────────────────
|
|
273
265
|
describe('formatUpgradeMsg() — outdated (AC-003-01/02/03)', () => {
|
|
274
266
|
beforeEach(() => { mod = require('../check-version'); });
|
|
275
|
-
const r = { outdated: true, local: '0.8.
|
|
267
|
+
const r = { outdated: true, local: '0.8.17', remote: '0.8.13', lagDays: 10 };
|
|
276
268
|
|
|
277
269
|
it('cli: full release link + upgrade cmd (AC-003-01)', () => {
|
|
278
270
|
const msg = mod.formatUpgradeMsg(r, 'cli');
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-SPRINTSTATUS-001 Sprint Status CLI
|
|
3
|
+
* @intent Validate xp-gate sprint-status command reads sprint-state.json and renders table
|
|
4
|
+
* @covers AC-SPRINTSTATUS-001-01 through AC-SPRINTSTATUS-001-09
|
|
5
|
+
*/
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
// Import the module
|
|
11
|
+
const sprintStatus = require('../sprint-status');
|
|
12
|
+
|
|
13
|
+
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-status-test-'));
|
|
14
|
+
const STATE_DIR = path.join(TMP_DIR, '.sprint-state');
|
|
15
|
+
const STATE_FILE = path.join(STATE_DIR, 'sprint-state.json');
|
|
16
|
+
|
|
17
|
+
// Active sprint state (canonical format per SKILL.md L893-942)
|
|
18
|
+
const ACTIVE_STATE = {
|
|
19
|
+
id: 'sprint-2026-06-16-01',
|
|
20
|
+
task_description: 'Test sprint',
|
|
21
|
+
phase: 2,
|
|
22
|
+
status: 'running',
|
|
23
|
+
started_at: '2026-06-16T10:00:00Z',
|
|
24
|
+
isolation: {
|
|
25
|
+
worktree_path: '.worktrees/sprint/sprint-2026-06-16-01',
|
|
26
|
+
branch: 'sprint/2026-06-16-01',
|
|
27
|
+
created_from: 'main',
|
|
28
|
+
created_from_commit: 'abc123'
|
|
29
|
+
},
|
|
30
|
+
auto_estimate: {
|
|
31
|
+
change_type: '新增功能',
|
|
32
|
+
estimated_level: '轻量',
|
|
33
|
+
user_decision: 'accepted'
|
|
34
|
+
},
|
|
35
|
+
phase_history: [
|
|
36
|
+
{ phase: -1, phase_name: 'ISOLATE', status: 'completed',
|
|
37
|
+
started_at: '2026-06-16T10:00:00Z', completed_at: '2026-06-16T10:03:00Z', duration_seconds: 180 },
|
|
38
|
+
{ phase: -0.5, phase_name: 'AUTO-ESTIMATE', status: 'completed',
|
|
39
|
+
started_at: '2026-06-16T10:03:00Z', completed_at: '2026-06-16T10:05:00Z', duration_seconds: 120 },
|
|
40
|
+
{ phase: 0, phase_name: 'THINK', status: 'completed',
|
|
41
|
+
started_at: '2026-06-16T10:05:00Z', completed_at: '2026-06-16T10:15:00Z', duration_seconds: 600 },
|
|
42
|
+
{ phase: 1, phase_name: 'PLAN', status: 'completed',
|
|
43
|
+
started_at: '2026-06-16T10:15:00Z', completed_at: '2026-06-16T10:25:00Z', duration_seconds: 600 },
|
|
44
|
+
{ phase: 2, phase_name: 'BUILD', status: 'in_progress',
|
|
45
|
+
started_at: '2026-06-16T10:25:00Z', completed_at: null, duration_seconds: null,
|
|
46
|
+
reqs: {
|
|
47
|
+
'REQ-001': { name: 'Feature A', status: 'completed' },
|
|
48
|
+
'REQ-002': { name: 'Feature B', status: 'in_progress' }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
outputs: { specification: '.sprint-state/phase-outputs/specification.yaml' },
|
|
53
|
+
metrics: { tests_passed: 5, tests_failed: 0, coverage_pct: 85 }
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const EMPTY_STATE = {};
|
|
57
|
+
|
|
58
|
+
const MINIMAL_STATE = {
|
|
59
|
+
id: 'sprint-minimal',
|
|
60
|
+
task_description: 'Minimal sprint',
|
|
61
|
+
phase: 0,
|
|
62
|
+
status: 'running',
|
|
63
|
+
started_at: '2026-06-16T10:00:00Z',
|
|
64
|
+
phase_history: []
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
beforeAll(() => {
|
|
68
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(() => {
|
|
72
|
+
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function writeState(data) {
|
|
76
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(data, null, 2));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function deleteState() {
|
|
80
|
+
try { fs.unlinkSync(STATE_FILE); } catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('sprint-status: readSprintState', () => {
|
|
84
|
+
// AC-SPRINTSTATUS-001-01: Reads state correctly
|
|
85
|
+
test('reads active sprint-state.json', () => {
|
|
86
|
+
writeState(ACTIVE_STATE);
|
|
87
|
+
const state = sprintStatus.readSprintState(TMP_DIR);
|
|
88
|
+
expect(state).not.toBeNull();
|
|
89
|
+
expect(state.task_description).toBe('Test sprint');
|
|
90
|
+
expect(state.phase).toBe(2);
|
|
91
|
+
expect(state.status).toBe('running');
|
|
92
|
+
expect(Array.isArray(state.phase_history)).toBe(true);
|
|
93
|
+
expect(state.phase_history.length).toBeGreaterThan(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// AC-SPRINTSTATUS-001-02: No active sprint
|
|
97
|
+
test('returns null when no sprint-state.json exists', () => {
|
|
98
|
+
deleteState();
|
|
99
|
+
const state = sprintStatus.readSprintState(TMP_DIR);
|
|
100
|
+
expect(state).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// AC-SPRINTSTATUS-001-05: Malformed JSON with graceful defaults
|
|
104
|
+
test('handles malformed sprint-state.json gracefully', () => {
|
|
105
|
+
fs.writeFileSync(STATE_FILE, '{not valid json!!!');
|
|
106
|
+
const state = sprintStatus.readSprintState(TMP_DIR);
|
|
107
|
+
expect(state).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// AC-SPRINTSTATUS-001-05: Missing fields use defaults
|
|
111
|
+
test('handles empty/partial state with defaults', () => {
|
|
112
|
+
writeState(EMPTY_STATE);
|
|
113
|
+
const state = sprintStatus.readSprintState(TMP_DIR);
|
|
114
|
+
// Should return the empty object without crashing
|
|
115
|
+
expect(state).toBeDefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// AC-SPRINTSTATUS-001-08: --dir parameter works
|
|
119
|
+
test('reads state from custom directory', () => {
|
|
120
|
+
writeState(ACTIVE_STATE);
|
|
121
|
+
const state = sprintStatus.readSprintState(TMP_DIR);
|
|
122
|
+
expect(state).not.toBeNull();
|
|
123
|
+
expect(state.task_description).toBe('Test sprint');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('sprint-status: formatSprintTable', () => {
|
|
128
|
+
beforeEach(() => writeState(ACTIVE_STATE));
|
|
129
|
+
|
|
130
|
+
// AC-SPRINTSTATUS-001-01: Table contains sprint info
|
|
131
|
+
test('renders table with task_description and branch', () => {
|
|
132
|
+
const output = sprintStatus.formatSprintTable(ACTIVE_STATE);
|
|
133
|
+
expect(output).toContain('Test sprint');
|
|
134
|
+
expect(output).toContain('sprint/2026-06-16-01');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// AC-SPRINTSTATUS-001-03: Completed phases have ✅
|
|
138
|
+
test('marks completed phases with ✅', () => {
|
|
139
|
+
const output = sprintStatus.formatSprintTable(ACTIVE_STATE);
|
|
140
|
+
expect(output).toContain('✅');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// AC-SPRINTSTATUS-001-03: In-progress phases have 🔄
|
|
144
|
+
test('marks in-progress phases with 🔄', () => {
|
|
145
|
+
const output = sprintStatus.formatSprintTable(ACTIVE_STATE);
|
|
146
|
+
expect(output).toContain('🔄');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// AC-SPRINTSTATUS-001-04: Missing phases show ⏳ Pending
|
|
150
|
+
test('shows pending for phases not in phase_history', () => {
|
|
151
|
+
const output = sprintStatus.formatSprintTable(ACTIVE_STATE);
|
|
152
|
+
// Phase 3-8 are missing from phase_history
|
|
153
|
+
expect(output).toContain('⏳');
|
|
154
|
+
expect(output).toContain('Pending');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// AC-SPRINTSTATUS-001-05: Missing fields don't crash
|
|
158
|
+
test('handles minimal state without crashing', () => {
|
|
159
|
+
const output = sprintStatus.formatSprintTable(MINIMAL_STATE);
|
|
160
|
+
expect(output).toBeDefined();
|
|
161
|
+
expect(typeof output).toBe('string');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// AC-SPRINTSTATUS-001-06: JSON mode
|
|
165
|
+
test('jsonMode returns raw state as JSON string', () => {
|
|
166
|
+
const json = sprintStatus.jsonMode(ACTIVE_STATE);
|
|
167
|
+
const parsed = JSON.parse(json);
|
|
168
|
+
expect(parsed.task_description).toBe('Test sprint');
|
|
169
|
+
expect(parsed.phase).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// AC-SPRINTSTATUS-001-09: Stale state detection
|
|
173
|
+
test('detects stale state (>1h old)', () => {
|
|
174
|
+
const staleState = JSON.parse(JSON.stringify(ACTIVE_STATE));
|
|
175
|
+
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
|
176
|
+
staleState.started_at = twoHoursAgo;
|
|
177
|
+
// Also backdate all phase_history timestamps so isStale() triggers
|
|
178
|
+
if (Array.isArray(staleState.phase_history)) {
|
|
179
|
+
for (const ph of staleState.phase_history) {
|
|
180
|
+
if (ph.started_at) ph.started_at = twoHoursAgo;
|
|
181
|
+
if (ph.completed_at) ph.completed_at = twoHoursAgo;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const output = sprintStatus.formatSprintTable(staleState);
|
|
185
|
+
expect(output).toContain('State may be stale');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// REQ-level progress in BUILD phase
|
|
189
|
+
test('shows REQ-level progress in BUILD phase', () => {
|
|
190
|
+
const output = sprintStatus.formatSprintTable(ACTIVE_STATE);
|
|
191
|
+
expect(output).toContain('REQ-001');
|
|
192
|
+
expect(output).toContain('Feature A');
|
|
193
|
+
expect(output).toContain('REQ-002');
|
|
194
|
+
expect(output).toContain('Feature B');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-SPRINTSTATUS-001 Sprint Status CLI
|
|
3
|
+
* @intent Validate xp-gate sprint-status command reads sprint-state.json and renders table
|
|
4
|
+
* @covers AC-SPRINTSTATUS-001-01 through AC-SPRINTSTATUS-001-09
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
|
|
11
|
+
// We need to wait until sprint-status.js exists before importing it
|
|
12
|
+
// For now, use a dynamic import pattern
|
|
13
|
+
let sprintStatus: typeof import('../sprint-status');
|
|
14
|
+
|
|
15
|
+
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-status-test-'));
|
|
16
|
+
const STATE_DIR = path.join(TMP_DIR, '.sprint-state');
|
|
17
|
+
const STATE_FILE = path.join(STATE_DIR, 'sprint-state.json');
|
|
18
|
+
|
|
19
|
+
// Active sprint state (canonical format per SKILL.md L893-942)
|
|
20
|
+
const ACTIVE_STATE = {
|
|
21
|
+
id: 'sprint-2026-06-16-01',
|
|
22
|
+
task_description: 'Test sprint',
|
|
23
|
+
phase: 2,
|
|
24
|
+
status: 'running',
|
|
25
|
+
started_at: '2026-06-16T10:00:00Z',
|
|
26
|
+
isolation: {
|
|
27
|
+
worktree_path: '.worktrees/sprint/sprint-2026-06-16-01',
|
|
28
|
+
branch: 'sprint/2026-06-16-01',
|
|
29
|
+
created_from: 'main',
|
|
30
|
+
created_from_commit: 'abc123'
|
|
31
|
+
},
|
|
32
|
+
auto_estimate: {
|
|
33
|
+
change_type: '新增功能',
|
|
34
|
+
estimated_level: '轻量',
|
|
35
|
+
user_decision: 'accepted'
|
|
36
|
+
},
|
|
37
|
+
phase_history: [
|
|
38
|
+
{ phase: -1, phase_name: 'ISOLATE', status: 'completed',
|
|
39
|
+
started_at: '2026-06-16T10:00:00Z', completed_at: '2026-06-16T10:03:00Z', duration_seconds: 180 },
|
|
40
|
+
{ phase: -0.5, phase_name: 'AUTO-ESTIMATE', status: 'completed',
|
|
41
|
+
started_at: '2026-06-16T10:03:00Z', completed_at: '2026-06-16T10:05:00Z', duration_seconds: 120 },
|
|
42
|
+
{ phase: 0, phase_name: 'THINK', status: 'completed',
|
|
43
|
+
started_at: '2026-06-16T10:05:00Z', completed_at: '2026-06-16T10:15:00Z', duration_seconds: 600 },
|
|
44
|
+
{ phase: 1, phase_name: 'PLAN', status: 'completed',
|
|
45
|
+
started_at: '2026-06-16T10:15:00Z', completed_at: '2026-06-16T10:25:00Z', duration_seconds: 600 },
|
|
46
|
+
{ phase: 2, phase_name: 'BUILD', status: 'in_progress',
|
|
47
|
+
started_at: '2026-06-16T10:25:00Z', completed_at: null, duration_seconds: null,
|
|
48
|
+
reqs: {
|
|
49
|
+
'REQ-001': { name: 'Feature A', status: 'completed' },
|
|
50
|
+
'REQ-002': { name: 'Feature B', status: 'in_progress' }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
outputs: { specification: '.sprint-state/phase-outputs/specification.yaml' },
|
|
55
|
+
metrics: { tests_passed: 5, tests_failed: 0, coverage_pct: 85 }
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const MINIMAL_STATE = {
|
|
59
|
+
id: 'sprint-minimal',
|
|
60
|
+
task_description: 'Minimal sprint',
|
|
61
|
+
phase: 0,
|
|
62
|
+
status: 'running',
|
|
63
|
+
started_at: '2026-06-16T10:00:00Z',
|
|
64
|
+
phase_history: []
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
69
|
+
// Import after module is created
|
|
70
|
+
try {
|
|
71
|
+
sprintStatus = await import('../sprint-status.js');
|
|
72
|
+
} catch {
|
|
73
|
+
// Module not yet created — tests will fail until GREEN phase
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterAll(() => {
|
|
78
|
+
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function writeState(data: Record<string, unknown>) {
|
|
82
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(data, null, 2));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function deleteState() {
|
|
86
|
+
try { fs.unlinkSync(STATE_FILE); } catch { /* ignore */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Helper to skip tests when module not yet implemented
|
|
90
|
+
function skipIfModuleMissing() {
|
|
91
|
+
if (!sprintStatus) {
|
|
92
|
+
console.warn('[SKIP] sprint-status module not yet implemented');
|
|
93
|
+
}
|
|
94
|
+
return !sprintStatus;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('sprint-status: readSprintState', () => {
|
|
98
|
+
// AC-SPRINTSTATUS-001-01: Reads state correctly
|
|
99
|
+
test('reads active sprint-state.json', () => {
|
|
100
|
+
if (skipIfModuleMissing()) return;
|
|
101
|
+
writeState(ACTIVE_STATE);
|
|
102
|
+
const state = sprintStatus!.readSprintState(TMP_DIR);
|
|
103
|
+
expect(state).not.toBeNull();
|
|
104
|
+
expect(state?.task_description).toBe('Test sprint');
|
|
105
|
+
expect(state?.phase).toBe(2);
|
|
106
|
+
expect(state?.status).toBe('running');
|
|
107
|
+
expect(Array.isArray(state?.phase_history)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// AC-SPRINTSTATUS-001-02: No active sprint
|
|
111
|
+
test('returns null when no sprint-state.json exists', () => {
|
|
112
|
+
if (skipIfModuleMissing()) return;
|
|
113
|
+
deleteState();
|
|
114
|
+
const state = sprintStatus!.readSprintState(TMP_DIR);
|
|
115
|
+
expect(state).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// AC-SPRINTSTATUS-001-05: Malformed JSON handled gracefully
|
|
119
|
+
test('handles malformed sprint-state.json gracefully', () => {
|
|
120
|
+
if (skipIfModuleMissing()) return;
|
|
121
|
+
fs.writeFileSync(STATE_FILE, '{not valid json!!!');
|
|
122
|
+
const state = sprintStatus!.readSprintState(TMP_DIR);
|
|
123
|
+
expect(state).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// AC-SPRINTSTATUS-001-05: Missing fields use defaults
|
|
127
|
+
test('handles empty/partial state with defaults', () => {
|
|
128
|
+
if (skipIfModuleMissing()) return;
|
|
129
|
+
writeState({});
|
|
130
|
+
const state = sprintStatus!.readSprintState(TMP_DIR);
|
|
131
|
+
expect(state).toBeDefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// AC-SPRINTSTATUS-001-08: --dir parameter works
|
|
135
|
+
test('reads from custom directory', () => {
|
|
136
|
+
if (skipIfModuleMissing()) return;
|
|
137
|
+
writeState(ACTIVE_STATE);
|
|
138
|
+
const state = sprintStatus!.readSprintState(TMP_DIR);
|
|
139
|
+
expect(state).not.toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('sprint-status: formatSprintTable', () => {
|
|
144
|
+
beforeEach(() => writeState(ACTIVE_STATE));
|
|
145
|
+
|
|
146
|
+
// AC-SPRINTSTATUS-001-01: Table contains sprint info
|
|
147
|
+
test('renders table with task_description and branch', () => {
|
|
148
|
+
if (skipIfModuleMissing()) return;
|
|
149
|
+
const output = sprintStatus!.formatSprintTable(ACTIVE_STATE);
|
|
150
|
+
expect(output).toContain('Test sprint');
|
|
151
|
+
expect(output).toContain('sprint/2026-06-16-01');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// AC-SPRINTSTATUS-001-03: Completed phases show ✅
|
|
155
|
+
test('marks completed phases with ✅', () => {
|
|
156
|
+
if (skipIfModuleMissing()) return;
|
|
157
|
+
const output = sprintStatus!.formatSprintTable(ACTIVE_STATE);
|
|
158
|
+
expect(output).toContain('✅');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// AC-SPRINTSTATUS-001-03: In-progress shows 🔄
|
|
162
|
+
test('marks in-progress phases with 🔄', () => {
|
|
163
|
+
if (skipIfModuleMissing()) return;
|
|
164
|
+
const output = sprintStatus!.formatSprintTable(ACTIVE_STATE);
|
|
165
|
+
expect(output).toContain('🔄');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// AC-SPRINTSTATUS-001-04: Missing phases show ⏳ Pending
|
|
169
|
+
test('shows pending for phases not in phase_history', () => {
|
|
170
|
+
if (skipIfModuleMissing()) return;
|
|
171
|
+
const output = sprintStatus!.formatSprintTable(ACTIVE_STATE);
|
|
172
|
+
expect(output).toContain('⏳');
|
|
173
|
+
expect(output).toContain('Pending');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// AC-SPRINTSTATUS-001-05: No crash on minimal state
|
|
177
|
+
test('handles minimal state without crashing', () => {
|
|
178
|
+
if (skipIfModuleMissing()) return;
|
|
179
|
+
const output = sprintStatus!.formatSprintTable(MINIMAL_STATE);
|
|
180
|
+
expect(output).toBeDefined();
|
|
181
|
+
expect(typeof output).toBe('string');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// AC-SPRINTSTATUS-001-09: Stale state detection (>1h)
|
|
185
|
+
test('detects stale state (>1h old)', () => {
|
|
186
|
+
if (skipIfModuleMissing()) return;
|
|
187
|
+
const oldDate = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
|
188
|
+
const staleState = JSON.parse(JSON.stringify(ACTIVE_STATE));
|
|
189
|
+
staleState.started_at = oldDate;
|
|
190
|
+
if (Array.isArray(staleState.phase_history)) {
|
|
191
|
+
for (const ph of staleState.phase_history) {
|
|
192
|
+
if (ph.started_at) ph.started_at = oldDate;
|
|
193
|
+
if (ph.completed_at) ph.completed_at = oldDate;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const output = sprintStatus!.formatSprintTable(staleState);
|
|
197
|
+
expect(output).toContain('stale');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// REQ-level progress in BUILD
|
|
201
|
+
test('shows REQ-level progress in BUILD phase', () => {
|
|
202
|
+
if (skipIfModuleMissing()) return;
|
|
203
|
+
const output = sprintStatus!.formatSprintTable(ACTIVE_STATE);
|
|
204
|
+
expect(output).toContain('REQ-001');
|
|
205
|
+
expect(output).toContain('Feature A');
|
|
206
|
+
expect(output).toContain('REQ-002');
|
|
207
|
+
expect(output).toContain('Feature B');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('sprint-status: jsonMode', () => {
|
|
212
|
+
// AC-SPRINTSTATUS-001-06: JSON mode
|
|
213
|
+
test('returns raw state as parseable JSON', () => {
|
|
214
|
+
if (skipIfModuleMissing()) return;
|
|
215
|
+
const json = sprintStatus!.jsonMode(ACTIVE_STATE);
|
|
216
|
+
const parsed = JSON.parse(json);
|
|
217
|
+
expect(parsed.task_description).toBe('Test sprint');
|
|
218
|
+
expect(parsed.phase).toBe(2);
|
|
219
|
+
});
|
|
220
|
+
});
|
package/lib/check-version.js
CHANGED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xp-gate sprint-status — Sprint Flow progress CLI
|
|
3
|
+
*
|
|
4
|
+
* Reads .sprint-state/sprint-state.json (canonical schema per skills/sprint-flow/SKILL.md L893-942)
|
|
5
|
+
* and renders a formatted progress table.
|
|
6
|
+
*
|
|
7
|
+
* @module sprint-status
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const PHASE_NAMES = {
|
|
14
|
+
'-1': 'ISOLATE',
|
|
15
|
+
'-0.5': 'AUTO-ESTIMATE',
|
|
16
|
+
'0': 'THINK',
|
|
17
|
+
'1': 'PLAN',
|
|
18
|
+
'2': 'BUILD',
|
|
19
|
+
'3': 'REVIEW',
|
|
20
|
+
'4': 'USER ACCEPT',
|
|
21
|
+
'5': 'FEEDBACK',
|
|
22
|
+
'6': 'SHIP',
|
|
23
|
+
'7': 'LAND',
|
|
24
|
+
'8': 'CLEANUP',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const PHASE_ORDER = ['-1', '-0.5', '0', '1', '2', '3', '4', '5', '6', '7', '8'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read and parse sprint-state.json from a project directory.
|
|
31
|
+
* @param {string} dir - Project root directory
|
|
32
|
+
* @returns {object|null} Parsed sprint state, or null if not found or malformed
|
|
33
|
+
*/
|
|
34
|
+
function readSprintState(dir) {
|
|
35
|
+
try {
|
|
36
|
+
const stateFile = path.join(dir, '.sprint-state', 'sprint-state.json');
|
|
37
|
+
if (!fs.existsSync(stateFile)) return null;
|
|
38
|
+
const raw = fs.readFileSync(stateFile, 'utf8');
|
|
39
|
+
return JSON.parse(raw);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get a phase's status icon.
|
|
47
|
+
* @param {'completed'|'in_progress'|'pending'|string} status
|
|
48
|
+
* @returns {string} Icon character
|
|
49
|
+
*/
|
|
50
|
+
function statusIcon(status) {
|
|
51
|
+
switch (status) {
|
|
52
|
+
case 'completed': return '✅';
|
|
53
|
+
case 'in_progress': return '🔄';
|
|
54
|
+
default: return '⏳';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Format a duration in seconds to a human-readable string.
|
|
60
|
+
* @param {number|null} seconds
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function formatDuration(seconds) {
|
|
64
|
+
if (seconds == null || seconds === 0) return '';
|
|
65
|
+
if (seconds < 60) return `${seconds}s`;
|
|
66
|
+
const mins = Math.floor(seconds / 60);
|
|
67
|
+
if (mins < 60) return `${mins}m`;
|
|
68
|
+
const hrs = Math.floor(mins / 60);
|
|
69
|
+
return `${hrs}h ${mins % 60}m`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if the state is stale (>1h since last activity).
|
|
74
|
+
* @param {object} state - Sprint state object
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
function isStale(state) {
|
|
78
|
+
if (!state || !state.started_at) return false;
|
|
79
|
+
const started = new Date(state.started_at).getTime();
|
|
80
|
+
if (isNaN(started)) return false;
|
|
81
|
+
// Check latest phase_history timestamp (started_at or completed_at)
|
|
82
|
+
let latest = started;
|
|
83
|
+
if (Array.isArray(state.phase_history) && state.phase_history.length > 0) {
|
|
84
|
+
for (const ph of state.phase_history) {
|
|
85
|
+
if (ph.completed_at) {
|
|
86
|
+
const t = new Date(ph.completed_at).getTime();
|
|
87
|
+
if (!isNaN(t) && t > latest) latest = t;
|
|
88
|
+
}
|
|
89
|
+
if (ph.started_at) {
|
|
90
|
+
const t = new Date(ph.started_at).getTime();
|
|
91
|
+
if (!isNaN(t) && t > latest) latest = t;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return Date.now() - latest > 3600000; // 1h
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Render sprint state as a formatted table string.
|
|
100
|
+
* @param {object} state - Sprint state object
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function formatSprintTable(state) {
|
|
104
|
+
if (!state || !state.task_description) {
|
|
105
|
+
return 'No active sprint in this directory';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const lines = [];
|
|
109
|
+
const branch = state.isolation?.branch || 'unknown';
|
|
110
|
+
const metrics = state.metrics || {};
|
|
111
|
+
|
|
112
|
+
// Header
|
|
113
|
+
lines.push(`Sprint: ${state.task_description}`);
|
|
114
|
+
lines.push(`ID: ${state.id || 'unknown'} | Branch: ${branch}`);
|
|
115
|
+
|
|
116
|
+
// Metrics line (if available)
|
|
117
|
+
const metricParts = [];
|
|
118
|
+
if (metrics.tests_passed != null) {
|
|
119
|
+
metricParts.push(`Tests: ${metrics.tests_passed} passed${metrics.tests_failed ? `, ${metrics.tests_failed} failed` : ''}`);
|
|
120
|
+
}
|
|
121
|
+
if (metrics.coverage_pct != null) {
|
|
122
|
+
metricParts.push(`Coverage: ${metrics.coverage_pct}%`);
|
|
123
|
+
}
|
|
124
|
+
if (metricParts.length > 0) {
|
|
125
|
+
lines.push(`Metrics: ${metricParts.join(' | ')}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Stale warning
|
|
129
|
+
if (isStale(state)) {
|
|
130
|
+
lines.push('⚠️ State may be stale (last updated >1h ago)');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
lines.push('');
|
|
134
|
+
|
|
135
|
+
// Build phase lookup from phase_history
|
|
136
|
+
const historyByPhase = {};
|
|
137
|
+
if (Array.isArray(state.phase_history)) {
|
|
138
|
+
for (const ph of state.phase_history) {
|
|
139
|
+
historyByPhase[String(ph.phase)] = ph;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Calculate column widths dynamically
|
|
144
|
+
let maxNameLen = 'Phase'.length;
|
|
145
|
+
for (const key of PHASE_ORDER) {
|
|
146
|
+
const history = historyByPhase[key];
|
|
147
|
+
const name = history?.phase_name || PHASE_NAMES[key] || key;
|
|
148
|
+
if (name.length > maxNameLen) maxNameLen = name.length;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Separator line
|
|
152
|
+
const sep = '─'.repeat(maxNameLen + 40);
|
|
153
|
+
|
|
154
|
+
lines.push(sep);
|
|
155
|
+
|
|
156
|
+
for (const key of PHASE_ORDER) {
|
|
157
|
+
const history = historyByPhase[key];
|
|
158
|
+
const name = history?.phase_name || PHASE_NAMES[key] || key;
|
|
159
|
+
const status = history?.status || 'pending';
|
|
160
|
+
const icon = statusIcon(status);
|
|
161
|
+
const dur = formatDuration(history?.duration_seconds);
|
|
162
|
+
const statusLabel = status === 'completed' ? 'Completed' :
|
|
163
|
+
status === 'in_progress' ? 'In Progress' : 'Pending';
|
|
164
|
+
|
|
165
|
+
const line = ` Phase ${key.padStart(4)} ${name.padEnd(maxNameLen + 1)} ${icon} ${dur.padEnd(5)} ${statusLabel}`;
|
|
166
|
+
lines.push(line);
|
|
167
|
+
|
|
168
|
+
// REQ-level progress for BUILD phase
|
|
169
|
+
if (history?.reqs) {
|
|
170
|
+
for (const [reqId, req] of Object.entries(history.reqs)) {
|
|
171
|
+
const reqIcon = statusIcon(req.status);
|
|
172
|
+
lines.push(` ${reqIcon} ${reqId} ${req.name}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
lines.push(sep);
|
|
178
|
+
|
|
179
|
+
return lines.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Convert sprint state to JSON string.
|
|
184
|
+
* @param {object} state - Sprint state object
|
|
185
|
+
* @returns {string} JSON string
|
|
186
|
+
*/
|
|
187
|
+
function jsonMode(state) {
|
|
188
|
+
return JSON.stringify(state, null, 2);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* CLI entry point. Parses args and executes the appropriate mode.
|
|
193
|
+
* @param {string[]} args - CLI subargs (without 'sprint-status')
|
|
194
|
+
* @returns {Promise<number>} Exit code
|
|
195
|
+
*/
|
|
196
|
+
async function handleSprintStatus(args = []) {
|
|
197
|
+
const jsonFlag = args.includes('--json');
|
|
198
|
+
const watchFlag = args.includes('--watch');
|
|
199
|
+
const dirIdx = args.indexOf('--dir');
|
|
200
|
+
let searchDir = process.cwd();
|
|
201
|
+
|
|
202
|
+
if (dirIdx >= 0 && dirIdx + 1 < args.length) {
|
|
203
|
+
searchDir = path.resolve(args[dirIdx + 1]);
|
|
204
|
+
// Path traversal protection: must be under cwd
|
|
205
|
+
if (!searchDir.startsWith(process.cwd())) {
|
|
206
|
+
console.error('Error: --dir path must be under current working directory');
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const state = readSprintState(searchDir);
|
|
212
|
+
|
|
213
|
+
if (!state) {
|
|
214
|
+
console.log('No active sprint in this directory');
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (jsonFlag) {
|
|
219
|
+
console.log(jsonMode(state));
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (watchFlag) {
|
|
224
|
+
const stateFile = path.join(searchDir, '.sprint-state', 'sprint-state.json');
|
|
225
|
+
return watchMode(stateFile);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
console.log(formatSprintTable(state));
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Watch mode: listen for changes to the sprint state file.
|
|
234
|
+
* Prefers fs.watch(), falls back to fs.watchFile().
|
|
235
|
+
* @param {string} stateFile - Path to sprint-state.json
|
|
236
|
+
* @returns {Promise<number>} This never resolves normally (process.exit on SIGINT)
|
|
237
|
+
*/
|
|
238
|
+
function watchMode(stateFile) {
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
const dir = path.dirname(stateFile);
|
|
241
|
+
let watcher = null;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
watcher = fs.watch(dir, (eventType, filename) => {
|
|
245
|
+
if (filename === 'sprint-state.json' || filename === path.basename(stateFile)) {
|
|
246
|
+
const state = readSprintState(path.dirname(dir)); // parent of .sprint-state
|
|
247
|
+
if (state) {
|
|
248
|
+
console.clear();
|
|
249
|
+
console.log(formatSprintTable(state));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
} catch {
|
|
254
|
+
// Fallback to fs.watchFile
|
|
255
|
+
try {
|
|
256
|
+
fs.watchFile(stateFile, { interval: 5000 }, () => {
|
|
257
|
+
const state = readSprintState(path.dirname(dir));
|
|
258
|
+
if (state) {
|
|
259
|
+
console.clear();
|
|
260
|
+
console.log(formatSprintTable(state));
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
} catch {
|
|
264
|
+
console.error('Watch mode not available on this platform');
|
|
265
|
+
resolve(1);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Initial render
|
|
271
|
+
const initialState = readSprintState(path.dirname(dir));
|
|
272
|
+
if (initialState) {
|
|
273
|
+
console.log(formatSprintTable(initialState));
|
|
274
|
+
}
|
|
275
|
+
console.log('Watching for changes... (Ctrl+C to stop)');
|
|
276
|
+
|
|
277
|
+
// Cleanup on exit
|
|
278
|
+
process.on('SIGINT', () => {
|
|
279
|
+
if (watcher) {
|
|
280
|
+
try { watcher.close(); } catch {}
|
|
281
|
+
}
|
|
282
|
+
try { fs.unwatchFile(stateFile); } catch {}
|
|
283
|
+
console.log('\nStopped watching.');
|
|
284
|
+
resolve(0);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
readSprintState,
|
|
291
|
+
formatSprintTable,
|
|
292
|
+
jsonMode,
|
|
293
|
+
isStale,
|
|
294
|
+
handleSprintStatus,
|
|
295
|
+
};
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
|
package/mutation/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MUTATION KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xp-gate",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.17",
|
|
4
4
|
"displayName": "XP-Gate",
|
|
5
5
|
"description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 10 quality gates (Gate 0-9), Sprint Flow (11 phases), and Delphi multi-expert review (>=90% consensus).",
|
|
6
6
|
"author": {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, 3 experts from ≥2 providers, domestic models only). Supports design + code-walkthrough modes.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/SPRINT-FLOW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**11-phase** development pipeline: ISOLATE → AUTO-ESTIMATE → THINK → PLAN → BUILD → REVIEW → USER ACCEPTANCE → FEEDBACK → SHIP → LAND → CLEANUP. Phase 2 default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE in Phase 1: design must pass Delphi review (≥90% consensus) before any coding.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
|
@@ -2,6 +2,9 @@ import { tool } from "@opencode-ai/plugin"
|
|
|
2
2
|
import { z } from "zod"
|
|
3
3
|
import { exec } from "child_process"
|
|
4
4
|
import { promisify } from "util"
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { homedir } from "node:os"
|
|
5
8
|
|
|
6
9
|
const execAsync = promisify(exec)
|
|
7
10
|
|
|
@@ -10,10 +13,6 @@ interface OpenCodePluginInput {
|
|
|
10
13
|
$: (strings: TemplateStringsArray, ...values: unknown[]) => Promise<{ text(): Promise<string> }>
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
/**
|
|
14
|
-
* Run a shell command via async exec, returning stdout or error message.
|
|
15
|
-
* Never throws — returns error string on failure.
|
|
16
|
-
*/
|
|
17
16
|
async function runCmd(cmd: string, cwd: string): Promise<string> {
|
|
18
17
|
try {
|
|
19
18
|
const { stdout } = await execAsync(cmd, { cwd, timeout: 30000 })
|
|
@@ -26,22 +25,15 @@ async function runCmd(cmd: string, cwd: string): Promise<string> {
|
|
|
26
25
|
}
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
/**
|
|
30
|
-
* Check for xp-gate CLI availability and run a command via exec.
|
|
31
|
-
*/
|
|
32
28
|
async function runXpGate(subcommand: string, cwd: string): Promise<string> {
|
|
33
|
-
// Check if xp-gate is on PATH
|
|
34
29
|
try {
|
|
35
30
|
await execAsync("command -v xp-gate", { cwd })
|
|
36
31
|
} catch {
|
|
37
|
-
return ""
|
|
32
|
+
return ""
|
|
38
33
|
}
|
|
39
34
|
return runCmd(`xp-gate ${subcommand}`, cwd)
|
|
40
35
|
}
|
|
41
36
|
|
|
42
|
-
/**
|
|
43
|
-
* Check for a newer xp-gate version (non-blocking, advisory only).
|
|
44
|
-
*/
|
|
45
37
|
async function getUpgradeSuggestion(cwd: string): Promise<string> {
|
|
46
38
|
try {
|
|
47
39
|
const result = await runXpGate("upgrade --preview", cwd)
|
|
@@ -57,6 +49,79 @@ async function getUpgradeSuggestion(cwd: string): Promise<string> {
|
|
|
57
49
|
}
|
|
58
50
|
}
|
|
59
51
|
|
|
52
|
+
// ── Auto-update check for opencode-plugin ──
|
|
53
|
+
|
|
54
|
+
const CACHE_TTL_MS = 86_400_000
|
|
55
|
+
const NPM_REGISTRY_URL = "https://registry.npmjs.org/-/package/@boyingliu01%2Fopencode-plugin/dist-tags"
|
|
56
|
+
const FETCH_TIMEOUT_MS = 5_000
|
|
57
|
+
const CACHE_FILE = join(homedir(), ".xp-gate", "opencode-plugin-version-check.json")
|
|
58
|
+
|
|
59
|
+
let checked = false
|
|
60
|
+
let checkInFlight: Promise<void> | null = null
|
|
61
|
+
|
|
62
|
+
function semverLt(a: string, b: string): boolean {
|
|
63
|
+
const pa = a.replace(/^v/, "").split(".").map(Number)
|
|
64
|
+
const pb = b.replace(/^v/, "").split(".").map(Number)
|
|
65
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
66
|
+
const na = pa[i] ?? 0
|
|
67
|
+
const nb = pb[i] ?? 0
|
|
68
|
+
if (na !== nb) return na < nb
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function checkPluginUpdate(pluginDir: string): Promise<void> {
|
|
74
|
+
if (checkInFlight) return
|
|
75
|
+
|
|
76
|
+
checkInFlight = (async () => {
|
|
77
|
+
try {
|
|
78
|
+
mkdirSync(join(homedir(), ".xp-gate"), { recursive: true })
|
|
79
|
+
|
|
80
|
+
if (existsSync(CACHE_FILE)) {
|
|
81
|
+
const cached = JSON.parse(readFileSync(CACHE_FILE, "utf8"))
|
|
82
|
+
if (Date.now() - cached.ts < CACHE_TTL_MS) return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let localVersion = ""
|
|
86
|
+
try {
|
|
87
|
+
const pkg = JSON.parse(readFileSync(join(pluginDir, "package.json"), "utf8"))
|
|
88
|
+
localVersion = pkg.version || ""
|
|
89
|
+
} catch {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const controller = new AbortController()
|
|
94
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(NPM_REGISTRY_URL, { signal: controller.signal })
|
|
97
|
+
if (!response.ok) return
|
|
98
|
+
const data: Record<string, unknown> = await response.json()
|
|
99
|
+
const remoteVersion = String(data.latest || "")
|
|
100
|
+
|
|
101
|
+
if (remoteVersion && localVersion && semverLt(localVersion, remoteVersion)) {
|
|
102
|
+
writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), localVersion, remoteVersion }))
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
`[XP-Gate] New opencode-plugin version v${remoteVersion} available (you have v${localVersion})\n` +
|
|
105
|
+
`[XP-Gate] Update with: cd ~/.config/opencode && npm update @boyingliu01/opencode-plugin\n`
|
|
106
|
+
)
|
|
107
|
+
} else if (remoteVersion && localVersion) {
|
|
108
|
+
// Cache "up to date" to avoid re-fetching every session
|
|
109
|
+
writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), localVersion, remoteVersion, status: "current" }))
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer)
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// All errors silently ignored
|
|
116
|
+
}
|
|
117
|
+
})()
|
|
118
|
+
|
|
119
|
+
await checkInFlight
|
|
120
|
+
checkInFlight = null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Plugin definition ──
|
|
124
|
+
|
|
60
125
|
export const XpGatePlugin = async (input: OpenCodePluginInput) => {
|
|
61
126
|
const { directory } = input
|
|
62
127
|
|
|
@@ -77,7 +142,6 @@ export const XpGatePlugin = async (input: OpenCodePluginInput) => {
|
|
|
77
142
|
const upgrade = await getUpgradeSuggestion(cwd)
|
|
78
143
|
return upgrade ? `${result}\n${upgrade}` : result
|
|
79
144
|
}
|
|
80
|
-
// Fallback: invoke xp-gate source directly via node
|
|
81
145
|
const cmd = `node ${directory}/src/npm-package/bin/xp-gate.js check "${target}"${gatesFlag}`
|
|
82
146
|
return runCmd(cmd, cwd)
|
|
83
147
|
},
|
|
@@ -95,7 +159,6 @@ export const XpGatePlugin = async (input: OpenCodePluginInput) => {
|
|
|
95
159
|
const upgrade = await getUpgradeSuggestion(cwd)
|
|
96
160
|
return upgrade ? `${result}\n${upgrade}` : result
|
|
97
161
|
}
|
|
98
|
-
// Fallback: npx tsx on the principles source
|
|
99
162
|
const cmd = `npx -y tsx ${directory}/src/principles/index.ts --files "${target}" --format console`
|
|
100
163
|
return runCmd(cmd, cwd)
|
|
101
164
|
},
|
|
@@ -113,12 +176,18 @@ export const XpGatePlugin = async (input: OpenCodePluginInput) => {
|
|
|
113
176
|
const upgrade = await getUpgradeSuggestion(cwd)
|
|
114
177
|
return upgrade ? `${result}\n${upgrade}` : result
|
|
115
178
|
}
|
|
116
|
-
// Fallback: @archlinter/cli directly
|
|
117
179
|
const cmd = `npx -y @archlinter/cli scan . --config ${config}`
|
|
118
180
|
return runCmd(cmd, cwd)
|
|
119
181
|
},
|
|
120
182
|
}),
|
|
121
183
|
},
|
|
184
|
+
"chat.message": async (_input: { message: string }) => {
|
|
185
|
+
if (!checked) {
|
|
186
|
+
checked = true
|
|
187
|
+
checkPluginUpdate(directory).catch((_err) => { /* silent: non-critical background check */ })
|
|
188
|
+
}
|
|
189
|
+
return { action: "continue" }
|
|
190
|
+
},
|
|
122
191
|
}
|
|
123
192
|
}
|
|
124
193
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, 3 experts from ≥2 providers, domestic models only). Supports design + code-walkthrough modes.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/SPRINT-FLOW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**11-phase** development pipeline: ISOLATE → AUTO-ESTIMATE → THINK → PLAN → BUILD → REVIEW → USER ACCEPTANCE → FEEDBACK → SHIP → LAND → CLEANUP. Phase 2 default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE in Phase 1: design must pass Delphi review (≥90% consensus) before any coding.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, 3 experts from ≥2 providers, domestic models only). Supports design + code-walkthrough modes.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/SPRINT-FLOW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**11-phase** development pipeline: ISOLATE → AUTO-ESTIMATE → THINK → PLAN → BUILD → REVIEW → USER ACCEPTANCE → FEEDBACK → SHIP → LAND → CLEANUP. Phase 2 default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE in Phase 1: design must pass Delphi review (≥90% consensus) before any coding.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
package/principles/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# PRINCIPLES CHECKER MODULE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Clean Code & SOLID principles checker — **Gate 4** of pre-commit. 14 rules × 9 language adapters, SARIF 2.1.0 output. Houses the **Boy Scout Rule** enforcement engine (Gate 6) and warning-baseline storage.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, 3 experts from ≥2 providers, domestic models only). Supports design + code-walkthrough modes.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/SPRINT-FLOW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**11-phase** development pipeline: ISOLATE → AUTO-ESTIMATE → THINK → PLAN → BUILD → REVIEW → USER ACCEPTANCE → FEEDBACK → SHIP → LAND → CLEANUP. Phase 2 default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE in Phase 1: design must pass Delphi review (≥90% consensus) before any coding.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-16
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** d3a0242
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.8.
|
|
6
|
+
**Version:** 0.8.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|