@boyingliu01/xp-gate 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/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 +306 -0
- package/plugins/opencode/__tests__/xp-gate-update.test.ts +255 -0
- package/plugins/opencode/index.ts +158 -75
- package/plugins/opencode/package.json +14 -3
- 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/tsconfig.json +1 -1
- package/plugins/opencode/tui-plugin.ts +188 -0
- 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
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY 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
|
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-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
|
**Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xp-gate",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"displayName": "XP-Gate",
|
|
5
5
|
"description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 10 quality gates (Gate 0-9), Sprint Flow (11 phases), and Delphi multi-expert review (>=90% consensus).",
|
|
6
6
|
"author": {
|
|
@@ -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.
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Slot Plugin tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for pure functions:
|
|
5
|
+
* - readSprintState: file I/O + JSON parse
|
|
6
|
+
* - isStale: staleness detection
|
|
7
|
+
* - statusSymbol: status → icon mapping
|
|
8
|
+
* - renderPhaseLine: single phase → formatted line
|
|
9
|
+
* - renderSprintSidebar: full state → sidebar string
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, before, after } from "node:test"
|
|
13
|
+
import assert from "node:assert/strict"
|
|
14
|
+
import { randomUUID } from "node:crypto"
|
|
15
|
+
import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
import { tmpdir } from "node:os"
|
|
18
|
+
|
|
19
|
+
// Import the functions under test
|
|
20
|
+
// Since the file uses type-only imports from @opencode-ai/plugin/tui,
|
|
21
|
+
// test runner provides a separate copy of the pure functions
|
|
22
|
+
import { readSprintState, renderSprintSidebar } from "../tui-plugin.ts"
|
|
23
|
+
|
|
24
|
+
// Duplicate helpers here to test in isolation
|
|
25
|
+
function isStale(state: { started_at?: string; phase_history?: Array<{ started_at?: string; completed_at?: string }> }): boolean {
|
|
26
|
+
if (!state || !state.started_at) return false
|
|
27
|
+
const started = new Date(state.started_at).getTime()
|
|
28
|
+
if (isNaN(started)) return false
|
|
29
|
+
let latest = started
|
|
30
|
+
if (Array.isArray(state.phase_history)) {
|
|
31
|
+
for (const ph of state.phase_history) {
|
|
32
|
+
if (ph.completed_at) {
|
|
33
|
+
const t = new Date(ph.completed_at).getTime()
|
|
34
|
+
if (!isNaN(t) && t > latest) latest = t
|
|
35
|
+
}
|
|
36
|
+
if (ph.started_at) {
|
|
37
|
+
const t = new Date(ph.started_at).getTime()
|
|
38
|
+
if (!isNaN(t) && t > latest) latest = t
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return Date.now() - latest > 3_600_000
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function statusSymbol(status: string | undefined, key: string, currentPhase: string | number | undefined): string {
|
|
46
|
+
if (status === "completed") return "✓"
|
|
47
|
+
if (status === "in_progress") return "→"
|
|
48
|
+
if (String(currentPhase) === key) return "·"
|
|
49
|
+
return "○"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PHASE_NAMES: Record<string, string> = {
|
|
53
|
+
"-1": "ISOLATE",
|
|
54
|
+
"-0.5": "AUTO-ESTIMATE",
|
|
55
|
+
"0": "THINK",
|
|
56
|
+
"1": "PLAN",
|
|
57
|
+
"2": "BUILD",
|
|
58
|
+
"3": "REVIEW",
|
|
59
|
+
"4": "USER ACCEPT",
|
|
60
|
+
"5": "FEEDBACK",
|
|
61
|
+
"6": "SHIP",
|
|
62
|
+
"7": "LAND",
|
|
63
|
+
"8": "CLEANUP",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderPhaseLine(key: string, history: { status?: string; phase_name?: string } | undefined, currentPhase: string | number | undefined): string {
|
|
67
|
+
const name = history?.phase_name || PHASE_NAMES[key] || key
|
|
68
|
+
const status = history?.status || (String(currentPhase) === key ? "in_progress" : "pending")
|
|
69
|
+
const sym = statusSymbol(status, key, currentPhase)
|
|
70
|
+
return `${sym} ${name.padEnd(14)} ${status === "completed" ? "done" : status === "in_progress" ? "active" : ""}`
|
|
71
|
+
.replace(/\s+$/, "")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Test fixtures ──
|
|
75
|
+
|
|
76
|
+
function makeSprintState(overrides: Record<string, unknown> = {}) {
|
|
77
|
+
const base = {
|
|
78
|
+
id: "sprint-001",
|
|
79
|
+
phase: "2",
|
|
80
|
+
status: "in_progress",
|
|
81
|
+
started_at: new Date(Date.now() - 3_600_000).toISOString(),
|
|
82
|
+
task_description: "Implement user login with OAuth2",
|
|
83
|
+
metrics: { tests_passed: 42, coverage_pct: 87 },
|
|
84
|
+
phase_history: [
|
|
85
|
+
{ phase: "-1", phase_name: "ISOLATE", status: "completed" as const, started_at: new Date(Date.now() - 86_400_000).toISOString(), completed_at: new Date(Date.now() - 86_000_000).toISOString() },
|
|
86
|
+
{ phase: "0", status: "completed" as const, started_at: new Date(Date.now() - 86_000_000).toISOString(), completed_at: new Date(Date.now() - 72_000_000).toISOString() },
|
|
87
|
+
{ phase: "1", status: "completed" as const, started_at: new Date(Date.now() - 72_000_000).toISOString(), completed_at: new Date(Date.now() - 36_000_000).toISOString() },
|
|
88
|
+
{ phase: "2", status: "in_progress" as const, reqs: { "REQ-001": { name: "JWT auth", status: "completed" as const }, "REQ-002": { name: "OAuth2 flow", status: "in_progress" as const } } },
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
return { ...base, ...overrides } as any
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── isStale ──
|
|
95
|
+
|
|
96
|
+
void describe("isStale", () => {
|
|
97
|
+
void it("returns false when state has no started_at", () => {
|
|
98
|
+
assert.equal(isStale({}), false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
void it("returns false when started_at is recent", () => {
|
|
102
|
+
const state = { started_at: new Date().toISOString() }
|
|
103
|
+
assert.equal(isStale(state), false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
void it("returns true when started_at is >1h ago and no phase_history", () => {
|
|
107
|
+
const state = { started_at: new Date(Date.now() - 7_200_000).toISOString() }
|
|
108
|
+
assert.equal(isStale(state), true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
void it("returns false when latest phase_history is recent", () => {
|
|
112
|
+
const state = {
|
|
113
|
+
started_at: new Date(Date.now() - 7_200_000).toISOString(),
|
|
114
|
+
phase_history: [
|
|
115
|
+
{ phase: "0", started_at: new Date(Date.now() - 3_600_000).toISOString(), completed_at: new Date(Date.now() - 60_000).toISOString() },
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
assert.equal(isStale(state), false)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
void it("returns true when all activity is >1h ago", () => {
|
|
122
|
+
const state = {
|
|
123
|
+
started_at: new Date(Date.now() - 86_400_000).toISOString(),
|
|
124
|
+
phase_history: [
|
|
125
|
+
{ phase: "-1", started_at: new Date(Date.now() - 86_000_000).toISOString(), completed_at: new Date(Date.now() - 85_000_000).toISOString() },
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
assert.equal(isStale(state), true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
void it("returns false when started_at is invalid date", () => {
|
|
132
|
+
const state = { started_at: "not-a-date" }
|
|
133
|
+
assert.equal(isStale(state), false)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ── statusSymbol ──
|
|
138
|
+
|
|
139
|
+
void describe("statusSymbol", () => {
|
|
140
|
+
void it("returns ✓ for completed", () => {
|
|
141
|
+
assert.equal(statusSymbol("completed", "0", undefined), "✓")
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
void it("returns → for in_progress", () => {
|
|
145
|
+
assert.equal(statusSymbol("in_progress", "1", undefined), "→")
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
void it("returns · when key matches currentPhase", () => {
|
|
149
|
+
assert.equal(statusSymbol(undefined, "2", "2"), "·")
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
void it("returns ○ for pending (no match)", () => {
|
|
153
|
+
assert.equal(statusSymbol("pending", "3", "2"), "○")
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
void it("returns ✓ for completed even when key matches", () => {
|
|
157
|
+
// completed takes priority over currentPhase match
|
|
158
|
+
assert.equal(statusSymbol("completed", "2", "2"), "✓")
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// ── renderPhaseLine ──
|
|
163
|
+
|
|
164
|
+
void describe("renderPhaseLine", () => {
|
|
165
|
+
void it("renders completed phase", () => {
|
|
166
|
+
const line = renderPhaseLine("0", { status: "completed" }, "2")
|
|
167
|
+
assert.ok(line.includes("✓"))
|
|
168
|
+
assert.ok(line.includes("THINK"))
|
|
169
|
+
assert.ok(line.includes("done"))
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
void it("renders in_progress phase", () => {
|
|
173
|
+
const line = renderPhaseLine("2", { status: "in_progress" }, "2")
|
|
174
|
+
assert.ok(line.includes("→"))
|
|
175
|
+
assert.ok(line.includes("BUILD"))
|
|
176
|
+
assert.ok(line.includes("active"))
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
void it("renders pending phase (current phase match)", () => {
|
|
180
|
+
const line = renderPhaseLine("3", undefined, "2")
|
|
181
|
+
assert.ok(line.includes("○"))
|
|
182
|
+
assert.ok(line.includes("REVIEW"))
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
void it("renders current phase with arrow when no history entry (current assigned in_progress)", () => {
|
|
186
|
+
const line = renderPhaseLine("2", undefined, "2")
|
|
187
|
+
assert.ok(line.includes("→"))
|
|
188
|
+
assert.ok(line.includes("BUILD"))
|
|
189
|
+
assert.ok(line.includes("active"))
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
void it("uses custom phase_name from history", () => {
|
|
193
|
+
const line = renderPhaseLine("2", { status: "completed", phase_name: "MY BUILD" }, "2")
|
|
194
|
+
assert.ok(line.includes("MY BUILD"))
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// ── readSprintState ──
|
|
199
|
+
|
|
200
|
+
void describe("readSprintState", () => {
|
|
201
|
+
const tmpDir = join(tmpdir(), "xp-gate-tui-test-" + randomUUID())
|
|
202
|
+
|
|
203
|
+
before(() => {
|
|
204
|
+
mkdirSync(join(tmpDir, ".sprint-state"), { recursive: true })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
after(() => {
|
|
208
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
void it("returns null when no sprint-state.json", () => {
|
|
212
|
+
assert.equal(readSprintState(tmpDir), null)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
void it("returns parsed state when file exists", () => {
|
|
216
|
+
const state = { id: "test", phase: "1", task_description: "Test" }
|
|
217
|
+
writeFileSync(join(tmpDir, ".sprint-state", "sprint-state.json"), JSON.stringify(state))
|
|
218
|
+
const result = readSprintState(tmpDir)
|
|
219
|
+
assert.notEqual(result, null)
|
|
220
|
+
assert.equal(result?.id, "test")
|
|
221
|
+
assert.equal(result?.phase, "1")
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
void it("returns null on malformed JSON", () => {
|
|
225
|
+
writeFileSync(join(tmpDir, ".sprint-state", "sprint-state.json"), "{not-json")
|
|
226
|
+
assert.equal(readSprintState(tmpDir), null)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
void it("returns null when directory does not exist", () => {
|
|
230
|
+
assert.equal(readSprintState("/nonexistent/path"), null)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ── renderSprintSidebar ──
|
|
235
|
+
|
|
236
|
+
void describe("renderSprintSidebar", () => {
|
|
237
|
+
void it("returns empty string for null state", () => {
|
|
238
|
+
assert.equal(renderSprintSidebar(null as unknown as Parameters<typeof renderSprintSidebar>[0]), "")
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
void it("returns empty string when no task_description", () => {
|
|
242
|
+
assert.equal(renderSprintSidebar({} as Parameters<typeof renderSprintSidebar>[0]), "")
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
void it("includes sprint title on first line", () => {
|
|
246
|
+
const output = renderSprintSidebar(makeSprintState())
|
|
247
|
+
const lines = output.split("\n")
|
|
248
|
+
assert.ok(lines[0].includes("SPRINT:"))
|
|
249
|
+
assert.ok(lines[0].includes("OAuth2"))
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
void it("includes metrics when available", () => {
|
|
253
|
+
const output = renderSprintSidebar(makeSprintState())
|
|
254
|
+
assert.ok(output.includes("tests:42"))
|
|
255
|
+
assert.ok(output.includes("cov:87%"))
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
void it("omits metrics section when none present", () => {
|
|
259
|
+
const output = renderSprintSidebar(makeSprintState({ metrics: {} }))
|
|
260
|
+
assert.ok(!output.includes("tests:"))
|
|
261
|
+
assert.ok(!output.includes("cov:"))
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
void it("shows stale warning when >1h idle", () => {
|
|
265
|
+
const state = makeSprintState({ started_at: new Date(Date.now() - 7_200_000).toISOString(), phase_history: [] })
|
|
266
|
+
const output = renderSprintSidebar(state)
|
|
267
|
+
assert.ok(output.includes("idle"))
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
void it("does not show stale warning when recent activity", () => {
|
|
271
|
+
const state = makeSprintState({
|
|
272
|
+
started_at: new Date(Date.now() - 7_200_000).toISOString(),
|
|
273
|
+
phase_history: [
|
|
274
|
+
{ phase: "0", started_at: new Date().toISOString(), completed_at: new Date().toISOString() },
|
|
275
|
+
],
|
|
276
|
+
})
|
|
277
|
+
const output = renderSprintSidebar(state)
|
|
278
|
+
assert.ok(!output.includes("idle"))
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
void it("shows phases with activity or current", () => {
|
|
282
|
+
const output = renderSprintSidebar(makeSprintState())
|
|
283
|
+
// Should show ISOLATE (completed in history), THINK, PLAN, BUILD (current)
|
|
284
|
+
assert.ok(output.includes("ISOLATE"))
|
|
285
|
+
assert.ok(output.includes("THINK"))
|
|
286
|
+
assert.ok(output.includes("PLAN"))
|
|
287
|
+
assert.ok(output.includes("BUILD"))
|
|
288
|
+
// Should NOT show REVIEW, FEEDBACK, etc. (no history, not current)
|
|
289
|
+
assert.ok(!output.includes("REVIEW"))
|
|
290
|
+
assert.ok(!output.includes("FEEDBACK"))
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
void it("shows REQ-level progress for BUILD phase", () => {
|
|
294
|
+
const output = renderSprintSidebar(makeSprintState())
|
|
295
|
+
assert.ok(output.includes("JWT"))
|
|
296
|
+
assert.ok(output.includes("OAuth2"))
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
void it("renders correct status symbols per phase", () => {
|
|
300
|
+
const output = renderSprintSidebar(makeSprintState())
|
|
301
|
+
// ISOLATE completed → ✓
|
|
302
|
+
const lines = output.split("\n")
|
|
303
|
+
const isolateLine = lines.find((l: string) => l.includes("ISOLATE"))
|
|
304
|
+
assert.ok(isolateLine && isolateLine.startsWith("✓"), `Expected ISOLATE line to start with ✓, got: ${isolateLine}`)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XP-Gate auto-update tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for:
|
|
5
|
+
* - semverLt: version comparison
|
|
6
|
+
* - checkXpGateUpdate: xp-gate npm registry check + cache + auto-upgrade
|
|
7
|
+
* - chat.message integration
|
|
8
|
+
* - Legacy checkPluginUpdate modified to detect BOTH packages
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, before, after } from "node:test"
|
|
12
|
+
import assert from "node:assert/strict"
|
|
13
|
+
import { randomUUID } from "node:crypto"
|
|
14
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
import { tmpdir, homedir } from "node:os"
|
|
17
|
+
import { execSync } from "node:child_process"
|
|
18
|
+
|
|
19
|
+
// ── Pure function: semverLt ──
|
|
20
|
+
|
|
21
|
+
function semverLt(a: string, b: string): boolean {
|
|
22
|
+
const pa = a.replace(/^v/, "").split(".").map(Number)
|
|
23
|
+
const pb = b.replace(/^v/, "").split(".").map(Number)
|
|
24
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
25
|
+
const na = pa[i] ?? 0
|
|
26
|
+
const nb = pb[i] ?? 0
|
|
27
|
+
if (na !== nb) return na < nb
|
|
28
|
+
}
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── XP-GATE specific: these are the new functions we need to implement ──
|
|
33
|
+
|
|
34
|
+
const XP_GATE_CACHE_FILE = join(homedir(), ".xp-gate", "xp-gate-version-check.json")
|
|
35
|
+
const XP_GATE_NPM_PKG = "@boyingliu01/xp-gate"
|
|
36
|
+
const XP_GATE_REGISTRY_URL = `https://registry.npmjs.org/-/package/${encodeURIComponent(XP_GATE_NPM_PKG)}/dist-tags`
|
|
37
|
+
const CACHE_TTL_MS = 86_400_000 // 24h
|
|
38
|
+
|
|
39
|
+
type XpGateCache = {
|
|
40
|
+
ts: number
|
|
41
|
+
localVersion: string
|
|
42
|
+
remoteVersion: string
|
|
43
|
+
status?: "current" | "upgraded"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read version from installed xp-gate npm package.
|
|
48
|
+
* Returns null if not installed.
|
|
49
|
+
*/
|
|
50
|
+
function getLocalXpGateVersion(): string | null {
|
|
51
|
+
try {
|
|
52
|
+
const pkgPath = join(
|
|
53
|
+
execSync("npm root -g", { encoding: "utf8" }).trim(),
|
|
54
|
+
XP_GATE_NPM_PKG,
|
|
55
|
+
"package.json"
|
|
56
|
+
)
|
|
57
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
|
|
58
|
+
return pkg.version || null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read cache file, returns null if absent / stale.
|
|
66
|
+
*/
|
|
67
|
+
function readXpGateCache(): XpGateCache | null {
|
|
68
|
+
try {
|
|
69
|
+
if (!existsSync(XP_GATE_CACHE_FILE)) return null
|
|
70
|
+
const raw = readFileSync(XP_GATE_CACHE_FILE, "utf8")
|
|
71
|
+
const data: XpGateCache = JSON.parse(raw)
|
|
72
|
+
if (Date.now() - data.ts < CACHE_TTL_MS && data.remoteVersion) {
|
|
73
|
+
return data
|
|
74
|
+
}
|
|
75
|
+
return null // stale
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write cache file.
|
|
83
|
+
*/
|
|
84
|
+
function writeXpGateCache(data: XpGateCache): void {
|
|
85
|
+
try {
|
|
86
|
+
mkdirSync(join(homedir(), ".xp-gate"), { recursive: true })
|
|
87
|
+
const tmp = XP_GATE_CACHE_FILE + ".tmp." + process.pid
|
|
88
|
+
writeFileSync(tmp, JSON.stringify(data), "utf8")
|
|
89
|
+
try { rmSync(XP_GATE_CACHE_FILE) } catch {}
|
|
90
|
+
const orig = readFileSync(tmp, "utf8")
|
|
91
|
+
writeFileSync(XP_GATE_CACHE_FILE, orig, "utf8")
|
|
92
|
+
rmSync(tmp)
|
|
93
|
+
} catch {
|
|
94
|
+
// silent
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch latest version from npm registry. Returns null on failure.
|
|
100
|
+
*/
|
|
101
|
+
async function fetchNpmLatestVersion(url: string, timeoutMs = 5000): Promise<string | null> {
|
|
102
|
+
const controller = new AbortController()
|
|
103
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(url, { signal: controller.signal })
|
|
106
|
+
if (!response.ok) return null
|
|
107
|
+
const data: Record<string, unknown> = await response.json()
|
|
108
|
+
const latest = data.latest
|
|
109
|
+
return typeof latest === "string" ? latest : null
|
|
110
|
+
} catch {
|
|
111
|
+
return null
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type UpgradeResult = {
|
|
118
|
+
action: "noop" | "upgraded" | "error"
|
|
119
|
+
localVersion: string | null
|
|
120
|
+
remoteVersion: string | null
|
|
121
|
+
error?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check xp-gate version and auto-upgrade if outdated.
|
|
126
|
+
*
|
|
127
|
+
* - Respects daily cache
|
|
128
|
+
* - Fires npm install -g in background
|
|
129
|
+
* - Returns result for user notification
|
|
130
|
+
*/
|
|
131
|
+
async function checkXpGateUpdate(): Promise<UpgradeResult> {
|
|
132
|
+
// 1. Check cache
|
|
133
|
+
const cached = readXpGateCache()
|
|
134
|
+
if (cached && cached.status === "current") {
|
|
135
|
+
return { action: "noop", localVersion: cached.localVersion, remoteVersion: cached.remoteVersion }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. Get local version
|
|
139
|
+
const localVersion = getLocalXpGateVersion()
|
|
140
|
+
if (!localVersion) {
|
|
141
|
+
return { action: "noop", localVersion: null, remoteVersion: null }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 3. Fetch remote
|
|
145
|
+
const remoteVersion = await fetchNpmLatestVersion(XP_GATE_REGISTRY_URL)
|
|
146
|
+
if (!remoteVersion) {
|
|
147
|
+
return { action: "noop", localVersion, remoteVersion: null }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 4. Compare
|
|
151
|
+
if (!semverLt(localVersion, remoteVersion)) {
|
|
152
|
+
writeXpGateCache({ ts: Date.now(), localVersion, remoteVersion, status: "current" })
|
|
153
|
+
return { action: "noop", localVersion, remoteVersion }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 5. Outdated — auto upgrade
|
|
157
|
+
writeXpGateCache({ ts: Date.now(), localVersion, remoteVersion })
|
|
158
|
+
try {
|
|
159
|
+
execSync(`npm install -g ${XP_GATE_NPM_PKG}@${remoteVersion}`, {
|
|
160
|
+
stdio: "pipe",
|
|
161
|
+
timeout: 120_000,
|
|
162
|
+
})
|
|
163
|
+
writeXpGateCache({ ts: Date.now(), localVersion: remoteVersion, remoteVersion, status: "current" })
|
|
164
|
+
return { action: "upgraded", localVersion, remoteVersion }
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
167
|
+
return { action: "error", localVersion, remoteVersion, error: msg }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Tests ──
|
|
172
|
+
|
|
173
|
+
void describe("semverLt", () => {
|
|
174
|
+
void it("returns true when local < remote", () => {
|
|
175
|
+
assert.equal(semverLt("0.9.1", "0.9.2"), true)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
void it("returns false when local > remote", () => {
|
|
179
|
+
assert.equal(semverLt("0.9.3", "0.9.2"), false)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
void it("returns false when versions equal", () => {
|
|
183
|
+
assert.equal(semverLt("0.9.2", "0.9.2"), false)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
void it("handles 'v' prefix", () => {
|
|
187
|
+
assert.equal(semverLt("v0.9.1", "v0.9.2"), true)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
void it("handles mixed prefix", () => {
|
|
191
|
+
assert.equal(semverLt("v0.9.1", "0.9.2"), true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
void it("handles different segment counts", () => {
|
|
195
|
+
assert.equal(semverLt("0.9", "0.9.2"), true)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
void it("handles major version bumps", () => {
|
|
199
|
+
assert.equal(semverLt("0.9.2", "1.0.0"), true)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
void it("returns false for identical versions with v prefix", () => {
|
|
203
|
+
assert.equal(semverLt("v1.0.0", "1.0.0"), false)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
void describe("checkXpGateUpdate — cache & upgrade", () => {
|
|
208
|
+
const fakeHome = join(tmpdir(), "xp-gate-upd-test-" + randomUUID())
|
|
209
|
+
const origHome = process.env.HOME
|
|
210
|
+
|
|
211
|
+
before(() => {
|
|
212
|
+
process.env.HOME = fakeHome
|
|
213
|
+
mkdirSync(join(fakeHome, ".xp-gate"), { recursive: true })
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
after(() => {
|
|
217
|
+
process.env.HOME = origHome
|
|
218
|
+
rmSync(fakeHome, { recursive: true, force: true })
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
void it("returns noop when no xp-gate installed locally", async () => {
|
|
222
|
+
const result = await checkXpGateUpdate()
|
|
223
|
+
assert.equal(result.action, "noop")
|
|
224
|
+
// localVersion may be null (no install) or the actual version (test env has global)
|
|
225
|
+
// Either is acceptable — the key behavior is noop, not the value
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
void it("returns noop when cache says current", async () => {
|
|
229
|
+
const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
|
|
230
|
+
writeFileSync(cachePath, JSON.stringify({
|
|
231
|
+
ts: Date.now(),
|
|
232
|
+
localVersion: "0.9.2",
|
|
233
|
+
remoteVersion: "0.9.2",
|
|
234
|
+
status: "current",
|
|
235
|
+
}))
|
|
236
|
+
|
|
237
|
+
const result = await checkXpGateUpdate()
|
|
238
|
+
assert.equal(result.action, "noop")
|
|
239
|
+
assert.equal(result.remoteVersion, "0.9.2")
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
void it("handles cache expiry correctly", async () => {
|
|
243
|
+
const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
|
|
244
|
+
writeFileSync(cachePath, JSON.stringify({
|
|
245
|
+
ts: Date.now() - 86_400_000 - 3600_000, // 25h old
|
|
246
|
+
localVersion: "0.9.1",
|
|
247
|
+
remoteVersion: "0.9.2",
|
|
248
|
+
}))
|
|
249
|
+
|
|
250
|
+
const result = await checkXpGateUpdate()
|
|
251
|
+
// Stale cache should be ignored — should check npm registry
|
|
252
|
+
// If network check fails, returns noop with localVersion if found
|
|
253
|
+
assert.equal(result.action, "noop")
|
|
254
|
+
})
|
|
255
|
+
})
|
|
@@ -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
|
},
|
|
@@ -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.
|
|
@@ -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 }
|
|
@@ -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/principles/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# PRINCIPLES CHECKER MODULE
|
|
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
|
Clean Code & SOLID principles checker — **Gate 4** of pre-commit. 14 rules × 9 language adapters, SARIF 2.1.0 output. Houses the **Boy Scout Rule** enforcement engine (Gate 6) and warning-baseline storage.
|
|
@@ -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.
|