@stackmemoryai/stackmemory
Version:
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, a
1,426 lines (1,406 loc) • 71.6 kB
JavaScript
import { fileURLToPath as __fileURLToPath } from 'url';
import { dirname as __pathDirname } from 'path';
const __filename = __fileURLToPath(import.meta.url);
const __dirname = __pathDirname(__filename);
import { Command } from "commander";
import { execSync, spawn as cpSpawn } from "child_process";
import {
existsSync,
mkdirSync,
writeFileSync,
readFileSync,
readdirSync,
copyFileSync
} from "fs";
import { join } from "path";
import { homedir, tmpdir } from "os";
import Database from "better-sqlite3";
import { logger } from "../../core/monitoring/logger.js";
import { isProcessAlive } from "../../utils/process-cleanup.js";
import { Conductor } from "./orchestrator.js";
import {
getAgentStatusDir,
getOutcomesLogPath
} from "./orchestrator.js";
import {
openTracesDb,
listSessions,
getSessionTurns,
getPhaseBreakdown,
getToolFrequencies,
getFailureTurns,
getTraceStats,
classifyErrorText
} from "./conductor-traces.js";
function getGlobalStorePath() {
const dir = join(homedir(), ".stackmemory", "conductor");
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
return dir;
}
function getGlobalDb() {
const dbPath = join(getGlobalStorePath(), "context.db");
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS symphony_contexts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 1,
workspace TEXT,
captured_at INTEGER NOT NULL,
context_type TEXT NOT NULL DEFAULT 'run',
summary TEXT,
frames_json TEXT,
anchors_json TEXT,
events_json TEXT,
metadata_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_symphony_issue
ON symphony_contexts(issue_id);
CREATE INDEX IF NOT EXISTS idx_symphony_captured
ON symphony_contexts(captured_at);
`);
return db;
}
function formatElapsed(ms) {
const seconds = Math.floor(ms / 1e3);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function fmtTokens(n) {
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
return String(n);
}
function formatTokens(n) {
return fmtTokens(n) + " tok";
}
function formatDuration(ms) {
const seconds = Math.floor(ms / 1e3);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
function budgetBar(pct, width = 30) {
const filled = Math.min(Math.round(pct / 100 * width), width);
const empty = width - filled;
const color = pct >= 75 ? "\x1B[31m" : pct >= 50 ? "\x1B[33m" : "\x1B[32m";
const dim = "\x1B[2m";
const rst = "\x1B[0m";
return `${color}${"\u2588".repeat(filled)}${dim}${"\u2591".repeat(empty)}${rst} ${String(pct).padStart(3)}%`;
}
const STALE_UI_THRESHOLD_MS = 5 * 60 * 1e3;
const STALE_FINALIZE_THRESHOLD_MS = 60 * 60 * 1e3;
const c = {
r: "\x1B[0m",
// reset
b: "\x1B[1m",
// bold
d: "\x1B[2m",
// dim
i: "\x1B[3m",
// italic
u: "\x1B[4m",
// underline
// Linear-inspired palette
purple: "\x1B[38;5;141m",
blue: "\x1B[38;5;75m",
cyan: "\x1B[38;5;80m",
green: "\x1B[38;5;114m",
yellow: "\x1B[38;5;221m",
orange: "\x1B[38;5;215m",
red: "\x1B[38;5;203m",
pink: "\x1B[38;5;176m",
gray: "\x1B[38;5;245m",
white: "\x1B[38;5;255m",
bg: {
purple: "\x1B[48;5;53m",
blue: "\x1B[48;5;24m",
green: "\x1B[48;5;22m",
red: "\x1B[48;5;52m",
yellow: "\x1B[48;5;58m",
gray: "\x1B[48;5;236m"
}
};
const phaseIcon = {
reading: "\u25D4",
planning: "\u25D1",
implementing: "\u25D5",
testing: "\u25CF",
committing: "\u2713"
};
const phaseColor = {
reading: c.cyan,
planning: c.blue,
implementing: c.yellow,
testing: c.pink,
committing: c.green
};
function phaseProgress(phase, toolCalls, stale, alive) {
const basePct = {
reading: 10,
planning: 25,
implementing: 50,
testing: 75,
committing: 90
};
let pct = basePct[phase] || 0;
if (phase === "implementing") {
pct += Math.min(Math.floor(toolCalls / 80 * 25), 25);
}
if (phase === "committing") {
pct = 90 + Math.min(Math.floor(toolCalls / 60 * 10), 9);
}
const labels = {
reading: "Reading",
planning: "Planning",
implementing: "Implementing",
testing: "Testing",
committing: "Committing"
};
let label = labels[phase] || phase;
let color = phaseColor[phase] || "";
let icon = phaseIcon[phase] || "\u25CB";
if (!alive) {
label = "Dead";
color = c.red;
icon = "\u2717";
} else if (stale) {
label = "Stalled";
color = c.orange;
icon = "\u23F8";
}
return { icon, color, pct, label };
}
function progressBar(pct, width) {
const filled = Math.min(Math.round(pct / 100 * width), width);
const empty = width - filled;
const col = pct >= 90 ? c.green : pct >= 50 ? c.yellow : c.cyan;
return `${col}${"\u2501".repeat(filled)}${c.d}${"\u254C".repeat(empty)}${c.r}`;
}
function scanAgentStatuses() {
const agentsDir2 = join(homedir(), ".stackmemory", "conductor", "agents");
if (!existsSync(agentsDir2)) return [];
const entries = readdirSync(agentsDir2, { withFileTypes: true });
const statuses = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const statusPath = join(agentsDir2, entry.name, "status.json");
if (!existsSync(statusPath)) continue;
try {
const data = JSON.parse(readFileSync(statusPath, "utf-8"));
statuses.push({ ...data, dir: entry.name });
} catch {
}
}
statuses.sort(
(a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
);
return statuses;
}
function enrichStatus(s) {
const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
const alive = isProcessAlive(s.pid);
const stale = alive && elapsed > STALE_UI_THRESHOLD_MS;
return { elapsed, alive, stale };
}
function fmtMinutes(m) {
if (m < 0) return "N/A";
if (m >= 60) return `${Math.floor(m / 60)}h ${m % 60}m`;
return `${m}m`;
}
function printUsageSummary(u) {
const totalTokens = u.totalTokens || 0;
const inputTokens = u.inputTokens || 0;
const outputTokens = u.outputTokens || 0;
const estMessages = u.estimatedMessages || 0;
const tokensPerMin = u.tokensPerMin || 0;
const budgetPct5x = u.budgetPct5x || 0;
const budgetPct20x = u.budgetPct20x || 0;
const mins5x = u.minutesRemaining5x ?? -1;
const mins20x = u.minutesRemaining20x ?? -1;
const cacheHitRate = u.cacheHitRate || 0;
console.log(`${c.b}Token Usage${c.r}`);
console.log(
` 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}`
);
console.log(
` 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}`
);
console.log("");
console.log(`${c.b}Budget (Max plan, 5h window)${c.r}`);
console.log(
` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${c.d}~${fmtMinutes(mins5x)} left${c.r}`
);
console.log(
` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${c.d}~${fmtMinutes(mins20x)} left${c.r}`
);
}
const DEFAULT_PROMPT_TEMPLATE = `# Agent Prompt \u2014 {{ISSUE_ID}}
You are working on Linear issue **{{ISSUE_ID}}**: {{TITLE}}
## Description
{{DESCRIPTION}}
## Context
- Priority: {{PRIORITY}}
- Labels: {{LABELS}}
{{PRIOR_CONTEXT}}
## Instructions
1. Read the issue description and related code carefully
2. Plan your approach before writing code
3. Implement the requested changes
4. Run \`npm run lint\` and fix any errors
5. Run \`npm run test:run\` and fix any failures
6. Commit your changes with format: \`type(scope): message\`
## Rules
- Follow existing code conventions (ESM imports with .js extension, TypeScript strict)
- Keep changes focused \u2014 only modify what the issue requires
- Write or update tests for any new functionality
- Do not skip pre-commit hooks
- If stuck, leave a comment in the code explaining the blocker
Work in the current directory. All changes will be on a dedicated branch.
`;
function ensureDefaultPromptTemplate() {
const templatePath = join(
homedir(),
".stackmemory",
"conductor",
"prompt-template.md"
);
if (!existsSync(templatePath)) {
const dir = join(homedir(), ".stackmemory", "conductor");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(templatePath, DEFAULT_PROMPT_TEMPLATE);
console.log(
` ${c.d}Created default prompt template: ${templatePath}${c.r}`
);
}
return templatePath;
}
function predictDifficulty(labels, description, priority, outcomes) {
let difficulty = "medium";
let confidence = 0.5;
const reasons = [];
const lowerLabels = labels.map((l) => l.toLowerCase());
if (outcomes.length > 0 && labels.length > 0) {
const matching = outcomes.filter(
(o) => o.labels && o.labels.some((ol) => lowerLabels.includes(ol.toLowerCase()))
);
if (matching.length >= 3) {
const failRate = matching.filter((o) => o.outcome === "failure").length / matching.length;
if (failRate > 0.6) {
difficulty = "hard";
confidence = Math.min(confidence + 0.1, 0.9);
reasons.push(
`Historical failure rate ${Math.round(failRate * 100)}% for similar labels`
);
} else if (failRate < 0.2) {
difficulty = "easy";
confidence = Math.min(confidence + 0.1, 0.9);
reasons.push(
`Historical failure rate ${Math.round(failRate * 100)}% for similar labels`
);
}
}
}
const hasBugOrFix = lowerLabels.some(
(l) => l === "bug" || l === "fix" || l.includes("bugfix")
);
if (description.length < 100 && hasBugOrFix) {
if (difficulty !== "easy") difficulty = "easy";
confidence = Math.min(confidence + 0.1, 0.9);
reasons.push("Short description with bug/fix label suggests simple fix");
}
const hasComplexLabel = lowerLabels.some(
(l) => l === "feature" || l === "refactor" || l === "refactoring" || l === "architecture"
);
if (description.length > 500 || hasComplexLabel) {
if (difficulty !== "hard") {
difficulty = description.length > 500 && hasComplexLabel ? "hard" : difficulty === "easy" ? "medium" : "hard";
}
confidence = Math.min(confidence + 0.1, 0.9);
if (description.length > 500) {
reasons.push(
`Long description (${description.length} chars) suggests complexity`
);
}
if (hasComplexLabel) {
reasons.push("Feature/refactor label suggests higher complexity");
}
}
if (priority === 1 || priority === 2) {
if (difficulty === "easy") difficulty = "medium";
else if (difficulty === "medium") difficulty = "hard";
confidence = Math.min(confidence + 0.1, 0.9);
reasons.push(
`Priority ${priority} (${priority === 1 ? "urgent" : "high"}) \u2014 higher difficulty expected`
);
}
if (outcomes.length > 0 && labels.length > 0) {
const matching = outcomes.filter(
(o) => o.labels && o.labels.some((ol) => lowerLabels.includes(ol.toLowerCase()))
);
if (matching.length >= 3) {
const avgToolCalls = matching.reduce((s, o) => s + o.toolCalls, 0) / matching.length;
if (avgToolCalls > 80) {
difficulty = "hard";
confidence = Math.min(confidence + 0.1, 0.9);
reasons.push(
`Historical avg tool calls ${Math.round(avgToolCalls)} for similar labels (>80)`
);
}
}
}
if (reasons.length === 0) {
reasons.push("No strong signals \u2014 defaulting to medium");
}
return { difficulty, confidence, reasons };
}
function analyzeErrorsFromTraces(failedIssues) {
const patterns = {};
const evidence = [];
let db;
try {
db = openTracesDb();
} catch {
return { patterns, evidence };
}
try {
for (const issueId of failedIssues) {
const turns = getFailureTurns(issueId, 3, db);
if (turns.length === 0) continue;
for (const turn of turns) {
const preview = turn.message_preview || "";
const tools = turn.tool_names ? JSON.parse(turn.tool_names) : [];
const pattern = classifyErrorText(preview);
if (pattern) {
patterns[pattern] = (patterns[pattern] || 0) + 1;
}
if (evidence.length < 15) {
evidence.push({
issue: issueId,
phase: turn.phase || "unknown",
tools,
preview: preview.slice(0, 300)
});
}
}
}
} finally {
db.close();
}
if (Object.keys(patterns).length === 0 && failedIssues.length > 0) {
patterns["unknown"] = failedIssues.length;
}
return { patterns, evidence };
}
function loadOutcomes() {
const logPath = getOutcomesLogPath();
if (!existsSync(logPath)) return [];
try {
return readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
} catch {
return [];
}
}
function spawnClaudePrint(prompt, timeoutMs = 12e4) {
return new Promise((resolve, reject) => {
const child = cpSpawn("claude", ["--print"], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env }
});
let stdout = "";
let stderr = "";
let killed = false;
const timer = setTimeout(() => {
killed = true;
child.kill("SIGTERM");
}, timeoutMs);
child.stdout.on("data", (d) => stdout += d.toString());
child.stderr.on("data", (d) => stderr += d.toString());
child.on("close", (code) => {
clearTimeout(timer);
if (killed)
return reject(new Error(`claude timed out after ${timeoutMs}ms`));
if (code !== 0 && !stdout)
return reject(new Error(stderr || `claude exited ${code}`));
resolve(stdout);
});
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
child.stdin.write(prompt);
child.stdin.end();
});
}
function printSimpleDiff(oldText, newText) {
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
const oldSet = new Set(oldLines);
const newSet = new Set(newLines);
const removed = oldLines.filter((l) => !newSet.has(l));
const added = newLines.filter((l) => !oldSet.has(l));
const removedSet = new Set(removed);
const addedSet = new Set(added);
const CONTEXT = 2;
const changedOld = /* @__PURE__ */ new Set();
for (let i = 0; i < oldLines.length; i++) {
if (removedSet.has(oldLines[i])) changedOld.add(i);
}
const changedNew = /* @__PURE__ */ new Set();
for (let i = 0; i < newLines.length; i++) {
if (addedSet.has(newLines[i])) changedNew.add(i);
}
let lastPrinted = -1;
for (let i = 0; i < oldLines.length; i++) {
let nearChange = false;
for (let j = Math.max(0, i - CONTEXT); j <= Math.min(oldLines.length - 1, i + CONTEXT); j++) {
if (changedOld.has(j)) {
nearChange = true;
break;
}
}
if (!nearChange) continue;
if (lastPrinted >= 0 && i > lastPrinted + 1) {
console.log(` ${c.d}...${c.r}`);
}
if (removedSet.has(oldLines[i])) {
console.log(` ${c.red}- ${oldLines[i]}${c.r}`);
} else {
console.log(` ${oldLines[i]}`);
}
lastPrinted = i;
}
if (removed.length > 0 && added.length > 0) {
console.log(` ${c.d}---${c.r}`);
}
lastPrinted = -1;
for (let i = 0; i < newLines.length; i++) {
let nearChange = false;
for (let j = Math.max(0, i - CONTEXT); j <= Math.min(newLines.length - 1, i + CONTEXT); j++) {
if (changedNew.has(j)) {
nearChange = true;
break;
}
}
if (!nearChange) continue;
if (lastPrinted >= 0 && i > lastPrinted + 1) {
console.log(` ${c.d}...${c.r}`);
}
if (addedSet.has(newLines[i])) {
console.log(` ${c.green}+ ${newLines[i]}${c.r}`);
} else {
console.log(` ${newLines[i]}`);
}
lastPrinted = i;
}
}
async function evolvePromptTemplate(input) {
const {
templatePath,
successRate,
failures,
failPhases,
errorPatterns,
recs,
outcomes,
dryRun,
traceEvidence
} = input;
let currentTemplate;
if (existsSync(templatePath)) {
currentTemplate = readFileSync(templatePath, "utf-8");
} else {
currentTemplate = DEFAULT_PROMPT_TEMPLATE;
const dir = join(homedir(), ".stackmemory", "conductor");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(templatePath, currentTemplate);
}
const failPhaseSummary = Object.entries(failPhases).sort((a, b) => b[1] - a[1]).map(([phase, count]) => ` - ${phase}: ${count} failures`).join("\n");
const errorSummary = Object.entries(errorPatterns).sort((a, b) => b[1] - a[1]).map(([pattern, count]) => ` - ${pattern}: ${count} occurrences`).join("\n");
let failureEvidenceSection;
if (traceEvidence && traceEvidence.length > 0) {
const evidenceLines = traceEvidence.slice(0, 10).map((ev) => {
const tools = ev.tools.length > 0 ? ev.tools.join(", ") : "none";
return ` [${ev.issue}, phase: ${ev.phase}, tools: ${tools}]
${ev.preview.slice(0, 200)}`;
});
failureEvidenceSection = `ACTUAL FAILURE EVIDENCE (from conversation traces):
${evidenceLines.join("\n\n")}`;
} else {
const failedOutcomes = outcomes.filter((o) => o.outcome === "failure" && o.errorTail).slice(-5);
const errorTails = failedOutcomes.map(
(o) => ` [${o.issue} attempt ${o.attempt}, phase: ${o.phase}]
${o.errorTail}`
).join("\n\n");
failureEvidenceSection = `SAMPLE ERROR TAILS FROM RECENT FAILURES:
${errorTails || " (none)"}`;
}
const mutationPrompt = `You are optimizing a prompt template for autonomous AI coding agents managed by a conductor system.
CURRENT PROMPT TEMPLATE:
\`\`\`markdown
${currentTemplate}
\`\`\`
PERFORMANCE DATA:
- Success rate: ${successRate}%
- Total failures: ${failures}
FAILURE PHASE BREAKDOWN:
${failPhaseSummary || " (none)"}
ERROR PATTERNS:
${errorSummary || " (none)"}
${failureEvidenceSection}
RECOMMENDATIONS FROM ANALYSIS:
${recs.map((r) => `- ${r}`).join("\n")}
YOUR TASK:
Improve the prompt template to reduce failures. Focus on:
1. Adding specific instructions that address the most common failure modes
2. Making implicit requirements explicit (lint rules, test commands, commit format)
3. Adding guardrails for the error patterns seen (e.g., if lint failures are common, add lint-specific instructions)
4. Keeping the template concise \u2014 agents work better with clear, structured prompts
5. Preserving all {{VARIABLE}} placeholders exactly as-is
REQUIREMENTS:
- Output ONLY the improved markdown template
- Keep all {{VARIABLE}} placeholders: {{ISSUE_ID}}, {{TITLE}}, {{DESCRIPTION}}, {{LABELS}}, {{PRIORITY}}, {{ATTEMPT}}, {{PRIOR_CONTEXT}}
- Do not add commentary, explanations, or markdown fences around the output
- Target similar length to the current template (no bloat)
OUTPUT THE IMPROVED TEMPLATE:`;
try {
console.log(
` ${c.d}Calling Claude to generate improved template...${c.r}`
);
const evolved = await spawnClaudePrint(mutationPrompt);
if (!evolved.trim()) {
console.log(` ${c.red}Empty response from Claude \u2014 skipping.${c.r}`);
return;
}
const requiredVars = ["{{ISSUE_ID}}", "{{TITLE}}", "{{DESCRIPTION}}"];
const missing = requiredVars.filter((v) => !evolved.includes(v));
if (missing.length > 0) {
console.log(
` ${c.red}Evolved template missing variables: ${missing.join(", ")} \u2014 skipping.${c.r}`
);
return;
}
if (dryRun) {
console.log(`
${c.b}${c.cyan}Dry-run diff:${c.r}
`);
printSimpleDiff(currentTemplate, evolved.trim());
console.log(
`
${c.d}Dry run \u2014 no files modified. Run without --dry-run to apply.${c.r}`
);
return;
}
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
const backupPath = templatePath.replace(".md", `.backup-${timestamp}.md`);
copyFileSync(templatePath, backupPath);
console.log(` ${c.d}Backed up to ${backupPath}${c.r}`);
writeFileSync(templatePath, evolved.trim() + "\n");
console.log(
` ${c.green}Evolved template written to ${templatePath}${c.r}`
);
const evolutionLog = join(
homedir(),
".stackmemory",
"conductor",
"evolution-log.jsonl"
);
const entry = {
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
successRate,
failures,
failPhases,
errorPatterns,
backupPath
};
const dir = join(homedir(), ".stackmemory", "conductor");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(
evolutionLog,
(existsSync(evolutionLog) ? readFileSync(evolutionLog, "utf-8") : "") + JSON.stringify(entry) + "\n"
);
console.log(` ${c.d}Evolution logged to ${evolutionLog}${c.r}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.log(` ${c.red}Evolution failed: ${msg}${c.r}`);
console.log(
` ${c.d}Tip: Ensure 'claude' CLI is available and authenticated.${c.r}`
);
}
}
function createConductorCommands() {
const CONDUCTOR_VERSION = "0.2.0";
const cmd = new Command("conductor");
cmd.description("Conductor \u2014 autonomous agent orchestration via Linear").option("--version", "Print conductor adapter version").action((options) => {
if (options.version) {
console.log(`symphony-adapter ${CONDUCTOR_VERSION}`);
return;
}
cmd.help();
});
cmd.command("capture").description("Capture workspace context after an agent run").requiredOption("--issue <id>", "Issue identifier (e.g., STA-476)").option("--workspace <path>", "Workspace directory", process.cwd()).option("--attempt <n>", "Attempt number", "1").action(async (options) => {
const workspace = options.workspace;
const issueId = options.issue;
const attempt = parseInt(options.attempt, 10);
const dbPath = join(workspace, ".stackmemory", "context.db");
let summary = "";
let framesJson = "[]";
let anchorsJson = "[]";
let eventsJson = "[]";
let frameCount = 0;
let anchorCount = 0;
if (existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const frames = db.prepare(
"SELECT frame_id, name, type, digest_text, created_at FROM frames ORDER BY created_at DESC LIMIT 20"
).all();
frameCount = frames.length;
framesJson = JSON.stringify(frames);
const anchors = db.prepare(
"SELECT anchor_id, type, text, priority FROM anchors WHERE type IN ('DECISION', 'FACT', 'CONSTRAINT', 'RISK') ORDER BY priority DESC LIMIT 30"
).all();
anchorCount = anchors.length;
anchorsJson = JSON.stringify(anchors);
const events = db.prepare(
"SELECT event_type, payload, ts FROM events ORDER BY ts DESC LIMIT 50"
).all();
eventsJson = JSON.stringify(events);
const digests = frames.filter((f) => f.digest_text).map((f) => f.digest_text).slice(0, 5);
summary = digests.join("\n");
db.close();
} catch (err) {
logger.warn("Failed to read workspace database", {
error: err.message
});
}
}
let metadata = { workspace, attempt };
try {
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: workspace,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5e3
}).trim();
const lastCommit = execSync("git log -1 --oneline", {
cwd: workspace,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5e3
}).trim();
metadata = { ...metadata, branch, lastCommit };
} catch {
}
const globalDb = getGlobalDb();
globalDb.prepare(
`INSERT INTO symphony_contexts
(issue_id, attempt, workspace, captured_at, context_type, summary, frames_json, anchors_json, events_json, metadata_json)
VALUES (?, ?, ?, ?, 'run', ?, ?, ?, ?, ?)`
).run(
issueId,
attempt,
workspace,
Math.floor(Date.now() / 1e3),
summary,
framesJson,
anchorsJson,
eventsJson,
JSON.stringify(metadata)
);
globalDb.close();
console.log(
`Captured ${frameCount} frames, ${anchorCount} anchors for ${issueId} (attempt ${attempt})`
);
});
cmd.command("restore").description("Restore context from prior runs into workspace").requiredOption("--issue <id>", "Issue identifier (e.g., STA-476)").option("--workspace <path>", "Workspace directory", process.cwd()).option("--related", "Also include context from related issues", false).action(async (options) => {
const workspace = options.workspace;
const issueId = options.issue;
const globalDbPath = join(getGlobalStorePath(), "context.db");
if (!existsSync(globalDbPath)) {
console.log("No prior orchestrator context found");
return;
}
const globalDb = getGlobalDb();
const contexts = globalDb.prepare(
`SELECT issue_id, attempt, summary, anchors_json, metadata_json, captured_at
FROM symphony_contexts
WHERE issue_id = ?
ORDER BY captured_at DESC
LIMIT 10`
).all(issueId);
if (contexts.length === 0 && !options.related) {
console.log(`No prior context for ${issueId}`);
globalDb.close();
return;
}
const lines = [
`# Prior Context for ${issueId}`,
"",
`Found ${contexts.length} prior run(s).`,
""
];
for (const ctx of contexts) {
const date = new Date(ctx.captured_at * 1e3).toISOString();
lines.push(`## Attempt ${ctx.attempt} (${date})`);
if (ctx.summary) {
lines.push("", ctx.summary);
}
try {
const anchors = JSON.parse(ctx.anchors_json || "[]");
const decisions = anchors.filter((a) => a.type === "DECISION");
const risks = anchors.filter((a) => a.type === "RISK");
if (decisions.length > 0) {
lines.push("", "### Decisions");
for (const d of decisions.slice(0, 10)) {
lines.push(`- ${d.text}`);
}
}
if (risks.length > 0) {
lines.push("", "### Risks");
for (const r of risks.slice(0, 5)) {
lines.push(`- ${r.text}`);
}
}
} catch {
}
try {
const meta = JSON.parse(ctx.metadata_json || "{}");
if (meta.branch || meta.lastCommit) {
lines.push("", "### Git State");
if (meta.branch) lines.push(`- Branch: ${meta.branch}`);
if (meta.lastCommit)
lines.push(`- Last commit: ${meta.lastCommit}`);
}
} catch {
}
lines.push("");
}
const restoreDir = join(workspace, ".stackmemory");
if (!existsSync(restoreDir)) {
mkdirSync(restoreDir, { recursive: true });
}
const restorePath = join(restoreDir, "conductor-context.md");
writeFileSync(restorePath, lines.join("\n"));
globalDb.close();
console.log(
`Restored ${contexts.length} prior run(s) for ${issueId} \u2192 ${restorePath}`
);
});
cmd.command("archive").description("Archive workspace context before removal").requiredOption("--issue <id>", "Issue identifier (e.g., STA-476)").option("--workspace <path>", "Workspace directory", process.cwd()).action(async (options) => {
const workspace = options.workspace;
const issueId = options.issue;
const dbPath = join(workspace, ".stackmemory", "context.db");
if (!existsSync(dbPath)) {
console.log(`No context to archive for ${issueId}`);
return;
}
const db = new Database(dbPath, { readonly: true });
const frames = db.prepare("SELECT * FROM frames ORDER BY created_at DESC").all();
const anchors = db.prepare("SELECT * FROM anchors").all();
const events = db.prepare("SELECT * FROM events ORDER BY ts DESC LIMIT 100").all();
const digests = frames.filter((f) => f.digest_text).map((f) => f.digest_text).slice(0, 10);
db.close();
const globalDb = getGlobalDb();
globalDb.prepare(
`INSERT INTO symphony_contexts
(issue_id, attempt, workspace, captured_at, context_type, summary, frames_json, anchors_json, events_json, metadata_json)
VALUES (?, 0, ?, ?, 'archive', ?, ?, ?, ?, ?)`
).run(
issueId,
workspace,
Math.floor(Date.now() / 1e3),
digests.join("\n"),
JSON.stringify(frames),
JSON.stringify(anchors),
JSON.stringify(events),
JSON.stringify({ archived: true, workspace })
);
globalDb.close();
console.log(
`Archived ${frames.length} frames, ${anchors.length} anchors for ${issueId}`
);
});
cmd.command("search").description("Search across all orchestrator issue contexts").argument("<query>", "Search query").option("--limit <n>", "Max results", "10").action(async (query, options) => {
const globalDbPath = join(getGlobalStorePath(), "context.db");
if (!existsSync(globalDbPath)) {
console.log("No orchestrator context database found");
return;
}
const limit = parseInt(options.limit, 10);
const globalDb = getGlobalDb();
const results = globalDb.prepare(
`SELECT issue_id, attempt, context_type, summary, anchors_json, captured_at
FROM symphony_contexts
WHERE summary LIKE ? OR anchors_json LIKE ?
ORDER BY captured_at DESC
LIMIT ?`
).all(`%${query}%`, `%${query}%`, limit);
if (results.length === 0) {
console.log(`No results for "${query}"`);
globalDb.close();
return;
}
console.log(`Found ${results.length} result(s) for "${query}":
`);
for (const r of results) {
const date = new Date(r.captured_at * 1e3).toISOString().slice(0, 16);
console.log(
` ${r.issue_id} [${r.context_type}] attempt ${r.attempt} (${date})`
);
if (r.summary) {
const snippet = r.summary.slice(0, 120).replace(/\n/g, " ");
console.log(` ${snippet}`);
}
}
globalDb.close();
});
cmd.command("status").description("Show running agent status table").action(async () => {
const statuses = scanAgentStatuses();
if (statuses.length === 0) {
console.log("No agent status files found");
return;
}
const enriched = statuses.map((s) => ({ ...s, ...enrichStatus(s) }));
const active = enriched.filter((s) => s.alive);
const stalled = enriched.filter((s) => s.stale);
const dead = enriched.filter((s) => !s.alive);
const healthy = active.length - stalled.length;
const parts = [];
if (healthy > 0) parts.push(`${c.green}\u25CF ${healthy}${c.r}`);
if (stalled.length > 0)
parts.push(`${c.orange}\u23F8 ${stalled.length}${c.r}`);
if (dead.length > 0) parts.push(`${c.red}\u2717 ${dead.length}${c.r}`);
console.log(`
${c.b}${c.white}Conductor${c.r} ${parts.join(" ")}
`);
const cols = (process.stdout.columns || 80) >= 90 ? 2 : 1;
const rows = [];
for (const s of enriched) {
const { icon, color, pct, label } = phaseProgress(
s.phase,
s.toolCalls,
s.stale,
s.alive
);
const bar = progressBar(pct, 8);
const timeColor = !s.alive ? c.red : s.stale ? c.orange : c.gray;
const cell = [
`${color}${icon}${c.r} ${c.b}${s.issue}${c.r} ${color}${label}${c.r}`,
` ${bar} ${c.d}${pct}%${c.r} ${c.gray}${s.toolCalls}t ${s.filesModified}f${c.r} ${timeColor}${formatElapsed(s.elapsed)}${c.r}`
];
rows.push(cell);
}
if (cols === 2) {
for (let i = 0; i < rows.length; i += 2) {
const left = rows[i];
const right = rows[i + 1];
const pad = 42;
if (right) {
console.log(
` ${left[0].padEnd(pad + 30)}${c.gray}\u2502${c.r} ${right[0]}`
);
console.log(
` ${left[1].padEnd(pad + 30)}${c.gray}\u2502${c.r} ${right[1]}`
);
} else {
console.log(` ${left[0]}`);
console.log(` ${left[1]}`);
}
if (i + 2 < rows.length) {
console.log(` ${c.gray}${"\u254C".repeat(38)}\u253C${"\u254C".repeat(38)}${c.r}`);
}
}
} else {
for (let i = 0; i < rows.length; i++) {
console.log(` ${rows[i][0]}`);
console.log(` ${rows[i][1]}`);
if (i < rows.length - 1) {
console.log(` ${c.gray}${"\u254C".repeat(38)}${c.r}`);
}
}
}
console.log("");
});
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) => {
const statuses = scanAgentStatuses();
const needsFinalize = statuses.map((s) => ({ ...s, ...enrichStatus(s) })).filter((s) => {
return !s.alive || s.elapsed > STALE_FINALIZE_THRESHOLD_MS;
});
if (needsFinalize.length === 0) {
console.log(
`${c.green}All agents are healthy \u2014 nothing to finalize.${c.r}`
);
return;
}
console.log(
`
${c.b}Finalizing ${needsFinalize.length} agent(s)${c.r}
`
);
for (const s of needsFinalize) {
const elapsedStr = formatElapsed(s.elapsed).replace(" ago", "");
let hasCommits = false;
if (s.workspacePath && existsSync(s.workspacePath)) {
let baseBranch = "main";
try {
const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
cwd: s.workspacePath,
encoding: "utf-8",
timeout: 5e3
}).trim();
baseBranch = ref.replace("refs/remotes/origin/", "");
} catch {
}
try {
const log = execSync(
`git log origin/${baseBranch}..HEAD --oneline`,
{
cwd: s.workspacePath,
encoding: "utf-8",
timeout: 1e4
}
);
hasCommits = log.trim().length > 0;
} catch {
}
}
const statusIcon = !s.alive ? `${c.red}\u2717 dead${c.r}` : `${c.orange}\u23F8 stalled ${elapsedStr}${c.r}`;
const commitStatus = hasCommits ? `${c.green}has commits \u2192 In Review${c.r}` : `${c.gray}no commits \u2192 mark failed${c.r}`;
console.log(` ${c.b}${s.issue}${c.r} ${statusIcon} ${commitStatus}`);
if (options.dryRun) continue;
if (s.alive) {
try {
process.kill(s.pid, "SIGTERM");
console.log(` ${c.gray}Sent SIGTERM to pid ${s.pid}${c.r}`);
} catch {
}
}
const statusPath = join(agentsDir, s.dir, "status.json");
try {
const updated = { ...s };
delete updated["dir"];
writeFileSync(
statusPath,
JSON.stringify(
{ ...updated, lastUpdate: (/* @__PURE__ */ new Date()).toISOString() },
null,
2
)
);
} catch {
}
if (hasCommits) {
console.log(
` ${c.cyan}\u2192 Move ${s.issue} to "In Review" in Linear${c.r}`
);
}
}
if (options.dryRun) {
console.log(
`
${c.d}Dry run \u2014 no changes made. Remove --dry-run to execute.${c.r}`
);
} else {
console.log(
`
${c.green}Done.${c.r} Run ${c.cyan}conductor status${c.r} to verify.`
);
}
});
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) => {
const logPath = join(getAgentStatusDir(issueId), "output.log");
if (!existsSync(logPath)) {
console.error(`No log file found for ${issueId} at ${logPath}`);
return;
}
const lines = parseInt(options.lines, 10);
const args = options.follow ? ["-f", "-n", String(lines), logPath] : ["-n", String(lines), logPath];
const tail = cpSpawn("tail", args, { stdio: "inherit" });
const forward = () => {
tail.kill("SIGTERM");
};
process.on("SIGINT", forward);
process.on("SIGTERM", forward);
await new Promise((resolve) => {
tail.on("close", () => {
process.removeListener("SIGINT", forward);
process.removeListener("SIGTERM", forward);
resolve();
});
});
});
cmd.command("learn").description(
"Analyze agent outcomes and generate improved prompt templates"
).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(
"--evolve",
"Auto-mutate prompt template using GEPA-style evolution from failure data",
false
).option(
"--dry-run",
"Show evolved template without writing (use with --evolve)",
false
).option(
"--no-evidence",
"Disable trace-based evidence display (on by default when traces.db exists)"
).option(
"--predict",
"Show difficulty predictions alongside actual outcomes",
false
).action(async (options) => {
const logPath = getOutcomesLogPath();
if (!existsSync(logPath)) {
console.log(
`${c.yellow}No outcomes log found.${c.r} Run conductor to generate data.`
);
return;
}
const raw = readFileSync(logPath, "utf-8").trim().split("\n").filter((l) => l.length > 0);
let outcomes = raw.map(
(line) => JSON.parse(line)
);
if (options.failuresOnly) {
outcomes = outcomes.filter((o) => o.outcome === "failure");
}
const lastN = parseInt(options.last, 10);
if (lastN > 0) {
outcomes = outcomes.slice(-lastN);
}
if (outcomes.length === 0) {
console.log(`${c.gray}No matching outcomes to analyze.${c.r}`);
return;
}
const total = outcomes.length;
const successes = outcomes.filter((o) => o.outcome === "success").length;
const failures = outcomes.filter((o) => o.outcome === "failure").length;
const successRate = Math.round(successes / total * 100);
const avgTokens = Math.round(
outcomes.reduce((s, o) => s + o.tokensUsed, 0) / total
);
const avgDuration = Math.round(
outcomes.reduce((s, o) => s + o.durationMs, 0) / total / 6e4
);
const avgTools = Math.round(
outcomes.reduce((s, o) => s + o.toolCalls, 0) / total
);
const failPhases = {};
for (const o of outcomes.filter((o2) => o2.outcome === "failure")) {
failPhases[o.phase] = (failPhases[o.phase] || 0) + 1;
}
const retries = outcomes.filter((o) => o.attempt > 1);
const retrySuccessRate = retries.length > 0 ? Math.round(
retries.filter((o) => o.outcome === "success").length / retries.length * 100
) : 0;
const failedIssueIds = [
...new Set(
outcomes.filter((o) => o.outcome === "failure").map((o) => o.issue)
)
];
const traceAnalysis = analyzeErrorsFromTraces(failedIssueIds);
let errorPatterns = traceAnalysis.patterns;
let traceEvidence = traceAnalysis.evidence;
if (Object.keys(errorPatterns).length === 0 || Object.keys(errorPatterns).length === 1 && errorPatterns["unknown"]) {
errorPatterns = {};
traceEvidence = [];
for (const o of outcomes.filter(
(o2) => o2.outcome === "failure" && o2.errorTail
)) {
const pattern = classifyErrorText(o.errorTail) ?? "unknown";
errorPatterns[pattern] = (errorPatterns[pattern] || 0) + 1;
}
}
if (options.export) {
const analysis = {
total,
successes,
failures,
successRate,
avgTokens,
avgDurationMin: avgDuration,
avgToolCalls: avgTools,
failPhases,
retrySuccessRate,
errorPatterns,
outcomes
};
console.log(JSON.stringify(analysis, null, 2));
return;
}
console.log(`
${c.b}${c.purple}Conductor Learning Report${c.r}
`);
const rateColor = successRate >= 80 ? c.green : successRate >= 50 ? c.yellow : c.red;
console.log(
` ${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`
);
console.log(
` ${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`
);
if (retries.length > 0) {
console.log(
` ${c.b}Retries${c.r} ${c.white}${retries.length}${c.r} attempts ${c.white}${retrySuccessRate}%${c.r} retry success rate`
);
}
if (failures > 0) {
console.log(`
${c.b}Failure Phases${c.r}`);
const sorted = Object.entries(failPhases).sort((a, b) => b[1] - a[1]);
for (const [phase, count] of sorted) {
const pct = Math.round(count / failures * 100);
const bar = progressBar(pct, 10);
console.log(
` ${phaseIcon[phase] || "\u25CB"} ${phase.padEnd(14)} ${bar} ${c.white}${count}${c.r} ${c.gray}(${pct}%)${c.r}`
);
}
}
if (Object.keys(errorPatterns).length > 0) {
const sourceLabel = traceEvidence.length > 0 ? "(from traces)" : "(from errorTail)";
console.log(
`
${c.b}Error Patterns${c.r} ${c.d}${sourceLabel}${c.r}`
);
const sorted = Object.entries(errorPatterns).sort(
(a, b) => b[1] - a[1]
);
for (const [pattern, count] of sorted) {
console.log(
` ${c.red}\u25CF${c.r} ${pattern.padEnd(20)} ${c.white}${count}${c.r}`
);
}
}
if (options.evidence && traceEvidence.length > 0) {
console.log(
`
${c.b}Failure Evidence${c.r} ${c.d}(from traces)${c.r}`
);
for (const ev of traceEvidence.slice(0, 15)) {
const tools = ev.tools.length > 0 ? ev.tools.join(", ") : "-";
console.log(
` ${c.cyan}${ev.issue}${c.r} [${ev.phase}] tools: ${tools}`
);
if (ev.preview) {
const lines = ev.preview.split("\n").slice(0, 3);
for (const line of lines) {
console.log(` ${c.d}${line.slice(0, 100)}${c.r}`);
}
}
}
} else if (options.evidence && traceEvidence.length === 0) {
console.log(
`
${c.d}No trace data available. Run conductor with trace logging enabled to collect evidence.${c.r}`
);
}
console.log(`
${c.b}Recommendations${c.r}`);
const recs = [];
if (errorPatterns["lint_failure"] > 0) {
recs.push(
"Add explicit lint rules to prompt template (ESLint conventions, import style)"
);
}
if (errorPatterns["test_failure"] > 0) {
recs.push(
'Add "run tests before committing" emphasis, include test command in prompt'
);
}
if (errorPatterns["timeout"] > 0) {
recs.push(
"Reduce scope per issue or increase turnTimeoutMs in conductor config"
);
}
if (failPhases["implementing"] > failures * 0.5) {
recs.push(
"Agents stall during implementation \u2014 add examples or break issues smaller"
);
}
if (failPhases["reading"] > 0) {
recs.push(
"Agents fail during reading \u2014 improve issue descriptions or add context pointers"
);
}
if (retrySuccessRate < 30 && retries.length > 2) {
recs.push(
"Low retry success \u2014 consider better prior-attempt context injection"
);
}
if (successRate >= 80) {
recs.push(
"High success rate \u2014 current prompt template is working well"
);
}
if (recs.length === 0) {
recs.push("Collect more data for actionable recommendations");
}
for (const rec of recs) {
console.log(` ${c.cyan}\u2192${c.r} ${rec}`);
}
const templatePath = join(
homedir(),
".stackmemory",
"conductor",
"prompt-template.md"
);
if (!existsSync(templatePath)) {
console.log(
`
${c.d}Tip: Create ${templatePath} to customize agent prompts.${c.r}`
);
console.log(
` ${c.d}Variables: {{ISSUE_ID}} {{TITLE}} {{DESCRIPTION}} {{LABELS}} {{PRIORITY}} {{ATTEMPT}} {{PRIOR_CONTEXT}}${c.r}`
);
} else {
console.log(`
${c.d}Using custom template: ${templatePath}${c.r}`);
}
if (options.evolve) {
console.log(`
${c.b}${c.cyan}Evolving prompt template...${c.r}
`);
await evolvePromptTemplate({
templatePath,
successRate,
failures,
failPhases,
errorPatterns,
recs,
outcomes,
dryRun: options.dryRun,
traceEvidence
});
}
if (options.predict) {
console.log(
`
${c.b}${c.purple}Difficulty Predictions vs Actual${c.r}
`
);
const byIssue = /* @__PURE__ */ new Map();
for (const o of outcomes) {
byIssue.set(o.issue, o);
}
const difficultyColor = {
easy: c.green,
medium: c.yellow,
hard: c.red
};
for (const [issue, outcome] of byIssue) {
const issueLabels = outcome.labels || [];
const otherOutcomes = outcomes.filter((o) => o.issue !== issue);
const pred = predictDifficulty(
issueLabels,
"",
// no description in outcome data
0,
// no priority in outcome data
otherOutcomes
);
const actualDifficulty = outcome.outcome === "success" && outcome.toolCalls < 40 ? "easy" : outcome.outcome === "failure" || outcome.toolCalls > 80 ? "hard" : "medium";
const match = pred.difficulty === actualDifficulty;
const matchIcon = match ? `${c.green}\u2713${c.r}` : `${c.red}\u2717${c.r}`;
console.log(
` ${matchIcon} ${c.white}${issue.padEnd(12)}${c.r} predicted: ${difficultyColor[pred.difficulty]}${pred.difficulty.padEnd(6)}${c.r} actual: ${difficultyColor[actualDifficulty]}${actualDifficulty.padEnd(6)}${c.r} ${c.gray}(${Math.round(pred.confidence * 100)}% conf)${c.r}`
);
}
const issueList = [...byIssue.values()];
const correct = issueList.filter((o) => {
const otherOutcomes = outcomes.filter((oo) => oo.issue !== o.issue);
const pred = predictDifficulty(o.labels || [], "", 0, otherOutcomes);
const actual = o.outcome === "success" && o.toolCalls < 40 ? "easy" : o.outcome === "failure" || o.toolCalls > 80 ? "hard" : "medium";
return pred.difficulty === actual;
}).length;
const accuracy = Math.round(correct / issueList.length * 100);
console.log(
`
${c.b}Accuracy${c.r}: ${accuracy}% (${correct}/${issueList.length})`
);
}
console.log("");
});
cmd.command("predict [issue-id]").description("Predict difficulty for an issue based on historical outcomes").option("--title <title>", "Issue title (for testing without Linear)").option(
"--labels <labels>",
"Comma-separated labels (for testing without Linear)"
).option("--priority <n>", "Priority 0-4 (for testing without Linear)", "0").option("--json", "Output as JSON", false).action(async (issueId, options) => {
let title = options.title || "";
let labels = options.labels ? options.labels.split(",").map((l) => l.trim()) : [];
let description = "";
let priority = parseInt(options.priority, 10) || 0;
if (issueId && !options.title && !options.labels) {
try {
const { LinearClient } = await import("../../integrations/linear/client.js");
const { LinearAuthManager } = await import("../../integrations/linear/auth.js");
let client;
try {
const authManager = new LinearAuthManager(process.cwd());
const token = await authManager.getValidToken();
client = new LinearClient({ apiKey: token, useBearer: true });
} catch {
const apiKey = process.env.LINEAR_API_KEY;
if (!apiKey) {
console.log(
`${c.red}No Linear auth found.${c.r} Use --title/--labels/--priority for testing.`
);
return;
}
client = new LinearClient({ apiKey });
}
const issue = await client.getIssue(issueId);
if (!issue) {
console.log(`${c.red}Issue ${issueId} not found.${c.r}`);
return;
}
title = issue.title;
description = issue.description || "";
labels = issue.labels.map((l) => l.name);
priority = issue.priority;
} catch (err) {
console.log(
`${c.red}Failed to fetch issue:${c.r} ${err.message}`
);
return;
}
}
if (!issueId && !options.title) {
console.log(
`${c.yellow}Provide an issue ID or --title/--labels for testing.${c.r}`
);
return;
}
const outcomes = loadOutcomes();
const pred = predictDifficulty(labels, description, priority, outcomes);
if (options.json) {
console.log(
JSON.stringify(
{
issueId: issueId || "inline",
title,
labels,
priority,
...pred
},
null,
2
)
);
return;
}
const difficultyColor = {
easy: c.green,
medium: c.yellow,
hard: c.red
};
console.log(`
${c.b}${c.purple}Difficulty Prediction${c.r}
`);
if (title) {