@boyingliu01/opencode-plugin 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/index.ts
CHANGED
|
@@ -55,7 +55,7 @@ async function fetchNpmLatestVersion(url: string): Promise<string | null> {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function readCache(file: string): { ts: number; remoteVersion: string; status?: string } | null {
|
|
58
|
+
function readCache(file: string): { ts: number; remoteVersion: string; localVersion?: string; status?: string } | null {
|
|
59
59
|
try {
|
|
60
60
|
if (!existsSync(file)) return null
|
|
61
61
|
const raw = readFileSync(file, "utf8")
|
|
@@ -100,11 +100,16 @@ function getLocalXpGateVersion(): string | null {
|
|
|
100
100
|
|
|
101
101
|
async function checkXpGateUpdate(): Promise<UpgradeResult> {
|
|
102
102
|
const cached = readCache(XP_GATE_CACHE_FILE)
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
const localVersion = getLocalXpGateVersion()
|
|
104
|
+
|
|
105
|
+
// If cache exists and local version hasn't changed AND status is "current",
|
|
106
|
+
// the cache is still valid — skip network check
|
|
107
|
+
if (cached?.status === "current" && cached.remoteVersion && localVersion && cached.localVersion === localVersion) {
|
|
108
|
+
return { action: "noop", localVersion, remoteVersion: cached.remoteVersion }
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
|
|
111
|
+
// If we have a valid cache but local version changed (e.g., manual upgrade)
|
|
112
|
+
// or cache has no "current" status, we need to re-check remote
|
|
108
113
|
if (!localVersion) return { action: "noop", localVersion: null, remoteVersion: null }
|
|
109
114
|
|
|
110
115
|
const remoteVersion = await fetchNpmLatestVersion(XP_GATE_REGISTRY_URL)
|
|
@@ -174,6 +179,22 @@ async function checkPluginUpdate(pluginDir: string): Promise<void> {
|
|
|
174
179
|
}
|
|
175
180
|
}
|
|
176
181
|
|
|
182
|
+
// ── TUI upgrade notice ──
|
|
183
|
+
|
|
184
|
+
const UPGRADE_NOTICE_FILE = join(homedir(), ".xp-gate", "upgrade-notice.json")
|
|
185
|
+
|
|
186
|
+
type UpgradeNotice = {
|
|
187
|
+
kind: "upgraded" | "outdated" | "error"
|
|
188
|
+
localVersion: string | null
|
|
189
|
+
remoteVersion: string | null
|
|
190
|
+
message: string
|
|
191
|
+
ts: number
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function writeUpgradeNotice(notice: UpgradeNotice): void {
|
|
195
|
+
writeCache(UPGRADE_NOTICE_FILE, notice)
|
|
196
|
+
}
|
|
197
|
+
|
|
177
198
|
// ── Combined background check (runs once on first chat.message) ──
|
|
178
199
|
|
|
179
200
|
async function runBackgroundUpdates(pluginDir: string): Promise<string | null> {
|
|
@@ -181,10 +202,19 @@ async function runBackgroundUpdates(pluginDir: string): Promise<string | null> {
|
|
|
181
202
|
await checkPluginUpdate(pluginDir)
|
|
182
203
|
|
|
183
204
|
if (result.action === "upgraded") {
|
|
184
|
-
|
|
205
|
+
const msg = `[XP-Gate] Auto-upgraded from v${result.localVersion} to v${result.remoteVersion}`
|
|
206
|
+
writeUpgradeNotice({ kind: "upgraded", localVersion: result.localVersion, remoteVersion: result.remoteVersion, message: msg, ts: Date.now() })
|
|
207
|
+
return msg
|
|
185
208
|
}
|
|
186
209
|
if (result.action === "error") {
|
|
187
|
-
|
|
210
|
+
const msg = `[XP-Gate] Upgrade check: v${result.remoteVersion} available (auto-upgrade failed: ${result.error})`
|
|
211
|
+
writeUpgradeNotice({ kind: "error", localVersion: result.localVersion, remoteVersion: result.remoteVersion, message: msg, ts: Date.now() })
|
|
212
|
+
return msg
|
|
213
|
+
}
|
|
214
|
+
if (result.remoteVersion && result.localVersion && semverLt(result.localVersion, result.remoteVersion)) {
|
|
215
|
+
const msg = `[XP-Gate] New version v${result.remoteVersion} available (you have v${result.localVersion})`
|
|
216
|
+
writeUpgradeNotice({ kind: "outdated", localVersion: result.localVersion, remoteVersion: result.remoteVersion, message: msg, ts: Date.now() })
|
|
217
|
+
return msg
|
|
188
218
|
}
|
|
189
219
|
return null
|
|
190
220
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW 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
|
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-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
|
**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-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
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
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
|
|
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,17 @@
|
|
|
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"
|
|
20
|
+
import { homedir } from "node:os"
|
|
17
21
|
import type { TuiPlugin, TuiSlotPlugin, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
|
18
|
-
|
|
19
|
-
//
|
|
22
|
+
|
|
23
|
+
// ── Phase constants (inlined from ../../src/npm-package/lib/shared-phase-constants.js) ──
|
|
20
24
|
|
|
21
25
|
const PHASE_NAMES: Record<string, string> = {
|
|
22
26
|
'-1': 'ISOLATE',
|
|
@@ -34,30 +38,6 @@ const PHASE_NAMES: Record<string, string> = {
|
|
|
34
38
|
|
|
35
39
|
const PHASE_ORDER = ['-1', '-0.5', '0', '1', '2', '3', '4', '5', '6', '7', '8'];
|
|
36
40
|
|
|
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
41
|
// ── Sprint state schema ──
|
|
62
42
|
|
|
63
43
|
interface SprintReq {
|
|
@@ -86,16 +66,142 @@ interface SprintState {
|
|
|
86
66
|
phase_history?: SprintPhaseHistory[]
|
|
87
67
|
}
|
|
88
68
|
|
|
89
|
-
|
|
69
|
+
interface DiscoveredSprint {
|
|
70
|
+
state: SprintState
|
|
71
|
+
sourcePath: string
|
|
72
|
+
worktreeExists: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Discovery: inlined from src/npm-package/lib/sprint-discovery.js ──
|
|
76
|
+
|
|
77
|
+
const MAX_DISCOVERY_RESULTS = 5;
|
|
78
|
+
|
|
79
|
+
function findGitRoot(startDir: string): string | null {
|
|
80
|
+
let current = resolve(startDir);
|
|
81
|
+
const root = parse(current).root;
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
|
|
84
|
+
while (current !== root) {
|
|
85
|
+
if (seen.has(current)) break;
|
|
86
|
+
seen.add(current);
|
|
87
|
+
if (existsSync(join(current, '.git'))) return current;
|
|
88
|
+
const parent = dirname(current);
|
|
89
|
+
if (parent === current) break;
|
|
90
|
+
current = parent;
|
|
91
|
+
}
|
|
92
|
+
if (existsSync(join(root, '.git'))) return root;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
90
95
|
|
|
91
96
|
function readSprintState(dir: string): SprintState | null {
|
|
92
97
|
try {
|
|
93
|
-
const stateFile = join(dir,
|
|
94
|
-
if (!existsSync(stateFile)) return null
|
|
95
|
-
return JSON.parse(readFileSync(stateFile,
|
|
98
|
+
const stateFile = join(dir, '.sprint-state', 'sprint-state.json');
|
|
99
|
+
if (!existsSync(stateFile)) return null;
|
|
100
|
+
return JSON.parse(readFileSync(stateFile, 'utf8')) as SprintState;
|
|
96
101
|
} catch {
|
|
97
|
-
return null
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function checkWorktreeExists(worktreePath: string | undefined): boolean {
|
|
107
|
+
if (!worktreePath) return false;
|
|
108
|
+
try { return existsSync(worktreePath); } catch { return false; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function discoverActiveSprints(dir: string): DiscoveredSprint[] {
|
|
112
|
+
const gitRoot = findGitRoot(dir);
|
|
113
|
+
const results: DiscoveredSprint[] = [];
|
|
114
|
+
|
|
115
|
+
if (gitRoot) {
|
|
116
|
+
const worktreeBase = join(gitRoot, '.worktrees', 'sprint');
|
|
117
|
+
let entries: { name: string; isDirectory: () => boolean }[] = [];
|
|
118
|
+
try {
|
|
119
|
+
if (existsSync(worktreeBase)) {
|
|
120
|
+
entries = readdirSync(worktreeBase, { withFileTypes: true });
|
|
121
|
+
}
|
|
122
|
+
} catch { /* EACCES */ }
|
|
123
|
+
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (!entry.isDirectory()) continue;
|
|
126
|
+
const sprintDir = join(worktreeBase, entry.name);
|
|
127
|
+
const state = readSprintState(sprintDir);
|
|
128
|
+
if (!state?.id) continue;
|
|
129
|
+
const worktreeExists = checkWorktreeExists(sprintDir);
|
|
130
|
+
if (isStaleSprint(state) && !worktreeExists) continue;
|
|
131
|
+
|
|
132
|
+
results.push({ state, sourcePath: join(sprintDir, '.sprint-state', 'sprint-state.json'), worktreeExists });
|
|
133
|
+
}
|
|
98
134
|
}
|
|
135
|
+
|
|
136
|
+
// Fallback: cwd's own .sprint-state/
|
|
137
|
+
const localState = readSprintState(dir);
|
|
138
|
+
if (localState?.id) {
|
|
139
|
+
const localWorktreePath = localState.isolation?.worktree_path;
|
|
140
|
+
const hasExplicitWorktree = !!localWorktreePath;
|
|
141
|
+
const localWorktreeExists = hasExplicitWorktree ? checkWorktreeExists(localWorktreePath) : false;
|
|
142
|
+
if (!hasExplicitWorktree || localWorktreeExists) {
|
|
143
|
+
results.push({
|
|
144
|
+
state: localState,
|
|
145
|
+
sourcePath: join(dir, '.sprint-state', 'sprint-state.json'),
|
|
146
|
+
worktreeExists: localWorktreeExists,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Dedup by state.id
|
|
152
|
+
const deduped = new Map<string, DiscoveredSprint>();
|
|
153
|
+
for (const entry of results) {
|
|
154
|
+
const id = entry.state.id!;
|
|
155
|
+
const existing = deduped.get(id);
|
|
156
|
+
if (!existing) { deduped.set(id, entry); continue; }
|
|
157
|
+
if (entry.worktreeExists && !existing.worktreeExists) { deduped.set(id, entry); continue; }
|
|
158
|
+
if (!entry.worktreeExists && existing.worktreeExists) continue;
|
|
159
|
+
const entryTs = entry.state.started_at ? new Date(entry.state.started_at).getTime() : 0;
|
|
160
|
+
const existingTs = existing.state.started_at ? new Date(existing.state.started_at).getTime() : 0;
|
|
161
|
+
if (entryTs > existingTs || (entryTs === existingTs && entry.sourcePath < existing.sourcePath)) {
|
|
162
|
+
deduped.set(id, entry);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return Array.from(deduped.values())
|
|
167
|
+
.sort((a, b) => {
|
|
168
|
+
const aTs = a.state.started_at ? new Date(a.state.started_at).getTime() : 0;
|
|
169
|
+
const bTs = b.state.started_at ? new Date(b.state.started_at).getTime() : 0;
|
|
170
|
+
if (bTs !== aTs) return bTs - aTs;
|
|
171
|
+
return String(b.state.id).localeCompare(String(a.state.id));
|
|
172
|
+
})
|
|
173
|
+
.slice(0, MAX_DISCOVERY_RESULTS);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Cache (module-level, 5s TTL) ──
|
|
177
|
+
|
|
178
|
+
let _cache: { data: DiscoveredSprint[]; upgradeNotice: string | null; ts: number; dir: string } | null = null;
|
|
179
|
+
const CACHE_TTL_MS = 5_000;
|
|
180
|
+
|
|
181
|
+
// ── Helpers ──
|
|
182
|
+
|
|
183
|
+
function parseTime(value: unknown): number {
|
|
184
|
+
return new Date(value as string).getTime();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sprintTimestamp(state: SprintState): number {
|
|
188
|
+
if (!state.started_at) return 0;
|
|
189
|
+
const started = parseTime(state.started_at);
|
|
190
|
+
if (isNaN(started)) return 0;
|
|
191
|
+
let latest = started;
|
|
192
|
+
if (Array.isArray(state.phase_history)) {
|
|
193
|
+
for (const ph of state.phase_history) {
|
|
194
|
+
latest = ph.completed_at ? Math.max(latest, parseTime(ph.completed_at)) : latest;
|
|
195
|
+
latest = ph.started_at ? Math.max(latest, parseTime(ph.started_at)) : latest;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return latest;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isStaleSprint(state: SprintState | null): boolean {
|
|
202
|
+
if (!state?.started_at) return false;
|
|
203
|
+
const latest = sprintTimestamp(state);
|
|
204
|
+
return latest > 0 && Date.now() - latest > 3_600_000;
|
|
99
205
|
}
|
|
100
206
|
|
|
101
207
|
function statusSymbol(status: string | undefined, key: string, currentPhase: string | number | undefined): string {
|
|
@@ -131,8 +237,8 @@ function buildMetricsLine(metrics: SprintState["metrics"]): string | null {
|
|
|
131
237
|
}
|
|
132
238
|
|
|
133
239
|
function buildStaleWarning(state: SprintState): string | null {
|
|
134
|
-
if (!state
|
|
135
|
-
return
|
|
240
|
+
if (!state?.started_at) return null
|
|
241
|
+
return isStaleSprint(state) ? "⚠ idle >1h" : null
|
|
136
242
|
}
|
|
137
243
|
|
|
138
244
|
function renderBuildReqs(history: SprintPhaseHistory): string[] {
|
|
@@ -178,17 +284,107 @@ function renderSprintSidebar(state: SprintState): string {
|
|
|
178
284
|
return lines.join("\n")
|
|
179
285
|
}
|
|
180
286
|
|
|
287
|
+
function renderSprintTitle(state: SprintState): string {
|
|
288
|
+
if (state.task_description) return state.task_description;
|
|
289
|
+
if (state.id) {
|
|
290
|
+
// Fallback: extract date from sprint ID like "sprint-2026-06-23-01"
|
|
291
|
+
const match = state.id.match(/sprint-(\d{4}-\d{2}-\d{2})-(\d+)/);
|
|
292
|
+
if (match) return `Sprint ${match[1]} #${match[2]}`;
|
|
293
|
+
return state.id;
|
|
294
|
+
}
|
|
295
|
+
return 'Unknown Sprint';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function renderMultiSprintSidebar(sprints: DiscoveredSprint[]): string | null {
|
|
299
|
+
if (sprints.length === 0) return null;
|
|
300
|
+
|
|
301
|
+
const blocks: string[] = [];
|
|
302
|
+
const displayCount = Math.min(sprints.length, 3);
|
|
303
|
+
|
|
304
|
+
for (let i = 0; i < displayCount; i++) {
|
|
305
|
+
const { state } = sprints[i];
|
|
306
|
+
const title = renderSprintTitle(state);
|
|
307
|
+
const block: string[] = [`SPRINT: ${title}`];
|
|
308
|
+
|
|
309
|
+
if (state.isolation?.branch) {
|
|
310
|
+
block.push(` ${state.isolation.branch}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const metricsLine = buildMetricsLine(state.metrics);
|
|
314
|
+
if (metricsLine) block.push(` ${metricsLine}`);
|
|
315
|
+
|
|
316
|
+
const staleWarning = buildStaleWarning(state);
|
|
317
|
+
if (staleWarning) block.push(` ${staleWarning}`);
|
|
318
|
+
|
|
319
|
+
const historyByPhase = buildPhaseLookup(state);
|
|
320
|
+
block.push(...renderPhaseLines(historyByPhase, state.phase));
|
|
321
|
+
|
|
322
|
+
blocks.push(block.join("\n"));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (sprints.length > 3) {
|
|
326
|
+
blocks.push(`… +${sprints.length - 3} more`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return blocks.join("\n---\n");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function renderContent(sprints: DiscoveredSprint[], upgradeNotice: string | null): string | null {
|
|
333
|
+
const sprintContent = renderMultiSprintSidebar(sprints)
|
|
334
|
+
if (upgradeNotice && sprintContent) return `${upgradeNotice}\n---\n${sprintContent}`
|
|
335
|
+
if (upgradeNotice) return upgradeNotice
|
|
336
|
+
return sprintContent
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Upgrade notice ──
|
|
340
|
+
|
|
341
|
+
const UPGRADE_NOTICE_FILE = join(homedir(), ".xp-gate", "upgrade-notice.json")
|
|
342
|
+
const UPGRADE_NOTICE_TTL_MS = 86_400_000 // 24h
|
|
343
|
+
|
|
344
|
+
type UpgradeNotice = {
|
|
345
|
+
kind: string
|
|
346
|
+
localVersion: string | null
|
|
347
|
+
remoteVersion: string | null
|
|
348
|
+
message: string
|
|
349
|
+
ts: number
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function readUpgradeNotice(): UpgradeNotice | null {
|
|
353
|
+
try {
|
|
354
|
+
if (!existsSync(UPGRADE_NOTICE_FILE)) return null
|
|
355
|
+
const raw = readFileSync(UPGRADE_NOTICE_FILE, "utf8")
|
|
356
|
+
const data = JSON.parse(raw) as UpgradeNotice
|
|
357
|
+
if (Date.now() - data.ts < UPGRADE_NOTICE_TTL_MS && data.message) return data
|
|
358
|
+
return null
|
|
359
|
+
} catch {
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function renderUpgradeNotice(): string | null {
|
|
365
|
+
const notice = readUpgradeNotice()
|
|
366
|
+
if (!notice) return null
|
|
367
|
+
const icon = notice.kind === "upgraded" ? "✓" : notice.kind === "outdated" ? "↑" : "⚠"
|
|
368
|
+
return `${icon} ${notice.message}`
|
|
369
|
+
}
|
|
370
|
+
|
|
181
371
|
// ── TUI Slot Plugin ──
|
|
182
372
|
|
|
183
373
|
const tuiPlugin: TuiSlotPlugin = {
|
|
184
374
|
slots: {
|
|
185
375
|
sidebar_content: (_props: TuiSlotProps) => {
|
|
186
|
-
const dir = process.env.XP_GATE_PROJECT_DIR || process.cwd()
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
|
|
376
|
+
const dir = process.env.XP_GATE_PROJECT_DIR || process.cwd();
|
|
377
|
+
const now = Date.now();
|
|
378
|
+
|
|
379
|
+
// Use cache if still valid for current directory
|
|
380
|
+
if (_cache && _cache.dir === dir && now - _cache.ts < CACHE_TTL_MS) {
|
|
381
|
+
return renderContent(_cache.data, _cache.upgradeNotice);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const sprints = discoverActiveSprints(dir);
|
|
385
|
+
const upgradeNotice = renderUpgradeNotice()
|
|
386
|
+
_cache = { data: sprints, ts: now, dir, upgradeNotice };
|
|
387
|
+
return renderContent(sprints, upgradeNotice);
|
|
192
388
|
},
|
|
193
389
|
},
|
|
194
390
|
}
|