@boyingliu01/opencode-plugin 0.10.8 → 0.10.9

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/tui-plugin.ts +197 -42
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/opencode-plugin",
3
- "version": "0.10.8",
3
+ "version": "0.10.9",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "XP-Gate quality gates + AI workflow skills + Sprint Flow TUI sidebar for OpenCode",
package/tui-plugin.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * XP-Gate OpenCode TUI Slot Plugin
3
3
  *
4
4
  * Registers sidebar_content slot to display Sprint Flow progress
5
- * from .sprint-state/sprint-state.json.
5
+ * from active sprint states discovered in .worktrees/sprint/ subdirectories.
6
6
  *
7
7
  * This is a separate plugin file because SDK 1.x PluginModule does not
8
8
  * support server + tui in the same module. Users register this file
@@ -10,13 +10,16 @@
10
10
  * { "plugin": ["@boyingliu01/opencode-plugin/tui"] }
11
11
  *
12
12
  * The npm package exports "./tui" from package.json for this resolution.
13
+ *
14
+ * Discovery logic is inlined here (mirrors src/npm-package/lib/sprint-discovery.js)
15
+ * because the plugin ships separately from the CLI package at runtime.
13
16
  */
14
17
 
15
- import { existsSync, readFileSync } from "node:fs"
16
- import { join } from "node:path"
18
+ import { existsSync, readFileSync, readdirSync } from "node:fs"
19
+ import { join, dirname, resolve, parse } from "node:path"
17
20
  import type { TuiPlugin, TuiSlotPlugin, TuiSlotProps } from "@opencode-ai/plugin/tui"
18
- // ── Phase constants (inlined from ../../src/npm-package/lib/shared-phase-constants.js)
19
- // This file is inlined because the installed npm package does not bundle src/ at publish time. ──
21
+
22
+ // ── Phase constants (inlined from ../../src/npm-package/lib/shared-phase-constants.js) ──
20
23
 
