@stackmemoryai/stackmemory 1.3.2 → 1.5.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.
package/README.md CHANGED
@@ -22,6 +22,9 @@ StackMemory is a **production-ready memory runtime** for AI coding tools that pr
22
22
  - **Multi-wrapper support** — `claude-sm`, `codex-sm`, `opencode-sm` with auto context loading
23
23
  - **Skills system** with `/spec` and `/linear-run` for Claude Code
24
24
  - **Automatic hooks** for task tracking, Linear sync, and spec progress
25
+ - **Snapshot capture** — post-run context snapshots for session handoff (`snapshot save`)
26
+ - **Pre-flight overlap check** — predict file conflicts before parallel task dispatch (`preflight`)
27
+ - **Conductor orchestrator** — polls Linear, creates worktrees, spawns agents with bounded concurrency
25
28
  - **Memory monitor daemon** with automatic capture/clear on RAM pressure
26
29
  - **Auto-save service** for periodic context persistence
27
30
  - **Comprehensive test coverage** across all core modules
@@ -63,6 +66,8 @@ Tools forget decisions and constraints between sessions. StackMemory makes conte
63
66
  - **Hooks**: automatic context save, task tracking, Linear sync, PROMPT_PLAN updates, cord tracing
64
67
  - **Prompt Forge**: watches CLAUDE.md and AGENTS.md for prompt optimization (GEPA)
65
68
  - **Safe branches**: worktree isolation with `--worktree` or `-w`
69
+ - **Snapshot**: post-run context capture — records what changed, commits, and decisions (`stackmemory snapshot`)
70
+ - **Pre-flight check**: file overlap prediction before parallel task dispatch (`stackmemory preflight`)
66
71
  - **Persistent context**: frames, anchors, decisions, retrieval
67
72
  - **Integrations**: Linear (API key + OAuth), DiffMem, Browser MCP, log-mcp (log analysis)
68
73
 
@@ -351,6 +356,39 @@ npm run mcp:dev
351
356
 
