@stackmemoryai/stackmemory 1.5.8 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
5
  import { spawn, execSync } from "child_process";
6
6
  import {
7
+ appendFileSync,
7
8
  existsSync,
8
9
  mkdirSync,
9
10
  readFileSync,
@@ -19,6 +20,7 @@ import { createInterface } from "readline";
19
20
  import { fileURLToPath } from "url";
20
21
  import { Transform } from "stream";
21
22
  import { logger } from "../../core/monitoring/logger.js";
23
+ import { isProcessAlive } from "../../utils/process-cleanup.js";
22
24
  import {
23
25
  LinearClient
24
26
  } from "../../integrations/linear/client.js";
@@ -28,6 +30,14 @@ import {
28
30
  } from "../../core/worktree/preflight.js";
29
31
  import { ContextCapture } from "../../core/worktree/capture.js";
30
32
  import { extractKeywords } from "../../core/utils/text.js";
33
+ function getOutcomesLogPath() {
34
+ return join(homedir(), ".stackmemory", "conductor", "outcomes.jsonl");
35
+ }
36
+ function logAgentOutcome(entry) {
37
+ const dir = join(homedir(), ".stackmemory", "conductor");
38
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
39
+ appendFileSync(getOutcomesLogPath(), JSON.stringify(entry) + "\n");
40
+ }
31
41
  function findPackageRoot() {
32
42
  const currentFile = fileURLToPath(import.meta.url);
33
43
  let dir = dirname(currentFile);
@@ -635,6 +645,18 @@ class Conductor {
635
645
  await this.runAgent(issue, run);
636
646
  run.status = "completed";
637
647
  this.completeCount++;
648
+ logAgentOutcome({
649
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
650
+ issue: issue.identifier,
651
+ attempt: run.attempt,
652
+ outcome: "success",
653
+ phase: run.phase,
654
+ toolCalls: run.toolCalls,
655
+ filesModified: run.filesModified,
656
+ tokensUsed: run.tokensUsed,
657
+ durationMs: Date.now() - run.startedAt,
658
+ hasCommits: true
659
+ });
638
660
  await this.runHook(
639
661
  "after-run",
640
662
  run.workspacePath,
@@ -1271,7 +1293,32 @@ class Conductor {
1271
1293
  }
1272
1294
  }
1273
1295
  }
1296
+ /**
1297
+ * Build the agent prompt. If a custom template exists at
1298
+ * ~/.stackmemory/conductor/prompt-template.md, use it with variable
1299
+ * substitution. Otherwise fall back to the default template.
1300
+ *
1301
+ * Template variables: {{ISSUE_ID}}, {{TITLE}}, {{DESCRIPTION}},
1302
+ * {{LABELS}}, {{PRIORITY}}, {{ATTEMPT}}, {{PRIOR_CONTEXT}}
1303
+ */
1274
1304
  buildPrompt(issue, attempt) {
1305
+ const templatePath = join(
1306
+ homedir(),
1307
+ ".stackmemory",
1308
+ "conductor",
1309
+ "prompt-template.md"
1310
+ );
1311
+ const priority = ["None", "Urgent", "High", "Medium", "Low"][issue.priority] || "None";
1312
+ const labels = issue.labels.length > 0 ? issue.labels.map((l) => l.name).join(", ") : "";
1313
+ const priorContext = attempt > 1 ? `This is attempt ${attempt}. Check .stackmemory/conductor-context.md for context from prior attempts.` : "";
1314
+ if (existsSync(templatePath)) {
1315
+ try {
1316
+ let template = readFileSync(templatePath, "utf-8");
1317
+ template = template.replace(/\{\{ISSUE_ID\}\}/g, issue.identifier).replace(/\{\{TITLE\}\}/g, issue.title).replace(/\{\{DESCRIPTION\}\}/g, issue.description || "").replace(/\{\{LABELS\}\}/g, labels).replace(/\{\{PRIORITY\}\}/g, priority).replace(/\{\{ATTEMPT\}\}/g, String(attempt)).replace(/\{\{PRIOR_CONTEXT\}\}/g, priorContext);
1318
+ return template;
1319
+ } catch {
1320
+ }
1321
+ }
1275
1322
  const lines = [
1276
1323
  `You are working on Linear issue ${issue.identifier}: ${issue.title}`,
1277
1324
  ""
@@ -1279,17 +1326,12 @@ class Conductor {
1279
1326
  if (issue.description) {
1280
1327
  lines.push("## Description", "", issue.description, "");
1281
1328
  }
1282
- if (issue.labels.length > 0) {
1283
- lines.push(`Labels: ${issue.labels.map((l) => l.name).join(", ")}`);
1329
+ if (labels) {
1330
+ lines.push(`Labels: ${labels}`);
1284
1331
  }
1285
- lines.push(
1286
- `Priority: ${["None", "Urgent", "High", "Medium", "Low"][issue.priority] || "None"}`
1287
- );
1288
- if (attempt > 1) {
1289
- lines.push(
1290
- "",
1291
- `This is attempt ${attempt}. Check .stackmemory/conductor-context.md for context from prior attempts.`
1292
- );
1332
+ lines.push(`Priority: ${priority}`);
1333
+ if (priorContext) {
1334
+ lines.push("", priorContext);
1293
1335
  }
1294
1336
  lines.push(
1295
1337
  "",
@@ -1428,14 +1470,7 @@ class Conductor {
1428
1470
  if (elapsed < staleThresholdMs) continue;
1429
1471
  const elapsedMin = Math.round(elapsed / 6e4);
1430
1472
  const pid = run.process?.pid;
1431
- let alive = false;
1432
- if (pid) {
1433
- try {
1434
- process.kill(pid, 0);
1435
- alive = true;
1436
- } catch {
1437
- }
1438
- }
1473
+ const alive = pid ? isProcessAlive(pid) : false;
1439
1474
  if (!alive) {
1440
1475
  logger.warn("Stale agent process is dead, cleaning up", {
1441
1476
  identifier: run.issue.identifier,
@@ -1485,6 +1520,35 @@ class Conductor {
1485
1520
  hasCommits = log.trim().length > 0;
1486
1521
  } catch {
1487
1522
  }
1523
+ let errorTail;
1524
+ if (!hasCommits) {
1525
+ try {
1526
+ const logPath = join(
1527
+ getAgentStatusDir(run.issue.identifier),
1528
+ "output.log"
1529
+ );
1530
+ if (existsSync(logPath)) {
1531
+ const content = readFileSync(logPath, "utf-8");
1532
+ const lines = content.trim().split("\n");
1533
+ errorTail = lines.slice(-5).join("\n");
1534
+ }
1535
+ } catch {
1536
+ }
1537
+ }
1538
+ const durationMs = Date.now() - run.startedAt;
1539
+ logAgentOutcome({
1540
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1541
+ issue: run.issue.identifier,
1542
+ attempt: run.attempt,
1543
+ outcome: hasCommits ? "success" : "failure",
1544
+ phase: run.phase,
1545
+ toolCalls: run.toolCalls,
1546
+ filesModified: run.filesModified,
1547
+ tokensUsed: run.tokensUsed,
1548
+ durationMs,
1549
+ hasCommits,
1550
+ errorTail
1551
+ });
1488
1552
  if (hasCommits) {
1489
1553
  logger.info("Stale agent had commits, transitioning to In Review", {
1490
1554
  identifier: run.issue.identifier
@@ -1567,5 +1631,6 @@ class Conductor {
1567
1631
  }
1568
1632
  export {
1569
1633
  Conductor,
1570
- getAgentStatusDir
1634
+ getAgentStatusDir,
1635
+ getOutcomesLogPath
1571
1636
  };
@@ -5,6 +5,7 @@ const __dirname = __pathDirname(__filename);
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
6
6
  import { join } from "path";
7
7
  import { homedir } from "os";
8
+ import { isProcessAlive } from "../utils/process-cleanup.js";
8
9
  const DEFAULT_DAEMON_CONFIG = {
9
10
  version: "1.0.0",
10
11
  context: {
@@ -139,9 +140,7 @@ function readDaemonStatus() {
139
140
  try {
140
141
  const pidContent = readFileSync(pidFile, "utf8").trim();
141
142
  const pid = parseInt(pidContent, 10);
142
- try {
143
- process.kill(pid, 0);
144
- } catch {
143
+ if (!isProcessAlive(pid)) {
145
144
  return defaultStatus;
146
145
  }
147
146
  if (!existsSync(statusFile)) {
@@ -6,6 +6,7 @@ const __dirname = __pathDirname(__filename);
6
6
  import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import { execSync } from "child_process";
9
+ import { isProcessAlive } from "../utils/process-cleanup.js";
9
10
  class SessionDaemon {
10
11
  config;
11
12
  state;
@@ -69,16 +70,14 @@ class SessionDaemon {
69
70
  try {
70
71
  const existingPid = fs.readFileSync(this.pidFile, "utf8").trim();
71
72
  const pid = parseInt(existingPid, 10);
72
- try {
73
- process.kill(pid, 0);
73
+ if (isProcessAlive(pid)) {
74
74
  this.log("WARN", "Daemon already running for this session", {
75
75
  existingPid: pid
76
76
  });
77
77
  return false;
78
- } catch {
79
- this.log("INFO", "Cleaning up stale PID file", { stalePid: pid });
80
- fs.unlinkSync(this.pidFile);
81
78
  }
79
+ this.log("INFO", "Cleaning up stale PID file", { stalePid: pid });
80
+ fs.unlinkSync(this.pidFile);
82
81
  } catch {
83
82
  try {
84
83
  fs.unlinkSync(this.pidFile);
@@ -10,6 +10,7 @@ import {
10
10
  appendFileSync,
11
11
  readFileSync
12
12
  } from "fs";
13
+ import { isProcessAlive } from "../utils/process-cleanup.js";
13
14
  import {
14
15
  loadDaemonConfig,
15
16
  getDaemonPaths,
@@ -75,14 +76,12 @@ class UnifiedDaemon {
75
76
  try {
76
77
  const existingPid = readFileSync(this.paths.pidFile, "utf8").trim();
77
78
  const pid = parseInt(existingPid, 10);
78
- try {
79
- process.kill(pid, 0);
79
+ if (isProcessAlive(pid)) {
80
80
  this.log("WARN", "daemon", "Daemon already running", { pid });
81
81
  return false;
82
- } catch {
83
- this.log("INFO", "daemon", "Cleaning stale PID file", { pid });
84
- unlinkSync(this.paths.pidFile);
85
82
  }
83
+ this.log("INFO", "daemon", "Cleaning stale PID file", { pid });
84
+ unlinkSync(this.paths.pidFile);
86
85
  } catch {
87
86
  try {
88
87
  unlinkSync(this.paths.pidFile);
@@ -10,6 +10,7 @@ import {
10
10
  } from "./types.js";
11
11
  import { createPredictionClient } from "./prediction-client.js";
12
12
  import { logger } from "../../core/monitoring/logger.js";
13
+ import { isProcessAlive } from "../../utils/process-cleanup.js";
13
14
  const HOME = process.env["HOME"] || "/tmp";
14
15
  const PID_FILE = join(HOME, ".stackmemory", "sweep", "server.pid");
15
16
  const LOG_FILE = join(HOME, ".stackmemory", "sweep", "server.log");
@@ -149,9 +150,7 @@ Download with: huggingface-cli download sweepai/sweep-next-edit-1.5B sweep-next-
149
150
  process.kill(status.pid, "SIGTERM");
150
151
  await new Promise((resolve) => {
151
152
  const checkInterval = setInterval(() => {
152
- try {
153
- process.kill(status.pid, 0);
154
- } catch {
153
+ if (!isProcessAlive(status.pid)) {
155
154
  clearInterval(checkInterval);
156
155
  resolve();
157
156
  }
@@ -186,9 +185,7 @@ Download with: huggingface-cli download sweepai/sweep-next-edit-1.5B sweep-next-
186
185
  try {
187
186
  const data = JSON.parse(readFileSync(PID_FILE, "utf-8"));
188
187
  const { pid, port, host, modelPath, startedAt } = data;
189
- try {
190
- process.kill(pid, 0);
191
- } catch {
188
+ if (!isProcessAlive(pid)) {
192
189
  return { running: false };
193
190
  }
194
191
  const client = createPredictionClient({ port, host });
@@ -13,6 +13,7 @@ import {
13
13
  import { join, extname, relative } from "path";
14
14
  import { spawn } from "child_process";
15
15
  import { loadConfig } from "./config.js";
16
+ import { isProcessAlive } from "../utils/process-cleanup.js";
16
17
  import {
17
18
  hookEmitter
18
19
  } from "./events.js";
@@ -51,13 +52,11 @@ async function startDaemon(options = {}) {
51
52
  const pidFile = config.daemon.pid_file;
52
53
  if (existsSync(pidFile)) {
53
54
  const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
54
- try {
55
- process.kill(pid, 0);
55
+ if (isProcessAlive(pid)) {
56
56
  log("warn", "Daemon already running", { pid });
57
57
  return;
58
- } catch {
59
- unlinkSync(pidFile);
60
58
  }
59
+ unlinkSync(pidFile);
61
60
  }
62
61
  if (!options.foreground) {
63
62
  const child = spawn(
@@ -117,17 +116,15 @@ function getDaemonStatus() {
117
116
  return { running: false };
118
117
  }
119
118
  const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
120
- try {
121
- process.kill(pid, 0);
119
+ if (isProcessAlive(pid)) {
122
120
  return {
123
121
  running: true,
124
122
  pid,
125
123
  uptime: state.running ? Date.now() - state.startTime : void 0,
126
124
  eventsProcessed: state.eventsProcessed
127
125
  };
128
- } catch {
129
- return { running: false };
130
126
  }
127
+ return { running: false };
131
128
  }
132
129
  function setupLogStream() {
133
130
  const logFile = config.daemon.log_file;
@@ -7,6 +7,14 @@ import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import * as os from "os";
9
9
  import { logger } from "../core/monitoring/logger.js";
10
+ function isProcessAlive(pid) {
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
10
18
  const STACKMEMORY_PROCESS_PATTERNS = [
11
19
  "stackmemory",
12
20
  "ralph orchestrate",
@@ -131,5 +139,6 @@ export {
131
139
  cleanupStaleProcesses,
132
140
  findStaleProcesses,
133
141
  getStackmemoryProcesses,
142
+ isProcessAlive,
134
143
  killStaleProcesses
135
144
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.5.8",
3
+ "version": "1.6.0",
4
4
  "description": "Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, and automatic hooks.",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
@@ -103,38 +103,6 @@ Located at `api/app/graphql/{api,private}/v1/`
103
103
  - Use `test()` instead of `it()` in tests
104
104
  - Use `toBeCalled()` instead of `toHaveBeenCalledWith()` in jest assertions
105
105
 
106
- ## LSP Setup (Code Intelligence)
107
-
108
- Claude Code uses LSP plugins for go-to-definition, find-references, and real-time diagnostics.
109
-
110
- ### Installed Plugins
111
- - **vtsls** (JS/TS) - `vtsls@claude-code-lsps` from boostvolt marketplace
112
- - **ruby-lsp** (Ruby) - `ruby-lsp@ruby-skills` + `ruby-skills@ruby-skills` from st0012 marketplace
113
-
114
- ### Prerequisites
115
- ```bash
116
- # TypeScript/JS language server
117
- npm install -g @vtsls/language-server
118
-
119
- # Ruby language server (use rbenv gem, not system)
120
- /Users/jwu/.rbenv/versions/3.3.5/bin/gem install ruby-lsp
121
- ```
122
-
123
- ### Known Issues
124
- - Official `typescript-lsp@claude-plugins-official` is broken (missing plugin.json) — use `vtsls@claude-code-lsps` instead
125
- - Ruby LSP launch script patched at `~/.claude/plugins/cache/ruby-skills/ruby-lsp/1.0.0/scripts/launch-ruby-lsp.sh` — macOS lacks GNU `timeout` which breaks the version manager detection. Patch hardcodes rbenv PATH.
126
- - Plugin auto-updates may overwrite the patch — re-apply if Ruby LSP stops working
127
-
128
- ### Reinstall Commands
129
- ```bash
130
- claude plugin marketplace add boostvolt/claude-code-lsps
131
- claude plugin install vtsls@claude-code-lsps
132
-
133
- claude plugin marketplace add st0012/ruby-skills
134
- claude plugin install ruby-lsp@ruby-skills
135
- claude plugin install ruby-skills@ruby-skills
136
- ```
137
-
138
106
  ## Key Configuration Files
139
107
 
140
108
  - `api/config/database.yml` - Database connections (primary + timescale)
@@ -103,38 +103,6 @@ Located at `api/app/graphql/{api,private}/v1/`
103
103
  - Use `test()` instead of `it()` in tests
104
104
  - Use `toBeCalled()` instead of `toHaveBeenCalledWith()` in jest assertions
105
105
 
106
- ## LSP Setup (Code Intelligence)
107
-
108
- Claude Code uses LSP plugins for go-to-definition, find-references, and real-time diagnostics.
109
-
110
- ### Installed Plugins
111
- - **vtsls** (JS/TS) - `vtsls@claude-code-lsps` from boostvolt marketplace
112
- - **ruby-lsp** (Ruby) - `ruby-lsp@ruby-skills` + `ruby-skills@ruby-skills` from st0012 marketplace
113
-
114
- ### Prerequisites
115
- ```bash
116
- # TypeScript/JS language server
117
- npm install -g @vtsls/language-server
118
-
119
- # Ruby language server (use rbenv gem, not system)
120
- /Users/jwu/.rbenv/versions/3.3.5/bin/gem install ruby-lsp
121
- ```
122
-
123
- ### Known Issues
124
- - Official `typescript-lsp@claude-plugins-official` is broken (missing plugin.json) — use `vtsls@claude-code-lsps` instead
125
- - Ruby LSP launch script patched at `~/.claude/plugins/cache/ruby-skills/ruby-lsp/1.0.0/scripts/launch-ruby-lsp.sh` — macOS lacks GNU `timeout` which breaks the version manager detection. Patch hardcodes rbenv PATH.
126
- - Plugin auto-updates may overwrite the patch — re-apply if Ruby LSP stops working
127
-
128
- ### Reinstall Commands
129
- ```bash
130
- claude plugin marketplace add boostvolt/claude-code-lsps
131
- claude plugin install vtsls@claude-code-lsps
132
-
133
- claude plugin marketplace add st0012/ruby-skills
134
- claude plugin install ruby-lsp@ruby-skills
135
- claude plugin install ruby-skills@ruby-skills
136
- ```
137
-
138
106
  ## Key Configuration Files
139
107
 
140
108
  - `api/config/database.yml` - Database connections (primary + timescale)
@@ -103,38 +103,6 @@ Located at `api/app/graphql/{api,private}/v1/`
103
103
  - Use `test()` instead of `it()` in tests
104
104
  - Use `toBeCalled()` instead of `toHaveBeenCalledWith()` in jest assertions
105
105
 
106
- ## LSP Setup (Code Intelligence)
107
-
108
- Claude Code uses LSP plugins for go-to-definition, find-references, and real-time diagnostics.
109
-
110
- ### Installed Plugins
111
- - **vtsls** (JS/TS) - `vtsls@claude-code-lsps` from boostvolt marketplace
112
- - **ruby-lsp** (Ruby) - `ruby-lsp@ruby-skills` + `ruby-skills@ruby-skills` from st0012 marketplace
113
-
114
- ### Prerequisites
115
- ```bash
116
- # TypeScript/JS language server
117
- npm install -g @vtsls/language-server
118
-
119
- # Ruby language server (use rbenv gem, not system)
120
- /Users/jwu/.rbenv/versions/3.3.5/bin/gem install ruby-lsp
121
- ```
122
-
123
- ### Known Issues
124
- - Official `typescript-lsp@claude-plugins-official` is broken (missing plugin.json) — use `vtsls@claude-code-lsps` instead
125
- - Ruby LSP launch script patched at `~/.claude/plugins/cache/ruby-skills/ruby-lsp/1.0.0/scripts/launch-ruby-lsp.sh` — macOS lacks GNU `timeout` which breaks the version manager detection. Patch hardcodes rbenv PATH.
126
- - Plugin auto-updates may overwrite the patch — re-apply if Ruby LSP stops working
127
-
128
- ### Reinstall Commands
129
- ```bash
130
- claude plugin marketplace add boostvolt/claude-code-lsps
131
- claude plugin install vtsls@claude-code-lsps
132
-
133
- claude plugin marketplace add st0012/ruby-skills
134
- claude plugin install ruby-lsp@ruby-skills
135
- claude plugin install ruby-skills@ruby-skills
136
- ```
137
-
138
106
  ## Key Configuration Files
139
107
 
140
108
  - `api/config/database.yml` - Database connections (primary + timescale)
@@ -1 +1,107 @@
1
- API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"You have reached your specified API usage limits. You will regain access on 2026-04-01 at 00:00 UTC."},"request_id":"req_011CYrHCd5bajfrRXjhYqSFH"}
1
+ # CLAUDE.md
2
+
3
+ Guidance for Claude Code when working in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ Sol is the **Rize** monorepo (automatic time tracking). Stack:
8
+ - **api/** - Rails 7.1 GraphQL backend (Ruby 3.3.5)
9
+ - **web/** - Next.js 14 React app (Node 22)
10
+ - **electron/** - Electron desktop app (Node 22)
11
+ - **services/** - Bun TypeScript event consumers/workers
12
+ - **vanity/** - Webflow marketing scripts (deprecated)
13
+ - **voyager/** - Marketing website (Next.js)
14
+ - **puppet/** - Puppeteer server for images/PDFs
15
+ - **chrome/** - Chrome extension
16
+ - **docs/** - Docusaurus site
17
+ - **zapier/** - Zapier integration
18
+
19
+ ## Development Commands
20
+
21
+ ### Start Dev Environment
22
+ ```bash
23
+ ./scripts/run-dev.sh # All services (requires iTerm2 on macOS)
24
+
25
+ # Individually:
26
+ cd api && hivemind Procfile.dev # Rails + AnyCable + Sidekiq + Clockwork
27
+ cd web && npm run dev # Next.js (port 3001)
28
+ cd electron && npm run dev # Electron with hot reload
29
+ cd services && hivemind Procfile.dev # Bun services
30
+ ```
31
+
32
+ ### Docker (required before api/services)
33
+ ```bash
34
+ cd api && docker-compose up -d
35
+ # TimescaleDB: localhost:15432 | Redis: localhost:16379
36
+ # Kafka: localhost:9092 | MySQL: localhost:13306
37
+ ```
38
+
39
+ ### Testing
40
+ ```bash
41
+ # API (RSpec)
42
+ cd api && bundle exec rspec
43
+ cd api && bundle exec rspec spec/path/to/file_spec.rb
44
+ cd api && bundle exec rspec spec/path/to/file_spec.rb:42
45
+
46
+ # Electron (Jest)
47
+ cd electron && npm test
48
+ cd electron && npm run test:watch
49
+ cd electron && npm run test:coverage
50
+
51
+ # Web: no active tests (exits 0)
52
+ ```
53
+
54
+ ### Building
55
+ ```bash
56
+ cd api && bundle install && rake db:migrate
57
+ cd web && npm run build # gql-gen + tailwind + next build
58
+ cd electron && npm run build # Electron Forge make
59
+ cd services && bun install
60
+ ```
61
+
62
+ ### GraphQL Code Generation
63
+ - `cd web && npm run build` — includes gql codegen
64
+ - `cd electron && npm run dev` — runs gql codegen automatically
65
+
66
+ ## Architecture
67
+
68
+ ### GraphQL API
69
+ Two endpoints:
70
+ - **api/v1** — Public API (OAuth, Zapier) → `api/app/graphql/api/v1/`
71
+ - **private/v1** — Internal API (web, electron) → `api/app/graphql/private/v1/`
72
+
73
+ ### Real-time
74
+ - AnyCable WebSocket subscriptions
75
+ - ActionCable channels: `api/app/channels/`
76
+ - Config: `api/config/cable.yml`, `api/config/anycable.yml`
77
+
78
+ ### Background Jobs
79
+ - Sidekiq (async): `api/config/sidekiq.yml`
80
+ - Clockwork (scheduled): `api/config/clock.rb`
81
+
82
+ ### Event Streaming
83
+ - Kafka publish/consume via `services/consumers/`
84
+ - Config: `api/config/initializers/kafka.rb`
85
+
86
+ ### Databases
87
+ - **PostgreSQL** — primary app data
88
+ - **TimescaleDB** — time-series (separate connection in `database.yml`)
89
+ - **MySQL** — legacy integrations
90
+ - **Redis** — caching, ActionCable, Sidekiq
91
+
92
+ ## Style Guidelines
93
+
94
+ ### JavaScript/TypeScript Tests
95
+ - Use `test()` not `it()`
96
+ - Use `toBeCalled()` not `toHaveBeenCalledWith()`
97
+
98
+ ## Key Config Files
99
+
100
+ | File | Purpose |
101
+ |------|---------|
102
+ | `api/config/database.yml` | DB connections (primary + timescale) |
103
+ | `api/config/cable.yml` | AnyCable WebSocket config |
104
+ | `api/Procfile.dev` | Dev processes (rails, anycable, sidekiq, clockwork) |
105
+ | `sol.code-workspace` | VS Code multi-folder workspace |
106
+
107
+ Each subproject needs its own `.env` file (not committed).