@boyingliu01/xp-gate 0.9.5 → 0.10.1

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.
@@ -0,0 +1,221 @@
1
+ // @no-test-required: mutmut wrapper — tested via gate-m integration + pre-push Gate M
2
+ import { spawn, execSync } from 'child_process';
3
+ import { writeFileSync, unlinkSync, readFileSync, existsSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
+ import type {
6
+ MutationRunner,
7
+ RunMutationOptions,
8
+ MutationRunOutcome,
9
+ MutationRunResult,
10
+ } from './types';
11
+
12
+ /**
13
+ * mutmut mutation runner for Python files.
14
+ *
15
+ * Uses `mutmut run` to generate mutants and parses emoji progress output.
16
+ * Supports mutmut v3.x (primary) with fallback to v2.x behavior.
17
+ *
18
+ * v3.x changes (GitHub issue #339):
19
+ * - Removed `--paths-to-mutate` CLI flag
20
+ * - Uses `source_paths` in `[tool.mutmut]` section of pyproject.toml
21
+ * - Results stored in SQLite; `mutmut results` outputs per-mutant status lines
22
+ * - `mutmut run` stdout shows emoji progress: 🎉 N 🫥 N ⏰ N 🤔 N 🙁 N
23
+ */
24
+ export class MutmutRunner implements MutationRunner {
25
+ readonly name = 'mutmut';
26
+ readonly extensions = ['py'];
27
+
28
+ async isAvailable(): Promise<boolean> {
29
+ try {
30
+ execSync('mutmut --version', { stdio: 'pipe', timeout: 5000 });
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ async run(options: RunMutationOptions): Promise<MutationRunOutcome> {
38
+ // Extract unique directories from file list
39
+ const sourceDirs = this.extractUniqueDirs(options.files);
40
+
41
+ const pyprojectPath = join(options.cwd, 'pyproject.toml');
42
+ const backupPath = join(options.cwd, 'pyproject.toml.xp-gate-backup');
43
+ let hadExisting = false;
44
+
45
+ try {
46
+ if (existsSync(pyprojectPath)) {
47
+ hadExisting = true;
48
+ const existing = readFileSync(pyprojectPath, 'utf-8');
49
+ writeFileSync(backupPath, existing, { encoding: 'utf-8' });
50
+ }
51
+ this.writeMutmutConfig(pyprojectPath, sourceDirs);
52
+
53
+ return await this.runMutmut(options);
54
+ } finally {
55
+ try {
56
+ if (hadExisting && existsSync(backupPath)) {
57
+ const backup = readFileSync(backupPath, 'utf-8');
58
+ writeFileSync(pyprojectPath, backup, { encoding: 'utf-8' });
59
+ unlinkSync(backupPath);
60
+ } else if (!hadExisting && existsSync(pyprojectPath)) {
61
+ unlinkSync(pyprojectPath);
62
+ }
63
+ } catch {
64
+ // Best effort cleanup
65
+ }
66
+ }
67
+ }
68
+
69
+ private async runMutmut(options: RunMutationOptions): Promise<MutationRunOutcome> {
70
+ return new Promise((resolve) => {
71
+ const child = spawn('mutmut', ['run'], {
72
+ stdio: 'pipe',
73
+ shell: false,
74
+ cwd: options.cwd,
75
+ });
76
+
77
+ let stderr = '';
78
+ let stdout = '';
79
+
80
+ child.stdout?.on('data', (data: Buffer) => {
81
+ stdout += data.toString();
82
+ });
83
+
84
+ child.stderr?.on('data', (data: Buffer) => {
85
+ stderr += data.toString();
86
+ });
87
+
88
+ const timeoutId = setTimeout(() => {
89
+ child.kill('SIGTERM');
90
+ setTimeout(() => {
91
+ if (!child.killed) child.kill('SIGKILL');
92
+ }, 5000);
93
+ }, options.timeoutMs);
94
+
95
+ child.on('close', (code) => {
96
+ clearTimeout(timeoutId);
97
+
98
+ if (code === null) {
99
+ resolve({ report: null, timedOut: true });
100
+ return;
101
+ }
102
+
103
+ // Parse results from mutmut run stdout (v3.x emoji progress)
104
+ const report = this.parseEmojiProgress(stdout);
105
+ resolve({
106
+ report,
107
+ timedOut: false,
108
+ error: code !== 0 ? stderr || stdout : undefined,
109
+ });
110
+ });
111
+
112
+ child.on('error', (err) => {
113
+ clearTimeout(timeoutId);
114
+ resolve({ report: null, timedOut: false, error: err.message });
115
+ });
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Parse v3.x emoji progress line from mutmut run stdout.
121
+ *
122
+ * mutmut v3.x prints a progress summary like:
123
+ * 38/38 🎉 9 🫥 29 ⏰ 0 🤔 0 🙁 0 🔇 0 🧙 0
124
+ *
125
+ * Emoji mapping:
126
+ * 🎉 = killed
127
+ * 🫥 = not checked (untested)
128
+ * ⏰ = timeout
129
+ * 🤔 = suspicious
130
+ * 🙁 = survived
131
+ * 🔇 = unknown (counted as survived)
132
+ * 🧙 = unknown (counted as survived)
133
+ */
134
+ private parseEmojiProgress(stdout: string): MutationRunResult | null {
135
+ // Find the last progress line (contains 🎉)
136
+ const lines = stdout.split('\n');
137
+ let progressLine = '';
138
+ for (let i = lines.length - 1; i >= 0; i--) {
139
+ if (lines[i].includes('🎉')) {
140
+ progressLine = lines[i];
141
+ break;
142
+ }
143
+ }
144
+ if (!progressLine) return null;
145
+
146
+ // Parse emoji counts: 🎉 N 🫥 N ⏰ N 🤔 N 🙁 N 🔇 N 🧙 N
147
+ const killed = this.extractEmojiCount(progressLine, '🎉');
148
+ const untested = this.extractEmojiCount(progressLine, '🫥');
149
+ const timeout = this.extractEmojiCount(progressLine, '⏰');
150
+ const suspicious = this.extractEmojiCount(progressLine, '🤔');
151
+ const survived = this.extractEmojiCount(progressLine, '🙁');
152
+ const unknown1 = this.extractEmojiCount(progressLine, '🔇');
153
+ const unknown2 = this.extractEmojiCount(progressLine, '🧙');
154
+
155
+ const nrOfKilledMutants = killed;
156
+ const nrOfSurvivedMutants =
157
+ survived + timeout + suspicious + untested + unknown1 + unknown2;
158
+ const nrOfMutants = nrOfKilledMutants + nrOfSurvivedMutants;
159
+
160
+ if (nrOfMutants === 0) return null;
161
+
162
+ return {
163
+ mutationScore: (nrOfKilledMutants / nrOfMutants) * 100,
164
+ nrOfMutants,
165
+ nrOfKilledMutants,
166
+ nrOfSurvivedMutants,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Extract count after an emoji in a progress line.
172
+ * e.g., "🎉 9" → 9
173
+ */
174
+ private extractEmojiCount(line: string, emoji: string): number {
175
+ const escapedEmoji = emoji.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
176
+ const match = line.match(new RegExp(`${escapedEmoji}\\s*(\\d+)`));
177
+ return parseInt(match?.[1] ?? '0', 10);
178
+ }
179
+
180
+ /**
181
+ * Extract unique directories from file paths.
182
+ * e.g., ['src/foo.py', 'src/bar.py', 'tests/baz.py'] → ['src', 'tests']
183
+ */
184
+ private extractUniqueDirs(files: string[]): string[] {
185
+ const dirs = new Set<string>();
186
+ for (const file of files) {
187
+ const dir = dirname(file);
188
+ if (dir && dir !== '.') {
189
+ dirs.add(dir);
190
+ }
191
+ }
192
+ // If no directories found, use current directory
193
+ return dirs.size > 0 ? Array.from(dirs) : ['.'];
194
+ }
195
+
196
+ private writeMutmutConfig(pyprojectPath: string, sourceDirs: string[]): void {
197
+ const sourcePaths = sourceDirs.map((d) => `"${d}"`).join(', ');
198
+ const sourcePathsLine = `source_paths = [${sourcePaths}]`;
199
+
200
+ let content = '';
201
+ if (existsSync(pyprojectPath)) {
202
+ content = readFileSync(pyprojectPath, 'utf-8');
203
+ if (content.includes('[tool.mutmut]')) {
204
+ const sourcePathsRegex = /source_paths\s*=\s*\[.*?\]/;
205
+ if (sourcePathsRegex.test(content)) {
206
+ content = content.replace(sourcePathsRegex, sourcePathsLine);
207
+ } else {
208
+ content = content.replace(
209
+ '[tool.mutmut]',
210
+ `[tool.mutmut]\n${sourcePathsLine}`,
211
+ );
212
+ }
213
+ } else {
214
+ content = content.trimEnd() + `\n\n[tool.mutmut]\n${sourcePathsLine}\n`;
215
+ }
216
+ } else {
217
+ content = `[tool.mutmut]\n${sourcePathsLine}\n`;
218
+ }
219
+ writeFileSync(pyprojectPath, content, { encoding: 'utf-8' });
220
+ }
221
+ }
@@ -0,0 +1,147 @@
1
+ // @no-test-required: Stryker wrapper — tested via gate-m integration + pre-push Gate M
2
+ import { spawn } from 'child_process';
3
+ import { readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import type {
6
+ MutationRunner,
7
+ RunMutationOptions,
8
+ MutationRunOutcome,
9
+ MutationRunResult,
10
+ } from './types';
11
+
12
+ const STRYKER_REPORT_PATH = '.stryker-report.json';
13
+ const STRYKER_PREPUSH_CONFIG = 'stryker.prepush.conf.json';
14
+
15
+ /**
16
+ * Stryker mutation runner for TypeScript files.
17
+ * Extracted from gate-m.ts to follow the LangAdapter pattern.
18
+ */
19
+ export class StrykerRunner implements MutationRunner {
20
+ readonly name = 'Stryker';
21
+ readonly extensions = ['ts', 'tsx'];
22
+
23
+ async isAvailable(): Promise<boolean> {
24
+ try {
25
+ const { spawnSync } = await import('child_process');
26
+ const result = spawnSync('npx', ['stryker', '--version'], {
27
+ stdio: 'pipe',
28
+ shell: false,
29
+ timeout: 5000,
30
+ });
31
+ return result.status !== null && result.status === 0;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ async run(options: RunMutationOptions): Promise<MutationRunOutcome> {
38
+ return new Promise((resolve) => {
39
+ const args = [
40
+ 'stryker',
41
+ 'run',
42
+ '--config',
43
+ this.resolveConfig(options.cwd),
44
+ ...options.files.flatMap(f => ['--mutate', f]),
45
+ ];
46
+
47
+ const child = spawn('npx', args, {
48
+ stdio: 'pipe',
49
+ shell: false,
50
+ cwd: options.cwd,
51
+ });
52
+
53
+ let stderr = '';
54
+ let stdout = '';
55
+
56
+ child.stdout?.on('data', (data: Buffer) => {
57
+ stdout += data.toString();
58
+ });
59
+
60
+ child.stderr?.on('data', (data: Buffer) => {
61
+ stderr += data.toString();
62
+ });
63
+
64
+ const timeoutId = setTimeout(() => {
65
+ child.kill('SIGTERM');
66
+ setTimeout(() => {
67
+ if (!child.killed) child.kill('SIGKILL');
68
+ }, 5000);
69
+ }, options.timeoutMs);
70
+
71
+ child.on('close', (code) => {
72
+ clearTimeout(timeoutId);
73
+
74
+ if (code === null) {
75
+ resolve({ report: null, timedOut: true });
76
+ return;
77
+ }
78
+
79
+ const report = this.parseReport(options.cwd);
80
+ resolve({
81
+ report,
82
+ timedOut: false,
83
+ error: code !== 0 ? stderr || stdout : undefined,
84
+ });
85
+ });
86
+
87
+ child.on('error', (err) => {
88
+ clearTimeout(timeoutId);
89
+ resolve({ report: null, timedOut: false, error: err.message });
90
+ });
91
+ });
92
+ }
93
+
94
+ private resolveConfig(cwd: string): string {
95
+ return join(cwd, STRYKER_PREPUSH_CONFIG);
96
+ }
97
+
98
+ private parseReport(cwd: string): MutationRunResult | null {
99
+ try {
100
+ const content = readFileSync(join(cwd, STRYKER_REPORT_PATH), 'utf-8');
101
+ return this.parseReportObject(JSON.parse(content));
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ private parseReportObject(parsed: Record<string, unknown>): MutationRunResult | null {
108
+ const mutationScore = asNumber(parsed.mutationScore);
109
+ const nrOfMutants = asNumber(parsed.nrOfMutants);
110
+ const nrOfKilledMutants = asNumber(parsed.nrOfKilledMutants);
111
+ const nrOfSurvivedMutants = asNumber(parsed.nrOfSurvivedMutants);
112
+
113
+ if (isNaN(mutationScore) || isNaN(nrOfMutants)) return null;
114
+
115
+ const result: MutationRunResult = {
116
+ mutationScore,
117
+ nrOfMutants,
118
+ nrOfKilledMutants,
119
+ nrOfSurvivedMutants,
120
+ };
121
+
122
+ if (parsed.files && typeof parsed.files === 'object') {
123
+ result.files = parseFilesObject(parsed.files as Record<string, Record<string, unknown>>);
124
+ }
125
+
126
+ return result;
127
+ }
128
+ }
129
+
130
+ function asNumber(value: unknown, fallback = 0): number {
131
+ return typeof value === 'number' ? value : fallback;
132
+ }
133
+
134
+ function parseFilesObject(
135
+ filesObj: Record<string, Record<string, unknown>>
136
+ ): Record<string, { mutationScore: number; nrOfMutants: number; nrOfKilledMutants: number; nrOfSurvivedMutants: number }> {
137
+ const files: Record<string, { mutationScore: number; nrOfMutants: number; nrOfKilledMutants: number; nrOfSurvivedMutants: number }> = {};
138
+ for (const [file, data] of Object.entries(filesObj)) {
139
+ files[file] = {
140
+ mutationScore: asNumber(data.mutationScore),
141
+ nrOfMutants: asNumber(data.nrOfMutants),
142
+ nrOfKilledMutants: asNumber(data.nrOfKilledMutants),
143
+ nrOfSurvivedMutants: asNumber(data.nrOfSurvivedMutants),
144
+ };
145
+ }
146
+ return files;
147
+ }
@@ -0,0 +1,100 @@
1
+ // @no-test-required: LangAdapter types/interface — tested via gate-m integration
2
+ /**
3
+ * Language-agnostic mutation runner interface.
4
+ *
5
+ * Each language-specific runner (Stryker for TypeScript, mutmut for Python)
6
+ * implements this interface so gate-m.ts can route by file extension without
7
+ * knowing the underlying tool.
8
+ */
9
+
10
+ /** Normalized mutation score — all runners emit this shape. */
11
+ export interface MutationFileReport {
12
+ /** Score as percentage (0–100). */
13
+ mutationScore: number;
14
+ /** Total number of mutants generated for this file. */
15
+ nrOfMutants: number;
16
+ /** Mutants killed by the test suite. */
17
+ nrOfKilledMutants: number;
18
+ /** Mutants that survived. */
19
+ nrOfSurvivedMutants: number;
20
+ }
21
+
22
+ /** Aggregated result from a single runner invocation. */
23
+ export interface MutationRunResult {
24
+ /** Top-level normalized mutation score (0–100). */
25
+ mutationScore: number;
26
+ /** Total mutants across all processed files. */
27
+ nrOfMutants: number;
28
+ /** Total killed mutants. */
29
+ nrOfKilledMutants: number;
30
+ /** Total survived mutants. */
31
+ nrOfSurvivedMutants: number;
32
+ /** Per-file breakdown (optional; runner may not support it). */
33
+ files?: Record<string, MutationFileReport>;
34
+ }
35
+
36
+ /** Outcome of invoking a mutation runner. */
37
+ export interface MutationRunOutcome {
38
+ /** Parsed report data. null if the run failed or timed out. */
39
+ report: MutationRunResult | null;
40
+ /** Whether the runner exceeded its timeout. */
41
+ timedOut: boolean;
42
+ /** Error message (if any). */
43
+ error?: string;
44
+ }
45
+
46
+ /** Parameters passed to all runners. */
47
+ export interface RunMutationOptions {
48
+ /** Source files to mutate (relative paths, no test files). */
49
+ files: string[];
50
+ /** Timeout in milliseconds. */
51
+ timeoutMs: number;
52
+ /** Project root directory (for config file resolution). */
53
+ cwd: string;
54
+ }
55
+
56
+ /**
57
+ * Language-specific mutation runner.
58
+ *
59
+ * Implement this interface for each tool (Stryker, mutmut, etc.).
60
+ * The runner is responsible for:
61
+ * 1. Invoking the mutation tool with the correct CLI args.
62
+ * 2. Parsing tool-specific output into a normalized MutationRunResult.
63
+ * 3. Enforcing the timeout and returning a MutationRunOutcome.
64
+ *
65
+ * Do NOT include threshold logic, baseline comparison, or file filtering here —
66
+ * those are the responsibility of gate-m.ts (the orchestrator).
67
+ */
68
+ export interface MutationRunner {
69
+ /** Human-readable name (e.g. "Stryker", "mutmut"). */
70
+ readonly name: string;
71
+
72
+ /** File extensions this runner handles (without dot, e.g. ["ts", "tsx"]). */
73
+ readonly extensions: string[];
74
+
75
+ /** Whether the tool is available on the system (quick check). */
76
+ isAvailable(): Promise<boolean>;
77
+
78
+ /** Run mutation testing on the given files. */
79
+ run(options: RunMutationOptions): Promise<MutationRunOutcome>;
80
+ }
81
+
82
+ /** Registry of runners, keyed by language name. */
83
+ export const runnerRegistry = new Map<string, MutationRunner>();
84
+
85
+ /** Register a runner so it can be resolved by file extension. */
86
+ export function registerRunner(runner: MutationRunner): void {
87
+ runnerRegistry.set(runner.name, runner);
88
+ }
89
+
90
+ /**
91
+ * Resolve the correct runner for a given file extension.
92
+ * Returns undefined if no runner supports this extension.
93
+ */
94
+ export function resolveRunner(ext: string): MutationRunner | undefined {
95
+ const normalized = ext.replace(/^\./, '');
96
+ for (const runner of Array.from(runnerRegistry.values())) {
97
+ if (runner.extensions.includes(normalized)) return runner;
98
+ }
99
+ return undefined;
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.9.5",
3
+ "version": "0.10.1",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "./bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.9.5",
3
+ "version": "0.10.1",
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
- **Generated:** 2026-06-21
4
- **Commit:** 1050a30
3
+ **Generated:** 2026-06-22
4
+ **Commit:** 448ac7e
5
5
  **Branch:** main
6
- **Version:** 0.9.5.0
6
+ **Version:** 0.10.1.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
- **Generated:** 2026-06-21
4
- **Commit:** 1050a30
3
+ **Generated:** 2026-06-22
4
+ **Commit:** 448ac7e
5
5
  **Branch:** main
6
- **Version:** 0.9.5.0
6
+ **Version:** 0.10.1.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
- **Generated:** 2026-06-21
4
- **Commit:** 1050a30
3
+ **Generated:** 2026-06-22
4
+ **Commit:** 448ac7e
5
5
  **Branch:** main
6
- **Version:** 0.9.5.0
6
+ **Version:** 0.10.1.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/opencode-plugin",
3
- "version": "0.9.5",
3
+ "version": "0.10.1",
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
- **Generated:** 2026-06-21
4
- **Commit:** 1050a30
3
+ **Generated:** 2026-06-22
4
+ **Commit:** 448ac7e
5
5
  **Branch:** main
6
- **Version:** 0.9.5.0
6
+ **Version:** 0.10.1.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
- **Generated:** 2026-06-21
4
- **Commit:** 1050a30
3
+ **Generated:** 2026-06-22
4
+ **Commit:** 448ac7e
5
5
  **Branch:** main
6
- **Version:** 0.9.5.0
6
+ **Version:** 0.10.1.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
- **Generated:** 2026-06-21
4
- **Commit:** 1050a30
3
+ **Generated:** 2026-06-22
4
+ **Commit:** 448ac7e
5
5
  **Branch:** main
6
- **Version:** 0.9.5.0
6
+ **Version:** 0.10.1.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -15,7 +15,67 @@
15
15
  import { existsSync, readFileSync } from "node:fs"
16
16
  import { join } from "node:path"
17
17
  import type { TuiPlugin, TuiSlotPlugin, TuiSlotProps } from "@opencode-ai/plugin/tui"
18
- import { PHASE_NAMES, PHASE_ORDER, getLatestTimestamp, isStale } from "../../src/npm-package/lib/shared-phase-constants.js"
18
+ // ── Phase constants (inlined from ../../src/npm-package/lib/shared-phase-constants.js)
19
+ // This file is inlined because the installed npm package does not bundle src/ at publish time. ──
20
+
21
+ const PHASE_NAMES: Record<string, string> = {
22
+ '-1': 'ISOLATE',
23
+ '-0.5': 'AUTO-ESTIMATE',
24
+ '0': 'THINK',
25
+ '1': 'PLAN',
26
+ '2': 'BUILD',
27
+ '3': 'REVIEW',
28
+ '4': 'USER ACCEPT',
29
+ '5': 'FEEDBACK',
30
+ '6': 'SHIP',
31
+ '7': 'LAND',
32
+ '8': 'CLEANUP',
33
+ };
34
+
35
+ const PHASE_ORDER = ['-1', '-0.5', '0', '1', '2', '3', '4', '5', '6', '7', '8'];
36
+
37
+ function parseTime(value: unknown): number {
38
+ return new Date(value as string).getTime();
39
+ }
40
+
41
+ function maxValid(current: number, candidate: unknown): number {
42
+ if (!candidate) return current;
43
+ const t = parseTime(candidate);
44
+ return !isNaN(t) && t > current ? t : current;
45
+ }
46
+
47
+ function getLatestTimestamp(state: Record<string, unknown> | null): number {
48
+ if (!state || !state.started_at) return 0;
49
+ const started = parseTime(state.started_at);
50
+ if (isNaN(started)) return 0;
51
+ let latest = started;
52
+ if (Array.isArray(state.phase_history)) {
53
+ for (const ph of state.phase_history) {
54
+ latest = maxValid(maxValid(latest, (ph as Record<string, unknown>).completed_at), (ph as Record<string, unknown>).started_at);
55
+ }
56
+ }
57
+ return latest;
58
+ }
59
+
60
+ function isStale(state: SprintState | null): boolean {
61
+ if (!state || !state.started_at) return false;
62
+ const latest = sprintTimestamp(state);
63
+ return latest > 0 && Date.now() - latest > 3600000;
64
+ }
65
+
66
+ function sprintTimestamp(state: SprintState | null): number {
67
+ if (!state || !state.started_at) return 0;
68
+ const started = parseTime(state.started_at);
69
+ if (isNaN(started)) return 0;
70
+ let latest = started;
71
+ if (Array.isArray(state.phase_history)) {
72
+ for (const ph of state.phase_history) {
73
+ latest = ph.completed_at ? Math.max(latest, parseTime(ph.completed_at)) : latest;
74
+ latest = ph.started_at ? Math.max(latest, parseTime(ph.started_at)) : latest;
75
+ }
76
+ }
77
+ return latest;
78
+ }
19
79
 
20
80
  // ── Sprint state schema ──
21
81
 
@@ -147,8 +207,7 @@ const tuiPlugin: TuiSlotPlugin = {
147
207
  if (!state) return null
148
208
  const text = renderSprintSidebar(state)
149
209
  if (!text) return null
150
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
- return text as any
210
+ return text
152
211
  },
153
212
  },
154
213
  }
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-06-21
4
- **Commit:** 1050a30
3
+ **Generated:** 2026-06-22
4
+ **Commit:** 448ac7e
5
5
  **Branch:** main
6
- **Version:** 0.9.5.0
6
+ **Version:** 0.10.1.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
- **Generated:** 2026-06-21
4
- **Commit:** 1050a30
3
+ **Generated:** 2026-06-22
4
+ **Commit:** 448ac7e
5
5
  **Branch:** main
6
- **Version:** 0.9.5.0
6
+ **Version:** 0.10.1.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.