@stackmemoryai/stackmemory 1.5.6 → 1.5.8
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/bin/gemini-sm +6 -0
- package/dist/src/cli/commands/orchestrate.js +296 -51
- package/dist/src/cli/commands/orchestrator.js +135 -22
- package/dist/src/cli/gemini-sm.js +481 -0
- package/package.json +3 -2
package/bin/gemini-sm
ADDED
|
@@ -16,7 +16,9 @@ import { homedir, tmpdir } from "os";
|
|
|
16
16
|
import Database from "better-sqlite3";
|
|
17
17
|
import { logger } from "../../core/monitoring/logger.js";
|
|
18
18
|
import { Conductor } from "./orchestrator.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
getAgentStatusDir
|
|
21
|
+
} from "./orchestrator.js";
|
|
20
22
|
function getGlobalStorePath() {
|
|
21
23
|
const dir = join(homedir(), ".stackmemory", "conductor");
|
|
22
24
|
if (!existsSync(dir)) {
|
|
@@ -73,6 +75,101 @@ function budgetBar(pct, width = 30) {
|
|
|
73
75
|
const rst = "\x1B[0m";
|
|
74
76
|
return `${color}${"\u2588".repeat(filled)}${dim}${"\u2591".repeat(empty)}${rst} ${String(pct).padStart(3)}%`;
|
|
75
77
|
}
|
|
78
|
+
const c = {
|
|
79
|
+
r: "\x1B[0m",
|
|
80
|
+
// reset
|
|
81
|
+
b: "\x1B[1m",
|
|
82
|
+
// bold
|
|
83
|
+
d: "\x1B[2m",
|
|
84
|
+
// dim
|
|
85
|
+
i: "\x1B[3m",
|
|
86
|
+
// italic
|
|
87
|
+
u: "\x1B[4m",
|
|
88
|
+
// underline
|
|
89
|
+
// Linear-inspired palette
|
|
90
|
+
purple: "\x1B[38;5;141m",
|
|
91
|
+
blue: "\x1B[38;5;75m",
|
|
92
|
+
cyan: "\x1B[38;5;80m",
|
|
93
|
+
green: "\x1B[38;5;114m",
|
|
94
|
+
yellow: "\x1B[38;5;221m",
|
|
95
|
+
orange: "\x1B[38;5;215m",
|
|
96
|
+
red: "\x1B[38;5;203m",
|
|
97
|
+
pink: "\x1B[38;5;176m",
|
|
98
|
+
gray: "\x1B[38;5;245m",
|
|
99
|
+
white: "\x1B[38;5;255m",
|
|
100
|
+
bg: {
|
|
101
|
+
purple: "\x1B[48;5;53m",
|
|
102
|
+
blue: "\x1B[48;5;24m",
|
|
103
|
+
green: "\x1B[48;5;22m",
|
|
104
|
+
red: "\x1B[48;5;52m",
|
|
105
|
+
yellow: "\x1B[48;5;58m",
|
|
106
|
+
gray: "\x1B[48;5;236m"
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const phaseIcon = {
|
|
110
|
+
reading: "\u25D4",
|
|
111
|
+
planning: "\u25D1",
|
|
112
|
+
implementing: "\u25D5",
|
|
113
|
+
testing: "\u25CF",
|
|
114
|
+
committing: "\u2713"
|
|
115
|
+
};
|
|
116
|
+
const phaseColor = {
|
|
117
|
+
reading: c.cyan,
|
|
118
|
+
planning: c.blue,
|
|
119
|
+
implementing: c.yellow,
|
|
120
|
+
testing: c.pink,
|
|
121
|
+
committing: c.green
|
|
122
|
+
};
|
|
123
|
+
function isProcessAlive(pid) {
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 0);
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function phaseProgress(phase, toolCalls, stale, alive) {
|
|
132
|
+
const basePct = {
|
|
133
|
+
reading: 10,
|
|
134
|
+
planning: 25,
|
|
135
|
+
implementing: 50,
|
|
136
|
+
testing: 75,
|
|
137
|
+
committing: 90
|
|
138
|
+
};
|
|
139
|
+
let pct = basePct[phase] || 0;
|
|
140
|
+
if (phase === "implementing") {
|
|
141
|
+
pct += Math.min(Math.floor(toolCalls / 80 * 25), 25);
|
|
142
|
+
}
|
|
143
|
+
if (phase === "committing") {
|
|
144
|
+
pct = 90 + Math.min(Math.floor(toolCalls / 60 * 10), 9);
|
|
145
|
+
}
|
|
146
|
+
const labels = {
|
|
147
|
+
reading: "Reading",
|
|
148
|
+
planning: "Planning",
|
|
149
|
+
implementing: "Implementing",
|
|
150
|
+
testing: "Testing",
|
|
151
|
+
committing: "Committing"
|
|
152
|
+
};
|
|
153
|
+
let label = labels[phase] || phase;
|
|
154
|
+
let color = phaseColor[phase] || "";
|
|
155
|
+
let icon = phaseIcon[phase] || "\u25CB";
|
|
156
|
+
if (!alive) {
|
|
157
|
+
label = "Dead";
|
|
158
|
+
color = c.red;
|
|
159
|
+
icon = "\u2717";
|
|
160
|
+
} else if (stale) {
|
|
161
|
+
label = "Stalled";
|
|
162
|
+
color = c.orange;
|
|
163
|
+
icon = "\u23F8";
|
|
164
|
+
}
|
|
165
|
+
return { icon, color, pct, label };
|
|
166
|
+
}
|
|
167
|
+
function progressBar(pct, width) {
|
|
168
|
+
const filled = Math.min(Math.round(pct / 100 * width), width);
|
|
169
|
+
const empty = width - filled;
|
|
170
|
+
const col = pct >= 90 ? c.green : pct >= 50 ? c.yellow : c.cyan;
|
|
171
|
+
return `${col}${"\u2501".repeat(filled)}${c.d}${"\u254C".repeat(empty)}${c.r}`;
|
|
172
|
+
}
|
|
76
173
|
function fmtMinutes(m) {
|
|
77
174
|
if (m < 0) return "N/A";
|
|
78
175
|
if (m >= 60) return `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
@@ -90,23 +187,23 @@ function printUsageSummary(u) {
|
|
|
90
187
|
const mins20x = u.minutesRemaining20x ?? -1;
|
|
91
188
|
const cacheHitRate = u.cacheHitRate || 0;
|
|
92
189
|
const b = "\x1B[1m";
|
|
93
|
-
const
|
|
190
|
+
const d2 = "\x1B[2m";
|
|
94
191
|
const w = "\x1B[37m";
|
|
95
|
-
const
|
|
96
|
-
console.log(`${b}Token Usage${
|
|
192
|
+
const r2 = "\x1B[0m";
|
|
193
|
+
console.log(`${b}Token Usage${r2}`);
|
|
97
194
|
console.log(
|
|
98
|
-
` Input ${w}${fmtTokens(inputTokens)}${
|
|
195
|
+
` Input ${w}${fmtTokens(inputTokens)}${r2} ${d2}|${r2} Output ${w}${fmtTokens(outputTokens)}${r2} ${d2}|${r2} Total ${w}${fmtTokens(totalTokens)}${r2}`
|
|
99
196
|
);
|
|
100
197
|
console.log(
|
|
101
|
-
` Rate ${w}${fmtTokens(tokensPerMin)}/min${
|
|
198
|
+
` Rate ${w}${fmtTokens(tokensPerMin)}/min${r2} ${d2}|${r2} Messages ${w}${estMessages}${r2} ${d2}|${r2} Cache hit ${w}${cacheHitRate}%${r2}`
|
|
102
199
|
);
|
|
103
200
|
console.log("");
|
|
104
|
-
console.log(`${b}Budget (Max plan, 5h window)${
|
|
201
|
+
console.log(`${b}Budget (Max plan, 5h window)${r2}`);
|
|
105
202
|
console.log(
|
|
106
|
-
` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${
|
|
203
|
+
` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${d2}~${fmtMinutes(mins5x)} left${r2}`
|
|
107
204
|
);
|
|
108
205
|
console.log(
|
|
109
|
-
` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${
|
|
206
|
+
` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${d2}~${fmtMinutes(mins20x)} left${r2}`
|
|
110
207
|
);
|
|
111
208
|
}
|
|
112
209
|
function createConductorCommands() {
|
|
@@ -226,14 +323,14 @@ function createConductorCommands() {
|
|
|
226
323
|
const risks = anchors.filter((a) => a.type === "RISK");
|
|
227
324
|
if (decisions.length > 0) {
|
|
228
325
|
lines.push("", "### Decisions");
|
|
229
|
-
for (const
|
|
230
|
-
lines.push(`- ${
|
|
326
|
+
for (const d2 of decisions.slice(0, 10)) {
|
|
327
|
+
lines.push(`- ${d2.text}`);
|
|
231
328
|
}
|
|
232
329
|
}
|
|
233
330
|
if (risks.length > 0) {
|
|
234
331
|
lines.push("", "### Risks");
|
|
235
|
-
for (const
|
|
236
|
-
lines.push(`- ${
|
|
332
|
+
for (const r2 of risks.slice(0, 5)) {
|
|
333
|
+
lines.push(`- ${r2.text}`);
|
|
237
334
|
}
|
|
238
335
|
}
|
|
239
336
|
} catch {
|
|
@@ -317,13 +414,13 @@ function createConductorCommands() {
|
|
|
317
414
|
}
|
|
318
415
|
console.log(`Found ${results.length} result(s) for "${query}":
|
|
319
416
|
`);
|
|
320
|
-
for (const
|
|
321
|
-
const date = new Date(
|
|
417
|
+
for (const r2 of results) {
|
|
418
|
+
const date = new Date(r2.captured_at * 1e3).toISOString().slice(0, 16);
|
|
322
419
|
console.log(
|
|
323
|
-
` ${
|
|
420
|
+
` ${r2.issue_id} [${r2.context_type}] attempt ${r2.attempt} (${date})`
|
|
324
421
|
);
|
|
325
|
-
if (
|
|
326
|
-
const snippet =
|
|
422
|
+
if (r2.summary) {
|
|
423
|
+
const snippet = r2.summary.slice(0, 120).replace(/\n/g, " ");
|
|
327
424
|
console.log(` ${snippet}`);
|
|
328
425
|
}
|
|
329
426
|
}
|
|
@@ -354,13 +451,162 @@ function createConductorCommands() {
|
|
|
354
451
|
statuses.sort(
|
|
355
452
|
(a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
|
|
356
453
|
);
|
|
357
|
-
const
|
|
358
|
-
|
|
454
|
+
const active = statuses.filter((s) => isProcessAlive(s.pid));
|
|
455
|
+
const stalled = statuses.filter(
|
|
456
|
+
(s) => isProcessAlive(s.pid) && Date.now() - new Date(s.lastUpdate).getTime() > 5 * 60 * 1e3
|
|
457
|
+
);
|
|
458
|
+
const dead = statuses.filter((s) => !isProcessAlive(s.pid));
|
|
459
|
+
const healthy = active.length - stalled.length;
|
|
460
|
+
const parts = [];
|
|
461
|
+
if (healthy > 0) parts.push(`${c.green}\u25CF ${healthy}${c.r}`);
|
|
462
|
+
if (stalled.length > 0)
|
|
463
|
+
parts.push(`${c.orange}\u23F8 ${stalled.length}${c.r}`);
|
|
464
|
+
if (dead.length > 0) parts.push(`${c.red}\u2717 ${dead.length}${c.r}`);
|
|
465
|
+
console.log(`
|
|
466
|
+
${c.b}${c.white}Conductor${c.r} ${parts.join(" ")}
|
|
467
|
+
`);
|
|
468
|
+
const cols = (process.stdout.columns || 80) >= 90 ? 2 : 1;
|
|
469
|
+
const rows = [];
|
|
359
470
|
for (const s of statuses) {
|
|
360
471
|
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
|
|
472
|
+
const staleFlag = elapsed > 5 * 60 * 1e3;
|
|
473
|
+
const alive = isProcessAlive(s.pid);
|
|
474
|
+
const { icon, color, pct, label } = phaseProgress(
|
|
475
|
+
s.phase,
|
|
476
|
+
s.toolCalls,
|
|
477
|
+
staleFlag,
|
|
478
|
+
alive
|
|
479
|
+
);
|
|
480
|
+
const bar = progressBar(pct, 8);
|
|
481
|
+
const timeColor = !alive ? c.red : staleFlag ? c.orange : c.gray;
|
|
482
|
+
const cell = [
|
|
483
|
+
`${color}${icon}${c.r} ${c.b}${s.issue}${c.r} ${color}${label}${c.r}`,
|
|
484
|
+
` ${bar} ${c.d}${pct}%${c.r} ${c.gray}${s.toolCalls}t ${s.filesModified}f${c.r} ${timeColor}${formatElapsed(elapsed)}${c.r}`
|
|
485
|
+
];
|
|
486
|
+
rows.push(cell);
|
|
487
|
+
}
|
|
488
|
+
if (cols === 2) {
|
|
489
|
+
for (let i = 0; i < rows.length; i += 2) {
|
|
490
|
+
const left = rows[i];
|
|
491
|
+
const right = rows[i + 1];
|
|
492
|
+
const pad = 42;
|
|
493
|
+
if (right) {
|
|
494
|
+
console.log(
|
|
495
|
+
` ${left[0].padEnd(pad + 30)}${c.gray}\u2502${c.r} ${right[0]}`
|
|
496
|
+
);
|
|
497
|
+
console.log(
|
|
498
|
+
` ${left[1].padEnd(pad + 30)}${c.gray}\u2502${c.r} ${right[1]}`
|
|
499
|
+
);
|
|
500
|
+
} else {
|
|
501
|
+
console.log(` ${left[0]}`);
|
|
502
|
+
console.log(` ${left[1]}`);
|
|
503
|
+
}
|
|
504
|
+
if (i + 2 < rows.length) {
|
|
505
|
+
console.log(` ${c.gray}${"\u254C".repeat(38)}\u253C${"\u254C".repeat(38)}${c.r}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
for (let i = 0; i < rows.length; i++) {
|
|
510
|
+
console.log(` ${rows[i][0]}`);
|
|
511
|
+
console.log(` ${rows[i][1]}`);
|
|
512
|
+
if (i < rows.length - 1) {
|
|
513
|
+
console.log(` ${c.gray}${"\u254C".repeat(38)}${c.r}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
console.log("");
|
|
518
|
+
});
|
|
519
|
+
cmd.command("finalize").description("Clean up completed/dead agents that conductor missed").option("--dry-run", "Show what would be done without doing it", false).action(async (options) => {
|
|
520
|
+
const agentsDir = join(homedir(), ".stackmemory", "conductor", "agents");
|
|
521
|
+
if (!existsSync(agentsDir)) {
|
|
522
|
+
console.log("No agent status files found");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const entries = readdirSync(agentsDir, { withFileTypes: true });
|
|
526
|
+
const statuses = [];
|
|
527
|
+
for (const entry of entries) {
|
|
528
|
+
if (!entry.isDirectory()) continue;
|
|
529
|
+
const statusPath = join(agentsDir, entry.name, "status.json");
|
|
530
|
+
if (!existsSync(statusPath)) continue;
|
|
531
|
+
try {
|
|
532
|
+
const data = JSON.parse(readFileSync(statusPath, "utf-8"));
|
|
533
|
+
statuses.push({ ...data, dir: entry.name });
|
|
534
|
+
} catch {
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const needsFinalize = statuses.filter((s) => {
|
|
538
|
+
const alive = isProcessAlive(s.pid);
|
|
539
|
+
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
540
|
+
return !alive || elapsed > 60 * 60 * 1e3;
|
|
541
|
+
});
|
|
542
|
+
if (needsFinalize.length === 0) {
|
|
543
|
+
console.log(
|
|
544
|
+
`${c.green}All agents are healthy \u2014 nothing to finalize.${c.r}`
|
|
545
|
+
);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
console.log(
|
|
549
|
+
`
|
|
550
|
+
${c.b}Finalizing ${needsFinalize.length} agent(s)${c.r}
|
|
551
|
+
`
|
|
552
|
+
);
|
|
553
|
+
for (const s of needsFinalize) {
|
|
554
|
+
const alive = isProcessAlive(s.pid);
|
|
555
|
+
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
556
|
+
const elapsedStr = formatElapsed(elapsed).replace(" ago", "");
|
|
557
|
+
let hasCommits = false;
|
|
558
|
+
if (s.workspacePath && existsSync(s.workspacePath)) {
|
|
559
|
+
try {
|
|
560
|
+
const log = execSync("git log origin/main..HEAD --oneline", {
|
|
561
|
+
cwd: s.workspacePath,
|
|
562
|
+
encoding: "utf-8",
|
|
563
|
+
timeout: 1e4
|
|
564
|
+
});
|
|
565
|
+
hasCommits = log.trim().length > 0;
|
|
566
|
+
} catch {
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const statusIcon = !alive ? `${c.red}\u2717 dead${c.r}` : `${c.orange}\u23F8 stalled ${elapsedStr}${c.r}`;
|
|
570
|
+
const commitStatus = hasCommits ? `${c.green}has commits \u2192 In Review${c.r}` : `${c.gray}no commits \u2192 mark failed${c.r}`;
|
|
571
|
+
console.log(` ${c.b}${s.issue}${c.r} ${statusIcon} ${commitStatus}`);
|
|
572
|
+
if (options.dryRun) continue;
|
|
573
|
+
if (alive) {
|
|
574
|
+
try {
|
|
575
|
+
process.kill(s.pid, "SIGTERM");
|
|
576
|
+
console.log(` ${c.gray}Sent SIGTERM to pid ${s.pid}${c.r}`);
|
|
577
|
+
} catch {
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const statusPath = join(agentsDir, s.dir, "status.json");
|
|
581
|
+
try {
|
|
582
|
+
const updated = { ...s };
|
|
583
|
+
delete updated["dir"];
|
|
584
|
+
writeFileSync(
|
|
585
|
+
statusPath,
|
|
586
|
+
JSON.stringify(
|
|
587
|
+
{ ...updated, lastUpdate: (/* @__PURE__ */ new Date()).toISOString() },
|
|
588
|
+
null,
|
|
589
|
+
2
|
|
590
|
+
)
|
|
591
|
+
);
|
|
592
|
+
} catch {
|
|
593
|
+
}
|
|
594
|
+
if (hasCommits) {
|
|
595
|
+
console.log(
|
|
596
|
+
` ${c.cyan}\u2192 Move ${s.issue} to "In Review" in Linear${c.r}`
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (options.dryRun) {
|
|
601
|
+
console.log(
|
|
602
|
+
`
|
|
603
|
+
${c.d}Dry run \u2014 no changes made. Remove --dry-run to execute.${c.r}`
|
|
604
|
+
);
|
|
605
|
+
} else {
|
|
606
|
+
console.log(
|
|
607
|
+
`
|
|
608
|
+
${c.green}Done.${c.r} Run ${c.cyan}conductor status${c.r} to verify.`
|
|
609
|
+
);
|
|
364
610
|
}
|
|
365
611
|
});
|
|
366
612
|
cmd.command("logs").description("Tail agent output log").argument("<issue-id>", "Issue identifier (e.g., STA-499)").option("-f, --follow", "Follow the log (tail -f)", false).option("-n, --lines <n>", "Number of lines to show", "50").action(async (issueId, options) => {
|
|
@@ -474,13 +720,6 @@ function createConductorCommands() {
|
|
|
474
720
|
let paused = false;
|
|
475
721
|
let refreshInterval = interval;
|
|
476
722
|
let phaseFilter = options.phase || null;
|
|
477
|
-
const b = "\x1B[1m";
|
|
478
|
-
const d = "\x1B[2m";
|
|
479
|
-
const cyan = "\x1B[36m";
|
|
480
|
-
const green = "\x1B[32m";
|
|
481
|
-
const yellow = "\x1B[33m";
|
|
482
|
-
const red = "\x1B[31m";
|
|
483
|
-
const r = "\x1B[0m";
|
|
484
723
|
function readStatuses() {
|
|
485
724
|
const agentsDir = join(
|
|
486
725
|
homedir(),
|
|
@@ -501,7 +740,7 @@ function createConductorCommands() {
|
|
|
501
740
|
}
|
|
502
741
|
}
|
|
503
742
|
statuses.sort(
|
|
504
|
-
(a,
|
|
743
|
+
(a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
|
|
505
744
|
);
|
|
506
745
|
if (phaseFilter) {
|
|
507
746
|
return statuses.filter((s) => s.phase === phaseFilter);
|
|
@@ -514,12 +753,19 @@ function createConductorCommands() {
|
|
|
514
753
|
console.log(` No active agents${filterNote}`);
|
|
515
754
|
return;
|
|
516
755
|
}
|
|
517
|
-
const header = `${"Issue".padEnd(12)}${"Phase".padEnd(16)}${"Tools".padStart(7)}${"Files".padStart(7)}${"Tokens".padStart(9)} Last Update`;
|
|
518
|
-
console.log(header);
|
|
519
756
|
for (const s of statuses) {
|
|
520
757
|
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
521
|
-
const
|
|
522
|
-
|
|
758
|
+
const stale = elapsed > 5 * 60 * 1e3;
|
|
759
|
+
const alive = isProcessAlive(s.pid);
|
|
760
|
+
const prog = phaseProgress(s.phase, s.toolCalls, stale, alive);
|
|
761
|
+
const bar = progressBar(prog.pct, 10);
|
|
762
|
+
const timeColor = !alive ? c.red : stale ? c.orange : c.gray;
|
|
763
|
+
console.log(
|
|
764
|
+
` ${prog.color}${prog.icon}${c.r} ${c.b}${s.issue}${c.r} ${prog.color}${prog.label}${c.r} ${timeColor}${formatElapsed(elapsed)}${c.r}`
|
|
765
|
+
);
|
|
766
|
+
console.log(
|
|
767
|
+
` ${bar} ${c.d}${prog.pct}%${c.r} ${c.gray}${s.toolCalls} tools ${s.filesModified} files ${fmtTokens(s.tokensUsed)} tok${c.r}`
|
|
768
|
+
);
|
|
523
769
|
}
|
|
524
770
|
}
|
|
525
771
|
function getWorktreeFiles(workspacePath) {
|
|
@@ -543,23 +789,25 @@ function createConductorCommands() {
|
|
|
543
789
|
}
|
|
544
790
|
for (const s of statuses) {
|
|
545
791
|
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
|
|
546
|
-
const
|
|
792
|
+
const stale = elapsed > 5 * 60 * 1e3;
|
|
793
|
+
const alive = isProcessAlive(s.pid);
|
|
794
|
+
const prog = phaseProgress(s.phase, s.toolCalls, stale, alive);
|
|
547
795
|
console.log(
|
|
548
|
-
|
|
796
|
+
` ${prog.color}${prog.icon}${c.r} ${c.b}${s.issue}${c.r} ${prog.color}${prog.label}${c.r} ${c.gray}${formatElapsed(elapsed)}${c.r}`
|
|
549
797
|
);
|
|
550
798
|
const files = getWorktreeFiles(s.workspacePath || "");
|
|
551
799
|
if (files.length === 0) {
|
|
552
|
-
console.log(`
|
|
800
|
+
console.log(` ${c.d}(no file changes)${c.r}`);
|
|
553
801
|
} else {
|
|
554
802
|
for (const f of files) {
|
|
555
803
|
const status = f.substring(0, 2);
|
|
556
804
|
const path = f.substring(3);
|
|
557
|
-
let
|
|
558
|
-
if (status.includes("M"))
|
|
805
|
+
let col = "";
|
|
806
|
+
if (status.includes("M")) col = c.yellow;
|
|
559
807
|
else if (status.includes("A") || status.includes("?"))
|
|
560
|
-
|
|
561
|
-
else if (status.includes("D"))
|
|
562
|
-
console.log(`
|
|
808
|
+
col = c.green;
|
|
809
|
+
else if (status.includes("D")) col = c.red;
|
|
810
|
+
console.log(` ${col}${status}${c.r} ${path}`);
|
|
563
811
|
}
|
|
564
812
|
}
|
|
565
813
|
console.log("");
|
|
@@ -575,23 +823,20 @@ function createConductorCommands() {
|
|
|
575
823
|
const pauseTag = paused ? " [PAUSED]" : "";
|
|
576
824
|
const intervalSec = Math.round(refreshInterval / 1e3);
|
|
577
825
|
console.log(
|
|
578
|
-
`${b}\
|
|
579
|
-
);
|
|
580
|
-
console.log(
|
|
581
|
-
`${b} Conductor Monitor${r} ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}${pauseTag}`
|
|
826
|
+
`${c.purple}${c.b} \u2501\u2501\u2501 Conductor Monitor \u2501\u2501\u2501${c.r} ${c.gray}${(/* @__PURE__ */ new Date()).toLocaleTimeString()}${pauseTag}${c.r}`
|
|
582
827
|
);
|
|
583
828
|
console.log(
|
|
584
|
-
` Mode
|
|
829
|
+
` ${c.gray}Mode:${c.r} ${c.cyan}${currentMode}${c.r} ${c.gray}\u2502${c.r} ${c.gray}Refresh:${c.r} ${intervalSec}s`
|
|
585
830
|
);
|
|
586
|
-
const filterNote = phaseFilter ? ` Filter
|
|
831
|
+
const filterNote = phaseFilter ? ` ${c.gray}Filter:${c.r} ${c.cyan}${phaseFilter}${c.r}` : "";
|
|
587
832
|
if (interactive) {
|
|
588
833
|
console.log(
|
|
589
|
-
` ${d}[s]tatus [u]sage [f]iles [d]ashboard [j]son [l]ogs [r]efresh [p]ause [1-5]phase [0]clear [+/-] [q]uit${r}`
|
|
834
|
+
` ${c.d}[s]tatus [u]sage [f]iles [d]ashboard [j]son [l]ogs [r]efresh [p]ause [1-5]phase [0]clear [+/-] [q]uit${c.r}`
|
|
590
835
|
);
|
|
591
836
|
}
|
|
592
837
|
if (filterNote) console.log(filterNote);
|
|
593
838
|
console.log(
|
|
594
|
-
`${
|
|
839
|
+
`${c.gray} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${c.r}`
|
|
595
840
|
);
|
|
596
841
|
console.log("");
|
|
597
842
|
const statuses = readStatuses();
|
|
@@ -6,6 +6,7 @@ import { spawn, execSync } from "child_process";
|
|
|
6
6
|
import {
|
|
7
7
|
existsSync,
|
|
8
8
|
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
9
10
|
rmSync,
|
|
10
11
|
writeFileSync,
|
|
11
12
|
readdirSync,
|
|
@@ -452,6 +453,7 @@ class Conductor {
|
|
|
452
453
|
logger.info("Rate limit backoff expired, resuming dispatch");
|
|
453
454
|
console.log("[rate-limit] Backoff expired, resuming dispatch");
|
|
454
455
|
}
|
|
456
|
+
await this.checkStaleAgents();
|
|
455
457
|
await this.reconcile();
|
|
456
458
|
const available = this.config.maxConcurrent - this.running.size;
|
|
457
459
|
if (available <= 0) {
|
|
@@ -949,6 +951,26 @@ class Conductor {
|
|
|
949
951
|
return "claude";
|
|
950
952
|
}
|
|
951
953
|
// ── Agent Execution ──
|
|
954
|
+
/**
|
|
955
|
+
* Build environment variables for an agent process.
|
|
956
|
+
* Injects PORTLESS_URL if portless is available, giving each worktree
|
|
957
|
+
* a stable named localhost URL for service discovery.
|
|
958
|
+
*/
|
|
959
|
+
buildAgentEnv(issue, run) {
|
|
960
|
+
const env = { ...process.env };
|
|
961
|
+
delete env["CLAUDECODE"];
|
|
962
|
+
delete env["ANTHROPIC_API_KEY"];
|
|
963
|
+
const wsKey = this.sanitizeIdentifier(issue.identifier);
|
|
964
|
+
return {
|
|
965
|
+
...env,
|
|
966
|
+
SYMPHONY_WORKSPACE_DIR: run.workspacePath,
|
|
967
|
+
SYMPHONY_ISSUE_ID: issue.id,
|
|
968
|
+
SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
969
|
+
SYMPHONY_ATTEMPT: String(run.attempt),
|
|
970
|
+
PORTLESS_URL: `http://${wsKey}.localhost:1355`,
|
|
971
|
+
PORTLESS_NAME: wsKey
|
|
972
|
+
};
|
|
973
|
+
}
|
|
952
974
|
async runAgent(issue, run) {
|
|
953
975
|
if (this.config.agentMode === "cli") {
|
|
954
976
|
try {
|
|
@@ -995,18 +1017,7 @@ class Conductor {
|
|
|
995
1017
|
],
|
|
996
1018
|
{
|
|
997
1019
|
cwd: run.workspacePath,
|
|
998
|
-
env: (
|
|
999
|
-
const env = { ...process.env };
|
|
1000
|
-
delete env.CLAUDECODE;
|
|
1001
|
-
delete env.ANTHROPIC_API_KEY;
|
|
1002
|
-
return {
|
|
1003
|
-
...env,
|
|
1004
|
-
SYMPHONY_WORKSPACE_DIR: run.workspacePath,
|
|
1005
|
-
SYMPHONY_ISSUE_ID: issue.id,
|
|
1006
|
-
SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
1007
|
-
SYMPHONY_ATTEMPT: String(run.attempt)
|
|
1008
|
-
};
|
|
1009
|
-
})(),
|
|
1020
|
+
env: this.buildAgentEnv(issue, run),
|
|
1010
1021
|
stdio: ["pipe", "pipe", "pipe"]
|
|
1011
1022
|
}
|
|
1012
1023
|
);
|
|
@@ -1114,18 +1125,9 @@ class Conductor {
|
|
|
1114
1125
|
runAgentAdapter(issue, run) {
|
|
1115
1126
|
return new Promise((resolve, reject) => {
|
|
1116
1127
|
const prompt = this.buildPrompt(issue, run.attempt);
|
|
1117
|
-
const env = { ...process.env };
|
|
1118
|
-
delete env.CLAUDECODE;
|
|
1119
|
-
delete env.ANTHROPIC_API_KEY;
|
|
1120
1128
|
const proc = spawn("node", [this.config.appServerPath], {
|
|
1121
1129
|
cwd: run.workspacePath,
|
|
1122
|
-
env:
|
|
1123
|
-
...env,
|
|
1124
|
-
SYMPHONY_WORKSPACE_DIR: run.workspacePath,
|
|
1125
|
-
SYMPHONY_ISSUE_ID: issue.id,
|
|
1126
|
-
SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
1127
|
-
SYMPHONY_ATTEMPT: String(run.attempt)
|
|
1128
|
-
},
|
|
1130
|
+
env: this.buildAgentEnv(issue, run),
|
|
1129
1131
|
stdio: ["pipe", "pipe", "pipe"]
|
|
1130
1132
|
});
|
|
1131
1133
|
run.process = proc;
|
|
@@ -1405,6 +1407,117 @@ class Conductor {
|
|
|
1405
1407
|
}
|
|
1406
1408
|
}
|
|
1407
1409
|
// ── Reconciliation ──
|
|
1410
|
+
/**
|
|
1411
|
+
* Check for stale agents — no status update in 1 hour.
|
|
1412
|
+
* If the process is dead, clean up. If alive but stuck, kill and free the slot.
|
|
1413
|
+
*/
|
|
1414
|
+
async checkStaleAgents() {
|
|
1415
|
+
if (this.running.size === 0) return;
|
|
1416
|
+
const staleThresholdMs = 60 * 60 * 1e3;
|
|
1417
|
+
const now = Date.now();
|
|
1418
|
+
for (const [issueId, run] of this.running) {
|
|
1419
|
+
const agentDir = getAgentStatusDir(run.issue.identifier);
|
|
1420
|
+
const statusPath = join(agentDir, "status.json");
|
|
1421
|
+
let lastUpdate = run.startedAt;
|
|
1422
|
+
try {
|
|
1423
|
+
const data = JSON.parse(readFileSync(statusPath, "utf-8"));
|
|
1424
|
+
lastUpdate = new Date(data.lastUpdate).getTime();
|
|
1425
|
+
} catch {
|
|
1426
|
+
}
|
|
1427
|
+
const elapsed = now - lastUpdate;
|
|
1428
|
+
if (elapsed < staleThresholdMs) continue;
|
|
1429
|
+
const elapsedMin = Math.round(elapsed / 6e4);
|
|
1430
|
+
const pid = run.process?.pid;
|
|
1431
|
+
let alive = false;
|
|
1432
|
+
if (pid) {
|
|
1433
|
+
try {
|
|
1434
|
+
process.kill(pid, 0);
|
|
1435
|
+
alive = true;
|
|
1436
|
+
} catch {
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (!alive) {
|
|
1440
|
+
logger.warn("Stale agent process is dead, cleaning up", {
|
|
1441
|
+
identifier: run.issue.identifier,
|
|
1442
|
+
pid,
|
|
1443
|
+
staleMins: elapsedMin
|
|
1444
|
+
});
|
|
1445
|
+
console.log(
|
|
1446
|
+
`[${run.issue.identifier}] Agent dead after ${elapsedMin}m \u2014 finalizing`
|
|
1447
|
+
);
|
|
1448
|
+
await this.finalizeStaleAgent(run);
|
|
1449
|
+
this.running.delete(issueId);
|
|
1450
|
+
} else {
|
|
1451
|
+
logger.warn("Stale agent still alive but stuck, killing", {
|
|
1452
|
+
identifier: run.issue.identifier,
|
|
1453
|
+
pid,
|
|
1454
|
+
staleMins: elapsedMin
|
|
1455
|
+
});
|
|
1456
|
+
console.log(
|
|
1457
|
+
`[${run.issue.identifier}] Agent stuck for ${elapsedMin}m \u2014 killing pid ${pid}`
|
|
1458
|
+
);
|
|
1459
|
+
run.process?.kill("SIGTERM");
|
|
1460
|
+
await new Promise((resolve) => setTimeout(resolve, 1e4));
|
|
1461
|
+
if (run.process && !run.process.killed) {
|
|
1462
|
+
run.process.kill("SIGKILL");
|
|
1463
|
+
}
|
|
1464
|
+
await this.finalizeStaleAgent(run);
|
|
1465
|
+
this.running.delete(issueId);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Finalize an agent that completed or died without proper cleanup.
|
|
1471
|
+
* Checks for commits, transitions Linear issue, takes snapshot.
|
|
1472
|
+
*/
|
|
1473
|
+
async finalizeStaleAgent(run) {
|
|
1474
|
+
const wsPath = run.workspacePath;
|
|
1475
|
+
if (!wsPath || !existsSync(wsPath)) {
|
|
1476
|
+
this.completed.add(run.issue.id);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
let hasCommits = false;
|
|
1480
|
+
try {
|
|
1481
|
+
const log = execSync(
|
|
1482
|
+
`git log origin/${this.config.baseBranch}..HEAD --oneline`,
|
|
1483
|
+
{ cwd: wsPath, encoding: "utf-8", timeout: 1e4 }
|
|
1484
|
+
);
|
|
1485
|
+
hasCommits = log.trim().length > 0;
|
|
1486
|
+
} catch {
|
|
1487
|
+
}
|
|
1488
|
+
if (hasCommits) {
|
|
1489
|
+
logger.info("Stale agent had commits, transitioning to In Review", {
|
|
1490
|
+
identifier: run.issue.identifier
|
|
1491
|
+
});
|
|
1492
|
+
console.log(
|
|
1493
|
+
`[${run.issue.identifier}] Found commits \u2014 moving to In Review`
|
|
1494
|
+
);
|
|
1495
|
+
this.takeSnapshot(wsPath, run.issue);
|
|
1496
|
+
await this.transitionIssue(run.issue, this.config.inReviewState).catch(
|
|
1497
|
+
(err) => {
|
|
1498
|
+
logger.error("Failed to transition stale agent issue", {
|
|
1499
|
+
identifier: run.issue.identifier,
|
|
1500
|
+
error: err.message
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
);
|
|
1504
|
+
this.completeCount++;
|
|
1505
|
+
} else {
|
|
1506
|
+
logger.info("Stale agent had no commits, marking as failed", {
|
|
1507
|
+
identifier: run.issue.identifier
|
|
1508
|
+
});
|
|
1509
|
+
console.log(
|
|
1510
|
+
`[${run.issue.identifier}] No commits found \u2014 marking failed`
|
|
1511
|
+
);
|
|
1512
|
+
this.failCount++;
|
|
1513
|
+
}
|
|
1514
|
+
this.writeAgentStatus(run.issue.identifier, {
|
|
1515
|
+
...run,
|
|
1516
|
+
status: hasCommits ? "completed" : "failed",
|
|
1517
|
+
phase: hasCommits ? "committing" : run.phase
|
|
1518
|
+
});
|
|
1519
|
+
this.completed.add(run.issue.id);
|
|
1520
|
+
}
|
|
1408
1521
|
async reconcile() {
|
|
1409
1522
|
if (!this.client || this.running.size === 0) return;
|
|
1410
1523
|
for (const [issueId, run] of this.running) {
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
3
|
+
import { dirname as __pathDirname } from 'path';
|
|
4
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = __pathDirname(__filename);
|
|
6
|
+
import { config as loadDotenv } from "dotenv";
|
|
7
|
+
loadDotenv({ override: true, debug: false });
|
|
8
|
+
import { spawn, execSync, execFileSync } from "child_process";
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
import * as os from "os";
|
|
12
|
+
import { program } from "commander";
|
|
13
|
+
import { v4 as uuidv4 } from "uuid";
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
import { initializeTracing, trace } from "../core/trace/index.js";
|
|
16
|
+
const DEFAULT_SM_CONFIG = {
|
|
17
|
+
defaultWorktree: false,
|
|
18
|
+
defaultTracing: true
|
|
19
|
+
};
|
|
20
|
+
function getConfigPath() {
|
|
21
|
+
return path.join(os.homedir(), ".stackmemory", "gemini-sm.json");
|
|
22
|
+
}
|
|
23
|
+
function loadSMConfig() {
|
|
24
|
+
try {
|
|
25
|
+
const configPath = getConfigPath();
|
|
26
|
+
if (fs.existsSync(configPath)) {
|
|
27
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
28
|
+
return { ...DEFAULT_SM_CONFIG, ...JSON.parse(content) };
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
return { ...DEFAULT_SM_CONFIG };
|
|
33
|
+
}
|
|
34
|
+
function saveSMConfig(config) {
|
|
35
|
+
const configPath = getConfigPath();
|
|
36
|
+
const dir = path.dirname(configPath);
|
|
37
|
+
if (!fs.existsSync(dir)) {
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
41
|
+
}
|
|
42
|
+
class GeminiSM {
|
|
43
|
+
config;
|
|
44
|
+
stackmemoryPath;
|
|
45
|
+
smConfig;
|
|
46
|
+
constructor() {
|
|
47
|
+
this.smConfig = loadSMConfig();
|
|
48
|
+
this.config = {
|
|
49
|
+
instanceId: this.generateInstanceId(),
|
|
50
|
+
useWorktree: this.smConfig.defaultWorktree,
|
|
51
|
+
contextEnabled: true,
|
|
52
|
+
tracingEnabled: this.smConfig.defaultTracing,
|
|
53
|
+
verboseTracing: false,
|
|
54
|
+
sessionStartTime: Date.now()
|
|
55
|
+
};
|
|
56
|
+
this.stackmemoryPath = this.findStackMemory();
|
|
57
|
+
}
|
|
58
|
+
getRepoRoot() {
|
|
59
|
+
try {
|
|
60
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
61
|
+
encoding: "utf8"
|
|
62
|
+
}).trim();
|
|
63
|
+
return root || null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
ensureInitialized() {
|
|
69
|
+
try {
|
|
70
|
+
const root = this.getRepoRoot();
|
|
71
|
+
const dir = root || process.cwd();
|
|
72
|
+
const dbPath = path.join(dir, ".stackmemory", "context.db");
|
|
73
|
+
if (!fs.existsSync(dbPath)) {
|
|
74
|
+
console.log(chalk.blue("Initializing StackMemory for this project..."));
|
|
75
|
+
execSync(`${this.stackmemoryPath} init`, {
|
|
76
|
+
cwd: dir,
|
|
77
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
generateInstanceId() {
|
|
84
|
+
return uuidv4().substring(0, 8);
|
|
85
|
+
}
|
|
86
|
+
findStackMemory() {
|
|
87
|
+
const possiblePaths = [
|
|
88
|
+
path.join(os.homedir(), ".stackmemory", "bin", "stackmemory"),
|
|
89
|
+
"/usr/local/bin/stackmemory",
|
|
90
|
+
"/opt/homebrew/bin/stackmemory",
|
|
91
|
+
"stackmemory"
|
|
92
|
+
];
|
|
93
|
+
for (const smPath of possiblePaths) {
|
|
94
|
+
try {
|
|
95
|
+
execFileSync("which", [smPath], { stdio: "ignore" });
|
|
96
|
+
return smPath;
|
|
97
|
+
} catch {
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return "stackmemory";
|
|
101
|
+
}
|
|
102
|
+
isGitRepo() {
|
|
103
|
+
try {
|
|
104
|
+
execSync("git rev-parse --git-dir", { stdio: "ignore" });
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
getCurrentBranch() {
|
|
111
|
+
try {
|
|
112
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
113
|
+
encoding: "utf8"
|
|
114
|
+
}).trim();
|
|
115
|
+
} catch {
|
|
116
|
+
return "main";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
hasUncommittedChanges() {
|
|
120
|
+
try {
|
|
121
|
+
const status = execSync("git status --porcelain", { encoding: "utf8" });
|
|
122
|
+
return status.length > 0;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
resolveGeminiBin() {
|
|
128
|
+
if (this.config.geminiBin && this.config.geminiBin.trim()) {
|
|
129
|
+
return this.config.geminiBin.trim();
|
|
130
|
+
}
|
|
131
|
+
const envBin = process.env["GEMINI_BIN"];
|
|
132
|
+
if (envBin && envBin.trim()) return envBin.trim();
|
|
133
|
+
const possiblePaths = [
|
|
134
|
+
path.join(
|
|
135
|
+
os.homedir(),
|
|
136
|
+
".nvm",
|
|
137
|
+
"versions",
|
|
138
|
+
"node",
|
|
139
|
+
"v22.22.0",
|
|
140
|
+
"bin",
|
|
141
|
+
"gemini"
|
|
142
|
+
),
|
|
143
|
+
"/usr/local/bin/gemini",
|
|
144
|
+
"/opt/homebrew/bin/gemini"
|
|
145
|
+
];
|
|
146
|
+
for (const binPath of possiblePaths) {
|
|
147
|
+
if (fs.existsSync(binPath)) {
|
|
148
|
+
return binPath;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
execSync("which gemini", { stdio: "ignore" });
|
|
153
|
+
return "gemini";
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
setupWorktree() {
|
|
159
|
+
if (!this.config.useWorktree || !this.isGitRepo()) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
console.log(chalk.blue("Setting up isolated worktree..."));
|
|
163
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").substring(0, 19);
|
|
164
|
+
const branch = this.config.branch || `gemini-${this.config.task || "work"}-${timestamp}-${this.config.instanceId}`;
|
|
165
|
+
const repoName = path.basename(process.cwd());
|
|
166
|
+
const worktreePath = path.join(
|
|
167
|
+
path.dirname(process.cwd()),
|
|
168
|
+
`${repoName}--${branch}`
|
|
169
|
+
);
|
|
170
|
+
try {
|
|
171
|
+
const cmd = `git worktree add -b "${branch}" "${worktreePath}"`;
|
|
172
|
+
execSync(cmd, { stdio: "inherit" });
|
|
173
|
+
console.log(chalk.green(`Worktree created: ${worktreePath}`));
|
|
174
|
+
console.log(chalk.gray(`Branch: ${branch}`));
|
|
175
|
+
const configPath = path.join(worktreePath, ".gemini-instance.json");
|
|
176
|
+
const configData = {
|
|
177
|
+
instanceId: this.config.instanceId,
|
|
178
|
+
worktreePath,
|
|
179
|
+
branch,
|
|
180
|
+
task: this.config.task,
|
|
181
|
+
created: (/* @__PURE__ */ new Date()).toISOString(),
|
|
182
|
+
parentRepo: process.cwd()
|
|
183
|
+
};
|
|
184
|
+
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2));
|
|
185
|
+
const envFiles = [".env", ".env.local", ".mise.toml", ".tool-versions"];
|
|
186
|
+
for (const file of envFiles) {
|
|
187
|
+
const srcPath = path.join(process.cwd(), file);
|
|
188
|
+
if (fs.existsSync(srcPath)) {
|
|
189
|
+
fs.copyFileSync(srcPath, path.join(worktreePath, file));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return worktreePath;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error(chalk.red("Failed to create worktree:"), err);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
saveContext(message, metadata = {}) {
|
|
199
|
+
if (!this.config.contextEnabled) return;
|
|
200
|
+
try {
|
|
201
|
+
const contextData = {
|
|
202
|
+
message,
|
|
203
|
+
metadata: {
|
|
204
|
+
...metadata,
|
|
205
|
+
instanceId: this.config.instanceId,
|
|
206
|
+
worktree: this.config.worktreePath,
|
|
207
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
208
|
+
tool: "gemini"
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const cmd = `${this.stackmemoryPath} context save --json '${JSON.stringify(contextData)}'`;
|
|
212
|
+
execSync(cmd, { stdio: "ignore" });
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
loadContext() {
|
|
217
|
+
if (!this.config.contextEnabled) return;
|
|
218
|
+
try {
|
|
219
|
+
console.log(chalk.blue("Loading previous context..."));
|
|
220
|
+
const cmd = `${this.stackmemoryPath} context show`;
|
|
221
|
+
const output = execSync(cmd, {
|
|
222
|
+
encoding: "utf8",
|
|
223
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
224
|
+
});
|
|
225
|
+
const lines = output.trim().split("\n").filter((l) => l.trim());
|
|
226
|
+
if (lines.length > 3) {
|
|
227
|
+
console.log(chalk.gray("Context stack loaded"));
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
detectMultipleInstances() {
|
|
233
|
+
try {
|
|
234
|
+
const lockDir = path.join(process.cwd(), ".gemini-worktree-locks");
|
|
235
|
+
if (!fs.existsSync(lockDir)) return false;
|
|
236
|
+
const locks = fs.readdirSync(lockDir).filter((f) => f.endsWith(".lock"));
|
|
237
|
+
const activeLocks = locks.filter((lockFile) => {
|
|
238
|
+
const lockPath = path.join(lockDir, lockFile);
|
|
239
|
+
const lockData = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
240
|
+
const lockAge = Date.now() - new Date(lockData.created).getTime();
|
|
241
|
+
return lockAge < 24 * 60 * 60 * 1e3;
|
|
242
|
+
});
|
|
243
|
+
return activeLocks.length > 0;
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
suggestWorktreeMode() {
|
|
249
|
+
if (this.hasUncommittedChanges()) {
|
|
250
|
+
console.log(chalk.yellow("Uncommitted changes detected"));
|
|
251
|
+
console.log(chalk.gray("Consider using --worktree to work in isolation"));
|
|
252
|
+
}
|
|
253
|
+
if (this.detectMultipleInstances()) {
|
|
254
|
+
console.log(chalk.yellow("Other Gemini instances detected"));
|
|
255
|
+
console.log(
|
|
256
|
+
chalk.gray("Using --worktree is recommended to avoid conflicts")
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async run(args) {
|
|
261
|
+
const geminiArgs = [];
|
|
262
|
+
let i = 0;
|
|
263
|
+
while (i < args.length) {
|
|
264
|
+
const arg = args[i];
|
|
265
|
+
switch (arg) {
|
|
266
|
+
case "--worktree":
|
|
267
|
+
case "-w":
|
|
268
|
+
this.config.useWorktree = true;
|
|
269
|
+
break;
|
|
270
|
+
case "--no-worktree":
|
|
271
|
+
case "-W":
|
|
272
|
+
this.config.useWorktree = false;
|
|
273
|
+
break;
|
|
274
|
+
case "--no-context":
|
|
275
|
+
this.config.contextEnabled = false;
|
|
276
|
+
break;
|
|
277
|
+
case "--no-trace":
|
|
278
|
+
this.config.tracingEnabled = false;
|
|
279
|
+
break;
|
|
280
|
+
case "--verbose-trace":
|
|
281
|
+
this.config.verboseTracing = true;
|
|
282
|
+
break;
|
|
283
|
+
case "--branch":
|
|
284
|
+
case "-b":
|
|
285
|
+
i++;
|
|
286
|
+
this.config.branch = args[i];
|
|
287
|
+
break;
|
|
288
|
+
case "--task":
|
|
289
|
+
case "-t":
|
|
290
|
+
i++;
|
|
291
|
+
this.config.task = args[i];
|
|
292
|
+
break;
|
|
293
|
+
case "--gemini-bin":
|
|
294
|
+
i++;
|
|
295
|
+
this.config.geminiBin = args[i];
|
|
296
|
+
process.env["GEMINI_BIN"] = this.config.geminiBin;
|
|
297
|
+
break;
|
|
298
|
+
case "--auto":
|
|
299
|
+
case "-a":
|
|
300
|
+
if (this.isGitRepo()) {
|
|
301
|
+
this.config.useWorktree = this.hasUncommittedChanges() || this.detectMultipleInstances();
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
default:
|
|
305
|
+
geminiArgs.push(arg);
|
|
306
|
+
}
|
|
307
|
+
i++;
|
|
308
|
+
}
|
|
309
|
+
if (this.config.tracingEnabled) {
|
|
310
|
+
process.env["DEBUG_TRACE"] = "true";
|
|
311
|
+
process.env["STACKMEMORY_DEBUG"] = "true";
|
|
312
|
+
process.env["TRACE_OUTPUT"] = "file";
|
|
313
|
+
process.env["TRACE_MASK_SENSITIVE"] = "true";
|
|
314
|
+
if (this.config.verboseTracing) {
|
|
315
|
+
process.env["TRACE_VERBOSITY"] = "full";
|
|
316
|
+
process.env["TRACE_PARAMS"] = "true";
|
|
317
|
+
process.env["TRACE_RESULTS"] = "true";
|
|
318
|
+
process.env["TRACE_MEMORY"] = "true";
|
|
319
|
+
} else {
|
|
320
|
+
process.env["TRACE_VERBOSITY"] = "summary";
|
|
321
|
+
process.env["TRACE_PARAMS"] = "true";
|
|
322
|
+
process.env["TRACE_RESULTS"] = "false";
|
|
323
|
+
}
|
|
324
|
+
initializeTracing();
|
|
325
|
+
trace.command(
|
|
326
|
+
"gemini-sm",
|
|
327
|
+
{
|
|
328
|
+
instanceId: this.config.instanceId,
|
|
329
|
+
worktree: this.config.useWorktree,
|
|
330
|
+
task: this.config.task
|
|
331
|
+
},
|
|
332
|
+
async () => {
|
|
333
|
+
}
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
console.log(chalk.cyan("Gemini + StackMemory"));
|
|
337
|
+
console.log();
|
|
338
|
+
this.ensureInitialized();
|
|
339
|
+
if (this.isGitRepo()) {
|
|
340
|
+
const branch = this.getCurrentBranch();
|
|
341
|
+
console.log(chalk.gray(`Branch: ${branch}`));
|
|
342
|
+
if (!this.config.useWorktree) {
|
|
343
|
+
this.suggestWorktreeMode();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (this.config.useWorktree) {
|
|
347
|
+
const worktreePath = this.setupWorktree();
|
|
348
|
+
if (worktreePath) {
|
|
349
|
+
this.config.worktreePath = worktreePath;
|
|
350
|
+
process.chdir(worktreePath);
|
|
351
|
+
this.saveContext("Created worktree for Gemini instance", {
|
|
352
|
+
action: "worktree_created",
|
|
353
|
+
path: worktreePath,
|
|
354
|
+
branch: this.config.branch
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
this.loadContext();
|
|
359
|
+
process.env["GEMINI_INSTANCE_ID"] = this.config.instanceId;
|
|
360
|
+
if (this.config.worktreePath) {
|
|
361
|
+
process.env["GEMINI_WORKTREE_PATH"] = this.config.worktreePath;
|
|
362
|
+
}
|
|
363
|
+
console.log(chalk.gray(`Instance: ${this.config.instanceId}`));
|
|
364
|
+
console.log(chalk.gray(`Working in: ${process.cwd()}`));
|
|
365
|
+
if (this.config.tracingEnabled) {
|
|
366
|
+
console.log(
|
|
367
|
+
chalk.gray(`Tracing enabled (logs to ~/.stackmemory/traces/)`)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
console.log();
|
|
371
|
+
console.log(chalk.gray("Starting Gemini CLI..."));
|
|
372
|
+
console.log(chalk.gray("-".repeat(40)));
|
|
373
|
+
const geminiBin = this.resolveGeminiBin();
|
|
374
|
+
if (!geminiBin) {
|
|
375
|
+
console.error(chalk.red("Gemini CLI not found."));
|
|
376
|
+
console.log(
|
|
377
|
+
chalk.gray(
|
|
378
|
+
"Install Gemini CLI or set an override:\n npm install -g @anthropic-ai/gemini-cli\n export GEMINI_BIN=/path/to/gemini\n gemini-sm --help"
|
|
379
|
+
)
|
|
380
|
+
);
|
|
381
|
+
process.exit(1);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const gemini = spawn(geminiBin, geminiArgs, {
|
|
385
|
+
stdio: "inherit",
|
|
386
|
+
env: process.env
|
|
387
|
+
});
|
|
388
|
+
gemini.on("error", (err) => {
|
|
389
|
+
console.error(chalk.red("Failed to launch Gemini CLI."));
|
|
390
|
+
if (err.code === "ENOENT") {
|
|
391
|
+
console.error(
|
|
392
|
+
chalk.gray("Not found. Set GEMINI_BIN or install gemini.")
|
|
393
|
+
);
|
|
394
|
+
} else {
|
|
395
|
+
console.error(chalk.gray(`${err.message}`));
|
|
396
|
+
}
|
|
397
|
+
process.exit(1);
|
|
398
|
+
});
|
|
399
|
+
gemini.on("exit", async (code) => {
|
|
400
|
+
this.saveContext("Gemini session ended", {
|
|
401
|
+
action: "session_end",
|
|
402
|
+
exitCode: code
|
|
403
|
+
});
|
|
404
|
+
if (this.config.tracingEnabled) {
|
|
405
|
+
const summary = trace.getExecutionSummary();
|
|
406
|
+
console.log();
|
|
407
|
+
console.log(chalk.gray("-".repeat(40)));
|
|
408
|
+
console.log(chalk.cyan("Trace Summary:"));
|
|
409
|
+
console.log(chalk.gray(summary));
|
|
410
|
+
}
|
|
411
|
+
if (this.config.worktreePath) {
|
|
412
|
+
console.log();
|
|
413
|
+
console.log(chalk.gray("-".repeat(40)));
|
|
414
|
+
console.log(chalk.cyan("Session ended in worktree:"));
|
|
415
|
+
console.log(chalk.gray(` ${this.config.worktreePath}`));
|
|
416
|
+
}
|
|
417
|
+
process.exit(code || 0);
|
|
418
|
+
});
|
|
419
|
+
process.on("SIGINT", () => {
|
|
420
|
+
this.saveContext("Gemini session interrupted", {
|
|
421
|
+
action: "session_interrupt"
|
|
422
|
+
});
|
|
423
|
+
gemini.kill("SIGINT");
|
|
424
|
+
});
|
|
425
|
+
process.on("SIGTERM", () => {
|
|
426
|
+
this.saveContext("Gemini session terminated", {
|
|
427
|
+
action: "session_terminate"
|
|
428
|
+
});
|
|
429
|
+
gemini.kill("SIGTERM");
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
program.name("gemini-sm").description("Gemini CLI with StackMemory context and worktree isolation").version("1.0.0");
|
|
434
|
+
const configCmd = program.command("config").description("Manage gemini-sm defaults");
|
|
435
|
+
configCmd.command("show").description("Show current default settings").action(() => {
|
|
436
|
+
const config = loadSMConfig();
|
|
437
|
+
console.log(chalk.cyan("gemini-sm defaults:"));
|
|
438
|
+
console.log(
|
|
439
|
+
` defaultWorktree: ${config.defaultWorktree ? chalk.green("true") : chalk.gray("false")}`
|
|
440
|
+
);
|
|
441
|
+
console.log(
|
|
442
|
+
` defaultTracing: ${config.defaultTracing ? chalk.green("true") : chalk.gray("false")}`
|
|
443
|
+
);
|
|
444
|
+
console.log(chalk.gray(`
|
|
445
|
+
Config: ${getConfigPath()}`));
|
|
446
|
+
});
|
|
447
|
+
configCmd.command("set <key> <value>").description("Set a default (e.g., set worktree true)").action((key, value) => {
|
|
448
|
+
const config = loadSMConfig();
|
|
449
|
+
const boolValue = value === "true" || value === "1" || value === "on";
|
|
450
|
+
const keyMap = {
|
|
451
|
+
worktree: "defaultWorktree",
|
|
452
|
+
tracing: "defaultTracing"
|
|
453
|
+
};
|
|
454
|
+
const configKey = keyMap[key];
|
|
455
|
+
if (!configKey) {
|
|
456
|
+
console.log(chalk.red(`Unknown key: ${key}`));
|
|
457
|
+
console.log(chalk.gray("Valid keys: worktree, tracing"));
|
|
458
|
+
process.exit(1);
|
|
459
|
+
}
|
|
460
|
+
config[configKey] = boolValue;
|
|
461
|
+
saveSMConfig(config);
|
|
462
|
+
console.log(chalk.green(`Set ${key} = ${boolValue}`));
|
|
463
|
+
});
|
|
464
|
+
configCmd.command("worktree-on").description("Enable worktree mode by default").action(() => {
|
|
465
|
+
const config = loadSMConfig();
|
|
466
|
+
config.defaultWorktree = true;
|
|
467
|
+
saveSMConfig(config);
|
|
468
|
+
console.log(chalk.green("Worktree mode enabled by default"));
|
|
469
|
+
});
|
|
470
|
+
configCmd.command("worktree-off").description("Disable worktree mode by default").action(() => {
|
|
471
|
+
const config = loadSMConfig();
|
|
472
|
+
config.defaultWorktree = false;
|
|
473
|
+
saveSMConfig(config);
|
|
474
|
+
console.log(chalk.green("Worktree mode disabled by default"));
|
|
475
|
+
});
|
|
476
|
+
program.option("-w, --worktree", "Create isolated worktree for this instance").option("-W, --no-worktree", "Disable worktree (override default)").option("-a, --auto", "Automatically detect and apply best settings").option("-b, --branch <name>", "Specify branch name for worktree").option("-t, --task <desc>", "Task description for context").option("--gemini-bin <path>", "Path to gemini CLI (or use GEMINI_BIN)").option("--no-context", "Disable StackMemory context integration").option("--no-trace", "Disable debug tracing (enabled by default)").option("--verbose-trace", "Enable verbose debug tracing with full details").helpOption("-h, --help", "Display help").allowUnknownOption(true).action(async (_options) => {
|
|
477
|
+
const geminiSM = new GeminiSM();
|
|
478
|
+
const args = process.argv.slice(2);
|
|
479
|
+
await geminiSM.run(args);
|
|
480
|
+
});
|
|
481
|
+
program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackmemoryai/stackmemory",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.8",
|
|
4
4
|
"description": "Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, and automatic hooks.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"codex-smd": "bin/codex-smd",
|
|
15
15
|
"claude-sm": "bin/claude-sm",
|
|
16
16
|
"claude-smd": "bin/claude-smd",
|
|
17
|
-
"opencode-sm": "bin/opencode-sm"
|
|
17
|
+
"opencode-sm": "bin/opencode-sm",
|
|
18
|
+
"gemini-sm": "bin/gemini-sm"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"bin",
|