@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 +38 -0
- package/dist/src/cli/commands/loop.js +123 -0
- package/dist/src/cli/commands/{symphony.js → orchestrate.js} +19 -17
- package/dist/src/cli/commands/{symphony-orchestrator.js → orchestrator.js} +125 -28
- package/dist/src/cli/commands/preflight.js +87 -0
- package/dist/src/cli/commands/snapshot.js +89 -0
- package/dist/src/cli/index.js +8 -2
- package/dist/src/core/retrieval/summary-generator.js +2 -14
- package/dist/src/core/utils/fs.js +18 -0
- package/dist/src/core/utils/git.js +91 -0
- package/dist/src/core/utils/text.js +63 -0
- package/dist/src/core/worktree/capture.js +178 -0
- package/dist/src/core/worktree/preflight.js +313 -0
- package/package.json +1 -1
- package/scripts/git-hooks/post-commit-stackmemory.sh +27 -0
- package/templates/claude-hooks/skill-eval.cjs +6 -0
- package/templates/claude-hooks/skill-rules.json +16 -0
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 {
|
|
12
|
+
import { Conductor } from "./orchestrator.js";
|
|
12
13
|
function getGlobalStorePath() {
|
|
13
|
-
const dir = join(homedir(), ".stackmemory", "
|
|
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
|
|
46
|
-
const cmd = new Command("
|
|
47
|
-
cmd.description("
|
|
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
|
|
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, "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(), "
|
|
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
|
|
292
|
+
await conductor.start();
|
|
291
293
|
});
|
|
292
294
|
return cmd;
|
|
293
295
|
}
|
|
294
296
|
export {
|
|
295
|
-
|
|
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(), "
|
|
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
|
-
"
|
|
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
|
|
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("
|
|
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
|
-
`
|
|
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("\
|
|
107
|
-
logger.info("
|
|
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
|
-
`
|
|
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
|
|
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 = `
|
|
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
|
-
|
|
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:
|
|
571
|
+
tool: params?.tool
|
|
492
572
|
});
|
|
493
573
|
}
|
|
494
574
|
if (msg.method === "turn/completed") {
|
|
495
|
-
const
|
|
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/
|
|
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
|
-
"
|
|
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
|
|
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 (
|
|
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
|
-
|
|
772
|
+
Conductor
|
|
676
773
|
};
|