diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index c16ef2a..84abd28 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -13,6 +13,6 @@ "anti-slop" ], "license": "MIT", - "version": "4.7.0", + "version": "4.7.2", "repository": "https://github.com/agent-kit-startup/agent-kit" } diff --git a/.cursor/agent-kit.json b/.cursor/agent-kit.json index abbee49..b397f6d 100644 --- a/.cursor/agent-kit.json +++ b/.cursor/agent-kit.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "version": "4.7.0", + "version": "4.7.2", "protected": [ ".cursor/HANDOFF.md", ".cursor/agents/test-suites.md", diff --git a/CHANGELOG.md b/CHANGELOG.md index 72756fb..056a319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and ## [Unreleased] +## [4.7.2] - 2026-07-25 + +### Fixed + +- Public-sync dashboard allowlist guard skips when `scripts/public-sync.manifest` is absent (private-only file), so the public mirror CI stays green + +## [4.7.1] - 2026-07-25 + +### Fixed + +- Public sync allowlists `dashboard/**` so Mission Control sources reach the mirror with the CLI dashboard tests that import them +- Regression guard: `packages/cli/src/dashboard/public-sync-manifest-guard.test.ts` fails when synced dashboard tests depend on paths outside the allowlist + ## [4.7.0] - 2026-07-25 Follows 4.5.1. Version 4.6.0 was withdrawn after release because it carried an unfinished dashboard; that number stays retired and is not reused. This release ships the completed Mission Control panel. diff --git a/dashboard/dashboard-data.mjs b/dashboard/dashboard-data.mjs new file mode 100644 index 0000000..63419f1 --- /dev/null +++ b/dashboard/dashboard-data.mjs @@ -0,0 +1,590 @@ +#!/usr/bin/env node +// dashboard/dashboard-data.mjs +// Data fetcher for Startup Kit Dashboard +// Scans .cursor/plans, HANDOFF, memory, config, git status, terminals, processes +// Outputs JSON to stdout (consumed by dashboard.html) + +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { execSync } from 'node:child_process'; +import { + MAX_STRING, + MAX_REPO_ROOT, + truncateStr, + parseGitStatusShort, + allowlistConfig, +} from './lib/guards.mjs'; +import { + parseHandoffMarkdown, + buildMissionControlView, + detectAwaitingPrompt, + parseExternalReport, + EXTERNAL_REPORT_FILE_RE, + MAX_AGENT_PROMPTS, + MAX_GIT_ACTIVITY, +} from './lib/semantic-model.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const MAX_TERMINALS = 20; +const MAX_PROCESSES = 25; +const MAX_TERMINAL_BYTES = 64 * 1024; +const MAX_LAST_OUTPUT_LINES = 15; +const MAX_LAST_OUTPUT_CHARS = 1200; + +// Agent-prompt scan bounds (fs half of the detection contract in semantic-model.mjs). +const MAX_TRANSCRIPT_FILES = 60; // cap directory reads per snapshot +const MAX_TRANSCRIPT_BYTES = 1024 * 1024; // skip oversized transcripts, degrade quietly +const TRANSCRIPT_RECENCY_MS = 30 * 24 * 60 * 60 * 1000; // 30-day recency window + +// External review report scan bounds (fs half of the triage contract). +const MAX_REPORT_FILES = 20; // cap memory reads per snapshot +const MAX_REPORT_BYTES = 512 * 1024; // skip oversized reports, degrade quietly +const REPORT_RECENCY_MS = 90 * 24 * 60 * 60 * 1000; // 90-day recency window + +/** Redact likely secrets in terminal output (paths-only git payload uses separate rules). */ +const SECRET_OUTPUT_PATTERNS = [ + /(?:API_KEY|SECRET|PASSWORD|TOKEN|PRIVATE_KEY)\s*=\s*\S+/gi, + /(?:api[_-]?key|secret|password|token|authorization)\s*[:=]\s*\S+/gi, +]; + +function redactTerminalMeta(meta) { + const out = { ...meta }; + if (out.cwd) out.cwd = truncateStr(out.cwd, MAX_STRING.terminalCwd); + if (out.lastCommand) out.lastCommand = truncateStr(out.lastCommand, MAX_STRING.terminalCommand); + return out; +} + +function redactTerminalOutput(text) { + let out = String(text); + for (const pat of SECRET_OUTPUT_PATTERNS) { + out = out.replace(pat, (match) => { + const sep = match.includes('=') ? '=' : ':'; + const key = match.split(sep)[0]; + return `${key}${sep}***`; + }); + } + return out; +} + +/** Last N lines of terminal body after YAML header, char-capped and redacted. */ +function extractLastOutput(rawContent) { + const lines = rawContent.split('\n'); + let headerEnd = 0; + let dashCount = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === '---') { + dashCount++; + if (dashCount === 2) { + headerEnd = i + 1; + break; + } + } + } + if (headerEnd === 0) headerEnd = 10; + + const bodyLines = lines.slice(headerEnd).filter((l) => l.trim() && !l.startsWith('---')); + if (bodyLines.length === 0) return null; + + const tail = bodyLines.slice(-MAX_LAST_OUTPUT_LINES); + let text = redactTerminalOutput(tail.join('\n')); + text = truncateStr(text, MAX_LAST_OUTPUT_CHARS); + return text && text.trim() ? text : null; +} + +const SNAPSHOT = { + _schema: { + version: '1.2.0', + description: 'Mission Control dashboard data model', + fields: { + generatedAt: 'ISO-8601 timestamp of snapshot generation', + dashboardDataVersion: 'Semantic version of the data model schema', + plans: 'Active plans from .cursor/plans/*.plan.md with frontmatter parsing', + system: 'System metadata: handoff state, allowlisted config summary, package info, version, name, repoRoot, contextPacks', + agents: 'Agent definitions from .cursor/agents/*.md', + commands: 'Slash commands from .cursor/commands/*.md', + memory: 'Memory records: error count, decision count, recent decisions', + git: 'Git repository state: branch, dirty status, commit, ahead/behind, bounded files[]', + terminals: 'Active Cursor terminal sessions with metadata, output line count, and capped lastOutput', + processes: 'Running process snapshots (node, serve.mjs, git operations)', + skills: 'Available skills discovered in .cursor/skills/', + health: 'Aggregated health status with per-check results', + missionControl: + 'Normalized now/activity/attention/plans view model (source-backed; bounded)', + }, + }, + generatedAt: new Date().toISOString(), + dashboardDataVersion: '1.2.0', + plans: [], + system: {}, + agents: [], + commands: [], + memory: {}, + git: {}, + terminals: [], + processes: [], + skills: [], + health: { status: 'ok', checks: [] }, + missionControl: null, +}; + +// 1. Plans +const plansDir = join(ROOT, '.cursor', 'plans'); +if (existsSync(plansDir)) { + const files = readdirSync(plansDir).filter(f => f.endsWith('.plan.md')); + for (const file of files) { + const content = readFileSync(join(plansDir, file), 'utf-8'); + const stats = statSync(join(plansDir, file)); + const todos = []; + let overview = ''; + let name = file.replace(/\.plan\.md$/, ''); + + // Parse frontmatter + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (fmMatch) { + const fm = fmMatch[1]; + const nameMatch = fm.match(/^name:\s*(.+)$/m); + if (nameMatch) name = nameMatch[1].trim(); + const overviewMatch = fm.match(/^overview:\s*"(.+)"$/m); + if (overviewMatch) overview = overviewMatch[1]; + + // Parse todos + const todoRegex = /^\s*-\s+id:\s*(\S+)\s*\n\s*content:\s*"(.+)"\s*\n\s*status:\s*(\S+)/gm; + let m; + while ((m = todoRegex.exec(fm)) !== null) { + todos.push({ id: m[1], content: m[2], status: m[3] }); + } + } + + const totalTodos = todos.length; + const doneTodos = todos.filter(t => t.status === 'completed').length; + const progress = totalTodos > 0 ? Math.round((doneTodos / totalTodos) * 100) : 0; + + SNAPSHOT.plans.push({ + id: name, + file, + path: `.cursor/plans/${file}`, + overview, + progress, + todos: { + total: totalTodos, + completed: doneTodos, + pending: todos.filter(t => t.status === 'pending').length, + inProgress: todos.filter(t => t.status === 'in_progress').length, + items: todos, + }, + modifiedAt: stats.mtime.toISOString(), + }); + } +} + +// 2. HANDOFF (rich parse for Mission Control now/attention) +const handoffPath = join(ROOT, '.cursor', 'HANDOFF.md'); +if (existsSync(handoffPath)) { + const content = readFileSync(handoffPath, 'utf-8'); + const handoff = parseHandoffMarkdown(content); + if (handoff) SNAPSHOT.system.handoff = handoff; +} + +// 3. Agents +const agentsDir = join(ROOT, '.cursor', 'agents'); +if (existsSync(agentsDir)) { + const files = readdirSync(agentsDir).filter(f => f.endsWith('.md')); + for (const file of files) { + const content = readFileSync(join(agentsDir, file), 'utf-8'); + const name = file.replace(/\.md$/, ''); + const descMatch = content.match(/(?:description|summary|#+ .+?)\n*([^#\n]{30,200})/); + SNAPSHOT.agents.push({ + id: name, + file, + path: `.cursor/agents/${file}`, + description: descMatch ? descMatch[1].trim().slice(0, 120) : '', + }); + } +} + +// 4. Commands +const commandsDir = join(ROOT, '.cursor', 'commands'); +if (existsSync(commandsDir)) { + const files = readdirSync(commandsDir).filter(f => f.endsWith('.md')); + for (const file of files) { + const name = file.replace(/\.md$/, ''); + SNAPSHOT.commands.push({ id: name, file, path: `.cursor/commands/${file}` }); + } +} + +// 5. Memory +const memoryErrorsDir = join(ROOT, '.cursor', 'memory', 'errors'); +const memoryDecisionsDir = join(ROOT, '.cursor', 'memory', 'decisions'); +if (existsSync(memoryErrorsDir)) { + SNAPSHOT.memory.errors = readdirSync(memoryErrorsDir).filter(f => f.endsWith('.md')).length; +} +if (existsSync(memoryDecisionsDir)) { + const files = readdirSync(memoryDecisionsDir).filter(f => f.endsWith('.md')); + SNAPSHOT.memory.decisions = files.length; + SNAPSHOT.memory.recentDecisions = files.slice(-5).reverse().map(f => ({ + id: f.replace(/\.md$/, ''), + path: `.cursor/memory/decisions/${f}`, + })); +} + +// 6. Git +try { + const gitOpts = { cwd: ROOT, encoding: 'utf-8', timeout: 5000 }; + const branch = execSync('git rev-parse --abbrev-ref HEAD', gitOpts).trim(); + const status = execSync('git status --short', gitOpts).trim(); + const lastCommit = execSync('git log -1 --oneline', gitOpts).trim(); + let ahead = 0; + let behind = 0; + try { + ahead = parseInt(execSync('git rev-list --count origin/main..HEAD', gitOpts).trim(), 10) || 0; + } catch { /* no upstream */ } + try { + behind = parseInt(execSync('git rev-list --count HEAD..origin/main', gitOpts).trim(), 10) || 0; + } catch { /* no upstream */ } + + const parsed = parseGitStatusShort(status); + let recentLog = []; + try { + recentLog = execSync(`git log --oneline -n ${MAX_GIT_ACTIVITY}`, gitOpts) + .trim() + .split('\n') + .filter(Boolean); + } catch { + recentLog = []; + } + + SNAPSHOT.git = { + branch: truncateStr(branch, MAX_STRING.branch), + dirty: parsed.total > 0, + dirtyCount: parsed.total, + files: parsed.files, + filesTruncated: parsed.truncated, + lastCommit: truncateStr(lastCommit, MAX_STRING.lastCommit), + ahead, + behind, + }; + SNAPSHOT._gitRecentLog = recentLog; +} catch { + SNAPSHOT.git = { error: 'unable to read git state' }; + SNAPSHOT._gitRecentLog = []; +} + +// 7. Terminals (read from Cursor terminal files) +const terminalsDir = resolve(process.env.HOME || '~', '.cursor', 'projects'); +// Derive project path from ROOT rather than hardcoding a specific slug +const projectSlug = ROOT.replace(/\//g, '-').replace(/^-/, ''); +const terminalProjectPath = join(terminalsDir, projectSlug, 'terminals'); + +if (existsSync(terminalProjectPath)) { + try { + const files = readdirSync(terminalProjectPath).filter(f => f.endsWith('.txt')).slice(0, MAX_TERMINALS); + for (const file of files) { + const full = join(terminalProjectPath, file); + const raw = readFileSync(full, 'utf-8'); + // Cap huge terminal dumps: only header meta + a line count estimate is needed + const content = raw.length > MAX_TERMINAL_BYTES ? raw.slice(0, MAX_TERMINAL_BYTES) : raw; + const lines = content.split('\n'); + const meta = {}; + for (const line of lines.slice(0, 15)) { + if (line.startsWith('pid:')) meta.pid = line.slice(4).trim(); + if (line.startsWith('cwd:')) meta.cwd = line.slice(4).trim(); + if (line.startsWith('command:')) meta.lastCommand = line.slice(8).trim(); + if (line.startsWith('last_command:')) meta.lastCommand = line.slice(13).trim(); + if (line.startsWith('last_exit_code:')) meta.lastExitCode = line.slice(15).trim(); + } + const outputLines = lines.slice(10).filter(l => { + return l.trim() && !l.startsWith('---'); + }).length; + const lastOutput = extractLastOutput(content); + const entry = { + id: file, + ...redactTerminalMeta(meta), + outputLines, + }; + if (lastOutput) entry.lastOutput = lastOutput; + SNAPSHOT.terminals.push(entry); + } + } catch { + // Ignore terminal read errors + } +} + +// 8. Config +const configPath = join(ROOT, '.cursor', 'context', 'config.json'); +if (existsSync(configPath)) { + try { + const rawConfig = JSON.parse(readFileSync(configPath, 'utf-8')); + SNAPSHOT.system.config = allowlistConfig(rawConfig); + } catch { + SNAPSHOT.system.config = { error: 'parse error' }; + } +} + +// 9. Package.json + workspace root (for Cursor-native file open URIs) +SNAPSHOT.system.repoRoot = truncateStr(ROOT, MAX_REPO_ROOT); +const pkgPath = join(ROOT, 'package.json'); +if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + SNAPSHOT.system.version = pkg.version; + SNAPSHOT.system.name = pkg.name; + } catch { + // ignore + } +} + +// 11. Context Packs +const contextCurrentDir = join(ROOT, '.cursor', 'context', 'current'); +if (existsSync(contextCurrentDir)) { + try { + const contextFiles = readdirSync(contextCurrentDir).filter(f => f.endsWith('.md')); + SNAPSHOT.system.contextPacks = contextFiles.map(f => ({ + id: f.replace(/\.md$/, ''), + file: f, + path: `.cursor/context/current/${f}`, + })); + } catch { + SNAPSHOT.system.contextPacks = []; + } +} + +// 12. Skills discovery +const skillsDir = join(ROOT, '.cursor', 'skills'); +if (existsSync(skillsDir)) { + try { + function scanSkills(dir, category = '') { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + scanSkills(fullPath, entry.name); + } else if (entry.name === 'SKILL.md') { + const relativeDir = dir.replace(skillsDir + '/', ''); + const raw = readFileSync(fullPath, 'utf-8'); + const titleMatch = raw.match(/^# (.+)$/m); + const descMatch = raw.match(/\n\n(.{20,200})/); + SNAPSHOT.skills.push({ + id: relativeDir, + category: category || 'root', + title: titleMatch ? titleMatch[1].trim() : relativeDir.split('/').pop(), + description: descMatch ? descMatch[1].trim().slice(0, 150) : '', + file: fullPath.replace(ROOT + '/', ''), + }); + } + } + } + scanSkills(skillsDir); + } catch { + // Ignore skills scan errors + } +} + +// 13. Process scanning (capped list: the UI only needs a sample of relevant procs) +try { + const psOutput = execSync('ps -axo pid=,pcpu=,pmem=,command=', { + encoding: 'utf-8', + timeout: 3000, + }).trim(); + if (psOutput) { + const interesting = []; + for (const line of psOutput.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (!/node|git|serve\.mjs|dashboard/i.test(trimmed)) continue; + if (/grep|dashboard-data/.test(trimmed)) continue; + const parts = trimmed.split(/\s+/); + const pid = parts[0]; + const cpu = parts[1]; + const mem = parts[2]; + const cmd = parts.slice(3).join(' ') || 'unknown'; + let label = 'other'; + if (cmd.includes('serve.mjs') || cmd.includes('node dashboard')) label = 'dashboard-server'; + else if (/\bgit\b/.test(cmd)) label = 'git'; + else if (cmd.includes('node')) label = 'node'; + interesting.push({ + pid, + cpu, + mem, + command: truncateStr(cmd, MAX_STRING.processCommand), + label, + }); + if (interesting.length >= MAX_PROCESSES) break; + } + SNAPSHOT.processes = interesting; + } +} catch { + SNAPSHOT.processes = []; +} + +// 10. Health checks (originally) +const checks = [ + { id: 'plans', label: 'Plans directory', ok: existsSync(plansDir) && SNAPSHOT.plans.length > 0 }, + { id: 'handoff', label: 'HANDOFF.md', ok: !!SNAPSHOT.system.handoff?.plan }, + { id: 'agents', label: 'Agents', ok: SNAPSHOT.agents.length > 0 }, + { id: 'commands', label: 'Commands', ok: SNAPSHOT.commands.length > 0 }, + { id: 'memory', label: 'Memory (errors + decisions)', ok: (SNAPSHOT.memory.errors || 0) + (SNAPSHOT.memory.decisions || 0) > 0 }, + { id: 'git', label: 'Git repository', ok: !!SNAPSHOT.git.branch }, + { id: 'config', label: 'Config', ok: !!SNAPSHOT.system.config }, +]; + +SNAPSHOT.health.checks = checks; +SNAPSHOT.health.status = checks.every(c => c.ok) ? 'ok' : checks.filter(c => !c.ok).length <= 2 ? 'warning' : 'degraded'; + +/** + * Agent-prompt detection contract (fs half). + * + * The project transcript store lives at + * `~/.cursor/projects//agent-transcripts//.jsonl`, where + * `` is the repo root path with slashes turned into dashes (the + * same derivation used for the terminals directory). Each `` directory + * holds one main transcript file named after the id; nested `subagents/` + * transcripts are ignored so a worker question never masquerades as a user + * prompt. The scan is read-only and bounded: it skips transcripts outside a + * 30-day recency window, skips files larger than the byte cap, reads at most + * MAX_TRANSCRIPT_FILES of the most recent, and returns at most + * MAX_AGENT_PROMPTS items. The awaiting-a-reply decision itself lives in + * `detectAwaitingPrompt`. A missing or unreadable store yields an empty list, + * never an error state. + */ +function collectAgentPrompts() { + const projectsDir = resolve(process.env.HOME || '~', '.cursor', 'projects'); + const slug = ROOT.replace(/\//g, '-').replace(/^-/, ''); + const transcriptsDir = join(projectsDir, slug, 'agent-transcripts'); + if (!existsSync(transcriptsDir)) return []; + + const prompts = []; + try { + const now = Date.now(); + const candidates = []; + for (const dirent of readdirSync(transcriptsDir, { withFileTypes: true })) { + if (!dirent.isDirectory()) continue; + const id = dirent.name; + const file = join(transcriptsDir, id, `${id}.jsonl`); + if (!existsSync(file)) continue; + let stat; + try { + stat = statSync(file); + } catch { + continue; + } + if (now - stat.mtimeMs > TRANSCRIPT_RECENCY_MS) continue; + if (stat.size > MAX_TRANSCRIPT_BYTES) continue; + candidates.push({ id, file, mtime: stat.mtime }); + } + candidates.sort((a, b) => b.mtime - a.mtime); + + for (const candidate of candidates.slice(0, MAX_TRANSCRIPT_FILES)) { + let raw; + try { + raw = readFileSync(candidate.file, 'utf-8'); + } catch { + continue; + } + const entries = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + entries.push(JSON.parse(trimmed)); + } catch { + // Ignore a malformed line; a partial write must not drop the transcript. + } + } + const awaiting = detectAwaitingPrompt(entries); + if (!awaiting) continue; + prompts.push({ + chatId: candidate.id, + label: awaiting.label, + quietAt: candidate.mtime.toISOString(), + }); + if (prompts.length >= MAX_AGENT_PROMPTS) break; + } + } catch { + return []; + } + return prompts; +} + +/** + * External review report scan (fs half). + * + * Reports written by `/plan-external-review` live directly in + * `.cursor/memory/` as `plan-monitor-.md`. The scan is read-only and + * bounded: it skips reports outside a 90-day recency window, skips files + * larger than the byte cap, and reads at most MAX_REPORT_FILES of the most + * recent; `buildExternalReportItems` then caps the surfaced items at + * MAX_EXTERNAL_REPORTS. The triaged-or-not decision itself + * lives in `isReportTriaged`. A missing or unreadable `.cursor/memory/` + * directory yields an empty list, never an error state. + */ +function collectExternalReports() { + const memoryDir = join(ROOT, '.cursor', 'memory'); + if (!existsSync(memoryDir)) return []; + + const reports = []; + try { + const now = Date.now(); + const candidates = []; + for (const name of readdirSync(memoryDir)) { + if (!EXTERNAL_REPORT_FILE_RE.test(name)) continue; + const file = join(memoryDir, name); + let stat; + try { + stat = statSync(file); + } catch { + continue; + } + if (!stat.isFile()) continue; + if (now - stat.mtimeMs > REPORT_RECENCY_MS) continue; + if (stat.size > MAX_REPORT_BYTES) continue; + candidates.push({ name, file, mtime: stat.mtime }); + } + candidates.sort((a, b) => b.mtime - a.mtime); + + for (const candidate of candidates.slice(0, MAX_REPORT_FILES)) { + let content; + try { + content = readFileSync(candidate.file, 'utf-8'); + } catch { + continue; + } + const report = parseExternalReport({ + file: candidate.name, + content, + modifiedAt: candidate.mtime.toISOString(), + }); + if (!report) continue; + reports.push(report); + } + } catch { + return []; + } + return reports; +} + +// 14. Mission Control semantic view model (now / activity / attention) +let readinessPending = []; +const readinessPath = join(ROOT, '.cursor', 'context', 'readiness.json'); +if (existsSync(readinessPath)) { + try { + const readiness = JSON.parse(readFileSync(readinessPath, 'utf-8')); + if (Array.isArray(readiness.pendingActions)) { + readinessPending = readiness.pendingActions; + } + } catch { + readinessPending = []; + } +} + +SNAPSHOT.missionControl = buildMissionControlView({ + plans: SNAPSHOT.plans, + handoff: SNAPSHOT.system.handoff || null, + gitLogLines: SNAPSHOT._gitRecentLog || [], + terminals: SNAPSHOT.terminals, + readinessPending, + agentPrompts: collectAgentPrompts(), + externalReports: collectExternalReports(), +}); +delete SNAPSHOT._gitRecentLog; + +process.stdout.write(JSON.stringify(SNAPSHOT, null, 2)); \ No newline at end of file diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html new file mode 100644 index 0000000..454b7bf --- /dev/null +++ b/dashboard/dashboard.html @@ -0,0 +1,4667 @@ + + + + + + +Mission Control + + + + + +
+

Mission Control

+ +
+ + + Loading... + + + + + + + +
+
+ + + + + + + \ No newline at end of file diff --git a/dashboard/lib/guards.mjs b/dashboard/lib/guards.mjs new file mode 100644 index 0000000..c2759f4 --- /dev/null +++ b/dashboard/lib/guards.mjs @@ -0,0 +1,221 @@ +// dashboard/lib/guards.mjs +// Pure helpers for Mission Control snapshot redaction and serve lockdown (testable). + +import { resolve } from 'node:path'; + +export const DEFAULT_HOST = '127.0.0.1'; + +export const MAX_STRING = { + branch: 64, + lastCommit: 120, + terminalCwd: 200, + terminalCommand: 120, + processCommand: 80, +}; + +export const MAX_GIT_FILES = 50; +export const MAX_GIT_PATH = 240; +export const MAX_REPO_ROOT = 400; + +/** Repo-relative path safe to join onto a trusted root (no traversal / schemes). */ +export function isSafeRepoRelativePath(relPath) { + if (typeof relPath !== 'string') return false; + const p = relPath.trim().replace(/\\/g, '/'); + if (!p || p.length > MAX_GIT_PATH) return false; + if (p.startsWith('/') || /^[A-Za-z]:\//.test(p)) return false; + if (p.includes('\0') || p.includes('://')) return false; + const parts = p.split('/'); + if (parts.some((part) => part === '' || part === '.' || part === '..')) return false; + return true; +} + +/** Join trusted absolute repo root with a safe relative path, or null. */ +export function joinRepoRoot(repoRoot, relPath) { + if (typeof repoRoot !== 'string' || !repoRoot.trim()) return null; + if (!isSafeRepoRelativePath(relPath)) return null; + const root = repoRoot.trim().replace(/[/\\]+$/, '').replace(/\\/g, '/'); + const rel = relPath.trim().replace(/\\/g, '/'); + return `${root}/${rel}`; +} + +/** + * Build Cursor / VS Code file URIs for opening a local absolute path in the IDE. + * Simple Browser may hand these to the host protocol handler; external browsers vary. + */ +export function buildEditorFileUris(absPath) { + if (typeof absPath !== 'string' || !absPath.trim()) return null; + let normalized = absPath.trim().replace(/\\/g, '/'); + if (!normalized.startsWith('/')) { + normalized = `/${normalized}`; + } + return { + vscode: `vscode://file${normalized}`, + cursor: `cursor://file${normalized}`, + }; +} + +export function resolveBindHost(envHost) { + return envHost || DEFAULT_HOST; +} + +export function truncateStr(value, maxLen) { + if (value == null) return value; + const s = String(value); + return s.length <= maxLen ? s : `${s.slice(0, maxLen)}…`; +} + +/** Parse `git status --short` into bounded file entries (paths only, no contents). */ +export function parseGitStatusShort(output) { + if (!output || !String(output).trim()) { + return { files: [], total: 0, truncated: false }; + } + const lines = String(output) + .split('\n') + .map((line) => line.replace(/\r$/, '')) + .filter((line) => line.trim().length > 0); + const files = []; + let truncated = false; + + for (const line of lines) { + if (files.length >= MAX_GIT_FILES) { + truncated = true; + break; + } + if (line.length < 3) continue; + + const status = line.slice(0, 2); + let rest = line.slice(2).trimStart(); + if (!rest) continue; + + let path = rest; + let oldPath = null; + if (rest.includes(' -> ')) { + const arrowIdx = rest.indexOf(' -> '); + oldPath = rest.slice(0, arrowIdx).trim(); + path = rest.slice(arrowIdx + 4).trim() || oldPath; + } + + const untracked = status === '??' || status[0] === '?' || status[1] === '?'; + const staged = !untracked && status[0] !== ' ' && status[0] !== '?'; + const unstaged = !untracked && status[1] !== ' ' && status[1] !== '?'; + + const entry = { + path: truncateStr(path, MAX_GIT_PATH), + status, + staged, + unstaged, + untracked, + }; + if (oldPath) { + entry.oldPath = truncateStr(oldPath, MAX_GIT_PATH); + entry.renamed = status[0] === 'R' || status[1] === 'R' || status[0] === 'C' || status[1] === 'C'; + } + + files.push(entry); + } + + return { files, total: lines.length, truncated }; +} + +/** Export only safe, UI-relevant config fields (no full nested onboarding checks). */ +export function allowlistConfig(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { error: 'invalid' }; + } + const summary = {}; + if (typeof raw.onboarded === 'boolean') summary.onboarded = raw.onboarded; + if (typeof raw.autoHandoff === 'boolean') summary.autoHandoff = raw.autoHandoff; + if (raw.onboarding && typeof raw.onboarding === 'object') { + summary.onboarding = { + status: typeof raw.onboarding.status === 'string' ? raw.onboarding.status : 'unknown', + contractVersion: raw.onboarding.contractVersion, + }; + } + if (raw.externalPlanReview && typeof raw.externalPlanReview === 'object') { + summary.externalPlanReview = { enabled: !!raw.externalPlanReview.enabled }; + } + if (raw.workspaceSkin && typeof raw.workspaceSkin === 'object') { + const modes = {}; + if (raw.workspaceSkin.modes && typeof raw.workspaceSkin.modes === 'object') { + for (const [mode, skin] of Object.entries(raw.workspaceSkin.modes)) { + modes[mode] = truncateStr(skin, 64); + } + } + summary.workspaceSkin = { + default: truncateStr(raw.workspaceSkin.default, 64), + modes, + }; + } + return summary; +} + +export function isAllowedOrigin(origin, port) { + if (!origin) return false; + try { + const url = new URL(origin); + const resolvedPort = url.port || (url.protocol === 'https:' ? '443' : '80'); + return ( + (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && + resolvedPort === String(port) + ); + } catch { + return false; + } +} + +export function applyCorsHeaders(req, res, port) { + const origin = req.headers?.origin; + if (isAllowedOrigin(origin, port)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + return true; + } + return false; +} + +export function isUnderDashboard(resolvedPath, dashboardReal) { + return resolvedPath === dashboardReal || resolvedPath.startsWith(`${dashboardReal}/`); +} + +/** + * Resolve a static pathname to an absolute file under dashboardReal, or null if blocked. + * fs hooks default to node:fs for production; tests may inject mocks. + */ +export function resolveDashboardStatic( + pathname, + { dashboardDir, dashboardReal, existsSync, realpathSync }, +) { + let rel = pathname; + if (rel === '/' || rel === '') { + rel = '/dashboard.html'; + } + + if (!rel.startsWith('/') || rel.includes('..') || rel.includes('\\')) { + return null; + } + + for (const segment of rel.split('/').filter(Boolean)) { + if (segment.startsWith('.')) { + return null; + } + } + + const candidate = resolve(dashboardDir, `.${rel}`); + if (!isUnderDashboard(candidate, dashboardReal)) { + return null; + } + + if (!existsSync(candidate)) { + return null; + } + + try { + const fileReal = realpathSync(candidate); + if (!isUnderDashboard(fileReal, dashboardReal)) { + return null; + } + return fileReal; + } catch { + return null; + } +} diff --git a/dashboard/lib/live-refresh.mjs b/dashboard/lib/live-refresh.mjs new file mode 100644 index 0000000..4745b49 --- /dev/null +++ b/dashboard/lib/live-refresh.mjs @@ -0,0 +1,97 @@ +// dashboard/lib/live-refresh.mjs +// Pure helpers for Mission Control live refresh (watch coverage, debounce, silence). + +import { join } from 'node:path'; + +/** Coalesce bursty fs.watch events into one trailing snapshot. */ +export const WATCH_DEBOUNCE_MS = 400; + +/** + * When SSE clients are connected, re-broadcast on this interval so git / terminals / + * processes (sources that do not touch watched files) cannot stay stale forever. + */ +export const PERIODIC_REFRESH_MS = 15000; + +/** Client: if no SSE data payload arrives within this window, resume polling. */ +export const SSE_SILENCE_MS = 45000; + +/** + * In-repo paths that dashboard-data.mjs reads. External sources (git state via + * child_process, ~/.cursor terminals, `ps`) are not listed; they need periodic refresh. + */ +export const SNAPSHOT_REPO_SOURCE_RELS = Object.freeze([ + '.cursor/plans', + '.cursor/HANDOFF.md', + '.cursor/agents', + '.cursor/commands', + '.cursor/memory', + '.cursor/context/config.json', + '.cursor/context/current', + '.cursor/context/readiness.json', + '.cursor/skills', + 'package.json', +]); + +/** + * Resolve fs.watch roots. Watching `.cursor` covers create-after-start for HANDOFF + * and nested sources (readiness, agents, skills) that a narrow allowlist missed. + * + * @param {string} root - repository root + * @param {string} dashboardDir - dashboard/ directory + * @returns {string[]} + */ +export function resolveWatchPaths(root, dashboardDir) { + return [ + join(root, '.cursor'), + join(root, 'package.json'), + join(dashboardDir, 'dashboard-data.mjs'), + ]; +} + +/** + * True when `targetAbs` is the watch root or nested under it. + * @param {string} watchAbs + * @param {string} targetAbs + */ +export function watchCoversPath(watchAbs, targetAbs) { + if (watchAbs === targetAbs) return true; + const prefix = watchAbs.endsWith('/') ? watchAbs : `${watchAbs}/`; + return targetAbs.startsWith(prefix); +} + +/** + * @param {string[]} watchAbsPaths + * @param {string} targetAbs + */ +export function isCoveredByWatchPaths(watchAbsPaths, targetAbs) { + return watchAbsPaths.some((w) => watchCoversPath(w, targetAbs)); +} + +/** + * Trailing debounce: each call resets the timer; fn runs once after the quiet period. + * @param {() => void} fn + * @param {number} ms + * @param {{ setTimeout?: typeof setTimeout, clearTimeout?: typeof clearTimeout }} [timers] + */ +export function createTrailingDebounce(fn, ms, timers = {}) { + const schedule = timers.setTimeout || setTimeout; + const cancel = timers.clearTimeout || clearTimeout; + let handle = null; + return () => { + if (handle != null) cancel(handle); + handle = schedule(() => { + handle = null; + fn(); + }, ms); + }; +} + +/** + * @param {number} lastEventAt - ms epoch of last SSE data payload + * @param {number} now - ms epoch + * @param {number} [silenceMs] + */ +export function isSseSilent(lastEventAt, now, silenceMs = SSE_SILENCE_MS) { + if (!Number.isFinite(lastEventAt) || !Number.isFinite(now)) return false; + return now - lastEventAt > silenceMs; +} diff --git a/dashboard/lib/semantic-model.mjs b/dashboard/lib/semantic-model.mjs new file mode 100644 index 0000000..8f91392 --- /dev/null +++ b/dashboard/lib/semantic-model.mjs @@ -0,0 +1,949 @@ +// dashboard/lib/semantic-model.mjs +// Pure Mission Control view-model helpers (testable; no fs/git I/O). + +import { truncateStr } from './guards.mjs'; + +export const MAX_ACTIVITY = 20; +export const MAX_ATTENTION = 15; +export const MAX_SEMANTIC_LABEL = 200; +export const MAX_GIT_ACTIVITY = 15; + +// Max agent-prompt items surfaced in Field Report. Kept under MAX_ATTENTION so +// prompts never crowd out the rest of the stack once merged. +export const MAX_AGENT_PROMPTS = 8; + +// Max external review reports surfaced in Field Report, for the same reason. +export const MAX_EXTERNAL_REPORTS = 6; + +// Max plan-state and readiness notes surfaced in Checklist. Matches the +// Field Report bound these items had before they moved, so relocation does not +// change how much the panel renders. +export const MAX_CHECKLIST_NOTES = 15; + +/** External review reports are `.cursor/memory/plan-monitor-.md`. */ +export const EXTERNAL_REPORT_FILE_RE = /^plan-monitor-(.+)\.md$/; + +/** + * A heading the triage step leaves behind in the report itself. Confirmed + * against the local reports: `## Triage note - residual (A) verified` and + * `## Follow-up plan - hitl_ask_questions_residuals_2026_07_20.plan.md`. + */ +export const TRIAGE_HEADING_RE = + /^#{2,6}\s+.*\b(triage|follow-?up plan|residuals plan)\b/im; + +/** `**Plan:** [`name.plan.md`](../plans/name.plan.md)` in the report header. */ +const REPORT_REVIEWED_PLAN_RE = /^\*\*Plan:\*\*\s*\[`([^`]+)`\]/m; + +/** + * Names an agent question tool_use inside a transcript entry. Confirmed from + * real local transcripts: the call is `AskQuestion`. The `ask_question` and + * `cursor/ask_question` spellings are accepted so the same rule survives the + * ACP and snake_case surfaces documented for the tool. + */ +export const AGENT_QUESTION_TOOL_RE = /^(ask[_-]?question|cursor\/ask_question)$/i; + +const AWAITING_MODE_RE = + /\b(awaiting|waiting|gate\s*[ab]|gate\s+b|start-project\s+gate|user\s+approval|hitl)\b/i; +const EXECUTING_MODE_RE = /\b(run-plan|in_progress|orchestrated|in-session|tick)\b/i; +const MERGE_PR_RE = /^([0-9a-f]{7,40})\s+Merge pull request #(\d+)\b(.*)$/i; +const STAGING_COMMIT_RE = /\b(git staging|\/git-staging|merge.*staging|to staging)\b/i; + +/** + * Parse Agent Kit HANDOFF.md into structured fields used by Mission Control. + * @param {string} content + */ +export function parseHandoffMarkdown(content) { + if (!content || typeof content !== 'string') { + return null; + } + + const handoff = {}; + + const planMatch = content.match(/^- \*\*Plan:\*\* `(.+?)`/m); + if (planMatch) { + const raw = planMatch[1].trim(); + handoff.plan = raw; + handoff.planPath = raw.startsWith('.cursor/') + ? raw + : `.cursor/plans/${raw.replace(/^plans\//, '')}`; + } + + const lastUpdated = content.match(/^- \*\*Last updated:\*\*\s*(.+)$/m); + if (lastUpdated) handoff.lastUpdated = lastUpdated[1].trim(); + + const modeMatch = content.match(/^- \*\*Mode:\*\*\s*(.+)$/m); + if (modeMatch) handoff.mode = truncateStr(modeMatch[1].trim(), MAX_SEMANTIC_LABEL); + + const phaseMatch = content.match(/^- \*\*Phase completed:\*\*\s*(.+)$/m); + if (phaseMatch) handoff.phaseCompleted = phaseMatch[1].trim(); + + const nextPhaseMatch = content.match(/^- \*\*Next phase:\*\*\s*(.+)$/m); + if (nextPhaseMatch) handoff.nextPhase = nextPhaseMatch[1].trim(); + + const completedMatch = content.match(/^- \*\*Completed to-dos:\*\*\s*(.+)$/m); + if (completedMatch) handoff.completedTodos = completedMatch[1].trim(); + + const nextTodosMatch = content.match(/^- \*\*Next to-dos:\*\*\s*(.+)$/m); + if (nextTodosMatch) handoff.nextTodos = nextTodosMatch[1].trim(); + + const parkedMatch = content.match(/^- \*\*Parked plans:\*\*\s*(.+)$/m); + if (parkedMatch) { + handoff.parkedPlansRaw = parkedMatch[1].trim(); + handoff.parkedPlans = parseParkedPlans(parkedMatch[1]); + } else { + handoff.parkedPlans = []; + } + + const instructionMatch = content.match( + /^- \*\*Instruction for the next agent:\*\*\s*(.+)$/m, + ); + if (instructionMatch) { + handoff.instruction = truncateStr(instructionMatch[1].trim(), MAX_SEMANTIC_LABEL); + } + + return Object.keys(handoff).length > 0 ? handoff : null; +} + +/** Extract plan file basenames from a parked-plans HANDOFF line. */ +export function parseParkedPlans(raw) { + if (!raw || typeof raw !== 'string') return []; + const ids = []; + const backtick = [...raw.matchAll(/`([^`]+)`/g)].map((m) => m[1]); + const sources = backtick.length > 0 ? backtick : raw.split(/[,;]/); + for (const part of sources) { + const cleaned = String(part) + .replace(/\(.*?\)/g, '') + .trim() + .replace(/^plans\//, ''); + if (!cleaned || /^none$/i.test(cleaned)) continue; + const base = cleaned.split('/').pop(); + if (base) ids.push(base); + } + return [...new Set(ids)]; +} + +function planFileKey(plan) { + if (!plan) return ''; + return String(plan.file || plan.path || plan.id || '') + .split('/') + .pop() + .trim(); +} + +function handoffPlanKey(handoff) { + if (!handoff?.plan) return ''; + return String(handoff.plan).split('/').pop().trim(); +} + +function isActivePlan(plan, handoff) { + const active = handoffPlanKey(handoff); + if (!active) return false; + const key = planFileKey(plan); + if (!key) return false; + return ( + key === active || + key === `${active}.plan.md` || + active === key.replace(/\.plan\.md$/, '') || + active.includes(key) || + key.includes(active.replace(/\.plan\.md$/, '')) + ); +} + +function isParkedPlan(plan, handoff) { + const parked = handoff?.parkedPlans || []; + if (parked.length === 0) return false; + const key = planFileKey(plan); + const id = String(plan?.id || ''); + return parked.some((p) => { + const base = String(p).split('/').pop(); + return ( + base === key || + base === `${id}.plan.md` || + base.replace(/\.plan\.md$/, '') === id || + key.includes(base.replace(/\.plan\.md$/, '')) + ); + }); +} + +function todoStats(plan) { + const items = plan?.todos?.items || []; + const total = plan?.todos?.total ?? items.length; + const completed = + plan?.todos?.completed ?? items.filter((t) => t.status === 'completed').length; + const inProgress = + plan?.todos?.inProgress ?? items.filter((t) => t.status === 'in_progress').length; + const pending = + plan?.todos?.pending ?? items.filter((t) => t.status === 'pending').length; + const cancelled = items.filter((t) => t.status === 'cancelled').length; + const open = total - completed - cancelled; + return { items, total, completed, inProgress, pending, cancelled, open }; +} + +function modeImpliesAwaiting(mode) { + return typeof mode === 'string' && AWAITING_MODE_RE.test(mode); +} + +function modeImpliesExecuting(mode) { + return typeof mode === 'string' && EXECUTING_MODE_RE.test(mode); +} + +/** + * Classify a plan lifecycle from HANDOFF + todo evidence. + * @returns {'executing'|'awaiting_user'|'parked'|'incomplete'|'completed'} + */ +export function classifyPlan(plan, handoff) { + if (isParkedPlan(plan, handoff)) return 'parked'; + + const stats = todoStats(plan); + const active = isActivePlan(plan, handoff); + const mode = handoff?.mode || ''; + + if (active) { + if (modeImpliesAwaiting(mode) && stats.inProgress === 0) return 'awaiting_user'; + if (stats.inProgress > 0 || modeImpliesExecuting(mode)) return 'executing'; + if (stats.open > 0) return 'awaiting_user'; + return 'completed'; + } + + if (stats.total > 0 && stats.open === 0) return 'completed'; + if (stats.open > 0) return 'incomplete'; + return 'completed'; +} + +function pickCurrentTodo(plan, handoff) { + const items = plan?.todos?.items || []; + const inProg = items.find((t) => t.status === 'in_progress'); + if (inProg) return inProg; + + const nextRaw = handoff?.nextTodos || ''; + const nextId = nextRaw.match(/`?([a-z0-9][\w-]*)`?/i)?.[1]; + if (nextId) { + const matched = items.find((t) => t.id === nextId); + if (matched) return matched; + } + return items.find((t) => t.status === 'pending') || null; +} + +function pickPreviousTodo(plan, current) { + const items = plan?.todos?.items || []; + let end = items.length; + if (current) { + const idx = items.findIndex((t) => t.id === current.id); + if (idx >= 0) end = idx; + } + for (let i = end - 1; i >= 0; i--) { + if (items[i].status === 'completed') return items[i]; + } + return null; +} + +function pickNextTodo(plan, current) { + const items = plan?.todos?.items || []; + if (!current) { + return items.find((t) => t.status === 'pending' || t.status === 'in_progress') || null; + } + const idx = items.findIndex((t) => t.id === current.id); + if (idx >= 0) { + for (let i = idx + 1; i < items.length; i++) { + if (items[i].status === 'pending' || items[i].status === 'in_progress') { + return items[i]; + } + } + } + return items.find((t) => t.id !== current.id && t.status === 'pending') || null; +} + +function compactTodo(todo) { + if (!todo) return null; + return { + id: todo.id, + content: truncateStr(todo.content || '', MAX_SEMANTIC_LABEL), + status: todo.status, + }; +} + +/** + * Build the "what is happening now" slice. + */ +export function buildCurrentExecution(plans, handoff) { + if (!handoff?.plan) { + return { + status: 'idle', + planId: null, + planFile: null, + planPath: null, + mode: null, + progress: { completed: 0, total: 0 }, + previousTodo: null, + currentTodo: null, + nextTodo: null, + modifiedAt: null, + sourcePath: '.cursor/HANDOFF.md', + lifecycle: null, + }; + } + + const active = + (plans || []).find((p) => isActivePlan(p, handoff)) || null; + const lifecycle = active ? classifyPlan(active, handoff) : null; + const stats = active ? todoStats(active) : { completed: 0, total: 0 }; + const currentTodo = active ? pickCurrentTodo(active, handoff) : null; + const previousTodo = active ? pickPreviousTodo(active, currentTodo) : null; + const nextTodo = active ? pickNextTodo(active, currentTodo) : null; + + let status = 'idle'; + if (lifecycle === 'executing') status = 'executing'; + else if (lifecycle === 'awaiting_user') status = 'awaiting_user'; + else if (active && stats.open > 0) status = 'awaiting_user'; + + return { + status, + planId: active?.id || handoff.plan.replace(/\.plan\.md$/, ''), + planFile: active?.file || handoffPlanKey(handoff), + planPath: active?.path || handoff.planPath || null, + mode: handoff.mode || null, + progress: { completed: stats.completed, total: stats.total }, + previousTodo: compactTodo(previousTodo), + currentTodo: compactTodo(currentTodo), + nextTodo: compactTodo(nextTodo), + modifiedAt: active?.modifiedAt || handoff.lastUpdated || null, + sourcePath: '.cursor/HANDOFF.md', + lifecycle, + }; +} + +function activityId(kind, parts) { + return truncateStr(`${kind}:${parts.filter(Boolean).join(':')}`, 120); +} + +/** + * Format durable git log lines into semantic activity events. + * @param {string[]} logLines - `git log --oneline` style lines (newest first) + */ +export function formatGitActivity(logLines, { limit = MAX_GIT_ACTIVITY } = {}) { + const events = []; + for (const line of logLines || []) { + if (events.length >= limit) break; + const trimmed = String(line || '').trim(); + if (!trimmed) continue; + + const merge = trimmed.match(MERGE_PR_RE); + if (merge) { + const sha = merge[1].slice(0, 7); + const pr = merge[2]; + events.push({ + id: activityId('merge', [pr, sha]), + kind: 'merge', + at: null, + label: truncateStr(`Merged PR #${pr} → ${sha}.`, MAX_SEMANTIC_LABEL), + refs: { pr: Number(pr), sha }, + }); + continue; + } + + const m = trimmed.match(/^([0-9a-f]{7,40})\s+(.+)$/i); + if (!m) continue; + const sha = m[1].slice(0, 7); + const message = m[2].trim(); + const kind = STAGING_COMMIT_RE.test(message) ? 'staging' : 'commit'; + events.push({ + id: activityId(kind, [sha]), + kind, + at: null, + label: truncateStr( + kind === 'staging' + ? `Staging ${sha}: ${message}` + : `Commit ${sha}: ${message}`, + MAX_SEMANTIC_LABEL, + ), + refs: { sha }, + }); + } + return events; +} + +/** + * Plan / HANDOFF milestone events (not refresh noise). + */ +export function formatPlanHandoffActivity({ now, handoff, plans }) { + const events = []; + + if (now?.status === 'executing' && now.currentTodo) { + const progress = + now.progress?.total > 0 + ? ` Plan: ${now.progress.completed}/${now.progress.total}.` + : ''; + const modeBit = now.mode ? `${truncateStr(now.mode, 80)}. ` : ''; + events.push({ + id: activityId('run_plan', [now.planFile, now.currentTodo.id]), + kind: 'run_plan', + at: now.modifiedAt || null, + label: truncateStr( + `${modeBit}Tick in flight: ${now.currentTodo.id}.${progress}`, + MAX_SEMANTIC_LABEL, + ), + sourcePath: now.planPath || null, + refs: { plan: now.planFile, todo: now.currentTodo.id }, + }); + } else if (now?.status === 'awaiting_user') { + events.push({ + id: activityId('handoff', [now.planFile, 'awaiting']), + kind: 'handoff', + at: now.modifiedAt || null, + label: truncateStr( + `HANDOFF awaiting user: ${now.planFile || handoff?.plan || 'plan'}` + + (now.nextTodo ? ` (next: ${now.nextTodo.id})` : ''), + MAX_SEMANTIC_LABEL, + ), + sourcePath: '.cursor/HANDOFF.md', + refs: { plan: now.planFile }, + }); + } + + // Only recent or parked completed plans: avoid flooding activity with old portfolio noise. + const completed = (plans || []) + .filter((plan) => { + const lifecycle = classifyPlan(plan, handoff); + return lifecycle === 'completed' || lifecycle === 'parked'; + }) + .filter((plan) => todoStats(plan).total > 0) + .sort((a, b) => String(b.modifiedAt || '').localeCompare(String(a.modifiedAt || ''))) + .slice(0, 3); + + for (const plan of completed) { + const stats = todoStats(plan); + const parked = classifyPlan(plan, handoff) === 'parked'; + events.push({ + id: activityId('plan_progress', [plan.file, parked ? 'parked' : 'done']), + kind: 'plan_progress', + at: plan.modifiedAt || null, + label: truncateStr( + parked + ? `Parked ${plan.id}: ${stats.completed}/${stats.total}.` + : `Plan ${plan.id}: ${stats.completed}/${stats.total} complete.`, + MAX_SEMANTIC_LABEL, + ), + sourcePath: plan.path || null, + refs: { plan: plan.file }, + }); + } + + return events; +} + +/** + * Narrow execution evidence from terminal lastOutput (explicit run-plan lines only). + */ +export function formatTerminalRunEvidence(terminals, { limit = 3 } = {}) { + const events = []; + for (const t of terminals || []) { + if (events.length >= limit) break; + const out = t?.lastOutput || ''; + if (!out || !/\/run-plan|LOOP_TICK_RESULT|Night shift:.*run-plan/i.test(out)) { + continue; + } + const line = + out + .split('\n') + .map((l) => l.trim()) + .find((l) => /run-plan|LOOP_TICK_RESULT|Tick →|Tick ->/i.test(l)) || null; + if (!line) continue; + events.push({ + id: activityId('run_plan', ['term', t.id, line.slice(0, 40)]), + kind: 'run_plan', + at: null, + label: truncateStr(line, MAX_SEMANTIC_LABEL), + sourcePath: null, + refs: { terminal: t.id }, + }); + } + return events; +} + +/** + * Merge activity streams newest-first, dedupe by id, bound length. + */ +export function mergeActivity(streams, { limit = MAX_ACTIVITY } = {}) { + const seen = new Set(); + const out = []; + for (const stream of streams) { + for (const ev of stream || []) { + if (!ev?.id || seen.has(ev.id)) continue; + seen.add(ev.id); + out.push({ + ...ev, + label: truncateStr(ev.label || '', MAX_SEMANTIC_LABEL), + }); + if (out.length >= limit) return out; + } + } + return out; +} + +/** + * Action contract for Field Report and Checklist rows. + * + * The panel is read-only: it copies and a human pastes. `path` targets are + * copied for the file picker; `copy` targets carry their own `subject` and + * `pasteDestination`. No action type opens anything. + * @param {'path'|'copy'} type + */ +function attentionAction(type, target, label) { + return { type, target, label }; +} + +/** + * Build the Field Report stack: what is waiting on a human reply. + * + * Carries agent prompts, untriaged external reviews, and the active HANDOFF + * gate. The gate stays here because it is a pending human decision read from + * `.cursor/HANDOFF.md`, not a portfolio state: the plan cannot advance until + * someone answers it, which is the same question the other two rows ask. Plan + * lifecycle and readiness advisories belong to Checklist + * (see `buildChecklistNotes`). + */ +export function buildAttentionItems({ + plans, + handoff, + now, + agentPrompts = [], + externalReports = [], + limit = MAX_ATTENTION, +}) { + const items = []; + + // Agent prompts awaiting a reply lead the stack: they are the clearest + // "waiting on you" signal and carry their own labelled group in the UI. + for (const prompt of buildAgentPromptItems(agentPrompts)) { + if (items.length >= limit) break; + items.push(prompt); + } + + // Untriaged external reviews follow: also user-owned work, also its own group. + for (const report of buildExternalReportItems(externalReports, plans)) { + if (items.length >= limit) break; + items.push(report); + } + + if (now?.status === 'awaiting_user') { + items.push({ + id: 'attention:handoff-awaiting', + kind: 'handoff', + severity: 'action', + label: truncateStr( + `Active HANDOFF awaits user: ${now.planFile || handoff?.plan}` + + (now.nextTodo ? ` → ${now.nextTodo.id}` : ''), + MAX_SEMANTIC_LABEL, + ), + sourcePath: '.cursor/HANDOFF.md', + modifiedAt: now.modifiedAt || handoff?.lastUpdated || null, + progress: now.progress || null, + action: attentionAction('path', '.cursor/HANDOFF.md', 'Copy path'), + }); + } + + return items.slice(0, limit); +} + +/** Shared row shape for a plan whose lifecycle needs a decision. */ +function planStateNote(plan, { kind, severity }) { + const stats = todoStats(plan); + const path = plan.path || `.cursor/plans/${plan.file}`; + const prefix = kind === 'parked' ? 'Parked plan' : 'Incomplete plan'; + return { + id: `attention:${kind}:${plan.file}`, + kind, + severity, + label: truncateStr( + `${prefix}: ${plan.id} (${stats.completed}/${stats.total})`, + MAX_SEMANTIC_LABEL, + ), + sourcePath: path, + // Lets the panel drop a note whose plan already renders as a plan card. + planFile: plan.file, + modifiedAt: plan.modifiedAt || null, + progress: { + completed: stats.completed, + total: stats.total, + label: `${stats.completed} of ${stats.total}`, + }, + action: attentionAction('path', path, 'Copy path'), + }; +} + +/** + * Build the Checklist notes: plan lifecycle and readiness advisories. + * + * These rows describe the state of the plan portfolio rather than a pending + * reply, so they live next to the plan cards instead of in Field Report. Rows + * carry `planFile` so the panel can reconcile a note against a plan already + * rendered as a card. + */ +export function buildChecklistNotes({ + plans, + handoff, + readinessPending = [], + limit = MAX_CHECKLIST_NOTES, +}) { + const items = []; + + for (const plan of plans || []) { + if (items.length >= limit) break; + if (classifyPlan(plan, handoff) !== 'parked') continue; + items.push(planStateNote(plan, { kind: 'parked', severity: 'info' })); + } + + for (const plan of plans || []) { + if (items.length >= limit) break; + if (classifyPlan(plan, handoff) !== 'incomplete') continue; + if (isActivePlan(plan, handoff)) continue; + items.push(planStateNote(plan, { kind: 'incomplete', severity: 'warning' })); + } + + for (const pending of readinessPending || []) { + if (items.length >= limit) break; + if (!pending || pending.essential === true) continue; + if (pending.status === 'ready') continue; + const id = pending.id || pending.checkId || 'readiness'; + items.push({ + id: `attention:readiness:${id}`, + kind: 'readiness', + severity: 'info', + label: truncateStr( + pending.label || + pending.title || + `Non-essential readiness: ${id} (${pending.status || 'pending'})`, + MAX_SEMANTIC_LABEL, + ), + sourcePath: '.cursor/context/readiness.json', + modifiedAt: null, + progress: null, + action: { + type: 'copy', + target: '/agent-kit-onboard', + label: 'Copy /agent-kit-onboard', + subject: '/agent-kit-onboard', + pasteDestination: 'chat input', + }, + }); + } + + return items.slice(0, limit); +} + +/** + * Enrich plan records with lifecycle classification (non-mutating copy). + */ +export function enrichPlans(plans, handoff) { + return (plans || []).map((plan) => { + const stats = todoStats(plan); + return { + id: plan.id, + file: plan.file, + path: plan.path, + overview: truncateStr(plan.overview || '', MAX_SEMANTIC_LABEL), + modifiedAt: plan.modifiedAt || null, + progress: { + completed: stats.completed, + total: stats.total, + label: `${stats.completed} of ${stats.total}`, + }, + lifecycle: classifyPlan(plan, handoff), + currentTodo: compactTodo(pickCurrentTodo(plan, handoff)), + nextTodo: compactTodo( + pickNextTodo(plan, pickCurrentTodo(plan, handoff)), + ), + }; + }); +} + +/** + * Allowlisted readiness pending actions for attention (no nested scan dump). + */ +export function allowlistReadinessPending(rawPending) { + if (!Array.isArray(rawPending)) return []; + return rawPending.slice(0, MAX_ATTENTION).map((item) => ({ + id: typeof item?.id === 'string' ? item.id : 'unknown', + status: typeof item?.status === 'string' ? item.status : 'unknown', + essential: item?.essential === true, + title: + typeof item?.title === 'string' + ? truncateStr(item.title, 120) + : typeof item?.label === 'string' + ? truncateStr(item.label, 120) + : undefined, + })); +} + +/** + * Agent-prompt detection contract (pure half). + * + * A transcript is one JSON object per line. Conversation entries carry a + * `role` of `user` or `assistant`; a trailing turn marker object has no + * `role` and is ignored. An assistant entry counts as a QUESTION when its + * `message.content` holds a `tool_use` whose `name` matches + * `AGENT_QUESTION_TOOL_RE` (the AskQuestion family). An ANSWER is any later + * entry with `role === 'user'`. A transcript is "awaiting a reply" only when it + * contains at least one question and no user entry follows the last one. The + * assistant speaking last carries no signal (almost every transcript ends on an + * assistant entry) and is deliberately not used. The fs half (directory + * location, file cap, recency window) lives in `dashboard-data.mjs`. + */ +export function isAgentQuestionEntry(entry) { + if (!entry || entry.role !== 'assistant') return false; + const content = entry.message?.content; + if (!Array.isArray(content)) return false; + return content.some( + (c) => + c && + c.type === 'tool_use' && + AGENT_QUESTION_TOOL_RE.test(String(c.name || '')), + ); +} + +export function isUserEntry(entry) { + return !!entry && entry.role === 'user'; +} + +/** Derive a human-readable label from the question tool_use itself. */ +export function extractQuestionLabel(entry) { + const content = entry?.message?.content; + if (!Array.isArray(content)) return null; + for (const c of content) { + if ( + !c || + c.type !== 'tool_use' || + !AGENT_QUESTION_TOOL_RE.test(String(c.name || '')) + ) { + continue; + } + const questions = c.input?.questions; + if (Array.isArray(questions)) { + for (const q of questions) { + const prompt = q?.prompt || q?.question || q?.text; + if (typeof prompt === 'string' && prompt.trim()) { + return truncateStr(prompt.trim(), MAX_SEMANTIC_LABEL); + } + } + } + const single = c.input?.prompt || c.input?.question; + if (typeof single === 'string' && single.trim()) { + return truncateStr(single.trim(), MAX_SEMANTIC_LABEL); + } + } + return null; +} + +/** + * Scan parsed transcript entries for an unanswered agent question. + * @param {object[]} entries - parsed JSONL objects, in file order + * @returns {{ label: string|null }|null} match when awaiting a reply, else null + */ +export function detectAwaitingPrompt(entries) { + if (!Array.isArray(entries)) return null; + let lastQuestionIdx = -1; + let lastUserIdx = -1; + let lastQuestionEntry = null; + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (!e || typeof e !== 'object') continue; + if (isUserEntry(e)) lastUserIdx = i; + if (isAgentQuestionEntry(e)) { + lastQuestionIdx = i; + lastQuestionEntry = e; + } + } + if (lastQuestionIdx < 0) return null; + if (lastUserIdx > lastQuestionIdx) return null; + return { label: extractQuestionLabel(lastQuestionEntry) }; +} + +/** + * Map detected prompts into attention-item shape. Each item copies the chat + * reference the user pastes into the past-chat picker; it does not open a chat. + * @param {{chatId:string,label?:string,quietAt?:string}[]} prompts + */ +export function buildAgentPromptItems(prompts, { limit = MAX_AGENT_PROMPTS } = {}) { + const items = []; + for (const p of prompts || []) { + if (items.length >= limit) break; + if (!p || !p.chatId) continue; + items.push({ + id: `attention:prompt:${p.chatId}`, + kind: 'prompt', + severity: 'action', + label: truncateStr( + p.label || 'Agent question awaiting a reply', + MAX_SEMANTIC_LABEL, + ), + sourcePath: null, + chatId: p.chatId, + modifiedAt: p.quietAt || null, + progress: null, + action: { + type: 'copy', + target: p.chatId, + label: 'Copy chat reference', + subject: 'chat reference', + pasteDestination: 'past-chat picker', + }, + }); + } + return items; +} + +/** Compare slugs across the `-` / `_` split between report and plan names. */ +function normalizeSlug(value) { + return String(value || '') + .toLowerCase() + .replace(/\.plan\.md$/, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +/** + * Parse one external review report into the fields the triage rule needs. + * @param {{file:string,content:string,modifiedAt?:string}} input + */ +export function parseExternalReport({ file, content, modifiedAt = null } = {}) { + const match = EXTERNAL_REPORT_FILE_RE.exec(String(file || '')); + if (!match) return null; + const text = typeof content === 'string' ? content : ''; + const reviewed = text.match(REPORT_REVIEWED_PLAN_RE); + return { + file, + path: `.cursor/memory/${file}`, + slug: match[1], + reviewedPlanFile: reviewed ? reviewed[1].trim() : null, + triageNoteInReport: TRIAGE_HEADING_RE.test(text), + modifiedAt, + }; +} + +/** + * "Not yet a plan" contract (pure half), derived from the local reports rather + * than assumed. A report at `.cursor/memory/plan-monitor-.md` counts as + * ALREADY TRIAGED when either signal holds: + * + * 1. The report carries a triage heading (`TRIAGE_HEADING_RE`). Both triaged + * reports in this repository do: one records `## Triage note` for an + * "ack and stop" outcome that produced no plan, the other records + * `## Follow-up plan`. + * 2. A plan other than the reviewed plan names the report slug or the + * reviewed plan in its own id or overview. The reviewed plan is read from + * the report's `**Plan:**` header, so a hash-suffixed plan file is + * excluded as itself rather than mistaken for its own follow-up. + * + * Neither signal alone is enough: `/plan-review-triage` may write a residuals + * plan without touching the report, and an "ack and stop" outcome produces a + * note with no plan at all. A report with neither signal is surfaced as + * awaiting triage. The fs half (directory, file cap, size cap, recency window) + * lives in `dashboard-data.mjs`, next to the prompt-scan contract. + */ +export function isReportTriaged(report, plans) { + if (!report) return false; + if (report.triageNoteInReport) return true; + + const slug = normalizeSlug(report.slug); + const reviewed = normalizeSlug(report.reviewedPlanFile); + if (!slug && !reviewed) return false; + + return (plans || []).some((plan) => { + if (!plan) return false; + const planFile = String(plan.file || ''); + if (report.reviewedPlanFile && planFile === report.reviewedPlanFile) return false; + const planSlug = normalizeSlug(plan.id || planFile); + if (planSlug === slug || (reviewed && planSlug === reviewed)) return false; + const haystack = normalizeSlug(`${plan.id || ''} ${plan.overview || ''}`); + if (!haystack) return false; + return ( + (!!slug && haystack.includes(slug)) || (!!reviewed && haystack.includes(reviewed)) + ); + }); +} + +/** + * Map untriaged reports into attention-item shape. Each item copies the triage + * command with the report path; it does not run triage. + * @param {object[]} reports - parsed reports (see `parseExternalReport`) + * @param {object[]} plans - plan records from the snapshot + */ +export function buildExternalReportItems( + reports, + plans, + { limit = MAX_EXTERNAL_REPORTS } = {}, +) { + const items = []; + for (const report of reports || []) { + if (items.length >= limit) break; + if (!report || !report.file) continue; + if (isReportTriaged(report, plans)) continue; + const reviewed = report.reviewedPlanFile + ? report.reviewedPlanFile.replace(/\.plan\.md$/, '') + : report.slug; + items.push({ + id: `attention:report:${report.slug}`, + kind: 'report', + severity: 'action', + label: truncateStr( + `External review of ${reviewed} has no triage outcome yet`, + MAX_SEMANTIC_LABEL, + ), + sourcePath: report.path, + modifiedAt: report.modifiedAt || null, + progress: null, + action: { + type: 'copy', + target: `/plan-review-triage ${report.path}`, + label: 'Copy triage command', + subject: 'triage command', + pasteDestination: 'chat input', + }, + }); + } + return items; +} + +/** + * Assemble the Mission Control view model attached to the dashboard snapshot. + */ +export function buildMissionControlView({ + plans = [], + handoff = null, + gitLogLines = [], + terminals = [], + readinessPending = [], + agentPrompts = [], + externalReports = [], +} = {}) { + const now = buildCurrentExecution(plans, handoff); + const classifiedPlans = enrichPlans(plans, handoff); + const planEvents = formatPlanHandoffActivity({ now, handoff, plans }); + const activity = mergeActivity([ + planEvents.filter((e) => e.kind === 'run_plan' || e.kind === 'handoff'), + formatGitActivity(gitLogLines), + planEvents.filter((e) => e.kind === 'plan_progress'), + formatTerminalRunEvidence(terminals), + ]); + const attention = buildAttentionItems({ + plans, + handoff, + now, + agentPrompts, + externalReports, + }); + const checklistNotes = buildChecklistNotes({ + plans, + handoff, + readinessPending: allowlistReadinessPending(readinessPending), + }); + + return { + schemaVersion: '1.0.0', + now, + activity, + attention, + checklistNotes, + plans: classifiedPlans, + }; +} diff --git a/dashboard/logo.svg b/dashboard/logo.svg new file mode 100644 index 0000000..ce37b66 --- /dev/null +++ b/dashboard/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/serve.mjs b/dashboard/serve.mjs new file mode 100644 index 0000000..59905e3 --- /dev/null +++ b/dashboard/serve.mjs @@ -0,0 +1,234 @@ +#!/usr/bin/env node +// dashboard/serve.mjs +// Server for the Startup Kit Dashboard +// - Serves dashboard HTML +// - Generates dashboard-data.json on each request +// - SSE endpoint for live push updates + +import { createServer } from 'node:http'; +import { readFileSync, existsSync, watch, realpathSync } from 'node:fs'; +import { join, extname, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { + DEFAULT_HOST, + resolveBindHost, + applyCorsHeaders, + resolveDashboardStatic, +} from './lib/guards.mjs'; +import { + WATCH_DEBOUNCE_MS, + PERIODIC_REFRESH_MS, + resolveWatchPaths, + createTrailingDebounce, +} from './lib/live-refresh.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const DASHBOARD_DIR = __dirname; +const HOST = resolveBindHost(process.env.HOST); +const PORT = parseInt(process.env.PORT || '3333', 10); +const DASHBOARD_REAL = realpathSync(DASHBOARD_DIR); + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.css': 'text/css', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +const dataScript = join(__dirname, 'dashboard-data.mjs'); + +function setCorsHeaders(req, res) { + applyCorsHeaders(req, res, PORT); +} + +function resolveStaticPath(pathname) { + return resolveDashboardStatic(pathname, { + dashboardDir: DASHBOARD_DIR, + dashboardReal: DASHBOARD_REAL, + existsSync, + realpathSync, + }); +} + +// SSE clients +const sseClients = new Set(); + +// Short TTL cache + single-flight so concurrent HTTP/SSE requests do not +// stack sync child processes and block the event loop for tens of seconds. +const CACHE_TTL_MS = 2000; +let cachedPayload = null; +let cachedAt = 0; +let inFlight = false; + +function generateDataSyncUncached() { + try { + return execFileSync(process.execPath, [dataScript], { + cwd: ROOT, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + timeout: 15000, + }); + } catch (e) { + // Every collection the panel reads must be present, or the render throws + // and a snapshot failure surfaces as "Render Failed" instead of an + // empty panel. + return JSON.stringify({ + generatedAt: new Date().toISOString(), + version: 'error', + error: e.message, + plans: [], system: {}, agents: [], commands: [], skills: [], + memory: {}, git: {}, terminals: [], processes: [], + health: { status: 'error', checks: [] }, + missionControl: null, + }); + } +} + +function generateDataSync({ force = false } = {}) { + const now = Date.now(); + if (!force && cachedPayload && now - cachedAt < CACHE_TTL_MS) { + return cachedPayload; + } + // Sync generation blocks the event loop; overlapping callers only run after + // we return, when the cache is warm. Never return a non-JSON sentinel. + if (inFlight && cachedPayload) { + return cachedPayload; + } + inFlight = true; + try { + const payload = generateDataSyncUncached(); + cachedPayload = payload; + cachedAt = Date.now(); + return payload; + } finally { + inFlight = false; + } +} + +function broadcast(data) { + const msg = `data: ${data}\n\n`; + for (const client of sseClients) { + try { + client.write(msg); + } catch { + sseClients.delete(client); + } + } +} + +let lastBroadcastAt = 0; + +const scheduleBroadcast = createTrailingDebounce(() => { + const data = generateDataSync({ force: true }); + lastBroadcastAt = Date.now(); + broadcast(data); +}, WATCH_DEBOUNCE_MS); + +// Watch every in-repo snapshot source (full `.cursor` tree + package + data script). +const watchPaths = resolveWatchPaths(ROOT, __dirname); + +for (const p of watchPaths) { + if (!existsSync(p)) { + console.warn(`[watch] skip missing path: ${p}`); + continue; + } + try { + watch(p, { recursive: true }, () => { + scheduleBroadcast(); + }); + } catch (err) { + console.warn(`[watch] failed for ${p}: ${err && err.message ? err.message : err}`); + } +} + +// Git / terminals / processes do not touch watched files; keep SSE clients fresh. +setInterval(() => { + if (sseClients.size === 0) return; + if (Date.now() - lastBroadcastAt < PERIODIC_REFRESH_MS) return; + scheduleBroadcast(); +}, PERIODIC_REFRESH_MS); + +const server = createServer((req, res) => { + const url = new URL(req.url, `http://127.0.0.1:${PORT}`); + const path = url.pathname; + + setCorsHeaders(req, res); + + // SSE endpoint + if (path === '/api/events') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + + // Send initial data + const data = generateDataSync(); + res.write(`data: ${data}\n\n`); + + sseClients.add(res); + const clientAddr = req.socket.remoteAddress || 'unknown'; + console.log(`[SSE] Client connected: ${clientAddr} (${sseClients.size} clients)`); + + req.on('close', () => { + sseClients.delete(res); + console.log(`[SSE] Client disconnected: ${clientAddr} (${sseClients.size} clients remaining)`); + }); + + // Heartbeat every 30s to keep connection alive + const heartbeat = setInterval(() => { + try { + res.write(':heartbeat\n\n'); + } catch { + clearInterval(heartbeat); + } + }, 30000); + + req.on('close', () => clearInterval(heartbeat)); + return; + } + + // Data endpoint + if (path === '/dashboard-data.json' || path === '/api/data') { + const data = generateDataSync(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(data); + return; + } + + const staticPath = resolveStaticPath(path); + if (!staticPath) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const ext = extname(staticPath); + const contentType = MIME[ext] || 'application/octet-stream'; + const content = readFileSync(staticPath); + res.writeHead(200, { 'Content-Type': contentType }); + res.end(content); +}); + +server.listen(PORT, HOST, () => { + const loopback = HOST === DEFAULT_HOST || HOST === 'localhost' || HOST === '::1'; + if (!loopback) { + console.warn( + `[WARN] Mission Control bound to ${HOST}:${PORT}, not loopback. Do not expose on shared networks.`, + ); + } + + const url = `http://127.0.0.1:${PORT}`; + console.log(`\n Mission Control\n`); + console.log(` Local: ${url}`); + console.log(` Data: ${url}/dashboard-data.json`); + console.log(` Events: ${url}/api/events (SSE)`); + console.log(`\n Open in Cursor Simple Browser or your browser.\n`); + console.log(` Press Ctrl+C to stop.\n`); +}); \ No newline at end of file diff --git a/dashboard/start.mjs b/dashboard/start.mjs new file mode 100644 index 0000000..f16b80f --- /dev/null +++ b/dashboard/start.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Terminal counterpart to the `/dashboard` slash command. + * + * Detects a listener on PORT (default 3333), detach-starts `serve.mjs` when + * needed (double-fork / setsid so the process survives the shell), waits until + * HTTP 200, prints the URL, and opens the default browser when possible. + * + * Foreground serve for debugging remains: `npm run start:dashboard`. + */ + +import { spawn, execFileSync, execSync } from "node:child_process"; +import { existsSync, openSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { platform } from "node:os"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, ".."); +const SERVE = join(__dirname, "serve.mjs"); +const LOG = process.env.MISSION_CONTROL_LOG || "/tmp/mission-control.log"; +const PORT = parseInt(process.env.PORT || "3333", 10); +const HOST = process.env.HOST || "127.0.0.1"; +const DISPLAY_HOST = HOST === "0.0.0.0" ? "127.0.0.1" : HOST; +const URL = `http://${DISPLAY_HOST}:${PORT}/`; +const READY_TIMEOUT_MS = 20_000; +const READY_POLL_MS = 250; + +function probeHttp() { + try { + const code = execFileSync( + "curl", + ["-sf", "-o", "/dev/null", "-w", "%{http_code}", URL], + { encoding: "utf8", timeout: 3000 }, + ).trim(); + return code === "200"; + } catch { + return false; + } +} + +function listeningPids() { + try { + const out = execFileSync( + "lsof", + ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN", "-t"], + { encoding: "utf8", timeout: 3000 }, + ).trim(); + return out ? out.split(/\n+/).filter(Boolean) : []; + } catch { + return []; + } +} + +function hasSetsid() { + try { + execSync("command -v setsid >/dev/null 2>&1", { shell: true }); + return true; + } catch { + return false; + } +} + +function detachStart() { + if (!existsSync(SERVE)) { + throw new Error(`Missing server entry: ${SERVE}`); + } + + if (hasSetsid()) { + const out = openSync(LOG, "a"); + const child = spawn("setsid", ["node", SERVE], { + cwd: ROOT, + detached: true, + stdio: ["ignore", out, out], + env: process.env, + }); + child.unref(); + return; + } + + // macOS and other hosts without setsid: Perl double-fork + setsid(). + const rootEsc = ROOT.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const serveEsc = SERVE.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const logEsc = LOG.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const perl = [ + "use POSIX qw(setsid);", + "exit if fork;", + "setsid();", + "exit if fork;", + 'open(STDIN,"<","/dev/null");', + `open(STDOUT,">","${logEsc}");`, + 'open(STDERR,">&STDOUT");', + `chdir("${rootEsc}");`, + `exec("node","${serveEsc}");`, + ].join(" "); + + const child = spawn("perl", ["-e", perl], { + cwd: ROOT, + detached: true, + stdio: "ignore", + env: process.env, + }); + child.unref(); +} + +async function waitReady() { + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (probeHttp()) return true; + await new Promise((r) => setTimeout(r, READY_POLL_MS)); + } + return false; +} + +function openBrowser(url) { + const os = platform(); + try { + if (os === "darwin") { + spawn("open", [url], { detached: true, stdio: "ignore" }).unref(); + return true; + } + if (os === "win32") { + spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); + return true; + } + spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref(); + return true; + } catch { + return false; + } +} + +async function main() { + const already = probeHttp() || listeningPids().length > 0; + if (!already) { + console.log(`Starting Mission Control on ${URL}…`); + detachStart(); + const ready = await waitReady(); + if (!ready) { + console.error( + `Mission Control did not answer ${URL} within ${READY_TIMEOUT_MS}ms.`, + ); + console.error(`Check the log: ${LOG}`); + process.exit(1); + } + } else { + console.log(`Mission Control already listening at ${URL}`); + } + + console.log(URL); + if (process.env.MISSION_CONTROL_NO_OPEN === "1") { + return; + } + if (openBrowser(URL)) { + console.log( + "Opened in the default browser. In Cursor, Simple Browser or /dashboard also works.", + ); + } else { + console.log( + "Open that URL in a browser (Cursor: Simple Browser, or run /dashboard in chat).", + ); + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/package.json b/package.json index 51408ce..039cc13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-kit", - "version": "4.7.0", + "version": "4.7.2", "description": "HITL framework for AI-assisted IDEs: plan, handoff, staging-to-prod, memory loop; project-aware setup for Cursor, VS Code, and Windsurf.", "private": true, "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index b5afe0e..4f7898a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@dadado/agent-kit-cli", - "version": "4.7.0", + "version": "4.7.2", "description": "Agent Kit CLI: HITL framework install and tooling for AI-assisted IDEs (rules, skills, plan/handoff, context).", "type": "module", "bin": { @@ -8,7 +8,9 @@ }, "main": "./dist/index.js", "types": "./dist/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsup src/index.ts --format esm --dts --clean", "dev": "tsx src/index.ts", diff --git a/packages/cli/src/dashboard/public-sync-manifest-guard.test.ts b/packages/cli/src/dashboard/public-sync-manifest-guard.test.ts new file mode 100644 index 0000000..d9ac498 --- /dev/null +++ b/packages/cli/src/dashboard/public-sync-manifest-guard.test.ts @@ -0,0 +1,134 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(fileURLToPath(import.meta.url), "../../../../.."); +const dashboardTestsDir = join(repoRoot, "packages/cli/src/dashboard"); +const manifestPath = join(repoRoot, "scripts/public-sync.manifest"); + +/** Same glob semantics as scripts/sync-public.mjs. */ +function globToRegex(glob: string): RegExp { + const re = glob + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, "\0") + .replace(/\*/g, "[^/]*") + .replace(/\0/g, ".*"); + return new RegExp(`^${re}$`); +} + +function parseManifest(filepath: string): { includes: string[]; excludes: string[] } { + const includes: string[] = []; + const excludes: string[] = []; + for (const raw of readFileSync(filepath, "utf8").split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("!")) excludes.push(line.slice(1)); + else includes.push(line); + } + return { includes, excludes }; +} + +function isAllowlisted( + relPath: string, + manifest: { includes: string[]; excludes: string[] }, +): boolean { + const included = manifest.includes.some((p) => globToRegex(p).test(relPath)); + if (!included) return false; + return !manifest.excludes.some((p) => globToRegex(p).test(relPath)); +} + +function extractLocalStaticUrls(html: string): string[] { + const urls = new Set(); + const attrRe = /\b(?:src|href)=["']([^"']+)["']/gi; + for (const match of html.matchAll(attrRe)) { + const raw = match[1].trim(); + if (!raw.startsWith("/")) continue; + if (raw.startsWith("//")) continue; + urls.add(raw.split("?")[0].split("#")[0]); + } + return [...urls]; +} + +/** Repo-relative dashboard paths required by synced CLI dashboard tests. */ +function collectRequiredDashboardPaths(testSources: string[]): string[] { + const required = new Set(); + + for (const source of testSources) { + for (const match of source.matchAll(/from\s+["'](?:\.\.\/)+dashboard\/([^"']+)["']/g)) { + required.add(`dashboard/${match[1]}`); + } + for (const match of source.matchAll( + /(?:resolve|join)\(\s*repoRoot\s*,\s*["']dashboard\/([^"']+)["']\s*\)/g, + )) { + required.add(`dashboard/${match[1]}`); + } + for (const match of source.matchAll( + /join\(\s*(?:dashboardDir|dashboardReal)\s*,\s*["']([^"']+)["']\s*\)/g, + )) { + required.add(`dashboard/${match[1]}`); + } + for (const match of source.matchAll(/join\(\s*repoRoot\s*,\s*["']dashboard["']\s*\)/g)) { + void match; + required.add("dashboard"); + } + } + + // Tests that read panel HTML also require every local static asset under dashboard/. + for (const source of testSources) { + const htmlReads = [ + ...source.matchAll( + /(?:resolve|join)\(\s*repoRoot\s*,\s*["']dashboard\/([^"']+\.html)["']\s*\)/g, + ), + ...source.matchAll( + /join\(\s*(?:dashboardDir|dashboardReal)\s*,\s*["']([^"']+\.html)["']\s*\)/g, + ), + ]; + for (const match of htmlReads) { + const htmlRel = match[1].startsWith("dashboard/") ? match[1] : `dashboard/${match[1]}`; + required.add(htmlRel); + const htmlAbs = join(repoRoot, htmlRel); + try { + const html = readFileSync(htmlAbs, "utf8"); + for (const url of extractLocalStaticUrls(html)) { + required.add(`dashboard${url}`); + } + } catch { + // Missing HTML is a separate failure; allowlist guard still lists the path. + } + } + } + + return [...required].sort(); +} + +describe("public-sync dashboard allowlist guard", () => { + it("allowlists every dashboard path required by packages/cli/src/dashboard/*.test.ts", () => { + // Manifest is private-factory only; public mirror never receives it. + if (!existsSync(manifestPath)) { + return; + } + + const testFiles = readdirSync(dashboardTestsDir) + .filter((name) => name.endsWith(".test.ts")) + .map((name) => join(dashboardTestsDir, name)); + + expect(testFiles.length).toBeGreaterThan(0); + + const sources = testFiles.map((file) => readFileSync(file, "utf8")); + const required = collectRequiredDashboardPaths(sources); + const manifest = parseManifest(manifestPath); + + expect(required.length).toBeGreaterThan(0); + + const missing = required.filter((relPath) => { + // Directory marker from join(repoRoot, "dashboard") is covered by dashboard/** + if (relPath === "dashboard") { + return !manifest.includes.some((p) => p === "dashboard/**" || p === "dashboard"); + } + return !isAllowlisted(relPath, manifest); + }); + + expect(missing, `Missing from public-sync.manifest:\n${missing.join("\n")}`).toEqual([]); + }); +});