+125
| 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, | ||
| }; |
+158
| 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, | ||
| }; |
+359
| 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, | ||
| }; |
+115
| 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, | ||
| }; |
+236
| 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, | ||
| }; |
+244
| 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, | ||
| }; |
+86
| 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" | ||
| } |
+58
-7
@@ -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 | |
+11
-0
@@ -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 = { |
+21
-4
@@ -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) { |
+2
-0
@@ -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')), |
+79
-1
@@ -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'; |
+16
-0
@@ -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, | ||
| }; |
+88
-11
@@ -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. |
+3
-0
@@ -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 @@ |
+231
-25
@@ -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 }; |
+28
-0
@@ -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, |
+244
-77
@@ -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', | ||
| '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. |
+556
-126
@@ -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, | ||
| }; |
+6
-1
@@ -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 @@ ); |
+174
-6
| 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, | ||
| }; |
+131
-7
@@ -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 }; |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 5 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
No website
QualityPackage does not have a website.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
301497
50.49%37
27.59%7117
56.42%0
-100%231
28.33%45
60.71%6
20%