@boyingliu01/opencode-plugin 0.12.3 → 0.12.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/opencode-plugin",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
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",
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-07-03
4
- **Commit:** 83b39b6
4
+ **Commit:** c3ed581
5
5
  **Branch:** main
6
- **Version:** 0.12.3.0
6
+ **Version:** 0.12.4.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-07-03
4
- **Commit:** 83b39b6
4
+ **Commit:** c3ed581
5
5
  **Branch:** main
6
- **Version:** 0.12.3.0
6
+ **Version:** 0.12.4.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **11-phase** development pipeline: ISOLATE → AUTO-ESTIMATE → THINK → PLAN → BUILD → REVIEW → SHIP → LAND → USER ACCEPTANCE → FEEDBACK → 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-07-03
4
- **Commit:** 83b39b6
4
+ **Commit:** c3ed581
5
5
  **Branch:** main
6
- **Version:** 0.12.3.0
6
+ **Version:** 0.12.4.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 DELETED
@@ -1,414 +0,0 @@
1
- /**
2
- * XP-Gate OpenCode TUI Slot Plugin
3
- *
4
- * Registers sidebar_content slot to display Sprint Flow progress
5
- * from active sprint states discovered in .worktrees/sprint/ subdirectories.
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
- * 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.
16
- */
17
-
18
- import { existsSync, readFileSync, readdirSync } from "node:fs"
19
- import { join, dirname, resolve, parse } from "node:path"
20
- import { homedir } from "node:os"
21
- import type { TuiPlugin, TuiSlotPlugin, TuiSlotProps } from "@opencode-ai/plugin/tui"
22
-
23
- // ── Phase constants (inlined from ../../src/npm-package/lib/shared-phase-constants.js) ──
24
-
25
- const PHASE_NAMES: Record<string, string> = {
26
- '-1': 'ISOLATE',
27
- '-0.5': 'AUTO-ESTIMATE',
28
- '0': 'THINK',
29
- '1': 'PLAN',
30
- '2': 'BUILD',
31
- '3': 'REVIEW',
32
- '4': 'USER ACCEPT',
33
- '5': 'FEEDBACK',
34
- '6': 'SHIP',
35
- '7': 'LAND',
36
- '8': 'CLEANUP',
37
- };
38
-
39
- const PHASE_ORDER = ['-1', '-0.5', '0', '1', '2', '3', '4', '5', '6', '7', '8'];
40
-
41
- // ── Sprint state schema ──
42
-
43
- interface SprintReq {
44
- name?: string
45
- status?: "completed" | "in_progress" | "pending"
46
- }
47
-
48
- interface SprintPhaseHistory {
49
- phase: number | string
50
- phase_name?: string
51
- status?: "completed" | "in_progress" | "pending"
52
- started_at?: string
53
- completed_at?: string
54
- duration_seconds?: number
55
- reqs?: Record<string, SprintReq>
56
- }
57
-
58
- interface SprintState {
59
- id?: string
60
- phase?: number | string
61
- status?: string
62
- started_at?: string
63
- task_description?: string
64
- isolation?: { branch?: string; worktree_path?: string }
65
- metrics?: { tests_passed?: number; tests_failed?: number; coverage_pct?: number }
66
- phase_history?: SprintPhaseHistory[]
67
- }
68
-
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
- }
95
-
96
- function readSprintState(dir: string): SprintState | null {
97
- try {
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;
101
- } catch {
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
- }
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;
205
- }
206
-
207
- function statusSymbol(status: string | undefined, key: string, currentPhase: string | number | undefined): string {
208
- if (status === "completed") return "✓"
209
- if (status === "in_progress") return "→"
210
- if (String(currentPhase) === key) return "·"
211
- return "○"
212
- }
213
-
214
- function renderPhaseLine(key: string, history: SprintPhaseHistory | undefined, currentPhase: string | number | undefined): string {
215
- const name = history?.phase_name || PHASE_NAMES[key] || key
216
- const status = history?.status || (String(currentPhase) === key ? "in_progress" : "pending")
217
- const sym = statusSymbol(status, key, currentPhase)
218
- return `${sym} ${name.padEnd(14)} ${status === "completed" ? "done" : status === "in_progress" ? "active" : ""}`
219
- .replace(/\s+$/, "")
220
- }
221
-
222
- function buildPhaseLookup(state: SprintState): Record<string, SprintPhaseHistory> {
223
- const lookup: Record<string, SprintPhaseHistory> = {}
224
- if (Array.isArray(state.phase_history)) {
225
- for (const ph of state.phase_history) {
226
- lookup[String(ph.phase)] = ph
227
- }
228
- }
229
- return lookup
230
- }
231
-
232
- function buildMetricsLine(metrics: SprintState["metrics"]): string | null {
233
- const parts: string[] = []
234
- if (metrics?.tests_passed != null) parts.push(`tests:${metrics.tests_passed}`)
235
- if (metrics?.coverage_pct != null) parts.push(`cov:${metrics.coverage_pct}%`)
236
- return parts.length > 0 ? parts.join(" ") : null
237
- }
238
-
239
- function buildStaleWarning(state: SprintState): string | null {
240
- if (!state?.started_at) return null
241
- return isStaleSprint(state) ? "⚠ idle >1h" : null
242
- }
243
-
244
- function renderBuildReqs(history: SprintPhaseHistory): string[] {
245
- if (!history.reqs) return []
246
- return Object.entries(history.reqs)
247
- .filter(([, r]) => r.name)
248
- .map(([id, r]) => ` ${statusSymbol(r.status, id, undefined)} ${r.name}`)
249
- }
250
-
251
- function appendBuildReqs(lines: string[], key: string, history: SprintPhaseHistory | undefined): void {
252
- if (key === "2" && history?.reqs) {
253
- lines.push(...renderBuildReqs(history))
254
- }
255
- }
256
-
257
- function renderPhaseLines(
258
- historyByPhase: Record<string, SprintPhaseHistory>,
259
- currentPhase: string | number | undefined,
260
- ): string[] {
261
- const lines: string[] = []
262
- for (const key of PHASE_ORDER) {
263
- const history = historyByPhase[key]
264
- if (!history && String(currentPhase) !== key) continue
265
- lines.push(renderPhaseLine(key, history, currentPhase))
266
- appendBuildReqs(lines, key, history)
267
- }
268
- return lines
269
- }
270
-
271
- function renderSprintSidebar(state: SprintState): string {
272
- if (!state || !state.task_description) return ""
273
-
274
- const lines: string[] = [`SPRINT: ${state.task_description}`]
275
- const metricsLine = buildMetricsLine(state.metrics)
276
- if (metricsLine) lines.push(metricsLine)
277
-
278
- const staleWarning = buildStaleWarning(state)
279
- if (staleWarning) lines.push(staleWarning)
280
-
281
- const historyByPhase = buildPhaseLookup(state)
282
- lines.push(...renderPhaseLines(historyByPhase, state.phase))
283
-
284
- return lines.join("\n")
285
- }
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, dir: string): string | null {
333
- const sprintContent = renderMultiSprintSidebar(sprints)
334
-
335
- if (sprintContent) {
336
- return [upgradeNotice, sprintContent].filter(Boolean).join("\n---\n")
337
- }
338
-
339
- // Early-phase placeholders: when no sprint data yet, check for directory hints
340
- const hasStateDir = existsSync(join(dir, ".sprint-state"))
341
- const gitRoot = findGitRoot(dir)
342
- const hasWorktreesRoot = gitRoot ? existsSync(join(gitRoot, ".worktrees")) : false
343
-
344
- if (hasStateDir) {
345
- const placeholder = "SPRINT FLOW\n → 初始化中..."
346
- return [upgradeNotice, placeholder].filter(Boolean).join("\n---\n")
347
- }
348
-
349
- if (hasWorktreesRoot) {
350
- const placeholder = "SPRINT FLOW\n · 准备中..."
351
- return [upgradeNotice, placeholder].filter(Boolean).join("\n---\n")
352
- }
353
-
354
- return upgradeNotice || null
355
- }
356
-
357
- // ── Upgrade notice ──
358
-
359
- const UPGRADE_NOTICE_FILE = join(homedir(), ".xp-gate", "upgrade-notice.json")
360
- const UPGRADE_NOTICE_TTL_MS = 86_400_000 // 24h
361
-
362
- type UpgradeNotice = {
363
- kind: string
364
- localVersion: string | null
365
- remoteVersion: string | null
366
- message: string
367
- ts: number
368
- }
369
-
370
- function readUpgradeNotice(): UpgradeNotice | null {
371
- try {
372
- if (!existsSync(UPGRADE_NOTICE_FILE)) return null
373
- const raw = readFileSync(UPGRADE_NOTICE_FILE, "utf8")
374
- const data = JSON.parse(raw) as UpgradeNotice
375
- if (Date.now() - data.ts < UPGRADE_NOTICE_TTL_MS && data.message) return data
376
- return null
377
- } catch {
378
- return null
379
- }
380
- }
381
-
382
- function renderUpgradeNotice(): string | null {
383
- const notice = readUpgradeNotice()
384
- if (!notice) return null
385
- const icon = notice.kind === "upgraded" ? "✓" : notice.kind === "outdated" ? "↑" : "⚠"
386
- return `${icon} ${notice.message}`
387
- }
388
-
389
- // ── TUI Slot Plugin ──
390
-
391
- const tuiPlugin: TuiSlotPlugin = {
392
- slots: {
393
- sidebar_content: (_props: TuiSlotProps) => {
394
- const dir = process.env.XP_GATE_PROJECT_DIR || process.cwd();
395
- const now = Date.now();
396
-
397
- if (_cache && _cache.dir === dir && now - _cache.ts < CACHE_TTL_MS) {
398
- return renderContent(_cache.data, _cache.upgradeNotice, dir);
399
- }
400
-
401
- const sprints = discoverActiveSprints(dir);
402
- const upgradeNotice = renderUpgradeNotice()
403
- _cache = { data: sprints, ts: now, dir, upgradeNotice };
404
- return renderContent(sprints, upgradeNotice, dir);
405
- },
406
- },
407
- }
408
-
409
- // Wrap as TuiPlugin (async factory)
410
- const plugin: TuiPlugin = async (api, _options, _meta) => {
411
- api.slots.register(tuiPlugin)
412
- }
413
-
414
- export { plugin as tui, readSprintState, renderSprintSidebar }