iikit-dashboard 1.2.0 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iikit-dashboard",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Real-time dashboard for Intent Integrity Kit (IIKit) — visualizes every phase of specification-driven AI development",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  ],
27
27
  "repository": {
28
28
  "type": "git",
29
- "url": "https://github.com/intent-integrity-chain/iikit-dashboard"
29
+ "url": "git+https://github.com/intent-integrity-chain/iikit-dashboard.git"
30
30
  },
31
31
  "author": "Baruch Sadogursky",
32
32
  "license": "MIT",
package/src/analyze.js ADDED
@@ -0,0 +1,244 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ parseAnalysisFindings,
7
+ parseAnalysisCoverage,
8
+ parseAnalysisMetrics,
9
+ parseConstitutionAlignment,
10
+ parsePhaseSeparation,
11
+ parseRequirements,
12
+ parseSuccessCriteria
13
+ } = require('./parser');
14
+
15
+ const SEVERITY_PENALTIES = { CRITICAL: 25, HIGH: 15, MEDIUM: 5, LOW: 2 };
16
+
17
+ /**
18
+ * Compute phase separation score from violation entries.
19
+ * Score = max(0, 100 - sum(penalties)).
20
+ *
21
+ * @param {Array<{severity: string}>} violations
22
+ * @returns {number}
23
+ */
24
+ function computePhaseSeparationScore(violations) {
25
+ if (!violations || violations.length === 0) return 100;
26
+
27
+ const penalty = violations.reduce((sum, v) => {
28
+ return sum + (SEVERITY_PENALTIES[v.severity] || 0);
29
+ }, 0);
30
+
31
+ return Math.max(0, 100 - penalty);
32
+ }
33
+
34
+ /**
35
+ * Compute constitution compliance percentage from alignment entries.
36
+ * (ALIGNED count / total) * 100, rounded.
37
+ *
38
+ * @param {Array<{status: string}>} entries
39
+ * @returns {number}
40
+ */
41
+ function computeConstitutionCompliance(entries) {
42
+ if (!entries || entries.length === 0) return 100;
43
+
44
+ const aligned = entries.filter(e => e.status === 'ALIGNED').length;
45
+ return Math.round((aligned / entries.length) * 100);
46
+ }
47
+
48
+ /**
49
+ * Compute health score from four factors (25% each).
50
+ *
51
+ * @param {{requirementsCoverage: number, constitutionCompliance: number, phaseSeparation: number, testCoverage: number}} factors
52
+ * @returns {{score: number, zone: string, factors: Object}}
53
+ */
54
+ function computeHealthScore(factors) {
55
+ const { requirementsCoverage, constitutionCompliance, phaseSeparation, testCoverage } = factors;
56
+
57
+ const score = Math.round(
58
+ (requirementsCoverage + constitutionCompliance + phaseSeparation + testCoverage) / 4
59
+ );
60
+
61
+ let zone;
62
+ if (score <= 40) zone = 'red';
63
+ else if (score <= 70) zone = 'yellow';
64
+ else zone = 'green';
65
+
66
+ return {
67
+ score,
68
+ zone,
69
+ factors: {
70
+ requirementsCoverage: { value: requirementsCoverage, label: 'Requirements Coverage' },
71
+ constitutionCompliance: { value: constitutionCompliance, label: 'Constitution Compliance' },
72
+ phaseSeparation: { value: phaseSeparation, label: 'Phase Separation' },
73
+ testCoverage: { value: testCoverage, label: 'Test Coverage' }
74
+ }
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Map a coverage entry to a cell status object.
80
+ *
81
+ * @param {boolean} hasArtifact
82
+ * @param {string[]} ids
83
+ * @param {string|null} statusStr - e.g., "Partial", "Full"
84
+ * @returns {{status: string, refs: string[]}}
85
+ */
86
+ function mapCellStatus(hasArtifact, ids, statusStr) {
87
+ if (statusStr && /partial/i.test(statusStr)) {
88
+ return { status: 'partial', refs: ids || [] };
89
+ }
90
+ if (hasArtifact && ids && ids.length > 0) {
91
+ return { status: 'covered', refs: ids };
92
+ }
93
+ if (!hasArtifact || !ids || ids.length === 0) {
94
+ return { status: 'missing', refs: [] };
95
+ }
96
+ return { status: 'covered', refs: ids };
97
+ }
98
+
99
+ /**
100
+ * Build heatmap rows from requirements and coverage data.
101
+ *
102
+ * @param {Array<{id: string, text: string}>} requirements
103
+ * @param {Array} coverageEntries
104
+ * @returns {Array<{id: string, text: string, cells: Object}>}
105
+ */
106
+ function buildHeatmapRows(requirements, coverageEntries) {
107
+ if (!requirements || requirements.length === 0) return [];
108
+
109
+ const coverageMap = {};
110
+ for (const entry of (coverageEntries || [])) {
111
+ coverageMap[entry.id] = entry;
112
+ }
113
+
114
+ return requirements.map(req => {
115
+ const coverage = coverageMap[req.id];
116
+ if (!coverage) {
117
+ return {
118
+ id: req.id,
119
+ text: req.text,
120
+ cells: {
121
+ tasks: { status: 'missing', refs: [] },
122
+ tests: { status: 'missing', refs: [] },
123
+ plan: { status: 'na', refs: [] }
124
+ }
125
+ };
126
+ }
127
+
128
+ return {
129
+ id: req.id,
130
+ text: req.text,
131
+ cells: {
132
+ tasks: mapCellStatus(coverage.hasTask, coverage.taskIds, coverage.status === 'Partial' && !coverage.hasTask ? 'Partial' : null),
133
+ tests: mapCellStatus(coverage.hasTest, coverage.testIds, null),
134
+ plan: coverage.hasPlan !== undefined
135
+ ? mapCellStatus(coverage.hasPlan, coverage.planRefs, null)
136
+ : { status: 'na', refs: [] }
137
+ }
138
+ };
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Compute the full analyze view state for a feature.
144
+ *
145
+ * @param {string} projectPath
146
+ * @param {string} featureId
147
+ * @returns {Object}
148
+ */
149
+ function computeAnalyzeState(projectPath, featureId) {
150
+ const featureDir = path.join(projectPath, 'specs', featureId);
151
+ const analysisPath = path.join(featureDir, 'analysis.md');
152
+ const specPath = path.join(featureDir, 'spec.md');
153
+
154
+ if (!fs.existsSync(analysisPath)) {
155
+ return {
156
+ healthScore: null,
157
+ heatmap: { columns: [], rows: [] },
158
+ issues: [],
159
+ metrics: null,
160
+ constitutionAlignment: [],
161
+ exists: false
162
+ };
163
+ }
164
+
165
+ const analysisContent = fs.readFileSync(analysisPath, 'utf-8');
166
+ const specContent = fs.existsSync(specPath) ? fs.readFileSync(specPath, 'utf-8') : '';
167
+
168
+ // Parse all sections
169
+ const findings = parseAnalysisFindings(analysisContent);
170
+ const coverage = parseAnalysisCoverage(analysisContent);
171
+ const metrics = parseAnalysisMetrics(analysisContent);
172
+ const constitutionAlignment = parseConstitutionAlignment(analysisContent);
173
+ const phaseSeparationViolations = parsePhaseSeparation(analysisContent);
174
+
175
+ // Build heatmap from spec requirements + coverage data
176
+ const requirements = [
177
+ ...parseRequirements(specContent),
178
+ ...parseSuccessCriteria(specContent)
179
+ ];
180
+ const heatmapRows = buildHeatmapRows(requirements, coverage);
181
+
182
+ // Compute health score factors
183
+ const reqCovPct = metrics.requirementCoveragePct || 0;
184
+ const testCovPct = metrics.testCoveragePct || 100;
185
+ const constitutionCompliancePct = computeConstitutionCompliance(constitutionAlignment);
186
+ const phaseSepScore = computePhaseSeparationScore(
187
+ phaseSeparationViolations.filter(v => v.severity)
188
+ );
189
+
190
+ const healthScore = computeHealthScore({
191
+ requirementsCoverage: reqCovPct,
192
+ constitutionCompliance: constitutionCompliancePct,
193
+ phaseSeparation: phaseSepScore,
194
+ testCoverage: testCovPct
195
+ });
196
+
197
+ // Map findings to issues (API terminology)
198
+ const issues = findings.map(f => ({
199
+ id: f.id,
200
+ category: f.category,
201
+ severity: f.severity.toLowerCase(),
202
+ location: f.location,
203
+ summary: f.summary,
204
+ recommendation: f.recommendation,
205
+ resolved: f.resolved
206
+ }));
207
+
208
+ return {
209
+ healthScore: {
210
+ ...healthScore,
211
+ trend: null
212
+ },
213
+ heatmap: {
214
+ columns: ['tasks', 'tests', 'plan'],
215
+ rows: heatmapRows
216
+ },
217
+ issues,
218
+ metrics: {
219
+ totalRequirements: metrics.totalRequirements,
220
+ totalTasks: metrics.totalTasks,
221
+ totalTestSpecs: metrics.totalTestSpecs,
222
+ requirementCoverage: metrics.requirementCoverage,
223
+ criticalIssues: metrics.criticalIssues,
224
+ highIssues: metrics.highIssues,
225
+ mediumIssues: metrics.mediumIssues,
226
+ lowIssues: metrics.lowIssues
227
+ },
228
+ constitutionAlignment: constitutionAlignment.map(a => ({
229
+ principle: a.principle,
230
+ status: a.status,
231
+ evidence: a.evidence
232
+ })),
233
+ exists: true
234
+ };
235
+ }
236
+
237
+ module.exports = {
238
+ computeHealthScore,
239
+ computeAnalyzeState,
240
+ buildHeatmapRows,
241
+ mapCellStatus,
242
+ computePhaseSeparationScore,
243
+ computeConstitutionCompliance
244
+ };
package/src/parser.js CHANGED
@@ -924,14 +924,259 @@ function parseTaskTestRefs(tasks) {
924
924
 
925
925
  const refs = {};
926
926
  for (const task of tasks) {
927
- const match = task.description ? task.description.match(/must pass ((?:TS-\d+(?:,\s*)?)+)/) : null;
928
- if (match) {
929
- refs[task.id] = match[1].split(/,\s*/).map(s => s.trim()).filter(Boolean);
927
+ const matches = task.description ? task.description.match(/TS-\d+/g) : null;
928
+ refs[task.id] = matches ? [...new Set(matches)] : [];
929
+ }
930
+ return refs;
931
+ }
932
+
933
+ /**
934
+ * Extract a markdown section by heading (## Title), returning content until next ## heading.
935
+ */
936
+ function extractSection(content, heading) {
937
+ const regex = new RegExp(`^## ${heading}\\s*$`, 'm');
938
+ const match = content.match(regex);
939
+ if (!match) return null;
940
+
941
+ const start = match.index + match[0].length;
942
+ const nextSection = content.indexOf('\n## ', start);
943
+ return content.substring(start, nextSection >= 0 ? nextSection : content.length).trim();
944
+ }
945
+
946
+ /**
947
+ * Parse rows from a pipe-delimited markdown table.
948
+ * Returns array of arrays (one per row, cells trimmed). Skips header separator row (|---|).
949
+ */
950
+ function parseMarkdownTable(text) {
951
+ const lines = text.split('\n').filter(l => l.trim().startsWith('|'));
952
+ if (lines.length < 2) return [];
953
+
954
+ // Skip header row (index 0) and separator row (index 1)
955
+ return lines.slice(2).map(line =>
956
+ line.split('|').slice(1, -1).map(cell => cell.trim())
957
+ ).filter(cells => cells.length > 0 && cells.some(c => c !== ''));
958
+ }
959
+
960
+ /**
961
+ * Parse analysis.md Findings section.
962
+ * Extracts issues with id, category, severity, resolved, location, summary, recommendation.
963
+ *
964
+ * @param {string} content - Raw analysis.md content
965
+ * @returns {Array<{id: string, category: string, severity: string, resolved: boolean, location: string, summary: string, recommendation: string}>}
966
+ */
967
+ function parseAnalysisFindings(content) {
968
+ if (!content || typeof content !== 'string') return [];
969
+
970
+ const section = extractSection(content, 'Findings');
971
+ if (!section) return [];
972
+
973
+ const rows = parseMarkdownTable(section);
974
+ if (rows.length === 0) return [];
975
+
976
+ return rows.map(cells => {
977
+ if (cells.length < 6) return null;
978
+
979
+ const rawSeverity = cells[2];
980
+ // Detect ~~SEVERITY~~ RESOLVED pattern
981
+ const resolvedMatch = rawSeverity.match(/~~(\w+)~~\s*RESOLVED/);
982
+ const resolved = !!resolvedMatch;
983
+ const severity = resolved ? resolvedMatch[1] : rawSeverity;
984
+
985
+ return {
986
+ id: cells[0],
987
+ category: cells[1],
988
+ severity,
989
+ resolved,
990
+ location: cells[3],
991
+ summary: cells[4],
992
+ recommendation: cells[5]
993
+ };
994
+ }).filter(Boolean);
995
+ }
996
+
997
+ /**
998
+ * Parse analysis.md Coverage Summary section.
999
+ * Handles both simple (Requirement, Has Task?, Notes) and
1000
+ * detailed (Requirement, Has Task?, Task IDs, Has Test?, Test IDs, Status) formats.
1001
+ *
1002
+ * @param {string} content - Raw analysis.md content
1003
+ * @returns {Array<{id: string, hasTask: boolean, taskIds: string[], hasTest: boolean, testIds: string[], status: string|null, notes: string}>}
1004
+ */
1005
+ function parseAnalysisCoverage(content) {
1006
+ if (!content || typeof content !== 'string') return [];
1007
+
1008
+ const section = extractSection(content, 'Coverage Summary');
1009
+ if (!section) return [];
1010
+
1011
+ const rows = parseMarkdownTable(section);
1012
+ if (rows.length === 0) return [];
1013
+
1014
+ // Detect format by number of columns in first data row
1015
+ const hasPlanCols = rows[0].length >= 8;
1016
+ const isDetailed = rows[0].length >= 6;
1017
+
1018
+ return rows.map(cells => {
1019
+ const id = cells[0];
1020
+ const hasTask = /^yes$/i.test(cells[1]);
1021
+
1022
+ if (isDetailed) {
1023
+ // Detailed: Requirement | Has Task? | Task IDs | Has Test? | Test IDs | [Has Plan? | Plan Refs |] Status
1024
+ const taskIds = parseIdList(cells[2]);
1025
+ const hasTest = /^yes$/i.test(cells[3]);
1026
+ const testIds = parseIdList(cells[4]);
1027
+
1028
+ if (hasPlanCols) {
1029
+ const hasPlan = /^yes$/i.test(cells[5]);
1030
+ const planRefs = parseIdList(cells[6]);
1031
+ const status = cells[7] && cells[7] !== '—' && cells[7] !== '-' ? cells[7] : null;
1032
+ return { id, hasTask, taskIds, hasTest, testIds, hasPlan, planRefs, status, notes: '' };
1033
+ }
1034
+
1035
+ const status = cells[5] && cells[5] !== '—' && cells[5] !== '-' ? cells[5] : null;
1036
+ return { id, hasTask, taskIds, hasTest, testIds, status, notes: '' };
930
1037
  } else {
931
- refs[task.id] = [];
1038
+ // Simple: Requirement | Has Task? | Notes
1039
+ const notes = cells[2] || '';
1040
+ return { id, hasTask, taskIds: [], hasTest: false, testIds: [], status: null, notes };
1041
+ }
1042
+ });
1043
+ }
1044
+
1045
+ /**
1046
+ * Parse a comma-separated list of IDs (e.g., "T001, T002" or "TS-001, TS-037").
1047
+ * Filters out dashes and empty entries.
1048
+ */
1049
+ function parseIdList(cell) {
1050
+ if (!cell || cell === '—' || cell === '-' || cell === '–') return [];
1051
+ return cell.split(',').map(s => s.trim()).filter(s => s && s !== '—' && s !== '-' && s !== '–');
1052
+ }
1053
+
1054
+ /**
1055
+ * Parse analysis.md Metrics section.
1056
+ * Handles both table format (| Metric | Value |) and bullet format (- Metric: Value).
1057
+ *
1058
+ * @param {string} content - Raw analysis.md content
1059
+ * @returns {{totalRequirements: number, totalTasks: number, totalTestSpecs: number, requirementCoverage: string, requirementCoveragePct: number, testCoverage: string|null, testCoveragePct: number, criticalIssues: number, highIssues: number, mediumIssues: number, lowIssues: number}}
1060
+ */
1061
+ function parseAnalysisMetrics(content) {
1062
+ const defaults = {
1063
+ totalRequirements: 0, totalTasks: 0, totalTestSpecs: 0,
1064
+ requirementCoverage: '', requirementCoveragePct: 0,
1065
+ testCoverage: null, testCoveragePct: 100,
1066
+ criticalIssues: 0, highIssues: 0, mediumIssues: 0, lowIssues: 0
1067
+ };
1068
+ if (!content || typeof content !== 'string') return defaults;
1069
+
1070
+ const section = extractSection(content, 'Metrics');
1071
+ if (!section) return defaults;
1072
+
1073
+ // Build key-value map from either table or bullet format
1074
+ const kvMap = {};
1075
+
1076
+ // Try table format first
1077
+ const tableRows = parseMarkdownTable(section);
1078
+ if (tableRows.length > 0) {
1079
+ for (const cells of tableRows) {
1080
+ if (cells.length >= 2) kvMap[cells[0].toLowerCase()] = cells[1];
1081
+ }
1082
+ } else {
1083
+ // Try bullet format: - Key: Value
1084
+ const bulletRegex = /^-\s+(.+?):\s+(.+)$/gm;
1085
+ let match;
1086
+ while ((match = bulletRegex.exec(section)) !== null) {
1087
+ kvMap[match[1].trim().toLowerCase()] = match[2].trim();
932
1088
  }
933
1089
  }
934
- return refs;
1090
+
1091
+ function findValue(keys) {
1092
+ for (const key of keys) {
1093
+ for (const [k, v] of Object.entries(kvMap)) {
1094
+ if (k.includes(key)) return v;
1095
+ }
1096
+ }
1097
+ return null;
1098
+ }
1099
+
1100
+ function extractPct(raw) {
1101
+ if (!raw) return null;
1102
+ const pctMatch = raw.match(/(\d+)%/);
1103
+ if (pctMatch) return parseInt(pctMatch[1], 10);
1104
+ const fracMatch = raw.match(/\((\d+)%\)/);
1105
+ if (fracMatch) return parseInt(fracMatch[1], 10);
1106
+ return null;
1107
+ }
1108
+
1109
+ const reqCovRaw = findValue(['requirement coverage']);
1110
+ const testCovRaw = findValue(['test coverage']);
1111
+
1112
+ return {
1113
+ totalRequirements: parseInt(findValue(['total requirements']) || '0', 10),
1114
+ totalTasks: parseInt(findValue(['total tasks']) || '0', 10),
1115
+ totalTestSpecs: parseInt(findValue(['total test spec']) || '0', 10),
1116
+ requirementCoverage: reqCovRaw || '',
1117
+ requirementCoveragePct: extractPct(reqCovRaw) || 0,
1118
+ testCoverage: testCovRaw || null,
1119
+ testCoveragePct: testCovRaw ? (extractPct(testCovRaw) || 0) : 100,
1120
+ criticalIssues: parseInt(findValue(['critical']) || '0', 10),
1121
+ highIssues: parseInt(findValue(['high']) || '0', 10),
1122
+ mediumIssues: parseInt(findValue(['medium']) || '0', 10),
1123
+ lowIssues: parseInt(findValue(['low']) || '0', 10)
1124
+ };
1125
+ }
1126
+
1127
+ /**
1128
+ * Parse analysis.md Constitution Alignment section.
1129
+ *
1130
+ * @param {string} content - Raw analysis.md content
1131
+ * @returns {Array<{principle: string, status: string, evidence: string}>}
1132
+ */
1133
+ function parseConstitutionAlignment(content) {
1134
+ if (!content || typeof content !== 'string') return [];
1135
+
1136
+ const section = extractSection(content, 'Constitution Alignment');
1137
+ if (!section) return [];
1138
+
1139
+ // Check for "None detected" text
1140
+ if (/none detected/i.test(section) && !section.includes('|')) return [];
1141
+
1142
+ const rows = parseMarkdownTable(section);
1143
+ return rows.map(cells => {
1144
+ if (cells.length < 3) return null;
1145
+ return {
1146
+ principle: cells[0],
1147
+ status: cells[1],
1148
+ evidence: cells[2]
1149
+ };
1150
+ }).filter(Boolean);
1151
+ }
1152
+
1153
+ /**
1154
+ * Parse analysis.md Phase Separation Violations section.
1155
+ *
1156
+ * @param {string} content - Raw analysis.md content
1157
+ * @returns {Array<{artifact: string, status: string, severity: string|null}>}
1158
+ */
1159
+ function parsePhaseSeparation(content) {
1160
+ if (!content || typeof content !== 'string') return [];
1161
+
1162
+ const section = extractSection(content, 'Phase Separation Violations');
1163
+ if (!section) return [];
1164
+
1165
+ // Check for "None detected" before the table
1166
+ const noneIdx = section.search(/none detected/i);
1167
+ const tableIdx = section.indexOf('|');
1168
+ if (noneIdx >= 0 && (tableIdx < 0 || noneIdx < tableIdx)) return [];
1169
+
1170
+ const rows = parseMarkdownTable(section);
1171
+ return rows.map(cells => {
1172
+ if (cells.length < 2) return null;
1173
+ const severity = cells.length >= 3 && cells[2] && cells[2] !== '—' && cells[2] !== '-' && cells[2] !== '–' ? cells[2] : null;
1174
+ return {
1175
+ artifact: cells[0],
1176
+ status: cells[1],
1177
+ severity
1178
+ };
1179
+ }).filter(Boolean);
935
1180
  }
936
1181
 
937
- module.exports = { parseSpecStories, parseTasks, parseChecklists, parseChecklistsDetailed, parseConstitutionTDD, hasClarifications, parseConstitutionPrinciples, parseRequirements, parseSuccessCriteria, parseClarifications, parseStoryRequirementRefs, parseTechContext, parseFileStructure, parseAsciiDiagram, parseTesslJson, parseResearchDecisions, parseTestSpecs, parseTaskTestRefs };
1182
+ module.exports = { parseSpecStories, parseTasks, parseChecklists, parseChecklistsDetailed, parseConstitutionTDD, hasClarifications, parseConstitutionPrinciples, parseRequirements, parseSuccessCriteria, parseClarifications, parseStoryRequirementRefs, parseTechContext, parseFileStructure, parseAsciiDiagram, parseTesslJson, parseResearchDecisions, parseTestSpecs, parseTaskTestRefs, parseAnalysisFindings, parseAnalysisCoverage, parseAnalysisMetrics, parseConstitutionAlignment, parsePhaseSeparation };
package/src/planview.js CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const childProcess = require('child_process');
6
+ const { promisify } = require('util');
5
7
  const { parseTechContext, parseFileStructure, parseAsciiDiagram, parseTesslJson, parseResearchDecisions } = require('./parser');
6
8
 
7
- module.exports = { computePlanViewState, classifyNodeTypes };
9
+ module.exports = { computePlanViewState, classifyNodeTypes, fetchTesslEvalData };
8
10
 
9
11
  // In-memory cache for LLM classification results per feature
10
12
  const classificationCache = new Map();
@@ -15,9 +17,12 @@ const classificationCache = new Map();
15
17
  *
16
18
  * @param {string} projectPath - Path to project root
17
19
  * @param {string} featureId - Feature directory name
20
+ * @param {Object} [options] - Optional configuration
21
+ * @param {Function} [options.fetchEvalData] - Custom eval data fetcher (for testing)
18
22
  * @returns {Promise<Object>} Plan view state
19
23
  */
20
- async function computePlanViewState(projectPath, featureId) {
24
+ async function computePlanViewState(projectPath, featureId, options = {}) {
25
+ const evalFetcher = options.fetchEvalData || fetchTesslEvalData;
21
26
  const featureDir = path.join(projectPath, 'specs', featureId);
22
27
  const planPath = path.join(featureDir, 'plan.md');
23
28
 
@@ -77,8 +82,14 @@ async function computePlanViewState(projectPath, featureId) {
77
82
  }
78
83
  }
79
84
 
80
- // Parse tessl.json
85
+ // Parse tessl.json and enrich with eval data
81
86
  const tesslTiles = parseTesslJson(projectPath);
87
+ const evalResults = await Promise.all(
88
+ tesslTiles.map(tile => evalFetcher(tile.name).catch(() => null))
89
+ );
90
+ tesslTiles.forEach((tile, i) => {
91
+ tile.eval = evalResults[i];
92
+ });
82
93
 
83
94
  return {
84
95
  techContext,
@@ -181,6 +192,108 @@ No explanation, just the JSON.`
181
192
  return result;
182
193
  }
183
194
 
195
+ // In-memory cache for eval data per tile name
196
+ const evalCache = new Map();
197
+
198
+ /**
199
+ * Fetch eval data for a Tessl tile using the tessl CLI.
200
+ * Returns structured eval data or null if unavailable.
201
+ *
202
+ * @param {string} tileName - Tile name (e.g., "tessl/npm-express")
203
+ * @returns {Promise<{score: number, multiplier: number, chartData: {pass: number, fail: number}}|null>}
204
+ */
205
+ async function fetchTesslEvalData(tileName) {
206
+ if (evalCache.has(tileName)) return evalCache.get(tileName);
207
+
208
+ try {
209
+ const execAsync = promisify(childProcess.exec);
210
+
211
+ // List eval runs for the tile
212
+ const { stdout: listOut } = await execAsync(
213
+ `tessl eval list --json --tile "${tileName}" --limit 1`,
214
+ { timeout: 10000 }
215
+ );
216
+ const listJson = JSON.parse(listOut.substring(listOut.indexOf('{')));
217
+
218
+ if (!listJson.data || listJson.data.length === 0) {
219
+ evalCache.set(tileName, null);
220
+ return null;
221
+ }
222
+
223
+ // Find the most recent completed eval run
224
+ const completedRun = listJson.data.find(r => r.attributes && r.attributes.status === 'completed');
225
+ if (!completedRun) {
226
+ evalCache.set(tileName, null);
227
+ return null;
228
+ }
229
+
230
+ // Fetch detailed eval results
231
+ const { stdout: viewOut } = await execAsync(
232
+ `tessl eval view --json ${completedRun.id}`,
233
+ { timeout: 15000 }
234
+ );
235
+ const viewJson = JSON.parse(viewOut.substring(viewOut.indexOf('{')));
236
+
237
+ const evalData = computeEvalSummary(viewJson.data);
238
+ evalCache.set(tileName, evalData);
239
+ return evalData;
240
+ } catch {
241
+ evalCache.set(tileName, null);
242
+ return null;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Compute eval summary from a detailed eval run response.
248
+ * Extracts score, multiplier, and pass/fail chart data from scenarios.
249
+ */
250
+ function computeEvalSummary(evalRun) {
251
+ if (!evalRun || !evalRun.attributes || !evalRun.attributes.scenarios) return null;
252
+
253
+ let usageTotal = 0, usageMax = 0, baselineTotal = 0;
254
+ let pass = 0, fail = 0;
255
+
256
+ for (const scenario of evalRun.attributes.scenarios) {
257
+ if (!scenario.solutions) continue;
258
+
259
+ const usageSpec = scenario.solutions.find(s => s.variant === 'usage-spec');
260
+ const baseline = scenario.solutions.find(s => s.variant === 'baseline');
261
+
262
+ if (usageSpec && usageSpec.assessmentResults) {
263
+ for (const r of usageSpec.assessmentResults) {
264
+ usageTotal += r.score;
265
+ usageMax += r.max_score;
266
+ if (r.score === r.max_score) pass++;
267
+ else fail++;
268
+ }
269
+ }
270
+
271
+ if (baseline && baseline.assessmentResults) {
272
+ for (const r of baseline.assessmentResults) {
273
+ baselineTotal += r.score;
274
+ }
275
+ }
276
+ }
277
+
278
+ if (usageMax === 0) return null;
279
+
280
+ const score = Math.round(usageTotal / usageMax * 100);
281
+ const multiplier = baselineTotal > 0
282
+ ? Math.round(usageTotal / baselineTotal * 100) / 100
283
+ : null;
284
+
285
+ return { score, multiplier, chartData: { pass, fail } };
286
+ }
287
+
288
+ /**
289
+ * Invalidate eval cache (e.g., when tessl.json changes).
290
+ */
291
+ function invalidateEvalCache() {
292
+ evalCache.clear();
293
+ }
294
+
295
+ module.exports.invalidateEvalCache = invalidateEvalCache;
296
+
184
297
  /**
185
298
  * Invalidate classification cache for a feature.
186
299
  */