352
357
  See [docs/cli.md](https://github.com/stackmemoryai/stackmemory/blob/main/docs/cli.md) for the full command reference.
353
358
 
359
+ ### Snapshot (`snapshot` / `snap`)
360
+
361
+ Capture a point-in-time snapshot of what changed on your branch — files modified, commits, and key decisions. Useful for session handoff and post-task review.
362
+
363
+ ```bash
364
+ # Save a snapshot of current branch state
365
+ stackmemory snapshot save --task "add auth middleware"
366
+
367
+ # Save with explicit decisions
368
+ stackmemory snap save -d "chose JWT over session cookies" -d "switched to argon2"
369
+
370
+ # List recent snapshots
371
+ stackmemory snap list
372
+
373
+ # Show latest snapshot (or by branch)
374
+ stackmemory snap show feature/auth
375
+ ```
376
+
377
+ ### Pre-flight Check (`preflight` / `pf`)
378
+
379
+ Predict file overlaps before running parallel tasks. Uses git history, import graphs, and keyword matching to flag conflicts.
380
+
381
+ ```bash
382
+ # Check if two tasks can safely run in parallel
383
+ stackmemory preflight "add user auth" "refactor database layer"
384
+
385
+ # With explicit file hints
386
+ stackmemory pf "auth work" "db migration" -f "task1:src/auth.ts;task2:src/db.ts"
387
+
388
+ # JSON output for programmatic use
389
+ stackmemory pf "task A" "task B" "task C" --json
390
+ ```
391
+
354
392
  ---
355
393
 
356
394
  ## Documentation
@@ -0,0 +1,123 @@
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 { Command } from "commander";
6
+ import { execSync } from "child_process";
7
+ import chalk from "chalk";
8
+ function parseSeconds(value) {
9
+ const match = value.match(/^(\d+)(s|m|h)?$/);
10
+ if (!match) return parseInt(value, 10) || 10;
11
+ const num = parseInt(match[1], 10);
12
+ switch (match[2]) {
13
+ case "h":
14
+ return num * 3600;
15
+ case "m":
16
+ return num * 60;
17
+ default:
18
+ return num;
19
+ }
20
+ }
21
+ function sleep(ms) {
22
+ return new Promise((resolve) => setTimeout(resolve, ms));
23
+ }
24
+ function checkCondition(output, exitCode, opts) {
25
+ if (opts.untilExit) return exitCode === 0;
26
+ if (opts.untilEmpty) return output.trim().length === 0;
27
+ if (opts.untilNonEmpty) return output.trim().length > 0;
28
+ if (opts.until) return output.includes(opts.until);
29
+ if (opts.untilNot) return !output.includes(opts.untilNot);
30
+ return exitCode === 0;
31
+ }
32
+ function createLoopCommand() {
33
+ return new Command("loop").alias("watch").description(
34
+ "Run a command repeatedly until a condition is met (monitor CI, deploys, logs)"
35
+ ).argument("<command>", "Shell command to run on each iteration").option("--until <pattern>", "Stop when output contains this string").option(
36
+ "--until-not <pattern>",
37
+ "Stop when output no longer contains this string"
38
+ ).option("--until-empty", "Stop when output is empty", false).option("--until-non-empty", "Stop when output is non-empty", false).option(
39
+ "--until-exit",
40
+ "Stop when command exits with code 0 (ignore output)",
41
+ false
42
+ ).option("-i, --interval <duration>", "Check interval (e.g. 10s, 1m)", "10s").option("-t, --timeout <duration>", "Max wait time (e.g. 30m, 1h)", "30m").option("-q, --quiet", "Only show final result", false).option("--json", "Output result as JSON", false).option("-l, --label <name>", "Label for this loop (shown in output)").option("--shell <path>", "Shell to use", "/bin/sh").action(async (command, opts) => {
43
+ const intervalSec = parseSeconds(opts.interval);
44
+ const timeoutSec = parseSeconds(opts.timeout);
45
+ const label = opts.label || command.slice(0, 40);
46
+ const startTime = Date.now();
47
+ const deadline = startTime + timeoutSec * 1e3;
48
+ let iterations = 0;
49
+ let lastOutput = "";
50
+ if (!opts.quiet && !opts.json) {
51
+ console.log(
52
+ chalk.cyan(`[loop] ${label}`) + chalk.gray(` (every ${intervalSec}s, timeout ${timeoutSec}s)`)
53
+ );
54
+ }
55
+ while (Date.now() < deadline) {
56
+ iterations++;
57
+ let output = "";
58
+ let exitCode = 0;
59
+ try {
60
+ output = execSync(command, {
61
+ encoding: "utf8",
62
+ timeout: Math.min(intervalSec * 1e3, 6e4),
63
+ shell: opts.shell,
64
+ stdio: ["pipe", "pipe", "pipe"]
65
+ });
66
+ } catch (error) {
67
+ const e = error;
68
+ exitCode = e.status ?? 1;
69
+ output = (e.stdout || "") + (e.stderr || "");
70
+ }
71
+ lastOutput = output;
72
+ if (!opts.quiet && !opts.json) {
73
+ const elapsed = Math.round((Date.now() - startTime) / 1e3);
74
+ const preview = output.trim().split("\n").slice(-3).join("\n");
75
+ console.log(
76
+ chalk.gray(`[${elapsed}s #${iterations}]`) + (preview ? `
77
+ ${preview}` : chalk.gray(" (no output)"))
78
+ );
79
+ }
80
+ if (checkCondition(output, exitCode, opts)) {
81
+ const result2 = {
82
+ ok: true,
83
+ reason: "matched",
84
+ iterations,
85
+ elapsed: Date.now() - startTime,
86
+ lastOutput: lastOutput.trim()
87
+ };
88
+ if (opts.json) {
89
+ console.log(JSON.stringify(result2));
90
+ } else if (!opts.quiet) {
91
+ console.log(
92
+ chalk.green(`[loop] Condition met after ${iterations} iterations`)
93
+ );
94
+ }
95
+ return;
96
+ }
97
+ const remaining = deadline - Date.now();
98
+ if (remaining > 0) {
99
+ await sleep(Math.min(intervalSec * 1e3, remaining));
100
+ }
101
+ }
102
+ const result = {
103
+ ok: false,
104
+ reason: "timeout",
105
+ iterations,
106
+ elapsed: Date.now() - startTime,
107
+ lastOutput: lastOutput.trim()
108
+ };
109
+ if (opts.json) {
110
+ console.log(JSON.stringify(result));
111
+ } else {
112
+ console.log(
113
+ chalk.yellow(
114
+ `[loop] Timed out after ${timeoutSec}s (${iterations} iterations)`
115
+ )
116
+ );
117
+ }
118
+ process.exit(1);
119
+ });
120
+ }
121
+ export {
122
+ createLoopCommand
123
+ };
@@ -3,14 +3,15 @@ import { dirname as __pathDirname } from 'path';
3
3
  const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
5
  import { Command } from "commander";
6
+ import { execSync } from "child_process";
6
7
  import { existsSync, mkdirSync, writeFileSync } from "fs";
7
8
  import { join } from "path";
8
9
  import { homedir, tmpdir } from "os";
9
10
  import Database from "better-sqlite3";
10
11
  import { logger } from "../../core/monitoring/logger.js";
11
- import { SymphonyOrchestrator } from "./symphony-orchestrator.js";
12
+ import { Conductor } from "./orchestrator.js";
12
13
  function getGlobalStorePath() {
13
- const dir = join(homedir(), ".stackmemory", "symphony");
14
+ const dir = join(homedir(), ".stackmemory", "conductor");
14
15
  if (!existsSync(dir)) {
15
16
  mkdirSync(dir, { recursive: true });
16
17
  }
@@ -42,9 +43,9 @@ function getGlobalDb() {
42
43
  `);
43
44
  return db;
44
45
  }
45
- function createSymphonyCommands() {
46
- const cmd = new Command("symphony");
47
- cmd.description("Symphony orchestrator integration commands");
46
+ function createConductorCommands() {
47
+ const cmd = new Command("conductor");
48
+ cmd.description("Conductor \u2014 autonomous agent orchestration via Linear");
48
49
  cmd.command("capture").description("Capture workspace context after an agent run").requiredOption("--issue <id>", "Issue identifier (e.g., STA-476)").option("--workspace <path>", "Workspace directory", process.cwd()).option("--attempt <n>", "Attempt number", "1").action(async (options) => {
49
50
  const workspace = options.workspace;
50
51
  const issueId = options.issue;
@@ -54,16 +55,20 @@ function createSymphonyCommands() {
54
55
  let framesJson = "[]";
55
56
  let anchorsJson = "[]";
56
57
  let eventsJson = "[]";
58
+ let frameCount = 0;
59
+ let anchorCount = 0;
57
60
  if (existsSync(dbPath)) {
58
61
  try {
59
62
  const db = new Database(dbPath, { readonly: true });
60
63
  const frames = db.prepare(
61
64
  "SELECT frame_id, name, type, digest_text, created_at FROM frames ORDER BY created_at DESC LIMIT 20"
62
65
  ).all();
66
+ frameCount = frames.length;
63
67
  framesJson = JSON.stringify(frames);
64
68
  const anchors = db.prepare(
65
69
  "SELECT anchor_id, type, text, priority FROM anchors WHERE type IN ('DECISION', 'FACT', 'CONSTRAINT', 'RISK') ORDER BY priority DESC LIMIT 30"
66
70
  ).all();
71
+ anchorCount = anchors.length;
67
72
  anchorsJson = JSON.stringify(anchors);
68
73
  const events = db.prepare(
69
74
  "SELECT event_type, payload, ts FROM events ORDER BY ts DESC LIMIT 50"
@@ -80,7 +85,6 @@ function createSymphonyCommands() {
80
85
  }
81
86
  let metadata = { workspace, attempt };
82
87
  try {
83
- const { execSync } = await import("child_process");
84
88
  const branch = execSync("git rev-parse --abbrev-ref HEAD", {
85
89
  cwd: workspace,
86
90
  encoding: "utf8",
@@ -113,8 +117,6 @@ function createSymphonyCommands() {
113
117
  JSON.stringify(metadata)
114
118
  );
115
119
  globalDb.close();
116
- const frameCount = JSON.parse(framesJson).length;
117
- const anchorCount = JSON.parse(anchorsJson).length;
118
120
  console.log(
119
121
  `Captured ${frameCount} frames, ${anchorCount} anchors for ${issueId} (attempt ${attempt})`
120
122
  );
@@ -124,7 +126,7 @@ function createSymphonyCommands() {
124
126
  const issueId = options.issue;
125
127
  const globalDbPath = join(getGlobalStorePath(), "context.db");
126
128
  if (!existsSync(globalDbPath)) {
127
- console.log("No prior Symphony context found");
129
+ console.log("No prior orchestrator context found");
128
130
  return;
129
131
  }
130
132
  const globalDb = getGlobalDb();
@@ -186,7 +188,7 @@ function createSymphonyCommands() {
186
188
  if (!existsSync(restoreDir)) {
187
189
  mkdirSync(restoreDir, { recursive: true });
188
190
  }
189
- const restorePath = join(restoreDir, "symphony-context.md");
191
+ const restorePath = join(restoreDir, "conductor-context.md");
190
192
  writeFileSync(restorePath, lines.join("\n"));
191
193
  globalDb.close();
192
194
  console.log(
@@ -227,10 +229,10 @@ function createSymphonyCommands() {
227
229
  `Archived ${frames.length} frames, ${anchors.length} anchors for ${issueId}`
228
230
  );
229
231
  });
230
- cmd.command("search").description("Search across all Symphony issue contexts").argument("<query>", "Search query").option("--limit <n>", "Max results", "10").action(async (query, options) => {
232
+ cmd.command("search").description("Search across all orchestrator issue contexts").argument("<query>", "Search query").option("--limit <n>", "Max results", "10").action(async (query, options) => {
231
233
  const globalDbPath = join(getGlobalStorePath(), "context.db");
232
234
  if (!existsSync(globalDbPath)) {
233
- console.log("No Symphony context database found");
235
+ console.log("No orchestrator context database found");
234
236
  return;
235
237
  }
236
238
  const limit = parseInt(options.limit, 10);
@@ -261,7 +263,7 @@ function createSymphonyCommands() {
261
263
  }
262
264
  globalDb.close();
263
265
  });
264
- cmd.command("start").description("Start the Symphony orchestrator daemon").option("--team <id>", "Linear team ID").option(
266
+ cmd.command("start").description("Start the orchestrator daemon").option("--team <id>", "Linear team ID").option(
265
267
  "--states <states>",
266
268
  "Comma-separated issue states to pick up",
267
269
  "Todo"
@@ -274,23 +276,23 @@ function createSymphonyCommands() {
274
276
  "State name for completed review",
275
277
  "In Review"
276
278
  ).option("--poll <ms>", "Polling interval in milliseconds", "30000").option("--concurrency <n>", "Max concurrent agents", "3").option("--workspace-root <path>", "Workspace root directory").option("--repo <path>", "Git repo root for worktrees", process.cwd()).option("--branch <name>", "Base branch for worktrees", "main").option("--retries <n>", "Max retries per issue", "1").option("--turn-timeout <ms>", "Agent turn timeout in ms", "3600000").action(async (options) => {
277
- const orchestrator = new SymphonyOrchestrator({
279
+ const conductor = new Conductor({
278
280
  teamId: options.team,
279
281
  activeStates: options.states.split(",").map((s) => s.trim()),
280
282
  inProgressState: options.inProgress,
281
283
  inReviewState: options.inReview,
282
284
  pollIntervalMs: parseInt(options.poll, 10),
283
285
  maxConcurrent: parseInt(options.concurrency, 10),
284
- workspaceRoot: options.workspaceRoot || join(tmpdir(), "symphony_workspaces"),
286
+ workspaceRoot: options.workspaceRoot || join(tmpdir(), "conductor_workspaces"),
285
287
  repoRoot: options.repo,
286
288
  baseBranch: options.branch,
287
289
  maxRetries: parseInt(options.retries, 10),
288
290
  turnTimeoutMs: parseInt(options.turnTimeout, 10)
289
291
  });
290
- await orchestrator.start();
292
+ await conductor.start();
291
293
  });
292
294
  return cmd;
293
295
  }
294
296
  export {
295
- createSymphonyCommands
297
+ createConductorCommands
296
298
  };
@@ -11,6 +11,11 @@ import {
11
11
  LinearClient
12
12
  } from "../../integrations/linear/client.js";
13
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";
14
19
  const DEFAULT_CONFIG = {
15
20
  activeStates: ["Todo"],
16
21
  terminalStates: ["Done", "Cancelled", "Canceled", "Closed"],
@@ -18,7 +23,7 @@ const DEFAULT_CONFIG = {
18
23
  inReviewState: "In Review",
19
24
  pollIntervalMs: 3e4,
20
25
  maxConcurrent: 3,
21
- workspaceRoot: join(tmpdir(), "symphony_workspaces"),
26
+ workspaceRoot: join(tmpdir(), "conductor_workspaces"),
22
27
  repoRoot: process.cwd(),
23
28
  baseBranch: "main",
24
29
  appServerPath: join(
@@ -27,14 +32,14 @@ const DEFAULT_CONFIG = {
27
32
  "..",
28
33
  "..",
29
34
  "scripts",
30
- "symphony",
35
+ "conductor",
31
36
  "claude-app-server.cjs"
32
37
  ),
33
38
  turnTimeoutMs: 36e5,
34
39
  maxRetries: 1,
35
40
  hookTimeoutMs: 6e4
36
41
  };
37
- class SymphonyOrchestrator {
42
+ class Conductor {
38
43
  config;
39
44
  client = null;
40
45
  running = /* @__PURE__ */ new Map();
@@ -47,8 +52,16 @@ class SymphonyOrchestrator {
47
52
  completeCount = 0;
48
53
  stopping = false;
49
54
  stateCache = /* @__PURE__ */ new Map();
55
+ activeStatesLower;
56
+ terminalStatesLower;
50
57
  constructor(config = {}) {
51
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
+ );
52
65
  }
53
66
  /**
54
67
  * Start the orchestrator loop.
@@ -77,14 +90,14 @@ class SymphonyOrchestrator {
77
90
  }
78
91
  this.client = await this.createLinearClient();
79
92
  await this.cacheWorkflowStates();
80
- logger.info("Symphony orchestrator started", {
93
+ logger.info("Orchestrator started", {
81
94
  activeStates: this.config.activeStates,
82
95
  maxConcurrent: this.config.maxConcurrent,
83
96
  pollIntervalMs: this.config.pollIntervalMs,
84
97
  workspaceRoot: this.config.workspaceRoot
85
98
  });
86
99
  console.log(
87
- `Symphony started \u2014 polling every ${this.config.pollIntervalMs / 1e3}s, max ${this.config.maxConcurrent} concurrent`
100
+ `Orchestrator started \u2014 polling every ${this.config.pollIntervalMs / 1e3}s, max ${this.config.maxConcurrent} concurrent`
88
101
  );
89
102
  const shutdown = () => this.stop();
90
103
  process.on("SIGINT", shutdown);
@@ -103,8 +116,8 @@ class SymphonyOrchestrator {
103
116
  async stop() {
104
117
  if (this.stopping) return;
105
118
  this.stopping = true;
106
- console.log("\nSymphony stopping...");
107
- logger.info("Symphony orchestrator stopping", {
119
+ console.log("\nOrchestrator stopping...");
120
+ logger.info("Orchestrator stopping", {
108
121
  runningCount: this.running.size
109
122
  });
110
123
  if (this.pollTimer) {
@@ -132,7 +145,7 @@ class SymphonyOrchestrator {
132
145
  this.running.clear();
133
146
  this.claimed.clear();
134
147
  console.log(
135
- `Symphony stopped. Completed: ${this.completeCount}, Failed: ${this.failCount}`
148
+ `Orchestrator stopped. Completed: ${this.completeCount}, Failed: ${this.failCount}`
136
149
  );
137
150
  }
138
151
  /**
@@ -185,10 +198,12 @@ class SymphonyOrchestrator {
185
198
  (issue) => !this.claimed.has(issue.id) && !this.completed.has(issue.id)
186
199
  );
187
200
  if (eligible.length === 0) return;
188
- const toDispatch = eligible.sort((a, b) => (a.priority || 4) - (b.priority || 4)).slice(0, available);
201
+ const sorted = eligible.sort((a, b) => (a.priority || 4) - (b.priority || 4)).slice(0, available);
202
+ const toDispatch = this.preflightFilter(sorted);
189
203
  logger.info("Dispatching issues", {
190
204
  count: toDispatch.length,
191
- identifiers: toDispatch.map((i) => i.identifier)
205
+ identifiers: toDispatch.map((i) => i.identifier),
206
+ skipped: sorted.length - toDispatch.length
192
207
  });
193
208
  for (const issue of toDispatch) {
194
209
  this.dispatch(issue).catch((err) => {
@@ -206,17 +221,81 @@ class SymphonyOrchestrator {
206
221
  teamId: this.config.teamId,
207
222
  limit: 50
208
223
  });
209
- const activeStatesLower = this.config.activeStates.map(
210
- (s) => s.trim().toLowerCase()
211
- );
212
224
  for (const issue of issues) {
213
225
  const stateName = issue.state.name.trim().toLowerCase();
214
- if (activeStatesLower.includes(stateName)) {
226
+ if (this.activeStatesLower.includes(stateName)) {
215
227
  allCandidates.push(issue);
216
228
  }
217
229
  }
218
230
  return allCandidates;
219
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
+ }
220
299
  // ── Dispatch ──
221
300
  async dispatch(issue) {
222
301
  const issueId = issue.id;
@@ -242,6 +321,7 @@ class SymphonyOrchestrator {
242
321
  run.status = "completed";
243
322
  this.completeCount++;
244
323
  await this.runHook("after-run", workspacePath, issue, run.attempt);
324
+ this.takeSnapshot(workspacePath, issue);
245
325
  await this.transitionIssue(issue, this.config.inReviewState);
246
326
  console.log(`[${issue.identifier}] Completed successfully`);
247
327
  } catch (err) {
@@ -314,7 +394,7 @@ class SymphonyOrchestrator {
314
394
  });
315
395
  return wsPath;
316
396
  }
317
- const branchName = `symphony/${wsKey}`;
397
+ const branchName = `conductor/${wsKey}`;
318
398
  try {
319
399
  execSync("git fetch origin", {
320
400
  cwd: this.config.repoRoot,
@@ -398,8 +478,7 @@ class SymphonyOrchestrator {
398
478
  run.process = proc;
399
479
  let stderr = "";
400
480
  let turnCompleted = false;
401
- let timer;
402
- timer = setTimeout(() => {
481
+ const timer = setTimeout(() => {
403
482
  if (!turnCompleted) {
404
483
  logger.warn("Agent turn timeout", {
405
484
  identifier: issue.identifier,
@@ -485,14 +564,16 @@ class SymphonyOrchestrator {
485
564
  });
486
565
  }
487
566
  handleAgentMessage(msg, issue, _run) {
567
+ const params = msg.params;
488
568
  if (msg.method === "item/commandExecution/started") {
489
569
  logger.debug("Agent tool use", {
490
570
  identifier: issue.identifier,
491
- tool: msg.params?.tool
571
+ tool: params?.tool
492
572
  });
493
573
  }
494
574
  if (msg.method === "turn/completed") {
495
- const output = msg.params?.result?.output;
575
+ const result = params?.result;
576
+ const output = result?.output;
496
577
  if (Array.isArray(output)) {
497
578
  const text = output.filter((b) => b.type === "text").map((b) => b.text).join("\n");
498
579
  if (text) {
@@ -521,7 +602,7 @@ class SymphonyOrchestrator {
521
602
  if (attempt > 1) {
522
603
  lines.push(
523
604
  "",
524
- `This is attempt ${attempt}. Check .stackmemory/symphony-context.md for context from prior attempts.`
605
+ `This is attempt ${attempt}. Check .stackmemory/conductor-context.md for context from prior attempts.`
525
606
  );
526
607
  }
527
608
  lines.push(
@@ -538,12 +619,32 @@ class SymphonyOrchestrator {
538
619
  );
539
620
  return lines.join("\n");
540
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
+ }
541
642
  // ── Hooks ──
542
643
  async runHook(hookName, workspacePath, issue, attempt) {
543
644
  const hookPath = join(
544
645
  this.config.repoRoot,
545
646
  "scripts",
546
- "symphony",
647
+ "conductor",
547
648
  `${hookName}.sh`
548
649
  );
549
650
  if (!existsSync(hookPath)) {
@@ -622,16 +723,12 @@ class SymphonyOrchestrator {
622
723
  // ── Reconciliation ──
623
724
  async reconcile() {
624
725
  if (!this.client || this.running.size === 0) return;
625
- const terminalLower = this.config.terminalStates.map(
626
- (s) => s.trim().toLowerCase()
627
- );
628
726
  for (const [issueId, run] of this.running) {
629
727
  try {
630
- const issues = await this.client.getIssues({ limit: 1 });
631
- const fresh = issues.find((i) => i.id === issueId);
728
+ const fresh = await this.client.getIssue(issueId);
632
729
  if (!fresh) continue;
633
730
  const currentState = fresh.state.name.trim().toLowerCase();
634
- if (terminalLower.includes(currentState)) {
731
+ if (this.terminalStatesLower.includes(currentState)) {
635
732
  logger.info(
636
733
  "Issue moved to terminal state externally, stopping agent",
637
734
  {
@@ -672,5 +769,5 @@ class SymphonyOrchestrator {
672
769
  }
673
770
  }
674
771
  export {
675
- SymphonyOrchestrator
772
+ Conductor
676
773
  };