mirror of
https://github.com/jparkerweb/plan2code.git
synced 2026-07-21 18:33:22 -07:00
v1.10.0
This commit is contained in:
@@ -99,13 +99,9 @@ function buildCohort(runs: RunMetrics[], cohortKey: string): CohortMetrics {
|
||||
const avgReqCoverage = avg(runs.map(r => r.step2_document.requirement_coverage_percent));
|
||||
const avgVerifItems = avg(runs.map(r => r.step2_document.verification_items_added));
|
||||
|
||||
// Step 3 (loop only)
|
||||
const loopRuns = runs.filter(r => r.step3_implement.used_loop_mode);
|
||||
const avgCompletionRate = avg(loopRuns.map(r => r.step3_implement.task_completion_rate));
|
||||
const avgBlockerCount = avg(loopRuns.map(r => r.step3_implement.blocker_count));
|
||||
const avgTotalIter = avg(loopRuns.map(r => r.step3_implement.total_iterations));
|
||||
const avgIterDuration = avg(loopRuns.map(r => r.step3_implement.avg_iteration_duration_ms));
|
||||
const avgMarkerSuccess = avg(loopRuns.map(r => r.step3_implement.completion_marker_success_rate));
|
||||
// Step 3 averages
|
||||
const avgCompletionRate = avg(runs.map(r => r.step3_implement.task_completion_rate));
|
||||
const avgBlockerCount = avg(runs.map(r => r.step3_implement.blocker_count));
|
||||
|
||||
// Step 4 averages
|
||||
const avgCompletionAtAudit = avg(runs.map(r => r.step4_finalize.completion_rate_at_audit));
|
||||
@@ -140,12 +136,8 @@ function buildCohort(runs: RunMetrics[], cohortKey: string): CohortMetrics {
|
||||
avg_requirement_coverage_percent: avgReqCoverage,
|
||||
avg_verification_items_added: avgVerifItems,
|
||||
|
||||
loop_run_count: loopRuns.length,
|
||||
avg_task_completion_rate: avgCompletionRate,
|
||||
avg_blocker_count: avgBlockerCount,
|
||||
avg_total_iterations: avgTotalIter,
|
||||
avg_iteration_duration_ms: avgIterDuration,
|
||||
avg_completion_marker_success_rate: avgMarkerSuccess,
|
||||
|
||||
avg_completion_rate_at_audit: avgCompletionAtAudit,
|
||||
avg_verification_failures_found: avgVerifFailures,
|
||||
|
||||
@@ -57,12 +57,12 @@ export interface AnalyzerOptions {
|
||||
aggregatedPath: string; // Path to aggregated.json
|
||||
plan2codeRoot: string; // Path to plan2code repo root
|
||||
proposalsDir: string; // Where to save diagnosis output
|
||||
model?: string; // AI model to use (default: claude-opus-4-6)
|
||||
model?: string; // AI model to use (default: agent's default)
|
||||
agent?: AgentType; // Agent to use (default: claude-code)
|
||||
}
|
||||
|
||||
export async function runAnalysis(opts: AnalyzerOptions): Promise<string> {
|
||||
const { aggregatedPath, plan2codeRoot, proposalsDir, model = 'claude-opus-4-6', agent = 'claude-code' } = opts;
|
||||
const { aggregatedPath, plan2codeRoot, proposalsDir, model = 'default', agent = 'claude-code' } = opts;
|
||||
|
||||
// Load aggregated metrics
|
||||
let aggregated: AggregatedMetrics | null = null;
|
||||
@@ -102,7 +102,7 @@ export async function runAnalysis(opts: AnalyzerOptions): Promise<string> {
|
||||
});
|
||||
|
||||
// Invoke Claude
|
||||
console.log(`\nInvoking AI analysis (model: ${model})...`);
|
||||
console.log(`\nInvoking AI analysis (agent: ${agent}, model: ${model === 'default' ? 'user default' : model})...`);
|
||||
console.log('This may take a minute...\n');
|
||||
|
||||
let diagnosisContent: string;
|
||||
|
||||
+227
-31
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { select, input, confirm } from '@inquirer/prompts';
|
||||
import chalk from 'chalk';
|
||||
@@ -18,6 +19,33 @@ import type { AggregatedMetrics, CohortMetrics } from './types.js';
|
||||
import { METRIC_TARGETS } from './types.js';
|
||||
import { AGENTS, type AgentType } from './invoke-llm.js';
|
||||
|
||||
// ── Session state (set at startup via interactive prompts) ───────────────────
|
||||
|
||||
let resolvedPlan2CodeRoot = '';
|
||||
let resolvedProjectRoot = '';
|
||||
|
||||
// ── Persisted user config (~/.plan2code-metrics.json) ──────────────────────
|
||||
|
||||
const USER_CONFIG_PATH = path.join(os.homedir(), '.plan2code-metrics.json');
|
||||
|
||||
interface UserConfig {
|
||||
plan2codeRepoPath?: string;
|
||||
}
|
||||
|
||||
function loadUserConfig(): UserConfig {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(USER_CONFIG_PATH, 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveUserConfig(config: UserConfig): void {
|
||||
try {
|
||||
fs.writeFileSync(USER_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_METRICS_DIR = '.plan2code-metrics';
|
||||
@@ -25,7 +53,7 @@ const DEFAULT_RUNS_SUBDIR = 'runs';
|
||||
const DEFAULT_AGGREGATED_FILE = 'aggregated.json';
|
||||
const DEFAULT_PROPOSALS_SUBDIR = 'proposals';
|
||||
|
||||
function getMetricsDirs(baseDir = process.cwd()) {
|
||||
function getMetricsDirs(baseDir = resolvedProjectRoot) {
|
||||
const metricsDir = path.join(baseDir, DEFAULT_METRICS_DIR);
|
||||
return {
|
||||
metricsDir,
|
||||
@@ -89,13 +117,7 @@ async function selectAgentAndModel(): Promise<{ agent: AgentType; model: string
|
||||
choices: Object.values(AGENTS).map(a => ({ name: a.displayName, value: a.name })),
|
||||
});
|
||||
|
||||
const agentDef = AGENTS[agentChoice];
|
||||
const model = await select({
|
||||
message: 'AI model:',
|
||||
choices: agentDef.models.map(m => ({ name: m.label, value: m.value })),
|
||||
});
|
||||
|
||||
return { agent: agentChoice, model };
|
||||
return { agent: agentChoice, model: AGENTS[agentChoice].defaultModel };
|
||||
}
|
||||
|
||||
// ── Flow: Collect metrics ─────────────────────────────────────────────────────
|
||||
@@ -107,8 +129,8 @@ async function flowCollect(): Promise<void> {
|
||||
const sourceChoice = await select({
|
||||
message: 'Where is the completed project spec?',
|
||||
choices: [
|
||||
{ name: 'Active spec directory (specs/<feature-name>/)', value: 'active' },
|
||||
{ name: 'Archived spec (specs--completed/<feature-name>/)', value: 'archived' },
|
||||
{ name: 'Active spec directory (specs/<feature-name>/)', value: 'active' },
|
||||
{ name: 'Custom path', value: 'custom' },
|
||||
],
|
||||
});
|
||||
@@ -118,10 +140,10 @@ async function flowCollect(): Promise<void> {
|
||||
|
||||
if (sourceChoice === 'active' || sourceChoice === 'archived') {
|
||||
const baseSubdir = sourceChoice === 'active' ? 'specs' : 'specs--completed';
|
||||
const baseDir = path.join(process.cwd(), baseSubdir);
|
||||
const baseDir = path.join(resolvedProjectRoot, baseSubdir);
|
||||
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
console.log(chalk.red(`No ${baseSubdir}/ directory found in ${process.cwd()}`));
|
||||
console.log(chalk.red(`No ${baseSubdir}/ directory found in ${resolvedProjectRoot}`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,7 +174,7 @@ async function flowCollect(): Promise<void> {
|
||||
}
|
||||
|
||||
// Detect plan2code root
|
||||
const plan2codeRoot = detectPlan2CodeRoot() ?? process.cwd();
|
||||
const plan2codeRoot = resolvedPlan2CodeRoot;
|
||||
const plan2codeVersion = detectPlan2CodeVersion(plan2codeRoot);
|
||||
|
||||
const { runsDir, aggregatedPath } = getMetricsDirs();
|
||||
@@ -179,7 +201,7 @@ async function flowCollect(): Promise<void> {
|
||||
console.log(chalk.bold('Collected:'));
|
||||
console.log(` Step 1 (Plan): ${metrics.step1_plan.present ? chalk.green('✓') : chalk.gray('—')}`);
|
||||
console.log(` Step 2 (Document): ${metrics.step2_document.present ? chalk.green('✓') : chalk.gray('—')}`);
|
||||
console.log(` Step 3 (Implement): ${metrics.step3_implement.present ? (metrics.step3_implement.used_loop_mode ? chalk.green('✓ (loop)') : chalk.yellow('✓ (manual)')) : chalk.gray('—')}`);
|
||||
console.log(` Step 3 (Implement): ${metrics.step3_implement.present ? chalk.green('✓') : chalk.gray('—')}`);
|
||||
console.log(` Step 4 (Finalize): ${metrics.step4_finalize.present ? chalk.green('✓') : chalk.gray('—')}`);
|
||||
console.log(` User Feedback: ${metrics.user_feedback ? chalk.green(`✓ (${metrics.user_feedback.overall_rating}/10)`) : chalk.gray('—')}`);
|
||||
|
||||
@@ -334,15 +356,13 @@ async function flowViewStatus(): Promise<void> {
|
||||
console.log(` avg_verification_items: ${metricStatus('avg_verification_items_added', cohort.avg_verification_items_added)}`);
|
||||
}
|
||||
|
||||
// Step 3 metrics (loop only)
|
||||
if (cohort.loop_run_count > 0) {
|
||||
console.log(chalk.bold(` Step 3 (Implement) — ${cohort.loop_run_count} loop run(s):`));
|
||||
// Step 3 metrics
|
||||
if (cohort.avg_task_completion_rate != null || cohort.avg_blocker_count != null) {
|
||||
console.log(chalk.bold(' Step 3 (Implement):'));
|
||||
if (cohort.avg_task_completion_rate != null)
|
||||
console.log(` avg_task_completion_rate: ${metricStatus('avg_task_completion_rate', cohort.avg_task_completion_rate)}`);
|
||||
if (cohort.avg_blocker_count != null)
|
||||
console.log(` avg_blocker_count: ${metricStatus('avg_blocker_count', cohort.avg_blocker_count)}`);
|
||||
if (cohort.avg_completion_marker_success_rate != null)
|
||||
console.log(` avg_marker_success_rate: ${metricStatus('avg_completion_marker_success_rate', cohort.avg_completion_marker_success_rate)}`);
|
||||
}
|
||||
|
||||
// Step 4 metrics
|
||||
@@ -402,7 +422,7 @@ async function flowRunAnalysis(): Promise<string | null> {
|
||||
console.log(chalk.bold.cyan('── Run Analysis (Diagnose Weak Steps) ──'));
|
||||
|
||||
const { aggregatedPath, proposalsDir } = getMetricsDirs();
|
||||
const plan2codeRoot = detectPlan2CodeRoot() ?? process.cwd();
|
||||
const plan2codeRoot = resolvedPlan2CodeRoot;
|
||||
|
||||
const aggregated = loadAggregated(aggregatedPath);
|
||||
if (!aggregated || aggregated.total_runs === 0) {
|
||||
@@ -460,7 +480,7 @@ async function flowGenerateProposal(): Promise<void> {
|
||||
console.log(chalk.bold.cyan('── Generate Improvement Proposal ──'));
|
||||
|
||||
const { aggregatedPath, proposalsDir, runsDir } = getMetricsDirs();
|
||||
const plan2codeRoot = detectPlan2CodeRoot() ?? process.cwd();
|
||||
const plan2codeRoot = resolvedPlan2CodeRoot;
|
||||
|
||||
// Find diagnosis files
|
||||
let diagFiles: string[] = [];
|
||||
@@ -540,7 +560,7 @@ async function flowReviewAndApply(): Promise<void> {
|
||||
console.log(chalk.bold.cyan('── Review and Apply a Proposal ──'));
|
||||
|
||||
const { proposalsDir } = getMetricsDirs();
|
||||
const plan2codeRoot = detectPlan2CodeRoot() ?? process.cwd();
|
||||
const plan2codeRoot = resolvedPlan2CodeRoot;
|
||||
|
||||
if (!fs.existsSync(proposalsDir)) {
|
||||
console.log(chalk.yellow('No proposals directory found. Generate a proposal first.'));
|
||||
@@ -577,6 +597,138 @@ async function flowReviewAndApply(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Flow: Delete metrics data ─────────────────────────────────────────────────
|
||||
|
||||
async function flowDelete(): Promise<void> {
|
||||
console.log();
|
||||
console.log(chalk.bold.cyan('── Delete Metrics Data ──'));
|
||||
|
||||
const { metricsDir, runsDir, aggregatedPath, proposalsDir } = getMetricsDirs();
|
||||
|
||||
if (!fs.existsSync(metricsDir)) {
|
||||
console.log(chalk.yellow('No metrics data found (.plan2code-metrics/ does not exist).'));
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = await select({
|
||||
message: 'What do you want to delete?',
|
||||
choices: [
|
||||
{ name: 'Delete specific run(s)', value: 'select' },
|
||||
{ name: 'Delete all runs and aggregated data', value: 'runs' },
|
||||
{ name: 'Delete everything (runs, aggregated data, proposals)', value: 'all' },
|
||||
{ name: 'Cancel', value: 'cancel' },
|
||||
],
|
||||
});
|
||||
|
||||
if (scope === 'cancel') return;
|
||||
|
||||
if (scope === 'select') {
|
||||
const runs = loadRunFiles(runsDir);
|
||||
if (runs.length === 0) {
|
||||
console.log(chalk.yellow('No run files found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const choices = runs.map(r => ({
|
||||
name: `${r.run_id} ${r.project.name} v${r.plan2code_version}`,
|
||||
value: r.run_id,
|
||||
}));
|
||||
|
||||
// Select runs one at a time since @inquirer/prompts select is single-choice
|
||||
const toDelete: string[] = [];
|
||||
let selecting = true;
|
||||
while (selecting) {
|
||||
const remaining = choices.filter(c => !toDelete.includes(c.value));
|
||||
if (remaining.length === 0) break;
|
||||
|
||||
const chosen = await select({
|
||||
message: `Select a run to delete (${toDelete.length} selected so far):`,
|
||||
choices: [
|
||||
...remaining,
|
||||
{ name: toDelete.length > 0 ? `Done selecting (delete ${toDelete.length})` : 'Cancel', value: '__done__' },
|
||||
],
|
||||
});
|
||||
|
||||
if (chosen === '__done__') {
|
||||
selecting = false;
|
||||
} else {
|
||||
toDelete.push(chosen);
|
||||
console.log(chalk.gray(` + ${chosen}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (toDelete.length === 0) return;
|
||||
|
||||
const confirmed = await confirm({
|
||||
message: `Delete ${toDelete.length} run(s)? This cannot be undone.`,
|
||||
default: false,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
let deleted = 0;
|
||||
for (const runId of toDelete) {
|
||||
const filePath = path.join(runsDir, `${runId}.json`);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
deleted++;
|
||||
} catch {
|
||||
console.log(chalk.yellow(` Could not delete ${runId}.json`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✓ Deleted ${deleted} run(s).`));
|
||||
|
||||
// Re-aggregate with remaining runs
|
||||
const remainingRuns = loadRunFiles(runsDir);
|
||||
if (remainingRuns.length > 0) {
|
||||
aggregate(runsDir, aggregatedPath);
|
||||
console.log(chalk.gray(` Aggregated metrics updated (${remainingRuns.length} runs remaining).`));
|
||||
} else {
|
||||
try { fs.unlinkSync(aggregatedPath); } catch { /* ignore */ }
|
||||
console.log(chalk.gray(' No runs remaining — aggregated data removed.'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// scope === 'runs' or 'all'
|
||||
const label = scope === 'all'
|
||||
? 'ALL metrics data (runs, aggregated data, and proposals)'
|
||||
: 'all runs and aggregated data';
|
||||
|
||||
const confirmed = await confirm({
|
||||
message: `Delete ${label}? This cannot be undone.`,
|
||||
default: false,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
// Delete run files
|
||||
let runCount = 0;
|
||||
if (fs.existsSync(runsDir)) {
|
||||
const files = fs.readdirSync(runsDir).filter(f => f.endsWith('.json'));
|
||||
for (const f of files) {
|
||||
try { fs.unlinkSync(path.join(runsDir, f)); runCount++; } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Delete aggregated file
|
||||
try { fs.unlinkSync(aggregatedPath); } catch { /* ignore */ }
|
||||
|
||||
console.log(chalk.green(`✓ Deleted ${runCount} run(s) and aggregated data.`));
|
||||
|
||||
if (scope === 'all') {
|
||||
// Delete proposals
|
||||
let proposalCount = 0;
|
||||
if (fs.existsSync(proposalsDir)) {
|
||||
const files = fs.readdirSync(proposalsDir);
|
||||
for (const f of files) {
|
||||
try { fs.unlinkSync(path.join(proposalsDir, f)); proposalCount++; } catch { /* ignore */ }
|
||||
}
|
||||
try { fs.rmdirSync(proposalsDir); } catch { /* ignore */ }
|
||||
}
|
||||
console.log(chalk.green(`✓ Deleted ${proposalCount} proposal file(s).`));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main menu ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function runCLI(): Promise<void> {
|
||||
@@ -585,18 +737,58 @@ export async function runCLI(): Promise<void> {
|
||||
console.log(chalk.gray('Recursive self-improvement toolchain for plan2code contributors'));
|
||||
console.log();
|
||||
|
||||
// Check if we're in (or near) a plan2code repo
|
||||
const plan2codeRoot = detectPlan2CodeRoot();
|
||||
if (!plan2codeRoot) {
|
||||
console.log(chalk.yellow('⚠ Could not detect plan2code repository (src/plan2code-*.md not found nearby).'));
|
||||
console.log(chalk.gray(' Prompt version hashing and analysis will be limited.'));
|
||||
console.log();
|
||||
} else {
|
||||
const version = detectPlan2CodeVersion(plan2codeRoot);
|
||||
console.log(chalk.gray(`plan2code root: ${plan2codeRoot} (v${version})`));
|
||||
console.log();
|
||||
// ── Prompt for paths ────────────────────────────────────────────────────────
|
||||
|
||||
const userConfig = loadUserConfig();
|
||||
const savedRoot = userConfig.plan2codeRepoPath && fs.existsSync(userConfig.plan2codeRepoPath)
|
||||
? userConfig.plan2codeRepoPath
|
||||
: null;
|
||||
const detectedRoot = savedRoot ?? detectPlan2CodeRoot();
|
||||
|
||||
const s2cPath = await input({
|
||||
message: 'Path to plan2code repo:',
|
||||
default: detectedRoot ?? undefined,
|
||||
validate: (v) => {
|
||||
if (!v.trim()) return 'Path is required';
|
||||
const resolved = path.resolve(v.trim());
|
||||
if (!fs.existsSync(resolved)) return 'Directory not found';
|
||||
return true;
|
||||
},
|
||||
});
|
||||
resolvedPlan2CodeRoot = path.resolve(s2cPath.trim());
|
||||
|
||||
// Persist the path for next run
|
||||
if (resolvedPlan2CodeRoot !== userConfig.plan2codeRepoPath) {
|
||||
saveUserConfig({ ...userConfig, plan2codeRepoPath: resolvedPlan2CodeRoot });
|
||||
}
|
||||
|
||||
// Warn if the path doesn't look like a plan2code repo
|
||||
const hasSrcPrompts =
|
||||
fs.existsSync(path.join(resolvedPlan2CodeRoot, 'src', 'plan2code-1--plan.md')) &&
|
||||
fs.existsSync(path.join(resolvedPlan2CodeRoot, 'src', 'plan2code-2--document.md'));
|
||||
if (!hasSrcPrompts) {
|
||||
console.log(chalk.yellow('⚠ No src/plan2code-*.md prompts found at that path. Hashing and analysis may be limited.'));
|
||||
}
|
||||
|
||||
const projPath = await input({
|
||||
message: 'Path to project repo (metrics source):',
|
||||
default: process.cwd(),
|
||||
validate: (v) => {
|
||||
if (!v.trim()) return 'Path is required';
|
||||
const resolved = path.resolve(v.trim());
|
||||
if (!fs.existsSync(resolved)) return 'Directory not found';
|
||||
return true;
|
||||
},
|
||||
});
|
||||
resolvedProjectRoot = path.resolve(projPath.trim());
|
||||
|
||||
// Display resolved paths
|
||||
const version = detectPlan2CodeVersion(resolvedPlan2CodeRoot);
|
||||
console.log();
|
||||
console.log(chalk.gray(`plan2code repo: ${resolvedPlan2CodeRoot} (v${version})`));
|
||||
console.log(chalk.gray(`project repo: ${resolvedProjectRoot}`));
|
||||
console.log();
|
||||
|
||||
let continueLoop = true;
|
||||
while (continueLoop) {
|
||||
const action = await select({
|
||||
@@ -608,6 +800,7 @@ export async function runCLI(): Promise<void> {
|
||||
{ name: 'Run analysis (diagnose weak steps)', value: 'analyze' },
|
||||
{ name: 'Generate improvement proposal', value: 'propose' },
|
||||
{ name: 'Review and apply a proposal', value: 'apply' },
|
||||
{ name: chalk.red('Delete metrics data'), value: 'delete' },
|
||||
{ name: 'Exit', value: 'exit' },
|
||||
],
|
||||
});
|
||||
@@ -631,6 +824,9 @@ export async function runCLI(): Promise<void> {
|
||||
case 'apply':
|
||||
await flowReviewAndApply();
|
||||
break;
|
||||
case 'delete':
|
||||
await flowDelete();
|
||||
break;
|
||||
case 'exit':
|
||||
continueLoop = false;
|
||||
break;
|
||||
|
||||
+114
-146
@@ -36,6 +36,34 @@ function readFileSafe(filePath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip the "## Success Criteria" section so its checkboxes aren't counted as tasks. */
|
||||
function stripSuccessCriteriaSection(overview: string): string {
|
||||
return overview.replace(/^## Success Criteria\s*\n[\s\S]*?(?=\n## |\n*$)/m, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract canonical task counts from the Completion Summary section.
|
||||
* Supports two formats found in real specs:
|
||||
* - Bold text: "**Completion Rate:** 100% (5/5 tasks)"
|
||||
* - Table row: "| Total Tasks | 28 (28/28 complete — 100%) |"
|
||||
* Returns null when no parseable counts are found.
|
||||
*/
|
||||
function parseCompletionSummaryTaskCount(overview: string): { completed: number; total: number } | null {
|
||||
const summaryMatch = overview.match(/## Completion Summary\s*\n([\s\S]*?)(?=\n## |\n*$)/);
|
||||
if (!summaryMatch) return null;
|
||||
const summary = summaryMatch[1];
|
||||
|
||||
// "Completion Rate: X% (Y/Z tasks)"
|
||||
const rateMatch = summary.match(/Completion Rate[:\s*]*\d{1,3}%\s*\((\d+)\/(\d+)\s*tasks?\)/i);
|
||||
if (rateMatch) return { completed: parseInt(rateMatch[1], 10), total: parseInt(rateMatch[2], 10) };
|
||||
|
||||
// "Total Tasks | 28 (28/28 complete"
|
||||
const tableMatch = summary.match(/Total Tasks\s*\|\s*(\d+)\s*\((\d+)\/(\d+)\s*complete/i);
|
||||
if (tableMatch) return { completed: parseInt(tableMatch[2], 10), total: parseInt(tableMatch[3], 10) };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function generateRunId(): string {
|
||||
const now = new Date();
|
||||
const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
@@ -173,13 +201,10 @@ export function collectStep2(specDir: string): Step2DocumentMetrics {
|
||||
requirement_coverage_percent: null, verification_items_added: null };
|
||||
}
|
||||
|
||||
// Count all checkbox tasks: - [ ], - [x], - [!], - [/]
|
||||
const allTasks = (overview.match(/^\s*-\s+\[[ x!/?]\]/gm) ?? []).length;
|
||||
|
||||
// Detect Parallel Execution Groups table
|
||||
const parallelGroups = (overview.match(/Parallel\s+Execution\s+Group/gi) ?? []).length;
|
||||
|
||||
// Count phase files
|
||||
// Count phase files (scanned first so we can use sum as a total_tasks fallback)
|
||||
let phaseCount = 0;
|
||||
let tasksPerPhase: number[] = [];
|
||||
try {
|
||||
@@ -190,13 +215,31 @@ export function collectStep2(specDir: string): Step2DocumentMetrics {
|
||||
phaseCount = phaseFiles.length;
|
||||
for (const pf of phaseFiles) {
|
||||
const content = readFileSafe(path.join(specDir, pf)) ?? '';
|
||||
const count = (content.match(/^\s*-\s+\[[ x!/?]\]/gm) ?? []).length;
|
||||
const count = (content.match(/^\s*-\s+\[[ x!/?]\]\s+\*\*Task\s+\d/gm) ?? []).length;
|
||||
tasksPerPhase.push(count);
|
||||
}
|
||||
} catch {
|
||||
// leave empty
|
||||
}
|
||||
|
||||
// total_tasks priority:
|
||||
// 1. Completion Summary canonical count
|
||||
// 2. Sum of phase file task counts
|
||||
// 3. Stripped overview checkboxes (excluding Success Criteria)
|
||||
const summaryCount = parseCompletionSummaryTaskCount(overview);
|
||||
const phaseSum = tasksPerPhase.length > 0 ? tasksPerPhase.reduce((a, b) => a + b, 0) : 0;
|
||||
const strippedOverview = stripSuccessCriteriaSection(overview);
|
||||
const strippedCheckboxCount = (strippedOverview.match(/^\s*-\s+\[[ x!/?]\]/gm) ?? []).length;
|
||||
|
||||
let totalTasks: number;
|
||||
if (summaryCount) {
|
||||
totalTasks = summaryCount.total;
|
||||
} else if (phaseSum > 0) {
|
||||
totalTasks = phaseSum;
|
||||
} else {
|
||||
totalTasks = strippedCheckboxCount;
|
||||
}
|
||||
|
||||
// Requirement coverage: look for coverage percentage in overview
|
||||
let reqCoverage: number | null = null;
|
||||
const covMatch = overview.match(/[Cc]overage[:\s]+(\d{1,3})%/);
|
||||
@@ -207,7 +250,7 @@ export function collectStep2(specDir: string): Step2DocumentMetrics {
|
||||
|
||||
return {
|
||||
present: true,
|
||||
total_tasks: allTasks || null,
|
||||
total_tasks: totalTasks || null,
|
||||
tasks_per_phase: tasksPerPhase.length > 0 ? tasksPerPhase : null,
|
||||
phase_count: phaseCount || null,
|
||||
parallel_groups_identified: parallelGroups || 0,
|
||||
@@ -216,124 +259,67 @@ export function collectStep2(specDir: string): Step2DocumentMetrics {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step 3: Implement (loop data) ─────────────────────────────────────────────
|
||||
// ── Step 3: Implement ──────────────────────────────────────────────────────────
|
||||
|
||||
export function collectStep3(specDir: string): Step3ImplementMetrics {
|
||||
const loopDir = path.join(specDir, '.plan2code-loop');
|
||||
const iterLogPath = path.join(loopDir, 'iteration.log');
|
||||
const configPath = path.join(loopDir, 'config.json');
|
||||
|
||||
if (!fs.existsSync(loopDir)) {
|
||||
return { present: true, used_loop_mode: false, loop_mode: null,
|
||||
task_completion_rate: null, tasks_completed: null, tasks_total: null,
|
||||
blocker_count: null, blocker_categories: null, total_iterations: null,
|
||||
avg_iteration_duration_ms: null, exit_code_distribution: null,
|
||||
completion_marker_success_rate: null };
|
||||
// Discover phase-*.md files
|
||||
let phaseFiles: string[] = [];
|
||||
try {
|
||||
const files = fs.readdirSync(specDir);
|
||||
phaseFiles = files
|
||||
.filter(f => /^phase-\d+\.md$/i.test(f))
|
||||
.sort();
|
||||
} catch {
|
||||
// leave empty
|
||||
}
|
||||
|
||||
// Parse config.json for loop mode
|
||||
let loopMode: 'task' | 'phase' | null = null;
|
||||
const configContent = readFileSafe(configPath);
|
||||
if (configContent) {
|
||||
try {
|
||||
const config = JSON.parse(configContent);
|
||||
loopMode = config.loopMode ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Parse NDJSON iteration.log
|
||||
const iterLogContent = readFileSafe(iterLogPath);
|
||||
if (!iterLogContent) {
|
||||
return { present: true, used_loop_mode: true, loop_mode: loopMode,
|
||||
task_completion_rate: null, tasks_completed: null, tasks_total: null,
|
||||
blocker_count: null, blocker_categories: null, total_iterations: null,
|
||||
avg_iteration_duration_ms: null, exit_code_distribution: null,
|
||||
completion_marker_success_rate: null };
|
||||
}
|
||||
|
||||
interface IterEntry {
|
||||
iteration: number;
|
||||
timestamp: string;
|
||||
duration: number;
|
||||
exitCode: number;
|
||||
status: string;
|
||||
completionMarker?: string;
|
||||
}
|
||||
|
||||
const entries: IterEntry[] = [];
|
||||
for (const line of iterLogContent.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
entries.push(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
const totalIterations = entries.length;
|
||||
if (totalIterations === 0) {
|
||||
return { present: true, used_loop_mode: true, loop_mode: loopMode,
|
||||
task_completion_rate: null, tasks_completed: null, tasks_total: null,
|
||||
blocker_count: null, blocker_categories: null, total_iterations: 0,
|
||||
avg_iteration_duration_ms: null, exit_code_distribution: null,
|
||||
completion_marker_success_rate: null };
|
||||
}
|
||||
|
||||
// Exit code distribution
|
||||
const exitCodes: Record<string, number> = {};
|
||||
for (const e of entries) {
|
||||
const key = String(e.exitCode ?? 'other');
|
||||
exitCodes[key] = (exitCodes[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Average duration
|
||||
const durations = entries.map(e => e.duration).filter(d => d != null && d > 0);
|
||||
const avgDuration = durations.length > 0
|
||||
? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)
|
||||
: null;
|
||||
|
||||
// Completion markers
|
||||
const markerEntries = entries.filter(e => e.completionMarker && e.completionMarker.trim() !== '');
|
||||
const taskCompleteEntries = markerEntries.filter(e =>
|
||||
e.completionMarker?.startsWith('TASK_COMPLETE')
|
||||
);
|
||||
const blockedEntries = markerEntries.filter(e =>
|
||||
e.completionMarker?.startsWith('TASK_BLOCKED')
|
||||
);
|
||||
|
||||
// Completion marker success rate: entries where a valid marker was detected vs total
|
||||
const validMarkerCount = markerEntries.length;
|
||||
const markerSuccessRate = totalIterations > 0
|
||||
? validMarkerCount / totalIterations
|
||||
: null;
|
||||
|
||||
// Task counts from markers (used for completion_marker_success_rate)
|
||||
const blockerCount = blockedEntries.length;
|
||||
|
||||
// Blocker categories (parse from "TASK_BLOCKED: X.Y - reason")
|
||||
const blockerCategories: string[] = [];
|
||||
for (const e of blockedEntries) {
|
||||
const match = e.completionMarker?.match(/TASK_BLOCKED:\s*[\d.]+\s*-\s*(.+)/);
|
||||
if (match) {
|
||||
// Normalize to snake_case category
|
||||
const reason = match[1].toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
|
||||
if (!blockerCategories.includes(reason)) {
|
||||
blockerCategories.push(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count tasks from overview.md checkboxes (consistent numerator and denominator)
|
||||
// Read overview.md for Completion Summary
|
||||
const overviewPath = path.join(specDir, 'overview.md');
|
||||
const overview = readFileSafe(overviewPath);
|
||||
const summaryCount = overview ? parseCompletionSummaryTaskCount(overview) : null;
|
||||
|
||||
// present = true if phase files exist OR overview has completion data
|
||||
const present = phaseFiles.length > 0 || summaryCount != null;
|
||||
if (!present) {
|
||||
return { present: false, task_completion_rate: null, tasks_completed: null,
|
||||
tasks_total: null, blocker_count: null };
|
||||
}
|
||||
|
||||
// Count checkboxes in phase files
|
||||
let phaseTotal = 0;
|
||||
let phaseCompleted = 0;
|
||||
let blockerCount = 0;
|
||||
for (const pf of phaseFiles) {
|
||||
const content = readFileSafe(path.join(specDir, pf)) ?? '';
|
||||
phaseTotal += (content.match(/^\s*-\s+\[[ x!/?]\]\s+\*\*Task\s+\d/gm) ?? []).length;
|
||||
phaseCompleted += (content.match(/^\s*-\s+\[x\]\s+\*\*Task\s+\d/gm) ?? []).length;
|
||||
blockerCount += (content.match(/^\s*-\s+\[!\]\s+\*\*Task\s+\d/gm) ?? []).length;
|
||||
}
|
||||
|
||||
// Count checkboxes in overview (stripped of Success Criteria)
|
||||
let overviewTotal = 0;
|
||||
let overviewCompleted = 0;
|
||||
if (overview) {
|
||||
const stripped = stripSuccessCriteriaSection(overview);
|
||||
overviewTotal = (stripped.match(/^\s*-\s+\[[ x!/?]\]/gm) ?? []).length;
|
||||
overviewCompleted = (stripped.match(/^\s*-\s+\[x\]/gm) ?? []).length;
|
||||
}
|
||||
|
||||
// Task counts priority:
|
||||
// 1. Completion Summary in overview.md
|
||||
// 2. Checkbox counting in phase files
|
||||
// 3. Checkbox counting in overview.md (stripped of Success Criteria)
|
||||
let tasksTotal: number | null = null;
|
||||
let tasksCompleted: number | null = null;
|
||||
if (overview) {
|
||||
tasksTotal = (overview.match(/^\s*-\s+\[[ x!/?]\]/gm) ?? []).length || null;
|
||||
tasksCompleted = (overview.match(/^\s*-\s+\[x\]/gm) ?? []).length || null;
|
||||
if (summaryCount) {
|
||||
tasksTotal = summaryCount.total;
|
||||
tasksCompleted = summaryCount.completed;
|
||||
} else if (phaseTotal > 0) {
|
||||
tasksTotal = phaseTotal;
|
||||
tasksCompleted = phaseCompleted;
|
||||
} else if (overviewTotal > 0) {
|
||||
tasksTotal = overviewTotal;
|
||||
tasksCompleted = overviewCompleted;
|
||||
}
|
||||
|
||||
const taskCompletionRate = tasksTotal && tasksTotal > 0 && tasksCompleted != null
|
||||
@@ -342,17 +328,10 @@ export function collectStep3(specDir: string): Step3ImplementMetrics {
|
||||
|
||||
return {
|
||||
present: true,
|
||||
used_loop_mode: true,
|
||||
loop_mode: loopMode,
|
||||
task_completion_rate: taskCompletionRate,
|
||||
tasks_completed: tasksCompleted || null,
|
||||
tasks_completed: tasksCompleted,
|
||||
tasks_total: tasksTotal,
|
||||
blocker_count: blockerCount,
|
||||
blocker_categories: blockerCategories.length > 0 ? blockerCategories : null,
|
||||
total_iterations: totalIterations,
|
||||
avg_iteration_duration_ms: avgDuration,
|
||||
exit_code_distribution: exitCodes,
|
||||
completion_marker_success_rate: markerSuccessRate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -380,10 +359,17 @@ export function collectStep4(specDir: string): Step4FinalizeMetrics {
|
||||
archival_succeeded: archivalSucceeded };
|
||||
}
|
||||
|
||||
// Completion rate: completed tasks / total tasks
|
||||
const totalTasks = (overview.match(/^\s*-\s+\[[ x!/?]\]/gm) ?? []).length;
|
||||
const completedTasks = (overview.match(/^\s*-\s+\[x\]/gm) ?? []).length;
|
||||
const completionRate = totalTasks > 0 ? completedTasks / totalTasks : null;
|
||||
// Completion rate: prefer Completion Summary, fall back to stripped overview checkboxes
|
||||
let completionRate: number | null = null;
|
||||
const summaryCount = parseCompletionSummaryTaskCount(overview);
|
||||
if (summaryCount) {
|
||||
completionRate = summaryCount.total > 0 ? summaryCount.completed / summaryCount.total : null;
|
||||
} else {
|
||||
const stripped = stripSuccessCriteriaSection(overview);
|
||||
const totalTasks = (stripped.match(/^\s*-\s+\[[ x!/?]\]/gm) ?? []).length;
|
||||
const completedTasks = (stripped.match(/^\s*-\s+\[x\]/gm) ?? []).length;
|
||||
completionRate = totalTasks > 0 ? completedTasks / totalTasks : null;
|
||||
}
|
||||
|
||||
// Verification failures: look for [!] tasks or "verification failed" text
|
||||
const blockedTasks = (overview.match(/^\s*-\s+\[!\]/gm) ?? []).length;
|
||||
@@ -476,24 +462,6 @@ export async function collectRun(opts: CollectorOptions): Promise<RunMetrics> {
|
||||
// leave null
|
||||
}
|
||||
|
||||
// Try to get started_at from earliest iteration log entry
|
||||
const loopDir = path.join(specDir, '.plan2code-loop');
|
||||
const iterLogPath = path.join(loopDir, 'iteration.log');
|
||||
const iterLogContent = readFileSafe(iterLogPath);
|
||||
if (iterLogContent) {
|
||||
const lines = iterLogContent.split('\n').filter(l => l.trim());
|
||||
if (lines.length > 0) {
|
||||
try {
|
||||
const first = JSON.parse(lines[0]);
|
||||
if (first.timestamp) startedAt = first.timestamp;
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const last = JSON.parse(lines[lines.length - 1]);
|
||||
if (last.timestamp) completedAt = last.timestamp;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const metrics: RunMetrics = {
|
||||
schema_version: '1.0',
|
||||
run_id: runId,
|
||||
|
||||
@@ -154,7 +154,7 @@ export interface ImproverResult {
|
||||
}
|
||||
|
||||
export async function generateImprovement(opts: ImproverOptions): Promise<ImproverResult> {
|
||||
const { diagnosisPath, plan2codeRoot, proposalsDir, runsDir, model = 'claude-opus-4-6', agent = 'claude-code' } = opts;
|
||||
const { diagnosisPath, plan2codeRoot, proposalsDir, runsDir, model = 'default', agent = 'claude-code' } = opts;
|
||||
|
||||
// Load diagnosis
|
||||
let diagnosisContent: string;
|
||||
@@ -193,7 +193,7 @@ export async function generateImprovement(opts: ImproverOptions): Promise<Improv
|
||||
});
|
||||
|
||||
// Invoke Claude
|
||||
console.log(`\nInvoking AI improvement proposal (model: ${model})...`);
|
||||
console.log(`\nInvoking AI improvement proposal (agent: ${agent}, model: ${model === 'default' ? 'user default' : model})...`);
|
||||
console.log('This may take a minute...\n');
|
||||
|
||||
let aiResponse: string;
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface AgentDef {
|
||||
name: AgentType;
|
||||
displayName: string;
|
||||
command: string;
|
||||
models: Array<{ value: string; label: string }>;
|
||||
defaultModel: string;
|
||||
}
|
||||
|
||||
export const AGENTS: Record<AgentType, AgentDef> = {
|
||||
@@ -26,23 +26,13 @@ export const AGENTS: Record<AgentType, AgentDef> = {
|
||||
name: 'claude-code',
|
||||
displayName: 'Claude Code',
|
||||
command: 'claude',
|
||||
models: [
|
||||
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6 (Recommended)' },
|
||||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (faster)' },
|
||||
],
|
||||
defaultModel: 'default',
|
||||
},
|
||||
'copilot-cli': {
|
||||
name: 'copilot-cli',
|
||||
displayName: 'GitHub Copilot CLI',
|
||||
command: 'copilot',
|
||||
models: [
|
||||
{ value: 'claude-sonnet-4', label: 'Claude Sonnet 4 (Default)' },
|
||||
{ value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
|
||||
{ value: 'claude-opus-4.5', label: 'Claude Opus 4.5' },
|
||||
{ value: 'gpt-5', label: 'GPT-5' },
|
||||
{ value: 'gpt-5-mini', label: 'GPT-5 Mini' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro' },
|
||||
],
|
||||
defaultModel: 'claude-sonnet-4',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,7 +59,8 @@ export async function invokeLLM(opts: InvokeLLMOptions): Promise<string> {
|
||||
'--print',
|
||||
'--dangerously-skip-permissions',
|
||||
];
|
||||
if (model) {
|
||||
// Only add --model if not using default
|
||||
if (model && model !== 'default') {
|
||||
args.push('--model', model);
|
||||
}
|
||||
|
||||
@@ -84,7 +75,8 @@ export async function invokeLLM(opts: InvokeLLMOptions): Promise<string> {
|
||||
} else {
|
||||
// Copilot CLI: pipe prompt via stdin string
|
||||
const args: string[] = [];
|
||||
if (model) {
|
||||
// Only add --model if not using default
|
||||
if (model && model !== 'default') {
|
||||
args.push('--model', model);
|
||||
}
|
||||
args.push('--allow-all-tools', '-s');
|
||||
|
||||
@@ -35,7 +35,6 @@ The following are the current contents of the plan2code workflow prompt files be
|
||||
| avg_verification_items_added (Step 2) | ≤ 1.5 | lower is better |
|
||||
| avg_task_completion_rate (Step 3) | ≥ 0.95 | higher is better |
|
||||
| avg_blocker_count (Step 3) | ≤ 1.5 | lower is better |
|
||||
| avg_completion_marker_success_rate (Step 3) | ≥ 0.95 | higher is better |
|
||||
| avg_verification_failures_found (Step 4) | ≤ 1.0 | lower is better |
|
||||
| archival_success_rate (Step 4) | ≥ 0.99 | higher is better |
|
||||
| avg_user_rating (Feedback) | ≥ 7.0 | higher is better (1-10 scale, null if no feedback) |
|
||||
|
||||
@@ -28,7 +28,7 @@ The following diagnosis was produced by the analysis step:
|
||||
|
||||
1. **Char limit:** Each target file MUST stay under 11,000 characters after your edit is applied. This is enforced by code — edits that violate it will be automatically rejected.
|
||||
2. **Maximum 5 edits per cycle.** Focus on the highest-impact changes only.
|
||||
3. **Each edit must cite a specific metric** in its `expected_metric_impact` field (e.g., "avg_completion_marker_success_rate", "avg_blocker_count").
|
||||
3. **Each edit must cite a specific metric** in its `expected_metric_impact` field (e.g., "avg_task_completion_rate", "avg_blocker_count").
|
||||
4. **`old_text` must be verbatim** from the file. Copy-paste exactly — include surrounding whitespace/newlines as they appear. Edits with mismatched old_text will be automatically rejected.
|
||||
5. **Do NOT modify Role sections** (lines starting with "You are" at the top of each file) or step headings (lines starting with `#`).
|
||||
6. **Each edit must be independently applicable** — no edit should depend on another edit being applied first.
|
||||
@@ -41,7 +41,6 @@ The following diagnosis was produced by the analysis step:
|
||||
|
||||
- Target the specific sections identified in "Recommended Improvement Targets" from the diagnosis.
|
||||
- For high `avg_clarification_rounds`: Add more upfront specification examples or decision criteria to Step 1.
|
||||
- For low `avg_completion_marker_success_rate`: Clarify or simplify the completion marker format in Step 3.
|
||||
- For high `avg_blocker_count`: Add blocker-recovery guidance or prerequisite check instructions.
|
||||
- For low `avg_confidence`: Strengthen the confidence calculation instructions with clearer rubrics.
|
||||
- For low `avg_parallel_groups`: Add explicit guidance for identifying parallel tasks in Step 2.
|
||||
|
||||
@@ -41,17 +41,10 @@ export interface Step2DocumentMetrics {
|
||||
|
||||
export interface Step3ImplementMetrics {
|
||||
present: boolean;
|
||||
used_loop_mode: boolean;
|
||||
loop_mode: 'task' | 'phase' | null;
|
||||
task_completion_rate: number | null;
|
||||
tasks_completed: number | null;
|
||||
tasks_total: number | null;
|
||||
blocker_count: number | null;
|
||||
blocker_categories: string[] | null;
|
||||
total_iterations: number | null;
|
||||
avg_iteration_duration_ms: number | null;
|
||||
exit_code_distribution: Record<string, number> | null;
|
||||
completion_marker_success_rate: number | null;
|
||||
}
|
||||
|
||||
export interface Step4FinalizeMetrics {
|
||||
@@ -132,13 +125,9 @@ export interface CohortMetrics {
|
||||
avg_requirement_coverage_percent: number | null;
|
||||
avg_verification_items_added: number | null;
|
||||
|
||||
// Step 3 averages (loop only)
|
||||
loop_run_count: number;
|
||||
// Step 3 averages
|
||||
avg_task_completion_rate: number | null;
|
||||
avg_blocker_count: number | null;
|
||||
avg_total_iterations: number | null;
|
||||
avg_iteration_duration_ms: number | null;
|
||||
avg_completion_marker_success_rate: number | null;
|
||||
|
||||
// Step 4 averages
|
||||
avg_completion_rate_at_audit: number | null;
|
||||
@@ -168,7 +157,6 @@ export const METRIC_TARGETS = {
|
||||
avg_verification_items_added: { target: 1.5, direction: 'lte' as const },
|
||||
avg_task_completion_rate: { target: 0.95, direction: 'gte' as const },
|
||||
avg_blocker_count: { target: 1.5, direction: 'lte' as const },
|
||||
avg_completion_marker_success_rate: { target: 0.95, direction: 'gte' as const },
|
||||
avg_verification_failures_found: { target: 1.0, direction: 'lte' as const },
|
||||
archival_success_rate: { target: 0.99, direction: 'gte' as const },
|
||||
avg_user_rating: { target: 7.0, direction: 'gte' as const },
|
||||
|
||||
Reference in New Issue
Block a user