iikit-dashboard 1.5.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.
package/README.md CHANGED
@@ -11,11 +11,12 @@ The dashboard launches automatically early in the IIKit workflow — no manual s
11
11
  You can also start it standalone to browse historical data for any project that has feature specs:
12
12
 
13
13
  ```bash
14
- npx iikit-dashboard # current directory
15
- npx iikit-dashboard /path/to/project # specific project
14
+ npx iikit-dashboard # current directory
15
+ npx iikit-dashboard /path/to/project # specific project
16
+ npx iikit-dashboard --port 3001 # custom port
16
17
  ```
17
18
 
18
- The dashboard opens at `http://localhost:3000`.
19
+ The dashboard opens at `http://localhost:3000` by default. A pidfile (`.specify/dashboard.pid.json`) is written on startup so external tools can discover which project a running dashboard serves and at which port.
19
20
 
20
21
  ## Views
21
22
 
@@ -23,40 +24,48 @@ The pipeline bar at the top shows all nine IIKit workflow phases. Click any phas
23
24
 
24
25
  | Phase | View |
25
26
  |-------|------|
26
- | **Constitution** | Radar chart of governance principles with obligation levels (MUST / SHOULD / MAY) |
27
- | **Spec** | Story map with swim lanes by priority + interactive requirements graph (US / FR / SC nodes and edges) |
28
- | **Clarify** | Q&A trail from clarification sessions, with clickable spec-item references that navigate back to the Spec view |
29
- | **Plan** | Tech stack badge wall, interactive file-structure tree (existing vs. planned files), rendered architecture diagram, and Tessl tile cards |
27
+ | **Constitution** | Radar chart of governance principles with obligation levels (MUST / SHOULD / MAY) and version timeline |
28
+ | **Spec** | Story map with swim lanes by priority + interactive force-directed requirements graph (US / FR / SC nodes and edges) with detail side-panel |
29
+ | **Clarify** | Q&A trail from clarification sessions grouped by date, with clickable spec-item references that navigate to the Spec view |
30
+ | **Plan** | Tech context key-value pairs, interactive file-structure tree (existing vs. planned files), rendered architecture diagram, and Tessl tile cards with live eval scores |
30
31
  | **Checklist** | Progress rings per checklist file with color coding (red/yellow/green), gate traffic light (OPEN/BLOCKED), and accordion detail view with CHK IDs and tag badges |
31
- | **Testify** | *Coming soon* |
32
- | **Tasks** | *Coming soon* |
33
- | **Analyze** | *Coming soon* |
34
- | **Implement** | Kanban board with cards sliding Todo → In Progress → Done as the agent checks off tasks |
32
+ | **Testify** | Assertion integrity seal (Verified/Tampered/Missing), Sankey traceability diagram (Requirements → Test Specs → Tasks), test pyramid, and gap highlighting for untested requirements |
33
+ | **Tasks** | Redirects to the Implement board (tasks are managed there) |
34
+ | **Analyze** | Health gauge (0–100) with four weighted factors, coverage heatmap (Tasks/Tests/Plan per requirement), and sortable/filterable severity table of analysis findings |
35
+ | **Implement** | Kanban board with cards sliding Todo → In Progress → Done as the agent checks off tasks, with collapsible per-story task lists |
35
36
 
36
37
  ## Features
37
38
 
38
39
  - **Live updates** — all views refresh in real time via WebSocket as project files change
39
40
  - **Pipeline navigation** — phase nodes show status (complete / in-progress / skipped / not started) with progress percentages
41
+ - **Cross-panel navigation** — Cmd/Ctrl+click any FR, US, SC, or task identifier to jump to its linked panel (Spec, Testify, Implement, Checklist, or Clarify)
40
42
  - **Feature selector** — dropdown to switch between features in `specs/`, sorted newest-first
41
- - **Clarification traceability** — Q&A entries link back to the FR / US / SC spec items they clarify
42
- - **Integrity badges** — shows whether test assertions have been tampered with
43
+ - **Project label** — header shows the project directory name with full path on hover, so you know which project a dashboard tab belongs to
44
+ - **Integrity badges** — shows whether test assertions have been tampered with (verified / tampered / missing)
45
+ - **Tessl eval scores** — Plan view tile cards display live eval data (score, pass/fail chart) when available
46
+ - **Activity indicator** — green dot pulses in the header when files are actively changing
47
+ - **Multi-project support** — pidfile at `.specify/dashboard.pid.json` lets external scripts identify running instances per project
43
48
  - **Three-state theme** — cycles System (OS preference) → Light → Dark
44
49
  - **Zero build step** — single HTML file with inline CSS and JS
45
50
 
46
51
  ## How It Works
47
52
 
48
- The server reads directly from your project's `specs/` directory:
53
+ The server reads directly from your project's directory:
49
54
 
50
55
  | File | Purpose |
51
56
  |------|---------|
52
- | `spec.md` | User stories, requirements, success criteria, and clarification Q&A |
53
- | `plan.md` | Tech stack, file structure, and architecture diagram |
54
- | `tasks.md` | Task checkboxes grouped by `[US1]`, `[US2]` tags |
55
- | `checklists/*.md` | Checklist items with completion status, CHK IDs, and category groupings |
56
57
  | `CONSTITUTION.md` | Governance principles and obligation levels |
57
- | `tessl.json` | Installed Tessl tiles for the dependency panel |
58
-
59
- A file watcher (chokidar) detects changes and pushes updates to the browser via WebSocket with 300 ms debounce.
58
+ | `specs/<feature>/spec.md` | User stories, requirements, success criteria, and clarification Q&A |
59
+ | `specs/<feature>/plan.md` | Tech stack, file structure, and architecture diagram |
60
+ | `specs/<feature>/research.md` | Research decisions (displayed as tooltips in Plan view) |
61
+ | `specs/<feature>/tasks.md` | Task checkboxes grouped by `[US1]`, `[US2]` tags |
62
+ | `specs/<feature>/checklists/*.md` | Checklist items with completion status, CHK IDs, and category groupings |
63
+ | `specs/<feature>/tests/test-specs.md` | Test specifications for the Testify traceability view |
64
+ | `specs/<feature>/context.json` | Assertion hash for integrity verification |
65
+ | `specs/<feature>/analysis.md` | Consistency analysis findings, coverage, and metrics |
66
+ | `tessl.json` | Installed Tessl tiles for the Plan dependency panel |
67
+
68
+ A file watcher (chokidar) monitors the project tree (excluding `node_modules` and `.git`) and pushes updates to the browser via WebSocket with 300 ms debounce.
60
69
 
61
70
  ## Requirements
62
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iikit-dashboard",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
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": {
@@ -12,7 +12,9 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "start": "node bin/iikit-dashboard.js",
15
- "test": "jest --forceExit"
15
+ "test": "jest --forceExit --testPathIgnorePatterns=test/visual",
16
+ "test:visual": "npx playwright test",
17
+ "test:visual:update": "npx playwright test --update-snapshots"
16
18
  },
