plankit-cli 1.4.0 → 1.6.0

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,200 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Fast static analyzer for Vue SFC (.vue) and JS/TS files.
6
+ * Uses lightweight block extraction and heuristic parsing without external dependencies.
7
+ */
8
+
9
+ export function analyzeVueFile(filePath, content = null) {
10
+ const code = content !== null ? content : fs.readFileSync(filePath, 'utf8');
11
+ const lines = code.split(/\r?\n/);
12
+ const lineCount = lines.length;
13
+
14
+ const templateBlock = extractBlock(code, 'template');
15
+ const scriptBlock = extractBlock(code, 'script');
16
+ const styleBlocks = extractAllBlocks(code, 'style');
17
+
18
+ const isScriptSetup = /<script\s+[^>]*setup/i.test(code);
19
+ const hasScript = scriptBlock !== null || isScriptSetup;
20
+ const scriptContent = scriptBlock ? scriptBlock.content : '';
21
+ const scriptAttributes = scriptBlock ? scriptBlock.attributes : '';
22
+
23
+ // Language detection
24
+ const isTs = /lang=["'](?:ts|typescript)["']/i.test(code) ||
25
+ filePath.endsWith('.ts') ||
26
+ filePath.endsWith('.tsx');
27
+ const language = isTs ? 'ts' : 'js';
28
+
29
+ // Vue style detection (Options API, Composition API, or <script setup>)
30
+ let vueStyle = 'unknown';
31
+ if (isScriptSetup) {
32
+ vueStyle = 'script_setup';
33
+ } else if (hasScript) {
34
+ if (/\bsetup\s*\([^)]*\)\s*\{/i.test(scriptContent) || /\bdefineComponent\s*\(/i.test(scriptContent)) {
35
+ vueStyle = 'composition';
36
+ } else if (/\b(?:data\s*\(\)|methods\s*:|computed\s*:|watch\s*:)/i.test(scriptContent)) {
37
+ vueStyle = 'options';
38
+ } else {
39
+ vueStyle = 'transitional';
40
+ }
41
+ }
42
+
43
+ // State detection
44
+ const hasPinia = /\b(?:use\w+Store|defineStore)\b/i.test(code);
45
+ const hasVuex = /\b(?:this\.\$store|mapState|mapGetters|mapActions|mapMutations|createNamespacedHelpers)\b/i.test(code);
46
+ const hasComposable = /\buse(?!Store\b)\w+\s*\(/i.test(code);
47
+ const hasLocal = /\b(?:ref|reactive|shallowRef)\s*\(/i.test(code);
48
+
49
+ let state = 'local';
50
+ if (hasPinia && hasVuex) {
51
+ state = 'mixed';
52
+ } else if (hasPinia) {
53
+ state = 'pinia';
54
+ } else if (hasVuex) {
55
+ state = 'vuex';
56
+ } else if (hasComposable) {
57
+ state = 'composable';
58
+ } else if (hasLocal) {
59
+ state = 'local';
60
+ }
61
+
62
+ // Data access detection
63
+ let dataAccess = 'unknown';
64
+ if (/\baxios\.(?:get|post|put|delete|patch)\b/i.test(code)) {
65
+ dataAccess = 'axios_component';
66
+ } else if (/\buseQuery\b|\buseMutation\b/i.test(code)) {
67
+ dataAccess = 'query_library';
68
+ } else if (/\b(?:useFetch|\$fetch)\b/i.test(code)) {
69
+ dataAccess = 'fetch_composable';
70
+ } else if (/\b(?:api|service|client)\.\w+\b/i.test(code)) {
71
+ dataAccess = 'api_service';
72
+ }
73
+
74
+ // Styling analysis
75
+ const stylingTypes = new Set();
76
+ let hasScoped = false;
77
+ let hasGlobal = false;
78
+ for (const style of styleBlocks) {
79
+ const isScoped = /\bscoped\b/i.test(style.attributes);
80
+ const isModule = /\bmodule\b/i.test(style.attributes);
81
+ const isScss = /lang=["'](?:scss|sass)["']/i.test(style.attributes);
82
+
83
+ if (isModule) stylingTypes.add('modules');
84
+ if (isScoped) {
85
+ hasScoped = true;
86
+ stylingTypes.add(isScss ? 'scoped_scss' : 'scoped_css');
87
+ } else {
88
+ hasGlobal = true;
89
+ stylingTypes.add(isScss ? 'global_scss' : 'global_css');
90
+ }
91
+ }
92
+ if (templateBlock && /\bclass=["'][^"']*\b(?:flex|grid|p-|m-|text-|bg-|w-|h-)\b/i.test(templateBlock.content)) {
93
+ stylingTypes.add('utility');
94
+ }
95
+ const styling = stylingTypes.size > 0 ? Array.from(stylingTypes) : ['scoped_css'];
96
+
97
+ // Design token discovery (hex colors, font sizes, margins, padding)
98
+ const tokens = extractTokens(code);
99
+
100
+ // Anti-patterns and hotspots
101
+ const hotspots = [];
102
+ if (lineCount > 400) {
103
+ hotspots.push({ type: 'god_component', message: `Large SFC (${lineCount} lines)` });
104
+ }
105
+ if (/\b(?:this\.\$)/.test(code)) {
106
+ hotspots.push({ type: 'deprecated_global_property', message: 'Uses legacy this.$ properties' });
107
+ }
108
+ if (/::v-deep|>>>|\/deep\//.test(code)) {
109
+ hotspots.push({ type: 'deprecated_deep_selector', message: 'Uses deprecated deep CSS selector (::v-deep or /deep/)' });
110
+ }
111
+ if (/\bmixins\s*:\s*\[/i.test(code)) {
112
+ hotspots.push({ type: 'vue_mixins', message: 'Uses Vue mixins (replace with composables)' });
113
+ }
114
+ if (/\bfilters\s*:\s*\{/i.test(code)) {
115
+ hotspots.push({ type: 'vue_filters', message: 'Uses Vue 2 filters (not supported in Vue 3)' });
116
+ }
117
+ if (/\b\$on\b|\b\$emit\b.*eventBus|new\s+Vue\(\)/i.test(code)) {
118
+ hotspots.push({ type: 'event_bus', message: 'Uses global event bus' });
119
+ }
120
+
121
+ // Maturity classification
122
+ let maturity = 'preferred';
123
+ if (vueStyle === 'options' || hotspots.some((h) => h.type === 'vue_mixins' || h.type === 'vue_filters')) {
124
+ maturity = 'legacy';
125
+ } else if (vueStyle === 'composition' || language === 'js' || state === 'vuex' || hasGlobal) {
126
+ maturity = 'transitional';
127
+ } else if (vueStyle === 'script_setup' && language === 'ts') {
128
+ maturity = 'preferred';
129
+ }
130
+
131
+ return {
132
+ filePath,
133
+ fileName: path.basename(filePath),
134
+ lineCount,
135
+ language,
136
+ vueStyle,
137
+ state,
138
+ dataAccess,
139
+ styling,
140
+ hasScoped,
141
+ hasGlobal,
142
+ tokens,
143
+ hotspots,
144
+ maturity
145
+ };
146
+ }
147
+
148
+ function extractBlock(code, tag) {
149
+ const openRegex = new RegExp(`<${tag}(\\s+[^>]*)?>`, 'i');
150
+ const match = openRegex.exec(code);
151
+ if (!match) return null;
152
+
153
+ const startIndex = match.index + match[0].length;
154
+ const closeTag = `</${tag}>`;
155
+ const endIndex = code.indexOf(closeTag, startIndex);
156
+ if (endIndex === -1) {
157
+ return {
158
+ attributes: match[1] || '',
159
+ content: code.slice(startIndex)
160
+ };
161
+ }
162
+
163
+ return {
164
+ attributes: match[1] || '',
165
+ content: code.slice(startIndex, endIndex)
166
+ };
167
+ }
168
+
169
+ function extractAllBlocks(code, tag) {
170
+ const blocks = [];
171
+ const regex = new RegExp(`<${tag}(\\s+[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'gi');
172
+ let match;
173
+ while ((match = regex.exec(code)) !== null) {
174
+ blocks.push({
175
+ attributes: match[1] || '',
176
+ content: match[2]
177
+ });
178
+ }
179
+ return blocks;
180
+ }
181
+
182
+ function extractTokens(code) {
183
+ const hexColors = new Set();
184
+ const hexRegex = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
185
+ let match;
186
+ while ((match = hexRegex.exec(code)) !== null) {
187
+ hexColors.add(match[0].toLowerCase());
188
+ }
189
+
190
+ const spacingValues = new Set();
191
+ const spacingRegex = /(?:margin|padding|gap|top|bottom|left|right):\s*([0-9]+(?:px|rem|em))/gi;
192
+ while ((match = spacingRegex.exec(code)) !== null) {
193
+ spacingValues.add(match[1].toLowerCase());
194
+ }
195
+
196
+ return {
197
+ hexColors: Array.from(hexColors),
198
+ spacing: Array.from(spacingValues)
199
+ };
200
+ }
package/src/cli.js CHANGED
@@ -23,6 +23,9 @@ import {
23
23
  normalizeFramework,
24
24
  parseFrameworksInput
25
25
  } from './commandBundles.js';
26
+ import { runAnalysis, formatReport } from './analyzer/engine.js';
27
+ import { loadDashboardData, renderStaticDashboard, runInteractiveDashboard } from './dashboard/dashboard.js';
28
+ import { startServer } from './server/server.js';
26
29
 
27
30
  export async function runCli(args, context = {}) {
28
31
  const io = createIo(context);
@@ -50,6 +53,19 @@ export async function runCli(args, context = {}) {
50
53
  case 'status':
51
54
  showStatus(parsed, io);
52
55
  break;
56
+ case 'scan':
57
+ case 'health':
58
+ case 'audit':
59
+ await scanRepository(parsed, io);
60
+ break;
61
+ case 'ui':
62
+ case 'dashboard':
63
+ await showDashboard(parsed, io);
64
+ break;
65
+ case 'serve':
66
+ case 'web':
67
+ await startWebServer(parsed, io);
68
+ break;
53
69
  case 'archive':
54
70
  archiveFeature(parsed, io);
55
71
  break;
@@ -365,6 +381,99 @@ function archiveFeature(parsed, io) {
365
381
  io.stdout(`Feature "${featureName}" archived.`);
366
382
  }
367
383
 
384
+ async function scanRepository(parsed, io) {
385
+ const { config } = loadConfig(io.cwd);
386
+ const targetDir = parsed.positionals[0] ? path.resolve(io.cwd, parsed.positionals[0]) : io.cwd;
387
+ const report = await runAnalysis(targetDir, {
388
+ artifactsDir: config.artifactsDir
389
+ });
390
+
391
+ if (parsed.flags.json) {
392
+ io.stdout(JSON.stringify(report, null, 2));
393
+ } else {
394
+ io.stdout(formatReport(report, io.isTTY));
395
+ }
396
+
397
+ if (parsed.flags.threshold) {
398
+ const threshold = Number.parseInt(parsed.flags.threshold, 10);
399
+ if (Number.isInteger(threshold) && report.score.total < threshold) {
400
+ throw new Error(`Health score ${report.score.total} is below required threshold ${threshold}`);
401
+ }
402
+ }
403
+ }
404
+
405
+ async function showDashboard(parsed, io) {
406
+ const { config } = loadConfig(io.cwd);
407
+ const data = loadDashboardData(io.cwd, config);
408
+
409
+ if (!io.isTTY || parsed.flags['no-tui']) {
410
+ renderStaticDashboard(data, io);
411
+ return;
412
+ }
413
+
414
+ await runInteractiveDashboard(io.cwd, io, config, {
415
+ implement: async (featureName, phaseNum) => {
416
+ prepareImplementation({ positionals: [featureName, String(phaseNum)], flags: {} }, io);
417
+ },
418
+ clarify: async (featureName) => {
419
+ clarifyFeature({ positionals: [featureName], flags: {} }, io);
420
+ },
421
+ review: async (featureName) => {
422
+ reviewFeature({ positionals: [featureName], flags: { 'skip-test': true, 'skip-build': true } }, io);
423
+ }
424
+ });
425
+ }
426
+
427
+ async function startWebServer(parsed, io) {
428
+ const { config } = loadConfig(io.cwd);
429
+ let port = null;
430
+
431
+ const rawPort = parsed.flags.port || parsed.flags.p;
432
+ if (rawPort !== undefined) {
433
+ const parsedNum = Number.parseInt(rawPort, 10);
434
+ if (!Number.isInteger(parsedNum) || parsedNum < 0 || parsedNum > 65535) {
435
+ throw new Error(`Invalid port "${rawPort}". Please provide a valid port between 1 and 65535.`);
436
+ }
437
+ port = parsedNum;
438
+ } else if (io.isTTY && io.promptChoice) {
439
+ const answer = await io.promptChoice('Enter port for PlanKit Web Dashboard (default: 4200): ', ['4200']);
440
+ if (answer && answer.trim()) {
441
+ const parsedNum = Number.parseInt(answer.trim(), 10);
442
+ if (!Number.isInteger(parsedNum) || parsedNum < 0 || parsedNum > 65535) {
443
+ throw new Error(`Invalid port "${answer}". Please provide a valid port between 1 and 65535.`);
444
+ }
445
+ port = parsedNum;
446
+ } else {
447
+ port = 4200;
448
+ }
449
+ } else {
450
+ port = 4200;
451
+ }
452
+
453
+ io.stdout(`Starting PlanKit Web Dashboard on port ${port}...`);
454
+
455
+ const { server, port: actualPort } = await startServer({
456
+ cwd: io.cwd,
457
+ config,
458
+ port,
459
+ io
460
+ });
461
+
462
+ const url = `http://localhost:${actualPort}`;
463
+ io.stdout('');
464
+ io.stdout('======================================================');
465
+ io.stdout(` PlanKit Web Dashboard running at: ${url}`);
466
+ io.stdout('======================================================');
467
+ io.stdout('Press Ctrl+C to stop.');
468
+
469
+ // Keep process alive if interactive/standalone
470
+ if (!parsed.flags['no-keep-alive'] && parsed.flags['keep-alive'] !== false) {
471
+ await new Promise(() => {});
472
+ } else {
473
+ server.close();
474
+ }
475
+ }
476
+
368
477
  function moveFeatureToArchive(cwd, config, featureName, dryRun = false) {
369
478
  const sourceDir = requireFeatureDir(cwd, config, featureName);
370
479
  const targetDir = safeJoin(cwd, config.artifactsDir, 'archived', featureName);
@@ -734,6 +843,9 @@ Commands:
734
843
  implement <feature> <phase> Validate phase context and prepare phase output artifact
735
844
  review <feature> Run verification and archive a completed feature
736
845
  status [--json] Show active and archived features
846
+ scan [dir] Run static analysis and generate artifacts/.plankit-index.json
847
+ ui Launch interactive feature progress dashboard
848
+ serve [--port <n>] Launch visual local web dashboard companion (alias: web)
737
849
  archive <feature> Move a feature to archived without running verification
738
850
  version Show the installed PlankKit CLI version
739
851
  help Show this help message
@@ -746,6 +858,10 @@ Options:
746
858
  --framework <name> Select framework command suite during init
747
859
  (angular, vue, dotnet, none, all; default: none)
748
860
  --frameworks <list> Comma-separated list of framework suites during init
861
+ --port <number> Specify port for web dashboard (default: 4200)
862
+ --threshold <score> Fail scan if health score is below threshold
863
+ --json Output scan report or status as JSON
864
+ --no-tui Render static summary table instead of interactive dashboard
749
865
  --global-gemini-skills Also install Gemini skills under the user profile
750
866
  --phases 3 Generate N default phases during plan
751
867
  --phases "Design,Build,Test" Generate named phases during plan
@@ -0,0 +1,272 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { listDirectories, safeJoin } from '../config.js';
5
+ import { runAnalysis, formatReport } from '../analyzer/engine.js';
6
+
7
+ export function loadDashboardData(cwd, config) {
8
+ const artifactsDir = safeJoin(cwd, config.artifactsDir || 'artifacts');
9
+ const currentDir = path.join(artifactsDir, 'current');
10
+ const archivedDir = path.join(artifactsDir, 'archived');
11
+
12
+ const activeNames = listDirectories(currentDir);
13
+ const archivedNames = listDirectories(archivedDir);
14
+
15
+ const activeFeatures = activeNames.map((name) => {
16
+ const featureDir = path.join(currentDir, name);
17
+ const phasesDir = path.join(featureDir, 'phases');
18
+ const outputsDir = path.join(featureDir, 'outputs');
19
+
20
+ const phaseFiles = fs.existsSync(phasesDir)
21
+ ? fs.readdirSync(phasesDir).filter((f) => f.startsWith('phase-') && f.endsWith('.md')).sort()
22
+ : [];
23
+ const outputFiles = fs.existsSync(outputsDir)
24
+ ? fs.readdirSync(outputsDir).filter((f) => f.startsWith('phase-') && f.endsWith('.md')).sort()
25
+ : [];
26
+
27
+ let state = null;
28
+ const stateFile = path.join(featureDir, 'plankit.json');
29
+ if (fs.existsSync(stateFile)) {
30
+ try {
31
+ state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
32
+ } catch {
33
+ state = null;
34
+ }
35
+ }
36
+
37
+ const phases = phaseFiles.map((pf, idx) => {
38
+ const num = idx + 1;
39
+ const expectedOutput = `phase-${num}-output.md`;
40
+ const hasOutput = outputFiles.includes(expectedOutput);
41
+ return {
42
+ number: num,
43
+ specFile: pf,
44
+ hasOutput,
45
+ status: hasOutput ? 'DONE' : (num === outputFiles.length + 1 ? 'IN_PROGRESS' : 'PENDING')
46
+ };
47
+ });
48
+
49
+ const completedCount = phases.filter((p) => p.hasOutput).length;
50
+ const totalCount = phases.length || 1;
51
+ const progressPercent = Math.round((completedCount / totalCount) * 100);
52
+
53
+ return {
54
+ name,
55
+ phases,
56
+ phaseCount: phases.length,
57
+ completedCount,
58
+ progressPercent,
59
+ state
60
+ };
61
+ });
62
+
63
+ return {
64
+ activeFeatures,
65
+ archivedCount: archivedNames.length,
66
+ archivedNames
67
+ };
68
+ }
69
+
70
+ export function renderStaticDashboard(data, io) {
71
+ io.stdout('================================================================');
72
+ io.stdout(' PlanKit Feature Management Matrix ');
73
+ io.stdout('================================================================');
74
+ io.stdout(`Active Features: ${data.activeFeatures.length} | Archived Features: ${data.archivedCount}`);
75
+ io.stdout('');
76
+
77
+ if (data.activeFeatures.length === 0) {
78
+ io.stdout(' No active features found in artifacts/current/.');
79
+ io.stdout(' Run "plankit plan <feature-name>" to start a feature.');
80
+ io.stdout('================================================================');
81
+ return;
82
+ }
83
+
84
+ io.stdout(padRight('Feature Name', 25) + padRight('Phases', 12) + padRight('Progress', 20) + 'Status');
85
+ io.stdout('-'.repeat(64));
86
+
87
+ data.activeFeatures.forEach((feat) => {
88
+ const progressText = `${progressBar(feat.progressPercent, 10)} ${feat.progressPercent}%`;
89
+ const statusText = feat.completedCount === feat.phaseCount ? 'READY_FOR_REVIEW' : `PHASE_${feat.completedCount + 1}`;
90
+ io.stdout(padRight(feat.name, 25) + padRight(`${feat.completedCount}/${feat.phaseCount}`, 12) + padRight(progressText, 20) + statusText);
91
+
92
+ feat.phases.forEach((p) => {
93
+ const check = p.hasOutput ? '[x]' : '[ ]';
94
+ io.stdout(` ${check} Phase ${p.number}: ${p.specFile} (${p.status})`);
95
+ });
96
+ io.stdout('');
97
+ });
98
+
99
+ io.stdout('================================================================');
100
+ }
101
+
102
+ export async function runInteractiveDashboard(cwd, io, config, handlers = {}) {
103
+ const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
104
+ const data = loadDashboardData(cwd, config);
105
+
106
+ if (!isTTY) {
107
+ renderStaticDashboard(data, io);
108
+ return;
109
+ }
110
+
111
+ let selectedIndex = 0;
112
+ let statusMessage = 'Use [↑/↓] or [j/k] to navigate. Press [q] to exit.';
113
+
114
+ function refresh() {
115
+ const freshData = loadDashboardData(cwd, config);
116
+ console.clear();
117
+ console.log('\x1b[1m================================================================\x1b[0m');
118
+ console.log('\x1b[1m\x1b[36m PlanKit Interactive Feature Dashboard \x1b[0m');
119
+ console.log('\x1b[1m================================================================\x1b[0m');
120
+ console.log(`Active Features: \x1b[32m${freshData.activeFeatures.length}\x1b[0m | Archived Features: \x1b[33m${freshData.archivedCount}\x1b[0m`);
121
+ console.log('');
122
+
123
+ if (freshData.activeFeatures.length === 0) {
124
+ console.log(' No active features in artifacts/current/.');
125
+ console.log(' Press [p] to plan a new feature, or [q] to exit.');
126
+ console.log('\x1b[1m================================================================\x1b[0m');
127
+ console.log(`Status: ${statusMessage}`);
128
+ return;
129
+ }
130
+
131
+ if (selectedIndex >= freshData.activeFeatures.length) {
132
+ selectedIndex = freshData.activeFeatures.length - 1;
133
+ }
134
+
135
+ freshData.activeFeatures.forEach((feat, index) => {
136
+ const isSelected = index === selectedIndex;
137
+ const pointer = isSelected ? '\x1b[36m▶ \x1b[0m' : ' ';
138
+ const nameFmt = isSelected ? `\x1b[1m\x1b[36m${padRight(feat.name, 22)}\x1b[0m` : padRight(feat.name, 22);
139
+ const progress = `${progressBar(feat.progressPercent, 10)} ${feat.progressPercent}%`;
140
+ console.log(`${pointer}${nameFmt} ${padRight(progress, 18)} (${feat.completedCount}/${feat.phaseCount} phases)`);
141
+ });
142
+
143
+ console.log('');
144
+ const selectedFeature = freshData.activeFeatures[selectedIndex];
145
+ if (selectedFeature) {
146
+ console.log(`\x1b[1mPhase Breakdown for "${selectedFeature.name}":\x1b[0m`);
147
+ selectedFeature.phases.forEach((p) => {
148
+ const badge = p.hasOutput ? '\x1b[32m[COMPLETED]\x1b[0m' : '\x1b[33m[PENDING]\x1b[0m';
149
+ console.log(` Phase ${p.number}: ${padRight(p.specFile, 32)} ${badge}`);
150
+ });
151
+ }
152
+
153
+ console.log('');
154
+ console.log('\x1b[1m----------------------------------------------------------------\x1b[0m');
155
+ console.log('\x1b[2mActions: [i] Implement Next Phase | [c] Clarify | [r] Review | [s] Scan | [q] Quit\x1b[0m');
156
+ console.log(`\x1b[33m${statusMessage}\x1b[0m`);
157
+ }
158
+
159
+ return new Promise((resolve) => {
160
+ readline.emitKeypressEvents(process.stdin);
161
+ if (process.stdin.setRawMode) {
162
+ process.stdin.setRawMode(true);
163
+ }
164
+ process.stdin.resume();
165
+
166
+ refresh();
167
+
168
+ const onKeypress = async (str, key) => {
169
+ if ((key && key.ctrl && key.name === 'c') || (key && key.name === 'q')) {
170
+ cleanup();
171
+ console.clear();
172
+ io.stdout('Exited PlanKit Dashboard.');
173
+ resolve();
174
+ return;
175
+ }
176
+
177
+ const freshData = loadDashboardData(cwd, config);
178
+ const currentFeature = freshData.activeFeatures[selectedIndex];
179
+
180
+ if (key && (key.name === 'up' || key.name === 'k')) {
181
+ if (selectedIndex > 0) selectedIndex -= 1;
182
+ statusMessage = `Selected ${freshData.activeFeatures[selectedIndex]?.name || ''}`;
183
+ refresh();
184
+ } else if (key && (key.name === 'down' || key.name === 'j')) {
185
+ if (selectedIndex < freshData.activeFeatures.length - 1) selectedIndex += 1;
186
+ statusMessage = `Selected ${freshData.activeFeatures[selectedIndex]?.name || ''}`;
187
+ refresh();
188
+ } else if (str === 's') {
189
+ cleanup();
190
+ console.clear();
191
+ io.stdout('Running PlanKit Static Analysis...\n');
192
+ const report = await runAnalysis(cwd, { artifactsDir: config.artifactsDir });
193
+ io.stdout(formatReport(report, true));
194
+ io.stdout('\nPress any key to return to dashboard...');
195
+ process.stdin.once('data', () => {
196
+ readline.emitKeypressEvents(process.stdin);
197
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
198
+ process.stdin.resume();
199
+ process.stdin.on('keypress', onKeypress);
200
+ statusMessage = 'Completed static analysis scan.';
201
+ refresh();
202
+ });
203
+ } else if (str === 'i' && currentFeature && handlers.implement) {
204
+ const nextPhase = currentFeature.completedCount + 1;
205
+ if (nextPhase > currentFeature.phaseCount) {
206
+ statusMessage = `Feature "${currentFeature.name}" has completed all phases! Press [r] to review.`;
207
+ refresh();
208
+ } else {
209
+ cleanup();
210
+ console.clear();
211
+ io.stdout(`Preparing Phase ${nextPhase} for "${currentFeature.name}"...\n`);
212
+ try {
213
+ await handlers.implement(currentFeature.name, nextPhase);
214
+ statusMessage = `Phase ${nextPhase} prepared successfully for "${currentFeature.name}".`;
215
+ } catch (err) {
216
+ statusMessage = `Error: ${err.message}`;
217
+ }
218
+ resume();
219
+ }
220
+ } else if (str === 'c' && currentFeature && handlers.clarify) {
221
+ cleanup();
222
+ console.clear();
223
+ try {
224
+ await handlers.clarify(currentFeature.name);
225
+ statusMessage = `Clarifications prepared for "${currentFeature.name}".`;
226
+ } catch (err) {
227
+ statusMessage = `Error: ${err.message}`;
228
+ }
229
+ resume();
230
+ } else if (str === 'r' && currentFeature && handlers.review) {
231
+ cleanup();
232
+ console.clear();
233
+ try {
234
+ await handlers.review(currentFeature.name);
235
+ statusMessage = `Feature "${currentFeature.name}" reviewed and archived.`;
236
+ } catch (err) {
237
+ statusMessage = `Review error: ${err.message}`;
238
+ }
239
+ resume();
240
+ }
241
+ };
242
+
243
+ function cleanup() {
244
+ process.stdin.removeListener('keypress', onKeypress);
245
+ if (process.stdin.setRawMode) {
246
+ process.stdin.setRawMode(false);
247
+ }
248
+ }
249
+
250
+ function resume() {
251
+ readline.emitKeypressEvents(process.stdin);
252
+ if (process.stdin.setRawMode) {
253
+ process.stdin.setRawMode(true);
254
+ }
255
+ process.stdin.resume();
256
+ process.stdin.on('keypress', onKeypress);
257
+ refresh();
258
+ }
259
+
260
+ process.stdin.on('keypress', onKeypress);
261
+ });
262
+ }
263
+
264
+ function progressBar(percent, length = 10) {
265
+ const filled = Math.round((percent / 100) * length);
266
+ const empty = length - filled;
267
+ return `[${'='.repeat(filled)}${'-'.repeat(empty)}]`;
268
+ }
269
+
270
+ function padRight(str, len) {
271
+ return String(str).padEnd(len, ' ');
272
+ }