@stackmemoryai/stackmemory 1.5.7 → 1.5.9

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,8 +4,10 @@ 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,
10
+ readFileSync,
9
11
  rmSync,
10
12
  writeFileSync,
11
13
  readdirSync,
@@ -27,6 +29,14 @@ import {
27
29
  } from "../../core/worktree/preflight.js";
28
30
  import { ContextCapture } from "../../core/worktree/capture.js";
29
31
  import { extractKeywords } from "../../core/utils/text.js";
32
+ function getOutcomesLogPath() {
33
+ return join(homedir(), ".stackmemory", "conductor", "outcomes.jsonl");
34
+ }
35
+ function logAgentOutcome(entry) {
36
+ const dir = join(homedir(), ".stackmemory", "conductor");
37
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
38
+ appendFileSync(getOutcomesLogPath(), JSON.stringify(entry) + "\n");
39
+ }
30
40
  function findPackageRoot() {
31
41
  const currentFile = fileURLToPath(import.meta.url);
32
42
  let dir = dirname(currentFile);
@@ -452,6 +462,7 @@ class Conductor {
452
462
  logger.info("Rate limit backoff expired, resuming dispatch");
453
463
  console.log("[rate-limit] Backoff expired, resuming dispatch");
454
464
  }
465
+ await this.checkStaleAgents();
455
466
  await this.reconcile();
456
467
  const available = this.config.maxConcurrent - this.running.size;
457
468
  if (available <= 0) {
@@ -633,6 +644,18 @@ class Conductor {
633
644
  await this.runAgent(issue, run);
634
645
  run.status = "completed";
635
646
  this.completeCount++;
647
+ logAgentOutcome({
648
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
649
+ issue: issue.identifier,
650
+ attempt: run.attempt,
651
+ outcome: "success",
652
+ phase: run.phase,
653
+ toolCalls: run.toolCalls,
654
+ filesModified: run.filesModified,
655
+ tokensUsed: run.tokensUsed,
656
+ durationMs: Date.now() - run.startedAt,
657
+ hasCommits: true
658
+ });
636
659
  await this.runHook(
637
660
  "after-run",
638
661
  run.workspacePath,
@@ -949,6 +972,26 @@ class Conductor {
949
972
  return "claude";
950
973
  }
951
974
  // ── Agent Execution ──
975
+ /**
976
+ * Build environment variables for an agent process.
977
+ * Injects PORTLESS_URL if portless is available, giving each worktree
978
+ * a stable named localhost URL for service discovery.
979
+ */
980
+ buildAgentEnv(issue, run) {
981
+ const env = { ...process.env };
982
+ delete env["CLAUDECODE"];
983
+ delete env["ANTHROPIC_API_KEY"];
984
+ const wsKey = this.sanitizeIdentifier(issue.identifier);
985
+ return {
986
+ ...env,
987
+ SYMPHONY_WORKSPACE_DIR: run.workspacePath,
988
+ SYMPHONY_ISSUE_ID: issue.id,
989
+ SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
990
+ SYMPHONY_ATTEMPT: String(run.attempt),
991
+ PORTLESS_URL: `http://${wsKey}.localhost:1355`,
992
+ PORTLESS_NAME: wsKey
993
+ };
994
+ }
952
995
  async runAgent(issue, run) {
953
996
  if (this.config.agentMode === "cli") {
954
997
  try {
@@ -995,18 +1038,7 @@ class Conductor {
995
1038
  ],
996
1039
  {
997
1040
  cwd: run.workspacePath,
998
- env: (() => {
999
- const env = { ...process.env };
1000
- delete env.CLAUDECODE;
1001
- delete env.ANTHROPIC_API_KEY;
1002
- return {
1003
- ...env,
1004
- SYMPHONY_WORKSPACE_DIR: run.workspacePath,
1005
- SYMPHONY_ISSUE_ID: issue.id,
1006
- SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
1007
- SYMPHONY_ATTEMPT: String(run.attempt)
1008
- };
1009
- })(),
1041
+ env: this.buildAgentEnv(issue, run),
1010
1042
  stdio: ["pipe", "pipe", "pipe"]
1011
1043
  }
1012
1044
  );
@@ -1114,18 +1146,9 @@ class Conductor {
1114
1146
  runAgentAdapter(issue, run) {
1115
1147
  return new Promise((resolve, reject) => {
1116
1148
  const prompt = this.buildPrompt(issue, run.attempt);
1117
- const env = { ...process.env };
1118
- delete env.CLAUDECODE;
1119
- delete env.ANTHROPIC_API_KEY;
1120
1149
  const proc = spawn("node", [this.config.appServerPath], {
1121
1150
  cwd: run.workspacePath,
1122
- env: {
1123
- ...env,
1124
- SYMPHONY_WORKSPACE_DIR: run.workspacePath,
1125
- SYMPHONY_ISSUE_ID: issue.id,
1126
- SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
1127
- SYMPHONY_ATTEMPT: String(run.attempt)
1128
- },
1151
+ env: this.buildAgentEnv(issue, run),
1129
1152
  stdio: ["pipe", "pipe", "pipe"]
1130
1153
  });
1131
1154
  run.process = proc;
@@ -1269,7 +1292,32 @@ class Conductor {
1269
1292
  }
1270
1293
  }
1271
1294
  }
1295
+ /**
1296
+ * Build the agent prompt. If a custom template exists at
1297
+ * ~/.stackmemory/conductor/prompt-template.md, use it with variable
1298
+ * substitution. Otherwise fall back to the default template.
1299
+ *
1300
+ * Template variables: {{ISSUE_ID}}, {{TITLE}}, {{DESCRIPTION}},
1301
+ * {{LABELS}}, {{PRIORITY}}, {{ATTEMPT}}, {{PRIOR_CONTEXT}}
1302
+ */
1272
1303
  buildPrompt(issue, attempt) {
1304
+ const templatePath = join(
1305
+ homedir(),
1306
+ ".stackmemory",
1307
+ "conductor",
1308
+ "prompt-template.md"
1309
+ );
1310
+ const priority = ["None", "Urgent", "High", "Medium", "Low"][issue.priority] || "None";
1311
+ const labels = issue.labels.length > 0 ? issue.labels.map((l) => l.name).join(", ") : "";
1312
+ const priorContext = attempt > 1 ? `This is attempt ${attempt}. Check .stackmemory/conductor-context.md for context from prior attempts.` : "";
1313
+ if (existsSync(templatePath)) {
1314
+ try {
1315
+ let template = readFileSync(templatePath, "utf-8");
1316
+ 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);
1317
+ return template;
1318
+ } catch {
1319
+ }
1320
+ }
1273
1321
  const lines = [
1274
1322
  `You are working on Linear issue ${issue.identifier}: ${issue.title}`,
1275
1323
  ""
@@ -1277,17 +1325,12 @@ class Conductor {
1277
1325
  if (issue.description) {
1278
1326
  lines.push("## Description", "", issue.description, "");
1279
1327
  }
1280
- if (issue.labels.length > 0) {
1281
- lines.push(`Labels: ${issue.labels.map((l) => l.name).join(", ")}`);
1328
+ if (labels) {
1329
+ lines.push(`Labels: ${labels}`);
1282
1330
  }
1283
- lines.push(
1284
- `Priority: ${["None", "Urgent", "High", "Medium", "Low"][issue.priority] || "None"}`
1285
- );
1286
- if (attempt > 1) {
1287
- lines.push(
1288
- "",
1289
- `This is attempt ${attempt}. Check .stackmemory/conductor-context.md for context from prior attempts.`
1290
- );
1331
+ lines.push(`Priority: ${priority}`);
1332
+ if (priorContext) {
1333
+ lines.push("", priorContext);
1291
1334
  }
1292
1335
  lines.push(
1293
1336
  "",
@@ -1405,6 +1448,146 @@ class Conductor {
1405
1448
  }
1406
1449
  }
1407
1450
  // ── Reconciliation ──
1451
+ /**
1452
+ * Check for stale agents — no status update in 1 hour.
1453
+ * If the process is dead, clean up. If alive but stuck, kill and free the slot.
1454
+ */
1455
+ async checkStaleAgents() {
1456
+ if (this.running.size === 0) return;
1457
+ const staleThresholdMs = 60 * 60 * 1e3;
1458
+ const now = Date.now();
1459
+ for (const [issueId, run] of this.running) {
1460
+ const agentDir = getAgentStatusDir(run.issue.identifier);
1461
+ const statusPath = join(agentDir, "status.json");
1462
+ let lastUpdate = run.startedAt;
1463
+ try {
1464
+ const data = JSON.parse(readFileSync(statusPath, "utf-8"));
1465
+ lastUpdate = new Date(data.lastUpdate).getTime();
1466
+ } catch {
1467
+ }
1468
+ const elapsed = now - lastUpdate;
1469
+ if (elapsed < staleThresholdMs) continue;
1470
+ const elapsedMin = Math.round(elapsed / 6e4);
1471
+ const pid = run.process?.pid;
1472
+ let alive = false;
1473
+ if (pid) {
1474
+ try {
1475
+ process.kill(pid, 0);
1476
+ alive = true;
1477
+ } catch {
1478
+ }
1479
+ }
1480
+ if (!alive) {
1481
+ logger.warn("Stale agent process is dead, cleaning up", {
1482
+ identifier: run.issue.identifier,
1483
+ pid,
1484
+ staleMins: elapsedMin
1485
+ });
1486
+ console.log(
1487
+ `[${run.issue.identifier}] Agent dead after ${elapsedMin}m \u2014 finalizing`
1488
+ );
1489
+ await this.finalizeStaleAgent(run);
1490
+ this.running.delete(issueId);
1491
+ } else {
1492
+ logger.warn("Stale agent still alive but stuck, killing", {
1493
+ identifier: run.issue.identifier,
1494
+ pid,
1495
+ staleMins: elapsedMin
1496
+ });
1497
+ console.log(
1498
+ `[${run.issue.identifier}] Agent stuck for ${elapsedMin}m \u2014 killing pid ${pid}`
1499
+ );
1500
+ run.process?.kill("SIGTERM");
1501
+ await new Promise((resolve) => setTimeout(resolve, 1e4));
1502
+ if (run.process && !run.process.killed) {
1503
+ run.process.kill("SIGKILL");
1504
+ }
1505
+ await this.finalizeStaleAgent(run);
1506
+ this.running.delete(issueId);
1507
+ }
1508
+ }
1509
+ }
1510
+ /**
1511
+ * Finalize an agent that completed or died without proper cleanup.
1512
+ * Checks for commits, transitions Linear issue, takes snapshot.
1513
+ */
1514
+ async finalizeStaleAgent(run) {
1515
+ const wsPath = run.workspacePath;
1516
+ if (!wsPath || !existsSync(wsPath)) {
1517
+ this.completed.add(run.issue.id);
1518
+ return;
1519
+ }
1520
+ let hasCommits = false;
1521
+ try {
1522
+ const log = execSync(
1523
+ `git log origin/${this.config.baseBranch}..HEAD --oneline`,
1524
+ { cwd: wsPath, encoding: "utf-8", timeout: 1e4 }
1525
+ );
1526
+ hasCommits = log.trim().length > 0;
1527
+ } catch {
1528
+ }
1529
+ let errorTail;
1530
+ if (!hasCommits) {
1531
+ try {
1532
+ const logPath = join(
1533
+ getAgentStatusDir(run.issue.identifier),
1534
+ "output.log"
1535
+ );
1536
+ if (existsSync(logPath)) {
1537
+ const content = readFileSync(logPath, "utf-8");
1538
+ const lines = content.trim().split("\n");
1539
+ errorTail = lines.slice(-5).join("\n");
1540
+ }
1541
+ } catch {
1542
+ }
1543
+ }
1544
+ const durationMs = Date.now() - run.startedAt;
1545
+ logAgentOutcome({
1546
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1547
+ issue: run.issue.identifier,
1548
+ attempt: run.attempt,
1549
+ outcome: hasCommits ? "success" : "failure",
1550
+ phase: run.phase,
1551
+ toolCalls: run.toolCalls,
1552
+ filesModified: run.filesModified,
1553
+ tokensUsed: run.tokensUsed,
1554
+ durationMs,
1555
+ hasCommits,
1556
+ errorTail
1557
+ });
1558
+ if (hasCommits) {
1559
+ logger.info("Stale agent had commits, transitioning to In Review", {
1560
+ identifier: run.issue.identifier
1561
+ });
1562
+ console.log(
1563
+ `[${run.issue.identifier}] Found commits \u2014 moving to In Review`
1564
+ );
1565
+ this.takeSnapshot(wsPath, run.issue);
1566
+ await this.transitionIssue(run.issue, this.config.inReviewState).catch(
1567
+ (err) => {
1568
+ logger.error("Failed to transition stale agent issue", {
1569
+ identifier: run.issue.identifier,
1570
+ error: err.message
1571
+ });
1572
+ }
1573
+ );
1574
+ this.completeCount++;
1575
+ } else {
1576
+ logger.info("Stale agent had no commits, marking as failed", {
1577
+ identifier: run.issue.identifier
1578
+ });
1579
+ console.log(
1580
+ `[${run.issue.identifier}] No commits found \u2014 marking failed`
1581
+ );
1582
+ this.failCount++;
1583
+ }
1584
+ this.writeAgentStatus(run.issue.identifier, {
1585
+ ...run,
1586
+ status: hasCommits ? "completed" : "failed",
1587
+ phase: hasCommits ? "committing" : run.phase
1588
+ });
1589
+ this.completed.add(run.issue.id);
1590
+ }
1408
1591
  async reconcile() {
1409
1592
  if (!this.client || this.running.size === 0) return;
1410
1593
  for (const [issueId, run] of this.running) {
@@ -1454,5 +1637,6 @@ class Conductor {
1454
1637
  }
1455
1638
  export {
1456
1639
  Conductor,
1457
- getAgentStatusDir
1640
+ getAgentStatusDir,
1641
+ getOutcomesLogPath
1458
1642
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.5.7",
3
+ "version": "1.5.9",
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).