@cluesmith/codev 1.4.1 → 1.4.2

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,544 @@
1
+ // Projects Tab - Parsing and Rendering (Spec 0045)
2
+
3
+ // Lifecycle stages in order
4
+ const LIFECYCLE_STAGES = ['conceived', 'specified', 'planned', 'implementing', 'implemented', 'committed', 'integrated'];
5
+
6
+ // Abbreviated column headers
7
+ const STAGE_HEADERS = {
8
+ 'conceived': "CONC'D",
9
+ 'specified': "SPEC'D",
10
+ 'planned': 'PLANNED',
11
+ 'implementing': 'IMPLING',
12
+ 'implemented': 'IMPLED',
13
+ 'committed': 'CMTD',
14
+ 'integrated': "INTGR'D"
15
+ };
16
+
17
+ // Stage tooltips
18
+ const STAGE_TOOLTIPS = {
19
+ 'conceived': "CONCEIVED: Idea has been captured.\nExit: Human approves the specification.",
20
+ 'specified': "SPECIFIED: Human approved the spec.\nExit: Architect creates an implementation plan.",
21
+ 'planned': "PLANNED: Implementation plan is ready.\nExit: Architect spawns a Builder.",
22
+ 'implementing': "IMPLEMENTING: Builder is working on the code.\nExit: Builder creates a PR.",
23
+ 'implemented': "IMPLEMENTED: PR is ready for review.\nExit: Builder merges after Architect review.",
24
+ 'committed': "COMMITTED: PR has been merged.\nExit: Human validates in production.",
25
+ 'integrated': "INTEGRATED: Validated in production.\nThis is the goal state."
26
+ };
27
+
28
+ // Parse a single project entry from YAML-like text
29
+ function parseProjectEntry(text) {
30
+ const project = {};
31
+ const lines = text.split('\n');
32
+
33
+ for (const line of lines) {
34
+ const match = line.match(/^\s*-?\s*(\w+):\s*(.*)$/);
35
+ if (!match) continue;
36
+
37
+ const [, key, rawValue] = match;
38
+ let value = rawValue.trim();
39
+ if ((value.startsWith('"') && value.endsWith('"')) ||
40
+ (value.startsWith("'") && value.endsWith("'"))) {
41
+ value = value.slice(1, -1);
42
+ }
43
+
44
+ if (key === 'files') {
45
+ project.files = {};
46
+ continue;
47
+ }
48
+ if (key === 'spec' || key === 'plan' || key === 'review') {
49
+ if (!project.files) project.files = {};
50
+ project.files[key] = value === 'null' ? null : value;
51
+ continue;
52
+ }
53
+
54
+ if (key === 'timestamps') {
55
+ project.timestamps = {};
56
+ continue;
57
+ }
58
+ const timestampFields = ['conceived_at', 'specified_at', 'planned_at',
59
+ 'implementing_at', 'implemented_at', 'committed_at', 'integrated_at'];
60
+ if (timestampFields.includes(key)) {
61
+ if (!project.timestamps) project.timestamps = {};
62
+ project.timestamps[key] = value === 'null' ? null : value;
63
+ continue;
64
+ }
65
+
66
+ if (key === 'dependencies' || key === 'tags' || key === 'ticks') {
67
+ if (value.startsWith('[') && value.endsWith(']')) {
68
+ const inner = value.slice(1, -1);
69
+ if (inner.trim() === '') {
70
+ project[key] = [];
71
+ } else {
72
+ project[key] = inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
73
+ }
74
+ } else {
75
+ project[key] = [];
76
+ }
77
+ continue;
78
+ }
79
+
80
+ if (value !== 'null') {
81
+ project[key] = value;
82
+ }
83
+ }
84
+
85
+ return project;
86
+ }
87
+
88
+ // Validate that a project entry is valid
89
+ function isValidProject(project) {
90
+ if (!project.id || project.id === 'NNNN' || !/^\d{4}$/.test(project.id)) {
91
+ return false;
92
+ }
93
+
94
+ const validStatuses = ['conceived', 'specified', 'planned', 'implementing',
95
+ 'implemented', 'committed', 'integrated', 'abandoned', 'on-hold'];
96
+ if (!project.status || !validStatuses.includes(project.status)) {
97
+ return false;
98
+ }
99
+
100
+ if (!project.title) {
101
+ return false;
102
+ }
103
+
104
+ if (project.tags && project.tags.includes('example')) {
105
+ return false;
106
+ }
107
+
108
+ return true;
109
+ }
110
+
111
+ // Parse projectlist.md content into array of projects
112
+ function parseProjectlist(content) {
113
+ const projects = [];
114
+
115
+ try {
116
+ const yamlBlockRegex = /```yaml\n([\s\S]*?)```/g;
117
+ let match;
118
+
119
+ while ((match = yamlBlockRegex.exec(content)) !== null) {
120
+ const block = match[1];
121
+ const projectMatches = block.split(/\n(?=\s*- id:)/);
122
+
123
+ for (const projectText of projectMatches) {
124
+ if (!projectText.trim() || !projectText.includes('id:')) continue;
125
+
126
+ const project = parseProjectEntry(projectText);
127
+ if (isValidProject(project)) {
128
+ projects.push(project);
129
+ }
130
+ }
131
+ }
132
+ } catch (err) {
133
+ console.error('Error parsing projectlist:', err);
134
+ return [];
135
+ }
136
+
137
+ return projects;
138
+ }
139
+
140
+ // Load projectlist.md from disk
141
+ async function loadProjectlist() {
142
+ try {
143
+ const response = await fetch('/file?path=codev/projectlist.md');
144
+
145
+ if (!response.ok) {
146
+ if (response.status === 404) {
147
+ projectsData = [];
148
+ projectlistError = null;
149
+ return;
150
+ }
151
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
152
+ }
153
+
154
+ const text = await response.text();
155
+ const newHash = hashString(text);
156
+
157
+ if (newHash !== projectlistHash) {
158
+ projectlistHash = newHash;
159
+ projectsData = parseProjectlist(text);
160
+ projectlistError = null;
161
+ }
162
+ } catch (err) {
163
+ console.error('Failed to load projectlist:', err);
164
+ projectlistError = 'Could not load projectlist.md: ' + err.message;
165
+ if (projectsData.length === 0) {
166
+ projectsData = [];
167
+ }
168
+ }
169
+ }
170
+
171
+ // Reload projectlist (manual refresh button)
172
+ async function reloadProjectlist() {
173
+ projectlistHash = null;
174
+ await loadProjectlist();
175
+ renderProjectsTabContent();
176
+ checkStarterMode();
177
+ }
178
+
179
+ // Poll projectlist for changes
180
+ async function pollProjectlist() {
181
+ if (activeTabId !== 'dashboard') return;
182
+
183
+ try {
184
+ const response = await fetch('/file?path=codev/projectlist.md');
185
+ if (!response.ok) return;
186
+
187
+ const text = await response.text();
188
+ const newHash = hashString(text);
189
+
190
+ if (newHash !== projectlistHash) {
191
+ clearTimeout(projectlistDebounce);
192
+ projectlistDebounce = setTimeout(async () => {
193
+ projectlistHash = newHash;
194
+ projectsData = parseProjectlist(text);
195
+ projectlistError = null;
196
+ renderProjectsTabContent();
197
+ checkStarterMode();
198
+ }, 500);
199
+ }
200
+ } catch (err) {
201
+ // Silently ignore polling errors
202
+ }
203
+ }
204
+
205
+ // Check if recently integrated
206
+ function isRecentlyIntegrated(project) {
207
+ if (project.status !== 'integrated') return false;
208
+ const integratedAt = project.timestamps?.integrated_at;
209
+ if (!integratedAt) return false;
210
+
211
+ const integratedDate = new Date(integratedAt);
212
+ if (isNaN(integratedDate.getTime())) return false;
213
+
214
+ const now = new Date();
215
+ const hoursDiff = (now - integratedDate) / (1000 * 60 * 60);
216
+ return hoursDiff <= 24;
217
+ }
218
+
219
+ // Get stage index
220
+ function getStageIndex(status) {
221
+ return LIFECYCLE_STAGES.indexOf(status);
222
+ }
223
+
224
+ // Get cell content for a stage
225
+ function getStageCellContent(project, stage) {
226
+ switch (stage) {
227
+ case 'specified':
228
+ if (project.files && project.files.spec) {
229
+ return { label: 'Spec', link: project.files.spec };
230
+ }
231
+ return { label: '', link: null };
232
+ case 'planned':
233
+ if (project.files && project.files.plan) {
234
+ return { label: 'Plan', link: project.files.plan };
235
+ }
236
+ return { label: '', link: null };
237
+ case 'implemented':
238
+ if (project.files && project.files.review) {
239
+ return { label: 'Revw', link: project.files.review };
240
+ }
241
+ return { label: '', link: null };
242
+ case 'committed':
243
+ if (project.notes) {
244
+ const prMatch = project.notes.match(/PR\s*#?(\d+)/i);
245
+ if (prMatch) {
246
+ return { label: 'PR', link: `https://github.com/cluesmith/codev/pull/${prMatch[1]}`, external: true };
247
+ }
248
+ }
249
+ return { label: '', link: null };
250
+ default:
251
+ return { label: '', link: null };
252
+ }
253
+ }
254
+
255
+ // Render a stage cell
256
+ function renderStageCell(project, stage) {
257
+ const currentIndex = getStageIndex(project.status);
258
+ const stageIndex = getStageIndex(stage);
259
+
260
+ let cellClass = 'stage-cell';
261
+ let content = '';
262
+ let ariaLabel = '';
263
+
264
+ if (stageIndex < currentIndex) {
265
+ ariaLabel = `${stage}: completed`;
266
+ const cellContent = getStageCellContent(project, stage);
267
+ if (cellContent.label && cellContent.link) {
268
+ if (cellContent.external) {
269
+ content = `<span class="checkmark">✓</span> <a href="${cellContent.link}" target="_blank" rel="noopener">${cellContent.label}</a>`;
270
+ } else {
271
+ content = `<span class="checkmark">✓</span> <a href="#" onclick="openProjectFile('${cellContent.link}'); return false;">${cellContent.label}</a>`;
272
+ }
273
+ } else {
274
+ content = '<span class="checkmark">✓</span>';
275
+ }
276
+ } else if (stageIndex === currentIndex) {
277
+ if (stage === 'integrated' && isRecentlyIntegrated(project)) {
278
+ ariaLabel = `${stage}: recently completed!`;
279
+ content = '<span class="celebration">🎉</span>';
280
+ } else {
281
+ ariaLabel = `${stage}: in progress`;
282
+ const cellContent = getStageCellContent(project, stage);
283
+ if (cellContent.label && cellContent.link) {
284
+ if (cellContent.external) {
285
+ content = `<span class="current-indicator"></span> <a href="${cellContent.link}" target="_blank" rel="noopener">${cellContent.label}</a>`;
286
+ } else {
287
+ content = `<span class="current-indicator"></span> <a href="#" onclick="openProjectFile('${cellContent.link}'); return false;">${cellContent.label}</a>`;
288
+ }
289
+ } else {
290
+ content = '<span class="current-indicator"></span>';
291
+ }
292
+ }
293
+ } else {
294
+ ariaLabel = `${stage}: pending`;
295
+ }
296
+
297
+ return `<td role="gridcell" class="${cellClass}" aria-label="${ariaLabel}">${content}</td>`;
298
+ }
299
+
300
+ // Open a project file in a new annotation tab
301
+ async function openProjectFile(path) {
302
+ try {
303
+ const response = await fetch('/api/tabs/file', {
304
+ method: 'POST',
305
+ headers: { 'Content-Type': 'application/json' },
306
+ body: JSON.stringify({ path })
307
+ });
308
+
309
+ if (!response.ok) {
310
+ throw new Error(await response.text());
311
+ }
312
+
313
+ await refresh();
314
+ showToast(`Opened ${path}`, 'success');
315
+ } catch (err) {
316
+ showToast('Failed to open file: ' + err.message, 'error');
317
+ }
318
+ }
319
+
320
+ // Render a single project row
321
+ function renderProjectRow(project) {
322
+ const isExpanded = expandedProjectId === project.id;
323
+
324
+ const row = `
325
+ <tr class="status-${project.status}"
326
+ role="row"
327
+ tabindex="0"
328
+ aria-expanded="${isExpanded}"
329
+ onkeydown="handleProjectRowKeydown(event, '${project.id}')">
330
+ <td role="gridcell">
331
+ <div class="project-cell clickable" onclick="toggleProjectDetails('${project.id}'); event.stopPropagation();">
332
+ <span class="project-id">${escapeProjectHtml(project.id)}</span>
333
+ <span class="project-title" title="${escapeProjectHtml(project.title)}">${escapeProjectHtml(project.title)}</span>
334
+ </div>
335
+ </td>
336
+ ${LIFECYCLE_STAGES.map(stage => renderStageCell(project, stage)).join('')}
337
+ </tr>
338
+ `;
339
+
340
+ if (isExpanded) {
341
+ return row + renderProjectDetailsRow(project);
342
+ }
343
+ return row;
344
+ }
345
+
346
+ // Render the details row when expanded
347
+ function renderProjectDetailsRow(project) {
348
+ const links = [];
349
+ if (project.files && project.files.review) {
350
+ links.push(`<a href="#" onclick="openProjectFile('${project.files.review}'); return false;">Review</a>`);
351
+ }
352
+
353
+ const dependencies = project.dependencies && project.dependencies.length > 0
354
+ ? `<div class="project-dependencies">Dependencies: ${project.dependencies.map(d => escapeProjectHtml(d)).join(', ')}</div>`
355
+ : '';
356
+
357
+ const ticks = project.ticks && project.ticks.length > 0
358
+ ? `<div class="project-ticks">TICKs: ${project.ticks.map(t => `<span class="tick-badge">TICK-${escapeProjectHtml(t)}</span>`).join(' ')}</div>`
359
+ : '';
360
+
361
+ return `
362
+ <tr class="project-details-row" role="row">
363
+ <td colspan="8">
364
+ <div class="project-details-content">
365
+ <h3>${escapeProjectHtml(project.title)}</h3>
366
+ ${project.summary ? `<p>${escapeProjectHtml(project.summary)}</p>` : ''}
367
+ ${project.notes ? `<p class="notes">${escapeProjectHtml(project.notes)}</p>` : ''}
368
+ ${ticks}
369
+ ${links.length > 0 ? `<div class="project-details-links">${links.join('')}</div>` : ''}
370
+ ${dependencies}
371
+ </div>
372
+ </td>
373
+ </tr>
374
+ `;
375
+ }
376
+
377
+ // Handle keyboard navigation on project rows
378
+ function handleProjectRowKeydown(event, projectId) {
379
+ if (event.key === 'Enter' || event.key === ' ') {
380
+ event.preventDefault();
381
+ toggleProjectDetails(projectId);
382
+ } else if (event.key === 'ArrowDown') {
383
+ event.preventDefault();
384
+ const currentRow = event.target.closest('tr');
385
+ let nextRow = currentRow.nextElementSibling;
386
+ while (nextRow && nextRow.classList.contains('project-details-row')) {
387
+ nextRow = nextRow.nextElementSibling;
388
+ }
389
+ if (nextRow) nextRow.focus();
390
+ } else if (event.key === 'ArrowUp') {
391
+ event.preventDefault();
392
+ const currentRow = event.target.closest('tr');
393
+ let prevRow = currentRow.previousElementSibling;
394
+ while (prevRow && prevRow.classList.contains('project-details-row')) {
395
+ prevRow = prevRow.previousElementSibling;
396
+ }
397
+ if (prevRow && prevRow.getAttribute('tabindex') === '0') prevRow.focus();
398
+ }
399
+ }
400
+
401
+ // Toggle project details expansion
402
+ function toggleProjectDetails(projectId) {
403
+ if (expandedProjectId === projectId) {
404
+ expandedProjectId = null;
405
+ } else {
406
+ expandedProjectId = projectId;
407
+ }
408
+ renderProjectsTabContent();
409
+ }
410
+
411
+ // Render a table for a list of projects
412
+ function renderProjectTable(projectList) {
413
+ if (projectList.length === 0) {
414
+ return '<p style="color: var(--text-muted); text-align: center; padding: 20px;">No projects</p>';
415
+ }
416
+
417
+ return `
418
+ <table class="kanban-grid" role="grid" aria-label="Project status grid">
419
+ <thead>
420
+ <tr role="row">
421
+ <th role="columnheader">Project</th>
422
+ ${LIFECYCLE_STAGES.map(stage => `<th role="columnheader" title="${STAGE_TOOLTIPS[stage]}">${STAGE_HEADERS[stage]}</th>`).join('')}
423
+ </tr>
424
+ </thead>
425
+ <tbody>
426
+ ${projectList.map(p => renderProjectRow(p)).join('')}
427
+ </tbody>
428
+ </table>
429
+ `;
430
+ }
431
+
432
+ // Render the Kanban grid with Active/Inactive sections
433
+ function renderKanbanGrid(projects) {
434
+ const activeStatuses = ['conceived', 'specified', 'planned', 'implementing', 'implemented', 'committed'];
435
+ const statusOrder = {
436
+ 'conceived': 0, 'specified': 1, 'planned': 2, 'implementing': 3,
437
+ 'implemented': 4, 'committed': 5, 'integrated': 6
438
+ };
439
+
440
+ const activeProjects = projects.filter(p =>
441
+ activeStatuses.includes(p.status) || isRecentlyIntegrated(p)
442
+ );
443
+
444
+ activeProjects.sort((a, b) => {
445
+ const orderA = statusOrder[a.status] || 0;
446
+ const orderB = statusOrder[b.status] || 0;
447
+ if (orderB !== orderA) return orderB - orderA;
448
+ return a.id.localeCompare(b.id);
449
+ });
450
+
451
+ const inactiveProjects = projects.filter(p =>
452
+ p.status === 'integrated' && !isRecentlyIntegrated(p)
453
+ );
454
+
455
+ let html = '';
456
+
457
+ if (activeProjects.length > 0 || inactiveProjects.length === 0) {
458
+ html += `
459
+ <details class="project-section" open>
460
+ <summary>Active <span class="section-count">(${activeProjects.length})</span></summary>
461
+ ${renderProjectTable(activeProjects)}
462
+ </details>
463
+ `;
464
+ }
465
+
466
+ if (inactiveProjects.length > 0) {
467
+ html += `
468
+ <details class="project-section">
469
+ <summary>Completed <span class="section-count">(${inactiveProjects.length})</span></summary>
470
+ ${renderProjectTable(inactiveProjects)}
471
+ </details>
472
+ `;
473
+ }
474
+
475
+ return html;
476
+ }
477
+
478
+ // Render the terminal projects section
479
+ function renderTerminalProjects(projects) {
480
+ const terminal = projects.filter(p => ['abandoned', 'on-hold'].includes(p.status));
481
+ if (terminal.length === 0) return '';
482
+
483
+ const items = terminal.map(p => {
484
+ const className = p.status === 'abandoned' ? 'project-abandoned' : 'project-on-hold';
485
+ const statusText = p.status === 'on-hold' ? ' (on-hold)' : '';
486
+ return `
487
+ <li>
488
+ <span class="${className}">
489
+ <span class="project-id">${escapeProjectHtml(p.id)}</span>
490
+ ${escapeProjectHtml(p.title)}${statusText}
491
+ </span>
492
+ </li>
493
+ `;
494
+ }).join('');
495
+
496
+ return `
497
+ <details class="terminal-projects">
498
+ <summary>Terminal Projects (${terminal.length})</summary>
499
+ <ul>${items}</ul>
500
+ </details>
501
+ `;
502
+ }
503
+
504
+ // Render error banner
505
+ function renderErrorBanner(message) {
506
+ return `
507
+ <div class="projects-error">
508
+ <span class="projects-error-message">${escapeProjectHtml(message)}</span>
509
+ <button onclick="reloadProjectlist()">Retry</button>
510
+ </div>
511
+ `;
512
+ }
513
+
514
+ // Render the projects section for dashboard
515
+ function renderDashboardProjectsSection() {
516
+ if (projectlistError) {
517
+ return renderErrorBanner(projectlistError);
518
+ }
519
+
520
+ if (projectsData.length === 0) {
521
+ return `
522
+ <div class="dashboard-empty-state" style="padding: 24px;">
523
+ No projects yet. Ask the Architect to create your first project.
524
+ </div>
525
+ `;
526
+ }
527
+
528
+ return `
529
+ ${renderKanbanGrid(projectsData)}
530
+ ${renderTerminalProjects(projectsData)}
531
+ `;
532
+ }
533
+
534
+ // Legacy function for backward compatibility
535
+ function renderProjectsTabContent() {
536
+ if (activeTabId === 'dashboard') {
537
+ renderDashboardTabContent();
538
+ }
539
+ }
540
+
541
+ // Legacy function alias
542
+ async function renderProjectsTab() {
543
+ await renderDashboardTab();
544
+ }
@@ -0,0 +1,91 @@
1
+ // Dashboard State Management
2
+
3
+ // STATE_INJECTION_POINT - server injects window.INITIAL_STATE here
4
+
5
+ // Global state
6
+ const state = window.INITIAL_STATE || {
7
+ architect: null,
8
+ builders: [],
9
+ utils: [],
10
+ annotations: []
11
+ };
12
+
13
+ // Tab state
14
+ let tabs = [];
15
+ let activeTabId = null;
16
+ let pendingCloseTabId = null;
17
+ let contextMenuTabId = null;
18
+
19
+ // Track known tab IDs to detect new tabs
20
+ let knownTabIds = new Set();
21
+
22
+ // Projects tab state
23
+ let projectsData = [];
24
+ let projectlistHash = null;
25
+ let expandedProjectId = null;
26
+ let projectlistError = null;
27
+ let projectlistDebounce = null;
28
+
29
+ // Files tab state (Spec 0055)
30
+ let filesTreeData = [];
31
+ let filesTreeExpanded = new Set(); // Set of expanded folder paths
32
+ let filesTreeError = null;
33
+ let filesTreeLoaded = false;
34
+
35
+ // File search state (Spec 0058)
36
+ let filesTreeFlat = []; // Flattened array of {name, path} objects for searching
37
+ let filesSearchQuery = '';
38
+ let filesSearchResults = [];
39
+ let filesSearchIndex = 0;
40
+ let filesSearchDebounceTimer = null;
41
+
42
+ // Cmd+P palette state (Spec 0058)
43
+ let paletteOpen = false;
44
+ let paletteQuery = '';
45
+ let paletteResults = [];
46
+ let paletteIndex = 0;
47
+ let paletteDebounceTimer = null;
48
+
49
+ // Activity state (Spec 0059)
50
+ let activityData = null;
51
+
52
+ // Collapsible section state (persisted to localStorage)
53
+ const SECTION_STATE_KEY = 'codev-dashboard-sections';
54
+ let sectionState = loadSectionState();
55
+
56
+ function loadSectionState() {
57
+ try {
58
+ const saved = localStorage.getItem(SECTION_STATE_KEY);
59
+ if (saved) return JSON.parse(saved);
60
+ } catch (e) { /* ignore */ }
61
+ return { tabs: true, files: true, projects: true };
62
+ }
63
+
64
+ function saveSectionState() {
65
+ try {
66
+ localStorage.setItem(SECTION_STATE_KEY, JSON.stringify(sectionState));
67
+ } catch (e) { /* ignore */ }
68
+ }
69
+
70
+ function toggleSection(section) {
71
+ sectionState[section] = !sectionState[section];
72
+ saveSectionState();
73
+ renderDashboardTabContent();
74
+ }
75
+
76
+ // Track current architect port to avoid re-rendering iframe unnecessarily
77
+ let currentArchitectPort = null;
78
+
79
+ // Track current tab content to avoid re-rendering iframe unnecessarily
80
+ let currentTabPort = null;
81
+ let currentTabType = null;
82
+
83
+ // Polling state
84
+ let pollInterval = null;
85
+ let starterModePollingInterval = null;
86
+
87
+ // Hot reload state (Spec 0060)
88
+ // Hot reload only activates in dev mode (localhost or ?dev=1 query param)
89
+ let hotReloadEnabled = true;
90
+ let hotReloadInterval = null;
91
+ let hotReloadMtimes = {}; // Track file modification times