tlc-claude-code 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.claude/agents/builder.md +144 -0
  2. package/.claude/agents/planner.md +143 -0
  3. package/.claude/agents/reviewer.md +160 -0
  4. package/.claude/commands/tlc/build.md +4 -0
  5. package/.claude/commands/tlc/deploy.md +194 -2
  6. package/.claude/commands/tlc/e2e-verify.md +214 -0
  7. package/.claude/commands/tlc/guard.md +191 -0
  8. package/.claude/commands/tlc/help.md +32 -0
  9. package/.claude/commands/tlc/init.md +73 -37
  10. package/.claude/commands/tlc/llm.md +19 -4
  11. package/.claude/commands/tlc/preflight.md +134 -0
  12. package/.claude/commands/tlc/review-plan.md +363 -0
  13. package/.claude/commands/tlc/review.md +172 -57
  14. package/.claude/commands/tlc/watchci.md +159 -0
  15. package/.claude/hooks/tlc-block-tools.sh +41 -0
  16. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  17. package/.claude/hooks/tlc-post-build.sh +38 -0
  18. package/.claude/hooks/tlc-post-push.sh +22 -0
  19. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  20. package/.claude/hooks/tlc-session-init.sh +123 -0
  21. package/CLAUDE.md +13 -0
  22. package/bin/install.js +268 -2
  23. package/bin/postinstall.js +102 -24
  24. package/bin/setup-autoupdate.js +206 -0
  25. package/bin/setup-autoupdate.test.js +124 -0
  26. package/bin/tlc.js +0 -0
  27. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  28. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  29. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  30. package/dashboard-web/dist/index.html +2 -2
  31. package/docker-compose.dev.yml +18 -12
  32. package/package.json +4 -2
  33. package/scripts/project-docs.js +1 -1
  34. package/server/index.js +228 -2
  35. package/server/lib/capture-bridge.js +242 -0
  36. package/server/lib/capture-bridge.test.js +363 -0
  37. package/server/lib/capture-guard.js +140 -0
  38. package/server/lib/capture-guard.test.js +182 -0
  39. package/server/lib/command-runner.js +159 -0
  40. package/server/lib/command-runner.test.js +92 -0
  41. package/server/lib/cost-tracker.test.js +49 -12
  42. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  43. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  44. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  45. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  46. package/server/lib/deploy/security-gates.js +11 -24
  47. package/server/lib/deploy/security-gates.test.js +9 -2
  48. package/server/lib/deploy-engine.js +182 -0
  49. package/server/lib/deploy-engine.test.js +147 -0
  50. package/server/lib/docker-api.js +137 -0
  51. package/server/lib/docker-api.test.js +202 -0
  52. package/server/lib/docker-client.js +297 -0
  53. package/server/lib/docker-client.test.js +308 -0
  54. package/server/lib/input-sanitizer.js +86 -0
  55. package/server/lib/input-sanitizer.test.js +117 -0
  56. package/server/lib/launchd-agent.js +225 -0
  57. package/server/lib/launchd-agent.test.js +185 -0
  58. package/server/lib/memory-api.js +3 -1
  59. package/server/lib/memory-api.test.js +3 -5
  60. package/server/lib/memory-bridge-e2e.test.js +160 -0
  61. package/server/lib/memory-committer.js +18 -4
  62. package/server/lib/memory-committer.test.js +21 -0
  63. package/server/lib/memory-hooks-capture.test.js +69 -4
  64. package/server/lib/memory-hooks-integration.test.js +98 -0
  65. package/server/lib/memory-hooks.js +42 -4
  66. package/server/lib/memory-store-adapter.js +105 -0
  67. package/server/lib/memory-store-adapter.test.js +141 -0
  68. package/server/lib/memory-wiring-e2e.test.js +93 -0
  69. package/server/lib/nginx-config.js +114 -0
  70. package/server/lib/nginx-config.test.js +82 -0
  71. package/server/lib/ollama-health.js +91 -0
  72. package/server/lib/ollama-health.test.js +74 -0
  73. package/server/lib/orchestration/agent-dispatcher.js +114 -0
  74. package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
  75. package/server/lib/orchestration/orchestrator.js +130 -0
  76. package/server/lib/orchestration/orchestrator.test.js +192 -0
  77. package/server/lib/orchestration/tmux-manager.js +101 -0
  78. package/server/lib/orchestration/tmux-manager.test.js +109 -0
  79. package/server/lib/orchestration/worktree-manager.js +132 -0
  80. package/server/lib/orchestration/worktree-manager.test.js +129 -0
  81. package/server/lib/port-guard.js +44 -0
  82. package/server/lib/port-guard.test.js +65 -0
  83. package/server/lib/project-scanner.js +37 -2
  84. package/server/lib/project-scanner.test.js +152 -0
  85. package/server/lib/remember-command.js +2 -0
  86. package/server/lib/remember-command.test.js +23 -0
  87. package/server/lib/review/plan-reviewer.js +260 -0
  88. package/server/lib/review/plan-reviewer.test.js +269 -0
  89. package/server/lib/review/review-schemas.js +173 -0
  90. package/server/lib/review/review-schemas.test.js +152 -0
  91. package/server/lib/security/crypto-utils.test.js +2 -2
  92. package/server/lib/semantic-recall.js +1 -1
  93. package/server/lib/semantic-recall.test.js +17 -0
  94. package/server/lib/ssh-client.js +184 -0
  95. package/server/lib/ssh-client.test.js +127 -0
  96. package/server/lib/vps-api.js +184 -0
  97. package/server/lib/vps-api.test.js +208 -0
  98. package/server/lib/vps-bootstrap.js +124 -0
  99. package/server/lib/vps-bootstrap.test.js +79 -0
  100. package/server/lib/vps-monitor.js +126 -0
  101. package/server/lib/vps-monitor.test.js +98 -0
  102. package/server/lib/workspace-api.js +182 -1
  103. package/server/lib/workspace-api.test.js +474 -0
  104. package/server/package-lock.json +737 -0
  105. package/server/package.json +3 -0
  106. package/server/setup.sh +271 -271
  107. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  108. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  109. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
