@cluesmith/codev 1.4.1 → 1.4.3
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/dist/agent-farm/servers/dashboard-server.js +487 -9
- package/dist/agent-farm/servers/dashboard-server.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +141 -40
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/utils/port-registry.d.ts.map +1 -1
- package/dist/agent-farm/utils/port-registry.js +19 -5
- package/dist/agent-farm/utils/port-registry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt.d.ts.map +1 -1
- package/dist/commands/adopt.js +10 -0
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/consult/index.d.ts +1 -0
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +56 -8
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +8 -0
- package/dist/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/skeleton/resources/commands/consult.md +50 -0
- package/skeleton/templates/projectlist-archive.md +21 -0
- package/skeleton/templates/projectlist.md +17 -0
- package/templates/dashboard/css/activity.css +151 -0
- package/templates/dashboard/css/dialogs.css +149 -0
- package/templates/dashboard/css/files.css +530 -0
- package/templates/dashboard/css/layout.css +124 -0
- package/templates/dashboard/css/projects.css +501 -0
- package/templates/dashboard/css/statusbar.css +23 -0
- package/templates/dashboard/css/tabs.css +314 -0
- package/templates/dashboard/css/utilities.css +50 -0
- package/templates/dashboard/css/variables.css +45 -0
- package/templates/dashboard/index.html +158 -0
- package/templates/dashboard/js/activity.js +238 -0
- package/templates/dashboard/js/dialogs.js +328 -0
- package/templates/dashboard/js/files.js +436 -0
- package/templates/dashboard/js/main.js +487 -0
- package/templates/dashboard/js/projects.js +544 -0
- package/templates/dashboard/js/state.js +91 -0
- package/templates/dashboard/js/tabs.js +500 -0
- package/templates/dashboard/js/utils.js +57 -0
- package/templates/tower.html +172 -4
- package/dist/commands/eject.d.ts +0 -18
- package/dist/commands/eject.d.ts.map +0 -1
- package/dist/commands/eject.js +0 -149
- package/dist/commands/eject.js.map +0 -1
- package/templates/dashboard-split.html +0 -3721
- package/templates/dashboard.html +0 -149
|
@@ -7,9 +7,11 @@ import http from 'node:http';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import net from 'node:net';
|
|
10
|
-
import { spawn, execSync } from 'node:child_process';
|
|
10
|
+
import { spawn, execSync, exec } from 'node:child_process';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
11
12
|
import { randomUUID } from 'node:crypto';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
14
|
+
const execAsync = promisify(exec);
|
|
13
15
|
import { Command } from 'commander';
|
|
14
16
|
import { loadState, getAnnotations, addAnnotation, removeAnnotation, getUtils, tryAddUtil, removeUtil, getBuilder, getBuilders, removeBuilder, upsertBuilder, clearState, } from '../state.js';
|
|
15
17
|
import { spawnTtyd } from '../utils/shell.js';
|
|
@@ -89,8 +91,8 @@ function findTemplatePath(filename, required = false) {
|
|
|
89
91
|
return null;
|
|
90
92
|
}
|
|
91
93
|
const projectRoot = findProjectRoot();
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
+
// Use modular dashboard template (Spec 0060)
|
|
95
|
+
const templatePath = findTemplatePath('dashboard/index.html', true);
|
|
94
96
|
// Clean up dead processes from state (called on state load)
|
|
95
97
|
function cleanupDeadProcesses() {
|
|
96
98
|
// Clean up dead shell processes
|
|
@@ -351,13 +353,14 @@ function spawnWorktreeBuilder(builderPort, state) {
|
|
|
351
353
|
// Create git branch and worktree
|
|
352
354
|
execSync(`git branch "${branchName}" HEAD`, { cwd: projectRoot, stdio: 'ignore' });
|
|
353
355
|
execSync(`git worktree add "${worktreePath}" "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
|
|
354
|
-
// Get builder command from config or use default
|
|
356
|
+
// Get builder command from config or use default shell
|
|
355
357
|
const configPath = path.resolve(projectRoot, 'codev', 'config.json');
|
|
356
|
-
|
|
358
|
+
const defaultShell = process.env.SHELL || 'bash';
|
|
359
|
+
let builderCommand = defaultShell;
|
|
357
360
|
if (fs.existsSync(configPath)) {
|
|
358
361
|
try {
|
|
359
362
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
360
|
-
builderCommand = config?.shell?.builder ||
|
|
363
|
+
builderCommand = config?.shell?.builder || defaultShell;
|
|
361
364
|
}
|
|
362
365
|
catch {
|
|
363
366
|
// Use default
|
|
@@ -500,8 +503,393 @@ function getOpenServerPath() {
|
|
|
500
503
|
}
|
|
501
504
|
return { script: jsPath, useTsx: false };
|
|
502
505
|
}
|
|
503
|
-
|
|
504
|
-
|
|
506
|
+
/**
|
|
507
|
+
* Escape a string for safe use in shell commands
|
|
508
|
+
* Handles special characters that could cause command injection
|
|
509
|
+
*/
|
|
510
|
+
function escapeShellArg(str) {
|
|
511
|
+
// Single-quote the string and escape any single quotes within it
|
|
512
|
+
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Get today's git commits from all branches for the current user
|
|
516
|
+
*/
|
|
517
|
+
async function getGitCommits(projectRoot) {
|
|
518
|
+
try {
|
|
519
|
+
const { stdout: authorRaw } = await execAsync('git config user.name', { cwd: projectRoot });
|
|
520
|
+
const author = authorRaw.trim();
|
|
521
|
+
if (!author)
|
|
522
|
+
return [];
|
|
523
|
+
// Escape author name to prevent command injection
|
|
524
|
+
const safeAuthor = escapeShellArg(author);
|
|
525
|
+
// Get commits from all branches since midnight
|
|
526
|
+
const { stdout: output } = await execAsync(`git log --all --since="midnight" --author=${safeAuthor} --format="%H|%s|%aI|%D"`, { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
527
|
+
if (!output.trim())
|
|
528
|
+
return [];
|
|
529
|
+
return output.trim().split('\n').filter(Boolean).map(line => {
|
|
530
|
+
const parts = line.split('|');
|
|
531
|
+
const hash = parts[0] || '';
|
|
532
|
+
const message = parts[1] || '';
|
|
533
|
+
const time = parts[2] || '';
|
|
534
|
+
const refs = parts.slice(3).join('|'); // refs might contain |
|
|
535
|
+
// Extract branch name from refs
|
|
536
|
+
let branch = 'unknown';
|
|
537
|
+
const headMatch = refs.match(/HEAD -> ([^,]+)/);
|
|
538
|
+
const branchMatch = refs.match(/([^,\s]+)$/);
|
|
539
|
+
if (headMatch) {
|
|
540
|
+
branch = headMatch[1];
|
|
541
|
+
}
|
|
542
|
+
else if (branchMatch && branchMatch[1]) {
|
|
543
|
+
branch = branchMatch[1];
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
hash: hash.slice(0, 7),
|
|
547
|
+
message: message.slice(0, 100), // Truncate long messages
|
|
548
|
+
time,
|
|
549
|
+
branch,
|
|
550
|
+
};
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
console.error('Error getting git commits:', err.message);
|
|
555
|
+
return [];
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Get unique files modified today
|
|
560
|
+
*/
|
|
561
|
+
async function getModifiedFiles(projectRoot) {
|
|
562
|
+
try {
|
|
563
|
+
const { stdout: authorRaw } = await execAsync('git config user.name', { cwd: projectRoot });
|
|
564
|
+
const author = authorRaw.trim();
|
|
565
|
+
if (!author)
|
|
566
|
+
return [];
|
|
567
|
+
// Escape author name to prevent command injection
|
|
568
|
+
const safeAuthor = escapeShellArg(author);
|
|
569
|
+
const { stdout: output } = await execAsync(`git log --all --since="midnight" --author=${safeAuthor} --name-only --format=""`, { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
570
|
+
if (!output.trim())
|
|
571
|
+
return [];
|
|
572
|
+
const files = [...new Set(output.trim().split('\n').filter(Boolean))];
|
|
573
|
+
return files.sort();
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
console.error('Error getting modified files:', err.message);
|
|
577
|
+
return [];
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Get GitHub PRs created or merged today via gh CLI
|
|
582
|
+
* Combines PRs created today AND PRs merged today (which may have been created earlier)
|
|
583
|
+
*/
|
|
584
|
+
async function getGitHubPRs(projectRoot) {
|
|
585
|
+
try {
|
|
586
|
+
// Use local time for the date (spec says "today" means local machine time)
|
|
587
|
+
const now = new Date();
|
|
588
|
+
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
589
|
+
// Fetch PRs created today AND PRs merged today in parallel
|
|
590
|
+
const [createdResult, mergedResult] = await Promise.allSettled([
|
|
591
|
+
execAsync(`gh pr list --author "@me" --state all --search "created:>=${today}" --json number,title,state,url`, { cwd: projectRoot, timeout: 15000 }),
|
|
592
|
+
execAsync(`gh pr list --author "@me" --state merged --search "merged:>=${today}" --json number,title,state,url`, { cwd: projectRoot, timeout: 15000 }),
|
|
593
|
+
]);
|
|
594
|
+
const prsMap = new Map();
|
|
595
|
+
// Process PRs created today
|
|
596
|
+
if (createdResult.status === 'fulfilled' && createdResult.value.stdout.trim()) {
|
|
597
|
+
const prs = JSON.parse(createdResult.value.stdout);
|
|
598
|
+
for (const pr of prs) {
|
|
599
|
+
prsMap.set(pr.number, {
|
|
600
|
+
number: pr.number,
|
|
601
|
+
title: pr.title.slice(0, 100),
|
|
602
|
+
state: pr.state,
|
|
603
|
+
url: pr.url,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// Process PRs merged today (may overlap with created, deduped by Map)
|
|
608
|
+
if (mergedResult.status === 'fulfilled' && mergedResult.value.stdout.trim()) {
|
|
609
|
+
const prs = JSON.parse(mergedResult.value.stdout);
|
|
610
|
+
for (const pr of prs) {
|
|
611
|
+
prsMap.set(pr.number, {
|
|
612
|
+
number: pr.number,
|
|
613
|
+
title: pr.title.slice(0, 100),
|
|
614
|
+
state: pr.state,
|
|
615
|
+
url: pr.url,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return Array.from(prsMap.values());
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
// gh CLI might not be available or authenticated
|
|
623
|
+
console.error('Error getting GitHub PRs:', err.message);
|
|
624
|
+
return [];
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Get builder activity from state.db for today
|
|
629
|
+
* Note: state.json doesn't track timestamps, so we can only report current builders
|
|
630
|
+
* without duration. They'll be counted as activity points, not time intervals.
|
|
631
|
+
*/
|
|
632
|
+
function getBuilderActivity() {
|
|
633
|
+
try {
|
|
634
|
+
const builders = getBuilders();
|
|
635
|
+
// Return current builders without time tracking (state.json lacks timestamps)
|
|
636
|
+
// Time tracking will rely primarily on git commits
|
|
637
|
+
return builders.map(b => ({
|
|
638
|
+
id: b.id,
|
|
639
|
+
status: b.status || 'unknown',
|
|
640
|
+
startTime: '', // Unknown - not tracked in state.json
|
|
641
|
+
endTime: undefined,
|
|
642
|
+
}));
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
console.error('Error getting builder activity:', err.message);
|
|
646
|
+
return [];
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Detect project status changes in projectlist.md today
|
|
651
|
+
* Handles YAML format inside Markdown fenced code blocks
|
|
652
|
+
*/
|
|
653
|
+
async function getProjectChanges(projectRoot) {
|
|
654
|
+
try {
|
|
655
|
+
const projectlistPath = path.join(projectRoot, 'codev/projectlist.md');
|
|
656
|
+
if (!fs.existsSync(projectlistPath))
|
|
657
|
+
return [];
|
|
658
|
+
// Get the first commit hash from today that touched projectlist.md
|
|
659
|
+
const { stdout: firstCommitOutput } = await execAsync(`git log --since="midnight" --format=%H -- codev/projectlist.md | tail -1`, { cwd: projectRoot });
|
|
660
|
+
if (!firstCommitOutput.trim())
|
|
661
|
+
return [];
|
|
662
|
+
// Get diff of projectlist.md from that commit's parent to HEAD
|
|
663
|
+
let diff;
|
|
664
|
+
try {
|
|
665
|
+
const { stdout } = await execAsync(`git diff ${firstCommitOutput.trim()}^..HEAD -- codev/projectlist.md`, { cwd: projectRoot, maxBuffer: 1024 * 1024 });
|
|
666
|
+
diff = stdout;
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
return [];
|
|
670
|
+
}
|
|
671
|
+
if (!diff.trim())
|
|
672
|
+
return [];
|
|
673
|
+
// Parse status changes from diff
|
|
674
|
+
// Format is YAML inside Markdown code blocks:
|
|
675
|
+
// - id: "0058"
|
|
676
|
+
// title: "File Search Autocomplete"
|
|
677
|
+
// status: implementing
|
|
678
|
+
const changes = [];
|
|
679
|
+
const lines = diff.split('\n');
|
|
680
|
+
let currentId = '';
|
|
681
|
+
let currentTitle = '';
|
|
682
|
+
let oldStatus = '';
|
|
683
|
+
let newStatus = '';
|
|
684
|
+
for (const line of lines) {
|
|
685
|
+
// Track current project context from YAML id field
|
|
686
|
+
// Match lines like: " - id: \"0058\"" or "+ - id: \"0058\""
|
|
687
|
+
const idMatch = line.match(/^[+-]?\s*-\s*id:\s*["']?(\d{4})["']?/);
|
|
688
|
+
if (idMatch) {
|
|
689
|
+
// If we have a pending status change from previous project, emit it
|
|
690
|
+
if (oldStatus && newStatus && currentId) {
|
|
691
|
+
changes.push({
|
|
692
|
+
id: currentId,
|
|
693
|
+
title: currentTitle,
|
|
694
|
+
oldStatus,
|
|
695
|
+
newStatus,
|
|
696
|
+
});
|
|
697
|
+
oldStatus = '';
|
|
698
|
+
newStatus = '';
|
|
699
|
+
}
|
|
700
|
+
currentId = idMatch[1];
|
|
701
|
+
currentTitle = ''; // Will be filled by title line
|
|
702
|
+
}
|
|
703
|
+
// Track title (comes after id in YAML)
|
|
704
|
+
// Match lines like: " title: \"File Search Autocomplete\""
|
|
705
|
+
const titleMatch = line.match(/^[+-]?\s*title:\s*["']?([^"']+)["']?/);
|
|
706
|
+
if (titleMatch && currentId) {
|
|
707
|
+
currentTitle = titleMatch[1].trim();
|
|
708
|
+
}
|
|
709
|
+
// Track status changes
|
|
710
|
+
// Match lines like: "- status: implementing" or "+ status: implemented"
|
|
711
|
+
const statusMatch = line.match(/^([+-])\s*status:\s*(\w+)/);
|
|
712
|
+
if (statusMatch) {
|
|
713
|
+
const [, modifier, status] = statusMatch;
|
|
714
|
+
if (modifier === '-') {
|
|
715
|
+
oldStatus = status;
|
|
716
|
+
}
|
|
717
|
+
else if (modifier === '+') {
|
|
718
|
+
newStatus = status;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// Emit final pending change if exists
|
|
723
|
+
if (oldStatus && newStatus && currentId) {
|
|
724
|
+
changes.push({
|
|
725
|
+
id: currentId,
|
|
726
|
+
title: currentTitle,
|
|
727
|
+
oldStatus,
|
|
728
|
+
newStatus,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
return changes;
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
console.error('Error getting project changes:', err.message);
|
|
735
|
+
return [];
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Merge overlapping time intervals
|
|
740
|
+
*/
|
|
741
|
+
function mergeIntervals(intervals) {
|
|
742
|
+
if (intervals.length === 0)
|
|
743
|
+
return [];
|
|
744
|
+
// Sort by start time
|
|
745
|
+
const sorted = [...intervals].sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
746
|
+
const merged = [{ ...sorted[0] }];
|
|
747
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
748
|
+
const last = merged[merged.length - 1];
|
|
749
|
+
const current = sorted[i];
|
|
750
|
+
// If overlapping or within 2 hours, merge
|
|
751
|
+
const gapMs = current.start.getTime() - last.end.getTime();
|
|
752
|
+
const twoHoursMs = 2 * 60 * 60 * 1000;
|
|
753
|
+
if (gapMs <= twoHoursMs) {
|
|
754
|
+
last.end = new Date(Math.max(last.end.getTime(), current.end.getTime()));
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
merged.push({ ...current });
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return merged;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Calculate active time from commits and builder activity
|
|
764
|
+
*/
|
|
765
|
+
function calculateTimeTracking(commits, builders) {
|
|
766
|
+
const intervals = [];
|
|
767
|
+
const fiveMinutesMs = 5 * 60 * 1000;
|
|
768
|
+
// Add commit timestamps (treat each as 5-minute interval)
|
|
769
|
+
for (const commit of commits) {
|
|
770
|
+
if (commit.time) {
|
|
771
|
+
const time = new Date(commit.time);
|
|
772
|
+
if (!isNaN(time.getTime())) {
|
|
773
|
+
intervals.push({
|
|
774
|
+
start: time,
|
|
775
|
+
end: new Date(time.getTime() + fiveMinutesMs),
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
// Add builder sessions
|
|
781
|
+
for (const builder of builders) {
|
|
782
|
+
if (builder.startTime) {
|
|
783
|
+
const start = new Date(builder.startTime);
|
|
784
|
+
const end = builder.endTime ? new Date(builder.endTime) : new Date();
|
|
785
|
+
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
|
|
786
|
+
intervals.push({ start, end });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (intervals.length === 0) {
|
|
791
|
+
return {
|
|
792
|
+
activeMinutes: 0,
|
|
793
|
+
firstActivity: '',
|
|
794
|
+
lastActivity: '',
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
const merged = mergeIntervals(intervals);
|
|
798
|
+
const totalMinutes = merged.reduce((sum, interval) => sum + (interval.end.getTime() - interval.start.getTime()) / (1000 * 60), 0);
|
|
799
|
+
return {
|
|
800
|
+
activeMinutes: Math.round(totalMinutes),
|
|
801
|
+
firstActivity: merged[0].start.toISOString(),
|
|
802
|
+
lastActivity: merged[merged.length - 1].end.toISOString(),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Find the consult CLI path
|
|
807
|
+
* Returns the path to the consult binary, checking multiple locations
|
|
808
|
+
*/
|
|
809
|
+
function findConsultPath() {
|
|
810
|
+
// When running from dist/, check relative paths
|
|
811
|
+
// dist/agent-farm/servers/ -> ../../../bin/consult.js
|
|
812
|
+
const distPath = path.join(__dirname, '../../../bin/consult.js');
|
|
813
|
+
if (fs.existsSync(distPath)) {
|
|
814
|
+
return distPath;
|
|
815
|
+
}
|
|
816
|
+
// When running from src/ with tsx, check src-relative paths
|
|
817
|
+
// src/agent-farm/servers/ -> ../../../bin/consult.js (won't exist, it's .ts in src)
|
|
818
|
+
// But bin/ is at packages/codev/bin/consult.js, so it should still work
|
|
819
|
+
// Fall back to npx consult (works if @cluesmith/codev is installed)
|
|
820
|
+
return 'npx consult';
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Generate AI summary via consult CLI
|
|
824
|
+
*/
|
|
825
|
+
async function generateAISummary(data) {
|
|
826
|
+
// Build prompt with commit messages and file names only (security: no full diffs)
|
|
827
|
+
const hours = Math.floor(data.timeTracking.activeMinutes / 60);
|
|
828
|
+
const mins = data.timeTracking.activeMinutes % 60;
|
|
829
|
+
const prompt = `Summarize this developer's activity today for a standup report.
|
|
830
|
+
|
|
831
|
+
Commits (${data.commits.length}):
|
|
832
|
+
${data.commits.slice(0, 20).map(c => `- ${c.message}`).join('\n') || '(none)'}
|
|
833
|
+
${data.commits.length > 20 ? `... and ${data.commits.length - 20} more` : ''}
|
|
834
|
+
|
|
835
|
+
PRs: ${data.prs.map(p => `#${p.number} ${p.title} (${p.state})`).join(', ') || 'None'}
|
|
836
|
+
|
|
837
|
+
Files modified: ${data.files.length} files
|
|
838
|
+
${data.files.slice(0, 10).join(', ')}${data.files.length > 10 ? ` ... and ${data.files.length - 10} more` : ''}
|
|
839
|
+
|
|
840
|
+
Project status changes:
|
|
841
|
+
${data.projectChanges.map(p => `- ${p.id} ${p.title}: ${p.oldStatus} → ${p.newStatus}`).join('\n') || '(none)'}
|
|
842
|
+
|
|
843
|
+
Active time: ~${hours}h ${mins}m
|
|
844
|
+
|
|
845
|
+
Write a brief, professional summary (2-3 sentences) focusing on accomplishments. Be concise and suitable for a standup or status report.`;
|
|
846
|
+
try {
|
|
847
|
+
// Use consult CLI to generate summary
|
|
848
|
+
const consultCmd = findConsultPath();
|
|
849
|
+
const safePrompt = escapeShellArg(prompt);
|
|
850
|
+
// Use async exec with timeout
|
|
851
|
+
const { stdout } = await execAsync(`${consultCmd} --model gemini general ${safePrompt}`, { timeout: 60000, maxBuffer: 1024 * 1024 });
|
|
852
|
+
return stdout.trim();
|
|
853
|
+
}
|
|
854
|
+
catch (err) {
|
|
855
|
+
console.error('AI summary generation failed:', err.message);
|
|
856
|
+
return '';
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Collect all activity data for today
|
|
861
|
+
*/
|
|
862
|
+
async function collectActivitySummary(projectRoot) {
|
|
863
|
+
// Collect data from all sources in parallel - these are now truly async
|
|
864
|
+
const [commits, files, prs, builders, projectChanges] = await Promise.all([
|
|
865
|
+
getGitCommits(projectRoot),
|
|
866
|
+
getModifiedFiles(projectRoot),
|
|
867
|
+
getGitHubPRs(projectRoot),
|
|
868
|
+
Promise.resolve(getBuilderActivity()), // This one is sync (reads from state)
|
|
869
|
+
getProjectChanges(projectRoot),
|
|
870
|
+
]);
|
|
871
|
+
const timeTracking = calculateTimeTracking(commits, builders);
|
|
872
|
+
// Generate AI summary (skip if no activity)
|
|
873
|
+
let aiSummary = '';
|
|
874
|
+
if (commits.length > 0 || prs.length > 0) {
|
|
875
|
+
aiSummary = await generateAISummary({
|
|
876
|
+
commits,
|
|
877
|
+
prs,
|
|
878
|
+
files,
|
|
879
|
+
timeTracking,
|
|
880
|
+
projectChanges,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
return {
|
|
884
|
+
commits,
|
|
885
|
+
prs,
|
|
886
|
+
builders,
|
|
887
|
+
projectChanges,
|
|
888
|
+
files,
|
|
889
|
+
timeTracking,
|
|
890
|
+
aiSummary: aiSummary || undefined,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
505
893
|
// Security: Validate request origin
|
|
506
894
|
function isRequestAllowed(req) {
|
|
507
895
|
const host = req.headers.host;
|
|
@@ -1144,10 +1532,100 @@ const server = http.createServer(async (req, res) => {
|
|
|
1144
1532
|
res.end(JSON.stringify(tree));
|
|
1145
1533
|
return;
|
|
1146
1534
|
}
|
|
1535
|
+
// API: Get daily activity summary (Spec 0059)
|
|
1536
|
+
if (req.method === 'GET' && url.pathname === '/api/activity-summary') {
|
|
1537
|
+
try {
|
|
1538
|
+
const activitySummary = await collectActivitySummary(projectRoot);
|
|
1539
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1540
|
+
res.end(JSON.stringify(activitySummary));
|
|
1541
|
+
}
|
|
1542
|
+
catch (err) {
|
|
1543
|
+
console.error('Activity summary error:', err);
|
|
1544
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1545
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1546
|
+
}
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
// API: Hot reload check (Spec 0060)
|
|
1550
|
+
// Returns modification times for all dashboard CSS/JS files
|
|
1551
|
+
if (req.method === 'GET' && url.pathname === '/api/hot-reload') {
|
|
1552
|
+
try {
|
|
1553
|
+
const dashboardDir = path.join(__dirname, '../../../templates/dashboard');
|
|
1554
|
+
const cssDir = path.join(dashboardDir, 'css');
|
|
1555
|
+
const jsDir = path.join(dashboardDir, 'js');
|
|
1556
|
+
const mtimes = {};
|
|
1557
|
+
// Collect CSS file modification times
|
|
1558
|
+
if (fs.existsSync(cssDir)) {
|
|
1559
|
+
for (const file of fs.readdirSync(cssDir)) {
|
|
1560
|
+
if (file.endsWith('.css')) {
|
|
1561
|
+
const stat = fs.statSync(path.join(cssDir, file));
|
|
1562
|
+
mtimes[`css/${file}`] = stat.mtimeMs;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
// Collect JS file modification times
|
|
1567
|
+
if (fs.existsSync(jsDir)) {
|
|
1568
|
+
for (const file of fs.readdirSync(jsDir)) {
|
|
1569
|
+
if (file.endsWith('.js')) {
|
|
1570
|
+
const stat = fs.statSync(path.join(jsDir, file));
|
|
1571
|
+
mtimes[`js/${file}`] = stat.mtimeMs;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1576
|
+
res.end(JSON.stringify({ mtimes }));
|
|
1577
|
+
}
|
|
1578
|
+
catch (err) {
|
|
1579
|
+
console.error('Hot reload check error:', err);
|
|
1580
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1581
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1582
|
+
}
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
// Serve dashboard CSS files
|
|
1586
|
+
if (req.method === 'GET' && url.pathname.startsWith('/dashboard/css/')) {
|
|
1587
|
+
const filename = url.pathname.replace('/dashboard/css/', '');
|
|
1588
|
+
// Validate filename to prevent path traversal
|
|
1589
|
+
if (!filename || filename.includes('..') || filename.includes('/') || !filename.endsWith('.css')) {
|
|
1590
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1591
|
+
res.end('Invalid filename');
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
const cssPath = path.join(__dirname, '../../../templates/dashboard/css', filename);
|
|
1595
|
+
if (fs.existsSync(cssPath)) {
|
|
1596
|
+
const content = fs.readFileSync(cssPath, 'utf-8');
|
|
1597
|
+
res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' });
|
|
1598
|
+
res.end(content);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1602
|
+
res.end('CSS file not found');
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
// Serve dashboard JS files
|
|
1606
|
+
if (req.method === 'GET' && url.pathname.startsWith('/dashboard/js/')) {
|
|
1607
|
+
const filename = url.pathname.replace('/dashboard/js/', '');
|
|
1608
|
+
// Validate filename to prevent path traversal
|
|
1609
|
+
if (!filename || filename.includes('..') || filename.includes('/') || !filename.endsWith('.js')) {
|
|
1610
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1611
|
+
res.end('Invalid filename');
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
const jsPath = path.join(__dirname, '../../../templates/dashboard/js', filename);
|
|
1615
|
+
if (fs.existsSync(jsPath)) {
|
|
1616
|
+
const content = fs.readFileSync(jsPath, 'utf-8');
|
|
1617
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
|
1618
|
+
res.end(content);
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1622
|
+
res.end('JS file not found');
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1147
1625
|
// Serve dashboard
|
|
1148
1626
|
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
1149
1627
|
try {
|
|
1150
|
-
let template = fs.readFileSync(
|
|
1628
|
+
let template = fs.readFileSync(templatePath, 'utf-8');
|
|
1151
1629
|
const state = loadStateWithCleanup();
|
|
1152
1630
|
// Inject project name into template (HTML-escaped for security)
|
|
1153
1631
|
const projectName = escapeHtml(getProjectName(projectRoot));
|