@stackmemoryai/stackmemory 1.5.8 → 1.5.9

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.
@@ -17,7 +17,8 @@ import Database from "better-sqlite3";
17
17
  import { logger } from "../../core/monitoring/logger.js";
18
18
  import { Conductor } from "./orchestrator.js";
19
19
  import {
20
- getAgentStatusDir
20
+ getAgentStatusDir,
21
+ getOutcomesLogPath
21
22
  } from "./orchestrator.js";
22
23
  function getGlobalStorePath() {
23
24
  const dir = join(homedir(), ".stackmemory", "conductor");
@@ -75,6 +76,8 @@ function budgetBar(pct, width = 30) {
75
76
  const rst = "\x1B[0m";
76
77
  return `${color}${"\u2588".repeat(filled)}${dim}${"\u2591".repeat(empty)}${rst} ${String(pct).padStart(3)}%`;
77
78
  }
79
+ const STALE_UI_THRESHOLD_MS = 5 * 60 * 1e3;
80
+ const STALE_FINALIZE_THRESHOLD_MS = 60 * 60 * 1e3;
78
81
  const c = {
79
82
  r: "\x1B[0m",
80
83
  // reset
@@ -170,6 +173,32 @@ function progressBar(pct, width) {
170
173
  const col = pct >= 90 ? c.green : pct >= 50 ? c.yellow : c.cyan;
171
174
  return `${col}${"\u2501".repeat(filled)}${c.d}${"\u254C".repeat(empty)}${c.r}`;
172
175
  }
176
+ function scanAgentStatuses() {
177
+ const agentsDir2 = join(homedir(), ".stackmemory", "conductor", "agents");
178
+ if (!existsSync(agentsDir2)) return [];
179
+ const entries = readdirSync(agentsDir2, { withFileTypes: true });
180
+ const statuses = [];
181
+ for (const entry of entries) {
182
+ if (!entry.isDirectory()) continue;
183
+ const statusPath = join(agentsDir2, entry.name, "status.json");
184
+ if (!existsSync(statusPath)) continue;
185
+ try {
186
+ const data = JSON.parse(readFileSync(statusPath, "utf-8"));
187
+ statuses.push({ ...data, dir: entry.name });
188
+ } catch {
189
+ }
190
+ }
191
+ statuses.sort(
192
+ (a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
193
+ );
194
+ return statuses;
195
+ }
196
+ function enrichStatus(s) {
197
+ const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
198
+ const alive = isProcessAlive(s.pid);
199
+ const stale = alive && elapsed > STALE_UI_THRESHOLD_MS;
200
+ return { elapsed, alive, stale };
201
+ }
173
202
  function fmtMinutes(m) {
174
203
  if (m < 0) return "N/A";
175
204
  if (m >= 60) return `${Math.floor(m / 60)}h ${m % 60}m`;
@@ -186,24 +215,20 @@ function printUsageSummary(u) {
186
215
  const mins5x = u.minutesRemaining5x ?? -1;
187
216
  const mins20x = u.minutesRemaining20x ?? -1;
188
217
  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}`);
218
+ console.log(`${c.b}Token Usage${c.r}`);
194
219
  console.log(
195
- ` Input ${w}${fmtTokens(inputTokens)}${r2} ${d2}|${r2} Output ${w}${fmtTokens(outputTokens)}${r2} ${d2}|${r2} Total ${w}${fmtTokens(totalTokens)}${r2}`
220
+ ` 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
221
  );
197
222
  console.log(
198
- ` Rate ${w}${fmtTokens(tokensPerMin)}/min${r2} ${d2}|${r2} Messages ${w}${estMessages}${r2} ${d2}|${r2} Cache hit ${w}${cacheHitRate}%${r2}`
223
+ ` 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
224
  );
200
225
  console.log("");
201
- console.log(`${b}Budget (Max plan, 5h window)${r2}`);
226
+ console.log(`${c.b}Budget (Max plan, 5h window)${c.r}`);
202
227
  console.log(
203
- ` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${d2}~${fmtMinutes(mins5x)} left${r2}`
228
+ ` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${c.d}~${fmtMinutes(mins5x)} left${c.r}`
204
229
  );
205
230
  console.log(
206
- ` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${d2}~${fmtMinutes(mins20x)} left${r2}`
231
+ ` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${c.d}~${fmtMinutes(mins20x)} left${c.r}`
207
232
  );
208
233
  }
209
234
  function createConductorCommands() {
@@ -323,14 +348,14 @@ function createConductorCommands() {
323
348
  const risks = anchors.filter((a) => a.type === "RISK");
324
349
  if (decisions.length > 0) {
325
350
  lines.push("", "### Decisions");
326
- for (const d2 of decisions.slice(0, 10)) {
327
- lines.push(`- ${d2.text}`);
351
+ for (const d of decisions.slice(0, 10)) {
352
+ lines.push(`- ${d.text}`);
328
353
  }
329
354
  }
330
355
  if (risks.length > 0) {
331
356
  lines.push("", "### Risks");
332
- for (const r2 of risks.slice(0, 5)) {
333
- lines.push(`- ${r2.text}`);
357
+ for (const r of risks.slice(0, 5)) {
358
+ lines.push(`- ${r.text}`);
334
359
  }
335
360
  }
336
361
  } catch {
@@ -414,48 +439,28 @@ function createConductorCommands() {
414
439
  }
415
440
  console.log(`Found ${results.length} result(s) for "${query}":
416
441
  `);
417
- for (const r2 of results) {
418
- const date = new Date(r2.captured_at * 1e3).toISOString().slice(0, 16);
442
+ for (const r of results) {
443
+ const date = new Date(r.captured_at * 1e3).toISOString().slice(0, 16);
419
444
  console.log(
420
- ` ${r2.issue_id} [${r2.context_type}] attempt ${r2.attempt} (${date})`
445
+ ` ${r.issue_id} [${r.context_type}] attempt ${r.attempt} (${date})`
421
446
  );
422
- if (r2.summary) {
423
- const snippet = r2.summary.slice(0, 120).replace(/\n/g, " ");
447
+ if (r.summary) {
448
+ const snippet = r.summary.slice(0, 120).replace(/\n/g, " ");
424
449
  console.log(` ${snippet}`);
425
450
  }
426
451
  }
427
452
  globalDb.close();
428
453
  });
429
454
  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
- }
455
+ const statuses = scanAgentStatuses();
447
456
  if (statuses.length === 0) {
448
457
  console.log("No agent status files found");
449
458
  return;
450
459
  }
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));
460
+ const enriched = statuses.map((s) => ({ ...s, ...enrichStatus(s) }));
461
+ const active = enriched.filter((s) => s.alive);
462
+ const stalled = enriched.filter((s) => s.stale);
463
+ const dead = enriched.filter((s) => !s.alive);
459
464
  const healthy = active.length - stalled.length;
460
465
  const parts = [];
461
466
  if (healthy > 0) parts.push(`${c.green}\u25CF ${healthy}${c.r}`);
@@ -467,21 +472,18 @@ function createConductorCommands() {
467
472
  `);
468
473
  const cols = (process.stdout.columns || 80) >= 90 ? 2 : 1;
469
474
  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);
475
+ for (const s of enriched) {
474
476
  const { icon, color, pct, label } = phaseProgress(
475
477
  s.phase,
476
478
  s.toolCalls,
477
- staleFlag,
478
- alive
479
+ s.stale,
480
+ s.alive
479
481
  );
480
482
  const bar = progressBar(pct, 8);
481
- const timeColor = !alive ? c.red : staleFlag ? c.orange : c.gray;
483
+ const timeColor = !s.alive ? c.red : s.stale ? c.orange : c.gray;
482
484
  const cell = [
483
485
  `${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}`
486
+ ` ${bar} ${c.d}${pct}%${c.r} ${c.gray}${s.toolCalls}t ${s.filesModified}f${c.r} ${timeColor}${formatElapsed(s.elapsed)}${c.r}`
485
487
  ];
486
488
  rows.push(cell);
487
489
  }
@@ -517,27 +519,9 @@ function createConductorCommands() {
517
519
  console.log("");
518
520
  });
519
521
  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;
522
+ const statuses = scanAgentStatuses();
523
+ const needsFinalize = statuses.map((s) => ({ ...s, ...enrichStatus(s) })).filter((s) => {
524
+ return !s.alive || s.elapsed > STALE_FINALIZE_THRESHOLD_MS;
541
525
  });
542
526
  if (needsFinalize.length === 0) {
543
527
  console.log(
@@ -551,9 +535,7 @@ function createConductorCommands() {
551
535
  `
552
536
  );
553
537
  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", "");
538
+ const elapsedStr = formatElapsed(s.elapsed).replace(" ago", "");
557
539
  let hasCommits = false;
558
540
  if (s.workspacePath && existsSync(s.workspacePath)) {
559
541
  try {
@@ -566,11 +548,11 @@ function createConductorCommands() {
566
548
  } catch {
567
549
  }
568
550
  }
569
- const statusIcon = !alive ? `${c.red}\u2717 dead${c.r}` : `${c.orange}\u23F8 stalled ${elapsedStr}${c.r}`;
551
+ const statusIcon = !s.alive ? `${c.red}\u2717 dead${c.r}` : `${c.orange}\u23F8 stalled ${elapsedStr}${c.r}`;
570
552
  const commitStatus = hasCommits ? `${c.green}has commits \u2192 In Review${c.r}` : `${c.gray}no commits \u2192 mark failed${c.r}`;
571
553
  console.log(` ${c.b}${s.issue}${c.r} ${statusIcon} ${commitStatus}`);
572
554
  if (options.dryRun) continue;
573
- if (alive) {
555
+ if (s.alive) {
574
556
  try {
575
557
  process.kill(s.pid, "SIGTERM");
576
558
  console.log(` ${c.gray}Sent SIGTERM to pid ${s.pid}${c.r}`);
@@ -629,6 +611,189 @@ function createConductorCommands() {
629
611
  process.on("SIGTERM", forward);
630
612
  });
631
613
  });
614
+ cmd.command("learn").description(
615
+ "Analyze agent outcomes and generate improved prompt templates"
616
+ ).option("--last <n>", "Analyze last N outcomes (default: all)", "0").option("--failures-only", "Only analyze failures", false).option("--export", "Export analysis as JSON", false).action(async (options) => {
617
+ const logPath = getOutcomesLogPath();
618
+ if (!existsSync(logPath)) {
619
+ console.log(
620
+ `${c.yellow}No outcomes log found.${c.r} Run conductor to generate data.`
621
+ );
622
+ return;
623
+ }
624
+ const raw = readFileSync(logPath, "utf-8").trim().split("\n").filter((l) => l.length > 0);
625
+ let outcomes = raw.map(
626
+ (line) => JSON.parse(line)
627
+ );
628
+ if (options.failuresOnly) {
629
+ outcomes = outcomes.filter((o) => o.outcome === "failure");
630
+ }
631
+ const lastN = parseInt(options.last, 10);
632
+ if (lastN > 0) {
633
+ outcomes = outcomes.slice(-lastN);
634
+ }
635
+ if (outcomes.length === 0) {
636
+ console.log(`${c.gray}No matching outcomes to analyze.${c.r}`);
637
+ return;
638
+ }
639
+ const total = outcomes.length;
640
+ const successes = outcomes.filter((o) => o.outcome === "success").length;
641
+ const failures = outcomes.filter((o) => o.outcome === "failure").length;
642
+ const successRate = Math.round(successes / total * 100);
643
+ const avgTokens = Math.round(
644
+ outcomes.reduce((s, o) => s + o.tokensUsed, 0) / total
645
+ );
646
+ const avgDuration = Math.round(
647
+ outcomes.reduce((s, o) => s + o.durationMs, 0) / total / 6e4
648
+ );
649
+ const avgTools = Math.round(
650
+ outcomes.reduce((s, o) => s + o.toolCalls, 0) / total
651
+ );
652
+ const failPhases = {};
653
+ for (const o of outcomes.filter((o2) => o2.outcome === "failure")) {
654
+ failPhases[o.phase] = (failPhases[o.phase] || 0) + 1;
655
+ }
656
+ const retries = outcomes.filter((o) => o.attempt > 1);
657
+ const retrySuccessRate = retries.length > 0 ? Math.round(
658
+ retries.filter((o) => o.outcome === "success").length / retries.length * 100
659
+ ) : 0;
660
+ const errorPatterns = {};
661
+ for (const o of outcomes.filter(
662
+ (o2) => o2.outcome === "failure" && o2.errorTail
663
+ )) {
664
+ const tail = o.errorTail;
665
+ if (tail.includes("lint"))
666
+ errorPatterns["lint_failure"] = (errorPatterns["lint_failure"] || 0) + 1;
667
+ else if (tail.includes("test"))
668
+ errorPatterns["test_failure"] = (errorPatterns["test_failure"] || 0) + 1;
669
+ else if (tail.includes("timeout") || tail.includes("timed out"))
670
+ errorPatterns["timeout"] = (errorPatterns["timeout"] || 0) + 1;
671
+ else if (tail.includes("conflict"))
672
+ errorPatterns["git_conflict"] = (errorPatterns["git_conflict"] || 0) + 1;
673
+ else if (tail.includes("429") || tail.includes("rate"))
674
+ errorPatterns["rate_limit"] = (errorPatterns["rate_limit"] || 0) + 1;
675
+ else errorPatterns["unknown"] = (errorPatterns["unknown"] || 0) + 1;
676
+ }
677
+ if (options.export) {
678
+ const analysis = {
679
+ total,
680
+ successes,
681
+ failures,
682
+ successRate,
683
+ avgTokens,
684
+ avgDurationMin: avgDuration,
685
+ avgToolCalls: avgTools,
686
+ failPhases,
687
+ retrySuccessRate,
688
+ errorPatterns,
689
+ outcomes
690
+ };
691
+ console.log(JSON.stringify(analysis, null, 2));
692
+ return;
693
+ }
694
+ console.log(`
695
+ ${c.b}${c.purple}Conductor Learning Report${c.r}
696
+ `);
697
+ const rateColor = successRate >= 80 ? c.green : successRate >= 50 ? c.yellow : c.red;
698
+ console.log(
699
+ ` ${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`
700
+ );
701
+ console.log(
702
+ ` ${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`
703
+ );
704
+ if (retries.length > 0) {
705
+ console.log(
706
+ ` ${c.b}Retries${c.r} ${c.white}${retries.length}${c.r} attempts ${c.white}${retrySuccessRate}%${c.r} retry success rate`
707
+ );
708
+ }
709
+ if (failures > 0) {
710
+ console.log(`
711
+ ${c.b}Failure Phases${c.r}`);
712
+ const sorted = Object.entries(failPhases).sort((a, b) => b[1] - a[1]);
713
+ for (const [phase, count] of sorted) {
714
+ const pct = Math.round(count / failures * 100);
715
+ const bar = progressBar(pct, 10);
716
+ console.log(
717
+ ` ${phaseIcon[phase] || "\u25CB"} ${phase.padEnd(14)} ${bar} ${c.white}${count}${c.r} ${c.gray}(${pct}%)${c.r}`
718
+ );
719
+ }
720
+ }
721
+ if (Object.keys(errorPatterns).length > 0) {
722
+ console.log(`
723
+ ${c.b}Error Patterns${c.r}`);
724
+ const sorted = Object.entries(errorPatterns).sort(
725
+ (a, b) => b[1] - a[1]
726
+ );
727
+ for (const [pattern, count] of sorted) {
728
+ console.log(
729
+ ` ${c.red}\u25CF${c.r} ${pattern.padEnd(16)} ${c.white}${count}${c.r}`
730
+ );
731
+ }
732
+ }
733
+ console.log(`
734
+ ${c.b}Recommendations${c.r}`);
735
+ const recs = [];
736
+ if (errorPatterns["lint_failure"] > 0) {
737
+ recs.push(
738
+ "Add explicit lint rules to prompt template (ESLint conventions, import style)"
739
+ );
740
+ }
741
+ if (errorPatterns["test_failure"] > 0) {
742
+ recs.push(
743
+ 'Add "run tests before committing" emphasis, include test command in prompt'
744
+ );
745
+ }
746
+ if (errorPatterns["timeout"] > 0) {
747
+ recs.push(
748
+ "Reduce scope per issue or increase turnTimeoutMs in conductor config"
749
+ );
750
+ }
751
+ if (failPhases["implementing"] > failures * 0.5) {
752
+ recs.push(
753
+ "Agents stall during implementation \u2014 add examples or break issues smaller"
754
+ );
755
+ }
756
+ if (failPhases["reading"] > 0) {
757
+ recs.push(
758
+ "Agents fail during reading \u2014 improve issue descriptions or add context pointers"
759
+ );
760
+ }
761
+ if (retrySuccessRate < 30 && retries.length > 2) {
762
+ recs.push(
763
+ "Low retry success \u2014 consider better prior-attempt context injection"
764
+ );
765
+ }
766
+ if (successRate >= 80) {
767
+ recs.push(
768
+ "High success rate \u2014 current prompt template is working well"
769
+ );
770
+ }
771
+ if (recs.length === 0) {
772
+ recs.push("Collect more data for actionable recommendations");
773
+ }
774
+ for (const rec of recs) {
775
+ console.log(` ${c.cyan}\u2192${c.r} ${rec}`);
776
+ }
777
+ const templatePath = join(
778
+ homedir(),
779
+ ".stackmemory",
780
+ "conductor",
781
+ "prompt-template.md"
782
+ );
783
+ if (!existsSync(templatePath)) {
784
+ console.log(
785
+ `
786
+ ${c.d}Tip: Create ${templatePath} to customize agent prompts.${c.r}`
787
+ );
788
+ console.log(
789
+ ` ${c.d}Variables: {{ISSUE_ID}} {{TITLE}} {{DESCRIPTION}} {{LABELS}} {{PRIORITY}} {{ATTEMPT}} {{PRIOR_CONTEXT}}${c.r}`
790
+ );
791
+ } else {
792
+ console.log(`
793
+ ${c.d}Using custom template: ${templatePath}${c.r}`);
794
+ }
795
+ console.log("");
796
+ });
632
797
  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
798
  const statusPath = join(
634
799
  process.cwd(),
@@ -721,27 +886,7 @@ function createConductorCommands() {
721
886
  let refreshInterval = interval;
722
887
  let phaseFilter = options.phase || null;
723
888
  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
- );
889
+ const statuses = scanAgentStatuses();
745
890
  if (phaseFilter) {
746
891
  return statuses.filter((s) => s.phase === phaseFilter);
747
892
  }
@@ -754,9 +899,7 @@ function createConductorCommands() {
754
899
  return;
755
900
  }
756
901
  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);
902
+ const { elapsed, alive, stale } = enrichStatus(s);
760
903
  const prog = phaseProgress(s.phase, s.toolCalls, stale, alive);
761
904
  const bar = progressBar(prog.pct, 10);
762
905
  const timeColor = !alive ? c.red : stale ? c.orange : c.gray;
@@ -788,9 +931,7 @@ function createConductorCommands() {
788
931
  return;
789
932
  }
790
933
  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);
934
+ const { elapsed, alive, stale } = enrichStatus(s);
794
935
  const prog = phaseProgress(s.phase, s.toolCalls, stale, alive);
795
936
  console.log(
796
937
  ` ${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 +954,10 @@ function createConductorCommands() {
813
954
  console.log("");
814
955
  }
815
956
  }
957
+ const cachedConductor = new Conductor({ repoRoot: process.cwd() });
816
958
  async function getUsage() {
817
- const conductor = new Conductor({ repoRoot: process.cwd() });
818
- await conductor.scanUsageLogs();
819
- return conductor.getUsageSummary();
959
+ await cachedConductor.scanUsageLogs();
960
+ return cachedConductor.getUsageSummary();
820
961
  }
821
962
  async function render() {
822
963
  process.stdout.write("\x1B[2J\x1B[H");
@@ -867,7 +1008,7 @@ function createConductorCommands() {
867
1008
  }
868
1009
  console.log("");
869
1010
  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}`
1011
+ `${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
1012
  );
872
1013
  }
873
1014
  await render();
@@ -4,6 +4,7 @@ const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
5
  import { spawn, execSync } from "child_process";
6
6
  import {
7
+ appendFileSync,
7
8
  existsSync,
8
9
  mkdirSync,
9
10
  readFileSync,
@@ -28,6 +29,14 @@ import {
28
29
  } from "../../core/worktree/preflight.js";
29
30
  import { ContextCapture } from "../../core/worktree/capture.js";
30
31
  import { extractKeywords } from "../../core/utils/text.js";
32
+ function getOutcomesLogPath() {
33
+ return join(homedir(), ".stackmemory", "conductor", "outcomes.jsonl");
34
+ }
35
+ function logAgentOutcome(entry) {
36
+ const dir = join(homedir(), ".stackmemory", "conductor");
37
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
38
+ appendFileSync(getOutcomesLogPath(), JSON.stringify(entry) + "\n");
39
+ }
31
40
  function findPackageRoot() {
32
41
  const currentFile = fileURLToPath(import.meta.url);
33
42
  let dir = dirname(currentFile);
@@ -635,6 +644,18 @@ class Conductor {
635
644
  await this.runAgent(issue, run);
636
645
  run.status = "completed";
637
646
  this.completeCount++;
647
+ logAgentOutcome({
648
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
649
+ issue: issue.identifier,
650
+ attempt: run.attempt,
651
+ outcome: "success",
652
+ phase: run.phase,
653
+ toolCalls: run.toolCalls,
654
+ filesModified: run.filesModified,
655
+ tokensUsed: run.tokensUsed,
656
+ durationMs: Date.now() - run.startedAt,
657
+ hasCommits: true
658
+ });
638
659
  await this.runHook(
639
660
  "after-run",
640
661
  run.workspacePath,
@@ -1271,7 +1292,32 @@ class Conductor {
1271
1292
  }
1272
1293
  }
1273
1294
  }
1295
+ /**
1296
+ * Build the agent prompt. If a custom template exists at
1297
+ * ~/.stackmemory/conductor/prompt-template.md, use it with variable
1298
+ * substitution. Otherwise fall back to the default template.
1299
+ *
1300
+ * Template variables: {{ISSUE_ID}}, {{TITLE}}, {{DESCRIPTION}},
1301
+ * {{LABELS}}, {{PRIORITY}}, {{ATTEMPT}}, {{PRIOR_CONTEXT}}
1302
+ */
1274
1303
  buildPrompt(issue, attempt) {
1304
+ const templatePath = join(
1305
+ homedir(),
1306
+ ".stackmemory",
1307
+ "conductor",
1308
+ "prompt-template.md"
1309
+ );
1310
+ const priority = ["None", "Urgent", "High", "Medium", "Low"][issue.priority] || "None";
1311
+ const labels = issue.labels.length > 0 ? issue.labels.map((l) => l.name).join(", ") : "";
1312
+ const priorContext = attempt > 1 ? `This is attempt ${attempt}. Check .stackmemory/conductor-context.md for context from prior attempts.` : "";
1313
+ if (existsSync(templatePath)) {
1314
+ try {
1315
+ let template = readFileSync(templatePath, "utf-8");
1316
+ template = template.replace(/\{\{ISSUE_ID\}\}/g, issue.identifier).replace(/\{\{TITLE\}\}/g, issue.title).replace(/\{\{DESCRIPTION\}\}/g, issue.description || "").replace(/\{\{LABELS\}\}/g, labels).replace(/\{\{PRIORITY\}\}/g, priority).replace(/\{\{ATTEMPT\}\}/g, String(attempt)).replace(/\{\{PRIOR_CONTEXT\}\}/g, priorContext);
1317
+ return template;
1318
+ } catch {
1319
+ }
1320
+ }
1275
1321
  const lines = [
1276
1322
  `You are working on Linear issue ${issue.identifier}: ${issue.title}`,
1277
1323
  ""
@@ -1279,17 +1325,12 @@ class Conductor {
1279
1325
  if (issue.description) {
1280
1326
  lines.push("## Description", "", issue.description, "");
1281
1327
  }
1282
- if (issue.labels.length > 0) {
1283
- lines.push(`Labels: ${issue.labels.map((l) => l.name).join(", ")}`);
1328
+ if (labels) {
1329
+ lines.push(`Labels: ${labels}`);
1284
1330
  }
1285
- lines.push(
1286
- `Priority: ${["None", "Urgent", "High", "Medium", "Low"][issue.priority] || "None"}`
1287
- );
1288
- if (attempt > 1) {
1289
- lines.push(
1290
- "",
1291
- `This is attempt ${attempt}. Check .stackmemory/conductor-context.md for context from prior attempts.`
1292
- );
1331
+ lines.push(`Priority: ${priority}`);
1332
+ if (priorContext) {
1333
+ lines.push("", priorContext);
1293
1334
  }
1294
1335
  lines.push(
1295
1336
  "",
@@ -1485,6 +1526,35 @@ class Conductor {
1485
1526
  hasCommits = log.trim().length > 0;
1486
1527
  } catch {
1487
1528
  }
1529
+ let errorTail;
1530
+ if (!hasCommits) {
1531
+ try {
1532
+ const logPath = join(
1533
+ getAgentStatusDir(run.issue.identifier),
1534
+ "output.log"
1535
+ );
1536
+ if (existsSync(logPath)) {
1537
+ const content = readFileSync(logPath, "utf-8");
1538
+ const lines = content.trim().split("\n");
1539
+ errorTail = lines.slice(-5).join("\n");
1540
+ }
1541
+ } catch {
1542
+ }
1543
+ }
1544
+ const durationMs = Date.now() - run.startedAt;
1545
+ logAgentOutcome({
1546
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1547
+ issue: run.issue.identifier,
1548
+ attempt: run.attempt,
1549
+ outcome: hasCommits ? "success" : "failure",
1550
+ phase: run.phase,
1551
+ toolCalls: run.toolCalls,
1552
+ filesModified: run.filesModified,
1553
+ tokensUsed: run.tokensUsed,
1554
+ durationMs,
1555
+ hasCommits,
1556
+ errorTail
1557
+ });
1488
1558
  if (hasCommits) {
1489
1559
  logger.info("Stale agent had commits, transitioning to In Review", {
1490
1560
  identifier: run.issue.identifier
@@ -1567,5 +1637,6 @@ class Conductor {
1567
1637
  }
1568
1638
  export {
1569
1639
  Conductor,
1570
- getAgentStatusDir
1640
+ getAgentStatusDir,
1641
+ getOutcomesLogPath
1571
1642
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.5.8",
3
+ "version": "1.5.9",
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",