@@ -0,0 +1,124 @@
1
+ /**
2
+ * VPS Bootstrap — idempotent server setup via SSH
3
+ * Phase 80 Task 5
4
+ */
5
+
6
+ const { isValidUsername } = require('./input-sanitizer.js');
7
+
8
+ /**
9
+ * Generate idempotent bootstrap bash script
10
+ * @param {Object} options
11
+ * @param {string} [options.deployUser=deploy] - Deploy user to create
12
+ * @returns {string} Bash script
13
+ */
14
+ function generateBootstrapScript(options = {}) {
15
+ const deployUser = options.deployUser || 'deploy';
16
+ if (!isValidUsername(deployUser)) throw new Error(`Invalid deploy user: ${deployUser}`);
17
+
18
+ return `#!/bin/bash
19
+ set -e
20
+
21
+ echo "=== TLC VPS Bootstrap ==="
22
+ echo "Date: $(date)"
23
+
24
+ # Step 1: Update packages
25
+ echo "[1/7] Updating packages..."
26
+ apt-get update -y && apt-get upgrade -y
27
+
28
+ # Step 2: Install Docker (idempotent)
29
+ echo "[2/7] Installing Docker..."
30
+ if command -v docker &>/dev/null; then
31
+ echo " Docker already installed: $(docker --version)"
32
+ else
33
+ curl -fsSL https://get.docker.com | sh
34
+ systemctl enable docker
35
+ systemctl start docker
36
+ echo " Docker installed: $(docker --version)"
37
+ fi
38
+
39
+ # Install Docker Compose plugin
40
+ if docker compose version &>/dev/null; then
41
+ echo " Docker Compose already installed"
42
+ else
43
+ apt-get install -y docker-compose-plugin
44
+ fi
45
+
46
+ # Step 3: Install Nginx (idempotent)
47
+ echo "[3/7] Installing Nginx..."
48
+ if command -v nginx &>/dev/null; then
49
+ echo " Nginx already installed: $(nginx -v 2>&1)"
50
+ else
51
+ apt-get install -y nginx
52
+ systemctl enable nginx
53
+ systemctl start nginx
54
+ echo " Nginx installed"
55
+ fi
56
+
57
+ # Step 4: Install Certbot (idempotent)
58
+ echo "[4/7] Installing Certbot..."
59
+ if command -v certbot &>/dev/null; then
60
+ echo " Certbot already installed: $(certbot --version 2>&1)"
61
+ else
62
+ apt-get install -y certbot python3-certbot-nginx
63
+ echo " Certbot installed"
64
+ fi
65
+
66
+ # Step 5: Configure UFW firewall
67
+ echo "[5/7] Configuring firewall..."
68
+ apt-get install -y ufw
69
+ ufw default deny incoming
70
+ ufw default allow outgoing
71
+ ufw allow 22/tcp
72
+ ufw allow 80/tcp
73
+ ufw allow 443/tcp
74
+ echo "y" | ufw enable
75
+ echo " Firewall configured (22, 80, 443)"
76
+
77
+ # Step 6: Create deploy user (idempotent)
78
+ echo "[6/7] Setting up deploy user..."
79
+ if id "${deployUser}" &>/dev/null; then
80
+ echo " User ${deployUser} already exists"
81
+ else
82
+ useradd -m -s /bin/bash ${deployUser}
83
+ usermod -aG docker ${deployUser}
84
+ mkdir -p /home/${deployUser}/.ssh
85
+ chmod 700 /home/${deployUser}/.ssh
86
+ # Copy authorized keys from root if available
87
+ if [ -f /root/.ssh/authorized_keys ]; then
88
+ cp /root/.ssh/authorized_keys /home/${deployUser}/.ssh/
89
+ chown -R ${deployUser}:${deployUser} /home/${deployUser}/.ssh
90
+ chmod 600 /home/${deployUser}/.ssh/authorized_keys
91
+ fi
92
+ echo " User ${deployUser} created and added to docker group"
93
+ fi
94
+
95
+ # Step 7: Create deployment directories
96
+ echo "[7/7] Setting up deployment directories..."
97
+ mkdir -p /opt/deploys
98
+ chown ${deployUser}:${deployUser} /opt/deploys
99
+
100
+ echo ""
101
+ echo "=== Bootstrap Complete ==="
102
+ echo "Docker: $(docker --version 2>/dev/null || echo 'not installed')"
103
+ echo "Nginx: $(nginx -v 2>&1 || echo 'not installed')"
104
+ echo "Certbot: $(certbot --version 2>&1 || echo 'not installed')"
105
+ echo "UFW: $(ufw status | head -1)"
106
+ echo "User: ${deployUser}"
107
+ `;
108
+ }
109
+
110
+ /**
111
+ * Parse bootstrap status from SSH command outputs
112
+ * @param {Object} output - { docker, nginx, certbot, ufw }
113
+ * @returns {Object} { docker, nginx, certbot, firewall }
114
+ */
115
+ function checkBootstrapStatus(output) {
116
+ return {
117
+ docker: !!(output.docker && !output.docker.includes('not found') && output.docker.includes('version')),
118
+ nginx: !!(output.nginx && !output.nginx.includes('not found') && output.nginx.match(/nginx/i)),
119
+ certbot: !!(output.certbot && !output.certbot.includes('not found') && output.certbot.match(/certbot/i)),
120
+ firewall: !!(output.ufw && output.ufw.includes('active') && !output.ufw.includes('inactive')),
121
+ };
122
+ }
123
+
124
+ module.exports = { generateBootstrapScript, checkBootstrapStatus };
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ const { generateBootstrapScript, checkBootstrapStatus } = await import('./vps-bootstrap.js');
4
+
5
+ describe('VPS Bootstrap', () => {
6
+ describe('generateBootstrapScript', () => {
7
+ it('returns a valid bash script', () => {
8
+ const script = generateBootstrapScript({ deployUser: 'deploy' });
9
+ expect(script).toContain('#!/bin/bash');
10
+ });
11
+
12
+ it('includes Docker install step', () => {
13
+ const script = generateBootstrapScript({});
14
+ expect(script).toContain('docker');
15
+ expect(script).toMatch(/get.docker.com|install.*docker/i);
16
+ });
17
+
18
+ it('includes Nginx install step', () => {
19
+ const script = generateBootstrapScript({});
20
+ expect(script).toContain('nginx');
21
+ expect(script).toMatch(/apt.*install.*nginx|install.*nginx/i);
22
+ });
23
+
24
+ it('includes UFW firewall config (ports 22, 80, 443)', () => {
25
+ const script = generateBootstrapScript({});
26
+ expect(script).toContain('ufw');
27
+ expect(script).toContain('22');
28
+ expect(script).toContain('80');
29
+ expect(script).toContain('443');
30
+ });
31
+
32
+ it('includes Certbot install for SSL', () => {
33
+ const script = generateBootstrapScript({});
34
+ expect(script).toContain('certbot');
35
+ });
36
+
37
+ it('creates deploy user when specified', () => {
38
+ const script = generateBootstrapScript({ deployUser: 'deploy' });
39
+ expect(script).toContain('deploy');
40
+ expect(script).toMatch(/useradd|adduser/);
41
+ });
42
+
43
+ it('is idempotent (checks before installing)', () => {
44
+ const script = generateBootstrapScript({});
45
+ // Should check if Docker already installed
46
+ expect(script).toMatch(/which docker|command -v docker|docker.*--version/);
47
+ });
48
+ });
49
+
50
+ describe('checkBootstrapStatus', () => {
51
+ it('parses installed components from SSH output', () => {
52
+ const output = {
53
+ docker: 'Docker version 24.0.0, build abc123',
54
+ nginx: 'nginx/1.22.1',
55
+ certbot: 'certbot 2.0.0',
56
+ ufw: 'Status: active',
57
+ };
58
+ const status = checkBootstrapStatus(output);
59
+ expect(status.docker).toBe(true);
60
+ expect(status.nginx).toBe(true);
61
+ expect(status.certbot).toBe(true);
62
+ expect(status.firewall).toBe(true);
63
+ });
64
+
65
+ it('detects missing components', () => {
66
+ const output = {
67
+ docker: 'command not found',
68
+ nginx: 'command not found',
69
+ certbot: 'command not found',
70
+ ufw: 'Status: inactive',
71
+ };
72
+ const status = checkBootstrapStatus(output);
73
+ expect(status.docker).toBe(false);
74
+ expect(status.nginx).toBe(false);
75
+ expect(status.certbot).toBe(false);
76
+ expect(status.firewall).toBe(false);
77
+ });
78
+ });
79
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * VPS Monitor — server metrics collection and alerting
3
+ * Phase 80 Task 9
4
+ */
5
+
6
+ const { isValidDomain } = require('./input-sanitizer.js');
7
+
8
+ /**
9
+ * Create VPS monitor
10
+ * @param {Object} options
11
+ * @param {Object} options.sshClient - SSH client instance
12
+ * @returns {Object} VPS monitor API
13
+ */
14
+ function createVpsMonitor({ sshClient }) {
15
+
16
+ /**
17
+ * Collect server metrics via SSH
18
+ * @param {Object} sshConfig
19
+ * @returns {Promise<Object>} metrics
20
+ */
21
+ async function getServerMetrics(sshConfig) {
22
+ const [dfResult, freeResult, cpuResult, uptimeResult, dockerResult] = await Promise.all([
23
+ sshClient.exec(sshConfig, "df -h / | tail -1"),
24
+ sshClient.exec(sshConfig, "free -k | head -2"),
25
+ sshClient.exec(sshConfig, "cat /proc/stat | head -1"),
26
+ sshClient.exec(sshConfig, "uptime"),
27
+ sshClient.exec(sshConfig, "docker ps --format json 2>/dev/null || echo '[]'"),
28
+ ]);
29
+
30
+ // Parse disk
31
+ const dfParts = dfResult.stdout.trim().split(/\s+/);
32
+ const diskPercent = parseInt((dfParts[4] || '0').replace('%', ''), 10);
33
+
34
+ // Parse memory
35
+ const memLines = freeResult.stdout.trim().split('\n');
36
+ const memParts = (memLines[1] || '').trim().split(/\s+/);
37
+ const totalKb = parseInt(memParts[1] || '0', 10);
38
+ const usedKb = parseInt(memParts[2] || '0', 10);
39
+
40
+ // Parse CPU
41
+ const cpuParts = cpuResult.stdout.trim().split(/\s+/).slice(1).map(Number);
42
+ const cpuTotal = cpuParts.reduce((a, b) => a + b, 0);
43
+ const cpuIdle = cpuParts[3] || 0;
44
+ const cpuPercent = cpuTotal > 0 ? Math.round(((cpuTotal - cpuIdle) / cpuTotal) * 100) : 0;
45
+
46
+ // Parse containers
47
+ let containers = [];
48
+ try {
49
+ const dockerOut = dockerResult.stdout.trim();
50
+ if (dockerOut.startsWith('[')) {
51
+ containers = JSON.parse(dockerOut);
52
+ } else if (dockerOut.startsWith('{')) {
53
+ containers = dockerOut.split('\n').filter(Boolean).map(l => JSON.parse(l));
54
+ }
55
+ } catch {}
56
+
57
+ return {
58
+ disk: { usedPercent: diskPercent, raw: dfResult.stdout.trim() },
59
+ memory: { totalKb, usedKb, usedPercent: totalKb > 0 ? Math.round((usedKb / totalKb) * 100) : 0 },
60
+ cpu: { usedPercent: cpuPercent },
61
+ uptime: uptimeResult.stdout.trim(),
62
+ containers: containers.map(c => ({
63
+ name: c.Names || c.name || '',
64
+ state: c.State || c.state || 'unknown',
65
+ status: c.Status || c.status || '',
66
+ })),
67
+ timestamp: new Date().toISOString(),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Check for alert conditions
73
+ * @param {Object} metrics
74
+ * @returns {Array} alerts
75
+ */
76
+ function checkAlerts(metrics) {
77
+ const alerts = [];
78
+
79
+ // Disk alerts
80
+ if (metrics.disk && metrics.disk.usedPercent > 90) {
81
+ alerts.push({ type: 'disk', level: 'critical', message: `Disk ${metrics.disk.usedPercent}% full` });
82
+ } else if (metrics.disk && metrics.disk.usedPercent > 80) {
83
+ alerts.push({ type: 'disk', level: 'warning', message: `Disk ${metrics.disk.usedPercent}% full` });
84
+ }
85
+
86
+ // Memory alerts
87
+ if (metrics.memory && metrics.memory.usedPercent > 90) {
88
+ alerts.push({ type: 'memory', level: 'warning', message: `Memory ${metrics.memory.usedPercent}% used` });
89
+ }
90
+
91
+ // Container alerts
92
+ if (metrics.containers) {
93
+ for (const c of metrics.containers) {
94
+ if (c.state === 'exited' && c.exitCode !== undefined && c.exitCode !== 0) {
95
+ alerts.push({ type: 'container', level: 'critical', message: `Container ${c.name} crashed (exit code ${c.exitCode})` });
96
+ }
97
+ }
98
+ }
99
+
100
+ return alerts;
101
+ }
102
+
103
+ /**
104
+ * Check SSL certificate expiry
105
+ * @param {Object} sshConfig
106
+ * @param {string} domain
107
+ * @returns {Promise<Object>}
108
+ */
109
+ async function checkSslExpiry(sshConfig, domain) {
110
+ if (!isValidDomain(domain)) throw new Error(`Invalid domain: ${domain}`);
111
+ const result = await sshClient.exec(
112
+ sshConfig,
113
+ `openssl x509 -enddate -noout -in /etc/letsencrypt/live/${domain}/fullchain.pem 2>/dev/null || echo "not found"`
114
+ );
115
+
116
+ const match = result.stdout.match(/notAfter=(.+)/);
117
+ const expiresAt = match ? match[1].trim() : null;
118
+ const daysLeft = expiresAt ? Math.floor((new Date(expiresAt) - new Date()) / 86400000) : null;
119
+
120
+ return { domain, expiresAt, daysLeft, warning: daysLeft !== null && daysLeft < 14 };
121
+ }
122
+
123
+ return { getServerMetrics, checkAlerts, checkSslExpiry };
124
+ }
125
+
126
+ module.exports = { createVpsMonitor };
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+
3
+ const { createVpsMonitor } = await import('./vps-monitor.js');
4
+
5
+ function createMockSsh() {
6
+ return {
7
+ exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }),
8
+ };
9
+ }
10
+
11
+ describe('VPS Monitor', () => {
12
+ let monitor;
13
+ let mockSsh;
14
+
15
+ beforeEach(() => {
16
+ mockSsh = createMockSsh();
17
+ monitor = createVpsMonitor({ sshClient: mockSsh });
18
+ });
19
+
20
+ describe('getServerMetrics', () => {
21
+ it('parses disk usage from df output', async () => {
22
+ mockSsh.exec.mockImplementation(async (config, cmd) => {
23
+ if (cmd.includes('df')) return { stdout: '/dev/sda1 50G 20G 28G 42% /', stderr: '', exitCode: 0 };
24
+ if (cmd.includes('free')) return { stdout: ' total used free\nMem: 8000000 4000000 3000000', stderr: '', exitCode: 0 };
25
+ if (cmd.includes('nproc')) return { stdout: '4', stderr: '', exitCode: 0 };
26
+ if (cmd.includes('/proc/stat')) return { stdout: 'cpu 1000 200 300 7000 100 0 0 0', stderr: '', exitCode: 0 };
27
+ if (cmd.includes('uptime')) return { stdout: ' 12:00:00 up 30 days', stderr: '', exitCode: 0 };
28
+ if (cmd.includes('docker')) return { stdout: '[]', stderr: '', exitCode: 0 };
29
+ return { stdout: '', stderr: '', exitCode: 0 };
30
+ });
31
+
32
+ const metrics = await monitor.getServerMetrics({ host: '1.2.3.4', username: 'deploy' });
33
+ expect(metrics.disk).toBeDefined();
34
+ expect(metrics.disk.usedPercent).toBe(42);
35
+ });
36
+
37
+ it('parses memory from free output', async () => {
38
+ mockSsh.exec.mockImplementation(async (config, cmd) => {
39
+ if (cmd.includes('df')) return { stdout: '/dev/sda1 50G 20G 28G 42% /', stderr: '', exitCode: 0 };
40
+ if (cmd.includes('free')) return { stdout: ' total used free\nMem: 8000000 4000000 3000000', stderr: '', exitCode: 0 };
41
+ if (cmd.includes('nproc')) return { stdout: '4', stderr: '', exitCode: 0 };
42
+ if (cmd.includes('/proc/stat')) return { stdout: 'cpu 1000 200 300 7000 100 0 0 0', stderr: '', exitCode: 0 };
43
+ if (cmd.includes('uptime')) return { stdout: ' 12:00:00 up 30 days', stderr: '', exitCode: 0 };
44
+ if (cmd.includes('docker')) return { stdout: '[]', stderr: '', exitCode: 0 };
45
+ return { stdout: '', stderr: '', exitCode: 0 };
46
+ });
47
+
48
+ const metrics = await monitor.getServerMetrics({ host: '1.2.3.4', username: 'deploy' });
49
+ expect(metrics.memory).toBeDefined();
50
+ expect(metrics.memory.totalKb).toBe(8000000);
51
+ expect(metrics.memory.usedKb).toBe(4000000);
52
+ });
53
+ });
54
+
55
+ describe('checkAlerts', () => {
56
+ it('returns warning for disk > 80%', () => {
57
+ const alerts = monitor.checkAlerts({ disk: { usedPercent: 85 }, memory: { usedPercent: 50 }, containers: [] });
58
+ expect(alerts.some(a => a.level === 'warning' && a.type === 'disk')).toBe(true);
59
+ });
60
+
61
+ it('returns critical for disk > 90%', () => {
62
+ const alerts = monitor.checkAlerts({ disk: { usedPercent: 95 }, memory: { usedPercent: 50 }, containers: [] });
63
+ expect(alerts.some(a => a.level === 'critical' && a.type === 'disk')).toBe(true);
64
+ });
65
+
66
+ it('returns alert for crashed container', () => {
67
+ const alerts = monitor.checkAlerts({
68
+ disk: { usedPercent: 30 },
69
+ memory: { usedPercent: 50 },
70
+ containers: [{ name: 'myapp', state: 'exited', exitCode: 1 }],
71
+ });
72
+ expect(alerts.some(a => a.type === 'container')).toBe(true);
73
+ });
74
+
75
+ it('returns no alerts when everything is healthy', () => {
76
+ const alerts = monitor.checkAlerts({
77
+ disk: { usedPercent: 30 },
78
+ memory: { usedPercent: 50 },
79
+ containers: [{ name: 'myapp', state: 'running', exitCode: 0 }],
80
+ });
81
+ expect(alerts).toHaveLength(0);
82
+ });
83
+ });
84
+
85
+ describe('checkSslExpiry', () => {
86
+ it('parses cert expiry date', async () => {
87
+ mockSsh.exec.mockResolvedValue({
88
+ stdout: 'notAfter=Mar 15 00:00:00 2026 GMT',
89
+ stderr: '',
90
+ exitCode: 0,
91
+ });
92
+
93
+ const expiry = await monitor.checkSslExpiry({ host: '1.2.3.4', username: 'deploy' }, 'myapp.dev');
94
+ expect(expiry.domain).toBe('myapp.dev');
95
+ expect(expiry.expiresAt).toBeTruthy();
96
+ });
97
+ });
98
+ });
@@ -16,6 +16,8 @@ const { createTestInventory } = require('./test-inventory');
16
16
  const { createRoadmapApi } = require('./roadmap-api');
17
17
  const { createPlanWriter } = require('./plan-writer');
18
18
  const { createBugWriter } = require('./bug-writer');
19
+ const { createMemoryStoreAdapter } = require('./memory-store-adapter');
20
+ const { createCaptureGuard } = require('./capture-guard');
19
21
 
20
22
  /**
21
23
  * Encode a project path to a URL-safe project ID
@@ -62,6 +64,7 @@ function readProjectStatus(projectPath) {
62
64
  phaseName: null,
63
65
  totalPhases: 0,
64
66
  completedPhases: 0,
67
+ coverage: null,
65
68
  };
66
69
 
67
70
  if (!status.exists) {
@@ -128,6 +131,19 @@ function readProjectStatus(projectPath) {
128
131
  }
129
132
  }
130
133
 
134
+ // Read coverage from Istanbul coverage-summary.json
135
+ const coveragePath = path.join(projectPath, 'coverage', 'coverage-summary.json');
136
+ if (fs.existsSync(coveragePath)) {
137
+ try {
138
+ const covData = JSON.parse(fs.readFileSync(coveragePath, 'utf-8'));
139
+ if (covData.total && covData.total.lines && typeof covData.total.lines.pct === 'number') {
140
+ status.coverage = covData.total.lines.pct;
141
+ }
142
+ } catch {
143
+ // Malformed coverage file — leave as null
144
+ }
145
+ }
146
+
131
147
  return status;
132
148
  }
133
149
 
@@ -292,7 +308,7 @@ function readProjectBugs(projectPath) {
292
308
  * @returns {express.Router} Express router with workspace endpoints
293
309
  */
294
310
  function createWorkspaceRouter(options = {}) {
295
- const { globalConfig, projectScanner } = options;
311
+ const { globalConfig, projectScanner, memoryApi, memoryDeps = {} } = options;
296
312
 
297
313
  if (!globalConfig) {
298
314
  throw new Error('globalConfig is required');
@@ -302,6 +318,7 @@ function createWorkspaceRouter(options = {}) {
302
318
  }
303
319
 
304
320
  const router = express.Router();
321
+ const captureGuard = createCaptureGuard();
305
322
 
306
323
  // =========================================================================
307
324
  // GET /config - Returns workspace configuration
@@ -805,6 +822,170 @@ function createWorkspaceRouter(options = {}) {
805
822
  }
806
823
  });
807
824
 
825
+ // =========================================================================
826
+ // Memory API routes (Phase 77, fixed per-project in Phase 78)
827
+ // =========================================================================
828
+ if (memoryApi) {
829
+ router.get('/projects/:projectId/memory/decisions', async (req, res) => {
830
+ try {
831
+ const roots = globalConfig.getRoots();
832
+ const project = findProjectById(projectScanner, roots, req.params.projectId);
833
+ if (!project) return res.status(404).json({ error: 'Project not found' });
834
+ const adapter = createMemoryStoreAdapter(project.path);
835
+ const decisions = await adapter.listDecisions();
836
+ res.json({ decisions });
837
+ } catch (err) {
838
+ res.status(500).json({ error: err.message });
839
+ }
840
+ });
841
+
842
+ router.get('/projects/:projectId/memory/gotchas', async (req, res) => {
843
+ try {
844
+ const roots = globalConfig.getRoots();
845
+ const project = findProjectById(projectScanner, roots, req.params.projectId);
846
+ if (!project) return res.status(404).json({ error: 'Project not found' });
847
+ const adapter = createMemoryStoreAdapter(project.path);
848
+ const gotchas = await adapter.listGotchas();
849
+ res.json({ gotchas });
850
+ } catch (err) {
851
+ res.status(500).json({ error: err.message });
852
+ }
853
+ });
854
+
855
+ router.get('/projects/:projectId/memory/stats', async (req, res) => {
856
+ try {
857
+ const roots = globalConfig.getRoots();
858
+ const project = findProjectById(projectScanner, roots, req.params.projectId);
859
+ if (!project) return res.status(404).json({ error: 'Project not found' });
860
+ const adapter = createMemoryStoreAdapter(project.path);
861
+ const stats = await adapter.getStats();
862
+ res.json(stats);
863
+ } catch (err) {
864
+ res.status(500).json({ error: err.message });
865
+ }
866
+ });
867
+ }
868
+
869
+ // =========================================================================
870
+ // Memory capture endpoint (Phase 79 Task 5)
871
+ // =========================================================================
872
+ router.post('/projects/:projectId/memory/capture', async (req, res) => {
873
+ try {
874
+ const projectId = req.params.projectId;
875
+
876
+ // Rate limit check
877
+ const rateCheck = captureGuard.checkRateLimit(projectId);
878
+ if (!rateCheck.ok) {
879
+ return res.status(rateCheck.status).json({ error: rateCheck.error });
880
+ }
881
+
882
+ // Payload validation (size + structure)
883
+ const validation = captureGuard.validate(req.body, projectId);
884
+ if (!validation.ok) {
885
+ return res.status(validation.status).json({ error: validation.error });
886
+ }
887
+
888
+ const roots = globalConfig.getRoots();
889
+ const project = findProjectById(projectScanner, roots, projectId);
890
+ if (!project) return res.status(404).json({ error: 'Project not found' });
891
+
892
+ // Deduplicate exchanges
893
+ const exchanges = captureGuard.deduplicate(req.body.exchanges, projectId);
894
+
895
+ if (exchanges.length === 0) {
896
+ return res.json({ captured: 0, deduplicated: true });
897
+ }
898
+
899
+ // Process in background — respond immediately
900
+ let captured = 0;
901
+ const { observeAndRemember, vectorIndexer } = memoryDeps;
902
+
903
+ for (const exchange of exchanges) {
904
+ try {
905
+ if (typeof observeAndRemember === 'function') {
906
+ await observeAndRemember(project.path, exchange);
907
+ }
908
+ if (vectorIndexer && typeof vectorIndexer.indexChunk === 'function') {
909
+ await vectorIndexer.indexChunk(exchange);
910
+ }
911
+ captured++;
912
+ } catch {
913
+ // Individual exchange failures don't stop the batch
914
+ }
915
+ }
916
+
917
+ res.json({ captured });
918
+ } catch (err) {
919
+ res.status(500).json({ error: err.message });
920
+ }
921
+ });
922
+
923
+ // =========================================================================
924
+ // Memory search endpoint (Phase 79 Task 6)
925
+ // =========================================================================
926
+ router.get('/projects/:projectId/memory/search', async (req, res) => {
927
+ try {
928
+ const roots = globalConfig.getRoots();
929
+ const project = findProjectById(projectScanner, roots, req.params.projectId);
930
+ if (!project) return res.status(404).json({ error: 'Project not found' });
931
+
932
+ const query = req.query.q;
933
+ if (!query) {
934
+ return res.status(400).json({ error: 'Query parameter q is required' });
935
+ }
936
+
937
+ const { semanticRecall } = memoryDeps;
938
+
939
+ // Try vector-based semantic recall first
940
+ if (semanticRecall && typeof semanticRecall.recall === 'function') {
941
+ try {
942
+ const results = await semanticRecall.recall(query, { projectRoot: project.path });
943
+ return res.json({ results: results || [], source: 'vector' });
944
+ } catch {
945
+ // Fall through to file-based search
946
+ }
947
+ }
948
+
949
+ // Fallback: file-based text search
950
+ try {
951
+ const { searchMemory } = require('./memory-reader');
952
+ const results = await searchMemory(project.path, query);
953
+ return res.json({ results: results || [], source: 'file' });
954
+ } catch {
955
+ return res.json({ results: [], source: 'file' });
956
+ }
957
+ } catch (err) {
958
+ res.status(500).json({ error: err.message });
959
+ }
960
+ });
961
+
962
+ // =========================================================================
963
+ // Project file endpoint (Phase 77)
964
+ // =========================================================================
965
+ router.get('/projects/:projectId/files/:filename', (req, res) => {
966
+ try {
967
+ const roots = globalConfig.getRoots();
968
+ const project = findProjectById(projectScanner, roots, req.params.projectId);
969
+ if (!project) return res.status(404).json({ error: 'Project not found' });
970
+
971
+ const filename = req.params.filename;
972
+ // Reject path traversal
973
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
974
+ return res.status(400).json({ error: 'Invalid filename' });
975
+ }
976
+
977
+ const filePath = path.join(project.path, '.planning', filename);
978
+ if (!fs.existsSync(filePath)) {
979
+ return res.status(404).json({ error: 'File not found' });
980
+ }
981
+
982
+ const content = fs.readFileSync(filePath, 'utf-8');
983
+ res.json({ filename, content });
984
+ } catch (err) {
985
+ res.status(500).json({ error: err.message });
986
+ }
987
+ });
988
+
808
989
  return router;
809
990
  }
810
991