@stackmemoryai/stackmemory 1.3.1 → 1.4.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.
@@ -0,0 +1,773 @@
1
+ import { fileURLToPath as __fileURLToPath } from 'url';
2
+ import { dirname as __pathDirname } from 'path';
3
+ const __filename = __fileURLToPath(import.meta.url);
4
+ const __dirname = __pathDirname(__filename);
5
+ import { spawn, execSync } from "child_process";
6
+ import { existsSync, mkdirSync, rmSync } from "fs";
7
+ import { join } from "path";
8
+ import { tmpdir } from "os";
9
+ import { logger } from "../../core/monitoring/logger.js";
10
+ import {
11
+ LinearClient
12
+ } from "../../integrations/linear/client.js";
13
+ import { LinearAuthManager } from "../../integrations/linear/auth.js";
14
+ import {
15
+ PreflightChecker
16
+ } from "../../core/worktree/preflight.js";
17
+ import { ContextCapture } from "../../core/worktree/capture.js";
18
+ import { extractKeywords } from "../../core/utils/text.js";
19
+ const DEFAULT_CONFIG = {
20
+ activeStates: ["Todo"],
21
+ terminalStates: ["Done", "Cancelled", "Canceled", "Closed"],
22
+ inProgressState: "In Progress",
23
+ inReviewState: "In Review",
24
+ pollIntervalMs: 3e4,
25
+ maxConcurrent: 3,
26
+ workspaceRoot: join(tmpdir(), "conductor_workspaces"),
27
+ repoRoot: process.cwd(),
28
+ baseBranch: "main",
29
+ appServerPath: join(
30
+ __dirname,
31
+ "..",
32
+ "..",
33
+ "..",
34
+ "scripts",
35
+ "conductor",
36
+ "claude-app-server.cjs"
37
+ ),
38
+ turnTimeoutMs: 36e5,
39
+ maxRetries: 1,
40
+ hookTimeoutMs: 6e4
41
+ };
42
+ class Conductor {
43
+ config;
44
+ client = null;
45
+ running = /* @__PURE__ */ new Map();
46
+ claimed = /* @__PURE__ */ new Set();
47
+ completed = /* @__PURE__ */ new Set();
48
+ pollTimer = null;
49
+ startedAt = 0;
50
+ totalAttempts = 0;
51
+ failCount = 0;
52
+ completeCount = 0;
53
+ stopping = false;
54
+ stateCache = /* @__PURE__ */ new Map();
55
+ activeStatesLower;
56
+ terminalStatesLower;
57
+ constructor(config = {}) {
58
+ this.config = { ...DEFAULT_CONFIG, ...config };
59
+ this.activeStatesLower = this.config.activeStates.map(
60
+ (s) => s.trim().toLowerCase()
61
+ );
62
+ this.terminalStatesLower = this.config.terminalStates.map(
63
+ (s) => s.trim().toLowerCase()
64
+ );
65
+ }
66
+ /**
67
+ * Start the orchestrator loop.
68
+ * Resolves when stopped via stop() or SIGINT/SIGTERM.
69
+ */
70
+ async start() {
71
+ this.startedAt = Date.now();
72
+ this.stopping = false;
73
+ if (!existsSync(this.config.appServerPath)) {
74
+ const altPath = join(
75
+ this.config.repoRoot,
76
+ "scripts",
77
+ "symphony",
78
+ "claude-app-server.cjs"
79
+ );
80
+ if (existsSync(altPath)) {
81
+ this.config.appServerPath = altPath;
82
+ } else {
83
+ throw new Error(
84
+ `claude-app-server.cjs not found at ${this.config.appServerPath}`
85
+ );
86
+ }
87
+ }
88
+ if (!existsSync(this.config.workspaceRoot)) {
89
+ mkdirSync(this.config.workspaceRoot, { recursive: true });
90
+ }
91
+ this.client = await this.createLinearClient();
92
+ await this.cacheWorkflowStates();
93
+ logger.info("Orchestrator started", {
94
+ activeStates: this.config.activeStates,
95
+ maxConcurrent: this.config.maxConcurrent,
96
+ pollIntervalMs: this.config.pollIntervalMs,
97
+ workspaceRoot: this.config.workspaceRoot
98
+ });
99
+ console.log(
100
+ `Orchestrator started \u2014 polling every ${this.config.pollIntervalMs / 1e3}s, max ${this.config.maxConcurrent} concurrent`
101
+ );
102
+ const shutdown = () => this.stop();
103
+ process.on("SIGINT", shutdown);
104
+ process.on("SIGTERM", shutdown);
105
+ try {
106
+ await this.poll();
107
+ } catch (err) {
108
+ logger.error("Initial poll failed", { error: err.message });
109
+ }
110
+ await this.schedulePoll();
111
+ }
112
+ /**
113
+ * Gracefully stop the orchestrator.
114
+ * Waits for running agents to finish (up to 30s), then force-kills.
115
+ */
116
+ async stop() {
117
+ if (this.stopping) return;
118
+ this.stopping = true;
119
+ console.log("\nOrchestrator stopping...");
120
+ logger.info("Orchestrator stopping", {
121
+ runningCount: this.running.size
122
+ });
123
+ if (this.pollTimer) {
124
+ clearTimeout(this.pollTimer);
125
+ this.pollTimer = null;
126
+ }
127
+ for (const [issueId, run] of this.running) {
128
+ if (run.process && !run.process.killed) {
129
+ logger.info("Killing agent process", {
130
+ issueId,
131
+ identifier: run.issue.identifier
132
+ });
133
+ run.process.kill("SIGTERM");
134
+ }
135
+ }
136
+ const deadline = Date.now() + 1e4;
137
+ while (this.running.size > 0 && Date.now() < deadline) {
138
+ await new Promise((r) => setTimeout(r, 500));
139
+ }
140
+ for (const [_issueId, run] of this.running) {
141
+ if (run.process && !run.process.killed) {
142
+ run.process.kill("SIGKILL");
143
+ }
144
+ }
145
+ this.running.clear();
146
+ this.claimed.clear();
147
+ console.log(
148
+ `Orchestrator stopped. Completed: ${this.completeCount}, Failed: ${this.failCount}`
149
+ );
150
+ }
151
+ /**
152
+ * Get current orchestrator stats.
153
+ */
154
+ getStats() {
155
+ const issues = Array.from(this.running.values()).map((r) => ({
156
+ identifier: r.issue.identifier,
157
+ status: r.status,
158
+ attempt: r.attempt,
159
+ runtime: Date.now() - r.startedAt
160
+ }));
161
+ return {
162
+ running: this.running.size,
163
+ completed: this.completeCount,
164
+ failed: this.failCount,
165
+ totalAttempts: this.totalAttempts,
166
+ uptime: Date.now() - this.startedAt,
167
+ issues
168
+ };
169
+ }
170
+ // ── Polling ──
171
+ async schedulePoll() {
172
+ while (!this.stopping) {
173
+ await new Promise((resolve) => {
174
+ this.pollTimer = setTimeout(resolve, this.config.pollIntervalMs);
175
+ });
176
+ if (this.stopping) break;
177
+ try {
178
+ await this.poll();
179
+ } catch (err) {
180
+ logger.error("Poll cycle failed", { error: err.message });
181
+ }
182
+ }
183
+ }
184
+ async poll() {
185
+ if (!this.client || this.stopping) return;
186
+ await this.reconcile();
187
+ const available = this.config.maxConcurrent - this.running.size;
188
+ if (available <= 0) {
189
+ logger.debug("At capacity, skipping dispatch", {
190
+ running: this.running.size,
191
+ max: this.config.maxConcurrent
192
+ });
193
+ return;
194
+ }
195
+ const candidates = await this.fetchCandidates();
196
+ if (candidates.length === 0) return;
197
+ const eligible = candidates.filter(
198
+ (issue) => !this.claimed.has(issue.id) && !this.completed.has(issue.id)
199
+ );
200
+ if (eligible.length === 0) return;
201
+ const sorted = eligible.sort((a, b) => (a.priority || 4) - (b.priority || 4)).slice(0, available);
202
+ const toDispatch = this.preflightFilter(sorted);
203
+ logger.info("Dispatching issues", {
204
+ count: toDispatch.length,
205
+ identifiers: toDispatch.map((i) => i.identifier),
206
+ skipped: sorted.length - toDispatch.length
207
+ });
208
+ for (const issue of toDispatch) {
209
+ this.dispatch(issue).catch((err) => {
210
+ logger.error("Dispatch failed", {
211
+ identifier: issue.identifier,
212
+ error: err.message
213
+ });
214
+ });
215
+ }
216
+ }
217
+ async fetchCandidates() {
218
+ if (!this.client) return [];
219
+ const allCandidates = [];
220
+ const issues = await this.client.getIssues({
221
+ teamId: this.config.teamId,
222
+ limit: 50
223
+ });
224
+ for (const issue of issues) {
225
+ const stateName = issue.state.name.trim().toLowerCase();
226
+ if (this.activeStatesLower.includes(stateName)) {
227
+ allCandidates.push(issue);
228
+ }
229
+ }
230
+ return allCandidates;
231
+ }
232
+ // ── Pre-flight ──
233
+ /**
234
+ * Filter candidates against running issues using file overlap prediction.
235
+ * Returns only issues that are parallel-safe with currently running work.
236
+ */
237
+ preflightFilter(candidates) {
238
+ if (candidates.length === 0 || this.running.size === 0) {
239
+ return candidates;
240
+ }
241
+ try {
242
+ const checker = new PreflightChecker(this.config.repoRoot);
243
+ const runningFileSets = [];
244
+ const runningNames = [];
245
+ for (const run of this.running.values()) {
246
+ const task = {
247
+ name: run.issue.identifier,
248
+ description: run.issue.title,
249
+ keywords: this.extractIssueKeywords(run.issue)
250
+ };
251
+ runningFileSets.push(checker.predictFiles(task));
252
+ runningNames.push(run.issue.identifier);
253
+ }
254
+ const safe = [];
255
+ for (const candidate of candidates) {
256
+ const candidateTask = {
257
+ name: candidate.identifier,
258
+ description: candidate.title,
259
+ keywords: this.extractIssueKeywords(candidate)
260
+ };
261
+ const candidateFiles = checker.predictFiles(candidateTask);
262
+ const conflictFiles = [];
263
+ const conflictTasks = [];
264
+ for (let i = 0; i < runningFileSets.length; i++) {
265
+ const shared = [...candidateFiles].filter(
266
+ (f) => runningFileSets[i].has(f)
267
+ );
268
+ if (shared.length > 0) {
269
+ conflictFiles.push(...shared);
270
+ conflictTasks.push(runningNames[i]);
271
+ }
272
+ }
273
+ if (conflictFiles.length > 0) {
274
+ const uniqueFiles = [...new Set(conflictFiles)].slice(0, 3);
275
+ logger.info("Preflight: skipping conflicting issue", {
276
+ identifier: candidate.identifier,
277
+ conflictsWith: conflictTasks,
278
+ files: uniqueFiles
279
+ });
280
+ console.log(
281
+ `[${candidate.identifier}] Deferred \u2014 file overlap with running work (${uniqueFiles.join(", ")})`
282
+ );
283
+ } else {
284
+ safe.push(candidate);
285
+ }
286
+ }
287
+ return safe;
288
+ } catch (err) {
289
+ logger.warn("Preflight check failed, dispatching all", {
290
+ error: err.message
291
+ });
292
+ return candidates;
293
+ }
294
+ }
295
+ extractIssueKeywords(issue) {
296
+ const labelText = issue.labels.map((l) => l.name).join(" ");
297
+ return extractKeywords(`${issue.title} ${labelText}`, { maxCount: 8 });
298
+ }
299
+ // ── Dispatch ──
300
+ async dispatch(issue) {
301
+ const issueId = issue.id;
302
+ this.claimed.add(issueId);
303
+ const run = {
304
+ issue,
305
+ workspacePath: "",
306
+ process: null,
307
+ attempt: 1,
308
+ startedAt: Date.now(),
309
+ status: "starting"
310
+ };
311
+ this.running.set(issueId, run);
312
+ this.totalAttempts++;
313
+ console.log(`[${issue.identifier}] Dispatching: ${issue.title}`);
314
+ try {
315
+ const workspacePath = await this.createWorkspace(issue);
316
+ run.workspacePath = workspacePath;
317
+ await this.transitionIssue(issue, this.config.inProgressState);
318
+ await this.runHook("after-create", workspacePath, issue);
319
+ run.status = "running";
320
+ await this.runAgent(issue, run);
321
+ run.status = "completed";
322
+ this.completeCount++;
323
+ await this.runHook("after-run", workspacePath, issue, run.attempt);
324
+ this.takeSnapshot(workspacePath, issue);
325
+ await this.transitionIssue(issue, this.config.inReviewState);
326
+ console.log(`[${issue.identifier}] Completed successfully`);
327
+ } catch (err) {
328
+ run.status = "failed";
329
+ run.error = err.message;
330
+ logger.error("Issue dispatch failed", {
331
+ identifier: issue.identifier,
332
+ error: run.error,
333
+ attempt: run.attempt
334
+ });
335
+ if (run.workspacePath) {
336
+ await this.runHook(
337
+ "after-run",
338
+ run.workspacePath,
339
+ issue,
340
+ run.attempt
341
+ ).catch(() => {
342
+ });
343
+ }
344
+ if (run.attempt < this.config.maxRetries + 1) {
345
+ console.log(
346
+ `[${issue.identifier}] Failed (attempt ${run.attempt}), retrying...`
347
+ );
348
+ run.attempt++;
349
+ this.totalAttempts++;
350
+ const backoffMs = Math.min(1e3 * Math.pow(2, run.attempt - 1), 3e5);
351
+ await new Promise((r) => setTimeout(r, backoffMs));
352
+ if (!this.stopping) {
353
+ try {
354
+ run.status = "running";
355
+ await this.runAgent(issue, run);
356
+ run.status = "completed";
357
+ this.completeCount++;
358
+ await this.runHook(
359
+ "after-run",
360
+ run.workspacePath,
361
+ issue,
362
+ run.attempt
363
+ ).catch(() => {
364
+ });
365
+ await this.transitionIssue(issue, this.config.inReviewState);
366
+ console.log(
367
+ `[${issue.identifier}] Completed on retry ${run.attempt}`
368
+ );
369
+ } catch (retryErr) {
370
+ run.status = "failed";
371
+ run.error = retryErr.message;
372
+ this.failCount++;
373
+ console.log(
374
+ `[${issue.identifier}] Failed after ${run.attempt} attempts: ${run.error}`
375
+ );
376
+ }
377
+ }
378
+ } else {
379
+ this.failCount++;
380
+ console.log(`[${issue.identifier}] Failed: ${run.error}`);
381
+ }
382
+ } finally {
383
+ this.running.delete(issueId);
384
+ }
385
+ }
386
+ // ── Workspace Management ──
387
+ async createWorkspace(issue) {
388
+ const wsKey = this.sanitizeIdentifier(issue.identifier);
389
+ const wsPath = join(this.config.workspaceRoot, wsKey);
390
+ if (existsSync(wsPath)) {
391
+ logger.info("Reusing existing workspace", {
392
+ identifier: issue.identifier,
393
+ path: wsPath
394
+ });
395
+ return wsPath;
396
+ }
397
+ const branchName = `conductor/${wsKey}`;
398
+ try {
399
+ execSync("git fetch origin", {
400
+ cwd: this.config.repoRoot,
401
+ stdio: "pipe",
402
+ timeout: 3e4
403
+ });
404
+ execSync(
405
+ `git worktree add "${wsPath}" -b "${branchName}" "origin/${this.config.baseBranch}"`,
406
+ {
407
+ cwd: this.config.repoRoot,
408
+ stdio: "pipe",
409
+ timeout: 3e4
410
+ }
411
+ );
412
+ logger.info("Created workspace", {
413
+ identifier: issue.identifier,
414
+ path: wsPath,
415
+ branch: branchName
416
+ });
417
+ } catch (err) {
418
+ try {
419
+ execSync(`git worktree add "${wsPath}" "${branchName}"`, {
420
+ cwd: this.config.repoRoot,
421
+ stdio: "pipe",
422
+ timeout: 3e4
423
+ });
424
+ } catch {
425
+ throw new Error(
426
+ `Failed to create workspace for ${issue.identifier}: ${err.message}`
427
+ );
428
+ }
429
+ }
430
+ return wsPath;
431
+ }
432
+ async removeWorkspace(issue) {
433
+ const wsKey = this.sanitizeIdentifier(issue.identifier);
434
+ const wsPath = join(this.config.workspaceRoot, wsKey);
435
+ if (!existsSync(wsPath)) return;
436
+ await this.runHook("before-remove", wsPath, issue).catch(() => {
437
+ });
438
+ try {
439
+ execSync(`git worktree remove "${wsPath}" --force`, {
440
+ cwd: this.config.repoRoot,
441
+ stdio: "pipe",
442
+ timeout: 3e4
443
+ });
444
+ } catch {
445
+ try {
446
+ rmSync(wsPath, { recursive: true, force: true });
447
+ execSync("git worktree prune", {
448
+ cwd: this.config.repoRoot,
449
+ stdio: "pipe",
450
+ timeout: 1e4
451
+ });
452
+ } catch {
453
+ logger.warn("Failed to clean workspace", {
454
+ identifier: issue.identifier,
455
+ path: wsPath
456
+ });
457
+ }
458
+ }
459
+ }
460
+ sanitizeIdentifier(identifier) {
461
+ return identifier.replace(/[^A-Za-z0-9._-]/g, "_");
462
+ }
463
+ // ── Agent Execution ──
464
+ runAgent(issue, run) {
465
+ return new Promise((resolve, reject) => {
466
+ const prompt = this.buildPrompt(issue, run.attempt);
467
+ const proc = spawn("node", [this.config.appServerPath], {
468
+ cwd: run.workspacePath,
469
+ env: {
470
+ ...process.env,
471
+ SYMPHONY_WORKSPACE_DIR: run.workspacePath,
472
+ SYMPHONY_ISSUE_ID: issue.id,
473
+ SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
474
+ SYMPHONY_ATTEMPT: String(run.attempt)
475
+ },
476
+ stdio: ["pipe", "pipe", "pipe"]
477
+ });
478
+ run.process = proc;
479
+ let stderr = "";
480
+ let turnCompleted = false;
481
+ const timer = setTimeout(() => {
482
+ if (!turnCompleted) {
483
+ logger.warn("Agent turn timeout", {
484
+ identifier: issue.identifier,
485
+ timeoutMs: this.config.turnTimeoutMs
486
+ });
487
+ proc.kill("SIGTERM");
488
+ reject(
489
+ new Error(`Agent timeout after ${this.config.turnTimeoutMs}ms`)
490
+ );
491
+ }
492
+ }, this.config.turnTimeoutMs);
493
+ const send = (msg) => {
494
+ proc.stdin.write(JSON.stringify(msg) + "\n");
495
+ };
496
+ let lineBuffer = "";
497
+ proc.stdout.on("data", (chunk) => {
498
+ lineBuffer += chunk.toString();
499
+ const lines = lineBuffer.split("\n");
500
+ lineBuffer = lines.pop() || "";
501
+ for (const line of lines) {
502
+ if (!line.trim()) continue;
503
+ try {
504
+ const msg = JSON.parse(line);
505
+ this.handleAgentMessage(msg, issue, run);
506
+ if (msg.method === "turn/completed") {
507
+ turnCompleted = true;
508
+ }
509
+ if (msg.method === "turn/failed") {
510
+ turnCompleted = true;
511
+ const errMsg = msg.params?.error?.message || "Agent turn failed";
512
+ clearTimeout(timer);
513
+ reject(new Error(errMsg));
514
+ return;
515
+ }
516
+ } catch {
517
+ }
518
+ }
519
+ });
520
+ proc.stderr.on("data", (data) => {
521
+ stderr += data.toString();
522
+ const lines = data.toString().split("\n").filter((l) => l.trim());
523
+ for (const line of lines) {
524
+ logger.debug(`[${issue.identifier}] ${line}`);
525
+ }
526
+ });
527
+ proc.on("error", (err) => {
528
+ clearTimeout(timer);
529
+ reject(new Error(`Failed to spawn agent: ${err.message}`));
530
+ });
531
+ proc.on("close", (code) => {
532
+ clearTimeout(timer);
533
+ run.process = null;
534
+ if (turnCompleted) {
535
+ resolve();
536
+ } else if (code === 0) {
537
+ resolve();
538
+ } else {
539
+ reject(
540
+ new Error(`Agent exited with code ${code}: ${stderr.slice(0, 500)}`)
541
+ );
542
+ }
543
+ });
544
+ send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
545
+ setTimeout(() => {
546
+ send({
547
+ jsonrpc: "2.0",
548
+ id: 2,
549
+ method: "thread/start",
550
+ params: { cwd: run.workspacePath }
551
+ });
552
+ setTimeout(() => {
553
+ send({
554
+ jsonrpc: "2.0",
555
+ id: 3,
556
+ method: "turn/start",
557
+ params: {
558
+ cwd: run.workspacePath,
559
+ input: [{ type: "text", text: prompt }]
560
+ }
561
+ });
562
+ }, 100);
563
+ }, 100);
564
+ });
565
+ }
566
+ handleAgentMessage(msg, issue, _run) {
567
+ const params = msg.params;
568
+ if (msg.method === "item/commandExecution/started") {
569
+ logger.debug("Agent tool use", {
570
+ identifier: issue.identifier,
571
+ tool: params?.tool
572
+ });
573
+ }
574
+ if (msg.method === "turn/completed") {
575
+ const result = params?.result;
576
+ const output = result?.output;
577
+ if (Array.isArray(output)) {
578
+ const text = output.filter((b) => b.type === "text").map((b) => b.text).join("\n");
579
+ if (text) {
580
+ logger.info("Agent completed", {
581
+ identifier: issue.identifier,
582
+ outputLength: text.length
583
+ });
584
+ }
585
+ }
586
+ }
587
+ }
588
+ buildPrompt(issue, attempt) {
589
+ const lines = [
590
+ `You are working on Linear issue ${issue.identifier}: ${issue.title}`,
591
+ ""
592
+ ];
593
+ if (issue.description) {
594
+ lines.push("## Description", "", issue.description, "");
595
+ }
596
+ if (issue.labels.length > 0) {
597
+ lines.push(`Labels: ${issue.labels.map((l) => l.name).join(", ")}`);
598
+ }
599
+ lines.push(
600
+ `Priority: ${["None", "Urgent", "High", "Medium", "Low"][issue.priority] || "None"}`
601
+ );
602
+ if (attempt > 1) {
603
+ lines.push(
604
+ "",
605
+ `This is attempt ${attempt}. Check .stackmemory/conductor-context.md for context from prior attempts.`
606
+ );
607
+ }
608
+ lines.push(
609
+ "",
610
+ "## Instructions",
611
+ "",
612
+ "1. Read the issue description carefully",
613
+ "2. Implement the requested changes",
614
+ "3. Write or update tests as needed",
615
+ "4. Run lint and tests to verify",
616
+ "5. Commit your changes with a descriptive message",
617
+ "",
618
+ "Work in the current directory. All changes will be on a dedicated branch."
619
+ );
620
+ return lines.join("\n");
621
+ }
622
+ // ── Snapshot ──
623
+ takeSnapshot(workspacePath, issue) {
624
+ try {
625
+ const capture = new ContextCapture(workspacePath);
626
+ const result = capture.capture({
627
+ task: `${issue.identifier}: ${issue.title}`
628
+ });
629
+ logger.info("Snapshot captured", {
630
+ identifier: issue.identifier,
631
+ filesChanged: result.filesChanged.length,
632
+ filesCreated: result.filesCreated.length,
633
+ commits: result.commits.length
634
+ });
635
+ } catch (err) {
636
+ logger.warn("Snapshot capture failed", {
637
+ identifier: issue.identifier,
638
+ error: err.message
639
+ });
640
+ }
641
+ }
642
+ // ── Hooks ──
643
+ async runHook(hookName, workspacePath, issue, attempt) {
644
+ const hookPath = join(
645
+ this.config.repoRoot,
646
+ "scripts",
647
+ "conductor",
648
+ `${hookName}.sh`
649
+ );
650
+ if (!existsSync(hookPath)) {
651
+ logger.debug("Hook not found, skipping", { hookName, hookPath });
652
+ return;
653
+ }
654
+ logger.debug("Running hook", { hookName, identifier: issue.identifier });
655
+ try {
656
+ execSync(`bash "${hookPath}"`, {
657
+ cwd: workspacePath,
658
+ timeout: this.config.hookTimeoutMs,
659
+ stdio: "pipe",
660
+ env: {
661
+ ...process.env,
662
+ SYMPHONY_WORKSPACE_DIR: workspacePath,
663
+ SYMPHONY_ISSUE_ID: issue.id,
664
+ SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
665
+ SYMPHONY_ATTEMPT: String(attempt || 1)
666
+ }
667
+ });
668
+ } catch (err) {
669
+ logger.warn("Hook failed", {
670
+ hookName,
671
+ identifier: issue.identifier,
672
+ error: err.message
673
+ });
674
+ if (hookName === "after-create") {
675
+ throw new Error(`Hook ${hookName} failed: ${err.message}`);
676
+ }
677
+ }
678
+ }
679
+ // ── State Transitions ──
680
+ async cacheWorkflowStates() {
681
+ if (!this.client || !this.config.teamId) return;
682
+ try {
683
+ const states = await this.client.getWorkflowStates(this.config.teamId);
684
+ for (const state of states) {
685
+ this.stateCache.set(state.name.trim().toLowerCase(), {
686
+ id: state.id,
687
+ name: state.name
688
+ });
689
+ }
690
+ logger.debug("Cached workflow states", { count: this.stateCache.size });
691
+ } catch (err) {
692
+ logger.warn("Failed to cache workflow states", {
693
+ error: err.message
694
+ });
695
+ }
696
+ }
697
+ async transitionIssue(issue, targetStateName) {
698
+ if (!this.client) return;
699
+ const stateKey = targetStateName.trim().toLowerCase();
700
+ const state = this.stateCache.get(stateKey);
701
+ if (!state) {
702
+ logger.warn("Target state not found in cache", {
703
+ targetState: targetStateName,
704
+ available: Array.from(this.stateCache.keys())
705
+ });
706
+ return;
707
+ }
708
+ try {
709
+ await this.client.updateIssueState(issue.id, state.id);
710
+ logger.info("Transitioned issue", {
711
+ identifier: issue.identifier,
712
+ from: issue.state.name,
713
+ to: state.name
714
+ });
715
+ } catch (err) {
716
+ logger.warn("Failed to transition issue", {
717
+ identifier: issue.identifier,
718
+ targetState: state.name,
719
+ error: err.message
720
+ });
721
+ }
722
+ }
723
+ // ── Reconciliation ──
724
+ async reconcile() {
725
+ if (!this.client || this.running.size === 0) return;
726
+ for (const [issueId, run] of this.running) {
727
+ try {
728
+ const fresh = await this.client.getIssue(issueId);
729
+ if (!fresh) continue;
730
+ const currentState = fresh.state.name.trim().toLowerCase();
731
+ if (this.terminalStatesLower.includes(currentState)) {
732
+ logger.info(
733
+ "Issue moved to terminal state externally, stopping agent",
734
+ {
735
+ identifier: run.issue.identifier,
736
+ state: fresh.state.name
737
+ }
738
+ );
739
+ if (run.process && !run.process.killed) {
740
+ run.process.kill("SIGTERM");
741
+ }
742
+ await this.removeWorkspace(run.issue);
743
+ this.running.delete(issueId);
744
+ this.completed.add(issueId);
745
+ }
746
+ } catch (err) {
747
+ logger.debug("Reconciliation check failed for issue", {
748
+ issueId,
749
+ error: err.message
750
+ });
751
+ }
752
+ }
753
+ }
754
+ // ── Linear Client ──
755
+ async createLinearClient() {
756
+ try {
757
+ const authManager = new LinearAuthManager(this.config.repoRoot);
758
+ const token = await authManager.getValidToken();
759
+ return new LinearClient({ apiKey: token, useBearer: true });
760
+ } catch {
761
+ const apiKey = process.env.LINEAR_API_KEY;
762
+ if (!apiKey) {
763
+ throw new Error(
764
+ "Linear authentication required. Run `stackmemory linear setup` or set LINEAR_API_KEY."
765
+ );
766
+ }
767
+ return new LinearClient({ apiKey });
768
+ }
769
+ }
770
+ }
771
+ export {
772
+ Conductor
773
+ };