17
19
  "keywords": [
18
20
  "iikit",
@@ -40,6 +42,7 @@
40
42
  "ws": "^8.19.0"
41
43
  },
42
44
  "devDependencies": {
45
+ "@playwright/test": "^1.58.2",
43
46
  "jest": "^30.2.0"
44
47
  }
45
48
  }
package/src/board.js CHANGED
@@ -19,8 +19,9 @@ function computeBoardState(stories, tasks) {
19
19
  if (!stories || !Array.isArray(stories)) return board;
20
20
  if (!tasks) tasks = [];
21
21
 
22
- // Group tasks by storyTag
22
+ // Group tasks by storyTag or bugTag
23
23
  const tasksByStory = {};
24
+ const tasksByBug = {};
24
25
  const untaggedTasks = [];
25
26
 
26
27
  for (const task of tasks) {
@@ -29,6 +30,11 @@ function computeBoardState(stories, tasks) {
29
30
  tasksByStory[task.storyTag] = [];
30
31
  }
31
32
  tasksByStory[task.storyTag].push(task);
33
+ } else if (task.bugTag) {
34
+ if (!tasksByBug[task.bugTag]) {
35
+ tasksByBug[task.bugTag] = [];
36
+ }
37
+ tasksByBug[task.bugTag].push(task);
32
38
  } else {
33
39
  untaggedTasks.push(task);
34
40
  }
@@ -87,6 +93,33 @@ function computeBoardState(stories, tasks) {
87
93
  board[column].push(card);
88
94
  }
89
95
 
96
+ // Handle bug fix tasks — group into per-bug cards
97
+ for (const [bugId, bugTasks] of Object.entries(tasksByBug)) {
98
+ const checkedCount = bugTasks.filter(t => t.checked).length;
99
+ const totalCount = bugTasks.length;
100
+
101
+ let column;
102
+ if (checkedCount === 0) {
103
+ column = 'todo';
104
+ } else if (checkedCount === totalCount) {
105
+ column = 'done';
106
+ } else {
107
+ column = 'in_progress';
108
+ }
109
+
110
+ const card = {
111
+ id: bugId,
112
+ title: `Bug Fix: ${bugId}`,
113
+ priority: 'P2',
114
+ tasks: bugTasks,
115
+ progress: `${checkedCount}/${totalCount}`,
116
+ column,
117
+ isBugCard: true
118
+ };
119
+
120
+ board[column].push(card);
121
+ }
122
+
90
123
  return board;
91
124
  }
92
125
 
