import 'dotenv/config'; import express from 'express'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import crypto from 'node:crypto'; import { Client, TablesDB, ID, Query } from 'node-appwrite'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 3045); const endpoint = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_LOCAL_ENDPOINT || process.env.APPWRITE_SELF_HOSTED_URL; const projectId = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_LOCAL_PROJECT_ID || process.env.APPWRITE_SELF_HOSTED_PROJECT_ID; const apiKey = process.env.APPWRITE_API_KEY || process.env.APPWRITE_LOCAL_API_KEY || process.env.APPWRITE_SELF_HOSTED_API_KEY; const databaseId = process.env.RANK_APPWRITE_DATABASE_ID || process.env.APPWRITE_DATABASE_ID || 'priority_rank'; const ideasTableId = process.env.RANK_IDEAS_TABLE_ID || 'ideas'; const milestonesTableId = process.env.RANK_MILESTONES_TABLE_ID || 'milestones'; const activityTableId = process.env.RANK_ACTIVITY_TABLE_ID || 'activity'; const agentToken = process.env.RANK_AGENT_TOKEN || process.env.PRIORITY_AGENT_TOKEN || ''; const appVersion = process.env.APP_VERSION || 'rank-local'; const appwriteTimeoutMs = Number(process.env.APPWRITE_TIMEOUT_MS || 7000); function withTimeout(promise, label = 'operation') { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out after ${appwriteTimeoutMs}ms`)), appwriteTimeoutMs); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } if (!endpoint || !projectId || !apiKey) { console.warn('[rank] Missing Appwrite configuration; /api/health will report degraded.'); } const client = new Client(); if (endpoint) client.setEndpoint(endpoint); if (projectId) client.setProject(projectId); if (apiKey) client.setKey(apiKey); const tables = new TablesDB(client); const app = express(); app.use(express.json({ limit: '256kb' })); app.use(express.static(path.join(__dirname, 'public'), { etag: true, maxAge: process.env.NODE_ENV === 'production' ? '10m' : 0, })); function clampInt(value, fallback, min = 0, max = 10) { const n = Number.parseInt(value, 10); if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, n)); } function cleanText(value, max = 1000) { return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max); } function cleanMultiline(value, max = 6000) { return String(value ?? '').replace(/\r\n/g, '\n').trim().slice(0, max); } function scoreIdea({ impact, effort, confidence, urgency }) { const i = clampInt(impact, 5); const e = clampInt(effort, 5, 1, 10); const c = clampInt(confidence, 5); const u = clampInt(urgency, 5); return Number((((i * 2.4) + (c * 1.2) + (u * 1.4)) / Math.max(1, e)).toFixed(2)); } function ideaDataFromInput(input = {}, defaults = {}) { const title = cleanText(input.title || input.name, 180); if (!title) throw Object.assign(new Error('Every feature needs a title.'), { code: 400 }); const data = { title, description: cleanMultiline(input.description || input.brief || '', 5000), source: cleanText(input.source || defaults.source || 'import', 40), sourceName: cleanText(input.sourceName || input.owner || defaults.sourceName || 'Prioritix feature set', 80), status: cleanText(input.status || defaults.status || 'inbox', 40), milestoneId: cleanText(input.milestoneId || input.milestone || defaults.milestoneId || 'inbox', 64), impact: clampInt(input.impact, defaults.impact ?? 5), effort: clampInt(input.effort, defaults.effort ?? 5, 1, 10), confidence: clampInt(input.confidence, defaults.confidence ?? 6), urgency: clampInt(input.urgency, defaults.urgency ?? 5), rank: clampInt(input.rank, defaults.rank ?? 0, -100000, 100000), labels: encodeList(input.labels || input.category || input.categories || []), notes: cleanMultiline(input.notes || '', 4000), archived: Boolean(input.archived ?? false), }; data.score = scoreIdea(data); return data; } function publicIdea(row) { return { id: row.$id, createdAt: row.$createdAt, updatedAt: row.$updatedAt, title: row.title, description: row.description || '', source: row.source || 'human', sourceName: row.sourceName || '', status: row.status || 'inbox', milestoneId: row.milestoneId || 'inbox', impact: row.impact ?? 5, effort: row.effort ?? 5, confidence: row.confidence ?? 5, urgency: row.urgency ?? 5, score: row.score ?? 0, rank: row.rank ?? 0, labels: parseList(row.labels), notes: row.notes || '', archived: Boolean(row.archived), }; } function publicMilestone(row) { return { id: row.$id, createdAt: row.$createdAt, updatedAt: row.$updatedAt, name: row.name, description: row.description || '', horizon: row.horizon || '', color: row.color || '#8cf7ff', position: row.position ?? 0, active: row.active !== false, }; } function parseList(value) { if (!value) return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.map(String).slice(0, 12) : []; } catch { return String(value).split(',').map(s => s.trim()).filter(Boolean).slice(0, 12); } } function encodeList(value) { if (Array.isArray(value)) return JSON.stringify(value.map(v => cleanText(v, 32)).filter(Boolean).slice(0, 12)); return JSON.stringify(parseList(value)); } function cleanTextList(value, maxItems = 6, maxText = 180) { const list = Array.isArray(value) ? value : parseList(value); return list.map(item => cleanText(item, maxText)).filter(Boolean).slice(0, maxItems); } function cleanMetricHints(item = {}) { const raw = { ...(item.factors && typeof item.factors === 'object' ? item.factors : {}), ...(item.scoring && typeof item.scoring === 'object' ? item.scoring : {}), ...(item.rankerHints && typeof item.rankerHints === 'object' ? item.rankerHints : {}), }; const aliases = { value: ['value', 'impact', 'userImpact', 'userValueScore'], effort: ['effort', 'buildEffort', 'complexity'], confidence: ['confidence', 'certainty'], urgency: ['urgency', 'timing'], revenue: ['revenue', 'commercial', 'buyerSignal'], novelty: ['novelty', 'differentiation', 'originality'], risk: ['risk', 'assumptionRisk', 'scopeRisk'], }; return Object.fromEntries(Object.entries(aliases).map(([metric, keys]) => { const found = keys.map(key => raw[key] ?? item[key]).find(value => value !== undefined && value !== null && value !== ''); const parsed = Number.parseFloat(found); return [metric, Number.isFinite(parsed) ? Math.min(10, Math.max(1, parsed)) : null]; }).filter(([, value]) => value !== null)); } function blendMetric(heuristic, explicit, weight = 0.58) { if (!Number.isFinite(explicit)) return heuristic; return Math.max(1, Math.min(10, heuristic * (1 - weight) + explicit * weight)); } function rowsFrom(result) { const rows = result?.rows || result?.documents; if (!Array.isArray(rows)) throw new Error('Appwrite returned an invalid table response'); return rows; } function assertRow(row) { if (!row?.$id) throw new Error('Appwrite returned an invalid row response'); return row; } function requireAgent(req, res, next) { if (!agentToken) return next(); const header = req.get('authorization') || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : req.get('x-rank-token'); const tokenBuffer = Buffer.from(token || ''); const expectedBuffer = Buffer.from(agentToken); if (tokenBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(tokenBuffer, expectedBuffer)) return next(); return res.status(401).json({ error: 'agent token required' }); } async function logActivity(type, message, ideaId = '', meta = '') { try { await tables.createRow({ databaseId, tableId: activityTableId, rowId: ID.unique(), data: { type, message: cleanText(message, 300), ideaId, meta: cleanText(meta, 800) }, }); } catch (error) { console.warn('[rank] activity log failed', error.message); } } app.get('/api/health', async (_req, res) => { const health = { ok: false, app: 'rank', version: appVersion, appwriteConfigured: Boolean(endpoint && projectId && apiKey), appwriteReachable: false, tableReachable: false }; try { if (health.appwriteConfigured) { const probe = await withTimeout( tables.listRows({ databaseId, tableId: ideasTableId, queries: [Query.limit(1)] }), 'Appwrite health probe' ); rowsFrom(probe); health.appwriteReachable = true; health.tableReachable = true; health.ok = true; } } catch (error) { health.error = error.message; } res.status(health.ok ? 200 : 503).json(health); }); app.get('/api/bootstrap', async (_req, res) => { const [ideas, milestones, activity] = await withTimeout(Promise.all([ tables.listRows({ databaseId, tableId: ideasTableId, queries: [Query.equal('archived', false), Query.orderDesc('score'), Query.orderAsc('rank'), Query.limit(100)] }), tables.listRows({ databaseId, tableId: milestonesTableId, queries: [Query.equal('active', true), Query.orderAsc('position'), Query.limit(50)] }), tables.listRows({ databaseId, tableId: activityTableId, queries: [Query.orderDesc('$createdAt'), Query.limit(18)] }).catch(() => ({ rows: [], documents: [] })), ]), 'Appwrite bootstrap'); res.json({ version: appVersion, ideas: rowsFrom(ideas).map(publicIdea), milestones: rowsFrom(milestones).map(publicMilestone), activity: rowsFrom(activity).map(row => ({ id: row.$id, createdAt: row.$createdAt, type: row.type, message: row.message, ideaId: row.ideaId || '' })), scoring: '((impact×2.4)+(confidence×1.2)+(urgency×1.4))/effort', }); }); app.post('/api/ideas', requireAgent, async (req, res) => { const data = ideaDataFromInput(req.body, { source: 'human', status: 'inbox', milestoneId: 'inbox' }); const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data })); await logActivity('idea.created', `Captured “${data.title}”`, row.$id, data.source); res.status(201).json(publicIdea(row)); }); app.post('/api/feature-set/import', requireAgent, async (req, res) => { const features = Array.isArray(req.body.features) ? req.body.features.slice(0, 100) : []; if (!features.length) return res.status(400).json({ error: 'Feature set must include a non-empty features array.' }); const defaults = req.body.defaults && typeof req.body.defaults === 'object' ? req.body.defaults : {}; const created = []; const errors = []; for (const [index, feature] of features.entries()) { try { const data = ideaDataFromInput(feature, { source: 'import', sourceName: req.body.name || 'Prioritix feature set', ...defaults }); const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data })); created.push(publicIdea(row)); } catch (error) { errors.push({ index, title: cleanText(feature?.title || feature?.name || '', 180), error: error.message }); } } if (created.length) await logActivity('feature_set.imported', `Imported ${created.length} feature${created.length === 1 ? '' : 's'}`, '', req.body.name || 'feature-set-v1'); res.status(created.length ? 201 : 400).json({ ok: errors.length === 0, imported: created.length, created, errors }); }); app.patch('/api/ideas/:id', requireAgent, async (req, res) => { const allowed = ['title', 'description', 'source', 'sourceName', 'status', 'milestoneId', 'impact', 'effort', 'confidence', 'urgency', 'rank', 'labels', 'notes', 'archived']; const data = {}; for (const key of allowed) { if (!(key in req.body)) continue; if (['impact', 'effort', 'confidence', 'urgency', 'rank'].includes(key)) data[key] = clampInt(req.body[key], key === 'effort' ? 5 : 0, key === 'effort' ? 1 : -100000, key === 'rank' ? 100000 : 10); else if (key === 'description' || key === 'notes') data[key] = cleanMultiline(req.body[key], key === 'description' ? 5000 : 4000); else if (key === 'labels') data[key] = encodeList(req.body[key]); else if (key === 'archived') data[key] = Boolean(req.body[key]); else data[key] = cleanText(req.body[key], key === 'title' ? 180 : 80); } if (['impact', 'effort', 'confidence', 'urgency'].some(k => k in data)) { const current = assertRow(await tables.getRow({ databaseId, tableId: ideasTableId, rowId: req.params.id })); data.score = scoreIdea({ ...current, ...data }); } const row = assertRow(await tables.updateRow({ databaseId, tableId: ideasTableId, rowId: req.params.id, data })); await logActivity('idea.updated', `Updated “${row.title}”`, row.$id, Object.keys(data).join(',')); res.json(publicIdea(row)); }); app.post('/api/milestones', requireAgent, async (req, res) => { const name = cleanText(req.body.name, 80); if (!name) return res.status(400).json({ error: 'name is required' }); const data = { name, description: cleanMultiline(req.body.description, 1000), horizon: cleanText(req.body.horizon || '', 80), color: cleanText(req.body.color || '#8cf7ff', 24), position: clampInt(req.body.position, 0, -10000, 10000), active: req.body.active !== false, }; const row = assertRow(await tables.createRow({ databaseId, tableId: milestonesTableId, rowId: ID.unique(), data })); await logActivity('milestone.created', `Added milestone “${name}”`, '', row.$id); res.status(201).json(publicMilestone(row)); }); app.patch('/api/milestones/:id', requireAgent, async (req, res) => { const data = {}; for (const key of ['name', 'description', 'horizon', 'color', 'position', 'active']) { if (!(key in req.body)) continue; if (key === 'position') data[key] = clampInt(req.body[key], 0, -10000, 10000); else if (key === 'active') data[key] = Boolean(req.body[key]); else if (key === 'description') data[key] = cleanMultiline(req.body[key], 1000); else data[key] = cleanText(req.body[key], key === 'name' ? 80 : 120); } const row = assertRow(await tables.updateRow({ databaseId, tableId: milestonesTableId, rowId: req.params.id, data })); await logActivity('milestone.updated', `Updated milestone “${row.name}”`, '', row.$id); res.json(publicMilestone(row)); }); app.post('/api/reorder', requireAgent, async (req, res) => { const updates = Array.isArray(req.body.updates) ? req.body.updates.slice(0, 100) : []; const changed = []; for (const item of updates) { if (!item?.id) continue; const data = {}; if ('rank' in item) data.rank = clampInt(item.rank, 0, -100000, 100000); if ('milestoneId' in item) data.milestoneId = cleanText(item.milestoneId, 64); if ('status' in item) data.status = cleanText(item.status, 40); if (Object.keys(data).length) { const row = assertRow(await tables.updateRow({ databaseId, tableId: ideasTableId, rowId: item.id, data })); changed.push(publicIdea(row)); } } if (changed.length) await logActivity('ideas.reordered', `Re-ranked ${changed.length} item${changed.length === 1 ? '' : 's'}`); res.json({ changed }); }); const judgementModes = { progress: { label: 'Fastest useful progress', weights: { value: 1.2, feasibility: 1.55, confidence: 1.25, urgency: 1.15, revenue: 0.55, novelty: 0.35, risk: -1.05 }, next: 'Test the highest-ranked low-effort option manually before adding product machinery.', }, mvp: { label: 'Best MVP order', weights: { value: 1.55, feasibility: 1.25, confidence: 1.15, urgency: 0.85, revenue: 0.75, novelty: 0.45, risk: -1.2 }, next: 'Build the smallest slice that proves the core user promise, then defer everything that needs the proof first.', }, revenue: { label: 'Revenue potential', weights: { value: 1.15, feasibility: 0.75, confidence: 0.95, urgency: 1.05, revenue: 1.85, novelty: 0.35, risk: -1.0 }, next: 'Validate willingness to pay before polishing delivery. A paid manual version beats a beautiful unpaid feature.', }, risk: { label: 'Risk reduction', weights: { value: 0.95, feasibility: 1.0, confidence: 1.45, urgency: 0.8, revenue: 0.45, novelty: 0.25, risk: -1.85 }, next: 'Start where uncertainty is highest and evidence is cheapest. Do not build around an untested assumption.', }, originality: { label: 'Most original / differentiated', weights: { value: 1.0, feasibility: 0.65, confidence: 0.75, urgency: 0.55, revenue: 0.65, novelty: 1.85, risk: -0.9 }, next: 'Keep the unusual angle, but demand one proof step so originality does not become expensive weirdness.', }, }; const wordSets = { revenue: ['pay', 'paid', 'price', 'pricing', 'stripe', 'checkout', 'invoice', 'sales', 'sell', 'buyer', 'subscription', 'revenue', 'client', 'customer', 'conversion', 'upsell'], value: ['pain', 'problem', 'save', 'faster', 'clear', 'clarity', 'decision', 'feedback', 'user', 'customer', 'relief', 'manual', 'core', 'must', 'need', 'trust'], effort: ['dashboard', 'workspace', 'workspaces', 'collaboration', 'realtime', 'integration', 'integrations', 'automation', 'accounts', 'auth', 'billing', 'subscription', 'pricing', 'checkout', 'invoices', 'ai', 'model', 'mobile', 'sync', 'admin', 'export', 'exportable', 'slack', 'notion', 'team', 'voting'], risk: ['unclear', 'maybe', 'complex', 'hard', 'risky', 'unknown', 'depends', 'enterprise', 'platform', 'everything', 'all users', 'team voting', 'marketplace', 'saved workspaces', 'team voting'], novelty: ['new', 'novel', 'different', 'unique', 'original', 'weird', 'unexpected', 'expert', 'judgement', 'reflection', 'rank', 'compare'], urgency: ['now', 'launch', 'blocker', 'first', 'mvp', 'today', 'week', 'urgent', 'stuck', 'before', 'next'], }; function hits(text, words) { const lower = ` ${String(text || '').toLowerCase()} `; return words.reduce((count, word) => count + (lower.includes(word) ? 1 : 0), 0); } function parseOptionsFromText(value) { const text = cleanMultiline(value, 12000); const lines = text.split('\n').map(line => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim()).filter(Boolean); const optionLines = lines.length >= 2 ? lines : text.split(/[;|]/).map(part => part.trim()).filter(Boolean); return optionLines.slice(0, 24).map((line, index) => { const [rawTitle, ...rest] = line.split(/\s[-–—:]\s/); return { id: `option-${index + 1}`, title: cleanText(rawTitle || line, 140), description: cleanText(rest.join(' — ') || line, 420), }; }).filter(item => item.title); } function cleanProvenance(input = {}) { const artifact = input.artifact && typeof input.artifact === 'object' ? input.artifact : {}; const source = input.source && typeof input.source === 'object' ? input.source : {}; return { schema: cleanText(input.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80), source: cleanText(input.sourceName || source.name || artifact.sourceName || 'Scattermind', 80), artifactId: cleanText(input.artifactId || input.sourceArtifactId || artifact.id || source.artifactId || '', 120), snapshotTitle: cleanText(input.snapshotTitle || artifact.snapshotTitle || input.ideaTitle || '', 160), conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || '', 120), originalPrompt: cleanMultiline(input.originalPrompt || input.initialPrompt || artifact.originalPrompt || source.originalPrompt || '', 1200), }; } function normalizeFeatureOption(item, index, fallbackId = 'feature') { const title = cleanText(item?.title || item?.name || item?.action || '', 140); const proofSteps = cleanTextList(item?.proofSteps || item?.proof || item?.validationSteps, 5, 180); const dependencies = cleanTextList(item?.dependencies || item?.blockedBy, 5, 120); const evidenceNeeded = cleanText(item?.evidenceNeeded || item?.evidence || item?.test || '', 260); const userValue = cleanText(item?.userValue || item?.value || item?.outcome || item?.why, 260); const risk = cleanText(item?.risk || item?.assumption || item?.unknown || '', 220); const sourceSection = cleanText(item?.sourceSection || item?.section || item?.lane || item?.origin || '', 80); const recommendedLane = cleanText(item?.recommendedLane || item?.laneHint || item?.suggestedLane || '', 40).toLowerCase(); const descriptionParts = [ item?.description || item?.brief || '', userValue && `User value: ${userValue}`, evidenceNeeded && `Evidence needed: ${evidenceNeeded}`, risk && `Risk: ${risk}`, proofSteps.length && `Proof steps: ${proofSteps.join('; ')}`, dependencies.length && `Dependencies: ${dependencies.join(', ')}`, ].filter(Boolean); return { id: cleanText(item?.id || item?.key || `${fallbackId}-${index + 1}`, 80) || `${fallbackId}-${index + 1}`, title, description: cleanText(descriptionParts.join(' '), 760), factors: { userValue, evidenceNeeded, risk, proofSteps, dependencies, recommendedLane, metricHints: cleanMetricHints(item) }, provenance: { sourceId: cleanText(item?.sourceId || item?.sourceArtifactId || item?.id || '', 120), sourceSection, }, }; } function optionsFromBody(body = {}) { const featureSet = body.featureSet && typeof body.featureSet === 'object' ? body.featureSet : {}; const rawFeatures = Array.isArray(body.features) ? body.features : Array.isArray(featureSet.features) ? featureSet.features : null; if (rawFeatures) { return rawFeatures.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index)).filter(item => item.title); } if (Array.isArray(body.options)) { return body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option')).filter(item => item.title); } return parseOptionsFromText(body.optionsText || featureSet.optionsText || ''); } function scoreOption(option, mode, context = '') { const factors = option.factors || {}; const text = `${option.title} ${option.description} ${factors.userValue || ''} ${factors.evidenceNeeded || ''} ${factors.risk || ''} ${(factors.proofSteps || []).join(' ')} ${context}`; const effortHits = hits(text, wordSets.effort); const riskHits = hits(text, wordSets.risk); const coreLoopHits = hits(text, ['ranked feedback map', 'feedback map', 'pasted feature', 'feature lists', 'first-pass', 'decision brief', 'expert reflections']); const bridgeHits = hits(text, ['snapshot', 'concept map', 'feature set', 'build order', 'rank-ready', 'provenance', 'next moves']); const proofHits = hits(text, ['evidence needed', 'proof steps', 'manual', 'test with', 'validate', '3 users', 'interview', 'mock', 'prototype']); const swampHits = hits(text, ['dashboard', 'workspace', 'workspaces', 'auth', 'accounts', 'billing', 'subscription', 'team voting', 'collaboration', 'admin']); const dependencyPenalty = Math.min(2.2, (factors.dependencies || []).length * 0.45); const laneHint = factors.recommendedLane || ''; const normalizedLaneHint = normalizeLaneHint(laneHint); const laneBoost = /do|first|now|build/.test(laneHint) ? 0.55 : /validate|test|proof/.test(laneHint) ? 0.25 : /defer|park|cut/.test(laneHint) ? -0.75 : 0; const lanePenalty = normalizedLaneHint === 'park' ? 18 : normalizedLaneHint === 'defer' ? 9 : 0; const heuristicMetrics = { value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + bridgeHits * 0.75 + proofHits * 0.2 + hits(context, wordSets.value) * 0.15), feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 - dependencyPenalty + Math.min(1.1, coreLoopHits * 0.28 + bridgeHits * 0.18 + proofHits * 0.12))), confidence: Math.max(1, Math.min(10, 5.8 + coreLoopHits * 0.35 + bridgeHits * 0.28 + proofHits * 0.32 + hits(text, ['manual', 'existing', 'already', 'simple', 'clear', 'known']) * 0.7 - hits(text, ['maybe', 'unknown', 'new market', 'all users']) * 0.9)), urgency: Math.min(10, 4.9 + hits(text, wordSets.urgency) * 0.9), revenue: Math.min(10, 3.8 + hits(text, wordSets.revenue) * 1.05), novelty: Math.min(10, 4.1 + hits(text, wordSets.novelty) * 0.95), risk: Math.min(10, 2.5 + riskHits * 1.1 + Math.max(0, effortHits - 2) * 0.45 + swampHits * 0.2 + dependencyPenalty), }; const hinted = factors.metricHints || {}; const hintedFeasibility = Number.isFinite(hinted.effort) ? 11 - hinted.effort : undefined; const metrics = { value: blendMetric(heuristicMetrics.value, hinted.value), feasibility: blendMetric(heuristicMetrics.feasibility, hintedFeasibility), confidence: blendMetric(heuristicMetrics.confidence, hinted.confidence), urgency: blendMetric(heuristicMetrics.urgency, hinted.urgency), revenue: blendMetric(heuristicMetrics.revenue, hinted.revenue), novelty: blendMetric(heuristicMetrics.novelty, hinted.novelty), risk: blendMetric(heuristicMetrics.risk, hinted.risk), }; const weights = mode.weights; const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0); const possible = Object.entries(weights).reduce((sum, [_key, weight]) => sum + Math.abs(weight) * 10, 0); const score = Math.max(0, Math.min(100, Math.round(((weighted + laneBoost) + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100) - lanePenalty)); return { ...metrics, score }; } function normalizeLaneHint(value = '') { const hint = cleanText(value, 40).toLowerCase().replace(/_/g, '-'); if (/^(do|do-first|first|build|build-now|now)$/.test(hint)) return 'do'; if (/^(validate|validate-next|test|test-next|proof|evidence)$/.test(hint)) return 'test'; if (/^(defer|later|sequence-later|after-proof)$/.test(hint)) return 'defer'; if (/^(park|cut|drop|icebox|not-now)$/.test(hint)) return 'park'; return ''; } function laneFor(option, rankIndex, total) { const hintedLane = normalizeLaneHint(option.factors?.recommendedLane || ''); // Ranker should defend build order, not blindly obey Scattermind. Positive // hints can nudge scoring, but explicit negative hints are safety rails: // if the source already marked something as defer/park, never promote it // into the active proof slice just because keyword scoring liked it. if (hintedLane === 'park') return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan', source: 'hint' }; if (hintedLane === 'defer') return { id: 'defer', label: 'Defer', action: 'Sequence after proof', source: 'hint' }; if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' }; if (hintedLane === 'test') return { id: 'test', label: 'Validate next', action: 'Find evidence', source: 'hint' }; if (rankIndex < Math.max(2, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' }; if (rankIndex >= Math.max(2, Math.floor(total * 0.72))) return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan' }; return { id: 'defer', label: 'Defer', action: 'Sequence after proof' }; } function reasonFor(option) { const m = option.metrics; if (option.lane?.id === 'do' && /snapshot|concept map|feature set|build order|rank/i.test(`${option.title} ${option.description}`)) return 'it strengthens the Scattermind → Ranker bridge instead of inventing a generic workspace'; if (option.factors?.evidenceNeeded && m.confidence >= 6.4) return 'it names the evidence needed, so the next move can be tested instead of guessed'; if (m.feasibility >= 7.2 && m.value >= 6.2) return 'it has high enough value with low enough delivery drag to create fast signal'; if (m.revenue >= 6.4) return 'it has a clearer buyer or money signal than the rest of the list'; if (m.risk >= 6.5) return 'it is interesting, but carries assumption risk that should be tested before build'; if (m.novelty >= 6.7) return 'it is more differentiated than the safe options, but still needs proof'; return 'it has the best balanced tradeoff across value, effort, confidence, and timing'; } function concernFor(option) { const m = option.metrics; if ((option.factors?.dependencies || []).length >= 3) return 'Too many prerequisites. Split the proof slice before treating this as build-ready.'; if (m.risk >= 6.5) return 'The hidden risk is pretending this is ready to build before the core assumption is proven.'; if (m.feasibility <= 4.5) return 'The likely trap is scope creep: this may need too much machinery for an MVP.'; if (m.confidence <= 4.5) return 'Evidence looks thin. Treat this as a question, not a roadmap item.'; if (m.revenue <= 4 && m.value >= 6) return 'Useful does not automatically mean sellable. Check willingness to pay or repeat use.'; return 'The main risk is sequencing: do it only if it supports the first useful proof.'; } function createDecisionBrief({ idea, context, mode, ranked, provenance }) { const top = ranked[0]; const second = ranked[1]; const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0]; const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3); const sourceLabel = [provenance?.snapshotTitle, provenance?.artifactId].filter(Boolean).join(' · '); const theme = top ? `The strongest signal is “${top.title}” because ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.'; return { headline: top ? `Start with ${top.title}` : 'Add options to get a ranked feedback map', summary: `${theme}${second ? ` “${second.title}” is the nearest follow-up, not a parallel first step.` : ''}${sourceLabel ? ` Source: ${sourceLabel}.` : ''}`, source: provenance ? { schema: provenance.schema, source: provenance.source, artifactId: provenance.artifactId, snapshotTitle: provenance.snapshotTitle, conceptMapId: provenance.conceptMapId, } : null, expertReflections: [ { lens: 'Product expert', text: top ? `The ranking says the first job is not to build more surface area; it is to prove the highest-signal option. ${mode.next}` : 'A product expert would ask for concrete alternatives before giving serious advice.', }, { lens: 'Scattermind simplifier', text: deferred.length ? `Park ${deferred.map(item => `“${item.title}”`).join(', ')} for now. Not bad ideas — just mental tabs you do not need open while testing the first move.` : 'The list is already narrow. Keep it that way; do not add a fake backlog around a simple decision.', }, { lens: 'Structured operator', text: risky ? `The decision quality depends on one assumption: ${risky.title}. What evidence would make this move up or down the list? Write that before committing resources.` : 'Expose the criteria, the confidence, and the thing that would change the decision. That is what makes this defensible.', }, ], next48Hours: top ? [ provenance?.artifactId ? `Open the source artifact (${provenance.artifactId}) and mark “${top.title}” as the defended first move.` : `Write a one-paragraph test for “${top.title}”.`, top.factors?.evidenceNeeded ? `Evidence to collect: ${top.factors.evidenceNeeded}.` : 'Name the evidence that would make this decision obviously right or wrong.', 'Put it in front of 3–5 real people or run it manually once.', `Do not touch ${deferred[0] ? `“${deferred[0].title}”` : 'the parked ideas'} until the first signal is real.`, ] : ['Paste 3–10 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'], caution: 'This is first-pass judgement, not an oracle. Change the criteria if the context changes.', }; } function createHandoffContract({ ranked, provenance }) { const warnings = []; if (!provenance?.artifactId) warnings.push('missing source artifact id'); if (!provenance?.originalPrompt) warnings.push('missing original prompt provenance'); const itemTrace = ranked.map(item => { if (!item.provenance?.sourceSection) warnings.push(`missing source section for ${item.id}`); if (!item.factors?.evidenceNeeded && ['do', 'test'].includes(item.lane?.id)) warnings.push(`missing evidence needed for active item ${item.id}`); return { id: item.id, title: item.title, lane: item.lane?.id || 'defer', sourceSection: item.provenance?.sourceSection || '', sourceId: item.provenance?.sourceId || '', evidenceNeeded: item.factors?.evidenceNeeded || '', }; }); return { schema: 'rank-feedback-result-v1', source: { schema: provenance?.schema || '', source: provenance?.source || '', artifactId: provenance?.artifactId || '', snapshotTitle: provenance?.snapshotTitle || '', conceptMapId: provenance?.conceptMapId || '', hasOriginalPrompt: Boolean(provenance?.originalPrompt), }, itemTrace, warnings: [...new Set(warnings)], }; } app.post('/api/rank-feedback', (req, res) => { const idea = cleanMultiline(req.body?.idea || '', 3000); const context = cleanMultiline(req.body?.context || '', 3000); const modeId = cleanText(req.body?.mode || 'progress', 40); const mode = judgementModes[modeId] || judgementModes.progress; const provenance = cleanProvenance(req.body || {}); let options = optionsFromBody(req.body || {}); if (options.length < 2) return res.status(400).json({ error: 'Paste at least two options, features, ideas, or next moves to rank.' }); const bridgeContext = `${idea}\n${context}\n${provenance.snapshotTitle}\n${provenance.schema}`; options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, bridgeContext) })) .sort((a, b) => b.metrics.score - a.metrics.score || b.metrics.value - a.metrics.value || a.metrics.risk - b.metrics.risk) .map((option, index, arr) => { const lane = laneFor(option, index, arr.length); const rankedOption = { ...option, rank: index + 1, lane }; return { ...rankedOption, reason: reasonFor(rankedOption), concern: concernFor(rankedOption), }; }); const brief = createDecisionBrief({ idea, context, mode, ranked: options, provenance }); const handoff = createHandoffContract({ ranked: options, provenance }); res.json({ ok: true, mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label }, input: { idea, context, optionCount: options.length, provenance }, ranked: options, brief, handoff, buildOrder: { doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id), validateNext: options.filter(item => item.lane.id === 'test').map(item => item.id), defer: options.filter(item => item.lane.id === 'defer').map(item => item.id), park: options.filter(item => item.lane.id === 'park').map(item => item.id), }, }); }); app.get(/.*/, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html'))); app.use((error, _req, res, _next) => { console.error('[rank]', error); res.status(error.code && error.code >= 400 && error.code < 600 ? error.code : 500).json({ error: error.message || 'Internal error' }); }); app.listen(PORT, () => console.log(`[rank] ${appVersion} listening on :${PORT}`));