@boyingliu01/xp-gate 0.10.8 → 0.10.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/lib/__tests__/sprint-discovery.test.ts +364 -0
- package/lib/sprint-discovery.js +220 -0
- package/lib/sprint-status.js +49 -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/__tests__/tui-plugin.test.ts +126 -9
- package/plugins/opencode/__tests__/xp-gate-update.test.ts +54 -16
- package/plugins/opencode/index.ts +36 -6
- 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/opencode/tui-plugin.ts +238 -42
- package/plugins/qoder/plugin.json +1 -1
- 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
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-DISCOVERY-001 Sprint state worktree discovery
|
|
3
|
+
* @intent Validate discoverActiveSprints() discovers sprint states from .worktrees/sprint/
|
|
4
|
+
* @covers AC-DISCOVERY-001-01 through AC-DISCOVERY-001-15
|
|
5
|
+
*
|
|
6
|
+
* Note: These tests import the JS module via dynamic import since
|
|
7
|
+
* sprint-discovery.js is CommonJS and vitest is ESM.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, beforeAll, afterAll } from 'vitest';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
|
|
14
|
+
// Types mirroring DiscoveredSprint interface
|
|
15
|
+
interface DiscoveredSprint {
|
|
16
|
+
state: Record<string, unknown>;
|
|
17
|
+
sourcePath: string;
|
|
18
|
+
worktreeExists: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let sprintDiscovery: {
|
|
22
|
+
discoverActiveSprints(dir: string): DiscoveredSprint[];
|
|
23
|
+
findGitRoot(dir: string): string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const TMP_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-discovery-test-'));
|
|
27
|
+
|
|
28
|
+
// Simulate a real repo structure:
|
|
29
|
+
// TMP_ROOT/
|
|
30
|
+
// .git/ (marker for git root discovery)
|
|
31
|
+
// .sprint-state/sprint-state.json (optional, for cwd fallback)
|
|
32
|
+
// .worktrees/sprint/
|
|
33
|
+
// sprint-2026-06-23-01/.sprint-state/sprint-state.json (worktree sprint)
|
|
34
|
+
// sprint-2026-06-23-02/.sprint-state/sprint-state.json (completed sprint)
|
|
35
|
+
// orphan-sprint/ (missing .sprint-state/)
|
|
36
|
+
// broken-sprint/.sprint-state/sprint-state.json (corrupted JSON)
|
|
37
|
+
|
|
38
|
+
const GIT_DIR = path.join(TMP_ROOT, '.git');
|
|
39
|
+
const WORKTREE_DIR = path.join(TMP_ROOT, '.worktrees', 'sprint');
|
|
40
|
+
|
|
41
|
+
const WORKTREE_1 = path.join(WORKTREE_DIR, 'sprint-2026-06-23-01');
|
|
42
|
+
const WORKTREE_2 = path.join(WORKTREE_DIR, 'sprint-2026-06-23-02');
|
|
43
|
+
const WORKTREE_3 = path.join(WORKTREE_DIR, 'orphan-sprint');
|
|
44
|
+
const WORKTREE_4 = path.join(WORKTREE_DIR, 'broken-sprint');
|
|
45
|
+
const WORKTREE_5 = path.join(WORKTREE_DIR, 'stale-in-progress'); // worktree deleted but state exists
|
|
46
|
+
const CWD_SPRINT_DIR = path.join(TMP_ROOT, '.sprint-state');
|
|
47
|
+
|
|
48
|
+
const ACTIVE_SPRINT = {
|
|
49
|
+
id: 'sprint-2026-06-23-01',
|
|
50
|
+
phase: 2,
|
|
51
|
+
status: 'in_progress',
|
|
52
|
+
started_at: '2026-06-23T10:00:00Z',
|
|
53
|
+
task_description: 'Fix issue #247 sprint status worktree discovery',
|
|
54
|
+
isolation: {
|
|
55
|
+
worktree_path: WORKTREE_1,
|
|
56
|
+
branch: 'sprint/sprint-2026-06-23-01',
|
|
57
|
+
base_commit: 'abc1234',
|
|
58
|
+
},
|
|
59
|
+
phase_history: [
|
|
60
|
+
{ phase: -1, phase_name: 'ISOLATE', status: 'completed', started_at: '2026-06-23T09:00:00Z', completed_at: '2026-06-23T09:02:00Z', duration_seconds: 120 },
|
|
61
|
+
{ phase: 0, phase_name: 'THINK', status: 'completed', started_at: '2026-06-23T09:02:00Z', completed_at: '2026-06-23T09:30:00Z', duration_seconds: 1680 },
|
|
62
|
+
{ phase: 2, phase_name: 'BUILD', status: 'in_progress', started_at: '2026-06-23T10:00:00Z' },
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const COMPLETED_SPRINT = {
|
|
67
|
+
id: 'sprint-2026-06-23-02',
|
|
68
|
+
phase: 8,
|
|
69
|
+
status: 'completed',
|
|
70
|
+
started_at: '2026-06-22T10:00:00Z',
|
|
71
|
+
task_description: 'Already finished sprint',
|
|
72
|
+
isolation: {
|
|
73
|
+
worktree_path: WORKTREE_2,
|
|
74
|
+
branch: 'sprint/sprint-2026-06-23-02',
|
|
75
|
+
},
|
|
76
|
+
phase_history: [
|
|
77
|
+
{ phase: 8, phase_name: 'CLEANUP', status: 'completed' },
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const CWD_SPRINT = {
|
|
82
|
+
id: 'sprint-cwd-only',
|
|
83
|
+
phase: 1,
|
|
84
|
+
status: 'in_progress',
|
|
85
|
+
started_at: '2026-06-23T11:00:00Z',
|
|
86
|
+
task_description: 'Sprint running in cwd (no worktree)',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function writeTestJson(filePath: string, data: unknown) {
|
|
90
|
+
const dir = path.dirname(filePath);
|
|
91
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beforeAll(async () => {
|
|
96
|
+
// Setup git root marker
|
|
97
|
+
fs.mkdirSync(GIT_DIR, { recursive: true });
|
|
98
|
+
|
|
99
|
+
// Create worktree sprints
|
|
100
|
+
writeTestJson(path.join(WORKTREE_1, '.sprint-state', 'sprint-state.json'), ACTIVE_SPRINT);
|
|
101
|
+
writeTestJson(path.join(WORKTREE_2, '.sprint-state', 'sprint-state.json'), COMPLETED_SPRINT);
|
|
102
|
+
|
|
103
|
+
// orphan-sprint: directory exists but no .sprint-state/
|
|
104
|
+
fs.mkdirSync(WORKTREE_3, { recursive: true });
|
|
105
|
+
|
|
106
|
+
// broken-sprint: corrupted JSON
|
|
107
|
+
fs.mkdirSync(path.join(WORKTREE_4, '.sprint-state'), { recursive: true });
|
|
108
|
+
fs.writeFileSync(path.join(WORKTREE_4, '.sprint-state', 'sprint-state.json'), '{not valid json!!!');
|
|
109
|
+
|
|
110
|
+
// stale-in-progress: sprint-state.json exists but worktree dir deleted after read
|
|
111
|
+
// We simulate this by creating the state file in a temp location
|
|
112
|
+
writeTestJson(path.join(WORKTREE_DIR, 'stale-in-progress', '.sprint-state', 'sprint-state.json'), {
|
|
113
|
+
id: 'sprint-stale',
|
|
114
|
+
phase: 5,
|
|
115
|
+
status: 'in_progress',
|
|
116
|
+
started_at: '2026-06-20T10:00:00Z',
|
|
117
|
+
task_description: 'Stale sprint — worktree deleted',
|
|
118
|
+
isolation: { worktree_path: path.join(WORKTREE_DIR, 'stale-in-progress'), branch: 'sprint/stale' },
|
|
119
|
+
});
|
|
120
|
+
// Delete the worktree dir to simulate orphan
|
|
121
|
+
if (fs.existsSync(WORKTREE_5)) fs.rmSync(WORKTREE_5, { recursive: true });
|
|
122
|
+
|
|
123
|
+
// Load the module
|
|
124
|
+
try {
|
|
125
|
+
sprintDiscovery = await import('../sprint-discovery.js');
|
|
126
|
+
} catch {
|
|
127
|
+
// Module not yet created — tests will fail until GREEN phase
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
afterAll(() => {
|
|
132
|
+
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function skipIfMissing() {
|
|
136
|
+
if (!sprintDiscovery) {
|
|
137
|
+
console.warn('[SKIP] sprint-discovery module not yet implemented');
|
|
138
|
+
}
|
|
139
|
+
return !sprintDiscovery;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── findGitRoot ──
|
|
143
|
+
|
|
144
|
+
describe('sprint-discovery: findGitRoot', () => {
|
|
145
|
+
test('finds git root from within repo directory', () => {
|
|
146
|
+
if (skipIfMissing()) return;
|
|
147
|
+
const root = sprintDiscovery.findGitRoot(TMP_ROOT);
|
|
148
|
+
expect(root).toBe(TMP_ROOT);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('finds git root from a subdirectory', () => {
|
|
152
|
+
if (skipIfMissing()) return;
|
|
153
|
+
const subDir = path.join(TMP_ROOT, '.worktrees', 'sprint', 'sprint-2026-06-23-01');
|
|
154
|
+
const root = sprintDiscovery.findGitRoot(subDir);
|
|
155
|
+
expect(root).toBe(TMP_ROOT);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('returns null when no .git found in ancestors', () => {
|
|
159
|
+
if (skipIfMissing()) return;
|
|
160
|
+
const noGit = fs.mkdtempSync(path.join(os.tmpdir(), 'no-git-'));
|
|
161
|
+
try {
|
|
162
|
+
const root = sprintDiscovery.findGitRoot(noGit);
|
|
163
|
+
expect(root).toBeNull();
|
|
164
|
+
} finally {
|
|
165
|
+
fs.rmSync(noGit, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('returns null for non-existent path', () => {
|
|
170
|
+
if (skipIfMissing()) return;
|
|
171
|
+
const root = sprintDiscovery.findGitRoot('/nonexistent/path/foo/bar');
|
|
172
|
+
expect(root).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── discoverActiveSprints ──
|
|
177
|
+
|
|
178
|
+
describe('sprint-discovery: discoverActiveSprints', () => {
|
|
179
|
+
test('discovers active sprint from worktree directory', () => {
|
|
180
|
+
if (skipIfMissing()) return;
|
|
181
|
+
const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
|
|
182
|
+
const active = results.filter(r => r.state.id === 'sprint-2026-06-23-01');
|
|
183
|
+
expect(active.length).toBe(1);
|
|
184
|
+
expect(active[0].state.task_description).toBe('Fix issue #247 sprint status worktree discovery');
|
|
185
|
+
expect(active[0].state.status).toBe('in_progress');
|
|
186
|
+
expect(active[0].worktreeExists).toBe(true);
|
|
187
|
+
expect(active[0].sourcePath).toContain('.worktrees/sprint/sprint-2026-06-23-01');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('includes completed sprints with existing worktrees', () => {
|
|
191
|
+
if (skipIfMissing()) return;
|
|
192
|
+
const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
|
|
193
|
+
// sprint-2026-06-23-02 is completed but worktree still exists — should be included
|
|
194
|
+
const completed = results.filter(r => r.state.id === 'sprint-2026-06-23-02');
|
|
195
|
+
expect(completed.length).toBe(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('handles corrupted sprint-state.json without crashing', () => {
|
|
199
|
+
if (skipIfMissing()) return;
|
|
200
|
+
// broken-sprint dir exists with invalid JSON — should be silently skipped
|
|
201
|
+
expect(() => sprintDiscovery.discoverActiveSprints(TMP_ROOT)).not.toThrow();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('skips worktree subdir without .sprint-state/', () => {
|
|
205
|
+
if (skipIfMissing()) return;
|
|
206
|
+
const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
|
|
207
|
+
const orphans = results.filter(r => r.state.id === 'orphan-sprint');
|
|
208
|
+
expect(orphans.length).toBe(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('filters out stale sprint with deleted worktree directory', () => {
|
|
212
|
+
if (skipIfMissing()) return;
|
|
213
|
+
const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
|
|
214
|
+
const stale = results.filter(r => r.state.id === 'sprint-stale');
|
|
215
|
+
// worktreeExists should be false (dir deleted), so should be filtered
|
|
216
|
+
expect(stale.length).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('falls back to cwd .sprint-state/ when no worktrees', () => {
|
|
220
|
+
if (skipIfMissing()) return;
|
|
221
|
+
// Setup a separate dir with no worktrees but cwd sprint state
|
|
222
|
+
const fallbackDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fallback-'));
|
|
223
|
+
writeTestJson(path.join(fallbackDir, '.sprint-state', 'sprint-state.json'), CWD_SPRINT);
|
|
224
|
+
try {
|
|
225
|
+
const results = sprintDiscovery.discoverActiveSprints(fallbackDir);
|
|
226
|
+
const cwdResults = results.filter(r => r.state.id === 'sprint-cwd-only');
|
|
227
|
+
expect(cwdResults.length).toBe(1);
|
|
228
|
+
expect(cwdResults[0].worktreeExists).toBe(false);
|
|
229
|
+
} finally {
|
|
230
|
+
fs.rmSync(fallbackDir, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('returns empty array when no sprint states anywhere', () => {
|
|
235
|
+
if (skipIfMissing()) return;
|
|
236
|
+
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-'));
|
|
237
|
+
try {
|
|
238
|
+
const results = sprintDiscovery.discoverActiveSprints(emptyDir);
|
|
239
|
+
expect(results).toEqual([]);
|
|
240
|
+
} finally {
|
|
241
|
+
fs.rmSync(emptyDir, { recursive: true, force: true });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('sorts results by started_at descending', () => {
|
|
246
|
+
if (skipIfMissing()) return;
|
|
247
|
+
// sprint-2026-06-23-01 started 2026-06-23, cwd sprint started 2026-06-23 (same day, but different time)
|
|
248
|
+
// We only have one active sprint in this test setup, so this tests deterministic ordering
|
|
249
|
+
const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
|
|
250
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
251
|
+
// Verify sorting: each result's started_at should be >= next result's started_at
|
|
252
|
+
for (let i = 0; i < results.length - 1; i++) {
|
|
253
|
+
const a = String(results[i].state.started_at ?? '');
|
|
254
|
+
const b = String(results[i + 1].state.started_at ?? '');
|
|
255
|
+
expect(a >= b).toBe(true);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('caps results at MAX_RESULTS (5)', () => {
|
|
260
|
+
if (skipIfMissing()) return;
|
|
261
|
+
// Create 7 fake active sprints in worktree
|
|
262
|
+
const worktreeDir = path.join(TMP_ROOT, '.worktrees', 'sprint');
|
|
263
|
+
for (let i = 3; i <= 10; i++) {
|
|
264
|
+
const sprintId = `sprint-many-${String(i).padStart(2, '0')}`;
|
|
265
|
+
const sprintDir = path.join(worktreeDir, sprintId);
|
|
266
|
+
writeTestJson(path.join(sprintDir, '.sprint-state', 'sprint-state.json'), {
|
|
267
|
+
id: sprintId,
|
|
268
|
+
phase: 2,
|
|
269
|
+
status: 'in_progress',
|
|
270
|
+
started_at: `2026-06-${String(i).padStart(2, '0')}T10:00:00Z`,
|
|
271
|
+
task_description: `Many sprint ${i}`,
|
|
272
|
+
isolation: { worktree_path: sprintDir, branch: `sprint/many-${i}` },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
|
|
276
|
+
expect(results.length).toBeLessThanOrEqual(5);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('deduplicates sprints found in both worktree and cwd', () => {
|
|
280
|
+
if (skipIfMissing()) return;
|
|
281
|
+
// Write the same sprint ID to cwd .sprint-state/
|
|
282
|
+
writeTestJson(path.join(CWD_SPRINT_DIR, 'sprint-state.json'), {
|
|
283
|
+
...ACTIVE_SPRINT,
|
|
284
|
+
isolation: { ...ACTIVE_SPRINT.isolation, worktree_path: TMP_ROOT },
|
|
285
|
+
});
|
|
286
|
+
const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
|
|
287
|
+
const duplicates = results.filter(r => r.state.id === 'sprint-2026-06-23-01');
|
|
288
|
+
expect(duplicates.length).toBe(1, 'Should deduplicate to exactly 1 entry');
|
|
289
|
+
expect(duplicates[0].worktreeExists).toBe(true, 'Should prefer worktree version');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('tie-breaker: latest started_at wins when same sprint.id in two worktrees', () => {
|
|
293
|
+
if (skipIfMissing()) return;
|
|
294
|
+
// Create WS-A (older) and WS-B (newer) with same sprint.id
|
|
295
|
+
const wa = path.join(WORKTREE_DIR, 'tie-a');
|
|
296
|
+
const wb = path.join(WORKTREE_DIR, 'tie-b');
|
|
297
|
+
writeTestJson(path.join(wa, '.sprint-state', 'sprint-state.json'), {
|
|
298
|
+
id: 'sprint-tie',
|
|
299
|
+
started_at: '2026-06-23T10:00:00Z',
|
|
300
|
+
status: 'in_progress',
|
|
301
|
+
task_description: 'Tiebreaker A (older)',
|
|
302
|
+
isolation: { worktree_path: wa, branch: 'tie-a' },
|
|
303
|
+
});
|
|
304
|
+
writeTestJson(path.join(wb, '.sprint-state', 'sprint-state.json'), {
|
|
305
|
+
id: 'sprint-tie',
|
|
306
|
+
started_at: '2026-06-23T12:00:00Z',
|
|
307
|
+
status: 'in_progress',
|
|
308
|
+
task_description: 'Tiebreaker B (newer)',
|
|
309
|
+
isolation: { worktree_path: wb, branch: 'tie-b' },
|
|
310
|
+
});
|
|
311
|
+
const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
|
|
312
|
+
const ties = results.filter(r => r.state.id === 'sprint-tie');
|
|
313
|
+
expect(ties.length).toBe(1);
|
|
314
|
+
expect(ties[0].state.task_description).toContain('newer');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('handles sprint-state.json with valid JSON but missing required fields', () => {
|
|
318
|
+
if (skipIfMissing()) return;
|
|
319
|
+
const partialDir = path.join(WORKTREE_DIR, 'partial-fields');
|
|
320
|
+
writeTestJson(path.join(partialDir, '.sprint-state', 'sprint-state.json'), {
|
|
321
|
+
// no id, no status, no started_at
|
|
322
|
+
task_description: 'Partial fields only',
|
|
323
|
+
});
|
|
324
|
+
// Should not throw, result should be skipped (no valid id)
|
|
325
|
+
const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
|
|
326
|
+
const partials = results.filter(r => r.state.task_description === 'Partial fields only');
|
|
327
|
+
expect(partials.length).toBe(0, 'Sprint without id should be skipped');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('handles EACCES on .sprint-state/ directory gracefully', () => {
|
|
331
|
+
if (skipIfMissing()) return;
|
|
332
|
+
// We can't easily test EACCES in unit tests, but we verify the code has try/catch
|
|
333
|
+
// and does not throw. We test that the function at least doesn't crash on valid setups.
|
|
334
|
+
expect(() => sprintDiscovery.discoverActiveSprints(TMP_ROOT)).not.toThrow();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('finds sprints from cwd that is inside a worktree subdirectory', () => {
|
|
338
|
+
if (skipIfMissing()) return;
|
|
339
|
+
// Simulate: cwd = TMP_ROOT/.worktrees/sprint/sprint-2026-06-23-01 (inside worktree)
|
|
340
|
+
// Should walk up to find git root, then discover ALL worktree sprints
|
|
341
|
+
const insideWorktree = path.join(TMP_ROOT, '.worktrees', 'sprint', 'sprint-2026-06-23-01');
|
|
342
|
+
const results = sprintDiscovery.discoverActiveSprints(insideWorktree);
|
|
343
|
+
// Should find the active sprint (sprint-2026-06-23-01) even when called from inside it
|
|
344
|
+
const active = results.filter(r => r.state.id === 'sprint-2026-06-23-01');
|
|
345
|
+
expect(active.length).toBe(1);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('uses sprint ID as fallback title when task_description missing', () => {
|
|
349
|
+
if (skipIfMissing()) return;
|
|
350
|
+
const noDescDir = path.join(WORKTREE_DIR, 'no-description');
|
|
351
|
+
writeTestJson(path.join(noDescDir, '.sprint-state', 'sprint-state.json'), {
|
|
352
|
+
id: 'sprint-2026-06-23-nodesc',
|
|
353
|
+
phase: 0,
|
|
354
|
+
status: 'in_progress',
|
|
355
|
+
started_at: '2026-06-23T10:00:00Z',
|
|
356
|
+
isolation: { worktree_path: noDescDir, branch: 'sprint/no-desc' },
|
|
357
|
+
});
|
|
358
|
+
const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
|
|
359
|
+
const desc = results.filter(r => r.state.id === 'sprint-2026-06-23-nodesc');
|
|
360
|
+
expect(desc.length).toBe(1);
|
|
361
|
+
// task_description will be null/undefined, but entry should still be returned
|
|
362
|
+
expect(desc[0].state.task_description).toBeUndefined();
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprint state discovery module.
|
|
3
|
+
*
|
|
4
|
+
* Scans .worktrees/sprint/ subdirectories under a git repository root
|
|
5
|
+
* to discover active sprint states. Consumed by:
|
|
6
|
+
* - CLI: src/npm-package/lib/sprint-status.js (CommonJS)
|
|
7
|
+
* - TUI: plugins/opencode/tui-plugin.ts (inlined logic, this is the canonical source)
|
|
8
|
+
*
|
|
9
|
+
* Single source of truth for sprint discovery logic.
|
|
10
|
+
*
|
|
11
|
+
* @module sprint-discovery
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maximum number of active sprints to return.
|
|
19
|
+
* TUI displays top 3 (rest collapsed), CLI --all can show all.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_RESULTS = 5;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Walk upward from startDir to find the git repository root
|
|
25
|
+
* (first parent directory containing a .git/ subdirectory).
|
|
26
|
+
*
|
|
27
|
+
* @param {string} startDir - Starting directory
|
|
28
|
+
* @returns {string|null} Git root path, or null if not found
|
|
29
|
+
*/
|
|
30
|
+
function findGitRoot(startDir) {
|
|
31
|
+
let current = path.resolve(startDir);
|
|
32
|
+
const root = path.parse(current).root;
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
|
|
35
|
+
while (current !== root) {
|
|
36
|
+
if (seen.has(current)) break; // symlink loop guard
|
|
37
|
+
seen.add(current);
|
|
38
|
+
|
|
39
|
+
const gitPath = path.join(current, '.git');
|
|
40
|
+
try {
|
|
41
|
+
if (fs.existsSync(gitPath)) {
|
|
42
|
+
return current;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// EACCES or other filesystem error — skip this level
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const parent = path.dirname(current);
|
|
49
|
+
if (parent === current) break; // reached filesystem root
|
|
50
|
+
current = parent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check root level too
|
|
54
|
+
try {
|
|
55
|
+
if (fs.existsSync(path.join(root, '.git'))) return root;
|
|
56
|
+
} catch {
|
|
57
|
+
// ignore
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read and parse a sprint-state.json from a given project directory.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} dir - Directory containing .sprint-state/
|
|
67
|
+
* @returns {object|null} Parsed sprint state, or null if not found or malformed
|
|
68
|
+
*/
|
|
69
|
+
function readSprintState(dir) {
|
|
70
|
+
try {
|
|
71
|
+
const stateFile = path.join(dir, '.sprint-state', 'sprint-state.json');
|
|
72
|
+
if (!fs.existsSync(stateFile)) return null;
|
|
73
|
+
const raw = fs.readFileSync(stateFile, 'utf8');
|
|
74
|
+
return JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a worktree directory still exists on disk.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} worktreePath - Path to worktree root
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
function checkWorktreeExists(worktreePath) {
|
|
87
|
+
if (!worktreePath) return false;
|
|
88
|
+
try {
|
|
89
|
+
return fs.existsSync(worktreePath);
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Discover all active sprint states from a project directory.
|
|
97
|
+
*
|
|
98
|
+
* Discovery flow:
|
|
99
|
+
* 1. Walk upward from dir to find git root (look for .git/)
|
|
100
|
+
* 2. In git root, scan .worktrees/sprint/ subdirectories
|
|
101
|
+
* 3. For each sprint-* dir, read .sprint-state/sprint-state.json (per-entry try/catch)
|
|
102
|
+
* 4. Also read the original dir's own .sprint-state/ (compat with non-worktree mode)
|
|
103
|
+
* 5. Deduplicate by state.id, preferring worktree version
|
|
104
|
+
* 6. Filter out completed sprints with deleted worktrees
|
|
105
|
+
* 7. Sort by started_at descending, secondary sort by id descending
|
|
106
|
+
* 8. Cap at MAX_RESULTS
|
|
107
|
+
*
|
|
108
|
+
* @param {string} dir - Starting directory (typically process.cwd())
|
|
109
|
+
* @returns {Array<{ state: object, sourcePath: string, worktreeExists: boolean }>}
|
|
110
|
+
*/
|
|
111
|
+
function discoverActiveSprints(dir) {
|
|
112
|
+
const gitRoot = findGitRoot(dir);
|
|
113
|
+
const results = [];
|
|
114
|
+
|
|
115
|
+
// ── 1. Scan .worktrees/sprint/ subdirectories ──
|
|
116
|
+
if (gitRoot) {
|
|
117
|
+
const worktreeBase = path.join(gitRoot, '.worktrees', 'sprint');
|
|
118
|
+
let entries = [];
|
|
119
|
+
try {
|
|
120
|
+
if (fs.existsSync(worktreeBase)) {
|
|
121
|
+
entries = fs.readdirSync(worktreeBase, { withFileTypes: true });
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// EACCES or other error — skip worktree scanning
|
|
125
|
+
entries = [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (!entry.isDirectory()) continue;
|
|
130
|
+
|
|
131
|
+
const sprintDir = path.join(worktreeBase, entry.name);
|
|
132
|
+
const state = readSprintState(sprintDir);
|
|
133
|
+
if (!state || !state.id) continue; // skip corrupted or anonymous sprints
|
|
134
|
+
|
|
135
|
+
const worktreeExists = checkWorktreeExists(sprintDir);
|
|
136
|
+
|
|
137
|
+
// Filter: completed sprints with deleted worktrees are orphans
|
|
138
|
+
if (state.status === 'completed' && !worktreeExists) continue;
|
|
139
|
+
|
|
140
|
+
// Filter: in_progress sprints with deleted worktrees are stale orphans
|
|
141
|
+
if ((state.status === 'in_progress' || state.status === 'running') && !worktreeExists) continue;
|
|
142
|
+
|
|
143
|
+
results.push({
|
|
144
|
+
state,
|
|
145
|
+
sourcePath: path.join(sprintDir, '.sprint-state', 'sprint-state.json'),
|
|
146
|
+
worktreeExists,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── 2. Fallback: read from the original dir's .sprint-state/ ──
|
|
152
|
+
const localState = readSprintState(dir);
|
|
153
|
+
if (localState && localState.id) {
|
|
154
|
+
const localWorktreePath = localState.isolation?.worktree_path;
|
|
155
|
+
const localWorktreeExists = localWorktreePath ? checkWorktreeExists(localWorktreePath) : false;
|
|
156
|
+
|
|
157
|
+
// Only filter as orphan if sprint explicitly references a worktree
|
|
158
|
+
// that no longer exists. If no worktree_path at all, assume valid.
|
|
159
|
+
const hasExplicitWorktree = !!localWorktreePath;
|
|
160
|
+
if (!hasExplicitWorktree || localWorktreeExists) {
|
|
161
|
+
results.push({
|
|
162
|
+
state: localState,
|
|
163
|
+
sourcePath: path.join(dir, '.sprint-state', 'sprint-state.json'),
|
|
164
|
+
worktreeExists: localWorktreeExists,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── 3. Deduplicate by state.id ──
|
|
170
|
+
// Prefer worktree version (worktreeExists=true) over cwd fallback.
|
|
171
|
+
// Tie-breaker for same-id worktrees: latest started_at wins;
|
|
172
|
+
// if timestamps equal, lexicographically smallest sourcePath wins.
|
|
173
|
+
const deduped = new Map();
|
|
174
|
+
for (const entry of results) {
|
|
175
|
+
const id = entry.state.id;
|
|
176
|
+
const existing = deduped.get(id);
|
|
177
|
+
if (!existing) {
|
|
178
|
+
deduped.set(id, entry);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Prefer worktree version over cwd fallback
|
|
183
|
+
if (entry.worktreeExists && !existing.worktreeExists) {
|
|
184
|
+
deduped.set(id, entry);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!entry.worktreeExists && existing.worktreeExists) {
|
|
188
|
+
continue; // keep existing worktree version
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Both worktree or both cwd: tie-break by started_at
|
|
192
|
+
const entryTs = entry.state.started_at ? new Date(entry.state.started_at).getTime() : 0;
|
|
193
|
+
const existingTs = existing.state.started_at ? new Date(existing.state.started_at).getTime() : 0;
|
|
194
|
+
|
|
195
|
+
if (entryTs > existingTs) {
|
|
196
|
+
deduped.set(id, entry);
|
|
197
|
+
} else if (entryTs === existingTs && entry.sourcePath < existing.sourcePath) {
|
|
198
|
+
deduped.set(id, entry);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── 4. Sort by started_at descending, then id descending ──
|
|
203
|
+
const sorted = Array.from(deduped.values()).sort((a, b) => {
|
|
204
|
+
const aTs = a.state.started_at ? new Date(a.state.started_at).getTime() : 0;
|
|
205
|
+
const bTs = b.state.started_at ? new Date(b.state.started_at).getTime() : 0;
|
|
206
|
+
if (bTs !== aTs) return bTs - aTs;
|
|
207
|
+
// Secondary sort: id descending for deterministic order
|
|
208
|
+
return String(b.state.id).localeCompare(String(a.state.id));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── 5. Cap at MAX_RESULTS ──
|
|
212
|
+
return sorted.slice(0, MAX_RESULTS);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
discoverActiveSprints,
|
|
217
|
+
findGitRoot,
|
|
218
|
+
readSprintState,
|
|
219
|
+
MAX_RESULTS,
|
|
220
|
+
};
|
package/lib/sprint-status.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { PHASE_NAMES, PHASE_ORDER, getLatestTimestamp, isStale } = require('./shared-phase-constants');
|
|
13
|
+
const { discoverActiveSprints } = require('./sprint-discovery');
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Read and parse sprint-state.json from a project directory.
|
|
@@ -188,6 +189,7 @@ function jsonMode(state) {
|
|
|
188
189
|
async function handleSprintStatus(args = []) {
|
|
189
190
|
const jsonFlag = args.includes('--json');
|
|
190
191
|
const watchFlag = args.includes('--watch');
|
|
192
|
+
const allFlag = args.includes('--all');
|
|
191
193
|
const dirIdx = args.indexOf('--dir');
|
|
192
194
|
let searchDir = process.cwd();
|
|
193
195
|
|
|
@@ -200,6 +202,10 @@ async function handleSprintStatus(args = []) {
|
|
|
200
202
|
}
|
|
201
203
|
}
|
|
202
204
|
|
|
205
|
+
if (allFlag) {
|
|
206
|
+
return handleAllSprints(searchDir, jsonFlag, watchFlag);
|
|
207
|
+
}
|
|
208
|
+
|
|
203
209
|
const state = readSprintState(searchDir);
|
|
204
210
|
|
|
205
211
|
if (!state) {
|
|
@@ -221,6 +227,49 @@ async function handleSprintStatus(args = []) {
|
|
|
221
227
|
return 0;
|
|
222
228
|
}
|
|
223
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Handle --all mode: discover all active sprints across worktrees.
|
|
232
|
+
* @param {string} searchDir - Starting directory
|
|
233
|
+
* @param {boolean} jsonFlag - JSON output mode
|
|
234
|
+
* @param {boolean} watchFlag - Watch mode (not supported in --all)
|
|
235
|
+
* @returns {Promise<number>} Exit code
|
|
236
|
+
*/
|
|
237
|
+
async function handleAllSprints(searchDir, jsonFlag, watchFlag) {
|
|
238
|
+
if (watchFlag) {
|
|
239
|
+
console.error('Error: --watch is not supported with --all');
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const sprints = discoverActiveSprints(searchDir);
|
|
244
|
+
|
|
245
|
+
if (sprints.length === 0) {
|
|
246
|
+
console.log('No active sprints found');
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (jsonFlag) {
|
|
251
|
+
console.log(jsonMode(sprints.map(s => ({
|
|
252
|
+
...s.state,
|
|
253
|
+
source_path: s.sourcePath,
|
|
254
|
+
worktree_exists: s.worktreeExists,
|
|
255
|
+
}))));
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (let i = 0; i < sprints.length; i++) {
|
|
260
|
+
const { state, worktreeExists } = sprints[i];
|
|
261
|
+
console.log(formatSprintTable(state));
|
|
262
|
+
if (worktreeExists) {
|
|
263
|
+
console.log(` Worktree: ${state.isolation?.worktree_path || 'unknown'}`);
|
|
264
|
+
}
|
|
265
|
+
if (i < sprints.length - 1) {
|
|
266
|
+
console.log('\n' + '='.repeat(60) + '\n');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
224
273
|
/**
|
|
225
274
|
* Watch mode: listen for changes to the sprint state file.
|
|
226
275
|
* Prefers fs.watch(), falls back to fs.watchFile().
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-23
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** bac916f
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.10.
|
|
6
|
+
**Version:** 0.10.10.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-23
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** bac916f
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.10.
|
|
6
|
+
**Version:** 0.10.10.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.
|