package/src/bugs.js ADDED
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { parseBugs, parseTasks } = require('./parser');
6
+
7
+ const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
8
+
9
+ /**
10
+ * Resolve a GitHub issue reference like "#13" to a full URL.
11
+ *
12
+ * @param {string|null} issueRef - Issue reference (e.g., "#13") or null
13
+ * @param {string|null} repoUrl - Repository URL from git remote or null
14
+ * @returns {string|null} Full URL or null
15
+ */
16
+ function resolveGitHubIssueUrl(issueRef, repoUrl) {
17
+ if (!issueRef || !repoUrl) return null;
18
+
19
+ // Skip sentinel values
20
+ if (/^_\(/.test(issueRef)) return null;
21
+
22
+ // Extract issue number
23
+ const numMatch = issueRef.match(/#(\d+)/);
24
+ if (!numMatch) return null;
25
+
26
+ const issueNumber = numMatch[1];
27
+
28
+ // Normalize repo URL
29
+ let baseUrl = repoUrl;
30
+
31
+ // Handle SSH format: git@github.com:user/repo.git
32
+ const sshMatch = baseUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
33
+ if (sshMatch) {
34
+ baseUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
35
+ }
36
+
37
+ // Strip trailing .git
38
+ baseUrl = baseUrl.replace(/\.git$/, '');
39
+
40
+ return `${baseUrl}/issues/${issueNumber}`;
41
+ }
42
+
43
+ /**
44
+ * Compute the bugs state for a feature.
45
+ * Reads bugs.md and tasks.md, cross-references fix tasks by [BUG-NNN] tag.
46
+ *
47
+ * @param {string} projectPath - Path to the project root
48
+ * @param {string} featureId - Feature directory name (e.g., "009-bugs-tab")
49
+ * @returns {{exists: boolean, bugs: Array, orphanedTasks: Array, summary: Object, repoUrl: string|null}}
50
+ */
51
+ function computeBugsState(projectPath, featureId) {
52
+ const featureDir = path.join(projectPath, 'specs', featureId);
53
+ const bugsPath = path.join(featureDir, 'bugs.md');
54
+ const tasksPath = path.join(featureDir, 'tasks.md');
55
+
56
+ const emptySummary = {
57
+ total: 0,
58
+ open: 0,
59
+ fixed: 0,
60
+ highestOpenSeverity: null,
61
+ bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }
62
+ };
63
+
64
+ if (!fs.existsSync(bugsPath)) {
65
+ return { exists: false, bugs: [], orphanedTasks: [], summary: emptySummary, repoUrl: null };
66
+ }
67
+
68
+ const bugsContent = fs.readFileSync(bugsPath, 'utf-8');
69
+ const bugs = parseBugs(bugsContent);
70
+
71
+ // Parse tasks for fix task cross-referencing
72
+ const tasksContent = fs.existsSync(tasksPath) ? fs.readFileSync(tasksPath, 'utf-8') : '';
73
+ const allTasks = parseTasks(tasksContent);
74
+ const bugFixTasks = allTasks.filter(t => t.isBugFix && t.bugTag);
75
+
76
+ // Build lookup: BUG-NNN -> [task, ...]
77
+ const tasksByBug = {};
78
+ for (const task of bugFixTasks) {
79
+ if (!tasksByBug[task.bugTag]) tasksByBug[task.bugTag] = [];
80
+ tasksByBug[task.bugTag].push(task);
81
+ }
82
+
83
+ // Track which bug IDs exist
84
+ const bugIds = new Set(bugs.map(b => b.id));
85
+
86
+ // Enrich bugs with fix tasks
87
+ for (const bug of bugs) {
88
+ const tasks = tasksByBug[bug.id] || [];
89
+ bug.fixTasks = {
90
+ total: tasks.length,
91
+ checked: tasks.filter(t => t.checked).length,
92
+ tasks: tasks.map(t => ({
93
+ id: t.id,
94
+ description: t.description,
95
+ checked: t.checked
96
+ }))
97
+ };
98
+ }
99
+
100
+ // Sort bugs: severity descending (critical first), then ID ascending
101
+ bugs.sort((a, b) => {
102
+ const sevA = SEVERITY_ORDER[a.severity] !== undefined ? SEVERITY_ORDER[a.severity] : 3;
103
+ const sevB = SEVERITY_ORDER[b.severity] !== undefined ? SEVERITY_ORDER[b.severity] : 3;
104
+ const sevDiff = sevA - sevB;
105
+ if (sevDiff !== 0) return sevDiff;
106
+ return a.id.localeCompare(b.id);
107
+ });
108
+
109
+ // Detect orphaned tasks (T-B tasks referencing non-existent BUG-NNN)
110
+ const orphanedTasks = bugFixTasks
111
+ .filter(t => !bugIds.has(t.bugTag))
112
+ .map(t => ({ id: t.id, bugTag: t.bugTag, description: t.description, checked: t.checked }));
113
+
114
+ // Compute summary
115
+ const openBugs = bugs.filter(b => b.status !== 'fixed');
116
+ const fixedBugs = bugs.filter(b => b.status === 'fixed');
117
+
118
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
119
+ for (const bug of openBugs) {
120
+ if (bySeverity.hasOwnProperty(bug.severity)) {
121
+ bySeverity[bug.severity]++;
122
+ }
123
+ }
124
+
125
+ let highestOpenSeverity = null;
126
+ for (const sev of ['critical', 'high', 'medium', 'low']) {
127
+ if (bySeverity[sev] > 0) {
128
+ highestOpenSeverity = sev;
129
+ break;
130
+ }
131
+ }
132
+
133
+ // Try to get repo URL from git remote
134
+ let repoUrl = null;
135
+ try {
136
+ const { execSync } = require('child_process');
137
+ repoUrl = execSync('git remote get-url origin', {
138
+ cwd: projectPath,
139
+ encoding: 'utf-8',
140
+ stdio: ['pipe', 'pipe', 'pipe']
141
+ }).trim();
142
+ // Strip .git suffix
143
+ repoUrl = repoUrl.replace(/\.git$/, '');
144
+ // Handle SSH format
145
+ const sshMatch = repoUrl.match(/^git@([^:]+):(.+)$/);
146
+ if (sshMatch) {
147
+ repoUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
148
+ }
149
+ } catch {
150
+ // No git remote — repoUrl stays null
151
+ }
152
+
153
+ return {
154
+ exists: true,
155
+ bugs,
156
+ orphanedTasks,
157
+ summary: {
158
+ total: bugs.length,
159
+ open: openBugs.length,
160
+ fixed: fixedBugs.length,
161
+ highestOpenSeverity,
162
+ bySeverity
163
+ },
164
+ repoUrl
165
+ };
166
+ }
167
+
168
+ module.exports = { computeBugsState, resolveGitHubIssueUrl };
package/src/parser.js CHANGED
@@ -57,23 +57,31 @@ function parseSpecStories(content) {
57
57
  /**
58
58
  * Parse tasks.md to extract tasks with checkbox status and story tags.
59
59
  * Pattern: - [x] TXXX [P]? [USy]? Description
60
+ * Extended: also matches T-B\d+ IDs and [BUG-\d+] tags for bug fix tasks.
60
61
  *
61
62
  * @param {string} content - Raw markdown content of tasks.md
62
- * @returns {Array<{id: string, storyTag: string|null, description: string, checked: boolean}>}
63
+ * @returns {Array<{id: string, storyTag: string|null, bugTag: string|null, description: string, checked: boolean, isBugFix: boolean}>}
63
64
  */
64
65
  function parseTasks(content) {
65
66
  if (!content || typeof content !== 'string') return [];
66
67
 
67
- const regex = /- \[([ x])\] (T\d+)\s+(?:\[P\]\s*)?(?:\[(US\d+)\]\s*)?(.*)/g;
68
+ const regex = /- \[([ x])\] (T(?:-B)?\d+)\s+(?:\[P\]\s*)?(?:\[(US\d+|BUG-\d+)\]\s*)?(.*)/g;
68
69
  const tasks = [];
69
70
  let match;
70
71
 
71
72
  while ((match = regex.exec(content)) !== null) {
73
+ const id = match[2];
74
+ const tag = match[3] || null;
75
+ const isBugFix = id.startsWith('T-B');
76
+ const isBugTag = tag && /^BUG-\d+$/.test(tag);
77
+
72
78
  tasks.push({
73
- id: match[2],
74
- storyTag: match[3] || null,
79
+ id,
80
+ storyTag: (tag && !isBugTag) ? tag : null,
81
+ bugTag: isBugTag ? tag : null,
75
82
  description: match[4].trim(),
76
- checked: match[1] === 'x'
83
+ checked: match[1] === 'x',
84
+ isBugFix
77
85
  });
78
86
  }
79
87
 
@@ -1179,4 +1187,75 @@ function parsePhaseSeparation(content) {
1179
1187
  }).filter(Boolean);
1180
1188
  }
1181
1189
 
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 };
1190
+ /**
1191
+ * Parse bugs.md to extract bug entries.
1192
+ * Pattern: ## BUG-NNN headings with field lines.
1193
+ * Permissive parsing — returns [] on missing/empty/malformed input.
1194
+ *
1195
+ * @param {string} content - Raw markdown content of bugs.md
1196
+ * @returns {Array<{id: string, reported: string|null, severity: string, status: string, githubIssue: string|null, description: string|null, rootCause: string|null, fixReference: string|null}>}
1197
+ */
1198
+ function parseBugs(content) {
1199
+ if (!content || typeof content !== 'string') return [];
1200
+
1201
+ const validSeverities = new Set(['critical', 'high', 'medium', 'low']);
1202
+ const validStatuses = new Set(['reported', 'fixed']);
1203
+
1204
+ const headingRegex = /^## (BUG-\d+)\s*$/gm;
1205
+ const bugStarts = [];
1206
+ let match;
1207
+
1208
+ while ((match = headingRegex.exec(content)) !== null) {
1209
+ bugStarts.push({ id: match[1], index: match.index });
1210
+ }
1211
+
1212
+ const bugs = [];
1213
+
1214
+ for (let i = 0; i < bugStarts.length; i++) {
1215
+ const start = bugStarts[i].index;
1216
+ const end = i + 1 < bugStarts.length ? bugStarts[i + 1].index : content.length;
1217
+ const section = content.substring(start, end);
1218
+
1219
+ const bug = {
1220
+ id: bugStarts[i].id,
1221
+ reported: extractField(section, 'Reported'),
1222
+ severity: extractField(section, 'Severity') || 'medium',
1223
+ status: extractField(section, 'Status') || 'reported',
1224
+ githubIssue: extractField(section, 'GitHub Issue'),
1225
+ description: extractField(section, 'Description'),
1226
+ rootCause: extractField(section, 'Root Cause'),
1227
+ fixReference: extractField(section, 'Fix Reference')
1228
+ };
1229
+
1230
+ // Validate severity
1231
+ if (!validSeverities.has(bug.severity)) {
1232
+ bug.severity = 'medium';
1233
+ }
1234
+
1235
+ // Validate status
1236
+ if (!validStatuses.has(bug.status)) {
1237
+ bug.status = 'reported';
1238
+ }
1239
+
1240
+ bugs.push(bug);
1241
+ }
1242
+
1243
+ return bugs;
1244
+ }
1245
+
1246
+ /**
1247
+ * Extract a **Field**: Value from a bug section.
1248
+ * Returns null for _(none)_, _(empty...)_ patterns, and missing fields.
1249
+ */
1250
+ function extractField(section, fieldName) {
1251
+ const regex = new RegExp(`\\*\\*${fieldName}\\*\\*:\\s*(.+)`, 'm');
1252
+ const match = section.match(regex);
1253
+ if (!match) return null;
1254
+
1255
+ const value = match[1].trim();
1256
+ if (!value || /^_\(/.test(value)) return null;
1257
+
1258
+ return value;
1259
+ }
1260
+
1261
+ 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, parseBugs };
@@ -724,9 +724,9 @@
724
724
  border-radius: var(--radius-md);
