Sign In

mindswap

Package Overview
Dependencies
Maintainers
1
Versions
13
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mindswap - npm Package Compare versions

Comparing version
3.2.0
to
3.2.1
+125
src/ask.js
const fs = require('fs');
const chalk = require('chalk');
const { getDataDir, readState } = require('./state');
const { searchContext } = require('./mcp-server');
async function ask(projectRoot, question, opts = {}) {
const dataDir = getDataDir(projectRoot);
if (!fs.existsSync(dataDir)) {
console.log(chalk.yellow('\nmindswap not initialized. Run: npx mindswap init\n'));
return;
}
const query = normalizeQuestion(question);
if (!query) {
console.log(chalk.yellow('\nPlease provide a question to ask.\n'));
return;
}
const state = readState(projectRoot);
const search = searchContext(projectRoot, query, 'all');
const results = parseSearchResults(search?.content?.[0]?.text || '');
const payload = buildAnswerPayload(query, results, state);
if (opts.json) {
console.log(JSON.stringify(payload, null, 2));
return;
}
console.log(chalk.bold('\n⚡ Ask\n'));
console.log(chalk.white(`Question: ${query}`));
console.log(chalk.cyan(`Answer: ${payload.answer}`));
if (payload.sources.length > 0) {
console.log(chalk.bold('\n Sources'));
for (const source of payload.sources.slice(0, 5)) {
console.log(chalk.dim(` • [${source.type}] (${source.score}) ${source.content}`));
}
}
if (payload.next_step) {
console.log(chalk.bold('\n Next step'));
console.log(chalk.white(` ${payload.next_step}`));
}
console.log();
}
function normalizeQuestion(question) {
if (Array.isArray(question)) return question.join(' ').trim();
return String(question || '').trim();
}
function parseSearchResults(text) {
const lines = String(text || '')
.split('\n')
.map(line => line.trim())
.filter(Boolean);
const results = [];
for (const line of lines) {
const match = line.match(/^\[(.+?)\]\s+\((\d+)\)\s+(.+)$/);
if (!match) continue;
results.push({
type: match[1],
score: Number(match[2]),
content: match[3],
});
}
return results;
}
function buildAnswerPayload(question, results, state) {
const top = results[0] || null;
const questionLower = question.toLowerCase();
const task = state.current_task || {};
let answer = 'No strong match found in the project context yet.';
if (top) {
if (top.type.includes('decision')) {
answer = `The strongest recorded decision is: ${stripDecisionPrefix(top.content)}.`;
} else if (top.type === 'blocker' || top.content.toLowerCase().includes('blocker')) {
answer = `The active blocker appears to be: ${top.content}.`;
} else if (questionLower.startsWith('why')) {
answer = `The best explanation from project memory is: ${stripContextPrefix(top.content)}.`;
} else if (questionLower.startsWith('what') || questionLower.startsWith('how')) {
answer = `The most relevant context says: ${stripContextPrefix(top.content)}.`;
} else {
answer = stripContextPrefix(top.content);
}
}
if (task.description && task.status !== 'idle' && !answer.includes(task.description)) {
answer += ` Current task: ${task.description}.`;
}
const next_step = top
? `Review the cited source lines in the handoff and, if needed, run \`npx mindswap search "${shortenQuestion(question)}"\` for a narrower pass.`
: 'Run `npx mindswap save` or log more context to improve future answers.';
return {
question,
answer,
next_step,
sources: results.slice(0, 5),
};
}
function stripDecisionPrefix(content) {
return String(content || '').replace(/^\[.*?\]\s*\[.*?\]\s*/, '').trim();
}
function stripContextPrefix(content) {
return String(content || '').replace(/^(Current task:|Current blocker:|Tech stack includes:)\s*/i, '').trim();
}
function shortenQuestion(question) {
return String(question || '').trim().slice(0, 80);
}
module.exports = {
ask,
parseSearchResults,
buildAnswerPayload,
normalizeQuestion,
};
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const { readState, getDataDir, getHistory } = require('./state');
const { isGitRepo, getCurrentBranch, getAllChangedFiles } = require('./git');
const { getOpenMemoryItems, getMemoryItems } = require('./memory');
const { detectMonorepo, detectChangedPackages } = require('./monorepo');
const { detectWorkPatterns } = require('./narrative');
const { isTeamMode, getAuthorIdentity } = require('./team');
async function contracts(projectRoot, opts = {}) {
const dataDir = getDataDir(projectRoot);
if (!fs.existsSync(dataDir)) {
console.log(chalk.yellow('\nmindswap not initialized. Run: npx mindswap init\n'));
return;
}
const payload = buildContracts(projectRoot);
const json = JSON.stringify(payload, null, 2);
fs.writeFileSync(path.join(dataDir, 'contracts.json'), json, 'utf-8');
fs.writeFileSync(path.join(projectRoot, 'CONTRACTS.json'), json, 'utf-8');
if (opts.json === false) {
console.log(chalk.bold('\n⚡ Interface Contracts\n'));
console.log(json);
console.log();
return;
}
console.log(json);
}
function buildContracts(projectRoot) {
const state = readState(projectRoot);
const changedFiles = isGitRepo(projectRoot) ? getAllChangedFiles(projectRoot) : [];
const history = getHistory(projectRoot, 10);
const branch = isGitRepo(projectRoot) ? getCurrentBranch(projectRoot) : null;
const projectPatterns = detectWorkPatterns(changedFiles);
const monorepo = detectMonorepo(projectRoot);
const changedPkgs = monorepo.isMonorepo ? detectChangedPackages(monorepo, changedFiles) : [];
const blockers = getOpenMemoryItems(projectRoot, 'blocker', 10);
const assumptions = getOpenMemoryItems(projectRoot, 'assumption', 10);
const questions = getOpenMemoryItems(projectRoot, 'question', 10);
const resolutions = getMemoryItems(projectRoot, { type: 'resolution', limit: 10 });
const author = isTeamMode(projectRoot) ? getAuthorIdentity(projectRoot) : null;
return {
version: '1.0.0',
generated_at: new Date().toISOString(),
project: {
name: state.project?.name || path.basename(projectRoot),
branch,
language: state.project?.language || 'unknown',
framework: state.project?.framework || 'none',
stack: state.project?.tech_stack || [],
monorepo: monorepo.isMonorepo ? monorepo.tool : null,
changed_packages: changedPkgs,
},
owner: author ? { type: 'author', identity: author } : null,
contracts: buildContractEntries({
projectRoot,
state,
changedFiles,
history,
projectPatterns,
blockers,
assumptions,
questions,
resolutions,
author,
}),
};
}
function buildContractEntries({ projectRoot, state, changedFiles, history, projectPatterns, blockers, assumptions, questions, resolutions, author }) {
const task = state.current_task || {};
const decisions = readDecisionLines(projectRoot);
const recentHistory = history.slice(0, 5);
const areas = projectPatterns.length > 0 ? projectPatterns : inferAreasFromFiles(changedFiles);
const contract = {
id: 'current-workstream',
type: 'workflow',
name: task.description || state.project?.name || 'current-workstream',
boundaries: areas.length > 0
? areas.map(area => `Scope includes ${area}`)
: ['Scope is the current feature and associated handoff only'],
inputs: [
'Current project state from HANDOFF.md',
'Recent commits and changed files',
'Open blockers, assumptions, and questions',
],
outputs: [
'Updated state.json',
'Updated decisions.log and memory.json',
'Refreshed handoff files',
],
blockers: [
...(task.blocker ? [task.blocker] : []),
...blockers.map(item => item.message),
],
assumptions: assumptions.map(item => item.message),
invariants: [
...decisions.slice(0, 5),
...(resolutions.slice(0, 3).map(item => item.message)),
],
owner: author || null,
status: task.status || 'idle',
recent_history: recentHistory.map(entry => ({
timestamp: entry.timestamp,
message: entry.message,
author: entry.author || null,
ai_tool: entry.ai_tool || null,
})),
open_questions: questions.map(item => item.message),
changed_files: changedFiles.slice(0, 20).map(file => ({
status: file.status,
file: file.file,
})),
};
return [contract];
}
function inferAreasFromFiles(changedFiles = []) {
const files = changedFiles.map(f => (f.file || f).toLowerCase());
const areas = new Set();
for (const file of files) {
if (file.includes('auth') || file.includes('login') || file.includes('session') || file.includes('jwt')) areas.add('authentication');
if (file.includes('db') || file.includes('migration') || file.includes('schema') || file.includes('sql')) areas.add('database');
if (file.includes('api') || file.includes('route') || file.includes('controller') || file.includes('handler')) areas.add('api');
if (file.includes('test') || file.includes('spec')) areas.add('tests');
if (file.includes('component') || file.includes('ui') || file.includes('page')) areas.add('ui');
}
return [...areas];
}
function readDecisionLines(projectRoot) {
try {
const decisionsPath = path.join(getDataDir(projectRoot), 'decisions.log');
if (!fs.existsSync(decisionsPath)) return [];
return fs.readFileSync(decisionsPath, 'utf-8')
.split('\n')
.filter(line => line.startsWith('['))
.slice(-10)
.map(line => line.replace(/^\[.*?\]\s*\[.*?\]\s*/, '').trim());
} catch {
return [];
}
}
module.exports = {
contracts,
buildContracts,
buildContractEntries,
inferAreasFromFiles,
};
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const { getDataDir, readState, getHistory, sanitizeBranch } = require('./state');
const { isGitRepo, getCurrentBranch } = require('./git');
const { detectAITool } = require('./detect-ai');
const { detectLastStatus } = require('./build-test');
const { findAllConflicts, checkDepsVsDecisions } = require('./conflicts');
const { calculateQualityScore } = require('./narrative');
const { analyzeGuardrails } = require('./guardrails');
const { getSyncHubPath, readHubSnapshot, buildSyncReport, buildLocalSnapshot } = require('./sync');
async function doctor(projectRoot, opts = {}) {
const report = analyzeProjectHealth(projectRoot);
if (opts.json) {
console.log(JSON.stringify(report, null, 2));
} else {
printDoctorReport(report);
}
if (report.summary.issues > 0) {
process.exitCode = 1;
}
return report;
}
function analyzeProjectHealth(projectRoot) {
const dataDir = getDataDir(projectRoot);
const checks = [];
const live = {
branch: null,
changedFiles: [],
recentCommits: [],
decisions: [],
history: [],
};
if (!fs.existsSync(dataDir)) {
addCheck(checks, 'issue', 'mindswap is not initialized', 'Run `npx mindswap init` in this project.');
return finalizeReport(projectRoot, checks);
}
addCheck(checks, 'ok', 'mindswap data directory exists');
const statePath = path.join(dataDir, 'state.json');
const configPath = path.join(dataDir, 'config.json');
const decisionsPath = path.join(dataDir, 'decisions.log');
const handoffPath = path.join(projectRoot, 'HANDOFF.md');
const localHandoffPath = path.join(dataDir, 'HANDOFF.md');
if (fs.existsSync(statePath)) addCheck(checks, 'ok', 'state.json is present');
else addCheck(checks, 'issue', 'state.json is missing', 'Re-run `npx mindswap init` to repair project state.');
if (fs.existsSync(configPath)) addCheck(checks, 'ok', 'config.json is present');
else addCheck(checks, 'warning', 'config.json is missing', 'Re-run `npx mindswap init` to restore default config.');
if (fs.existsSync(decisionsPath)) addCheck(checks, 'ok', 'decisions log is present');
else addCheck(checks, 'warning', 'decisions.log is missing', 'Create it with `npx mindswap init` or restore it from history.');
if (fs.existsSync(handoffPath)) addCheck(checks, 'ok', 'HANDOFF.md exists at project root');
else addCheck(checks, 'issue', 'HANDOFF.md is missing', 'Run `npx mindswap` or `npx mindswap gen --handoff`.');
if (fs.existsSync(localHandoffPath)) addCheck(checks, 'ok', 'local .mindswap/HANDOFF.md exists');
else addCheck(checks, 'warning', '.mindswap/HANDOFF.md is missing', 'Run `npx mindswap gen --handoff` to regenerate local handoff state.');
if (fs.existsSync(statePath)) {
const freshnessInputs = [statePath, decisionsPath].filter(fs.existsSync);
const staleRootHandoff = isStale(handoffPath, freshnessInputs);
const staleLocalHandoff = isStale(localHandoffPath, freshnessInputs);
if (fs.existsSync(handoffPath) && !staleRootHandoff) {
addCheck(checks, 'ok', 'project HANDOFF.md is fresh');
} else if (fs.existsSync(handoffPath)) {
addCheck(checks, 'warning', 'project HANDOFF.md looks stale', 'Run `npx mindswap` to refresh generated context files.');
}
if (fs.existsSync(localHandoffPath) && !staleLocalHandoff) {
addCheck(checks, 'ok', 'local .mindswap/HANDOFF.md is fresh');
} else if (fs.existsSync(localHandoffPath)) {
addCheck(checks, 'warning', 'local .mindswap/HANDOFF.md looks stale', 'Run `npx mindswap gen --handoff` to refresh local context.');
}
}
let state = null;
try {
state = readState(projectRoot);
} catch (err) {
addCheck(checks, 'issue', 'state.json could not be read', err.message);
}
if (state) {
live.history = getHistory(projectRoot, 5);
if (fs.existsSync(decisionsPath)) {
live.decisions = fs.readFileSync(decisionsPath, 'utf-8').split('\n').filter(line => line.startsWith('['));
}
if (state.last_checkpoint?.timestamp) {
addCheck(checks, 'ok', `last checkpoint recorded ${timeAgo(new Date(state.last_checkpoint.timestamp))}`);
} else {
addCheck(checks, 'warning', 'no checkpoint has been recorded yet', 'Run `npx mindswap` after meaningful work to establish context.');
}
if (state.current_task?.started_at && state.current_task.status === 'in_progress') {
const ageHours = (Date.now() - new Date(state.current_task.started_at).getTime()) / 3600000;
if (ageHours > 72) {
addCheck(checks, 'warning', `current task has been in progress for ${Math.floor(ageHours / 24)}d`, 'Consider checkpointing, pausing, or marking the task done.');
}
}
const statusProbe = detectLastStatus(projectRoot);
if (state.test_status || statusProbe.test) {
addCheck(checks, 'ok', 'test status is available');
} else if (expectsTestStatus(projectRoot)) {
addCheck(checks, 'warning', 'test status is missing or stale', 'Run `npx mindswap --check` to capture current test results.');
}
if (state.build_status || statusProbe.build) {
addCheck(checks, 'ok', 'build status is available');
} else if (expectsBuildStatus(projectRoot)) {
addCheck(checks, 'warning', 'build status is missing', 'Capture a build result during checkpointing if this project has a build step.');
}
}
if (isGitRepo(projectRoot)) {
const branch = getCurrentBranch(projectRoot);
live.branch = branch;
addCheck(checks, 'ok', `git repo detected on branch ${branch}`);
const branchStatePath = path.join(dataDir, 'branches', `${sanitizeBranch(branch)}.json`);
if (fs.existsSync(branchStatePath)) {
addCheck(checks, 'ok', 'branch-specific state file exists');
} else {
addCheck(checks, 'warning', 'branch-specific state file is missing', 'Run `npx mindswap` to write branch-aware state for the current branch.');
}
const hookStatus = inspectPostCommitHook(projectRoot);
if (hookStatus.level === 'ok') addCheck(checks, 'ok', hookStatus.message);
else addCheck(checks, hookStatus.level, hookStatus.message, hookStatus.fix);
} else {
addCheck(checks, 'warning', 'project is not a git repository', 'Initialize git to unlock branch-aware state and auto-checkpoints.');
}
const conflicts = findAllConflicts(projectRoot);
const depConflicts = checkDepsVsDecisions(projectRoot);
if (conflicts.length === 0) {
addCheck(checks, 'ok', 'no decision conflicts detected');
} else {
addCheck(checks, 'issue', `${conflicts.length} decision conflict${conflicts.length === 1 ? '' : 's'} detected`, conflicts[0].reason);
}
if (depConflicts.length === 0) {
addCheck(checks, 'ok', 'no dependency-vs-decision conflicts detected');
} else {
addCheck(checks, 'issue', `${depConflicts.length} dependency conflict${depConflicts.length === 1 ? '' : 's'} detected`, depConflicts[0].reason);
}
const guardrails = analyzeGuardrails(projectRoot);
if (guardrails.warnings.length === 0) {
addCheck(checks, 'ok', 'no architectural drift signals detected');
} else {
addCheck(checks, 'warning', `${guardrails.warnings.length} architectural drift signal${guardrails.warnings.length === 1 ? '' : 's'} detected`, guardrails.warnings[0].reason);
}
const aiContextStatus = inspectAIContextFiles(projectRoot);
for (const item of aiContextStatus) {
addCheck(checks, item.level, item.message, item.fix);
}
const hubPath = getSyncHubPath(projectRoot);
if (fs.existsSync(hubPath)) {
const report = buildSyncReport({
local: buildLocalSnapshot(projectRoot),
hub: readHubSnapshot(hubPath),
hubPath,
mode: 'status',
});
if (report.conflict) {
addCheck(checks, 'warning', 'shared sync hub is diverged from local state', report.message);
} else {
addCheck(checks, 'ok', `shared sync hub status: ${report.status}`);
}
} else if (process.env.MINDSWAP_SYNC_HUB) {
addCheck(checks, 'warning', 'sync hub path is configured but the hub file is missing', `Create or point MINDSWAP_SYNC_HUB at a writable JSON file (${hubPath}).`);
}
if (state) {
const quality = calculateQualityScore(state, live);
if (quality.score >= 75) {
addCheck(checks, 'ok', `context quality is ${quality.grade} (${quality.score}/100)`);
} else {
addCheck(checks, 'warning', `context quality is ${quality.grade} (${quality.score}/100)`, quality.missing[0] || 'Add more task, test, and decision context.');
}
}
return finalizeReport(projectRoot, checks);
}
function inspectPostCommitHook(projectRoot) {
const hookPath = path.join(projectRoot, '.git', 'hooks', 'post-commit');
if (!fs.existsSync(hookPath)) {
return {
level: 'warning',
message: 'git post-commit hook is missing',
fix: 'Run `npx mindswap init` to install the default post-commit hook.',
};
}
const content = fs.readFileSync(hookPath, 'utf-8');
if (!content.includes('mindswap save --quiet')) {
return {
level: 'warning',
message: 'git post-commit hook does not include mindswap auto-save',
fix: 'Re-run `npx mindswap init` or add the mindswap hook stanza manually.',
};
}
return { level: 'ok', message: 'git post-commit hook includes mindswap auto-save' };
}
function inspectAIContextFiles(projectRoot) {
const checks = [];
const detected = detectAITool(projectRoot);
if (!detected) {
checks.push({ level: 'ok', message: 'no AI-tool specific context files detected' });
return checks;
}
checks.push({ level: 'ok', message: `detected AI tool context: ${detected}` });
const expectations = [
{ label: 'Claude Code', file: path.join(projectRoot, 'CLAUDE.md'), fix: 'Run `npx mindswap gen --claude`.' },
{ label: 'Cursor', file: path.join(projectRoot, '.cursor', 'rules', 'mindswap-context.mdc'), fix: 'Run `npx mindswap gen --cursor`.' },
{ label: 'GitHub Copilot', file: path.join(projectRoot, '.github', 'copilot-instructions.md'), fix: 'Run `npx mindswap gen --copilot`.' },
{ label: 'Codex', file: path.join(projectRoot, 'CODEX.md'), fix: 'Run `npx mindswap gen --codex`.' },
{ label: 'AI Agent (AGENTS.md)', file: path.join(projectRoot, 'AGENTS.md'), fix: 'Run `npx mindswap gen --agents`.' },
{ label: 'Windsurf', file: path.join(projectRoot, '.windsurfrules'), fix: 'Run `npx mindswap gen --windsurf`.' },
{ label: 'Cline', file: path.join(projectRoot, '.cline', 'mindswap-context.md'), fix: 'Run `npx mindswap gen --cline`.' },
{ label: 'Roo Code', file: path.join(projectRoot, '.roo', 'rules', 'mindswap-context.md'), fix: 'Run `npx mindswap gen --roo`.' },
];
for (const expected of expectations) {
if (!detected.includes(expected.label)) continue;
if (fs.existsSync(expected.file)) {
checks.push({ level: 'ok', message: `${expected.label} context file exists` });
} else {
checks.push({ level: 'warning', message: `${expected.label} context file is missing`, fix: expected.fix });
}
}
return checks;
}
function isStale(targetFile, sourceFiles) {
if (!fs.existsSync(targetFile) || sourceFiles.length === 0) return false;
const targetMtime = fs.statSync(targetFile).mtimeMs;
const newestSource = Math.max(...sourceFiles.map(file => fs.statSync(file).mtimeMs));
return newestSource - targetMtime > 1000;
}
function expectsTestStatus(projectRoot) {
const pkg = readPackageJson(projectRoot);
if (pkg) {
const scripts = pkg.scripts || {};
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (scripts.test && !scripts.test.includes('no test specified')) return true;
if (deps.jest || deps.vitest || deps.mocha || deps.ava || deps['@playwright/test']) return true;
}
return fs.existsSync(path.join(projectRoot, 'go.mod')) ||
fs.existsSync(path.join(projectRoot, 'Cargo.toml')) ||
fs.existsSync(path.join(projectRoot, 'pytest.ini')) ||
fs.existsSync(path.join(projectRoot, 'pyproject.toml'));
}
function expectsBuildStatus(projectRoot) {
const pkg = readPackageJson(projectRoot);
if (pkg?.scripts?.build) return true;
return fs.existsSync(path.join(projectRoot, 'go.mod'));
}
function readPackageJson(projectRoot) {
try {
return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
} catch {
return null;
}
}
function addCheck(checks, level, message, fix = null) {
checks.push({ level, message, fix });
}
function finalizeReport(projectRoot, checks) {
const summary = {
ok: checks.filter(check => check.level === 'ok').length,
warnings: checks.filter(check => check.level === 'warning').length,
issues: checks.filter(check => check.level === 'issue').length,
};
let status = 'healthy';
if (summary.issues > 0) status = 'failing';
else if (summary.warnings > 0) status = 'warning';
return {
projectRoot,
status,
summary,
checks,
};
}
function printDoctorReport(report) {
console.log(chalk.bold('\n⚡ mindswap doctor\n'));
console.log(chalk.dim(' Status: ') + colorStatus(report.status));
console.log(chalk.dim(' Checks: ') + chalk.white(`${report.summary.ok} ok, ${report.summary.warnings} warnings, ${report.summary.issues} issues`));
printGroup(report.checks, 'issue', chalk.red, 'Issues');
printGroup(report.checks, 'warning', chalk.yellow, 'Warnings');
printGroup(report.checks, 'ok', chalk.green, 'OK');
console.log();
}
function printGroup(checks, level, color, label) {
const items = checks.filter(check => check.level === level);
if (items.length === 0) return;
console.log(chalk.bold(`\n${label}`));
for (const item of items) {
console.log(color(` • ${item.message}`));
if (item.fix) {
console.log(chalk.dim(` ${item.fix}`));
}
}
}
function colorStatus(status) {
if (status === 'healthy') return chalk.green(status);
if (status === 'warning') return chalk.yellow(status);
return chalk.red(status);
}
function timeAgo(date) {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
module.exports = {
doctor,
analyzeProjectHealth,
inspectPostCommitHook,
inspectAIContextFiles,
};
const fs = require('fs');
const path = require('path');
const { getAllChangedFiles, getDiffContent } = require('./git');
const REJECT_PATTERNS = [
/not\s+using\s+([a-z0-9@._-]+)/i,
/don'?t\s+use\s+([a-z0-9@._-]+)/i,
/avoid\s+([a-z0-9@._-]+)/i,
/removed?\s+([a-z0-9@._-]+)/i,
/rejected?\s+([a-z0-9@._-]+)/i,
/chose\s+[a-z0-9@._-]+\s+over\s+([a-z0-9@._-]+)/i,
/use\s+[a-z0-9@._-]+\s+instead\s+of\s+([a-z0-9@._-]+)/i,
];
const TERM_ALIASES = {
session: ['session', 'sessions', 'passport', 'auth-session'],
sessions: ['session', 'sessions', 'passport', 'auth-session'],
redis: ['redis', 'ioredis', 'bull', 'bullmq'],
postgres: ['postgres', 'postgresql', 'pg', '@neondatabase/serverless', '@planetscale/database'],
postgresql: ['postgres', 'postgresql', 'pg', '@neondatabase/serverless'],
mysql: ['mysql', 'mysql2'],
sqlite: ['sqlite', 'better-sqlite3', 'sql.js'],
stripe: ['stripe', 'billing', 'payment', 'invoice', 'subscription'],
payment: ['stripe', 'billing', 'payment', 'invoice', 'subscription'],
payments: ['stripe', 'billing', 'payment', 'invoice', 'subscription'],
auth: ['auth', 'authentication', 'login', 'jwt', 'token'],
};
function analyzeGuardrails(projectRoot, opts = {}) {
const changedFiles = opts.changedFiles || getAllChangedFiles(projectRoot);
const diffContent = opts.diffContent || getDiffContent(projectRoot, 200);
const decisions = readDecisionLines(projectRoot);
const surface = buildSurfaceTokens(changedFiles, diffContent);
const warnings = [];
const seen = new Set();
for (const line of decisions) {
const rejectedTerms = extractRejectedTerms(line);
for (const term of rejectedTerms) {
if (!surfaceMatches(term, surface)) continue;
const key = `${line}::${term}`;
if (seen.has(key)) continue;
seen.add(key);
warnings.push({
type: 'architectural_drift',
reason: `Diff appears to touch "${term}" after a decision rejected it`,
decision: line,
evidence: sampleEvidence(surface, term),
});
}
}
return {
warnings,
surface,
decisionLines: decisions,
};
}
function buildGuardrailSection(guardrails = {}) {
const warnings = guardrails.warnings || [];
if (warnings.length === 0) return '';
const lines = [];
lines.push('## Guardrails');
lines.push(`- **Status**: ${warnings.length} drift signal${warnings.length === 1 ? '' : 's'} detected`);
for (const warning of warnings.slice(0, 5)) {
lines.push(`- **Warning**: ${warning.reason}`);
if (warning.decision) lines.push(` - Decision: ${stripDecisionPrefix(warning.decision)}`);
if (warning.evidence) lines.push(` - Evidence: ${warning.evidence}`);
}
return lines.join('\n');
}
function extractRejectedTerms(line) {
const terms = new Set();
const text = String(line || '');
for (const pattern of REJECT_PATTERNS) {
const match = text.match(pattern);
if (match && match[1]) {
addTermVariants(terms, match[1]);
}
}
return [...terms];
}
function buildSurfaceTokens(changedFiles, diffContent) {
const tokens = new Set();
for (const file of changedFiles || []) {
addTokenizedPath(tokens, file.file || file);
addTokenizedText(tokens, file.status || '');
}
addTokenizedText(tokens, diffContent || '');
return tokens;
}
function addTokenizedPath(tokens, value) {
const base = path.basename(String(value || ''));
addTokenizedText(tokens, base);
addTokenizedText(tokens, String(value || ''));
}
function addTokenizedText(tokens, value) {
for (const token of String(value || '')
.toLowerCase()
.split(/[^a-z0-9@]+/)
.map(token => token.trim())
.filter(Boolean)) {
tokens.add(token);
}
}
function surfaceMatches(term, surfaceTokens) {
const variants = new Set();
addTermVariants(variants, term);
for (const variant of variants) {
if (surfaceTokens.has(variant)) return true;
}
return false;
}
function addTermVariants(set, term) {
const normalized = String(term || '').toLowerCase().replace(/[^a-z0-9@._-]/g, '');
if (!normalized) return;
set.add(normalized);
if (normalized.endsWith('s')) set.add(normalized.slice(0, -1));
if (TERM_ALIASES[normalized]) {
for (const alias of TERM_ALIASES[normalized]) set.add(alias);
}
}
function sampleEvidence(surfaceTokens, term) {
const variants = new Set();
addTermVariants(variants, term);
for (const token of surfaceTokens) {
if (variants.has(token)) return token;
}
return [...variants][0] || '';
}
function readDecisionLines(projectRoot) {
const decisionsPath = path.join(projectRoot, '.mindswap', 'decisions.log');
if (!fs.existsSync(decisionsPath)) return [];
return fs.readFileSync(decisionsPath, 'utf-8')
.split('\n')
.filter(line => line.startsWith('['));
}
function stripDecisionPrefix(line) {
return String(line || '').replace(/^\[.*?\]\s*\[.*?\]\s*/, '').trim();
}
module.exports = {
analyzeGuardrails,
buildGuardrailSection,
extractRejectedTerms,
buildSurfaceTokens,
surfaceMatches,
};
const fs = require('fs');
const path = require('path');
const { getDataDir } = require('./state');
const MEMORY_FILE = 'memory.json';
const MEMORY_TYPES = new Set(['decision', 'blocker', 'assumption', 'question', 'resolution']);
function getMemoryPath(projectRoot) {
return path.join(getDataDir(projectRoot), MEMORY_FILE);
}
function getDefaultMemory() {
return {
version: '1.0.0',
items: [],
};
}
function ensureMemory(projectRoot) {
const memoryPath = getMemoryPath(projectRoot);
if (!fs.existsSync(memoryPath)) {
fs.writeFileSync(memoryPath, JSON.stringify(getDefaultMemory(), null, 2), 'utf-8');
}
return memoryPath;
}
function readMemory(projectRoot) {
const memoryPath = getMemoryPath(projectRoot);
if (!fs.existsSync(memoryPath)) return getDefaultMemory();
try {
const data = JSON.parse(fs.readFileSync(memoryPath, 'utf-8'));
return {
version: data.version || '1.0.0',
items: Array.isArray(data.items) ? data.items : [],
};
} catch {
return getDefaultMemory();
}
}
function writeMemory(projectRoot, memory) {
const memoryPath = ensureMemory(projectRoot);
fs.writeFileSync(memoryPath, JSON.stringify(memory, null, 2), 'utf-8');
}
function appendMemoryItem(projectRoot, item) {
const memory = readMemory(projectRoot);
const normalizedType = normalizeType(item.type);
const now = item.created_at || new Date().toISOString();
const status = item.status || (normalizedType === 'resolution' ? 'resolved' : 'open');
const entry = {
id: item.id || generateId(),
type: normalizedType,
tag: item.tag || 'general',
message: item.message,
status,
created_at: now,
resolved_at: item.resolved_at || (status === 'resolved' ? now : null),
source: item.source || 'cli',
metadata: item.metadata || {},
};
memory.items.push(entry);
writeMemory(projectRoot, memory);
return entry;
}
function getMemoryItems(projectRoot, opts = {}) {
const memory = readMemory(projectRoot);
let items = memory.items.slice();
if (opts.type) {
const types = Array.isArray(opts.type) ? opts.type : [opts.type];
items = items.filter(item => types.includes(item.type));
}
if (opts.status) {
items = items.filter(item => item.status === opts.status);
}
if (opts.limit) {
items = items.slice(-opts.limit);
}
return items;
}
function getOpenMemoryItems(projectRoot, type, limit = 10) {
return getMemoryItems(projectRoot, { type, status: 'open', limit });
}
function getRecentMemoryItems(projectRoot, limit = 20) {
return getMemoryItems(projectRoot, { limit });
}
function normalizeType(type) {
return MEMORY_TYPES.has(type) ? type : 'decision';
}
function generateId() {
return Math.random().toString(36).slice(2, 10);
}
module.exports = {
MEMORY_FILE,
MEMORY_TYPES,
getMemoryPath,
getDefaultMemory,
ensureMemory,
readMemory,
writeMemory,
appendMemoryItem,
getMemoryItems,
getOpenMemoryItems,
getRecentMemoryItems,
normalizeType,
};
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const { readState, getDataDir, getHistory } = require('./state');
const { isGitRepo, getCurrentBranch, getAllChangedFiles, getRecentCommits } = require('./git');
const { findAllConflicts, checkDepsVsDecisions } = require('./conflicts');
const { calculateQualityScore } = require('./narrative');
const { getOpenMemoryItems, getRecentMemoryItems } = require('./memory');
const { parseNativeSessions } = require('./session-parser');
async function resume(projectRoot, opts = {}) {
const dataDir = getDataDir(projectRoot);
if (!fs.existsSync(dataDir)) {
console.log(chalk.yellow('\nmindswap not initialized. Run: npx mindswap init\n'));
return;
}
const state = readState(projectRoot);
const live = gatherResumeData(projectRoot);
const briefing = buildResumeBriefing(state, live, opts);
if (opts.json) {
console.log(JSON.stringify(briefing, null, 2));
return;
}
console.log(chalk.bold('\n⚡ Resume Briefing\n'));
console.log(chalk.white(briefing.summary));
console.log();
console.log(chalk.bold(' State'));
for (const line of briefing.stateLines) {
console.log(chalk.dim(' ') + line);
}
console.log();
console.log(chalk.bold(' Recommendation'));
console.log(chalk.cyan(` ${briefing.recommendation.summary}`));
for (const step of briefing.recommendation.next_steps) {
console.log(chalk.dim(' • ') + chalk.white(step));
}
if (briefing.recommendation.command) {
console.log(chalk.dim(' Next command: ') + chalk.white(briefing.recommendation.command));
}
console.log();
}
function gatherResumeData(projectRoot) {
const branch = isGitRepo(projectRoot) ? getCurrentBranch(projectRoot) : null;
const changedFiles = isGitRepo(projectRoot) ? getAllChangedFiles(projectRoot) : [];
const recentCommits = isGitRepo(projectRoot) ? getRecentCommits(projectRoot, 5) : [];
const history = getHistory(projectRoot, 10);
const nativeSessions = parseNativeSessions(projectRoot);
const decisions = readDecisions(projectRoot);
const structuredMemory = getRecentMemoryItems(projectRoot, 20);
const blockers = getOpenMemoryItems(projectRoot, 'blocker', 5);
const questions = getOpenMemoryItems(projectRoot, 'question', 5);
const conflicts = findAllConflicts(projectRoot);
const depConflicts = checkDepsVsDecisions(projectRoot);
return {
branch,
changedFiles,
recentCommits,
history,
nativeSessions,
decisions,
structuredMemory,
blockers,
questions,
conflicts,
depConflicts,
};
}
function buildResumeBriefing(state, live, opts = {}) {
const proj = state.project || {};
const task = state.current_task || {};
const quality = calculateQualityScore(state, {
branch: live.branch,
changedFiles: live.changedFiles,
recentCommits: live.recentCommits,
decisions: live.decisions,
history: live.history,
});
const stateLines = [];
stateLines.push(`Project: ${proj.name || 'unknown'}`);
stateLines.push(`Branch: ${live.branch || 'unknown'}`);
stateLines.push(`Task: ${task.description || 'no active task'} [${task.status || 'unknown'}]`);
if (task.blocker) stateLines.push(`Blocker: ${task.blocker}`);
if (task.next_steps?.length) stateLines.push(`Next steps: ${task.next_steps.join(', ')}`);
if (state.test_status) {
const ts = state.test_status;
let detail = ts.status;
if (ts.passed != null) detail = `${ts.passed} passed, ${ts.failed || 0} failed`;
stateLines.push(`Tests: ${detail}`);
}
if (state.build_status) {
stateLines.push(`Build: ${state.build_status.status}`);
}
if (live.changedFiles.length > 0) {
stateLines.push(`Uncommitted changes: ${live.changedFiles.length} file(s)`);
}
if (live.recentCommits.length > 0) {
stateLines.push(`Recent commit: ${live.recentCommits[0].message}`);
}
if (live.blockers.length > 0) {
stateLines.push(`Open blocker: ${live.blockers[0].message}`);
}
if (live.questions.length > 0) {
stateLines.push(`Open question: ${live.questions[0].message}`);
}
if (live.nativeSessions.length > 0) {
stateLines.push(`Native sessions: ${live.nativeSessions.length} relevant session(s)`);
}
if (live.conflicts.length + live.depConflicts.length > 0) {
stateLines.push(`Conflicts detected: ${live.conflicts.length + live.depConflicts.length}`);
}
const recommendation = recommendNextAction(state, live, quality);
return {
summary: opts.compact
? `${proj.name || 'project'} · ${recommendation.summary}`
: 'Resume from the current branch with the next best action, not a raw state dump.',
state: {
project: proj,
task,
branch: live.branch,
recent_commits: live.recentCommits.slice(0, 5),
changed_files: live.changedFiles,
tests: state.test_status || null,
build: state.build_status || null,
quality,
native_sessions: live.nativeSessions,
blockers: live.blockers,
questions: live.questions,
conflicts: live.conflicts,
dep_conflicts: live.depConflicts,
},
stateLines,
recommendation,
};
}
function recommendNextAction(state, live, quality) {
const task = state.current_task || {};
if (task.blocker) {
return {
summary: `Resolve the active blocker first: ${task.blocker}`,
next_steps: [
'Confirm the blocker is still current.',
'Unblock the dependency or decision before making broader changes.',
],
command: 'npx mindswap status',
};
}
if (state.test_status?.status === 'fail' || (state.test_status && state.test_status.failed > 0)) {
return {
summary: 'Fix failing tests before continuing feature work.',
next_steps: [
'Open the failing test output and identify the first regression.',
'Run the smallest targeted fix, then re-run tests.',
],
command: 'npm test',
};
}
if (live.conflicts.length + live.depConflicts.length > 0) {
return {
summary: 'Resolve continuity conflicts before extending the feature.',
next_steps: [
'Review the detected decision or dependency conflicts.',
'Update the project memory so the next session does not repeat the mismatch.',
],
command: 'npx mindswap doctor',
};
}
if (live.changedFiles.length > 0) {
return {
summary: 'Review uncommitted changes and regenerate context if needed.',
next_steps: [
'Inspect the current diff and ensure the handoff reflects the latest edits.',
'Save the checkpoint once the state is coherent.',
],
command: 'npx mindswap',
};
}
if (task.description && task.status !== 'idle') {
return {
summary: `Continue the active task: ${task.description}`,
next_steps: task.next_steps?.length ? task.next_steps : ['Pick the next concrete implementation step.'],
command: 'npx mindswap status',
};
}
if (quality.score < 75 && quality.missing?.length > 0) {
return {
summary: `Improve handoff quality: ${quality.missing[0]}`,
next_steps: [
'Capture the missing context before switching tools again.',
'Run a fresh save to regenerate the handoff files.',
],
command: 'npx mindswap doctor',
};
}
return {
summary: 'No active blocker detected. Re-open the current task or choose the next feature to continue.',
next_steps: [
'Check the current task state.',
'If the feature is complete, mark it done and start the next one.',
],
command: 'npx mindswap status',
};
}
function readDecisions(projectRoot) {
const decisionsPath = path.join(projectRoot, '.mindswap', 'decisions.log');
if (!fs.existsSync(decisionsPath)) return [];
return fs.readFileSync(decisionsPath, 'utf-8')
.split('\n')
.filter(line => line.startsWith('['));
}
module.exports = {
resume,
gatherResumeData,
buildResumeBriefing,
recommendNextAction,
};
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const { getDataDir, readState, updateState, getHistory, addToHistory } = require('./state');
const { isGitRepo, getCurrentBranch } = require('./git');
const { readMemory, writeMemory, getDefaultMemory } = require('./memory');
const { annotateHistoryEntry } = require('./team');
async function sync(projectRoot, opts = {}) {
const dataDir = getDataDir(projectRoot);
if (!fs.existsSync(dataDir)) {
console.log(chalk.yellow('\nmindswap not initialized. Run: npx mindswap init\n'));
return;
}
const hubPath = getSyncHubPath(projectRoot, opts);
const mode = opts.pull ? 'pull' : opts.push ? 'push' : 'status';
let local = buildLocalSnapshot(projectRoot);
let hub = readHubSnapshot(hubPath);
let report = buildSyncReport({ local, hub, hubPath, mode });
if (mode === 'status') {
if (opts.json) {
console.log(JSON.stringify(report, null, 2));
return;
}
printSyncReport(report);
return;
}
if (mode === 'push') {
if (report.conflict && !opts.force) {
printSyncReport(report);
process.exitCode = 1;
return;
}
writeHubSnapshot(hubPath, local);
addToHistory(projectRoot, {
timestamp: new Date().toISOString(),
message: `synced local state to hub (${path.basename(hubPath)})`,
type: 'sync_push',
ai_tool: 'mindswap',
});
local = buildLocalSnapshot(projectRoot);
hub = readHubSnapshot(hubPath);
report = buildSyncReport({ local, hub, hubPath, mode });
report.message = 'Local state has been pushed to the shared hub.';
report.status = 'in-sync';
report.conflict = false;
report.diverged = false;
console.log(chalk.bold('\n⚡ Sync\n'));
console.log(chalk.green(` Pushed to ${hubPath}`));
console.log(chalk.dim(` Status: ${report.status}`));
console.log();
if (opts.json) console.log(JSON.stringify(report, null, 2));
return;
}
if (mode === 'pull') {
if (!hub) {
console.log(chalk.yellow(`\nNo sync hub found at ${hubPath}\n`));
process.exitCode = 1;
return;
}
if (report.conflict && !opts.force) {
printSyncReport(report);
process.exitCode = 1;
return;
}
applyHubSnapshot(projectRoot, hub);
addToHistory(projectRoot, {
timestamp: new Date().toISOString(),
message: `pulled shared state from hub (${path.basename(hubPath)})`,
type: 'sync_pull',
ai_tool: 'mindswap',
});
local = buildLocalSnapshot(projectRoot);
hub = readHubSnapshot(hubPath);
report = buildSyncReport({ local, hub, hubPath, mode });
report.message = 'Shared hub state has been pulled into the local project.';
report.status = 'in-sync';
report.conflict = false;
report.diverged = false;
console.log(chalk.bold('\n⚡ Sync\n'));
console.log(chalk.green(` Pulled from ${hubPath}`));
console.log(chalk.dim(` Status: ${report.status}`));
console.log();
if (opts.json) console.log(JSON.stringify(report, null, 2));
}
}
function buildLocalSnapshot(projectRoot) {
const state = readState(projectRoot);
return {
version: '1.0.0',
updated_at: state.last_checkpoint?.timestamp || new Date().toISOString(),
branch: isGitRepo(projectRoot) ? getCurrentBranch(projectRoot) : null,
state,
history: getHistory(projectRoot, 20),
memory: readMemory(projectRoot),
};
}
function buildSyncReport({ local, hub, hubPath, mode }) {
const localTime = timestampValue(local?.updated_at);
const hubTime = timestampValue(hub?.updated_at);
const status = !hub
? 'no-hub'
: localTime === hubTime
? 'in-sync'
: localTime > hubTime
? 'local-ahead'
: 'hub-ahead';
const diverged = Boolean(hub && localTime && hubTime && (localTime !== hubTime || !sameBranch(local, hub)));
const conflict = Boolean(
diverged && (
mode === 'status' ||
(mode === 'push' && status === 'hub-ahead') ||
(mode === 'pull' && status === 'local-ahead')
)
);
const report = {
mode,
hub_path: hubPath,
status,
conflict,
diverged,
local_updated_at: local?.updated_at || null,
hub_updated_at: hub?.updated_at || null,
local_branch: local?.branch || null,
hub_branch: hub?.branch || null,
local_history: local?.history?.length || 0,
hub_history: hub?.history?.length || 0,
memory_items: local?.memory?.items?.length || 0,
message: buildStatusMessage(status, conflict, hubPath),
};
return report;
}
function buildStatusMessage(status, conflict, hubPath) {
if (status === 'no-hub') return `No sync hub found at ${hubPath}`;
if (conflict) return 'Local and shared state are diverged. Resolve before pushing or pulling.';
if (status === 'in-sync') return 'Local state is in sync with the shared hub.';
if (status === 'local-ahead') return 'Local state is newer than the shared hub.';
if (status === 'hub-ahead') return 'Shared hub state is newer than local state.';
return 'Sync status unknown.';
}
function printSyncReport(report) {
console.log(chalk.bold('\n⚡ Sync Status\n'));
console.log(chalk.white(` ${report.message}`));
console.log(chalk.dim(` Mode: ${report.mode}`));
console.log(chalk.dim(` Local: ${report.local_updated_at || 'none'}`));
console.log(chalk.dim(` Hub: ${report.hub_updated_at || 'none'}`));
if (report.diverged) {
console.log(chalk.dim(` Divergence: ${report.conflict ? 'blocking' : 'non-blocking'}`));
}
if (report.conflict) {
console.log(chalk.bold.yellow('\n ⚠ Conflict detected'));
console.log(chalk.yellow(' Resolve the divergence before pushing or pulling without --force.'));
}
console.log();
}
function getSyncHubPath(projectRoot, opts = {}) {
return path.resolve(opts.hub || process.env.MINDSWAP_SYNC_HUB || path.join(projectRoot, '.mindswap', 'sync-hub.json'));
}
function readHubSnapshot(hubPath) {
if (!fs.existsSync(hubPath)) return null;
try {
return JSON.parse(fs.readFileSync(hubPath, 'utf-8'));
} catch {
return null;
}
}
function writeHubSnapshot(hubPath, snapshot) {
fs.mkdirSync(path.dirname(hubPath), { recursive: true });
fs.writeFileSync(hubPath, JSON.stringify(snapshot, null, 2), 'utf-8');
}
function applyHubSnapshot(projectRoot, hub) {
if (!hub?.state) return;
updateState(projectRoot, hub.state);
if (hub.memory?.items) {
const memory = readMemory(projectRoot);
const existing = new Set((memory.items || []).map(item => item.id || `${item.type}:${item.message}`));
for (const item of hub.memory.items) {
const key = item.id || `${item.type}:${item.message}`;
if (existing.has(key)) continue;
memory.items.push(item);
}
writeMemory(projectRoot, memory);
}
if (Array.isArray(hub.history)) {
const existingHistory = new Set(getHistory(projectRoot, 200).map(historyEntryKey));
for (const entry of hub.history.slice(-20)) {
const key = historyEntryKey(annotateHistoryEntry(projectRoot, entry));
if (existingHistory.has(key)) continue;
addToHistory(projectRoot, entry);
existingHistory.add(key);
}
}
}
function sameBranch(local, hub) {
if (!local?.branch || !hub?.branch) return true;
return local.branch === hub.branch;
}
function timestampValue(value) {
const parsed = Date.parse(value || '');
return Number.isNaN(parsed) ? 0 : parsed;
}
function historyEntryKey(entry = {}) {
return JSON.stringify({
timestamp: entry.timestamp || null,
message: entry.message || '',
type: entry.type || '',
ai_tool: entry.ai_tool || '',
branch: entry.branch || '',
author: entry.author || '',
status: entry.status || '',
team_mode: entry.team_mode ?? null,
});
}
module.exports = {
sync,
getSyncHubPath,
buildLocalSnapshot,
buildSyncReport,
readHubSnapshot,
writeHubSnapshot,
applyHubSnapshot,
};
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function isTeamMode(projectRoot) {
return readTeamConfig(projectRoot).enabled || process.env.MINDSWAP_TEAM === '1';
}
function readTeamConfig(projectRoot) {
const configPath = path.join(projectRoot, '.mindswap', 'team.json');
try {
const data = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
return {
enabled: data.enabled !== false,
name: data.name || null,
shared_memory: data.shared_memory !== false,
};
} catch {
return { enabled: false, name: null, shared_memory: false };
}
}
function getAuthorIdentity(projectRoot) {
const name = gitConfig(projectRoot, 'user.name') || process.env.GIT_AUTHOR_NAME || process.env.USER || 'unknown';
const email = gitConfig(projectRoot, 'user.email') || process.env.GIT_AUTHOR_EMAIL || null;
return email ? `${name} <${email}>` : name;
}
function annotateHistoryEntry(projectRoot, entry) {
const teamMode = isTeamMode(projectRoot);
return {
...entry,
author: entry.author || getAuthorIdentity(projectRoot),
team_mode: entry.team_mode ?? teamMode,
};
}
function formatTeamHistory(history = []) {
if (history.length === 0) return 'No recent team handoffs recorded.';
return history
.slice(-5)
.map(entry => {
const author = entry.author ? ` — ${entry.author}` : '';
const mode = entry.team_mode ? ' [shared]' : '';
return `- **${entry.timestamp || 'unknown'}**${author}${mode}: ${entry.message || 'updated context'}`;
})
.join('\n');
}
function teamSection(projectRoot, history = []) {
if (!isTeamMode(projectRoot)) return '';
const config = readTeamConfig(projectRoot);
const sharedMemory = config.shared_memory || process.env.MINDSWAP_TEAM === '1';
const lines = [];
lines.push('## Team mode');
lines.push(`- **Enabled**: yes`);
if (config.name) lines.push(`- **Workspace**: ${config.name}`);
lines.push(`- **Author**: ${getAuthorIdentity(projectRoot)}`);
lines.push(`- **Shared memory**: ${sharedMemory ? 'on' : 'off'}`);
lines.push('');
lines.push('## Team history');
lines.push(formatTeamHistory(history));
return lines.join('\n');
}
function gitConfig(projectRoot, key) {
try {
return execSync(`git config --get ${key}`, {
cwd: projectRoot,
encoding: 'utf-8',
stdio: 'pipe',
}).trim() || null;
} catch {
return null;
}
}
module.exports = {
isTeamMode,
readTeamConfig,
getAuthorIdentity,
annotateHistoryEntry,
formatTeamHistory,
teamSection,
};
+89
-1

@@ -16,5 +16,10 @@ #!/usr/bin/env node

const { summary } = require('../src/summary');
const { resume } = require('../src/resume');
const { ask } = require('../src/ask');
const { contracts } = require('../src/contracts');
const { sync } = require('../src/sync');
const { save } = require('../src/save');
const { pr } = require('../src/pr');
const { startMCPServer } = require('../src/mcp-server');
const { doctor } = require('../src/doctor');

@@ -91,4 +96,5 @@ const program = new Command();

.alias('l')
.description('Log a decision. Warns if it conflicts with existing decisions.')
.description('Log a memory item. Decisions warn on conflicts; blockers/questions/assumptions/resolutions are stored in structured memory.')
.option('--tag <tag>', 'Tag (e.g., architecture, database, auth)')
.option('--type <type>', 'Memory type: decision, blocker, assumption, question, resolution')
.action(async (message, opts) => {

@@ -119,2 +125,16 @@ try {

// ─── doctor ───
program
.command('doctor')
.description('Diagnose setup, context freshness, hooks, and continuity health.')
.option('--json', 'Output as JSON')
.action(async (opts) => {
try {
await doctor(process.cwd(), opts);
} catch (err) {
console.error(chalk.red('Error:'), err.message);
process.exit(1);
}
});
// ─── generate ───

@@ -183,2 +203,7 @@ program

.option('-i, --interval <ms>', 'Debounce interval in ms', '2000')
.option('--all', 'Refresh all generated context files on each change')
.option('--save', 'Run a full save cycle on each change')
.option('--tool <tool>', 'Associate watcher lifecycle with a specific AI tool')
.option('-m, --message <msg>', 'Session note for automatic watcher start/end saves')
.option('--no-hooks', 'Skip automatic session start/end hooks')
.action(async (opts) => {

@@ -199,2 +224,4 @@ try {

.option('-m, --message <msg>', 'Checkpoint message')
.option('--from <tool>', 'Override the current tool for the session-end hook')
.option('--no-hooks', 'Skip automatic session start/end hooks')
.option('--no-open', 'Don\'t try to open the tool')

@@ -241,2 +268,63 @@ .action(async (tool, opts) => {

// ─── ask ───
program
.command('ask <question...>')
.description('Answer a question from project memory using semantic search and cited sources.')
.option('--json', 'Output as JSON')
.action(async (question, opts) => {
try {
await ask(process.cwd(), question, opts);
} catch (err) {
console.error(chalk.red('Error:'), err.message);
process.exit(1);
}
});
// ─── contracts ───
program
.command('contracts')
.description('Emit machine-readable interface contracts for the current workstream.')
.option('--json', 'Output as JSON')
.action(async (opts) => {
try {
await contracts(process.cwd(), opts);
} catch (err) {
console.error(chalk.red('Error:'), err.message);
process.exit(1);
}
});
// ─── sync ───
program
.command('sync')
.description('Push or pull shared hub state. Without flags, prints sync status.')
.option('--push', 'Push local state to the shared hub')
.option('--pull', 'Pull shared state from the hub')
.option('--force', 'Override sync conflicts')
.option('--hub <path>', 'Path to the sync hub JSON file')
.option('--json', 'Output as JSON')
.action(async (opts) => {
try {
await sync(process.cwd(), opts);
} catch (err) {
console.error(chalk.red('Error:'), err.message);
process.exit(1);
}
});
// ─── resume ───
program
.command('resume')
.description('Action-oriented briefing — state, blockers, and the next best move.')
.option('--compact', 'Short, recommendation-first briefing')
.option('--json', 'Output as JSON')
.action(async (opts) => {
try {
await resume(process.cwd(), opts);
} catch (err) {
console.error(chalk.red('Error:'), err.message);
process.exit(1);
}
});
// ─── mcp ───

@@ -243,0 +331,0 @@ program

+2
-2
{
"name": "mindswap",
"version": "3.2.0",
"version": "3.2.1",
"description": "Your AI's black box recorder. Auto-track project state so any AI tool picks up where the last one stopped.",

@@ -56,3 +56,3 @@ "main": "src/index.js",

},
"homepage": "https://mindswap-ui.vercel.app"
"homepage": "https://mindswap.vercel.app"
}

@@ -17,3 +17,5 @@ # mindswap

npx mindswap init # once — auto-detects everything
npx mindswap ask "Why did we choose JWT?" # answer from project memory with cited matches
npx mindswap # save state when switching tools
npx mindswap doctor # diagnose setup, context freshness, and gaps
npx mindswap mcp-install # enable MCP for Claude Code / Cursor

@@ -67,2 +69,4 @@ ```

npx mindswap init # once per project
npx mindswap resume # get an action-oriented start-of-session briefing
npx mindswap doctor # sanity-check setup and context health
npx mindswap # when switching tools

@@ -74,3 +78,3 @@ npx mindswap done # when feature is complete

## 10 commands
## 15 commands

@@ -83,7 +87,12 @@ | Command | Alias | What it does |

| `mindswap done [msg]` | `d` | Mark task complete, archive to history, reset to idle |
| `mindswap log <msg>` | `l` | Log a decision. Warns if it conflicts with existing decisions |
| `mindswap log <msg>` | `l` | Log a memory item. Decisions warn on conflicts; use `--type` for blockers, assumptions, questions, and resolutions |
| `mindswap status` | `s` | Current state — task, branch, build/test, conflicts. `--stats` for charts |
| `mindswap doctor` | — | Diagnose setup, hook health, stale context files, conflicts, and missing continuity signals. `--json` for automation |
| `mindswap resume` | — | Action-oriented briefing — state, blockers, and the next best move. `--compact` / `--json` |
| `mindswap ask <question>` | — | Semantic question answering from project memory and history. `--json` for machine use |
| `mindswap contracts` | — | Emit machine-readable interface contracts for the current workstream |
| `mindswap sync` | — | Push, pull, or inspect shared hub state. `--push`, `--pull`, `--force`, `--hub` |
| `mindswap summary` | `sum` | Full session narrative — task, commits, decisions, conflicts. `--json` for scripts |
| `mindswap gen --all` | `gen` | Generate context files for all AI tools. Safe merge — never overwrites |
| `mindswap watch` | `w` | Background watcher — auto-updates HANDOFF.md on file changes |
| `mindswap watch` | `w` | Background watcher — auto-updates HANDOFF.md, or all context files with `--all`; `--save` runs a full save cycle |
| `mindswap reset` | `r` | Clear task state. Decisions preserved. `--full` to clear everything |

@@ -95,3 +104,3 @@

- **Task detection** — from branch name (`feat/user-auth` → "user auth") + recent commits
- **Dependency tracking** — added Stripe? Auto-logged. Removed Redis? Logged too.
- **Dependency tracking** — added Stripe? Auto-logged. Removed Redis? Logged too. Works across JS/TS, Python, Go, Rust, and Ruby manifests.
- **Git hooks** — auto-saves state on every commit

@@ -102,5 +111,43 @@

### Team mode
Set `MINDSWAP_TEAM=1` to make history author-aware and surface a team handoff section in generated context files.
### Native session normalization
mindswap reads recent Claude Code and Codex session files, normalizes them into a structured model, and surfaces the last session's findings, blockers, and edited files in `HANDOFF.md` and MCP context.
### Decision conflict detection
Log "NOT using Redis" then later "using Redis"? mindswap warns you. Also catches reversed choices and package.json contradictions.
### Architectural guardrails
When your diff touches code that contradicts a recorded decision, mindswap warns you in `save`, `doctor`, generated handoff files, and MCP context. It is a proactive drift check, not just a static conflict log.
### Interface contracts
`mindswap contracts` emits a machine-readable JSON contract for the active workstream, including boundaries, blockers, assumptions, and recent history so another agent can resume without re-reading the whole repo.
### Shared sync
`mindswap sync` can push or pull a local JSON hub file so teams can share continuity state across machines. Conflicts are explicit, and `doctor` surfaces the sync health.
### Structured memory
Not everything is a decision. mindswap now keeps structured memory for blockers, assumptions, open questions, and resolutions in `.mindswap/memory.json`, while keeping `decisions.log` for compatibility and conflict checks.
```bash
npx mindswap log "Need prod webhook secret" --type blocker
npx mindswap log "Assume single-region rollout for MVP" --type assumption
npx mindswap log "Should we rotate refresh tokens?" --type question
npx mindswap log "Moved to JWT after auth review" --type resolution
```
Generated context files surface unresolved blockers and questions separately so the next AI does not have to infer them from free-form notes.
### Continuity diagnostics
```bash
npx mindswap doctor
```
Checks whether mindswap is initialized correctly, whether generated handoff files are stale, whether git hooks are installed, whether AI-tool-specific context files are missing, and whether conflicts or weak continuity signals need attention.
### Semantic ask
```bash
npx mindswap ask "Why did we choose JWT?"
```
Answers natural-language questions against decisions, history, memory, and recent session context, then cites the strongest matching project records.
### Safe merge

@@ -116,2 +163,5 @@ Already have a CLAUDE.md? mindswap appends its section inside `<!-- mindswap:start/end -->` markers. Your content is never touched.

### Auto-sync
`mindswap switch` records session start/end hooks when configured, and `mindswap watch --save --all` can run a full save + context refresh loop for deeper IDE/tool integration.
### 30+ frameworks detected

@@ -126,3 +176,4 @@ Next.js, Remix, Astro, SolidJS, Angular, NestJS, Express, Fastify, Hono, Django, FastAPI, Flask, Gin, Echo, GoFr, Fiber, Actix, Axum, Rails, Spring Boot, and more. Plus databases, monorepo tools, CI/CD, and infrastructure.

├── state.json ← machine-readable state
├── decisions.log ← WHY you made each decision
├── decisions.log ← decision log (kept for compatibility + conflicts)
├── memory.json ← structured memory: blockers, assumptions, questions, resolutions
├── config.json ← your preferences

@@ -133,3 +184,3 @@ ├── branches/ ← per-branch state (auto)

**Commit these** (handoff context): `state.json`, `decisions.log`, `config.json`, `HANDOFF.md`
**Commit these** (handoff context): `state.json`, `decisions.log`, `memory.json`, `config.json`, `HANDOFF.md`

@@ -148,3 +199,3 @@ **Don't commit** (auto-added to .gitignore): `history/`, `branches/`

|----------|-----------------|-----------------|
| `mindswap_get_context` | Session start — "What do I need to know?" | Synthesized briefing: task, decisions, conflicts, tests, recent work |
| `mindswap_get_context` | Session start — "What do I need to know?" | Synthesized briefing: task, decisions, conflicts, tests, recent work, native session findings |
| `mindswap_save_context` | Session end — "Here's what I did" | Persists summary, decisions, next steps, blockers |

@@ -151,0 +202,0 @@ | `mindswap_search` | Mid-session — "What did we decide about auth?" | Searches decisions + history + state |

@@ -6,2 +6,3 @@ const chalk = require('chalk');

const { runChecks, detectLastStatus } = require('./build-test');
const { appendMemoryItem } = require('./memory');

@@ -69,2 +70,12 @@ async function checkpoint(projectRoot, message, opts = {}) {

if (opts.blocker) {
appendMemoryItem(projectRoot, {
type: 'blocker',
tag: 'checkpoint',
message: opts.blocker,
created_at: now,
source: 'checkpoint',
});
}
// Save to history

@@ -71,0 +82,0 @@ const historyEntry = {

@@ -6,2 +6,3 @@ const fs = require('fs');

const { checkConflicts } = require('./conflicts');
const { appendMemoryItem, normalizeType } = require('./memory');

@@ -21,6 +22,16 @@ async function log(projectRoot, message, opts = {}) {

const tag = opts.tag || 'general';
const type = normalizeType(opts.type || 'decision');
const entry = `[${timestamp}] [${tag}] ${message}`;
if (type === 'decision') {
const entry = `[${timestamp}] [${tag}] ${message}`;
fs.appendFileSync(decisionsPath, entry + '\n', 'utf-8');
}
fs.appendFileSync(decisionsPath, entry + '\n', 'utf-8');
const memoryItem = appendMemoryItem(projectRoot, {
type,
tag,
message,
created_at: timestamp,
source: 'cli',
});

@@ -34,8 +45,14 @@ // Auto-regenerate HANDOFF.md

console.log(chalk.bold('\n⚡ Decision logged\n'));
console.log(chalk.dim(' Type: ') + chalk.white(type));
console.log(chalk.dim(' Tag: ') + chalk.white(tag));
console.log(chalk.dim(' Message: ') + chalk.white(message));
console.log(chalk.dim(' File: ') + chalk.green('.mindswap/decisions.log'));
console.log(chalk.dim(' Memory: ') + chalk.green('.mindswap/memory.json'));
if (type === 'decision') {
console.log(chalk.dim(' File: ') + chalk.green('.mindswap/decisions.log'));
} else {
console.log(chalk.dim(' Status: ') + chalk.white(memoryItem.status));
}
// Warn about conflicts
if (conflicts.length > 0) {
if (type === 'decision' && conflicts.length > 0) {
console.log(chalk.bold.yellow('\n ⚠ Potential conflicts:'));

@@ -42,0 +59,0 @@ for (const c of conflicts) {

@@ -16,2 +16,3 @@ const fs = require('fs');

{ path: '.codex', name: 'Codex' },
{ path: 'CODEX.md', name: 'Codex' },
{ path: '.windsurf', name: 'Windsurf' },

@@ -44,2 +45,3 @@ { path: '.cline', name: 'Cline' },

'.github/copilot-instructions.md': fs.existsSync(path.join(projectRoot, '.github', 'copilot-instructions.md')),
'CODEX.md': fs.existsSync(path.join(projectRoot, 'CODEX.md')),
'.windsurf/rules': fs.existsSync(path.join(projectRoot, '.windsurf', 'rules')),

@@ -46,0 +48,0 @@ '.cline/rules': fs.existsSync(path.join(projectRoot, '.cline', 'rules')),

@@ -9,2 +9,6 @@ const fs = require('fs');

const { detectMonorepo, getMonorepoSection, detectChangedPackages } = require('./monorepo');
const { teamSection } = require('./team');
const { getOpenMemoryItems, getMemoryItems } = require('./memory');
const { parseNativeSessions, getSessionSummary } = require('./session-parser');
const { analyzeGuardrails, buildGuardrailSection } = require('./guardrails');

@@ -179,3 +183,9 @@ const SECTION_START = '<!-- mindswap:start -->';

}
data.structuredMemory = getStructuredMemory(projectRoot);
data.history = getHistory(projectRoot, 5);
data.nativeSessions = parseNativeSessions(projectRoot);
data.guardrails = analyzeGuardrails(projectRoot, {
changedFiles: data.changedFiles,
diffContent: data.diff,
});
return data;

@@ -256,9 +266,35 @@ }

const memoryLines = formatStructuredMemoryLines(live.structuredMemory);
if (memoryLines.length > 0) {
md += `\n## Structured memory\n`;
for (const line of memoryLines) {
md += `${line}\n`;
}
}
if (live.history.length > 0) {
md += `\n## Session history (recent)\n`;
for (const h of live.history) {
md += `- **${h.timestamp}**: ${h.message}${h.ai_tool ? ` (${h.ai_tool})` : ''}\n`;
const author = h.author ? ` — ${h.author}` : '';
md += `- **${h.timestamp}**${author}: ${h.message}${h.ai_tool ? ` (${h.ai_tool})` : ''}\n`;
}
}
const teamInfo = teamSection(projectRoot, live.history);
if (teamInfo) {
md += `\n${teamInfo}\n`;
}
if (live.nativeSessions?.length > 0) {
const sessionSummary = getSessionSummary(live.nativeSessions);
if (sessionSummary.trim()) {
md += `\n${sessionSummary}\n`;
}
}
const guardrailSection = buildGuardrailSection(live.guardrails);
if (guardrailSection) {
md += `\n${guardrailSection}\n`;
}
if (live.diffSummary && live.diffSummary !== 'No changes') {

@@ -307,2 +343,5 @@ md += `\n## Diff summary\n\`\`\`\n${live.diffSummary}\n\`\`\`\n`;

${live.decisions.length > 0 ? live.decisions.join('\n') : 'No decisions logged yet. Use `npx mindswap log "your decision"` to add them.'}
## Structured memory
${formatStructuredMemoryText(live.structuredMemory)}
`;

@@ -333,2 +372,7 @@ }

## Structured memory
${formatStructuredMemoryText(live.structuredMemory)}
${buildGuardrailSection(live.guardrails)}
## Recent changes

@@ -358,2 +402,5 @@ ${live.changedFiles.slice(0, 15).map(f => `${f.status}: ${f.file}`).join('\n') || 'No uncommitted changes.'}

${live.decisions.slice(-5).map(d => `# ${d}`).join('\n') || '# None logged.'}
# Structured memory:
${formatStructuredMemoryLines(live.structuredMemory).map(line => `# ${line.slice(2)}`).join('\n') || '# None logged.'}
`;

@@ -380,2 +427,5 @@ }

${live.decisions.slice(-5).join('\n') || 'None logged.'}
## Structured memory
${formatStructuredMemoryText(live.structuredMemory)}
`;

@@ -414,2 +464,7 @@ }

## Structured memory
${formatStructuredMemoryText(live.structuredMemory)}
${buildGuardrailSection(live.guardrails)}
## Recent changes

@@ -420,2 +475,25 @@ ${live.changedFiles.slice(0, 15).map(f => `${f.status}: ${f.file}`).join('\n') || 'No uncommitted changes.'}

function getStructuredMemory(projectRoot) {
return {
blockers: getOpenMemoryItems(projectRoot, 'blocker', 5),
assumptions: getOpenMemoryItems(projectRoot, 'assumption', 5),
questions: getOpenMemoryItems(projectRoot, 'question', 5),
resolutions: getMemoryItems(projectRoot, { type: 'resolution', limit: 5 }),
};
}
function formatStructuredMemoryLines(memory) {
const lines = [];
for (const item of memory.blockers || []) lines.push(`- BLOCKER: ${item.message}`);
for (const item of memory.questions || []) lines.push(`- QUESTION: ${item.message}`);
for (const item of memory.assumptions || []) lines.push(`- ASSUMPTION: ${item.message}`);
for (const item of memory.resolutions || []) lines.push(`- RESOLUTION: ${item.message}`);
return lines;
}
function formatStructuredMemoryText(memory) {
const lines = formatStructuredMemoryLines(memory);
return lines.join('\n') || 'No structured memory logged yet.';
}
function guessBuildCommands(proj) {

@@ -422,0 +500,0 @@ const pm = proj.package_manager || 'npm';

@@ -10,2 +10,6 @@ const { init } = require('./init');

const { summary } = require('./summary');
const { resume } = require('./resume');
const { ask } = require('./ask');
const { contracts } = require('./contracts');
const { sync } = require('./sync');
const { save } = require('./save');

@@ -23,3 +27,6 @@ const { readState, writeState, updateState, getHistory } = require('./state');

const { parseNativeSessions } = require('./session-parser');
const { analyzeGuardrails } = require('./guardrails');
const { pr } = require('./pr');
const { readMemory, appendMemoryItem, getMemoryItems } = require('./memory');
const { doctor } = require('./doctor');

@@ -38,2 +45,6 @@ module.exports = {

summary,
resume,
ask,
contracts,
sync,
readState,

@@ -61,3 +72,8 @@ writeState,

parseNativeSessions,
analyzeGuardrails,
pr,
readMemory,
appendMemoryItem,
getMemoryItems,
doctor,
};

@@ -8,2 +8,3 @@ const fs = require('fs');

const { detectMonorepo } = require('./monorepo');
const { ensureMemory, writeMemory, getDefaultMemory, appendMemoryItem, readMemory } = require('./memory');

@@ -74,4 +75,9 @@ async function init(projectRoot, opts = {}) {

ensureMemory(projectRoot);
writeMemory(projectRoot, getDefaultMemory());
console.log(chalk.dim(' Created: ') + chalk.green('.mindswap/memory.json'));
// 6. Import existing AI context files
const imported = importExistingContext(projectRoot, dataDir);
const importTracker = createImportTracker(projectRoot, dataDir);
const imported = importExistingContext(projectRoot, dataDir, importTracker);
if (imported > 0) {

@@ -90,8 +96,20 @@ console.log(chalk.dim(' Imported: ') + chalk.green(`${imported} decisions from existing AI context files`));

for (const d of session.decisions) {
fs.appendFileSync(decisionsPath, `[${timestamp}] [imported:${session.tool}] ${d}\n`);
sessionImported++;
const added = appendImportedDecision(projectRoot, decisionsPath, importTracker, {
type: 'decision',
tag: `imported:${session.tool}`,
message: d,
created_at: timestamp,
source: 'import',
});
if (added) sessionImported++;
}
for (const c of session.context) {
fs.appendFileSync(decisionsPath, `[${timestamp}] [context:${session.tool}] ${c}\n`);
sessionImported++;
const added = appendImportedMemory(projectRoot, importTracker, {
type: 'assumption',
tag: `context:${session.tool}`,
message: c,
created_at: timestamp,
source: 'import',
});
if (added) sessionImported++;
}

@@ -119,3 +137,3 @@ }

// 7. Install git hooks (optional)
if (!opts.noHooks && isGitRepo(projectRoot)) {
if (opts.hooks !== false && isGitRepo(projectRoot)) {
installGitHooks(projectRoot);

@@ -146,3 +164,3 @@ console.log(chalk.dim(' Installed: ') + chalk.green('git post-commit hook'));

*/
function importExistingContext(projectRoot, dataDir) {
function importExistingContext(projectRoot, dataDir, importTracker) {
const decisionsPath = path.join(dataDir, 'decisions.log');

@@ -172,4 +190,10 @@ let imported = 0;

for (const d of decisions) {
fs.appendFileSync(decisionsPath, `[${timestamp}] [imported:${file.source}] ${d}\n`);
imported++;
const added = appendImportedDecision(projectRoot, decisionsPath, importTracker, {
type: 'decision',
tag: `imported:${file.source}`,
message: d,
created_at: timestamp,
source: 'import',
});
if (added) imported++;
}

@@ -183,4 +207,10 @@ }

for (const d of decisions) {
fs.appendFileSync(decisionsPath, `[${timestamp}] [imported:${file.source}] ${d}\n`);
imported++;
const added = appendImportedDecision(projectRoot, decisionsPath, importTracker, {
type: 'decision',
tag: `imported:${file.source}`,
message: d,
created_at: timestamp,
source: 'import',
});
if (added) imported++;
}

@@ -194,2 +224,49 @@ } catch {}

function createImportTracker(projectRoot, dataDir) {
const decisionKeys = new Set();
const memoryKeys = new Set();
const decisionsPath = path.join(dataDir, 'decisions.log');
if (fs.existsSync(decisionsPath)) {
const lines = fs.readFileSync(decisionsPath, 'utf-8').split('\n').filter(line => line.startsWith('['));
for (const line of lines) {
const match = line.match(/^\[[^\]]+\]\s+\[([^\]]+)\]\s+(.+)$/);
if (!match) continue;
decisionKeys.add(`${match[1]}::${match[2]}`);
}
}
const memory = readMemory(projectRoot);
for (const item of memory.items) {
memoryKeys.add(`${item.type}::${item.tag}::${item.message}`);
}
return { decisionKeys, memoryKeys };
}
function appendImportedDecision(projectRoot, decisionsPath, tracker, item) {
const decisionKey = `${item.tag}::${item.message}`;
const memoryKey = `${item.type}::${item.tag}::${item.message}`;
if (tracker.decisionKeys.has(decisionKey) || tracker.memoryKeys.has(memoryKey)) {
return false;
}
fs.appendFileSync(decisionsPath, `[${item.created_at}] [${item.tag}] ${item.message}\n`);
appendMemoryItem(projectRoot, item);
tracker.decisionKeys.add(decisionKey);
tracker.memoryKeys.add(memoryKey);
return true;
}
function appendImportedMemory(projectRoot, tracker, item) {
const memoryKey = `${item.type}::${item.tag}::${item.message}`;
if (tracker.memoryKeys.has(memoryKey)) {
return false;
}
appendMemoryItem(projectRoot, item);
tracker.memoryKeys.add(memoryKey);
return true;
}
/**

@@ -196,0 +273,0 @@ * Extract decision-like statements from markdown content.

@@ -5,2 +5,3 @@ const fs = require('fs');

const { readState, updateState, addToHistory, getDataDir } = require('./state');
const { ensureMemory, writeMemory, getDefaultMemory } = require('./memory');

@@ -99,2 +100,4 @@ async function done(projectRoot, message) {

);
ensureMemory(projectRoot);
writeMemory(projectRoot, getDefaultMemory());
}

@@ -101,0 +104,0 @@

@@ -13,2 +13,6 @@ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');

const { detectMonorepo, getMonorepoSection, detectChangedPackages } = require('./monorepo');
const { importSessions } = require('./session-import');
const { appendMemoryItem, getOpenMemoryItems, getRecentMemoryItems } = require('./memory');
const { parseNativeSessions, getSessionSummary } = require('./session-parser');
const { analyzeGuardrails, buildGuardrailSection } = require('./guardrails');

@@ -59,2 +63,8 @@ /**

.describe('Key decisions made during this session (e.g., "chose JWT over sessions for stateless API")'),
assumptions: z.array(z.string()).optional()
.describe('Assumptions made during this session that should carry forward'),
questions: z.array(z.string()).optional()
.describe('Open questions that remain unresolved'),
resolutions: z.array(z.string()).optional()
.describe('Resolved items or conclusions reached during this session'),
next_steps: z.array(z.string()).optional()

@@ -67,4 +77,4 @@ .describe('What should be done next'),

},
async ({ summary, decisions, next_steps, blocker, task_status }) => {
return saveContext(projectRoot, { summary, decisions, next_steps, blocker, task_status });
async ({ summary, decisions, assumptions, questions, resolutions, next_steps, blocker, task_status }) => {
return saveContext(projectRoot, { summary, decisions, assumptions, questions, resolutions, next_steps, blocker, task_status });
}

@@ -150,2 +160,12 @@ );

const memoryLines = formatMemorySection(projectRoot);
if (memoryLines.length > 0) {
sections.push(`## Structured Memory\n${memoryLines.join('\n')}`);
}
const guardrailSection = buildGuardrailSection(liveData.guardrails);
if (guardrailSection) {
sections.push(guardrailSection);
}
// Conflicts

@@ -177,2 +197,9 @@ const conflicts = findAllConflicts(projectRoot);

if (liveData.nativeSessions?.length > 0) {
const sessionSummary = getSessionSummary(liveData.nativeSessions);
if (sessionSummary.trim()) {
sections.push(sessionSummary.trim());
}
}
if (focus === 'all') {

@@ -208,3 +235,3 @@ // Project info

function saveContext(projectRoot, { summary, decisions, next_steps, blocker, task_status }) {
function saveContext(projectRoot, { summary, decisions, assumptions, questions, resolutions, next_steps, blocker, task_status }) {
const dataDir = getDataDir(projectRoot);

@@ -250,4 +277,31 @@ if (!fs.existsSync(dataDir)) {

fs.appendFileSync(decisionsPath, `[${now}] [ai-session] ${d}\n`);
appendMemoryItem(projectRoot, { type: 'decision', tag: 'ai-session', message: d, created_at: now, source: 'mcp' });
}
}
if (assumptions?.length > 0) {
for (const item of assumptions) {
appendMemoryItem(projectRoot, { type: 'assumption', tag: 'ai-session', message: item, created_at: now, source: 'mcp' });
}
}
if (questions?.length > 0) {
for (const item of questions) {
appendMemoryItem(projectRoot, { type: 'question', tag: 'ai-session', message: item, created_at: now, source: 'mcp' });
}
}
if (resolutions?.length > 0) {
for (const item of resolutions) {
appendMemoryItem(projectRoot, {
type: 'resolution',
tag: 'ai-session',
message: item,
created_at: now,
resolved_at: now,
status: 'resolved',
source: 'mcp',
});
}
}
if (blocker) {
appendMemoryItem(projectRoot, { type: 'blocker', tag: 'ai-session', message: blocker, created_at: now, source: 'mcp' });
}

@@ -260,2 +314,5 @@ // Save to history

decisions: decisions || [],
assumptions: assumptions || [],
questions: questions || [],
resolutions: resolutions || [],
next_steps: next_steps || [],

@@ -275,2 +332,5 @@ });

if (decisions?.length) saved.push(`${decisions.length} decisions`);
if (assumptions?.length) saved.push(`${assumptions.length} assumptions`);
if (questions?.length) saved.push(`${questions.length} questions`);
if (resolutions?.length) saved.push(`${resolutions.length} resolutions`);
if (next_steps?.length) saved.push('next steps');

@@ -296,4 +356,5 @@ if (blocker) saved.push('blocker');

const queryLower = query.toLowerCase();
const queryTokens = tokenize(query);
const results = [];
const seen = new Set();

@@ -309,5 +370,7 @@ // Search decisions

for (const line of lines) {
if (line.toLowerCase().includes(queryLower)) {
results.push({ type: 'decision', content: line });
}
addScoredResult(results, seen, {
type: 'decision',
content: line,
source: 'decisions.log',
}, queryTokens, 1.2);
}

@@ -321,9 +384,7 @@ }

for (const entry of history) {
const entryStr = JSON.stringify(entry).toLowerCase();
if (entryStr.includes(queryLower)) {
results.push({
type: 'history',
content: `[${entry.timestamp}] ${entry.message}${entry.ai_tool ? ` (${entry.ai_tool})` : ''}`,
});
}
addScoredResult(results, seen, {
type: 'history',
content: `[${entry.timestamp}] ${entry.message}${entry.ai_tool ? ` (${entry.ai_tool})` : ''}`,
source: 'history',
}, queryTokens, 1.0, JSON.stringify(entry));
}

@@ -334,14 +395,65 @@ }

if (type === 'all') {
const memoryItems = getRecentMemoryItems(projectRoot, 50);
for (const item of memoryItems) {
addScoredResult(results, seen, {
type: `memory:${item.type}`,
content: `${item.type}: ${item.message} [${item.status}]`,
source: 'memory',
}, queryTokens, item.status === 'open' ? 1.15 : 1.0, `${item.type} ${item.tag} ${item.status} ${item.message}`);
}
const state = readState(projectRoot);
const stateStr = JSON.stringify(state).toLowerCase();
if (stateStr.includes(queryLower)) {
if (state.current_task?.description?.toLowerCase().includes(queryLower)) {
results.push({ type: 'task', content: `Current task: ${state.current_task.description} [${state.current_task.status}]` });
}
if (state.project?.tech_stack?.some(t => t.toLowerCase().includes(queryLower))) {
results.push({ type: 'project', content: `Tech stack includes: ${state.project.tech_stack.join(', ')}` });
}
if (state.current_task?.description) {
addScoredResult(results, seen, {
type: 'task',
content: `Current task: ${state.current_task.description} [${state.current_task.status}]`,
source: 'state.current_task',
}, queryTokens, 1.35, state.current_task.description);
}
if (state.project?.tech_stack?.length) {
addScoredResult(results, seen, {
type: 'project',
content: `Tech stack includes: ${state.project.tech_stack.join(', ')}`,
source: 'state.project',
}, queryTokens, 0.9, state.project.tech_stack.join(' '));
}
if (state.current_task?.blocker) {
addScoredResult(results, seen, {
type: 'blocker',
content: `Current blocker: ${state.current_task.blocker}`,
source: 'state.current_task',
}, queryTokens, 1.15, state.current_task.blocker);
}
}
if (type === 'all') {
const nativeSessions = parseNativeSessions(projectRoot) || [];
for (const session of nativeSessions) {
const combined = [
session.summary || '',
session.blockers?.join(' '),
session.failures?.join(' '),
session.fileEdits?.join(' '),
session.toolCalls?.join(' '),
session.messages?.map(message => message.text).join(' '),
].filter(Boolean).join(' ');
addScoredResult(results, seen, {
type: 'native-session',
content: `${session.tool}${session.timestamp ? ` @ ${session.timestamp}` : ''}: ${session.summary || 'session context'}`,
source: session.tool,
}, queryTokens, 0.9, combined || session.rawText || session.tool);
}
const imported = importSessions(projectRoot) || [];
for (const session of imported) {
const sourceLabel = session.tool || 'session';
const combined = [...(session.decisions || []), ...(session.context || [])].join(' ');
addScoredResult(results, seen, {
type: 'imported',
content: `${sourceLabel}: ${(session.context || session.decisions || []).slice(0, 3).join(' | ')}`,
source: sourceLabel,
}, queryTokens, 0.8, combined);
}
}
if (results.length === 0) {

@@ -351,3 +463,3 @@ return {

type: 'text',
text: `No results for "${query}". Try broader terms or log more decisions with: npx mindswap log "your decision"`,
text: `No results for "${query}". Try broader terms, or log more context with: npx mindswap log "your decision"`,
}],

@@ -357,3 +469,6 @@ };

const formatted = results.slice(0, 15).map(r => `[${r.type}] ${r.content}`).join('\n');
const topResults = results
.sort((a, b) => b.score - a.score)
.slice(0, 15);
const formatted = topResults.map(r => `[${r.type}] (${Math.round(r.score)}) ${r.content}`).join('\n');
return {

@@ -378,2 +493,3 @@ content: [{

history: [],
nativeSessions: [],
};

@@ -394,6 +510,96 @@

data.structuredMemory = getRecentMemoryItems(projectRoot, 20);
data.history = getHistory(projectRoot, 5);
data.nativeSessions = parseNativeSessions(projectRoot);
data.guardrails = analyzeGuardrails(projectRoot, {
changedFiles: data.changedFiles,
diffContent: '',
});
return data;
}
module.exports = { startMCPServer };
function tokenize(query) {
const tokens = String(query || '')
.toLowerCase()
.split(/[^a-z0-9]+/)
.map(token => token.trim())
.filter(token => token.length > 1);
const expanded = new Set(tokens);
for (const token of tokens) {
for (const alias of QUERY_ALIASES[token] || []) {
expanded.add(alias);
}
}
return [...expanded];
}
function addScoredResult(results, seen, entry, queryTokens, weight, haystackText = '') {
const text = haystackText || entry.content || '';
const score = scoreText(text, queryTokens, weight);
if (score <= 0) return;
const key = `${entry.type}::${entry.content}`;
if (seen.has(key)) return;
seen.add(key);
results.push({ ...entry, score });
}
function scoreText(text, queryTokens, weight = 1) {
if (!text || queryTokens.length === 0) return 0;
const haystack = String(text).toLowerCase();
let score = 0;
for (const token of queryTokens) {
if (haystack.includes(token)) {
score += token.length >= 5 ? 4 : 2;
} else {
const fuzzy = findLooseMatch(token, haystack);
if (fuzzy) score += 1;
}
}
const coverage = score / Math.max(queryTokens.length * 4, 1);
return score * weight * (0.75 + coverage);
}
function findLooseMatch(token, haystack) {
if (token.length < 4) return false;
const variants = [
token.replace(/s$/, ''),
token.replace(/ing$/, ''),
token.replace(/ed$/, ''),
token.replace(/tion$/, 't'),
].filter(Boolean);
return variants.some(v => v !== token && v.length >= 3 && haystack.includes(v));
}
const QUERY_ALIASES = {
auth: ['authentication', 'login', 'session', 'jwt', 'token'],
authentication: ['auth', 'login', 'session', 'jwt', 'token'],
database: ['db', 'postgres', 'postgresql', 'mysql', 'sqlite', 'prisma', 'drizzle'],
db: ['database', 'postgres', 'postgresql', 'mysql', 'sqlite', 'prisma', 'drizzle'],
session: ['auth', 'login', 'jwt', 'token'],
sessions: ['auth', 'login', 'jwt', 'token'],
login: ['auth', 'authentication', 'session', 'jwt', 'token'],
api: ['route', 'endpoint', 'handler', 'controller'],
testing: ['test', 'tests', 'spec', 'jest', 'vitest', 'pytest'],
test: ['testing', 'tests', 'spec', 'jest', 'vitest', 'pytest'],
deployment: ['deploy', 'release', 'ci', 'cd', 'workflow'],
deploy: ['deployment', 'release', 'ci', 'cd', 'workflow'],
billing: ['payment', 'invoice', 'stripe', 'subscription'],
payment: ['billing', 'invoice', 'stripe', 'subscription'],
config: ['configuration', 'settings', 'env'],
ui: ['frontend', 'component', 'page', 'view'],
};
function formatMemorySection(projectRoot) {
const lines = [];
for (const item of getOpenMemoryItems(projectRoot, 'blocker', 5)) lines.push(`- BLOCKER: ${item.message}`);
for (const item of getOpenMemoryItems(projectRoot, 'question', 5)) lines.push(`- QUESTION: ${item.message}`);
for (const item of getOpenMemoryItems(projectRoot, 'assumption', 5)) lines.push(`- ASSUMPTION: ${item.message}`);
for (const item of getRecentMemoryItems(projectRoot, 10).filter(item => item.type === 'resolution').slice(-5)) {
lines.push(`- RESOLUTION: ${item.message}`);
}
return lines;
}
module.exports = { startMCPServer, searchContext, tokenize, scoreText, formatMemorySection };

@@ -51,2 +51,8 @@ const fs = require('fs');

// ─── Recent native AI session ───
const sessionBrief = describeSessionFindings(liveData.nativeSessions);
if (sessionBrief) {
parts.push(sessionBrief);
}
// ─── Test/build status ───

@@ -119,2 +125,7 @@ if (state.test_status) {

const sessionBrief = describeSessionFindings(liveData.nativeSessions);
if (sessionBrief) {
lines.push(`SESSION: ${sessionBrief}`);
}
// Decisions (stripped, semicolon-separated)

@@ -247,2 +258,18 @@ if (liveData.decisions?.length > 0) {

function describeSessionFindings(sessions) {
if (!sessions || sessions.length === 0) return null;
const session = sessions[0];
const parts = [];
parts.push(`${session.tool}${session.timestamp ? ` @ ${session.timestamp}` : ''}`);
if (session.summary) parts.push(session.summary);
if (session.blockers?.length > 0) parts.push(`blocker: ${session.blockers[0]}`);
if (session.failures?.length > 0) parts.push(`failure: ${session.failures[0]}`);
if (session.fileEdits?.length > 0) {
parts.push(`files: ${session.fileEdits.slice(0, 3).map(f => path.basename(f)).join(', ')}`);
}
return parts.join(' — ');
}
/**

@@ -314,2 +341,3 @@ * Calculate context quality score (0-100).

describeWorkDone,
describeSessionFindings,
detectWorkPatterns,

@@ -316,0 +344,0 @@ calculateQualityScore,

@@ -5,3 +5,3 @@ const fs = require('fs');

const { readState, updateState, addToHistory, getDataDir } = require('./state');
const { isGitRepo, getCurrentBranch, getAllChangedFiles, getDiffSummary, getRecentCommits, getLastCommitInfo } = require('./git');
const { isGitRepo, getCurrentBranch, getAllChangedFiles, getDiffSummary, getDiffContent, getRecentCommits, getLastCommitInfo } = require('./git');
const { detectAITool } = require('./detect-ai');

@@ -11,3 +11,5 @@ const { detectLastStatus, runChecks } = require('./build-test');

const { calculateQualityScore } = require('./narrative');
const { parseNativeSessions } = require('./session-parser');
const { analyzeGuardrails } = require('./guardrails');
const { parseNativeSessions, getSessionSummary } = require('./session-parser');
const { annotateHistoryEntry } = require('./team');

@@ -28,2 +30,4 @@ /**

const aiTool = detectAITool(projectRoot);
let changedFiles = [];
let diffContent = '';

@@ -59,5 +63,6 @@ const quiet = opts.quiet || false;

const branch = getCurrentBranch(projectRoot);
const changedFiles = getAllChangedFiles(projectRoot);
changedFiles = getAllChangedFiles(projectRoot);
const commits = getRecentCommits(projectRoot, 5);
const lastCommit = getLastCommitInfo(projectRoot);
diffContent = getDiffContent(projectRoot, 150);

@@ -68,2 +73,3 @@ gitInfo = {

git_diff_summary: getDiffSummary(projectRoot),
git_diff_content: diffContent,
recent_commits: commits,

@@ -98,2 +104,13 @@ last_commit: lastCommit,

const guardrails = analyzeGuardrails(projectRoot, {
changedFiles,
diffContent,
});
if (guardrails.warnings.length > 0 && !quiet) {
console.log(chalk.dim(' Guardrails: ') + chalk.white(`${guardrails.warnings.length} drift signal${guardrails.warnings.length === 1 ? '' : 's'} detected`));
for (const warning of guardrails.warnings.slice(0, 3)) {
console.log(chalk.yellow(` • ${warning.reason}`));
}
}
// ─── 5. Parse native AI sessions for richer context ───

@@ -103,6 +120,5 @@ try {

if (sessions.length > 0 && !quiet) {
for (const s of sessions) {
if (s.fileEdits?.length > 0) {
console.log(chalk.dim(` ${s.tool}: `) + chalk.white(`${s.fileEdits.length} files edited in last session`));
}
const sessionSummary = getSessionSummary(sessions);
for (const line of sessionSummary.split('\n').filter(Boolean).slice(0, 10)) {
console.log(chalk.dim(' ') + chalk.white(line.trim()));
}

@@ -126,2 +142,3 @@ }

...gitInfo,
guardrails,
},

@@ -136,3 +153,3 @@ modified_files: gitInfo.files_changed || [],

// ─── 7. Save to history ───
addToHistory(projectRoot, {
addToHistory(projectRoot, annotateHistoryEntry(projectRoot, {
timestamp: now,

@@ -143,3 +160,3 @@ message: message,

...gitInfo,
});
}));

@@ -221,68 +238,10 @@ // ─── 8. Generate ALL context files ───

/**
* Auto-detect dependency changes by comparing current package.json with saved state.
* Returns array of decision strings like "added redis (ioredis@^5.0.0)"
* Auto-detect dependency changes across supported ecosystems.
* Returns array of decision strings like "added Redis (ioredis@^5.0.0)".
*/
function autoDetectDepChanges(projectRoot, state) {
const changes = [];
const pkgPath = path.join(projectRoot, 'package.json');
if (!fs.existsSync(pkgPath)) return changes;
let pkg;
try {
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
} catch { return changes; }
const currentDeps = { ...pkg.dependencies, ...pkg.devDependencies };
const currentDeps = collectDependencySnapshot(projectRoot);
const currentDepNames = new Set(Object.keys(currentDeps));
// Compare with what we know from saved state tech_stack
const knownTech = new Set((state.project?.tech_stack || []).map(t => t.toLowerCase()));
// Check for notable new deps that weren't in the detected stack
const notableDeps = {
// Databases & ORMs
'redis': 'Redis', 'ioredis': 'Redis (ioredis)', 'bullmq': 'BullMQ (Redis)', 'bull': 'Bull (Redis)',
'prisma': 'Prisma', '@prisma/client': 'Prisma', 'drizzle-orm': 'Drizzle ORM', 'drizzle-kit': 'Drizzle Kit',
'mongoose': 'MongoDB (Mongoose)', 'mongodb': 'MongoDB', 'pg': 'PostgreSQL', 'postgres': 'PostgreSQL',
'@neondatabase/serverless': 'Neon PostgreSQL', 'mysql2': 'MySQL', 'mysql': 'MySQL',
'better-sqlite3': 'SQLite', 'sql.js': 'SQLite', 'typeorm': 'TypeORM', 'knex': 'Knex',
'sequelize': 'Sequelize', '@planetscale/database': 'PlanetScale',
// Payments
'stripe': 'Stripe', '@stripe/stripe-js': 'Stripe', 'paypal-rest-sdk': 'PayPal', '@paypal/checkout-server-sdk': 'PayPal',
'razorpay': 'Razorpay', 'lemon-squeezy': 'Lemon Squeezy',
// Auth
'next-auth': 'NextAuth', '@auth/core': 'Auth.js', 'passport': 'Passport.js',
'lucia': 'Lucia Auth', 'lucia-auth': 'Lucia Auth', '@clerk/nextjs': 'Clerk',
'jsonwebtoken': 'JWT', 'jose': 'JWT (jose)', 'bcrypt': 'bcrypt',
// BaaS & Cloud
'firebase': 'Firebase', 'firebase-admin': 'Firebase Admin',
'@supabase/supabase-js': 'Supabase', 'supabase': 'Supabase',
'aws-sdk': 'AWS SDK', '@aws-sdk/client-s3': 'AWS S3', '@aws-sdk/client-dynamodb': 'DynamoDB',
'@google-cloud/storage': 'GCP Storage', '@azure/storage-blob': 'Azure Blob',
// Realtime
'socket.io': 'Socket.IO', 'ws': 'WebSockets', 'pusher': 'Pusher', '@pusher/push-notifications-web': 'Pusher',
// API & GraphQL
'graphql': 'GraphQL', '@apollo/server': 'Apollo GraphQL', '@apollo/client': 'Apollo Client',
'@trpc/server': 'tRPC', '@trpc/client': 'tRPC Client',
// Monitoring
'@sentry/node': 'Sentry', '@sentry/nextjs': 'Sentry', 'newrelic': 'New Relic',
'pino': 'Pino Logger', 'winston': 'Winston Logger', 'datadog-metrics': 'Datadog',
// AI/ML
'openai': 'OpenAI', '@anthropic-ai/sdk': 'Anthropic Claude', 'langchain': 'LangChain',
'@huggingface/inference': 'Hugging Face', 'ai': 'Vercel AI SDK', '@ai-sdk/openai': 'Vercel AI SDK',
// Email
'nodemailer': 'Nodemailer', 'resend': 'Resend', '@sendgrid/mail': 'SendGrid', 'postmark': 'Postmark',
// Storage & CDN
'@uploadthing/react': 'UploadThing', 'cloudinary': 'Cloudinary', 'sharp': 'Sharp (image processing)',
// Frameworks (notable additions mid-project)
'next': 'Next.js', 'express': 'Express', 'fastify': 'Fastify', 'hono': 'Hono',
'@nestjs/core': 'NestJS', 'remix': 'Remix', 'astro': 'Astro',
// Testing
'vitest': 'Vitest', 'jest': 'Jest', 'playwright': 'Playwright', '@playwright/test': 'Playwright',
'cypress': 'Cypress', 'msw': 'MSW (Mock Service Worker)',
// Infra
'docker-compose': 'Docker', 'kubernetes-client': 'Kubernetes',
};
// Read previously saved deps (if any)
const savedDepsPath = path.join(projectRoot, '.mindswap', '.deps-snapshot.json');

@@ -298,17 +257,14 @@ let savedDeps = {};

// Detect additions
for (const dep of currentDepNames) {
if (!savedDepNames.has(dep) && notableDeps[dep]) {
changes.push(`added ${notableDeps[dep]} (${dep}@${currentDeps[dep]})`);
if (!savedDepNames.has(dep) && NOTABLE_DEPS[dep]) {
changes.push(`added ${NOTABLE_DEPS[dep]} (${dep}@${currentDeps[dep]})`);
}
}
// Detect removals
for (const dep of savedDepNames) {
if (!currentDepNames.has(dep) && notableDeps[dep]) {
changes.push(`removed ${notableDeps[dep]} (${dep})`);
if (!currentDepNames.has(dep) && NOTABLE_DEPS[dep]) {
changes.push(`removed ${NOTABLE_DEPS[dep]} (${dep})`);
}
}
// Save current snapshot for next comparison
try {

@@ -321,2 +277,213 @@ fs.writeFileSync(savedDepsPath, JSON.stringify(currentDeps, null, 2), 'utf-8');

const NOTABLE_DEPS = {
// JS/TS
'redis': 'Redis', 'ioredis': 'Redis (ioredis)', 'bullmq': 'BullMQ (Redis)', 'bull': 'Bull (Redis)',
'prisma': 'Prisma', '@prisma/client': 'Prisma', 'drizzle-orm': 'Drizzle ORM', 'drizzle-kit': 'Drizzle Kit',
'mongoose': 'MongoDB (Mongoose)', 'mongodb': 'MongoDB', 'pg': 'PostgreSQL', 'postgres': 'PostgreSQL',
'@neondatabase/serverless': 'Neon PostgreSQL', 'mysql2': 'MySQL', 'mysql': 'MySQL',
'better-sqlite3': 'SQLite', 'sql.js': 'SQLite', 'typeorm': 'TypeORM', 'knex': 'Knex',
'sequelize': 'Sequelize', '@planetscale/database': 'PlanetScale',
'stripe': 'Stripe', '@stripe/stripe-js': 'Stripe', 'paypal-rest-sdk': 'PayPal', '@paypal/checkout-server-sdk': 'PayPal',
'razorpay': 'Razorpay', 'lemon-squeezy': 'Lemon Squeezy',
'next-auth': 'NextAuth', '@auth/core': 'Auth.js', 'passport': 'Passport.js',
'lucia': 'Lucia Auth', 'lucia-auth': 'Lucia Auth', '@clerk/nextjs': 'Clerk',
'jsonwebtoken': 'JWT', 'jose': 'JWT (jose)', 'bcrypt': 'bcrypt',
'firebase': 'Firebase', 'firebase-admin': 'Firebase Admin',
'@supabase/supabase-js': 'Supabase', 'supabase': 'Supabase',
'aws-sdk': 'AWS SDK', '@aws-sdk/client-s3': 'AWS S3', '@aws-sdk/client-dynamodb': 'DynamoDB',
'@google-cloud/storage': 'GCP Storage', '@azure/storage-blob': 'Azure Blob',
'socket.io': 'Socket.IO', 'ws': 'WebSockets', 'pusher': 'Pusher', '@pusher/push-notifications-web': 'Pusher',
'graphql': 'GraphQL', '@apollo/server': 'Apollo GraphQL', '@apollo/client': 'Apollo Client',
'@trpc/server': 'tRPC', '@trpc/client': 'tRPC Client',
'@sentry/node': 'Sentry', '@sentry/nextjs': 'Sentry', 'newrelic': 'New Relic',
'pino': 'Pino Logger', 'winston': 'Winston Logger', 'datadog-metrics': 'Datadog',
'openai': 'OpenAI', '@anthropic-ai/sdk': 'Anthropic Claude', 'langchain': 'LangChain',
'@huggingface/inference': 'Hugging Face', 'ai': 'Vercel AI SDK', '@ai-sdk/openai': 'Vercel AI SDK',
'nodemailer': 'Nodemailer', 'resend': 'Resend', '@sendgrid/mail': 'SendGrid', 'postmark': 'Postmark',
'@uploadthing/react': 'UploadThing', 'cloudinary': 'Cloudinary', 'sharp': 'Sharp (image processing)',
'next': 'Next.js', 'express': 'Express', 'fastify': 'Fastify', 'hono': 'Hono',
'@nestjs/core': 'NestJS', 'remix': 'Remix', 'astro': 'Astro',
'vitest': 'Vitest', 'jest': 'Jest', 'playwright': 'Playwright', '@playwright/test': 'Playwright',
'cypress': 'Cypress', 'msw': 'MSW (Mock Service Worker)',
'docker-compose': 'Docker', 'kubernetes-client': 'Kubernetes',
// Python
'django': 'Django', 'flask': 'Flask', 'fastapi': 'FastAPI', 'streamlit': 'Streamlit',
'sqlalchemy': 'SQLAlchemy', 'psycopg2': 'PostgreSQL (psycopg2)', 'psycopg2-binary': 'PostgreSQL (psycopg2-binary)',
'redis-py': 'Redis (redis-py)', 'redis': 'Redis', 'celery': 'Celery', 'uvicorn': 'Uvicorn',
'gunicorn': 'Gunicorn', 'pytest': 'Pytest', 'pydantic': 'Pydantic', 'httpx': 'HTTPX',
// Go
'github.com/gin-gonic/gin': 'Gin', 'github.com/labstack/echo/v4': 'Echo', 'github.com/gofiber/fiber/v2': 'Fiber',
'gofr.dev': 'GoFr', 'gorm.io/gorm': 'GORM', 'gorm.io/driver/postgres': 'GORM Postgres',
'gorm.io/driver/mysql': 'GORM MySQL', 'gorm.io/driver/sqlite': 'GORM SQLite',
'github.com/redis/go-redis/v9': 'Redis (go-redis)', 'github.com/stripe/stripe-go/v78': 'Stripe',
'github.com/aws/aws-sdk-go-v2': 'AWS SDK v2',
// Rust
'actix-web': 'Actix Web', 'axum': 'Axum', 'rocket': 'Rocket', 'tokio': 'Tokio',
'sqlx': 'SQLx', 'diesel': 'Diesel', 'serde': 'Serde', 'reqwest': 'Reqwest',
'redis-rs': 'Redis (redis-rs)', 'redis': 'Redis', 'sea-orm': 'SeaORM',
// Ruby
'rails': 'Rails', 'sinatra': 'Sinatra', 'sidekiq': 'Sidekiq', 'pg': 'PostgreSQL',
'mysql2': 'MySQL', 'redis': 'Redis', 'devise': 'Devise', 'puma': 'Puma',
};
function collectDependencySnapshot(projectRoot) {
const snapshot = {};
mergeInto(snapshot, parsePackageJsonDeps(projectRoot));
mergeInto(snapshot, parseRequirementsDeps(projectRoot));
mergeInto(snapshot, parsePyprojectDeps(projectRoot));
mergeInto(snapshot, parseGoModDeps(projectRoot));
mergeInto(snapshot, parseCargoDeps(projectRoot));
mergeInto(snapshot, parseGemfileDeps(projectRoot));
return snapshot;
}
function mergeInto(target, source) {
for (const [name, version] of Object.entries(source)) {
target[name] = version;
}
}
function parsePackageJsonDeps(projectRoot) {
const pkgPath = path.join(projectRoot, 'package.json');
if (!fs.existsSync(pkgPath)) return {};
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
return { ...pkg.dependencies, ...pkg.devDependencies };
} catch {
return {};
}
}
function parseRequirementsDeps(projectRoot) {
const files = ['requirements.txt', 'Pipfile.lock', 'poetry.lock'];
const deps = {};
const reqPath = path.join(projectRoot, 'requirements.txt');
if (fs.existsSync(reqPath)) {
const content = fs.readFileSync(reqPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue;
const match = trimmed.match(/^([A-Za-z0-9_.-]+)\s*(?:==|>=|<=|~=|!=|>|<)?\s*([^;\s]+)?/);
if (match) deps[match[1].toLowerCase()] = match[2] || 'unknown';
}
}
const pipfileLockPath = path.join(projectRoot, 'Pipfile.lock');
if (fs.existsSync(pipfileLockPath)) {
try {
const lock = JSON.parse(fs.readFileSync(pipfileLockPath, 'utf-8'));
for (const section of ['default', 'develop']) {
for (const [name, info] of Object.entries(lock[section] || {})) {
deps[name.toLowerCase()] = typeof info === 'string' ? info : info.version || 'unknown';
}
}
} catch {}
}
return deps;
}
function parsePyprojectDeps(projectRoot) {
const pyprojectPath = path.join(projectRoot, 'pyproject.toml');
if (!fs.existsSync(pyprojectPath)) return {};
const deps = {};
const content = fs.readFileSync(pyprojectPath, 'utf-8');
const patterns = [
/dependencies\s*=\s*\[([\s\S]*?)\]/m,
/dev-dependencies\s*=\s*\[([\s\S]*?)\]/m,
];
for (const pattern of patterns) {
const match = content.match(pattern);
if (!match) continue;
const entries = match[1].split('\n').map(line => line.trim()).filter(Boolean);
for (const entry of entries) {
const depMatch = entry.match(/"?([A-Za-z0-9_.-]+)[^"]*"?/);
if (depMatch) {
const name = depMatch[1].toLowerCase();
const versionMatch = entry.match(/([0-9][A-Za-z0-9.+-]*)/);
deps[name] = versionMatch ? versionMatch[1] : 'unknown';
}
}
}
const poetrySectionMatch = content.match(/\[tool\.poetry\.dependencies\]([\s\S]*?)(?:\n\[|$)/m);
if (poetrySectionMatch) {
for (const line of poetrySectionMatch[1].split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('python')) continue;
const match = trimmed.match(/^([A-Za-z0-9_.-]+)\s*=\s*["']?([^"']+)["']?/);
if (match) deps[match[1].toLowerCase()] = match[2];
}
}
return deps;
}
function parseGoModDeps(projectRoot) {
const goModPath = path.join(projectRoot, 'go.mod');
if (!fs.existsSync(goModPath)) return {};
const deps = {};
const content = fs.readFileSync(goModPath, 'utf-8');
const requireBlockMatch = content.match(/require\s*\(([\s\S]*?)\)/m);
if (requireBlockMatch) {
for (const line of requireBlockMatch[1].split('\n')) {
const trimmed = line.trim();
const match = trimmed.match(/^([^\s]+)\s+([^\s]+)/);
if (match) deps[match[1]] = match[2];
}
}
for (const line of content.split('\n')) {
const trimmed = line.trim();
const match = trimmed.match(/^require\s+([^\s]+)\s+([^\s]+)/);
if (match) deps[match[1]] = match[2];
}
return deps;
}
function parseCargoDeps(projectRoot) {
const cargoPath = path.join(projectRoot, 'Cargo.toml');
if (!fs.existsSync(cargoPath)) return {};
const deps = {};
const content = fs.readFileSync(cargoPath, 'utf-8');
const sections = new Set(['dependencies', 'dev-dependencies', 'build-dependencies']);
let currentSection = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
currentSection = sections.has(sectionMatch[1]) ? sectionMatch[1] : null;
continue;
}
if (!currentSection) continue;
const depMatch = trimmed.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
if (!depMatch) continue;
const name = depMatch[1];
const versionMatch = depMatch[2].match(/["']([^"']+)["']/);
deps[name] = versionMatch ? versionMatch[1] : depMatch[2].trim();
}
return deps;
}
function parseGemfileDeps(projectRoot) {
const gemfilePath = path.join(projectRoot, 'Gemfile');
if (!fs.existsSync(gemfilePath)) return {};
const deps = {};
const content = fs.readFileSync(gemfilePath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^gem\s+['"]([^'"]+)['"](?:,\s*['"]([^'"]+)['"])?/);
if (match) deps[match[1]] = match[2] || 'unknown';
}
return deps;
}
/**

@@ -323,0 +490,0 @@ * Auto-generate a work summary from file changes and recent commits.

@@ -5,5 +5,10 @@ const fs = require('fs');

const MAX_FILES = 20;
const MAX_COMMANDS = 10;
const MAX_MESSAGES = 8;
const MAX_SESSION_FILES = 8;
/**
* Parse native AI tool session files for rich context.
* Reads actual conversation/session data from tool-specific formats.
* Parse native AI tool sessions into a normalized model.
* Returns the most relevant recent sessions for the current project.
*/

@@ -13,154 +18,567 @@ function parseNativeSessions(projectRoot) {

const claude = parseClaudeCodeSessions(projectRoot);
if (claude) sessions.push(claude);
sessions.push(...parseClaudeCodeSessions(projectRoot));
sessions.push(...parseCodexSessions(projectRoot));
const codex = parseCodexSessions(projectRoot);
if (codex) sessions.push(codex);
return sessions;
return sessions
.filter(session => session && session.projectMatch?.score > 0)
.sort((a, b) => {
const scoreDiff = (b.projectMatch?.score || 0) - (a.projectMatch?.score || 0);
if (scoreDiff !== 0) return scoreDiff;
return (b.timestamp || '').localeCompare(a.timestamp || '');
})
.slice(0, 4);
}
/**
* Parse Claude Code session data.
* Claude Code stores sessions in ~/.claude/projects/<hash>/
* Each session is a JSONL file with messages.
* Parse Claude Code session data from ~/.claude/projects/.
*/
function parseClaudeCodeSessions(projectRoot) {
const claudeBase = path.join(os.homedir(), '.claude');
if (!fs.existsSync(claudeBase)) return null;
const projectsDir = path.join(claudeBase, 'projects');
if (!fs.existsSync(projectsDir)) return null;
if (!fs.existsSync(projectsDir)) return [];
// Find the project directory matching our project root
// Claude uses sanitized paths as directory names
const sanitizedRoot = projectRoot.replace(/\//g, '-').replace(/^-/, '');
const projectDirs = safeReaddir(projectsDir);
const sessions = [];
for (const projectDirName of safeReaddir(projectsDir)) {
const projectDir = path.join(projectsDir, projectDirName);
if (!isDirectory(projectDir)) continue;
let sessionDir = null;
for (const dir of projectDirs) {
if (dir.includes(sanitizedRoot) || dir.includes(path.basename(projectRoot))) {
sessionDir = path.join(projectsDir, dir);
break;
const sessionFiles = safeReaddir(projectDir)
.filter(f => f.endsWith('.jsonl'))
.sort((a, b) => fileMtime(path.join(projectDir, b)) - fileMtime(path.join(projectDir, a)))
.slice(0, MAX_SESSION_FILES);
for (const sessionFile of sessionFiles) {
const sourceFile = path.join(projectDir, sessionFile);
const parsed = parseClaudeSessionFile(projectRoot, sourceFile, projectDirName);
if (parsed) sessions.push(parsed);
}
}
if (!sessionDir) return null;
return sessions;
}
const result = {
function parseClaudeSessionFile(projectRoot, sourceFile, projectDirName) {
const content = readText(sourceFile);
if (!content) return null;
const entries = content
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.slice(-5000)
.map(line => safeJsonParse(line))
.filter(Boolean);
if (entries.length === 0) return null;
return normalizeSession({
tool: 'Claude Code',
lastSession: null,
fileEdits: [],
toolCalls: [],
messages: [],
summary: null,
};
sourceFile,
projectRoot,
sourceLabel: projectDirName,
entries,
rawText: content,
modifiedAt: fileMtime(sourceFile),
});
}
// Read session JSONL files
const sessionFiles = safeReaddir(sessionDir)
.filter(f => f.endsWith('.jsonl'))
.sort()
.reverse(); // Most recent first
/**
* Parse OpenAI Codex CLI sessions from ~/.codex/sessions/.
*/
function parseCodexSessions(projectRoot) {
const codexDir = path.join(os.homedir(), '.codex', 'sessions');
if (!fs.existsSync(codexDir)) return [];
if (sessionFiles.length === 0) return null;
const sessions = [];
const sessionFiles = safeReaddir(codexDir)
.filter(f => f.endsWith('.json'))
.sort((a, b) => fileMtime(path.join(codexDir, b)) - fileMtime(path.join(codexDir, a)))
.slice(0, MAX_SESSION_FILES);
// Parse the most recent session
const latestSession = path.join(sessionDir, sessionFiles[0]);
try {
const content = fs.readFileSync(latestSession, 'utf-8');
const lines = content.split('\n').filter(l => l.trim()).slice(-5000); // Limit to last 5000 lines
for (const sessionFile of sessionFiles) {
const sourceFile = path.join(codexDir, sessionFile);
const content = readText(sourceFile);
if (!content) continue;
for (const line of lines) {
try {
const entry = JSON.parse(line);
const session = safeJsonParse(content);
if (!session) continue;
// Extract assistant messages (summarize what was done)
if (entry.role === 'assistant' && entry.content) {
const text = typeof entry.content === 'string' ? entry.content :
Array.isArray(entry.content) ? entry.content.filter(c => c.type === 'text').map(c => c.text).join(' ') : '';
if (text.length > 20 && text.length < 500) {
result.messages.push(text.slice(0, 300));
}
}
// Extract tool use (file edits, commands run)
if (entry.role === 'assistant' && Array.isArray(entry.content)) {
for (const block of entry.content) {
if (block.type === 'tool_use') {
if (block.name === 'Edit' || block.name === 'Write') {
result.fileEdits.push(block.input?.file_path || 'unknown file');
}
if (block.name === 'Bash') {
const cmd = (block.input?.command || '').slice(0, 100);
if (cmd && !cmd.includes('password') && !cmd.includes('secret')) {
result.toolCalls.push(cmd);
}
}
}
}
}
} catch {}
const entries = [];
if (Array.isArray(session.messages)) {
for (const message of session.messages) {
entries.push(message);
}
}
if (Array.isArray(session.events)) {
for (const event of session.events) {
entries.push(event);
}
}
if (Array.isArray(session.transcript)) {
for (const item of session.transcript) {
entries.push(item);
}
}
if (entries.length === 0) {
entries.push(session);
}
// Deduplicate
result.fileEdits = [...new Set(result.fileEdits)].slice(0, 20);
result.toolCalls = [...new Set(result.toolCalls)].slice(0, 10);
result.messages = result.messages.slice(-5);
result.lastSession = sessionFiles[0];
const normalized = normalizeSession({
tool: 'Codex',
sourceFile,
projectRoot,
sourceLabel: sessionFile,
entries,
rawText: content,
modifiedAt: fileMtime(sourceFile),
});
} catch {}
if (normalized) sessions.push(normalized);
}
return result.fileEdits.length > 0 || result.messages.length > 0 ? result : null;
return sessions;
}
/**
* Parse OpenAI Codex CLI session data.
* Codex stores sessions in ~/.codex/sessions/ as JSON files.
* Normalize a raw session file into a structured record.
*/
function parseCodexSessions(projectRoot) {
const codexDir = path.join(os.homedir(), '.codex', 'sessions');
if (!fs.existsSync(codexDir)) return null;
function normalizeSession({ tool, sourceFile, projectRoot, sourceLabel, entries, rawText, modifiedAt }) {
const messages = [];
const fileEdits = [];
const toolCalls = [];
const blockers = [];
const failures = [];
const snippets = [];
let timestamp = null;
const result = {
tool: 'Codex',
lastSession: null,
fileEdits: [],
toolCalls: [],
messages: [],
for (const entry of entries) {
if (!entry) continue;
if (typeof entry === 'string') {
const text = entry.trim();
if (text) {
messages.push({ role: 'unknown', text });
snippets.push(text);
if (looksBlocked(text)) blockers.push(firstSentence(text));
if (looksFailed(text)) failures.push(firstSentence(text));
}
continue;
}
if (typeof entry !== 'object') continue;
const entryTime = extractTimestamp(entry);
if (entryTime && (!timestamp || entryTime > timestamp)) timestamp = entryTime;
const entryText = extractEntryText(entry);
if (entryText) snippets.push(entryText);
const messageText = extractMessageText(entry);
if (messageText) {
const role = typeof entry.role === 'string'
? entry.role
: typeof entry.type === 'string'
? entry.type
: 'unknown';
messages.push({ role, text: messageText });
if (looksBlocked(messageText)) blockers.push(firstSentence(messageText));
if (looksFailed(messageText)) failures.push(firstSentence(messageText));
}
const extractedFiles = extractFileEdits(entry);
for (const file of extractedFiles) {
fileEdits.push(file);
snippets.push(file);
}
const extractedCommands = extractToolCalls(entry);
for (const cmd of extractedCommands) {
toolCalls.push(cmd);
snippets.push(cmd);
}
}
if (!timestamp) timestamp = modifiedAt || null;
const uniqueFiles = uniqueStrings(fileEdits).slice(0, MAX_FILES);
const uniqueCommands = uniqueStrings(toolCalls).slice(0, MAX_COMMANDS);
const uniqueMessages = dedupeMessages(messages).slice(-MAX_MESSAGES);
const uniqueBlockers = uniqueStrings(blockers).slice(0, 5);
const uniqueFailures = uniqueStrings(failures).slice(0, 5);
const score = scoreProjectMatch({
projectRoot,
sourceFile,
sourceLabel,
rawText,
snippets,
fileEdits: uniqueFiles,
toolCalls: uniqueCommands,
messages: uniqueMessages,
});
if (score.score <= 0 && uniqueFiles.length === 0 && uniqueCommands.length === 0 && uniqueMessages.length === 0) {
return null;
}
return {
tool,
sourceFile,
sourceLabel,
timestamp,
projectMatch: score,
status: uniqueBlockers.length > 0 || uniqueFailures.length > 0 ? 'blocked' : 'active',
summary: buildSessionSummary(uniqueMessages, uniqueFiles, uniqueCommands, uniqueBlockers, uniqueFailures),
fileEdits: uniqueFiles,
toolCalls: uniqueCommands,
messages: uniqueMessages,
blockers: uniqueBlockers,
failures: uniqueFailures,
};
}
const sessionFiles = safeReaddir(codexDir)
.filter(f => f.endsWith('.json'))
.sort()
.reverse();
function buildSessionSummary(messages, fileEdits, toolCalls, blockers, failures) {
const opener = messages.find(m => m.role === 'assistant' && m.text)?.text
|| messages.find(m => m.text)?.text
|| '';
if (opener) {
const trimmed = firstSentence(opener);
if (trimmed) return trimmed;
}
if (sessionFiles.length === 0) return null;
if (blockers.length > 0) {
return `Blocked: ${blockers[0]}`;
}
// Parse most recent session
try {
const content = fs.readFileSync(path.join(codexDir, sessionFiles[0]), 'utf-8');
const session = JSON.parse(content);
if (failures.length > 0) {
return `Failed: ${failures[0]}`;
}
if (Array.isArray(session.messages)) {
for (const msg of session.messages) {
if (msg.role === 'assistant' && typeof msg.content === 'string') {
if (msg.content.length > 20 && msg.content.length < 500) {
result.messages.push(msg.content.slice(0, 300));
}
if (fileEdits.length > 0) {
const sample = fileEdits.slice(0, 3).map(f => `\`${path.basename(f)}\``).join(', ');
return `Edited ${sample}`;
}
if (toolCalls.length > 0) {
const sample = toolCalls.slice(0, 2).map(c => `\`${c.slice(0, 40)}\``).join(', ');
return `Ran ${sample}`;
}
return '';
}
function scoreProjectMatch({ projectRoot, sourceFile, sourceLabel, rawText, snippets, fileEdits, toolCalls, messages }) {
const projectName = path.basename(projectRoot).toLowerCase();
const normalizedRoot = normalizePath(projectRoot);
let score = 0;
const signals = [];
const haystacks = [
normalizePath(rawText),
normalizePath(sourceFile),
normalizePath(sourceLabel),
...snippets.map(normalizePath),
...fileEdits.map(normalizePath),
...toolCalls.map(normalizePath),
...messages.map(m => normalizePath(m.text || '')),
].filter(Boolean);
if (normalizePath(sourceFile).includes(normalizedRoot)) {
score += 40;
signals.push('path match');
}
if (normalizePath(sourceLabel).includes(projectName)) {
score += 20;
signals.push('project dir match');
}
if (haystacks.some(text => text.includes(normalizedRoot))) {
score += 45;
signals.push('project path mentioned');
}
if (haystacks.some(text => text.includes(projectName))) {
score += 15;
signals.push('project name mentioned');
}
const projectFiles = fileEdits.filter(file => isProjectPath(file, projectRoot));
if (projectFiles.length > 0) {
score += Math.min(30, projectFiles.length * 10);
signals.push(`${projectFiles.length} file(s) in project`);
}
const commandHits = toolCalls.filter(cmd => isProjectPath(cmd, projectRoot));
if (commandHits.length > 0) {
score += Math.min(20, commandHits.length * 5);
signals.push(`${commandHits.length} command(s) in project`);
}
return {
score,
matched: score > 0,
signals: uniqueStrings(signals).slice(0, 8),
};
}
function extractTimestamp(entry) {
const candidates = [
entry.timestamp,
entry.created_at,
entry.createdAt,
entry.time,
entry.ts,
entry.date,
entry.updated_at,
entry.updatedAt,
];
for (const candidate of candidates) {
const parsed = parseDate(candidate);
if (parsed) return parsed;
}
return null;
}
function extractEntryText(entry) {
const parts = [];
const push = value => {
if (typeof value === 'string' && value.trim()) parts.push(value.trim());
};
push(entry.text);
push(entry.message);
push(entry.content);
push(entry.command);
push(entry.output);
push(entry.result);
push(entry.summary);
if (Array.isArray(entry.content)) {
for (const block of entry.content) {
if (!block || typeof block !== 'object') continue;
push(block.text);
push(block.content);
push(block.command);
push(block.input?.command);
push(block.input?.text);
push(block.input?.file_path);
push(block.input?.path);
push(block.input?.new_string);
push(block.input?.old_string);
push(block.input?.files);
}
}
if (Array.isArray(entry.messages)) {
for (const message of entry.messages) {
push(extractMessageText(message));
}
}
return uniqueStrings(parts).join(' ');
}
function extractMessageText(entry) {
if (!entry || typeof entry !== 'object') return '';
if (typeof entry.content === 'string') return entry.content.trim();
if (Array.isArray(entry.content)) {
const textParts = [];
for (const block of entry.content) {
if (!block || typeof block !== 'object') continue;
if (block.type === 'text' && typeof block.text === 'string') {
textParts.push(block.text.trim());
} else if (block.type === 'tool_use') {
continue;
} else if (typeof block.text === 'string') {
textParts.push(block.text.trim());
}
}
return textParts.join(' ').trim();
}
if (typeof entry.message === 'string') return entry.message.trim();
if (typeof entry.text === 'string') return entry.text.trim();
if (typeof entry.output === 'string') return entry.output.trim();
if (typeof entry.summary === 'string') return entry.summary.trim();
return '';
}
function extractFileEdits(entry) {
const edits = [];
const scanInput = input => {
if (!input || typeof input !== 'object') return;
const paths = [
input.file_path,
input.path,
input.file,
input.filename,
input.target_file,
input.targetFile,
input.files,
input.paths,
];
for (const value of paths) {
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === 'string') edits.push(item.trim());
}
} else if (typeof value === 'string') {
edits.push(value.trim());
}
}
};
result.lastSession = sessionFiles[0];
result.messages = result.messages.slice(-5);
} catch {}
if (Array.isArray(entry.content)) {
for (const block of entry.content) {
if (!block || typeof block !== 'object') continue;
if (block.type === 'tool_use' || block.type === 'tool') {
if (['Edit', 'Write', 'MultiEdit', 'Replace', 'Patch'].includes(block.name)) {
scanInput(block.input);
}
}
}
}
return result.messages.length > 0 ? result : null;
scanInput(entry);
return uniqueStrings(edits);
}
function extractToolCalls(entry) {
const commands = [];
const pushCommand = value => {
if (typeof value !== 'string') return;
const trimmed = value.trim();
if (!trimmed) return;
if (trimmed.length > 240) {
commands.push(trimmed.slice(0, 240));
} else {
commands.push(trimmed);
}
};
if (Array.isArray(entry.content)) {
for (const block of entry.content) {
if (!block || typeof block !== 'object') continue;
if (block.type === 'tool_use' || block.type === 'tool') {
if (block.name === 'Bash' || block.name === 'Shell' || block.name === 'Terminal') {
pushCommand(block.input?.command || block.input?.text || block.text);
}
}
}
}
pushCommand(entry.command);
pushCommand(entry.input?.command);
pushCommand(entry.input?.text);
pushCommand(entry.shell);
return uniqueStrings(commands);
}
function looksBlocked(text) {
return /\b(blocked|blocker|stuck|waiting on|can't continue|cannot continue|need .* before|hold up)\b/i.test(text);
}
function looksFailed(text) {
return /\b(fail|failed|error|exception|traceback|crash|broken|cannot run)\b/i.test(text);
}
function firstSentence(text) {
return String(text || '')
.replace(/\s+/g, ' ')
.split(/[.!?\n]/)[0]
.trim();
}
function normalizePath(value) {
return String(value || '').toLowerCase().replace(/\\/g, '/');
}
function isProjectPath(value, projectRoot) {
const normalized = normalizePath(value);
const root = normalizePath(projectRoot);
const base = path.basename(projectRoot).toLowerCase();
return normalized.includes(root) || normalized.includes(`/${base}/`) || normalized.endsWith(`/${base}`);
}
function parseDate(value) {
if (!value) return null;
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
if (typeof value === 'number' && Number.isFinite(value)) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (typeof value === 'string') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
function fileMtime(filePath) {
try {
return new Date(fs.statSync(filePath).mtimeMs).toISOString();
} catch {
return null;
}
}
function readText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return '';
}
}
function safeReaddir(dir) {
try {
return fs.readdirSync(dir);
} catch {
return [];
}
}
function isDirectory(filePath) {
try {
return fs.statSync(filePath).isDirectory();
} catch {
return false;
}
}
function safeJsonParse(content) {
try {
return JSON.parse(content);
} catch {
return null;
}
}
function uniqueStrings(values) {
return [...new Set((values || []).map(value => String(value || '').trim()).filter(Boolean))];
}
function dedupeMessages(messages) {
const seen = new Set();
const result = [];
for (const message of messages || []) {
if (!message || !message.text) continue;
const key = `${message.role || 'unknown'}::${message.text}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({ role: message.role || 'unknown', text: message.text });
}
return result;
}
/**
* Get a summary of native session data for HANDOFF.md.
* Get a summary of native session data for HANDOFF.md and MCP context.
*/
function getSessionSummary(sessions) {
if (sessions.length === 0) return '';
if (!sessions || sessions.length === 0) return '';

@@ -171,4 +589,11 @@ const lines = [];

for (const session of sessions) {
lines.push(`### ${session.tool}`);
const relevance = session.projectMatch?.score != null ? `${session.projectMatch.score}/100` : 'n/a';
const timestamp = session.timestamp ? ` @ ${session.timestamp}` : '';
lines.push(`### ${session.tool}${timestamp}`);
lines.push(`Relevance: ${relevance}`);
if (session.summary) {
lines.push(`Summary: ${session.summary}`);
}
if (session.fileEdits?.length > 0) {

@@ -182,9 +607,16 @@ lines.push(`Files edited: ${session.fileEdits.slice(0, 10).map(f => `\`${path.basename(f)}\``).join(', ')}`);

if (session.blockers?.length > 0) {
lines.push(`Blocker: ${session.blockers[0]}`);
}
if (session.failures?.length > 0) {
lines.push(`Failure: ${session.failures[0]}`);
}
if (session.messages?.length > 0) {
lines.push(`Last actions:`);
for (const msg of session.messages.slice(-3)) {
// Extract first sentence
const firstSentence = msg.split(/[.!?\n]/)[0].trim();
if (firstSentence.length > 10) {
lines.push(`- ${firstSentence}`);
lines.push('Last actions:');
for (const message of session.messages.slice(-3)) {
const first = firstSentence(message.text);
if (first.length > 10) {
lines.push(`- ${first}`);
}

@@ -199,10 +631,8 @@ }

function safeReaddir(dir) {
try {
return fs.readdirSync(dir);
} catch {
return [];
}
}
module.exports = { parseNativeSessions, getSessionSummary, parseClaudeCodeSessions };
module.exports = {
parseNativeSessions,
getSessionSummary,
parseClaudeCodeSessions,
parseCodexSessions,
normalizeSession,
};

@@ -160,5 +160,10 @@ const fs = require('fs');

const filename = `checkpoint-${timestamp}-${randomSuffix()}.json`;
let normalizedEntry = entry;
try {
const { annotateHistoryEntry } = require('./team');
normalizedEntry = annotateHistoryEntry(projectRoot, entry);
} catch {}
fs.writeFileSync(
path.join(historyDir, filename),
JSON.stringify(entry, null, 2),
JSON.stringify(normalizedEntry, null, 2),
'utf-8'

@@ -165,0 +170,0 @@ );

const chalk = require('chalk');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { save } = require('./save');
const { generate } = require('./generate');
const { addToHistory, readState } = require('./state');
const { detectAITool } = require('./detect-ai');

@@ -69,4 +73,108 @@ const TOOLS = {

async function switchTool(projectRoot, toolName, opts = {}) {
const tool = TOOLS[toolName?.toLowerCase()];
function resolveTool(toolName) {
if (!toolName) return null;
const normalized = String(toolName).trim().toLowerCase();
if (!normalized) return null;
if (TOOLS[normalized]) {
return { key: normalized, ...TOOLS[normalized] };
}
const matches = Object.entries(TOOLS)
.filter(([key, tool]) => {
const description = tool.description.toLowerCase();
return description === normalized ||
description.includes(normalized) ||
normalized.includes(key);
})
.map(([key, tool]) => ({ key, ...tool }));
return matches.length === 1 ? matches[0] : null;
}
function getConfig(projectRoot) {
const configPath = path.join(projectRoot, '.mindswap', 'config.json');
try {
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} catch {
return {};
}
}
function getHookConfig(projectRoot) {
const config = getConfig(projectRoot);
return config.ide_hooks || config.session_hooks || {};
}
function getHookCommand(projectRoot, toolKey, event) {
const hookConfig = getHookConfig(projectRoot);
const toolHooks = hookConfig.tools && toolKey ? hookConfig.tools[toolKey] : null;
return toolHooks?.[event] || hookConfig[event] || null;
}
function buildHookEnv(projectRoot, tool, context = {}) {
return {
...process.env,
MINDSWAP_PROJECT_ROOT: projectRoot,
MINDSWAP_TOOL: tool?.key || '',
MINDSWAP_TOOL_DESCRIPTION: tool?.description || '',
MINDSWAP_SESSION_EVENT: context.event || '',
MINDSWAP_EVENT: context.event || '',
MINDSWAP_TRIGGER: context.trigger || '',
MINDSWAP_MESSAGE: context.message || '',
MINDSWAP_SOURCE_TOOL: context.sourceTool || '',
MINDSWAP_TARGET_TOOL: context.targetTool || '',
};
}
function recordSessionEvent(projectRoot, event, tool, context = {}, deps = {}) {
const addHistory = deps.addToHistory || addToHistory;
const entry = {
timestamp: context.timestamp || new Date().toISOString(),
type: event,
ai_tool: tool.description,
tool: tool.key,
message: context.message || `${event.replace('_', ' ')}: ${tool.description}`,
trigger: context.trigger || 'manual',
source: context.source || 'switch',
counterpart: context.counterpart || null,
};
addHistory(projectRoot, entry);
return entry;
}
function runSessionHook(projectRoot, event, tool, context = {}, deps = {}) {
const exec = deps.execSync || execSync;
const command = getHookCommand(projectRoot, tool.key, event);
if (!command) {
return { ran: false, command: null };
}
exec(command, {
cwd: projectRoot,
stdio: 'pipe',
timeout: 5000,
env: buildHookEnv(projectRoot, tool, { ...context, event }),
});
return { ran: true, command };
}
function inferActiveTool(projectRoot, opts = {}, deps = {}) {
const resolver = deps.resolveTool || resolveTool;
if (opts.from) {
return resolver(opts.from);
}
const stateReader = deps.readState || readState;
const detector = deps.detectAITool || detectAITool;
const fromState = resolver(stateReader(projectRoot)?.last_checkpoint?.ai_tool);
if (fromState) return fromState;
return resolver(detector(projectRoot));
}
async function switchTool(projectRoot, toolName, opts = {}, deps = {}) {
const tool = resolveTool(toolName);
if (!tool) {

@@ -84,11 +192,39 @@ console.log(chalk.red(`\nUnknown tool: "${toolName}"\n`));

const saveFn = deps.save || save;
const generateFn = deps.generate || generate;
const exec = deps.execSync || execSync;
const hooksEnabled = opts.hooks !== false;
const activeTool = hooksEnabled ? inferActiveTool(projectRoot, opts, deps) : null;
// Step 1: Save full state (auto-detects everything)
console.log(chalk.dim(' 1. ') + 'Saving state...');
await save(projectRoot, { message: opts.message || `switching to ${tool.description}`, quiet: true });
await saveFn(projectRoot, { message: opts.message || `switching to ${tool.description}`, quiet: true });
console.log(chalk.green(' ✓ ') + 'State saved');
// Step 1b: End previous session if we can identify it
if (hooksEnabled && activeTool && activeTool.key !== tool.key) {
console.log(chalk.dim(' 1b. ') + `Ending ${activeTool.description} session...`);
recordSessionEvent(projectRoot, 'session_end', activeTool, {
source: 'switch',
trigger: 'switch',
counterpart: tool.key,
message: `session end: ${activeTool.description} → ${tool.description}`,
}, deps);
try {
runSessionHook(projectRoot, 'session_end', activeTool, {
trigger: 'switch',
message: opts.message || '',
sourceTool: activeTool.key,
targetTool: tool.key,
}, deps);
console.log(chalk.green(' ✓ ') + `${activeTool.description} session ended`);
} catch {
console.log(chalk.yellow(' ⚠ ') + `Session end hook failed for ${activeTool.description}`);
}
}
// Step 2: Generate context files
console.log(chalk.dim(' 2. ') + `Generating ${tool.description} context...`);
const genOpts = { handoff: true, [tool.generateFlag]: true, quiet: true };
await generate(projectRoot, genOpts);
await generateFn(projectRoot, genOpts);
console.log(chalk.green(' ✓ ') + 'Context files updated');

@@ -100,3 +236,3 @@

try {
execSync(`${tool.openCmd} ${tool.openArg}`, {
exec(`${tool.openCmd} ${tool.openArg}`, {
cwd: projectRoot,

@@ -112,2 +248,24 @@ stdio: 'pipe',

if (hooksEnabled) {
const step = tool.openCmd && !opts.noOpen ? ' 4. ' : ' 3. ';
console.log(chalk.dim(step) + `Starting ${tool.description} session...`);
recordSessionEvent(projectRoot, 'session_start', tool, {
source: 'switch',
trigger: 'switch',
counterpart: activeTool?.key || null,
message: `session start: ${tool.description}`,
}, deps);
try {
runSessionHook(projectRoot, 'session_start', tool, {
trigger: 'switch',
message: opts.message || '',
sourceTool: activeTool?.key || '',
targetTool: tool.key,
}, deps);
console.log(chalk.green(' ✓ ') + `${tool.description} session started`);
} catch {
console.log(chalk.yellow(' ⚠ ') + `Session start hook failed for ${tool.description}`);
}
}
// Summary

@@ -129,2 +287,3 @@ console.log(chalk.bold.green(`\n✓ Ready for ${tool.description}\n`));

console.log(chalk.dim(' Codex/agents will auto-read:'));
console.log(chalk.white(' CODEX.md'));
console.log(chalk.white(' AGENTS.md'));

@@ -143,2 +302,11 @@ console.log(chalk.white(' HANDOFF.md'));

module.exports = { switchTool, getAvailableTools };
module.exports = {
TOOLS,
switchTool,
getAvailableTools,
resolveTool,
inferActiveTool,
getHookCommand,
recordSessionEvent,
runSessionHook,
};

@@ -7,2 +7,4 @@ const chalk = require('chalk');

const { getAllChangedFiles } = require('./git');
const { save } = require('./save');
const { inferActiveTool, resolveTool, recordSessionEvent, runSessionHook } = require('./switch');

@@ -18,2 +20,4 @@ async function watch(projectRoot, opts = {}) {

const debounceMs = parseInt(opts.interval) || 2000;
const watchPlan = getWatchPlan(opts);
const session = await startWatchSession(projectRoot, opts);

@@ -23,2 +27,6 @@ console.log(chalk.bold('\n⚡ mindswap watching...\n'));

console.log(chalk.dim(` Debounce: ${debounceMs}ms`));
console.log(chalk.dim(` Mode: ${watchPlan.label}`));
if (session?.tool) {
console.log(chalk.dim(` Session: ${session.tool.description}`));
}
console.log(chalk.dim(' Press Ctrl+C to stop.\n'));

@@ -59,4 +67,8 @@

try {
const { generate } = require('./generate');
await generate(projectRoot, { handoff: true, quiet: true });
if (watchPlan.save) {
await save(projectRoot, { quiet: true, check: opts.check || false });
} else {
const { generate } = require('./generate');
await generate(projectRoot, { ...watchPlan.generateOpts, quiet: true });
}
} catch {}

@@ -67,3 +79,3 @@

chalk.white(`${changed.length} changed files `) +
chalk.dim('→ HANDOFF.md updated')
chalk.dim(`→ ${watchPlan.actionLabel}`)
);

@@ -81,9 +93,29 @@ } catch (err) {

// Handle graceful shutdown
process.on('SIGINT', () => {
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
if (debounceTimer) clearTimeout(debounceTimer);
watcher.close();
await watcher.close();
await stopWatchSession(projectRoot, opts, {
trigger: signal,
tool: session?.tool || null,
});
console.log(chalk.dim('\n Stopped watching.\n'));
process.exit(0);
}
// Handle graceful shutdown
process.on('SIGINT', () => {
shutdown('SIGINT').catch(err => {
process.stderr.write(`mindswap watch shutdown error: ${err.message}\n`);
process.exit(1);
});
});
process.on('SIGTERM', () => {
shutdown('SIGTERM').catch(err => {
process.stderr.write(`mindswap watch shutdown error: ${err.message}\n`);
process.exit(1);
});
});

@@ -103,2 +135,94 @@ // Keep process alive

module.exports = { watch };
function getWatchPlan(opts = {}) {
if (opts.save) {
return {
save: true,
label: opts.all ? 'save + full context refresh' : 'save + handoff refresh',
actionLabel: opts.all ? 'saved state and refreshed all context files' : 'saved state and refreshed HANDOFF.md',
generateOpts: opts.all ? { all: true } : { handoff: true },
};
}
return {
save: false,
label: opts.all ? 'full context refresh' : 'handoff-only refresh',
actionLabel: opts.all ? 'refreshed all context files' : 'updated HANDOFF.md',
generateOpts: opts.all ? { all: true } : { handoff: true },
};
}
async function startWatchSession(projectRoot, opts = {}, deps = {}) {
if (opts.hooks === false) return { tool: null, saved: false, hookRan: false };
const saveFn = deps.save || save;
const resolve = deps.resolveTool || resolveTool;
const infer = deps.inferActiveTool || inferActiveTool;
const record = deps.recordSessionEvent || recordSessionEvent;
const runHook = deps.runSessionHook || runSessionHook;
const tool = resolve(opts.tool) || infer(projectRoot, opts, deps);
if (!tool) return { tool: null, saved: false, hookRan: false };
await saveFn(projectRoot, {
message: opts.message || `session start: ${tool.description}`,
quiet: true,
check: opts.check || false,
});
record(projectRoot, 'session_start', tool, {
source: 'watch',
trigger: 'watch-start',
message: `session start: ${tool.description}`,
}, deps);
let hookRan = false;
try {
const result = runHook(projectRoot, 'session_start', tool, {
trigger: 'watch-start',
message: opts.message || '',
targetTool: tool.key,
}, deps);
hookRan = !!result?.ran;
} catch {}
return { tool, saved: true, hookRan };
}
async function stopWatchSession(projectRoot, opts = {}, runtime = {}, deps = {}) {
if (opts.hooks === false) return { tool: null, saved: false, hookRan: false };
const saveFn = deps.save || save;
const resolve = deps.resolveTool || resolveTool;
const infer = deps.inferActiveTool || inferActiveTool;
const record = deps.recordSessionEvent || recordSessionEvent;
const runHook = deps.runSessionHook || runSessionHook;
const tool = runtime.tool || resolve(opts.tool) || infer(projectRoot, opts, deps);
if (!tool) return { tool: null, saved: false, hookRan: false };
await saveFn(projectRoot, {
message: opts.message || `session end: ${tool.description}`,
quiet: true,
check: false,
});
record(projectRoot, 'session_end', tool, {
source: 'watch',
trigger: runtime.trigger || 'watch-stop',
message: `session end: ${tool.description}`,
}, deps);
let hookRan = false;
try {
const result = runHook(projectRoot, 'session_end', tool, {
trigger: runtime.trigger || 'watch-stop',
message: opts.message || '',
sourceTool: tool.key,
}, deps);
hookRan = !!result?.ran;
} catch {}
return { tool, saved: true, hookRan };
}
module.exports = { watch, getWatchPlan, startWatchSession, stopWatchSession };