@stackmemoryai/stackmemory 1.5.8 → 1.6.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.
@@ -9,15 +9,18 @@ import {
9
9
  mkdirSync,
10
10
  writeFileSync,
11
11
  readFileSync,
12
- readdirSync
12
+ readdirSync,
13
+ copyFileSync
13
14
  } from "fs";
14
15
  import { join } from "path";
15
16
  import { homedir, tmpdir } from "os";
16
17
  import Database from "better-sqlite3";
17
18
  import { logger } from "../../core/monitoring/logger.js";
19
+ import { isProcessAlive } from "../../utils/process-cleanup.js";
18
20
  import { Conductor } from "./orchestrator.js";
19
21
  import {
20
- getAgentStatusDir
22
+ getAgentStatusDir,
23
+ getOutcomesLogPath
21
24
  } from "./orchestrator.js";
22
25
  function getGlobalStorePath() {
23
26
  const dir = join(homedir(), ".stackmemory", "conductor");
@@ -75,6 +78,8 @@ function budgetBar(pct, width = 30) {
75
78
  const rst = "\x1B[0m";
76
79
  return `${color}${"\u2588".repeat(filled)}${dim}${"\u2591".repeat(empty)}${rst} ${String(pct).padStart(3)}%`;
77
80
  }
81
+ const STALE_UI_THRESHOLD_MS = 5 * 60 * 1e3;
82
+ const STALE_FINALIZE_THRESHOLD_MS = 60 * 60 * 1e3;
78
83
  const c = {
79
84
  r: "\x1B[0m",
80
85
  // reset
@@ -120,14 +125,6 @@ const phaseColor = {
120
125
  testing: c.pink,
121
126
  committing: c.green
122
127
  };
123
- function isProcessAlive(pid) {
124
- try {
125
- process.kill(pid, 0);
126
- return true;
127
- } catch {
128
- return false;
129
- }
130
- }
131
128
  function phaseProgress(phase, toolCalls, stale, alive) {
132
129
  const basePct = {
133
130
  reading: 10,
@@ -170,6 +167,32 @@ function progressBar(pct, width) {
170
167
  const col = pct >= 90 ? c.green : pct >= 50 ? c.yellow : c.cyan;
171
168
  return `${col}${"\u2501".repeat(filled)}${c.d}${"\u254C".repeat(empty)}${c.r}`;
172
169
  }
170
+ function scanAgentStatuses() {
171
+ const agentsDir2 = join(homedir(), ".stackmemory", "conductor", "agents");
172
+ if (!existsSync(agentsDir2)) return [];
173
+ const entries = readdirSync(agentsDir2, { withFileTypes: true });
174
+ const statuses = [];
175
+ for (const entry of entries) {
176
+ if (!entry.isDirectory()) continue;
177
+ const statusPath = join(agentsDir2, entry.name, "status.json");
178
+ if (!existsSync(statusPath)) continue;
179
+ try {
180
+ const data = JSON.parse(readFileSync(statusPath, "utf-8"));
181
+ statuses.push({ ...data, dir: entry.name });
182
+ } catch {
183
+ }
184
+ }
185
+ statuses.sort(
186
+ (a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
187
+ );
188
+ return statuses;
189
+ }
190
+ function enrichStatus(s) {
191
+ const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
192
+ const alive = isProcessAlive(s.pid);
193
+ const stale = alive && elapsed > STALE_UI_THRESHOLD_MS;
194
+ return { elapsed, alive, stale };
195
+ }
173
196
  function fmtMinutes(m) {
174
197
  if (m < 0) return "N/A";
175
198
  if (m >= 60) return `${Math.floor(m / 60)}h ${m % 60}m`;
@@ -186,25 +209,220 @@ function printUsageSummary(u) {
186
209
  const mins5x = u.minutesRemaining5x ?? -1;
187
210
  const mins20x = u.minutesRemaining20x ?? -1;
188
211
  const cacheHitRate = u.cacheHitRate || 0;
189
- const b = "\x1B[1m";
190
- const d2 = "\x1B[2m";
191
- const w = "\x1B[37m";
192
- const r2 = "\x1B[0m";
193
- console.log(`${b}Token Usage${r2}`);
212
+ console.log(`${c.b}Token Usage${c.r}`);
194
213
  console.log(
195
- ` Input ${w}${fmtTokens(inputTokens)}${r2} ${d2}|${r2} Output ${w}${fmtTokens(outputTokens)}${r2} ${d2}|${r2} Total ${w}${fmtTokens(totalTokens)}${r2}`
214
+ ` Input ${c.white}${fmtTokens(inputTokens)}${c.r} ${c.d}|${c.r} Output ${c.white}${fmtTokens(outputTokens)}${c.r} ${c.d}|${c.r} Total ${c.white}${fmtTokens(totalTokens)}${c.r}`
196
215
  );
197
216
  console.log(
198
- ` Rate ${w}${fmtTokens(tokensPerMin)}/min${r2} ${d2}|${r2} Messages ${w}${estMessages}${r2} ${d2}|${r2} Cache hit ${w}${cacheHitRate}%${r2}`
217
+ ` Rate ${c.white}${fmtTokens(tokensPerMin)}/min${c.r} ${c.d}|${c.r} Messages ${c.white}${estMessages}${c.r} ${c.d}|${c.r} Cache hit ${c.white}${cacheHitRate}%${c.r}`
199
218
  );
200
219
  console.log("");
201
- console.log(`${b}Budget (Max plan, 5h window)${r2}`);
220
+ console.log(`${c.b}Budget (Max plan, 5h window)${c.r}`);
202
221
  console.log(
203
- ` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${d2}~${fmtMinutes(mins5x)} left${r2}`
222
+ ` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${c.d}~${fmtMinutes(mins5x)} left${c.r}`
204
223
  );
205
224
  console.log(
206
- ` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${d2}~${fmtMinutes(mins20x)} left${r2}`
225
+ ` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${c.d}~${fmtMinutes(mins20x)} left${c.r}`
226
+ );
227
+ }
228
+ const DEFAULT_PROMPT_TEMPLATE = `# Agent Prompt \u2014 {{ISSUE_ID}}
229
+
230
+ You are working on Linear issue **{{ISSUE_ID}}**: {{TITLE}}
231
+
232
+ ## Description
233
+
234
+ {{DESCRIPTION}}
235
+
236
+ ## Context
237
+
238
+ - Priority: {{PRIORITY}}
239
+ - Labels: {{LABELS}}
240
+ {{PRIOR_CONTEXT}}
241
+
242
+ ## Instructions
243
+
244
+ 1. Read the issue description and related code carefully
245
+ 2. Plan your approach before writing code
246
+ 3. Implement the requested changes
247
+ 4. Run \`npm run lint\` and fix any errors
248
+ 5. Run \`npm run test:run\` and fix any failures
249
+ 6. Commit your changes with format: \`type(scope): message\`
250
+
251
+ ## Rules
252
+
253
+ - Follow existing code conventions (ESM imports with .js extension, TypeScript strict)
254
+ - Keep changes focused \u2014 only modify what the issue requires
255
+ - Write or update tests for any new functionality
256
+ - Do not skip pre-commit hooks
257
+ - If stuck, leave a comment in the code explaining the blocker
258
+
259
+ Work in the current directory. All changes will be on a dedicated branch.
260
+ `;
261
+ function ensureDefaultPromptTemplate() {
262
+ const templatePath = join(
263
+ homedir(),
264
+ ".stackmemory",
265
+ "conductor",
266
+ "prompt-template.md"
207
267
  );
268
+ if (!existsSync(templatePath)) {
269
+ const dir = join(homedir(), ".stackmemory", "conductor");
270
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
271
+ writeFileSync(templatePath, DEFAULT_PROMPT_TEMPLATE);
272
+ console.log(
273
+ ` ${c.d}Created default prompt template: ${templatePath}${c.r}`
274
+ );
275
+ }
276
+ return templatePath;
277
+ }
278
+ function spawnClaudePrint(prompt, timeoutMs = 12e4) {
279
+ return new Promise((resolve, reject) => {
280
+ const child = cpSpawn("claude", ["--print"], {
281
+ stdio: ["pipe", "pipe", "pipe"],
282
+ env: { ...process.env }
283
+ });
284
+ let stdout = "";
285
+ let stderr = "";
286
+ let killed = false;
287
+ const timer = setTimeout(() => {
288
+ killed = true;
289
+ child.kill("SIGTERM");
290
+ }, timeoutMs);
291
+ child.stdout.on("data", (d) => stdout += d.toString());
292
+ child.stderr.on("data", (d) => stderr += d.toString());
293
+ child.on("close", (code) => {
294
+ clearTimeout(timer);
295
+ if (killed)
296
+ return reject(new Error(`claude timed out after ${timeoutMs}ms`));
297
+ if (code !== 0 && !stdout)
298
+ return reject(new Error(stderr || `claude exited ${code}`));
299
+ resolve(stdout);
300
+ });
301
+ child.on("error", (err) => {
302
+ clearTimeout(timer);
303
+ reject(err);
304
+ });
305
+ child.stdin.write(prompt);
306
+ child.stdin.end();
307
+ });
308
+ }
309
+ async function evolvePromptTemplate(input) {
310
+ const {
311
+ templatePath,
312
+ successRate,
313
+ failures,
314
+ failPhases,
315
+ errorPatterns,
316
+ recs,
317
+ outcomes
318
+ } = input;
319
+ let currentTemplate;
320
+ if (existsSync(templatePath)) {
321
+ currentTemplate = readFileSync(templatePath, "utf-8");
322
+ } else {
323
+ currentTemplate = DEFAULT_PROMPT_TEMPLATE;
324
+ const dir = join(homedir(), ".stackmemory", "conductor");
325
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
326
+ writeFileSync(templatePath, currentTemplate);
327
+ }
328
+ const failPhaseSummary = Object.entries(failPhases).sort((a, b) => b[1] - a[1]).map(([phase, count]) => ` - ${phase}: ${count} failures`).join("\n");
329
+ const errorSummary = Object.entries(errorPatterns).sort((a, b) => b[1] - a[1]).map(([pattern, count]) => ` - ${pattern}: ${count} occurrences`).join("\n");
330
+ const failedOutcomes = outcomes.filter((o) => o.outcome === "failure" && o.errorTail).slice(-5);
331
+ const errorTails = failedOutcomes.map(
332
+ (o) => ` [${o.issue} attempt ${o.attempt}, phase: ${o.phase}]
333
+ ${o.errorTail}`
334
+ ).join("\n\n");
335
+ const mutationPrompt = `You are optimizing a prompt template for autonomous AI coding agents managed by a conductor system.
336
+
337
+ CURRENT PROMPT TEMPLATE:
338
+ \`\`\`markdown
339
+ ${currentTemplate}
340
+ \`\`\`
341
+
342
+ PERFORMANCE DATA:
343
+ - Success rate: ${successRate}%
344
+ - Total failures: ${failures}
345
+
346
+ FAILURE PHASE BREAKDOWN:
347
+ ${failPhaseSummary || " (none)"}
348
+
349
+ ERROR PATTERNS:
350
+ ${errorSummary || " (none)"}
351
+
352
+ SAMPLE ERROR TAILS FROM RECENT FAILURES:
353
+ ${errorTails || " (none)"}
354
+
355
+ RECOMMENDATIONS FROM ANALYSIS:
356
+ ${recs.map((r) => `- ${r}`).join("\n")}
357
+
358
+ YOUR TASK:
359
+ Improve the prompt template to reduce failures. Focus on:
360
+ 1. Adding specific instructions that address the most common failure modes
361
+ 2. Making implicit requirements explicit (lint rules, test commands, commit format)
362
+ 3. Adding guardrails for the error patterns seen (e.g., if lint failures are common, add lint-specific instructions)
363
+ 4. Keeping the template concise \u2014 agents work better with clear, structured prompts
364
+ 5. Preserving all {{VARIABLE}} placeholders exactly as-is
365
+
366
+ REQUIREMENTS:
367
+ - Output ONLY the improved markdown template
368
+ - Keep all {{VARIABLE}} placeholders: {{ISSUE_ID}}, {{TITLE}}, {{DESCRIPTION}}, {{LABELS}}, {{PRIORITY}}, {{ATTEMPT}}, {{PRIOR_CONTEXT}}
369
+ - Do not add commentary, explanations, or markdown fences around the output
370
+ - Target similar length to the current template (no bloat)
371
+
372
+ OUTPUT THE IMPROVED TEMPLATE:`;
373
+ try {
374
+ console.log(
375
+ ` ${c.d}Calling Claude to generate improved template...${c.r}`
376
+ );
377
+ const evolved = await spawnClaudePrint(mutationPrompt);
378
+ if (!evolved.trim()) {
379
+ console.log(` ${c.red}Empty response from Claude \u2014 skipping.${c.r}`);
380
+ return;
381
+ }
382
+ const requiredVars = ["{{ISSUE_ID}}", "{{TITLE}}", "{{DESCRIPTION}}"];
383
+ const missing = requiredVars.filter((v) => !evolved.includes(v));
384
+ if (missing.length > 0) {
385
+ console.log(
386
+ ` ${c.red}Evolved template missing variables: ${missing.join(", ")} \u2014 skipping.${c.r}`
387
+ );
388
+ return;
389
+ }
390
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
391
+ const backupPath = templatePath.replace(".md", `.backup-${timestamp}.md`);
392
+ copyFileSync(templatePath, backupPath);
393
+ console.log(` ${c.d}Backed up to ${backupPath}${c.r}`);
394
+ writeFileSync(templatePath, evolved.trim() + "\n");
395
+ console.log(
396
+ ` ${c.green}Evolved template written to ${templatePath}${c.r}`
397
+ );
398
+ const evolutionLog = join(
399
+ homedir(),
400
+ ".stackmemory",
401
+ "conductor",
402
+ "evolution-log.jsonl"
403
+ );
404
+ const entry = {
405
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
406
+ successRate,
407
+ failures,
408
+ failPhases,
409
+ errorPatterns,
410
+ backupPath
411
+ };
412
+ const dir = join(homedir(), ".stackmemory", "conductor");
413
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
414
+ writeFileSync(
415
+ evolutionLog,
416
+ (existsSync(evolutionLog) ? readFileSync(evolutionLog, "utf-8") : "") + JSON.stringify(entry) + "\n"
417
+ );
418
+ console.log(` ${c.d}Evolution logged to ${evolutionLog}${c.r}`);
419
+ } catch (err) {
420
+ const msg = err instanceof Error ? err.message : String(err);
421
+ console.log(` ${c.red}Evolution failed: ${msg}${c.r}`);
422
+ console.log(
423
+ ` ${c.d}Tip: Ensure 'claude' CLI is available and authenticated.${c.r}`
424
+ );
425
+ }
208
426
  }
209
427
  function createConductorCommands() {
210
428
  const cmd = new Command("conductor");
@@ -323,14 +541,14 @@ function createConductorCommands() {
323
541
  const risks = anchors.filter((a) => a.type === "RISK");
324
542
  if (decisions.length > 0) {
325
543
  lines.push("", "### Decisions");
326
- for (const d2 of decisions.slice(0, 10)) {
327
- lines.push(`- ${d2.text}`);
544
+ for (const d of decisions.slice(0, 10)) {
545
+ lines.push(`- ${d.text}`);
328
546
  }
329
547
  }
330
548
  if (risks.length > 0) {
331
549
  lines.push("", "### Risks");
332
- for (const r2 of risks.slice(0, 5)) {
333
- lines.push(`- ${r2.text}`);
550
+ for (const r of risks.slice(0, 5)) {
551
+ lines.push(`- ${r.text}`);
334
552
  }
335
553
  }
336
554
  } catch {
@@ -414,48 +632,28 @@ function createConductorCommands() {
414
632
  }
415
633
  console.log(`Found ${results.length} result(s) for "${query}":
416
634
  `);
417
- for (const r2 of results) {
418
- const date = new Date(r2.captured_at * 1e3).toISOString().slice(0, 16);
635
+ for (const r of results) {
636
+ const date = new Date(r.captured_at * 1e3).toISOString().slice(0, 16);
419
637
  console.log(
420
- ` ${r2.issue_id} [${r2.context_type}] attempt ${r2.attempt} (${date})`
638
+ ` ${r.issue_id} [${r.context_type}] attempt ${r.attempt} (${date})`
421
639
  );
422
- if (r2.summary) {
423
- const snippet = r2.summary.slice(0, 120).replace(/\n/g, " ");
640
+ if (r.summary) {
641
+ const snippet = r.summary.slice(0, 120).replace(/\n/g, " ");
424
642
  console.log(` ${snippet}`);
425
643
  }
426
644
  }
427
645
  globalDb.close();
428
646
  });
429
647
  cmd.command("status").description("Show running agent status table").action(async () => {
430
- const agentsDir = join(homedir(), ".stackmemory", "conductor", "agents");
431
- if (!existsSync(agentsDir)) {
432
- console.log("No agent status files found");
433
- return;
434
- }
435
- const entries = readdirSync(agentsDir, { withFileTypes: true });
436
- const statuses = [];
437
- for (const entry of entries) {
438
- if (!entry.isDirectory()) continue;
439
- const statusPath = join(agentsDir, entry.name, "status.json");
440
- if (!existsSync(statusPath)) continue;
441
- try {
442
- const data = JSON.parse(readFileSync(statusPath, "utf-8"));
443
- statuses.push(data);
444
- } catch {
445
- }
446
- }
648
+ const statuses = scanAgentStatuses();
447
649
  if (statuses.length === 0) {
448
650
  console.log("No agent status files found");
449
651
  return;
450
652
  }
451
- statuses.sort(
452
- (a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
453
- );
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));
653
+ const enriched = statuses.map((s) => ({ ...s, ...enrichStatus(s) }));
654
+ const active = enriched.filter((s) => s.alive);
655
+ const stalled = enriched.filter((s) => s.stale);
656
+ const dead = enriched.filter((s) => !s.alive);
459
657
  const healthy = active.length - stalled.length;
460
658
  const parts = [];
461
659
  if (healthy > 0) parts.push(`${c.green}\u25CF ${healthy}${c.r}`);
@@ -467,21 +665,18 @@ function createConductorCommands() {
467
665
  `);
468
666
  const cols = (process.stdout.columns || 80) >= 90 ? 2 : 1;
469
667
  const rows = [];
470
- for (const s of statuses) {
471
- const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
472
- const staleFlag = elapsed > 5 * 60 * 1e3;
473
- const alive = isProcessAlive(s.pid);
668
+ for (const s of enriched) {
474
669
  const { icon, color, pct, label } = phaseProgress(
475
670
  s.phase,
476
671
  s.toolCalls,
477
- staleFlag,
478
- alive
672
+ s.stale,
673
+ s.alive
479
674
  );
480
675
  const bar = progressBar(pct, 8);
481
- const timeColor = !alive ? c.red : staleFlag ? c.orange : c.gray;
676
+ const timeColor = !s.alive ? c.red : s.stale ? c.orange : c.gray;
482
677
  const cell = [
483
678
  `${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}`
679
+ ` ${bar} ${c.d}${pct}%${c.r} ${c.gray}${s.toolCalls}t ${s.filesModified}f${c.r} ${timeColor}${formatElapsed(s.elapsed)}${c.r}`
485
680
  ];
486
681
  rows.push(cell);
487
682
  }
@@ -517,27 +712,9 @@ function createConductorCommands() {
517
712
  console.log("");
518
713
  });
519
714
  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;
715
+ const statuses = scanAgentStatuses();
716
+ const needsFinalize = statuses.map((s) => ({ ...s, ...enrichStatus(s) })).filter((s) => {
717
+ return !s.alive || s.elapsed > STALE_FINALIZE_THRESHOLD_MS;
541
718
  });
542
719
  if (needsFinalize.length === 0) {
543
720
  console.log(
@@ -551,26 +728,37 @@ function createConductorCommands() {
551
728
  `
552
729
  );
553
730
  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", "");
731
+ const elapsedStr = formatElapsed(s.elapsed).replace(" ago", "");
557
732
  let hasCommits = false;
558
733
  if (s.workspacePath && existsSync(s.workspacePath)) {
734
+ let baseBranch = "main";
559
735
  try {
560
- const log = execSync("git log origin/main..HEAD --oneline", {
736
+ const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
561
737
  cwd: s.workspacePath,
562
738
  encoding: "utf-8",
563
- timeout: 1e4
564
- });
739
+ timeout: 5e3
740
+ }).trim();
741
+ baseBranch = ref.replace("refs/remotes/origin/", "");
742
+ } catch {
743
+ }
744
+ try {
745
+ const log = execSync(
746
+ `git log origin/${baseBranch}..HEAD --oneline`,
747
+ {
748
+ cwd: s.workspacePath,
749
+ encoding: "utf-8",
750
+ timeout: 1e4
751
+ }
752
+ );
565
753
  hasCommits = log.trim().length > 0;
566
754
  } catch {
567
755
  }
568
756
  }
569
- const statusIcon = !alive ? `${c.red}\u2717 dead${c.r}` : `${c.orange}\u23F8 stalled ${elapsedStr}${c.r}`;
757
+ const statusIcon = !s.alive ? `${c.red}\u2717 dead${c.r}` : `${c.orange}\u23F8 stalled ${elapsedStr}${c.r}`;
570
758
  const commitStatus = hasCommits ? `${c.green}has commits \u2192 In Review${c.r}` : `${c.gray}no commits \u2192 mark failed${c.r}`;
571
759
  console.log(` ${c.b}${s.issue}${c.r} ${statusIcon} ${commitStatus}`);
572
760
  if (options.dryRun) continue;
573
- if (alive) {
761
+ if (s.alive) {
574
762
  try {
575
763
  process.kill(s.pid, "SIGTERM");
576
764
  console.log(` ${c.gray}Sent SIGTERM to pid ${s.pid}${c.r}`);
@@ -618,17 +806,220 @@ function createConductorCommands() {
618
806
  const lines = parseInt(options.lines, 10);
619
807
  const args = options.follow ? ["-f", "-n", String(lines), logPath] : ["-n", String(lines), logPath];
620
808
  const tail = cpSpawn("tail", args, { stdio: "inherit" });
809
+ const forward = () => {
810
+ tail.kill("SIGTERM");
811
+ };
812
+ process.on("SIGINT", forward);
813
+ process.on("SIGTERM", forward);
621
814
  await new Promise((resolve) => {
622
815
  tail.on("close", () => {
816
+ process.removeListener("SIGINT", forward);
817
+ process.removeListener("SIGTERM", forward);
623
818
  resolve();
624
819
  });
625
- const forward = () => {
626
- tail.kill("SIGTERM");
627
- };
628
- process.on("SIGINT", forward);
629
- process.on("SIGTERM", forward);
630
820
  });
631
821
  });
822
+ cmd.command("learn").description(
823
+ "Analyze agent outcomes and generate improved prompt templates"
824
+ ).option("--last <n>", "Analyze last N outcomes (default: all)", "0").option("--failures-only", "Only analyze failures", false).option("--export", "Export analysis as JSON", false).option(
825
+ "--evolve",
826
+ "Auto-mutate prompt template using GEPA-style evolution from failure data",
827
+ false
828
+ ).action(async (options) => {
829
+ const logPath = getOutcomesLogPath();
830
+ if (!existsSync(logPath)) {
831
+ console.log(
832
+ `${c.yellow}No outcomes log found.${c.r} Run conductor to generate data.`
833
+ );
834
+ return;
835
+ }
836
+ const raw = readFileSync(logPath, "utf-8").trim().split("\n").filter((l) => l.length > 0);
837
+ let outcomes = raw.map(
838
+ (line) => JSON.parse(line)
839
+ );
840
+ if (options.failuresOnly) {
841
+ outcomes = outcomes.filter((o) => o.outcome === "failure");
842
+ }
843
+ const lastN = parseInt(options.last, 10);
844
+ if (lastN > 0) {
845
+ outcomes = outcomes.slice(-lastN);
846
+ }
847
+ if (outcomes.length === 0) {
848
+ console.log(`${c.gray}No matching outcomes to analyze.${c.r}`);
849
+ return;
850
+ }
851
+ const total = outcomes.length;
852
+ const successes = outcomes.filter((o) => o.outcome === "success").length;
853
+ const failures = outcomes.filter((o) => o.outcome === "failure").length;
854
+ const successRate = Math.round(successes / total * 100);
855
+ const avgTokens = Math.round(
856
+ outcomes.reduce((s, o) => s + o.tokensUsed, 0) / total
857
+ );
858
+ const avgDuration = Math.round(
859
+ outcomes.reduce((s, o) => s + o.durationMs, 0) / total / 6e4
860
+ );
861
+ const avgTools = Math.round(
862
+ outcomes.reduce((s, o) => s + o.toolCalls, 0) / total
863
+ );
864
+ const failPhases = {};
865
+ for (const o of outcomes.filter((o2) => o2.outcome === "failure")) {
866
+ failPhases[o.phase] = (failPhases[o.phase] || 0) + 1;
867
+ }
868
+ const retries = outcomes.filter((o) => o.attempt > 1);
869
+ const retrySuccessRate = retries.length > 0 ? Math.round(
870
+ retries.filter((o) => o.outcome === "success").length / retries.length * 100
871
+ ) : 0;
872
+ const errorPatterns = {};
873
+ for (const o of outcomes.filter(
874
+ (o2) => o2.outcome === "failure" && o2.errorTail
875
+ )) {
876
+ const tail = o.errorTail;
877
+ if (tail.includes("lint"))
878
+ errorPatterns["lint_failure"] = (errorPatterns["lint_failure"] || 0) + 1;
879
+ else if (tail.includes("test"))
880
+ errorPatterns["test_failure"] = (errorPatterns["test_failure"] || 0) + 1;
881
+ else if (tail.includes("timeout") || tail.includes("timed out"))
882
+ errorPatterns["timeout"] = (errorPatterns["timeout"] || 0) + 1;
883
+ else if (tail.includes("conflict"))
884
+ errorPatterns["git_conflict"] = (errorPatterns["git_conflict"] || 0) + 1;
885
+ else if (tail.includes("429") || tail.includes("rate"))
886
+ errorPatterns["rate_limit"] = (errorPatterns["rate_limit"] || 0) + 1;
887
+ else errorPatterns["unknown"] = (errorPatterns["unknown"] || 0) + 1;
888
+ }
889
+ if (options.export) {
890
+ const analysis = {
891
+ total,
892
+ successes,
893
+ failures,
894
+ successRate,
895
+ avgTokens,
896
+ avgDurationMin: avgDuration,
897
+ avgToolCalls: avgTools,
898
+ failPhases,
899
+ retrySuccessRate,
900
+ errorPatterns,
901
+ outcomes
902
+ };
903
+ console.log(JSON.stringify(analysis, null, 2));
904
+ return;
905
+ }
906
+ console.log(`
907
+ ${c.b}${c.purple}Conductor Learning Report${c.r}
908
+ `);
909
+ const rateColor = successRate >= 80 ? c.green : successRate >= 50 ? c.yellow : c.red;
910
+ console.log(
911
+ ` ${c.b}Outcomes${c.r} ${c.white}${total}${c.r} total ${c.green}${successes}${c.r} success ${c.red}${failures}${c.r} failed ${rateColor}${successRate}%${c.r} success rate`
912
+ );
913
+ console.log(
914
+ ` ${c.b}Averages${c.r} ${c.white}${avgDuration}m${c.r} duration ${c.white}${fmtTokens(avgTokens)}${c.r} tokens ${c.white}${avgTools}${c.r} tool calls`
915
+ );
916
+ if (retries.length > 0) {
917
+ console.log(
918
+ ` ${c.b}Retries${c.r} ${c.white}${retries.length}${c.r} attempts ${c.white}${retrySuccessRate}%${c.r} retry success rate`
919
+ );
920
+ }
921
+ if (failures > 0) {
922
+ console.log(`
923
+ ${c.b}Failure Phases${c.r}`);
924
+ const sorted = Object.entries(failPhases).sort((a, b) => b[1] - a[1]);
925
+ for (const [phase, count] of sorted) {
926
+ const pct = Math.round(count / failures * 100);
927
+ const bar = progressBar(pct, 10);
928
+ console.log(
929
+ ` ${phaseIcon[phase] || "\u25CB"} ${phase.padEnd(14)} ${bar} ${c.white}${count}${c.r} ${c.gray}(${pct}%)${c.r}`
930
+ );
931
+ }
932
+ }
933
+ if (Object.keys(errorPatterns).length > 0) {
934
+ console.log(`
935
+ ${c.b}Error Patterns${c.r}`);
936
+ const sorted = Object.entries(errorPatterns).sort(
937
+ (a, b) => b[1] - a[1]
938
+ );
939
+ for (const [pattern, count] of sorted) {
940
+ console.log(
941
+ ` ${c.red}\u25CF${c.r} ${pattern.padEnd(16)} ${c.white}${count}${c.r}`
942
+ );
943
+ }
944
+ }
945
+ console.log(`
946
+ ${c.b}Recommendations${c.r}`);
947
+ const recs = [];
948
+ if (errorPatterns["lint_failure"] > 0) {
949
+ recs.push(
950
+ "Add explicit lint rules to prompt template (ESLint conventions, import style)"
951
+ );
952
+ }
953
+ if (errorPatterns["test_failure"] > 0) {
954
+ recs.push(
955
+ 'Add "run tests before committing" emphasis, include test command in prompt'
956
+ );
957
+ }
958
+ if (errorPatterns["timeout"] > 0) {
959
+ recs.push(
960
+ "Reduce scope per issue or increase turnTimeoutMs in conductor config"
961
+ );
962
+ }
963
+ if (failPhases["implementing"] > failures * 0.5) {
964
+ recs.push(
965
+ "Agents stall during implementation \u2014 add examples or break issues smaller"
966
+ );
967
+ }
968
+ if (failPhases["reading"] > 0) {
969
+ recs.push(
970
+ "Agents fail during reading \u2014 improve issue descriptions or add context pointers"
971
+ );
972
+ }
973
+ if (retrySuccessRate < 30 && retries.length > 2) {
974
+ recs.push(
975
+ "Low retry success \u2014 consider better prior-attempt context injection"
976
+ );
977
+ }
978
+ if (successRate >= 80) {
979
+ recs.push(
980
+ "High success rate \u2014 current prompt template is working well"
981
+ );
982
+ }
983
+ if (recs.length === 0) {
984
+ recs.push("Collect more data for actionable recommendations");
985
+ }
986
+ for (const rec of recs) {
987
+ console.log(` ${c.cyan}\u2192${c.r} ${rec}`);
988
+ }
989
+ const templatePath = join(
990
+ homedir(),
991
+ ".stackmemory",
992
+ "conductor",
993
+ "prompt-template.md"
994
+ );
995
+ if (!existsSync(templatePath)) {
996
+ console.log(
997
+ `
998
+ ${c.d}Tip: Create ${templatePath} to customize agent prompts.${c.r}`
999
+ );
1000
+ console.log(
1001
+ ` ${c.d}Variables: {{ISSUE_ID}} {{TITLE}} {{DESCRIPTION}} {{LABELS}} {{PRIORITY}} {{ATTEMPT}} {{PRIOR_CONTEXT}}${c.r}`
1002
+ );
1003
+ } else {
1004
+ console.log(`
1005
+ ${c.d}Using custom template: ${templatePath}${c.r}`);
1006
+ }
1007
+ if (options.evolve) {
1008
+ console.log(`
1009
+ ${c.b}${c.cyan}Evolving prompt template...${c.r}
1010
+ `);
1011
+ await evolvePromptTemplate({
1012
+ templatePath,
1013
+ successRate,
1014
+ failures,
1015
+ failPhases,
1016
+ errorPatterns,
1017
+ recs,
1018
+ outcomes
1019
+ });
1020
+ }
1021
+ console.log("");
1022
+ });
632
1023
  cmd.command("usage").description("Show token usage, budget, and time-to-exhaustion").option("--json", "Output as JSON", false).option("--scan", "Scan Claude Code JSONL logs for historical data", false).action(async (options) => {
633
1024
  const statusPath = join(
634
1025
  process.cwd(),
@@ -694,6 +1085,7 @@ function createConductorCommands() {
694
1085
  'Agent mode: "cli" (claude -p, session auth) or "adapter" (JSON-RPC, API key)',
695
1086
  "cli"
696
1087
  ).action(async (options) => {
1088
+ ensureDefaultPromptTemplate();
697
1089
  const conductor = new Conductor({
698
1090
  teamId: options.team,
699
1091
  activeStates: options.states.split(",").map((s) => s.trim()),
@@ -721,27 +1113,7 @@ function createConductorCommands() {
721
1113
  let refreshInterval = interval;
722
1114
  let phaseFilter = options.phase || null;
723
1115
  function readStatuses() {
724
- const agentsDir = join(
725
- homedir(),
726
- ".stackmemory",
727
- "conductor",
728
- "agents"
729
- );
730
- if (!existsSync(agentsDir)) return [];
731
- const entries = readdirSync(agentsDir, { withFileTypes: true });
732
- const statuses = [];
733
- for (const entry of entries) {
734
- if (!entry.isDirectory()) continue;
735
- const statusPath = join(agentsDir, entry.name, "status.json");
736
- if (!existsSync(statusPath)) continue;
737
- try {
738
- statuses.push(JSON.parse(readFileSync(statusPath, "utf-8")));
739
- } catch {
740
- }
741
- }
742
- statuses.sort(
743
- (a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
744
- );
1116
+ const statuses = scanAgentStatuses();
745
1117
  if (phaseFilter) {
746
1118
  return statuses.filter((s) => s.phase === phaseFilter);
747
1119
  }
@@ -754,9 +1126,7 @@ function createConductorCommands() {
754
1126
  return;
755
1127
  }
756
1128
  for (const s of statuses) {
757
- const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
758
- const stale = elapsed > 5 * 60 * 1e3;
759
- const alive = isProcessAlive(s.pid);
1129
+ const { elapsed, alive, stale } = enrichStatus(s);
760
1130
  const prog = phaseProgress(s.phase, s.toolCalls, stale, alive);
761
1131
  const bar = progressBar(prog.pct, 10);
762
1132
  const timeColor = !alive ? c.red : stale ? c.orange : c.gray;
@@ -788,9 +1158,7 @@ function createConductorCommands() {
788
1158
  return;
789
1159
  }
790
1160
  for (const s of statuses) {
791
- const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
792
- const stale = elapsed > 5 * 60 * 1e3;
793
- const alive = isProcessAlive(s.pid);
1161
+ const { elapsed, alive, stale } = enrichStatus(s);
794
1162
  const prog = phaseProgress(s.phase, s.toolCalls, stale, alive);
795
1163
  console.log(
796
1164
  ` ${prog.color}${prog.icon}${c.r} ${c.b}${s.issue}${c.r} ${prog.color}${prog.label}${c.r} ${c.gray}${formatElapsed(elapsed)}${c.r}`
@@ -813,10 +1181,10 @@ function createConductorCommands() {
813
1181
  console.log("");
814
1182
  }
815
1183
  }
1184
+ const cachedConductor = new Conductor({ repoRoot: process.cwd() });
816
1185
  async function getUsage() {
817
- const conductor = new Conductor({ repoRoot: process.cwd() });
818
- await conductor.scanUsageLogs();
819
- return conductor.getUsageSummary();
1186
+ await cachedConductor.scanUsageLogs();
1187
+ return cachedConductor.getUsageSummary();
820
1188
  }
821
1189
  async function render() {
822
1190
  process.stdout.write("\x1B[2J\x1B[H");
@@ -867,7 +1235,7 @@ function createConductorCommands() {
867
1235
  }
868
1236
  console.log("");
869
1237
  console.log(
870
- `${d}\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\u2500${r}`
1238
+ `${c.d}\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\u2500${c.r}`
871
1239
  );
872
1240
  }
873
1241
  await render();
@@ -876,14 +1244,14 @@ function createConductorCommands() {
876
1244
  if (!paused) await render();
877
1245
  }, refreshInterval);
878
1246
  await new Promise((resolve) => {
879
- process.on("SIGINT", () => {
880
- clearInterval(timer);
881
- resolve();
882
- });
883
- process.on("SIGTERM", () => {
1247
+ const cleanup = () => {
884
1248
  clearInterval(timer);
1249
+ process.removeListener("SIGINT", cleanup);
1250
+ process.removeListener("SIGTERM", cleanup);
885
1251
  resolve();
886
- });
1252
+ };
1253
+ process.on("SIGINT", cleanup);
1254
+ process.on("SIGTERM", cleanup);
887
1255
  });
888
1256
  return;
889
1257
  }
@@ -996,16 +1364,15 @@ function createConductorCommands() {
996
1364
  }
997
1365
  });
998
1366
  await new Promise((resolve) => {
999
- process.on("SIGINT", () => {
1367
+ const cleanup = () => {
1000
1368
  if (refreshTimer) clearTimeout(refreshTimer);
1001
1369
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
1370
+ process.removeListener("SIGINT", cleanup);
1371
+ process.removeListener("SIGTERM", cleanup);
1002
1372
  resolve();
1003
- });
1004
- process.on("SIGTERM", () => {
1005
- if (refreshTimer) clearTimeout(refreshTimer);
1006
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
1007
- resolve();
1008
- });
1373
+ };
1374
+ process.on("SIGINT", cleanup);
1375
+ process.on("SIGTERM", cleanup);
1009
1376
  });
1010
1377
  });
1011
1378
  return cmd;