725
725
  cursor: pointer;
726
726
  transition: background var(--transition-fast), transform var(--transition-fast);
727
- flex: 0 1 96px;
728
- width: 96px;
729
- min-width: 40px;
727
+ flex: 1 1 0;
728
+ min-width: 72px;
729
+ max-width: 110px;
730
730
  position: relative;
731
731
  border: 1px solid var(--color-border-subtle);
732
732
  background: transparent;
@@ -830,6 +830,340 @@
830
830
  opacity: 0.8;
831
831
  }
832
832
 
833
+ /* ====== Bugs Tab (standalone, no connector) ====== */
834
+ .bugs-tab-gap {
835
+ width: 16px;
836
+ flex-shrink: 0;
837
+ }
838
+
839
+ .bugs-tab {
840
+ display: flex;
841
+ flex-direction: column;
842
+ align-items: center;
843
+ gap: 4px;
844
+ padding: 6px 12px;
845
+ cursor: pointer;
846
+ border-radius: 8px;
847
+ border: 1px solid var(--color-border);
848
+ background: var(--color-surface);
849
+ transition: all 0.15s ease;
850
+ position: relative;
851
+ font-family: inherit;
852
+ color: inherit;
853
+ }
854
+
855
+ .bugs-tab:hover {
856
+ border-color: var(--color-text-secondary);
857
+ background: rgba(255, 255, 255, 0.06);
858
+ }
859
+
860
+ .bugs-tab.active {
861
+ border-color: var(--color-accent);
862
+ background: rgba(99, 102, 241, 0.08);
863
+ }
864
+
865
+ .bugs-tab.muted {
866
+ opacity: 0.45;
867
+ }
868
+
869
+ .bugs-tab-icon {
870
+ font-size: 14px;
871
+ line-height: 1;
872
+ }
873
+
874
+ .bugs-tab-label {
875
+ font-size: 10px;
876
+ text-transform: uppercase;
877
+ letter-spacing: 0.5px;
878
+ color: var(--color-text-secondary);
879
+ }
880
+
881
+ .bugs-tab-badge {
882
+ position: absolute;
883
+ top: -6px;
884
+ right: -6px;
885
+ min-width: 18px;
886
+ height: 18px;
887
+ padding: 0 5px;
888
+ border-radius: 9px;
889
+ font-size: 11px;
890
+ font-weight: 600;
891
+ line-height: 18px;
892
+ text-align: center;
893
+ color: #fff;
894
+ }
895
+
896
+ .bugs-tab-badge.muted {
897
+ opacity: 0.4;
898
+ background: var(--color-text-secondary);
899
+ }
900
+
901
+ .bugs-tab-badge.severity-critical { background: #ff4757; }
902
+ .bugs-tab-badge.severity-high { background: #ffa502; }
903
+ .bugs-tab-badge.severity-medium { background: #f1c40f; color: #1a1a2e; }
904
+ .bugs-tab-badge.severity-low { background: #6b7189; }
905
+
906
+ /* ====== Bug Severity Colors ====== */
907
+ .severity-critical { color: #ff4757; }
908
+ .severity-high { color: #ffa502; }
909
+ .severity-medium { color: #f1c40f; }
910
+ .severity-low { color: #6b7189; }
911
+
912
+ .severity-badge {
913
+ display: inline-block;
914
+ padding: 2px 8px;
915
+ border-radius: 4px;
916
+ font-size: 11px;
917
+ font-weight: 600;
918
+ text-transform: uppercase;
919
+ letter-spacing: 0.3px;
920
+ }
921
+
922
+ .severity-badge.severity-critical { background: rgba(255, 71, 87, 0.15); color: #ff4757; }
923
+ .severity-badge.severity-high { background: rgba(255, 165, 2, 0.15); color: #ffa502; }
924
+ .severity-badge.severity-medium { background: rgba(241, 196, 15, 0.15); color: #f1c40f; }
925
+ .severity-badge.severity-low { background: rgba(107, 113, 137, 0.15); color: #6b7189; }
926
+
927
+ /* ====== Bugs View ====== */
928
+ .bugs-view {
929
+ padding: 24px;
930
+ max-width: 1200px;
931
+ margin: 0 auto;
932
+ }
933
+
934
+ .bugs-view-header {
935
+ margin-bottom: 20px;
936
+ }
937
+
938
+ .bugs-view-title {
939
+ font-size: 20px;
940
+ font-weight: 600;
941
+ color: var(--color-text-primary);
942
+ }
943
+
944
+ .bugs-view-subtitle {
945
+ font-size: 13px;
946
+ color: var(--color-text-secondary);
947
+ margin-top: 4px;
948
+ }
949
+
950
+ .bugs-table {
951
+ width: 100%;
952
+ border-collapse: collapse;
953
+ font-size: 13px;
954
+ }
955
+
956
+ .bugs-table th {
957
+ text-align: left;
958
+ padding: 10px 12px;
959
+ font-weight: 600;
960
+ color: var(--color-text-secondary);
961
+ border-bottom: 1px solid var(--color-border);
962
+ font-size: 11px;
963
+ text-transform: uppercase;
964
+ letter-spacing: 0.5px;
965
+ }
966
+
967
+ .bugs-table td {
968
+ padding: 10px 12px;
969
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
970
+ vertical-align: middle;
971
+ }
972
+
973
+ .bugs-table tr:hover td {
974
+ background: rgba(255, 255, 255, 0.02);
975
+ }
976
+
977
+ .bugs-table tr.highlighted td {
978
+ background: rgba(99, 102, 241, 0.12);
979
+ transition: background 0.3s ease;
980
+ }
981
+
982
+ .bugs-table .bug-id {
983
+ font-family: 'SF Mono', 'Fira Code', monospace;
984
+ font-weight: 600;
985
+ color: var(--color-text-primary);
986
+ }
987
+
988
+ .bugs-table .bug-status {
989
+ text-transform: capitalize;
990
+ }
991
+
992
+ .bugs-table .bug-status.fixed {
993
+ color: var(--color-done);
994
+ }
995
+
996
+ .bugs-table .bug-progress {
997
+ font-family: 'SF Mono', 'Fira Code', monospace;
998
+ font-size: 12px;
999
+ }
1000
+
1001
+ .bugs-table .bug-description {
1002
+ max-width: 300px;
1003
+ overflow: hidden;
1004
+ text-overflow: ellipsis;
1005
+ white-space: nowrap;
1006
+ }
1007
+
1008
+ .bugs-table .github-link {
1009
+ color: var(--color-accent);
1010
+ text-decoration: none;
1011
+ }
1012
+
1013
+ .bugs-table .github-link:hover {
1014
+ text-decoration: underline;
1015
+ }
1016
+
1017
+ .bugs-empty {
1018
+ display: flex;
1019
+ flex-direction: column;
1020
+ align-items: center;
1021
+ justify-content: center;
1022
+ padding: 80px 20px;
1023
+ text-align: center;
1024
+ }
1025
+
1026
+ .bugs-empty-icon {
1027
+ font-size: 48px;
1028
+ margin-bottom: 16px;
1029
+ opacity: 0.4;
1030
+ }
1031
+
1032
+ .bugs-empty-title {
1033
+ font-size: 18px;
1034
+ font-weight: 600;
1035
+ color: var(--color-text-primary);
1036
+ margin-bottom: 8px;
1037
+ }
1038
+
1039
+ .bugs-empty-text {
1040
+ font-size: 14px;
1041
+ color: var(--color-text-secondary);
1042
+ }
1043
+
1044
+ .orphaned-section {
1045
+ margin-top: 24px;
1046
+ padding: 16px;
1047
+ border: 1px dashed var(--color-border);
1048
+ border-radius: 8px;
1049
+ opacity: 0.7;
1050
+ }
1051
+
1052
+ .orphaned-title {
1053
+ font-size: 13px;
1054
+ font-weight: 600;
1055
+ color: var(--color-text-secondary);
1056
+ margin-bottom: 8px;
1057
+ }
1058
+
1059
+ .orphaned-task {
1060
+ font-size: 12px;
1061
+ color: var(--color-text-secondary);
1062
+ padding: 4px 0;
1063
+ }
1064
+
1065
+ .bug-icon-inline {
1066
+ display: inline-block;
1067
+ width: 14px;
1068
+ height: 14px;
1069
+ margin-right: 4px;
1070
+ vertical-align: middle;
1071
+ opacity: 0.7;
1072
+ }
1073
+
1074
+ /* Pipeline bugs gap & node (spec aliases) */
1075
+ .pipeline-bugs-gap {
1076
+ width: 20px;
1077
+ flex-shrink: 0;
1078
+ }
1079
+ .pipeline-node-bugs.muted {
1080
+ opacity: 0.5;
1081
+ }
1082
+ .bugs-dot {
1083
+ background: var(--color-surface-elevated) !important;
1084
+ border: 2px solid var(--color-border) !important;
1085
+ color: var(--color-text-muted) !important;
1086
+ }
1087
+ .bug-count-badge {
1088
+ display: inline-flex;
1089
+ align-items: center;
1090
+ justify-content: center;
1091
+ min-width: 18px;
1092
+ height: 18px;
1093
+ padding: 0 5px;
1094
+ border-radius: 9px;
1095
+ font-size: 11px;
1096
+ font-weight: 700;
1097
+ margin-left: 4px;
1098
+ vertical-align: middle;
1099
+ }
1100
+ .bug-count-badge.muted {
1101
+ background: var(--color-surface-elevated);
1102
+ color: var(--color-text-muted);
1103
+ }
1104
+
1105
+ /* Bug view additional styles */
1106
+ .bugs-view-container {
1107
+ padding: 24px;
1108
+ max-width: 1200px;
1109
+ }
1110
+ .bugs-view-container h2 {
1111
+ font-size: 18px;
1112
+ font-weight: 600;
1113
+ margin-bottom: 16px;
1114
+ color: var(--color-text);
1115
+ }
1116
+ .bug-row:hover {
1117
+ background: var(--color-surface-hover);
1118
+ }
1119
+ .fix-progress {
1120
+ font-variant-numeric: tabular-nums;
1121
+ font-weight: 500;
1122
+ }
1123
+ .bug-status {
1124
+ text-transform: capitalize;
1125
+ }
1126
+ .bug-status.fixed {
1127
+ color: var(--color-success, #2ecc71);
1128
+ }
1129
+ .bug-github-link {
1130
+ color: var(--color-accent);
1131
+ text-decoration: none;
1132
+ }
1133
+ .bug-github-link:hover {
1134
+ text-decoration: underline;
1135
+ }
1136
+ .bug-empty-state {
1137
+ text-align: center;
1138
+ padding: 48px 24px;
1139
+ color: var(--color-text-muted);
1140
+ font-size: 14px;
1141
+ }
1142
+ .bug-empty-state svg {
1143
+ display: block;
1144
+ margin: 0 auto 12px;
1145
+ opacity: 0.4;
1146
+ }
1147
+ .orphaned-tasks-section {
1148
+ margin-top: 32px;
1149
+ padding: 16px;
1150
+ background: var(--color-surface-elevated);
1151
+ border-radius: var(--radius-md);
1152
+ border: 1px solid var(--color-warning, #f5a623);
1153
+ opacity: 0.7;
1154
+ }
1155
+ .orphaned-tasks-section h3 {
1156
+ font-size: 13px;
1157
+ font-weight: 600;
1158
+ color: var(--color-warning, #f5a623);
1159
+ margin-bottom: 8px;
1160
+ }
1161
+ .orphaned-tasks-section .task-item {
1162
+ font-size: 12px;
1163
+ color: var(--color-text-muted);
1164
+ padding: 4px 0;
1165
+ }
1166
+
833
1167
  /* ====== Content Area ====== */
834
1168
  .content-area {
835
1169
  flex: 1;
@@ -868,6 +1202,8 @@
868
1202
  font-size: 13px;
869
1203
  color: var(--color-text-muted);
870
1204
  max-width: 360px;
1205
+ margin: 0 auto;
1206
+ text-align: center;
871
1207
  }
872
1208
 
873
1209
  /* ====== Checklist Quality Gates Tab ====== */
@@ -1559,6 +1895,10 @@
1559
1895
  text-align: center;
1560
1896
  padding: 60px 20px;
1561
1897
  color: var(--color-text-muted);
1898
+ max-width: 480px;
1899
+ margin: 0 auto;
1900
+ word-break: normal;
1901
+ overflow-wrap: break-word;
1562
1902
  }
1563
1903
 
1564
1904
  .clarify-header {
@@ -2427,6 +2767,7 @@
2427
2767
  let expandedChecklist = null;
2428
2768
  let currentTestify = null;
2429
2769
  let currentAnalyze = null;
2770
+ let currentBugs = null;
2430
2771
  let activeTab = null;
2431
2772
  let externalSankeyHighlight = null;
2432
2773
  let ws = null;
@@ -2453,6 +2794,25 @@
2453
2794
  available: '&#9679;'
2454
2795
  };
2455
2796
 
2797
+ const BUG_ICON_SMALL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 2l1.88 1.88M14.12 3.88L16 2M9 7.13v-1a3 3 0 0 1 6 0v1M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6zM3.5 11H2M5.06 6.5l-1.5-1M22 11h-1.5M18.94 6.5l1.5-1M12 20v2"/></svg>';
2798
+
2799
+ function getBugsBadgeHtml(bugsData) {
2800
+ if (!bugsData || !bugsData.exists) return '';
2801
+ const count = bugsData.summary.open;
2802
+ const sev = bugsData.summary.highestOpenSeverity;
2803
+
2804
+ const colors = { critical: '#ff4757', high: '#ffa502', medium: '#f1c40f', low: '#6b7189' };
2805
+ const textColors = { critical: '#fff', high: '#fff', medium: '#1a1a2e', low: '#fff' };
2806
+
2807
+ if (count === 0) {
2808
+ return ' <span class="bug-count-badge muted">0</span>';
2809
+ }
2810
+
2811
+ const bg = colors[sev] || '#6b7189';
2812
+ const fg = textColors[sev] || '#fff';
2813
+ return ` <span class="bug-count-badge" style="background:${bg};color:${fg}">${count}</span>`;
2814
+ }
2815
+
2456
2816
  function renderPipeline(pipeline) {
2457
2817
  if (!pipeline || !pipeline.phases) return;
2458
2818
  currentPipeline = pipeline;
@@ -2487,6 +2847,28 @@
2487
2847
  node.addEventListener('click', () => switchTab(phase.id));
2488
2848
  pipelineBar.appendChild(node);
2489
2849
  });
2850
+
2851
+ // Bugs tab — standalone, no connector
2852
+ const bugsGap = document.createElement('div');
2853
+ bugsGap.className = 'pipeline-bugs-gap';
2854
+ pipelineBar.appendChild(bugsGap);
2855
+
2856
+ const bugsTab = document.createElement('button');
2857
+ bugsTab.className = 'bugs-tab' + (activeTab === 'bugs' ? ' active' : '');
2858
+ bugsTab.setAttribute('data-view', 'bugs');
2859
+ bugsTab.setAttribute('tabindex', '0');
2860
+ if (activeTab === 'bugs') bugsTab.setAttribute('aria-current', 'true');
2861
+
2862
+ const badgeHtml = currentBugs ? getBugsBadgeHtml(currentBugs) : '';
2863
+ bugsTab.innerHTML = `
2864
+ <div class="pipeline-dot bugs-dot">${BUG_ICON_SMALL}</div>
2865
+ <span class="pipeline-label">Bugs${badgeHtml}</span>
2866
+ `;
2867
+ if (currentBugs && !currentBugs.exists) {
2868
+ bugsTab.classList.add('muted');
2869
+ }
2870
+ bugsTab.addEventListener('click', () => switchTab('bugs'));
2871
+ pipelineBar.appendChild(bugsTab);
2490
2872
  }
2491
2873
 
2492
2874
  function switchTab(phaseId) {
@@ -2519,6 +2901,8 @@
2519
2901
  renderTestifyView();
2520
2902
  } else if (phaseId === 'analyze') {
2521
2903
  renderAnalyzeView();
2904
+ } else if (phaseId === 'bugs') {
2905
+ renderBugsView();
2522
2906
  } else {
2523
2907
  renderPlaceholderView(phaseId);
2524
2908
  }
@@ -2533,7 +2917,8 @@
2533
2917
  // Sentinel elements per panel — poll until the panel has rendered
2534
2918
  const sentinels = {
2535
2919
  spec: '.graph-node', testify: '.sankey-node', implement: '.card',
2536
- checklist: '.checklist-item', clarify: '.clarify-entry', analyze: '.heatmap-row'
2920
+ checklist: '.checklist-item', clarify: '.clarify-entry', analyze: '.heatmap-row',
2921
+ bugs: '.bug-row'
2537
2922
  };
2538
2923
  const sel = sentinels[panelId];
2539
2924
  if (sel) {
@@ -2549,7 +2934,8 @@
2549
2934
  testify: highlightTestifyEntity,
2550
2935
  implement: highlightBoardEntity,
2551
2936
  checklist: highlightChecklistEntity,
2552
- clarify: highlightClarifyEntity
2937
+ clarify: highlightClarifyEntity,
2938
+ bugs: highlightBugsEntity
2553
2939
  };
2554
2940
  const fn = highlighters[panelId];
2555
2941
  if (fn) fn(entityId);
@@ -2687,6 +3073,138 @@
2687
3073
  }
2688
3074
  }
2689
3075
 
3076
+ // ====== Bugs View (T014, T015, T016) ======
3077
+
3078
+ async function renderBugsView() {
3079
+ if (!currentFeature) return;
3080
+
3081
+ contentArea.innerHTML = '<div class="loading">Loading bug data...</div>';
3082
+ try {
3083
+ const res = await fetch(`/api/bugs/${currentFeature}`);
3084
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
3085
+ const data = await res.json();
3086
+ currentBugs = data;
3087
+ if (currentPipeline) renderPipeline(currentPipeline);
3088
+ renderBugsContent(data);
3089
+ } catch (err) {
3090
+ contentArea.innerHTML = `<div class="error-message">Error loading bugs: ${escapeHtml(err.message)}</div>`;
3091
+ }
3092
+ }
3093
+
3094
+ function renderBugsContent(data) {
3095
+ if (!data || !data.exists || data.bugs.length === 0) {
3096
+ // T015: Empty state
3097
+ const msg = !data || !data.exists
3098
+ ? 'No bugs have been reported for this feature.'
3099
+ : 'All clear — no bug entries found.';
3100
+ contentArea.innerHTML = `
3101
+ <div class="bugs-empty">
3102
+ <div class="bugs-empty-icon">&#10003;</div>
3103
+ <div class="bugs-empty-title">No Bugs</div>
3104
+ <div class="bugs-empty-text">${msg}</div>
3105
+ </div>`;
3106
+ return;
3107
+ }
3108
+
3109
+ const { bugs, orphanedTasks, summary, repoUrl } = data;
3110
+
3111
+ let tableRows = '';
3112
+ for (const bug of bugs) {
3113
+ // Fix task progress
3114
+ const progressText = bug.fixTasks.total > 0
3115
+ ? `${bug.fixTasks.checked}/${bug.fixTasks.total}`
3116
+ : '\u2014';
3117
+
3118
+ // GitHub link (T016)
3119
+ let githubHtml = '\u2014';
3120
+ if (bug.githubIssue) {
3121
+ const resolvedUrl = resolveGitHubUrl(bug.githubIssue, repoUrl);
3122
+ if (resolvedUrl) {
3123
+ githubHtml = `<a href="${escapeHtml(resolvedUrl)}" class="github-link bug-github-link" target="_blank" rel="noopener">${escapeHtml(bug.githubIssue)} <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg></a>`;
3124
+ } else {
3125
+ githubHtml = escapeHtml(bug.githubIssue);
3126
+ }
3127
+ }
3128
+
3129
+ // Fix task list for cross-links
3130
+ let taskLinks = '';
3131
+ if (bug.fixTasks.total > 0) {
3132
+ taskLinks = bug.fixTasks.tasks.map(t =>
3133
+ `<span class="task-id cross-link" data-cross-target="implement" data-cross-id="${t.id}" style="cursor:pointer;color:var(--color-accent);font-family:monospace;font-size:11px;">${t.id}</span>`
3134
+ ).join(', ');
3135
+ }
3136
+
3137
+ tableRows += `
3138
+ <tr class="bug-row" data-bug-id="${bug.id}">
3139
+ <td class="bug-id">${bug.id}</td>
3140
+ <td><span class="severity-badge severity-${bug.severity}">${bug.severity}</span></td>
3141
+ <td class="bug-status ${bug.status === 'fixed' ? 'fixed' : ''}">${bug.status}</td>
3142
+ <td class="bug-progress">${progressText}${taskLinks ? '<br>' + taskLinks : ''}</td>
3143
+ <td class="bug-description" title="${escapeHtml(bug.description || '')}">${escapeHtml(bug.description || '\u2014')}</td>
3144
+ <td>${githubHtml}</td>
3145
+ </tr>`;
3146
+ }
3147
+
3148
+ // Orphaned tasks section (TS-020)
3149
+ let orphanedHtml = '';
3150
+ if (orphanedTasks && orphanedTasks.length > 0) {
3151
+ orphanedHtml = `
3152
+ <div class="orphaned-section">
3153
+ <div class="orphaned-title">&#9888; Orphaned Fix Tasks (no matching bug in bugs.md)</div>
3154
+ ${orphanedTasks.map(t => `
3155
+ <div class="orphaned-task">
3156
+ <span class="task-id cross-link" data-cross-target="implement" data-cross-id="${t.id}" style="cursor:pointer;color:var(--color-accent);">${t.id}</span>
3157
+ [${t.bugTag}] ${escapeHtml(t.description)}
3158
+ </div>
3159
+ `).join('')}
3160
+ </div>`;
3161
+ }
3162
+
3163
+ contentArea.innerHTML = `
3164
+ <div class="bugs-view">
3165
+ <div class="bugs-view-header">
3166
+ <div class="bugs-view-title">Bug Tracking</div>
3167
+ <div class="bugs-view-subtitle">${summary.total} total &middot; ${summary.open} open &middot; ${summary.fixed} fixed</div>
3168
+ </div>
3169
+ <div class="cross-link-tip">Tip: ${CROSS_LINK_MOD_KEY}+click task IDs to navigate to the Implement board</div>
3170
+ <table class="bugs-table" role="table" aria-label="Bug status table">
3171
+ <thead>
3172
+ <tr>
3173
+ <th>Bug ID</th>
3174
+ <th aria-sort="descending">Severity</th>
3175
+ <th>Status</th>
3176
+ <th>Fix Tasks</th>
3177
+ <th>Description</th>
3178
+ <th>GitHub</th>
3179
+ </tr>
3180
+ </thead>
3181
+ <tbody>${tableRows}</tbody>
3182
+ </table>
3183
+ ${orphanedHtml}
3184
+ </div>`;
3185
+ }
3186
+
3187
+ // T016: Client-side GitHub URL resolution
3188
+ function resolveGitHubUrl(issueRef, repoUrl) {
3189
+ if (!issueRef || !repoUrl) return null;
3190
+ const match = issueRef.match(/#(\d+)/);
3191
+ if (!match) return null;
3192
+ let base = repoUrl.replace(/\.git$/, '');
3193
+ const ssh = base.match(/^git@([^:]+):(.+)$/);
3194
+ if (ssh) base = 'https://' + ssh[1] + '/' + ssh[2];
3195
+ return base + '/issues/' + match[1];
3196
+ }
3197
+
3198
+ // T021: Highlight a bug row in the Bugs tab
3199
+ function highlightBugsEntity(entityId) {
3200
+ const row = contentArea.querySelector(`tr[data-bug-id="${entityId}"]`);
3201
+ if (row) {
3202
+ row.scrollIntoView({ behavior: 'smooth', block: 'center' });
3203
+ row.classList.add('highlighted');
3204
+ setTimeout(() => row.classList.remove('highlighted'), 2000);
3205
+ }
3206
+ }
3207
+
2690
3208
  function renderPlaceholderView(phaseId) {
2691
3209
  const phaseNames = {
2692
3210
  constitution: 'Constitution', spec: 'Specification', clarify: 'Clarification',
@@ -3720,11 +4238,22 @@
3720
4238
 
3721
4239
  function renderClarifyContent(clarifications) {
3722
4240
  if (!clarifications.length) {
4241
+ // TS-019/TS-020: Distinguish "clarify was run, no issues" from "never run"
4242
+ const clarifyPhase = currentPipeline?.phases?.find(p => p.id === 'clarify');
4243
+ const clarifyWasRun = clarifyPhase && (clarifyPhase.status === 'complete' || clarifyPhase.status === 'skipped');
4244
+
4245
+ const title = clarifyWasRun
4246
+ ? 'Clarification Complete'
4247
+ : 'Clarify Not Yet Run';
4248
+ const text = clarifyWasRun
4249
+ ? 'The clarification phase was completed with no ambiguities found. The specification is clear as written.'
4250
+ : 'This is an optional phase. Run <code>/iikit-02-clarify</code> to identify and resolve ambiguities in the spec.';
4251
+
3723
4252
  contentArea.innerHTML = `
3724
4253
  <div class="clarify-view" role="region" aria-label="Clarifications">
3725
4254
  <div class="clarify-empty">
3726
- <div class="placeholder-view-title">No Clarifications Recorded</div>
3727
- <div class="placeholder-view-text">This is an optional phase. The specification exists but no clarification sessions have been recorded. Run <code>/iikit-02-clarify</code> to identify and resolve ambiguities in the spec.</div>
4255
+ <div class="placeholder-view-title">${title}</div>
4256
+ <div class="placeholder-view-text">${text}</div>
3728
4257
  </div>
3729
4258
  </div>`;
3730
4259
  return;
@@ -4419,6 +4948,7 @@
4419
4948
  featureSelect.value = currentFeature;
4420
4949
  loadPipeline(currentFeature);
4421
4950
  loadBoard(currentFeature);
4951
+ loadBugs(currentFeature);
4422
4952
  } else if (features.length === 0) {
4423
4953
  showEmptyState();
4424
4954
  }
@@ -4451,8 +4981,10 @@
4451
4981
  currentPlanView = null; // Reset plan view cache
4452
4982
  currentTestify = null; // Reset testify cache
4453
4983
  currentAnalyze = null; // Reset analyze cache
4984
+ currentBugs = null; // Reset bugs cache
4454
4985
  loadPipeline(val);
4455
4986
  loadBoard(val);
4987
+ loadBugs(val);
4456
4988
  // Resubscribe WebSocket
4457
4989
  if (ws && ws.readyState === WebSocket.OPEN) {
4458
4990
  ws.send(JSON.stringify({ type: 'subscribe', feature: val }));
@@ -4483,6 +5015,21 @@
4483
5015
  }
4484
5016
  }
4485
5017
 
5018
+ async function loadBugs(featureId) {
5019
+ try {
5020
+ const res = await fetch(`/api/bugs/${featureId}`);
5021
+ if (res.ok) {
5022
+ currentBugs = await res.json();
5023
+ // Update badge on pipeline bar
5024
+ if (currentPipeline) renderPipeline(currentPipeline);
5025
+ // If bugs view is active, re-render it
5026
+ if (activeTab === 'bugs') renderBugsContent(currentBugs);
5027
+ }
5028
+ } catch {
5029
+ // Silently fail — bugs are optional
5030
+ }
5031
+ }
5032
+
4486
5033
  function showLoading() {
4487
5034
  boardEl.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
4488
5035
  }
@@ -4576,9 +5123,11 @@
4576
5123
  if (targetEl) renderBoardInto(targetEl, board);
4577
5124
  }
4578
5125
 
5126
+ const BUG_SVG_ICON = '<svg class="bug-icon-inline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 2l1.88 1.88M14.12 3.88L16 2M9 7.13v-1a3.003 3.003 0 116 0v1M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 014-4h4a4 4 0 014 4v3c0 3.3-2.7 6-6 6zM2 11h3M19 11h3M2 16h3M19 16h3"/></svg>';
5127
+
4579
5128
  function createCardElement(card, columnKey) {
4580
5129
  const el = document.createElement('div');
4581
- el.className = 'card';
5130
+ el.className = 'card' + (card.isBugCard ? ' bug-card' : '');
4582
5131
  el.setAttribute('data-card-id', card.id);
4583
5132
  el.setAttribute('role', 'article');
4584
5133
  el.setAttribute('aria-label', `${card.title} - ${card.priority} - ${card.progress} tasks complete`);
@@ -4590,10 +5139,14 @@
4590
5139
 
4591
5140
  const priorityClass = card.priority ? card.priority.toLowerCase() : 'p3';
4592
5141
 
5142
+ const cardIdHtml = card.isBugCard
5143
+ ? `<div class="card-id cross-link" data-cross-target="bugs" data-cross-id="${card.id}">${BUG_SVG_ICON}${card.id}</div>`
5144
+ : `<div class="card-id cross-link" data-cross-target="spec" data-cross-id="${card.id}">${card.id}</div>`;
5145
+
4593
5146
  el.innerHTML = `
4594
- <div class="card-id cross-link" data-cross-target="spec" data-cross-id="${card.id}">${card.id}</div>
5147
+ ${cardIdHtml}
4595
5148
  <div class="card-header">
4596
- <div class="card-title" title="${escapeHtml(card.title)}">${escapeHtml(card.title)}</div>
5149
+ <div class="card-title" title="${escapeHtml(card.title)}">${card.isBugCard ? BUG_SVG_ICON : ''}${escapeHtml(card.title)}</div>
4597
5150
  <span class="priority-badge ${priorityClass}" aria-label="Priority ${card.priority}">${card.priority}</span>
4598
5151
  </div>
4599
5152
  <div class="progress-container">
@@ -4610,13 +5163,19 @@
4610
5163
  ${(card.tasks || []).length} tasks
4611
5164
  </button>
4612
5165
  <ul class="task-list collapsed" id="tasks-${card.id}" aria-label="Tasks for ${card.id}">
4613
- ${(card.tasks || []).map(t => `
4614
- <li class="task-item ${t.checked ? 'checked' : ''}">
5166
+ ${(card.tasks || []).map(t => {
5167
+ const isBugTask = t.isBugFix || (t.id && t.id.startsWith('T-B'));
5168
+ const taskIcon = isBugTask ? BUG_SVG_ICON : '';
5169
+ const bugTagLink = t.bugTag
5170
+ ? ` <span class="cross-link" data-cross-target="bugs" data-cross-id="${t.bugTag}" style="cursor:pointer;color:var(--color-accent);font-size:11px;">[${t.bugTag}]</span>`
5171
+ : '';
5172
+ return `
5173
+ <li class="task-item ${t.checked ? 'checked' : ''}" data-task-id="${t.id}">
4615
5174
  <span class="task-checkbox ${t.checked ? 'checked' : ''}" aria-hidden="true"></span>
4616
- <span class="task-id cross-link" data-cross-target="testify" data-cross-id="${t.id}">${t.id}</span>
5175
+ ${taskIcon}<span class="task-id cross-link" data-cross-target="testify" data-cross-id="${t.id}">${t.id}</span>${bugTagLink}
4617
5176
  <span class="task-description">${escapeHtml(t.description)}</span>
4618
- </li>
4619
- `).join('')}
5177
+ </li>`;
5178
+ }).join('')}
4620
5179
  </ul>`;
4621
5180
 
4622
5181
  return el;
@@ -5126,6 +5685,16 @@
5126
5685
  }
5127
5686
  break;
5128
5687
 
5688
+ case 'bugs_update':
5689
+ if (msg.feature === currentFeature && msg.bugs) {
5690
+ currentBugs = msg.bugs;
5691
+ if (currentPipeline) renderPipeline(currentPipeline);
5692
+ if (activeTab === 'bugs') {
5693
+ renderBugsContent(msg.bugs);
5694
+ }
5695
+ }
5696
+ break;
5697
+
5129
5698
  case 'features_update':
5130
5699
  if (msg.features) {
5131
5700
  updateFeatureSelector(msg.features);
package/src/server.js CHANGED
@@ -15,6 +15,7 @@ const { computePlanViewState } = require('./planview');
15
15
  const { computeChecklistViewState } = require('./checklist');
16
16
  const { computeTestifyState } = require('./testify');
17
17
  const { computeAnalyzeState } = require('./analyze');
18
+ const { computeBugsState } = require('./bugs');
18
19
 
19
20
  /**
20
21
  * List features from specs/ directory.
@@ -245,6 +246,20 @@ function createServer({ projectPath, port = 3000 }) {
245
246
  }
246
247
  });
247
248
 
249
+ // API: bugs state for a feature
250
+ app.get('/api/bugs/:feature', (req, res) => {
251
+ try {
252
+ const featureDir = path.join(projectPath, 'specs', req.params.feature);
253
+ if (!fs.existsSync(featureDir)) {
254
+ return res.status(404).json({ error: 'Feature not found' });
255
+ }
256
+ const bugs = computeBugsState(projectPath, req.params.feature);
257
+ res.json(bugs);
258
+ } catch (err) {
259
+ res.status(500).json({ error: err.message });
260
+ }
261
+ });
262
+
248
263
  // API: board state for a feature
249
264
  app.get('/api/board/:feature', (req, res) => {
250
265
  try {
@@ -358,6 +373,14 @@ function createServer({ projectPath, port = 3000 }) {
358
373
  analyze
359
374
  }));
360
375
  }
376
+ const bugs = computeBugsState(projectPath, ws.currentFeature);
377
+ if (bugs) {
378
+ ws.send(JSON.stringify({
379
+ type: 'bugs_update',
380
+ feature: ws.currentFeature,
381
+ bugs
382
+ }));
383
+ }
361
384
  } catch {
362
385
  // ignore errors during push
363
386
  }