@boyingliu01/opencode-plugin 0.9.2 → 0.9.3
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 +158 -75
- package/package.json +14 -3
- 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/tsconfig.json +1 -1
- package/tui-plugin.ts +188 -0
package/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin"
|
|
2
2
|
import { z } from "zod"
|
|
3
|
-
import { exec } from "child_process"
|
|
4
|
-
import { promisify } from "util"
|
|
5
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
|
|
3
|
+
import { exec, execSync } from "node:child_process"
|
|
4
|
+
import { promisify } from "node:util"
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs"
|
|
6
6
|
import { join } from "node:path"
|
|
7
7
|
import { homedir } from "node:os"
|
|
8
8
|
|
|
@@ -13,6 +13,158 @@ interface OpenCodePluginInput {
|
|
|
13
13
|
$: (strings: TemplateStringsArray, ...values: unknown[]) => Promise<{ text(): Promise<string> }>
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// ── Constants ──
|
|
17
|
+
|
|
18
|
+
const CACHE_TTL_MS = 86_400_000 // 24h
|
|
19
|
+
const FETCH_TIMEOUT_MS = 5_000
|
|
20
|
+
|
|
21
|
+
const XP_GATE_NPM_PKG = "@boyingliu01/xp-gate"
|
|
22
|
+
const XP_GATE_CACHE_FILE = join(homedir(), ".xp-gate", "xp-gate-version-check.json")
|
|
23
|
+
const XP_GATE_REGISTRY_URL = `https://registry.npmjs.org/-/package/${encodeURIComponent(XP_GATE_NPM_PKG)}/dist-tags`
|
|
24
|
+
|
|
25
|
+
const OPENCODE_PLUGIN_REGISTRY = "https://registry.npmjs.org/-/package/@boyingliu01%2Fopencode-plugin/dist-tags"
|
|
26
|
+
const OPENCODE_CACHE_FILE = join(homedir(), ".xp-gate", "opencode-plugin-version-check.json")
|
|
27
|
+
|
|
28
|
+
let checked = false
|
|
29
|
+
|
|
30
|
+
// ── Utilities ──
|
|
31
|
+
|
|
32
|
+
function semverLt(a: string, b: string): boolean {
|
|
33
|
+
const pa = a.replace(/^v/, "").split(".").map(Number)
|
|
34
|
+
const pb = b.replace(/^v/, "").split(".").map(Number)
|
|
35
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
36
|
+
const na = pa[i] ?? 0
|
|
37
|
+
const nb = pb[i] ?? 0
|
|
38
|
+
if (na !== nb) return na < nb
|
|
39
|
+
}
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchNpmLatestVersion(url: string): Promise<string | null> {
|
|
44
|
+
const controller = new AbortController()
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(url, { signal: controller.signal })
|
|
48
|
+
if (!response.ok) return null
|
|
49
|
+
const data: Record<string, unknown> = await response.json()
|
|
50
|
+
return typeof data.latest === "string" ? data.latest : null
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readCache(file: string): { ts: number; remoteVersion: string; status?: string } | null {
|
|
59
|
+
try {
|
|
60
|
+
if (!existsSync(file)) return null
|
|
61
|
+
const raw = readFileSync(file, "utf8")
|
|
62
|
+
const data = JSON.parse(raw)
|
|
63
|
+
if (Date.now() - data.ts < CACHE_TTL_MS && data.remoteVersion) return data
|
|
64
|
+
return null
|
|
65
|
+
} catch {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeCache(file: string, data: object): void {
|
|
71
|
+
try {
|
|
72
|
+
mkdirSync(join(homedir(), ".xp-gate"), { recursive: true })
|
|
73
|
+
writeFileSync(file + ".tmp", JSON.stringify(data), "utf8")
|
|
74
|
+
try { rmSync(file) } catch {}
|
|
75
|
+
writeFileSync(file, readFileSync(file + ".tmp", "utf8"), "utf8")
|
|
76
|
+
try { rmSync(file + ".tmp") } catch {}
|
|
77
|
+
} catch {
|
|
78
|
+
// silent
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── XP-Gate npm package auto-update ──
|
|
83
|
+
|
|
84
|
+
type UpgradeResult = {
|
|
85
|
+
action: "noop" | "upgraded" | "error"
|
|
86
|
+
localVersion: string | null
|
|
87
|
+
remoteVersion: string | null
|
|
88
|
+
error?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getLocalXpGateVersion(): string | null {
|
|
92
|
+
try {
|
|
93
|
+
const globalRoot = execSync("npm root -g", { encoding: "utf8" }).trim()
|
|
94
|
+
const pkg = JSON.parse(readFileSync(join(globalRoot, XP_GATE_NPM_PKG, "package.json"), "utf8"))
|
|
95
|
+
return pkg.version || null
|
|
96
|
+
} catch {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function checkXpGateUpdate(): Promise<UpgradeResult> {
|
|
102
|
+
const cached = readCache(XP_GATE_CACHE_FILE)
|
|
103
|
+
if (cached?.status === "current" && cached.remoteVersion) {
|
|
104
|
+
return { action: "noop", localVersion: cached.remoteVersion, remoteVersion: cached.remoteVersion }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const localVersion = getLocalXpGateVersion()
|
|
108
|
+
if (!localVersion) return { action: "noop", localVersion: null, remoteVersion: null }
|
|
109
|
+
|
|
110
|
+
const remoteVersion = await fetchNpmLatestVersion(XP_GATE_REGISTRY_URL)
|
|
111
|
+
if (!remoteVersion) return { action: "noop", localVersion, remoteVersion: null }
|
|
112
|
+
|
|
113
|
+
if (!semverLt(localVersion, remoteVersion)) {
|
|
114
|
+
writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion, status: "current" })
|
|
115
|
+
return { action: "noop", localVersion, remoteVersion }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion })
|
|
119
|
+
try {
|
|
120
|
+
execSync(`npm install -g ${XP_GATE_NPM_PKG}@${remoteVersion}`, { stdio: "pipe", timeout: 120_000 })
|
|
121
|
+
writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion: remoteVersion, remoteVersion, status: "current" })
|
|
122
|
+
return { action: "upgraded", localVersion, remoteVersion }
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
125
|
+
return { action: "error", localVersion, remoteVersion, error: msg }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── OpenCode plugin version check (notification only) ──
|
|
130
|
+
|
|
131
|
+
async function checkPluginUpdate(pluginDir: string): Promise<void> {
|
|
132
|
+
const cached = readCache(OPENCODE_CACHE_FILE)
|
|
133
|
+
if (cached?.status === "current" && cached.remoteVersion) return
|
|
134
|
+
|
|
135
|
+
let localVersion = ""
|
|
136
|
+
try {
|
|
137
|
+
const pkg = JSON.parse(readFileSync(join(pluginDir, "package.json"), "utf8"))
|
|
138
|
+
localVersion = pkg.version || ""
|
|
139
|
+
} catch {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const remoteVersion = await fetchNpmLatestVersion(OPENCODE_PLUGIN_REGISTRY)
|
|
144
|
+
if (!remoteVersion) return
|
|
145
|
+
|
|
146
|
+
if (remoteVersion && localVersion && semverLt(localVersion, remoteVersion)) {
|
|
147
|
+
writeCache(OPENCODE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion })
|
|
148
|
+
} else if (remoteVersion && localVersion) {
|
|
149
|
+
writeCache(OPENCODE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion, status: "current" })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Combined background check (runs once on first chat.message) ──
|
|
154
|
+
|
|
155
|
+
async function runBackgroundUpdates(pluginDir: string): Promise<string | null> {
|
|
156
|
+
const result = await checkXpGateUpdate()
|
|
157
|
+
await checkPluginUpdate(pluginDir)
|
|
158
|
+
|
|
159
|
+
if (result.action === "upgraded") {
|
|
160
|
+
return `[XP-Gate] Auto-upgraded from v${result.localVersion} to v${result.remoteVersion}`
|
|
161
|
+
}
|
|
162
|
+
if (result.action === "error") {
|
|
163
|
+
return `[XP-Gate] Upgrade check: v${result.remoteVersion} available (auto-upgrade failed: ${result.error})`
|
|
164
|
+
}
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
16
168
|
async function runCmd(cmd: string, cwd: string): Promise<string> {
|
|
17
169
|
try {
|
|
18
170
|
const { stdout } = await execAsync(cmd, { cwd, timeout: 30000 })
|
|
@@ -49,77 +201,6 @@ async function getUpgradeSuggestion(cwd: string): Promise<string> {
|
|
|
49
201
|
}
|
|
50
202
|
}
|
|
51
203
|
|
|
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
204
|
// ── Plugin definition ──
|
|
124
205
|
|
|
125
206
|
export const XpGatePlugin = async (input: OpenCodePluginInput) => {
|
|
@@ -184,7 +265,9 @@ export const XpGatePlugin = async (input: OpenCodePluginInput) => {
|
|
|
184
265
|
"chat.message": async (_input: { message: string }) => {
|
|
185
266
|
if (!checked) {
|
|
186
267
|
checked = true
|
|
187
|
-
|
|
268
|
+
runBackgroundUpdates(directory).then((msg) => {
|
|
269
|
+
if (msg) process.stderr.write(`${msg}\n`)
|
|
270
|
+
})
|
|
188
271
|
}
|
|
189
272
|
return { action: "continue" }
|
|
190
273
|
},
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boyingliu01/opencode-plugin",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
|
-
"description": "XP-Gate quality gates + AI workflow skills for OpenCode",
|
|
6
|
+
"description": "XP-Gate quality gates + AI workflow skills + Sprint Flow TUI sidebar for OpenCode",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/boyingliu01/xp-gate",
|
|
@@ -13,8 +13,19 @@
|
|
|
13
13
|
"registry": "https://registry.npmjs.org",
|
|
14
14
|
"access": "public"
|
|
15
15
|
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./index.ts",
|
|
19
|
+
"types": "./index.ts"
|
|
20
|
+
},
|
|
21
|
+
"./tui": {
|
|
22
|
+
"import": "./tui-plugin.ts",
|
|
23
|
+
"types": "./tui-plugin.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
16
26
|
"files": [
|
|
17
27
|
"index.ts",
|
|
28
|
+
"tui-plugin.ts",
|
|
18
29
|
"skills/",
|
|
19
30
|
"tsconfig.json",
|
|
20
31
|
"README.md"
|
|
@@ -24,7 +35,7 @@
|
|
|
24
35
|
"check": "tsc --noEmit"
|
|
25
36
|
},
|
|
26
37
|
"dependencies": {
|
|
27
|
-
"@opencode-ai/plugin": "^1.
|
|
38
|
+
"@opencode-ai/plugin": "^1.17.8"
|
|
28
39
|
},
|
|
29
40
|
"devDependencies": {
|
|
30
41
|
"typescript": "^5.4.0"
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-06-18
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** b67fc85
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.9.
|
|
6
|
+
**Version:** 0.9.3.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-18
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** b67fc85
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.9.
|
|
6
|
+
**Version:** 0.9.3.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-18
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** b67fc85
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.9.
|
|
6
|
+
**Version:** 0.9.3.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
package/tsconfig.json
CHANGED
package/tui-plugin.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XP-Gate OpenCode TUI Slot Plugin
|
|
3
|
+
*
|
|
4
|
+
* Registers sidebar_content slot to display Sprint Flow progress
|
|
5
|
+
* from .sprint-state/sprint-state.json.
|
|
6
|
+
*
|
|
7
|
+
* This is a separate plugin file because SDK 1.x PluginModule does not
|
|
8
|
+
* support server + tui in the same module. Users register this file
|
|
9
|
+
* in ~/.config/opencode/tui.json as:
|
|
10
|
+
* { "plugin": ["@boyingliu01/opencode-plugin/tui"] }
|
|
11
|
+
*
|
|
12
|
+
* The npm package exports "./tui" from package.json for this resolution.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
import type { TuiPlugin, TuiSlotPlugin, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
|
18
|
+
|
|
19
|
+
// ── Sprint state schema ──
|
|
20
|
+
|
|
21
|
+
interface SprintReq {
|
|
22
|
+
name?: string
|
|
23
|
+
status?: "completed" | "in_progress" | "pending"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SprintPhaseHistory {
|
|
27
|
+
phase: number | string
|
|
28
|
+
phase_name?: string
|
|
29
|
+
status?: "completed" | "in_progress" | "pending"
|
|
30
|
+
started_at?: string
|
|
31
|
+
completed_at?: string
|
|
32
|
+
duration_seconds?: number
|
|
33
|
+
reqs?: Record<string, SprintReq>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SprintState {
|
|
37
|
+
id?: string
|
|
38
|
+
phase?: number | string
|
|
39
|
+
status?: string
|
|
40
|
+
started_at?: string
|
|
41
|
+
task_description?: string
|
|
42
|
+
isolation?: { branch?: string; worktree_path?: string }
|
|
43
|
+
metrics?: { tests_passed?: number; tests_failed?: number; coverage_pct?: number }
|
|
44
|
+
phase_history?: SprintPhaseHistory[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Constants ──
|
|
48
|
+
|
|
49
|
+
const PHASE_NAMES: Record<string, string> = {
|
|
50
|
+
"-1": "ISOLATE",
|
|
51
|
+
"-0.5": "AUTO-ESTIMATE",
|
|
52
|
+
"0": "THINK",
|
|
53
|
+
"1": "PLAN",
|
|
54
|
+
"2": "BUILD",
|
|
55
|
+
"3": "REVIEW",
|
|
56
|
+
"4": "USER ACCEPT",
|
|
57
|
+
"5": "FEEDBACK",
|
|
58
|
+
"6": "SHIP",
|
|
59
|
+
"7": "LAND",
|
|
60
|
+
"8": "CLEANUP",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PHASE_ORDER = ["-1", "-0.5", "0", "1", "2", "3", "4", "5", "6", "7", "8"]
|
|
64
|
+
|
|
65
|
+
// ── Helpers ──
|
|
66
|
+
|
|
67
|
+
function readSprintState(dir: string): SprintState | null {
|
|
68
|
+
try {
|
|
69
|
+
const stateFile = join(dir, ".sprint-state", "sprint-state.json")
|
|
70
|
+
if (!existsSync(stateFile)) return null
|
|
71
|
+
return JSON.parse(readFileSync(stateFile, "utf8"))
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isStale(state: SprintState): boolean {
|
|
78
|
+
if (!state || !state.started_at) return false
|
|
79
|
+
const started = new Date(state.started_at).getTime()
|
|
80
|
+
if (isNaN(started)) return false
|
|
81
|
+
let latest = started
|
|
82
|
+
if (Array.isArray(state.phase_history)) {
|
|
83
|
+
for (const ph of state.phase_history) {
|
|
84
|
+
if (ph.completed_at) {
|
|
85
|
+
const t = new Date(ph.completed_at).getTime()
|
|
86
|
+
if (!isNaN(t) && t > latest) latest = t
|
|
87
|
+
}
|
|
88
|
+
if (ph.started_at) {
|
|
89
|
+
const t = new Date(ph.started_at).getTime()
|
|
90
|
+
if (!isNaN(t) && t > latest) latest = t
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Date.now() - latest > 3_600_000
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function statusSymbol(status: string | undefined, key: string, currentPhase: string | number | undefined): string {
|
|
98
|
+
if (status === "completed") return "✓"
|
|
99
|
+
if (status === "in_progress") return "→"
|
|
100
|
+
if (String(currentPhase) === key) return "·"
|
|
101
|
+
return "○"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderPhaseLine(key: string, history: SprintPhaseHistory | undefined, currentPhase: string | number | undefined): string {
|
|
105
|
+
const name = history?.phase_name || PHASE_NAMES[key] || key
|
|
106
|
+
const status = history?.status || (String(currentPhase) === key ? "in_progress" : "pending")
|
|
107
|
+
const sym = statusSymbol(status, key, currentPhase)
|
|
108
|
+
return `${sym} ${name.padEnd(14)} ${status === "completed" ? "done" : status === "in_progress" ? "active" : ""}`
|
|
109
|
+
.replace(/\s+$/, "")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderSprintSidebar(state: SprintState): string {
|
|
113
|
+
if (!state || !state.task_description) return ""
|
|
114
|
+
|
|
115
|
+
const lines: string[] = []
|
|
116
|
+
const metrics = state.metrics || {}
|
|
117
|
+
const currentPhase = state.phase
|
|
118
|
+
|
|
119
|
+
// Build lookup
|
|
120
|
+
const historyByPhase: Record<string, SprintPhaseHistory> = {}
|
|
121
|
+
if (Array.isArray(state.phase_history)) {
|
|
122
|
+
for (const ph of state.phase_history) {
|
|
123
|
+
historyByPhase[String(ph.phase)] = ph
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Title
|
|
128
|
+
lines.push(`SPRINT: ${state.task_description}`)
|
|
129
|
+
|
|
130
|
+
// Metrics
|
|
131
|
+
const metricParts: string[] = []
|
|
132
|
+
if (metrics.tests_passed != null) {
|
|
133
|
+
metricParts.push(`tests:${metrics.tests_passed}`)
|
|
134
|
+
}
|
|
135
|
+
if (metrics.coverage_pct != null) {
|
|
136
|
+
metricParts.push(`cov:${metrics.coverage_pct}%`)
|
|
137
|
+
}
|
|
138
|
+
if (metricParts.length > 0) {
|
|
139
|
+
lines.push(metricParts.join(" "))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Stale warning
|
|
143
|
+
if (isStale(state)) {
|
|
144
|
+
lines.push("⚠ idle >1h")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Phase progress
|
|
148
|
+
for (const key of PHASE_ORDER) {
|
|
149
|
+
const history = historyByPhase[key]
|
|
150
|
+
// Only show phases with activity or current
|
|
151
|
+
if (!history && String(currentPhase) !== key) continue
|
|
152
|
+
const line = renderPhaseLine(key, history, currentPhase)
|
|
153
|
+
lines.push(line)
|
|
154
|
+
|
|
155
|
+
// REQ-level progress for BUILD phase
|
|
156
|
+
if (key === "2" && history?.reqs) {
|
|
157
|
+
const reqNames = Object.entries(history.reqs)
|
|
158
|
+
.filter(([, r]) => r.name)
|
|
159
|
+
.map(([id, r]) => ` ${statusSymbol(r.status, id, undefined)} ${r.name}`)
|
|
160
|
+
if (reqNames.length > 0) lines.push(...reqNames)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return lines.join("\n")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── TUI Slot Plugin ──
|
|
168
|
+
|
|
169
|
+
const tuiPlugin: TuiSlotPlugin = {
|
|
170
|
+
slots: {
|
|
171
|
+
sidebar_content: (_props: TuiSlotProps) => {
|
|
172
|
+
const dir = process.env.XP_GATE_PROJECT_DIR || process.cwd()
|
|
173
|
+
const state = readSprintState(dir)
|
|
174
|
+
if (!state) return null
|
|
175
|
+
const text = renderSprintSidebar(state)
|
|
176
|
+
if (!text) return null
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
178
|
+
return text as any
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Wrap as TuiPlugin (async factory)
|
|
184
|
+
const plugin: TuiPlugin = async (api, _options, _meta) => {
|
|
185
|
+
api.slots.register(tuiPlugin)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export { plugin as tui, readSprintState, renderSprintSidebar }
|