Files
rank/server.js
T
2026-05-27 00:19:33 +02:00

1164 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 cleanFlexibleTextList(value, maxItems = 8, maxText = 180) {
if (Array.isArray(value)) return value.map(item => cleanText(item, maxText)).filter(Boolean).slice(0, maxItems);
const text = cleanMultiline(value || '', maxItems * maxText);
if (!text) return [];
return text
.split(/\n|;|\|/)
.map(item => item.replace(/^\s*[-*•\d.)]+\s*/, '').trim())
.filter(Boolean)
.map(item => cleanText(item, maxText))
.slice(0, maxItems);
}
function uniqueList(items = [], maxItems = 8) {
const seen = new Set();
return items.filter(item => {
const cleaned = cleanText(item, 180);
const key = cleaned.toLowerCase();
if (!cleaned || seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, maxItems);
}
function contextSentences(value = '') {
return cleanMultiline(value, 3000)
.split(/\n|;|\.|\|/)
.map(item => item.replace(/^\s*[-*•\d.)]+\s*/, '').trim())
.filter(Boolean);
}
function guardrailsFromContextText(value = '') {
const nonGoals = [];
const constraints = [];
for (const sentence of contextSentences(value)) {
const cleaned = cleanText(sentence, 180);
if (!cleaned) continue;
if (/^(avoid|no|do not|don't|dont|must not|never)\b/i.test(cleaned)) nonGoals.push(cleaned);
else if (/\b(avoid|no auth|no account|no billing|no workspace|not a dashboard|without accounts|before proof|manual proof|solo builder|constraint)\b/i.test(cleaned)) constraints.push(cleaned);
}
return { nonGoals: uniqueList(nonGoals), constraints: uniqueList(constraints) };
}
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: 'Safest proof order',
weights: { value: 0.95, feasibility: 1.0, confidence: 1.45, urgency: 0.8, revenue: 0.45, novelty: 0.25, risk: -1.85 },
next: 'Start with the option that reduces risk without betting the roadmap on it.',
},
validation: {
label: 'Fastest validation',
weights: { value: 1.25, feasibility: 1.7, confidence: 1.35, urgency: 1.1, revenue: 0.35, novelty: 0.25, risk: -0.85 },
next: 'Choose the option that can produce real user evidence fastest, even if the first version is manual.',
},
learning: {
label: 'Biggest assumption test',
weights: { value: 1.1, feasibility: 1.35, confidence: -0.35, urgency: 0.55, revenue: 0.45, novelty: 0.45, risk: 1.15 },
next: 'Pick the riskiest important assumption that can be tested cheaply. The goal is learning, not shipping surface area.',
},
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, sourceSection = 'optionsText') {
const text = cleanMultiline(value, 12000);
const candidateText = /\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\b[:\s]/i.test(text)
? text.replace(/^[\s\S]*?\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\b[:\s]*/i, '')
: text;
const cleanedLines = candidateText.split('\n').map(line => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim()).filter(Boolean);
const sentenceList = candidateText
.replace(/\b(maybe|possibly|options?|features?|ideas?|next moves?|functionality|backlog)\s*:/gi, '\n')
.split(/\n|;|\|/)
.map(part => part.replace(/^\s*[-*•\d.)]+\s*/, '').trim())
.filter(Boolean);
const commaList = sentenceList.length === 1
? sentenceList[0].split(/,|\s+and\s+|[.!?]\s+/i).map(part => part.trim()).filter(Boolean)
: [];
const optionLines = cleanedLines.length >= 2
? cleanedLines
: sentenceList.length >= 2
? sentenceList
: commaList.length >= 2
? commaList
: candidateText.split(/[;|]/).map(part => part.trim()).filter(Boolean);
return optionLines.slice(0, 24).map((line, index) => {
const normalized = line
.replace(/^\s*(maybe|possibly|also|plus|and|or|then|later|eventually|build|add|include|some kind of|kind of)\b\s*/i, '')
.replace(/\?+$/, '')
.trim();
const [rawTitle, ...rest] = normalized.split(/\s*[-–—:]\s+/);
return {
id: `option-${index + 1}`,
title: cleanText(rawTitle || normalized || line, 140),
description: cleanText(rest.join(' — '), 420),
provenance: { sourceSection },
};
}).filter(item => item.title && !/^(i('| a)?m|i am|we are|i only|want|need)\b/i.test(item.title));
}
function objectFrom(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
}
function looksLikeRankPayload(value = {}) {
return Boolean(
value.schema
|| value.featureSet
|| value.conceptMap
|| value.lenses
|| value.reference_code
|| value.referenceCode
|| value.artifactId
|| value.sourceArtifactId
|| value.ideaText
|| Array.isArray(value.features)
|| Array.isArray(value.actions)
|| Array.isArray(value.nextMoves)
|| Array.isArray(value.candidates)
);
}
function parseEmbeddedRankPayload(value = '') {
const text = cleanMultiline(value, 12000);
if (!text.startsWith('{') || !text.endsWith('}')) return null;
try {
const parsed = JSON.parse(text);
return looksLikeRankPayload(parsed) ? parsed : null;
} catch {
return null;
}
}
function expandEmbeddedRankPayload(body = {}) {
const original = objectFrom(body);
for (const key of ['payload', 'rankPayload', 'scattermindPayload', 'conceptMapJson', 'idea', 'ideaText', 'optionsText']) {
const embedded = typeof original[key] === 'string'
? parseEmbeddedRankPayload(original[key])
: looksLikeRankPayload(original[key])
? original[key]
: null;
if (!embedded) continue;
const expanded = { ...original, ...embedded };
if (key === 'idea' && !embedded.idea && !embedded.ideaText) expanded.idea = '';
if (key === 'optionsText' && !embedded.optionsText) expanded.optionsText = '';
if (original.mode && !embedded.mode) expanded.mode = original.mode;
if (original.context && !embedded.context) expanded.context = original.context;
expanded._embeddedPayloadSource = key;
return expanded;
}
return original;
}
function cleanProvenance(input = {}) {
const featureSet = objectFrom(input.featureSet);
const artifact = objectFrom(input.artifact || featureSet.artifact);
const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap);
const snapshot = objectFrom(input.snapshot || featureSet.snapshot || artifact.snapshot || conceptMap.snapshot);
const source = objectFrom(input.source || featureSet.source || artifact.source);
return {
schema: cleanText(input.schema || featureSet.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80),
source: cleanText(input.sourceName || featureSet.sourceName || source.name || artifact.sourceName || 'Scattermind', 80),
artifactId: cleanText(input.artifactId || input.sourceArtifactId || input.referenceCode || input.reference_code || artifact.id || source.artifactId || conceptMap.artifactId || conceptMap.id || conceptMap.referenceCode || conceptMap.reference_code || snapshot.artifactId || snapshot.id || '', 120),
snapshotTitle: cleanText(input.snapshotTitle || input.working_name || input.workingName || artifact.snapshotTitle || snapshot.title || snapshot.name || conceptMap.snapshotTitle || conceptMap.working_name || conceptMap.workingName || input.ideaTitle || '', 160),
conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || conceptMap.id || conceptMap.artifactId || input.referenceCode || input.reference_code || conceptMap.referenceCode || conceptMap.reference_code || '', 120),
originalPrompt: cleanMultiline(input.originalPrompt || input.initialPrompt || input.ideaText || input.prompt || artifact.originalPrompt || source.originalPrompt || snapshot.originalPrompt || snapshot.prompt || conceptMap.originalPrompt || conceptMap.ideaText || input.idea || '', 1200),
};
}
function lensContent(lens = {}) {
if (Array.isArray(lens)) return lens;
if (typeof lens === 'string' || typeof lens === 'number') return String(lens);
const obj = objectFrom(lens);
return obj.content || obj.text || obj.summary || obj.items || '';
}
function cleanSentenceList(value = '', maxItems = 8, maxText = 180) {
if (Array.isArray(value)) return cleanFlexibleTextList(value, maxItems, maxText);
return contextSentences(value).map(item => cleanText(item, maxText)).filter(Boolean).slice(0, maxItems);
}
function collectContextList(sources = [], aliases = [], maxItems = 8) {
return uniqueList(sources.flatMap(source => {
const obj = objectFrom(source);
return aliases.flatMap(alias => cleanFlexibleTextList(obj[alias], maxItems, 180));
}), maxItems);
}
function firstContextText(sources = [], aliases = []) {
for (const source of sources) {
const obj = objectFrom(source);
for (const alias of aliases) {
const cleaned = cleanText(obj[alias], 180);
if (cleaned) return cleaned;
}
}
return '';
}
function cleanDecisionContext(input = {}) {
const featureSet = objectFrom(input.featureSet);
const artifact = objectFrom(input.artifact || featureSet.artifact);
const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap);
const conceptMapLenses = objectFrom(conceptMap.lenses || input.lenses || featureSet.lenses);
const riskLens = conceptMapLenses.risk || conceptMapLenses.risks || conceptMapLenses.boundaries || conceptMapLenses.notYet;
const audienceLens = conceptMapLenses.audience || conceptMapLenses.who || conceptMapLenses.customer || conceptMapLenses.users;
const constraintsLens = conceptMapLenses.constraints || conceptMapLenses.boundaries || conceptMapLenses.scope;
const assumptionsLens = conceptMapLenses.assumptions || conceptMapLenses.unknowns || conceptMapLenses.openQuestions;
const structuredContext = objectFrom(input.context);
const contextSources = [
input.decisionContext,
featureSet.decisionContext,
artifact.decisionContext,
conceptMap.decisionContext,
structuredContext,
conceptMap.context,
featureSet.context,
artifact.context,
];
const textContextGuardrails = guardrailsFromContextText([
typeof input.context === 'string' ? input.context : '',
lensContent(riskLens),
lensContent(constraintsLens),
conceptMap.risk || conceptMap.whatNotToBuildYet || conceptMap.notYet || '',
].filter(Boolean).join('\n'));
return {
targetAudience: cleanText(input.targetAudience || featureSet.targetAudience || firstContextText(contextSources, ['targetAudience', 'audience', 'who', 'whoItHelps', 'customer', 'users']) || conceptMap.targetAudience || lensContent(audienceLens), 180),
constraints: uniqueList([
...cleanFlexibleTextList(input.constraints || featureSet.constraints || conceptMap.constraints, 8, 180),
...collectContextList(contextSources, ['constraints', 'constraint', 'boundaries', 'scope'], 8),
...cleanSentenceList(lensContent(constraintsLens), 8, 180),
...textContextGuardrails.constraints,
], 8),
nonGoals: uniqueList([
...cleanFlexibleTextList(input.nonGoals || input.avoid || featureSet.nonGoals || featureSet.avoid || conceptMap.nonGoals || conceptMap.avoid, 8, 180),
...collectContextList(contextSources, ['nonGoals', 'nonGoal', 'avoid', 'notYet', 'doNotBuild'], 8),
...textContextGuardrails.nonGoals,
], 8),
assumptions: uniqueList([
...cleanFlexibleTextList(input.assumptions || featureSet.assumptions || conceptMap.assumptions, 6, 180),
...collectContextList(contextSources, ['assumptions', 'assumption', 'unknowns', 'openQuestions'], 6),
...cleanSentenceList(lensContent(assumptionsLens), 6, 180),
], 6),
};
}
function cleanContextText(value = '') {
if (!value || typeof value !== 'object' || Array.isArray(value)) return cleanMultiline(value || '', 3000);
const pieces = [
value.summary || value.description || value.notes || value.brief || '',
value.targetAudience && `Target audience: ${value.targetAudience}`,
...cleanFlexibleTextList(value.constraints, 8, 180).map(item => `Constraint: ${item}`),
...cleanFlexibleTextList(value.nonGoals || value.avoid, 8, 180).map(item => `Non-goal: ${item}`),
...cleanFlexibleTextList(value.assumptions, 6, 180).map(item => `Assumption: ${item}`),
].filter(Boolean);
return cleanMultiline(pieces.join('\n'), 3000);
}
function meaningfulTokens(text = '') {
const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'user', 'users', 'idea', 'ideas', 'build', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'manual', 'value', 'layer']);
return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8);
}
function nonGoalConflicts(optionText, decisionContext = {}) {
const lower = String(optionText || '').toLowerCase();
return (decisionContext.nonGoals || []).filter(nonGoal => {
const tokens = meaningfulTokens(nonGoal);
return tokens.length > 0 && tokens.some(token => {
if (lower.includes(token)) return true;
const singular = token.endsWith('ies') ? `${token.slice(0, -3)}y` : token.replace(/(?:es|s)$/, '');
return singular.length >= 4 && lower.includes(singular);
});
});
}
function normalizeFeatureOption(item, index, fallbackId = 'feature', defaultSourceSection = '', defaultRecommendedLane = '') {
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 || defaultSourceSection, 80);
const recommendedLane = cleanText(item?.recommendedLane || item?.laneHint || item?.suggestedLane || defaultRecommendedLane || '', 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 normalizeOptionIds(options = []) {
const seen = new Map();
return options.map((option, index) => {
const baseId = cleanText(option.id || `option-${index + 1}`, 80) || `option-${index + 1}`;
const count = seen.get(baseId) || 0;
seen.set(baseId, count + 1);
if (count === 0) return { ...option, id: baseId };
return {
...option,
id: cleanText(`${baseId}-${count + 1}`, 80),
provenance: {
...(option.provenance || {}),
originalId: baseId,
idNormalized: true,
},
};
});
}
function compactCandidateGroup(group = []) {
return group.filter(entry => Array.isArray(entry?.items) && entry.items.length > 0);
}
function normalizeCandidateGroup(group = []) {
const options = group.flatMap(entry => {
const fallbackId = entry.sourceSection.toLowerCase().includes('action') ? 'action' : 'feature';
return (entry.items || []).slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, fallbackId, entry.sourceSection, entry.defaultLane));
}).filter(item => item.title).slice(0, 24);
return normalizeOptionIds(options);
}
function sentenceFragments(text = '') {
return cleanMultiline(text, 4000)
.replace(/\s+(build first|start here|ship first|test manually|validate next|defer|probably noise|park|do not build yet|don't build yet)\s*:/gi, '\n$1:')
.split(/\n|;|\s+[•-]\s+/)
.map(part => part.trim())
.filter(Boolean);
}
function titleFromBuildOrderFragment(value = '') {
const cleaned = cleanText(value.replace(/^(build first|start here|ship first|test manually|validate next|defer|probably noise|park|do not build yet|don't build yet)\s*:\s*/i, ''), 220);
const first = cleaned.split(/\s[-–—:]\s|\.\s/)[0] || cleaned;
return cleanText(first, 120);
}
function laneFromBuildOrderLabel(fragment = '') {
if (/^(build first|start here|ship first)\s*:/i.test(fragment)) return 'do-first';
if (/^(test manually|validate next)\s*:/i.test(fragment)) return 'validate-next';
if (/^(defer|do not build yet|don't build yet)\s*:/i.test(fragment)) return 'defer';
if (/^(probably noise|park)\s*:/i.test(fragment)) return 'park';
return '';
}
function optionsFromBuildOrderText(text = '', sourceSection = 'concept-map.lenses.channel') {
const fragments = sentenceFragments(text);
const labelled = fragments.filter(fragment => laneFromBuildOrderLabel(fragment));
return labelled.map((fragment, index) => {
const lane = laneFromBuildOrderLabel(fragment);
return {
id: `build-order-${index + 1}`,
action: titleFromBuildOrderFragment(fragment),
why: fragment.replace(/^\s*[^:]{1,40}:\s*/, '').trim(),
evidence: /test|validate|proof|prove|signal|ask|show/i.test(fragment) ? fragment : (lane === 'do-first' ? 'Prove this first move manually before adding product machinery.' : ''),
suggestedLane: lane,
rankerHints: lane === 'do-first'
? { value: 8, effort: 2, confidence: 7, urgency: 7, risk: 2 }
: lane === 'validate-next'
? { value: 7, effort: 3, confidence: 6, urgency: 5, risk: 3 }
: undefined,
sourceSection,
};
}).filter(item => item.action);
}
function optionsFromBody(body = {}) {
const featureSet = objectFrom(body.featureSet);
const conceptMap = objectFrom(body.conceptMap || featureSet.conceptMap);
const conceptMapLenses = objectFrom(conceptMap.lenses || body.lenses || featureSet.lenses);
const buildOrderLens = objectFrom(conceptMapLenses.channel || conceptMapLenses.buildOrder || conceptMap.buildOrder);
const directCandidateGroup = compactCandidateGroup([
{ items: body.features, sourceSection: 'features' },
{ items: featureSet.features, sourceSection: 'feature-set.features' },
{ items: body.actions, sourceSection: 'actions' },
{ items: featureSet.actions, sourceSection: 'feature-set.actions' },
{ items: body.nextMoves, sourceSection: 'nextMoves' },
{ items: featureSet.nextMoves, sourceSection: 'feature-set.nextMoves' },
{ items: body.candidates, sourceSection: 'candidates' },
{ items: featureSet.candidates, sourceSection: 'feature-set.candidates' },
]);
const conceptMapCandidateGroup = compactCandidateGroup([
{ items: conceptMap.nextActions, sourceSection: 'concept-map.nextActions' },
{ items: conceptMap.nextMoves, sourceSection: 'concept-map.nextMoves' },
{ items: conceptMap.features, sourceSection: 'concept-map.features' },
{ items: conceptMap.candidates, sourceSection: 'concept-map.candidates' },
{ items: conceptMap.validateNext || conceptMap.validate || conceptMap.validation, sourceSection: 'concept-map.validateNext', defaultLane: 'validate-next' },
{ items: conceptMap.deferred || conceptMap.defer || conceptMap.later, sourceSection: 'concept-map.deferred', defaultLane: 'defer' },
{ items: conceptMap.parkingLot || conceptMap.park || conceptMap.parked, sourceSection: 'concept-map.parkingLot', defaultLane: 'park' },
]);
const groupedCandidates = [...directCandidateGroup, ...conceptMapCandidateGroup];
if (groupedCandidates.length) return normalizeCandidateGroup(groupedCandidates);
const buildOrderText = lensContent(conceptMapLenses.channel)
|| lensContent(conceptMapLenses.buildOrder)
|| lensContent(conceptMap.buildOrder)
|| buildOrderLens.content
|| buildOrderLens.text
|| '';
const buildOrderOptions = optionsFromBuildOrderText(buildOrderText);
if (buildOrderOptions.length) return normalizeCandidateGroup([{ items: buildOrderOptions, sourceSection: 'concept-map.lenses.channel' }]);
if (Array.isArray(body.options)) {
return normalizeOptionIds(body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title));
}
const fallbackText = body.optionsText || featureSet.optionsText || conceptMap.optionsText || body.idea || body.ideaText || '';
const fallbackSourceSection = body.optionsText || featureSet.optionsText || conceptMap.optionsText
? 'optionsText'
: body.idea
? 'idea'
: body.ideaText
? 'ideaText'
: 'optionsText';
return normalizeOptionIds(parseOptionsFromText(fallbackText, fallbackSourceSection));
}
function scoreOption(option, mode, context = '', decisionContext = {}) {
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 conflicts = nonGoalConflicts(`${option.title} ${option.description}`, decisionContext);
const nonGoalPenalty = Math.min(14, conflicts.length * 7);
const laneBoost = /do|first|now|build/.test(laneHint) ? 1.35 : /validate|test|proof/.test(laneHint) ? 0.35 : /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 - conflicts.length * 1.1 + 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 + conflicts.length * 1.25),
};
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 - nonGoalPenalty));
return { ...metrics, score, nonGoalConflicts: conflicts };
}
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 and source non-goals
// are safety rails: if the source already marked something as not-now, or
// if the candidate conflicts with the source guardrails, 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 (option.metrics?.nonGoalConflicts?.length) return { id: 'defer', label: 'Defer', action: 'Resolve source guardrail first', source: 'source-non-goal' };
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 scoreDriversFor(option) {
const m = option.metrics || {};
const drivers = [];
if (m.nonGoalConflicts?.length) drivers.push('source guardrail conflict');
if (m.value >= 7) drivers.push('strong user value');
if (m.feasibility >= 7) drivers.push('low delivery drag');
if (m.confidence >= 7) drivers.push('clear proof path');
if (m.revenue >= 6.5) drivers.push('buyer signal');
if (m.novelty >= 6.5) drivers.push('differentiated angle');
if (option.factors?.evidenceNeeded) drivers.push('explicit evidence needed');
if ((option.factors?.dependencies || []).length === 0) drivers.push('few dependencies');
if (m.risk >= 6.5) drivers.push('high assumption risk');
return drivers.slice(0, 4);
}
function reasonFor(option) {
const m = option.metrics;
const drivers = scoreDriversFor(option).filter(driver => driver !== 'source guardrail conflict' && driver !== 'high assumption risk');
if (m.nonGoalConflicts?.length) return `it conflicts with the source non-goal “${m.nonGoalConflicts[0]}”, so it should not lead the build order`;
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 (drivers.length >= 2) return `it wins on ${drivers.join(', ')} while staying inside the current proof slice`;
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.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 important enough to investigate, 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 (m.nonGoalConflicts?.length) return `Source context says not to do this yet: ${m.nonGoalConflicts.join('; ')}.`;
if ((option.factors?.dependencies || []).length >= 3) return 'Too many prerequisites. Split the proof slice before treating this as build-ready.';
if (option.factors?.risk) return `The explicit risk is: ${option.factors.risk}.`;
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 evidenceQuestionFor(option) {
if (!option) return '';
if (option.factors?.evidenceNeeded) return option.factors.evidenceNeeded;
if (option.lane?.id === 'park') return `What would make “${option.title}” worth reopening later?`;
if (option.metrics?.revenue >= 6.4) return `Will a real buyer ask for or pay for “${option.title}” before it is polished?`;
if (option.metrics?.risk >= 6.2) return `What is the cheapest test that could disprove “${option.title}”?`;
return `Can 35 target users understand and act on “${option.title}” without extra explanation?`;
}
function nextStepFor(option) {
if (!option) return '';
if (option.lane?.id === 'do') return `Run one manual proof of “${option.title}” before building supporting machinery.`;
if (option.lane?.id === 'test') return `Design the smallest evidence test for “${option.title}” and collect signal from real users.`;
if (option.lane?.id === 'defer') return `Keep “${option.title}” sequenced after the active proof; do not parallel-build it.`;
return `Park “${option.title}” unless new evidence changes the decision.`;
}
function successSignalFor(option) {
if (!option) return '';
if (option.metrics?.revenue >= 6.4) return 'A real prospect asks for the outcome, accepts a price, or requests the next step.';
if (option.lane?.id === 'do') return 'At least 2 of 3 real users can name why this should be first and what they would do next.';
if (option.lane?.id === 'test') return 'The test produces a clear yes/no learning, not polite interest.';
return 'New evidence makes this more urgent than the current active lane.';
}
function killSignalFor(option) {
if (!option) return '';
if (option.metrics?.nonGoalConflicts?.length) return 'It still conflicts with the source guardrails after review.';
if (option.metrics?.feasibility <= 4.5) return 'The proof slice needs platform work before any user signal exists.';
return 'People understand the idea but do not take, request, or value the next step.';
}
function scoringNotesFor(option) {
const notes = [];
const m = option.metrics || {};
if (option.factors?.evidenceNeeded) notes.push('Boosted because it names evidence to collect.');
if (m.nonGoalConflicts?.length) notes.push('Penalized because it conflicts with source guardrails.');
if (m.feasibility >= 7) notes.push('Boosted for low delivery drag.');
if (m.value >= 7) notes.push('Boosted for user value.');
if (m.revenue >= 6.5) notes.push('Boosted for buyer signal.');
if (m.risk >= 6.5) notes.push('Flagged as assumption-heavy.');
if ((option.factors?.dependencies || []).length >= 2) notes.push('Penalized for dependencies.');
return notes.slice(0, 4);
}
function whatWouldChangeRanking(top, second, risky) {
if (!top) return ['Add at least two concrete next moves with evidence needed.'];
const changes = [];
if (top.factors?.evidenceNeeded) changes.push(`If evidence fails for “${top.title}” (${top.factors.evidenceNeeded}), move it out of Do first.`);
else changes.push(`If “${top.title}” cannot name a cheap proof step, demote it until the evidence question is clear.`);
if (second) changes.push(`If “${second.title}” gets stronger proof or meaningfully lower effort, it can overtake the current first move.`);
if (risky && risky.id !== top.id) changes.push(`If the riskiest assumption around “${risky.title}” is answered cheaply, re-rank before building around it.`);
changes.push('If the source Concept Map adds new constraints or non-goals, re-run the order instead of editing around stale context.');
return changes.slice(0, 4);
}
function createDecisionBrief({ idea, context, mode, ranked, provenance, decisionContext }) {
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.';
const quickGlance = top ? {
topPick: top.title,
topLane: top.lane?.label || '',
whyThisWins: reasonFor(top),
nextAction: nextStepFor(top),
evidenceQuestion: evidenceQuestionFor(top),
biggestTrap: concernFor(top),
doNotBuildYet: deferred.slice(0, 2).map(item => item.title),
} : null;
const assumptions = [
...(decisionContext?.assumptions || []),
...(decisionContext?.constraints || []).map(item => `Constraint: ${item}`),
...(decisionContext?.nonGoals || []).map(item => `Non-goal: ${item}`),
].slice(0, 6);
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}.` : ''}`,
quickGlance,
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 35 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 310 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'],
assumptions,
whatWouldChangeRanking: whatWouldChangeRanking(top, second, risky),
caution: 'This is first-pass judgement, not an oracle. Change the criteria if the context changes.',
};
}
function rankConfidenceFor(ranked = []) {
if (ranked.length < 2) return { level: 'low', reason: 'There are not enough comparable options for a confident ranking.' };
const gap = Math.abs((ranked[0].metrics?.score || 0) - (ranked[1].metrics?.score || 0));
if (gap <= 3) return { level: 'close call', reason: `The top two options are only ${gap} points apart, so treat the winner as a sequencing bet, not a law.` };
if (gap <= 8) return { level: 'medium', reason: `The top option leads by ${gap} points, but the follow-up is still worth rechecking after one proof cycle.` };
return { level: 'strong', reason: `The top option leads by ${gap} points and has the clearest first-proof profile.` };
}
function closeCallsFor(ranked = []) {
const calls = [];
for (let index = 0; index < ranked.length - 1; index += 1) {
const current = ranked[index];
const next = ranked[index + 1];
const gap = Math.abs((current.metrics?.score || 0) - (next.metrics?.score || 0));
if (gap <= 5) calls.push({
pair: [current.title, next.title],
gap,
note: `${current.title}” barely beats “${next.title}”; rerank if new evidence changes effort, confidence, or buyer signal.`,
});
}
return calls.slice(0, 3);
}
function compactBuildItems(items = []) {
return items.map(item => ({
id: item.id,
title: item.title,
reason: item.reason,
nextStep: item.nextStep,
evidenceQuestion: item.evidenceQuestion,
concern: item.concern,
sourceSection: item.provenance?.sourceSection || '',
sourceId: item.provenance?.sourceId || '',
laneSource: item.lane?.source || 'ranked',
score: item.metrics?.score ?? null,
confidence: item.metrics?.confidence ?? null,
}));
}
function createHandoffContract({ ranked, provenance, decisionContext }) {
const warnings = [];
const expectsSourceTrace = Boolean(provenance?.artifactId || provenance?.conceptMapId || provenance?.snapshotTitle);
if (!provenance?.artifactId && provenance?.originalPrompt) warnings.push('missing source artifact id');
if (expectsSourceTrace && !provenance?.originalPrompt) warnings.push('missing original prompt provenance');
const itemTrace = ranked.map(item => {
if (expectsSourceTrace && !item.provenance?.sourceSection) warnings.push(`missing source section for ${item.id}`);
if (item.provenance?.idNormalized) warnings.push(`duplicate source id ${item.provenance.originalId} normalized to ${item.id}`);
if (expectsSourceTrace && !item.factors?.evidenceNeeded && ['do', 'test'].includes(item.lane?.id)) warnings.push(`missing evidence needed for active item ${item.id}`);
if (item.metrics?.nonGoalConflicts?.length && ['do', 'test'].includes(item.lane?.id)) warnings.push(`active item ${item.id} conflicts with source non-goals: ${item.metrics.nonGoalConflicts.join('; ')}`);
return {
id: item.id,
title: item.title,
lane: item.lane?.id || 'defer',
sourceSection: item.provenance?.sourceSection || '',
sourceId: item.provenance?.sourceId || '',
originalId: item.provenance?.originalId || '',
idNormalized: Boolean(item.provenance?.idNormalized),
evidenceNeeded: item.factors?.evidenceNeeded || '',
nonGoalConflicts: item.metrics?.nonGoalConflicts || [],
};
});
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),
requiresSourceTrace: expectsSourceTrace,
},
decisionContext: {
targetAudience: decisionContext?.targetAudience || '',
constraints: decisionContext?.constraints || [],
nonGoals: decisionContext?.nonGoals || [],
assumptions: decisionContext?.assumptions || [],
},
itemTrace,
warnings: [...new Set(warnings)],
};
}
app.post('/api/rank-feedback', (req, res) => {
const body = expandEmbeddedRankPayload(req.body || {});
const idea = cleanMultiline(body?.idea || '', 3000);
const context = cleanContextText(body?.context || '');
const modeId = cleanText(body?.mode || 'progress', 40);
const mode = judgementModes[modeId] || judgementModes.progress;
const provenance = cleanProvenance(body || {});
const decisionContext = cleanDecisionContext(body || {});
let options = optionsFromBody(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}\n${decisionContext.targetAudience}\n${decisionContext.constraints.join('\n')}\n${decisionContext.assumptions.join('\n')}`;
options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, bridgeContext, decisionContext) }))
.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),
nextStep: nextStepFor(rankedOption),
evidenceQuestion: evidenceQuestionFor(rankedOption),
successSignal: successSignalFor(rankedOption),
killSignal: killSignalFor(rankedOption),
scoreDrivers: scoreDriversFor(rankedOption),
scoringNotes: scoringNotesFor(rankedOption),
};
});
const brief = createDecisionBrief({ idea, context, mode, ranked: options, provenance, decisionContext });
const handoff = createHandoffContract({ ranked: options, provenance, decisionContext });
res.json({
ok: true,
mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
input: { idea, context, optionCount: options.length, provenance, decisionContext, embeddedPayloadSource: body._embeddedPayloadSource || '' },
ranked: options,
brief,
rankConfidence: rankConfidenceFor(options),
closeCalls: closeCallsFor(options),
handoff,
availableModes: Object.entries(judgementModes).map(([id, item]) => ({ id, label: item.label })),
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),
},
buildOrderDetails: {
doFirst: compactBuildItems(options.filter(item => item.lane.id === 'do')),
validateNext: compactBuildItems(options.filter(item => item.lane.id === 'test')),
defer: compactBuildItems(options.filter(item => item.lane.id === 'defer')),
park: compactBuildItems(options.filter(item => item.lane.id === 'park')),
},
});
});
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}`));