21
24
  const PHASE_NAMES: Record<string, string> = {
22
25
  '-1': 'ISOLATE',
@@ -34,30 +37,6 @@ const PHASE_NAMES: Record<string, string> = {
34
37
 
35
38
  const PHASE_ORDER = ['-1', '-0.5', '0', '1', '2', '3', '4', '5', '6', '7', '8'];
36
39
 
37
- function parseTime(value: unknown): number {
38
- return new Date(value as string).getTime();
39
- }
40
-
41
- function isStale(state: SprintState | null): boolean {
42
- if (!state || !state.started_at) return false;
43
- const latest = sprintTimestamp(state);
44
- return latest > 0 && Date.now() - latest > 3600000;
45
- }
46
-
47
- function sprintTimestamp(state: SprintState | null): number {
48
- if (!state || !state.started_at) return 0;
49
- const started = parseTime(state.started_at);
50
- if (isNaN(started)) return 0;
51
- let latest = started;
52
- if (Array.isArray(state.phase_history)) {
53
- for (const ph of state.phase_history) {
54
- latest = ph.completed_at ? Math.max(latest, parseTime(ph.completed_at)) : latest;
55
- latest = ph.started_at ? Math.max(latest, parseTime(ph.started_at)) : latest;
56
- }
57
- }
58
- return latest;
59
- }
60
-
61
40
  // ── Sprint state schema ──
62
41
 
63
42
  interface SprintReq {
@@ -86,18 +65,144 @@ interface SprintState {
86
65
  phase_history?: SprintPhaseHistory[]
87
66
  }
88
67
 
89
- // ── Helpers ──
68
+ interface DiscoveredSprint {
69
+ state: SprintState
70
+ sourcePath: string
71
+ worktreeExists: boolean
72
+ }
73
+
74
+ // ── Discovery: inlined from src/npm-package/lib/sprint-discovery.js ──
75
+
76
+ const MAX_DISCOVERY_RESULTS = 5;
77
+
78
+ function findGitRoot(startDir: string): string | null {
79
+ let current = resolve(startDir);
80
+ const root = parse(current).root;
81
+ const seen = new Set<string>();
82
+
83
+ while (current !== root) {
84
+ if (seen.has(current)) break;
85
+ seen.add(current);
86
+ if (existsSync(join(current, '.git'))) return current;
87
+ const parent = dirname(current);
88
+ if (parent === current) break;
89
+ current = parent;
90
+ }
91
+ if (existsSync(join(root, '.git'))) return root;
92
+ return null;
93
+ }
90
94
 
91
95
  function readSprintState(dir: string): SprintState | null {
92
96
  try {
93
- const stateFile = join(dir, ".sprint-state", "sprint-state.json")
94
- if (!existsSync(stateFile)) return null
95
- return JSON.parse(readFileSync(stateFile, "utf8"))
97
+ const stateFile = join(dir, '.sprint-state', 'sprint-state.json');
98
+ if (!existsSync(stateFile)) return null;
99
+ return JSON.parse(readFileSync(stateFile, 'utf8')) as SprintState;
96
100
  } catch {
97
- return null
101
+ return null;
98
102
  }
99
103
  }
100
104
 
105
+ function checkWorktreeExists(worktreePath: string | undefined): boolean {
106
+ if (!worktreePath) return false;
107
+ try { return existsSync(worktreePath); } catch { return false; }
108
+ }
109
+
110
+ function discoverActiveSprints(dir: string): DiscoveredSprint[] {
111
+ const gitRoot = findGitRoot(dir);
112
+ const results: DiscoveredSprint[] = [];
113
+
114
+ if (gitRoot) {
115
+ const worktreeBase = join(gitRoot, '.worktrees', 'sprint');
116
+ let entries: { name: string; isDirectory: () => boolean }[] = [];
117
+ try {
118
+ if (existsSync(worktreeBase)) {
119
+ entries = readdirSync(worktreeBase, { withFileTypes: true });
120
+ }
121
+ } catch { /* EACCES */ }
122
+
123
+ for (const entry of entries) {
124
+ if (!entry.isDirectory()) continue;
125
+ const sprintDir = join(worktreeBase, entry.name);
126
+ const state = readSprintState(sprintDir);
127
+ if (!state?.id) continue;
128
+ const worktreeExists = checkWorktreeExists(sprintDir);
129
+ if (isStaleSprint(state) && !worktreeExists) continue;
130
+
131
+ results.push({ state, sourcePath: join(sprintDir, '.sprint-state', 'sprint-state.json'), worktreeExists });
132
+ }
133
+ }
134
+
135
+ // Fallback: cwd's own .sprint-state/
136
+ const localState = readSprintState(dir);
137
+ if (localState?.id) {
138
+ const localWorktreePath = localState.isolation?.worktree_path;
139
+ const hasExplicitWorktree = !!localWorktreePath;
140
+ const localWorktreeExists = hasExplicitWorktree ? checkWorktreeExists(localWorktreePath) : false;
141
+ if (!hasExplicitWorktree || localWorktreeExists) {
142
+ results.push({
143
+ state: localState,
144
+ sourcePath: join(dir, '.sprint-state', 'sprint-state.json'),
145
+ worktreeExists: localWorktreeExists,
146
+ });
147
+ }
148
+ }
149
+
150
+ // Dedup by state.id
151
+ const deduped = new Map<string, DiscoveredSprint>();
152
+ for (const entry of results) {
153
+ const id = entry.state.id!;
154
+ const existing = deduped.get(id);
155
+ if (!existing) { deduped.set(id, entry); continue; }
156
+ if (entry.worktreeExists && !existing.worktreeExists) { deduped.set(id, entry); continue; }
157
+ if (!entry.worktreeExists && existing.worktreeExists) continue;
158
+ const entryTs = entry.state.started_at ? new Date(entry.state.started_at).getTime() : 0;
159
+ const existingTs = existing.state.started_at ? new Date(existing.state.started_at).getTime() : 0;
160
+ if (entryTs > existingTs || (entryTs === existingTs && entry.sourcePath < existing.sourcePath)) {
161
+ deduped.set(id, entry);
162
+ }
163
+ }
164
+
165
+ return Array.from(deduped.values())
166
+ .sort((a, b) => {
167
+ const aTs = a.state.started_at ? new Date(a.state.started_at).getTime() : 0;
168
+ const bTs = b.state.started_at ? new Date(b.state.started_at).getTime() : 0;
169
+ if (bTs !== aTs) return bTs - aTs;
170
+ return String(b.state.id).localeCompare(String(a.state.id));
171
+ })
172
+ .slice(0, MAX_DISCOVERY_RESULTS);
173
+ }
174
+
175
+ // ── Cache (module-level, 5s TTL) ──
176
+
177
+ let _cache: { data: DiscoveredSprint[]; ts: number; dir: string } | null = null;
178
+ const CACHE_TTL_MS = 5_000;
179
+
180
+ // ── Helpers ──
181
+
182
+ function parseTime(value: unknown): number {
183
+ return new Date(value as string).getTime();
184
+ }
185
+
186
+ function sprintTimestamp(state: SprintState): number {
187
+ if (!state.started_at) return 0;
188
+ const started = parseTime(state.started_at);
189
+ if (isNaN(started)) return 0;
190
+ let latest = started;
191
+ if (Array.isArray(state.phase_history)) {
192
+ for (const ph of state.phase_history) {
193
+ latest = ph.completed_at ? Math.max(latest, parseTime(ph.completed_at)) : latest;
194
+ latest = ph.started_at ? Math.max(latest, parseTime(ph.started_at)) : latest;
195
+ }
196
+ }
197
+ return latest;
198
+ }
199
+
200
+ function isStaleSprint(state: SprintState | null): boolean {
201
+ if (!state?.started_at) return false;
202
+ const latest = sprintTimestamp(state);
203
+ return latest > 0 && Date.now() - latest > 3_600_000;
204
+ }
205
+
101
206
  function statusSymbol(status: string | undefined, key: string, currentPhase: string | number | undefined): string {
102
207
  if (status === "completed") return "✓"
103
208
  if (status === "in_progress") return "→"
@@ -131,8 +236,8 @@ function buildMetricsLine(metrics: SprintState["metrics"]): string | null {
131
236
  }
132
237
 
133
238
  function buildStaleWarning(state: SprintState): string | null {
134
- if (!state || !state.started_at) return null
135
- return isStale(state) ? "⚠ idle >1h" : null
239
+ if (!state?.started_at) return null
240
+ return isStaleSprint(state) ? "⚠ idle >1h" : null
136
241
  }
137
242
 
138
243
  function renderBuildReqs(history: SprintPhaseHistory): string[] {
@@ -178,17 +283,67 @@ function renderSprintSidebar(state: SprintState): string {
178
283
  return lines.join("\n")
179
284
  }
180
285
 
286
+ function renderSprintTitle(state: SprintState): string {
287
+ if (state.task_description) return state.task_description;
288
+ if (state.id) {
289
+ // Fallback: extract date from sprint ID like "sprint-2026-06-23-01"
290
+ const match = state.id.match(/sprint-(\d{4}-\d{2}-\d{2})-(\d+)/);
291
+ if (match) return `Sprint ${match[1]} #${match[2]}`;
292
+ return state.id;
293
+ }
294
+ return 'Unknown Sprint';
295
+ }
296
+
297
+ function renderMultiSprintSidebar(sprints: DiscoveredSprint[]): string | null {
298
+ if (sprints.length === 0) return null;
299
+
300
+ const blocks: string[] = [];
301
+ const displayCount = Math.min(sprints.length, 3);
302
+
303
+ for (let i = 0; i < displayCount; i++) {
304
+ const { state } = sprints[i];
305
+ const title = renderSprintTitle(state);
306
+ const block: string[] = [`SPRINT: ${title}`];
307
+
308
+ if (state.isolation?.branch) {
309
+ block.push(` ${state.isolation.branch}`);
310
+ }
311
+
312
+ const metricsLine = buildMetricsLine(state.metrics);
313
+ if (metricsLine) block.push(` ${metricsLine}`);
314
+
315
+ const staleWarning = buildStaleWarning(state);
316
+ if (staleWarning) block.push(` ${staleWarning}`);
317
+
318
+ const historyByPhase = buildPhaseLookup(state);
319
+ block.push(...renderPhaseLines(historyByPhase, state.phase));
320
+
321
+ blocks.push(block.join("\n"));
322
+ }
323
+
324
+ if (sprints.length > 3) {
325
+ blocks.push(`… +${sprints.length - 3} more`);
326
+ }
327
+
328
+ return blocks.join("\n---\n");
329
+ }
330
+
181
331
  // ── TUI Slot Plugin ──
182
332
 
183
333
  const tuiPlugin: TuiSlotPlugin = {
184
334
  slots: {
185
335
  sidebar_content: (_props: TuiSlotProps) => {
186
- const dir = process.env.XP_GATE_PROJECT_DIR || process.cwd()
187
- const state = readSprintState(dir)
188
- if (!state) return null
189
- const text = renderSprintSidebar(state)
190
- if (!text) return null
191
- return text
336
+ const dir = process.env.XP_GATE_PROJECT_DIR || process.cwd();
337
+ const now = Date.now();
338
+
339
+ // Use cache if still valid for current directory
340
+ if (_cache && _cache.dir === dir && now - _cache.ts < CACHE_TTL_MS) {
341
+ return renderMultiSprintSidebar(_cache.data);
342
+ }
343
+
344
+ const sprints = discoverActiveSprints(dir);
345
+ _cache = { data: sprints, ts: now, dir };
346
+ return renderMultiSprintSidebar(sprints);
192
347
  },
193
348
  },
194
349
  }