@stackmemoryai/stackmemory 1.3.2 → 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.
- 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 +6 -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
|
@@ -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
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
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 chalk from "chalk";
|
|
7
|
+
import {
|
|
8
|
+
PreflightChecker
|
|
9
|
+
} from "../../core/worktree/preflight.js";
|
|
10
|
+
function createPreflightCommand() {
|
|
11
|
+
const cmd = new Command("preflight").alias("pf").description("Check file overlap before running parallel tasks").argument("<tasks...>", "Task descriptions (quoted strings)").option(
|
|
12
|
+
"-k, --keywords <keywords>",
|
|
13
|
+
"Comma-separated keywords per task (task1:kw1,kw2;task2:kw3)"
|
|
14
|
+
).option(
|
|
15
|
+
"-f, --files <files>",
|
|
16
|
+
"Comma-separated files per task (task1:file1,file2;task2:file3)"
|
|
17
|
+
).option("--json", "Output as JSON").action((taskArgs, options) => {
|
|
18
|
+
const checker = new PreflightChecker();
|
|
19
|
+
const tasks = taskArgs.map((desc, i) => {
|
|
20
|
+
const task = {
|
|
21
|
+
name: `task-${i + 1}`,
|
|
22
|
+
description: desc
|
|
23
|
+
};
|
|
24
|
+
if (options.keywords) {
|
|
25
|
+
const kwParts = options.keywords.split(";");
|
|
26
|
+
if (kwParts[i]) {
|
|
27
|
+
const [, kws] = kwParts[i].split(":");
|
|
28
|
+
if (kws) task.keywords = kws.split(",");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (options.files) {
|
|
32
|
+
const fileParts = options.files.split(";");
|
|
33
|
+
if (fileParts[i]) {
|
|
34
|
+
const [, fs] = fileParts[i].split(":");
|
|
35
|
+
if (fs) task.files = fs.split(",");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (desc.length <= 40) {
|
|
39
|
+
task.name = desc;
|
|
40
|
+
}
|
|
41
|
+
return task;
|
|
42
|
+
});
|
|
43
|
+
const result = checker.check(tasks);
|
|
44
|
+
if (options.json) {
|
|
45
|
+
console.log(JSON.stringify(result, null, 2));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
console.log(chalk.cyan("\nPre-flight Check\n"));
|
|
49
|
+
console.log(chalk.gray(`Tasks: ${tasks.length}`));
|
|
50
|
+
console.log(chalk.gray(`Overlaps: ${result.allOverlaps.length}
|
|
51
|
+
`));
|
|
52
|
+
if (result.parallelSafe.length === 1 && result.allOverlaps.length === 0) {
|
|
53
|
+
console.log(chalk.green("All tasks are parallel-safe.\n"));
|
|
54
|
+
for (const task of result.parallelSafe[0]) {
|
|
55
|
+
console.log(chalk.green(` + ${task.name}`));
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
for (let i = 0; i < result.parallelSafe.length; i++) {
|
|
59
|
+
const group = result.parallelSafe[i];
|
|
60
|
+
console.log(chalk.cyan(`Group ${i + 1} (parallel-safe):`));
|
|
61
|
+
for (const task of group) {
|
|
62
|
+
console.log(chalk.green(` + ${task.name}`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (result.sequential.length > 0) {
|
|
67
|
+
console.log(chalk.yellow("\nSequential (file overlaps detected):"));
|
|
68
|
+
for (const entry of result.sequential) {
|
|
69
|
+
console.log(
|
|
70
|
+
chalk.yellow(` "${entry.task.name}" -> after "${entry.after}"`)
|
|
71
|
+
);
|
|
72
|
+
for (const overlap of entry.overlaps.slice(0, 5)) {
|
|
73
|
+
const conf = Math.round(overlap.confidence * 100);
|
|
74
|
+
console.log(
|
|
75
|
+
chalk.gray(` ${overlap.file} (${overlap.source}, ${conf}%)`)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
console.log(chalk.gray(`
|
|
81
|
+
${result.summary}`));
|
|
82
|
+
});
|
|
83
|
+
return cmd;
|
|
84
|
+
}
|
|
85
|
+
export {
|
|
86
|
+
createPreflightCommand
|
|
87
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
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 chalk from "chalk";
|
|
7
|
+
import { ContextCapture } from "../../core/worktree/capture.js";
|
|
8
|
+
function createSnapshotCommand() {
|
|
9
|
+
const cmd = new Command("snapshot").alias("snap").description("Point-in-time snapshot of work (what changed and why)");
|
|
10
|
+
cmd.command("save").alias("s").description("Save a snapshot of current branch state").option("-t, --task <name>", "Task name or description").option(
|
|
11
|
+
"-b, --base <branch>",
|
|
12
|
+
"Base branch to diff against (default: auto-detect)"
|
|
13
|
+
).option(
|
|
14
|
+
"-d, --decision <decisions...>",
|
|
15
|
+
"Key decisions made during this task"
|
|
16
|
+
).option("--json", "Output as JSON").action((options) => {
|
|
17
|
+
const capture = new ContextCapture();
|
|
18
|
+
const result = capture.capture({
|
|
19
|
+
task: options.task,
|
|
20
|
+
baseBranch: options.base,
|
|
21
|
+
decisions: options.decision
|
|
22
|
+
});
|
|
23
|
+
if (options.json) {
|
|
24
|
+
console.log(JSON.stringify(result, null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
console.log(chalk.green("\nSnapshot saved.\n"));
|
|
28
|
+
console.log(chalk.gray(` Branch: ${result.branch}`));
|
|
29
|
+
console.log(chalk.gray(` Base: ${result.baseBranch}`));
|
|
30
|
+
console.log(chalk.gray(` Changed: ${result.filesChanged.length} files`));
|
|
31
|
+
console.log(chalk.gray(` Created: ${result.filesCreated.length} files`));
|
|
32
|
+
console.log(chalk.gray(` Deleted: ${result.filesDeleted.length} files`));
|
|
33
|
+
console.log(chalk.gray(` Commits: ${result.commits.length}`));
|
|
34
|
+
if (result.decisions.length > 0) {
|
|
35
|
+
console.log(chalk.cyan("\n Decisions:"));
|
|
36
|
+
result.decisions.forEach((d) => console.log(chalk.gray(` - ${d}`)));
|
|
37
|
+
}
|
|
38
|
+
if (result.duration) {
|
|
39
|
+
console.log(chalk.gray(` Duration: ${result.duration}`));
|
|
40
|
+
}
|
|
41
|
+
console.log(chalk.gray(`
|
|
42
|
+
Saved: ${result.id}`));
|
|
43
|
+
});
|
|
44
|
+
cmd.command("list").alias("ls").description("List recent snapshots").option("-n, --limit <n>", "Number of captures to show", "10").option("--json", "Output as JSON").action((options) => {
|
|
45
|
+
const capture = new ContextCapture();
|
|
46
|
+
const captures = capture.list(parseInt(options.limit));
|
|
47
|
+
if (captures.length === 0) {
|
|
48
|
+
console.log(chalk.yellow("No snapshots found."));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (options.json) {
|
|
52
|
+
console.log(JSON.stringify(captures, null, 2));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
console.log(chalk.cyan(`
|
|
56
|
+
Recent Snapshots (${captures.length}):
|
|
57
|
+
`));
|
|
58
|
+
for (const cap of captures) {
|
|
59
|
+
const date = new Date(cap.timestamp).toLocaleDateString();
|
|
60
|
+
const files = cap.filesChanged.length + cap.filesCreated.length;
|
|
61
|
+
console.log(
|
|
62
|
+
chalk.gray(
|
|
63
|
+
` ${date} ${cap.branch.padEnd(30)} ${files} files ${cap.commits.length} commits`
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
cmd.command("show [branch]").description("Show snapshot details (latest or by branch)").option("--json", "Output as JSON").action((branch, options) => {
|
|
69
|
+
const capturer = new ContextCapture();
|
|
70
|
+
const result = capturer.getLatest(branch);
|
|
71
|
+
if (!result) {
|
|
72
|
+
console.log(
|
|
73
|
+
chalk.yellow(
|
|
74
|
+
branch ? `No snapshot found for branch: ${branch}` : "No snapshots found."
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (options.json) {
|
|
80
|
+
console.log(JSON.stringify(result, null, 2));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
console.log(capturer.format(result));
|
|
84
|
+
});
|
|
85
|
+
return cmd;
|
|
86
|
+
}
|
|
87
|
+
export {
|
|
88
|
+
createSnapshotCommand
|
|
89
|
+
};
|
package/dist/src/cli/index.js
CHANGED
|
@@ -57,7 +57,9 @@ import { createBenchCommand } from "./commands/bench.js";
|
|
|
57
57
|
import { createDigestCommands } from "./commands/digest.js";
|
|
58
58
|
import { createTeamCommands } from "./commands/team.js";
|
|
59
59
|
import { createDesiresCommands } from "./commands/desires.js";
|
|
60
|
-
import {
|
|
60
|
+
import { createConductorCommands } from "./commands/orchestrate.js";
|
|
61
|
+
import { createPreflightCommand } from "./commands/preflight.js";
|
|
62
|
+
import { createSnapshotCommand } from "./commands/snapshot.js";
|
|
61
63
|
import chalk from "chalk";
|
|
62
64
|
import * as fs from "fs";
|
|
63
65
|
import * as path from "path";
|
|
@@ -508,7 +510,9 @@ program.addCommand(createBenchCommand());
|
|
|
508
510
|
program.addCommand(createDigestCommands());
|
|
509
511
|
program.addCommand(createTeamCommands());
|
|
510
512
|
program.addCommand(createDesiresCommands());
|
|
511
|
-
program.addCommand(
|
|
513
|
+
program.addCommand(createConductorCommands());
|
|
514
|
+
program.addCommand(createPreflightCommand());
|
|
515
|
+
program.addCommand(createSnapshotCommand());
|
|
512
516
|
registerSetupCommands(program);
|
|
513
517
|
program.command("mm-spike").description(
|
|
514
518
|
"Run multi-agent planning/implementation spike (planner/implementer/critic)"
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
DEFAULT_RETRIEVAL_CONFIG
|
|
7
7
|
} from "./types.js";
|
|
8
8
|
import { logger } from "../monitoring/logger.js";
|
|
9
|
+
import { extractKeywords as extractKeywordsShared } from "../utils/text.js";
|
|
9
10
|
class CompressedSummaryGenerator {
|
|
10
11
|
db;
|
|
11
12
|
frameManager;
|
|
@@ -570,20 +571,7 @@ class CompressedSummaryGenerator {
|
|
|
570
571
|
}
|
|
571
572
|
}
|
|
572
573
|
extractKeywords(text) {
|
|
573
|
-
|
|
574
|
-
"the",
|
|
575
|
-
"a",
|
|
576
|
-
"an",
|
|
577
|
-
"and",
|
|
578
|
-
"or",
|
|
579
|
-
"but",
|
|
580
|
-
"in",
|
|
581
|
-
"on",
|
|
582
|
-
"at",
|
|
583
|
-
"to",
|
|
584
|
-
"for"
|
|
585
|
-
]);
|
|
586
|
-
return text.toLowerCase().split(/\W+/).filter((word) => word.length > 2 && !stopWords.has(word));
|
|
574
|
+
return extractKeywordsShared(text, { maxCount: 20 });
|
|
587
575
|
}
|
|
588
576
|
/**
|
|
589
577
|
* Clear the cache
|
|
@@ -0,0 +1,18 @@
|
|
|
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 { readdirSync, unlinkSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
function pruneOldFiles(dir, ext, maxKeep) {
|
|
8
|
+
try {
|
|
9
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(ext)).sort().reverse();
|
|
10
|
+
for (const old of files.slice(maxKeep)) {
|
|
11
|
+
unlinkSync(join(dir, old));
|
|
12
|
+
}
|
|
13
|
+
} catch {
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
pruneOldFiles
|
|
18
|
+
};
|