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

2018 lines
122 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|non-goal|non goal|not yet|out of scope)\b/i.test(cleaned)) nonGoals.push(cleaned.replace(/^non[- ]goal\s*:\s*/i, ''));
else if (/\b(keep|leave|hold)\b[^.!?]{0,80}\b(out|later|until proof|after proof|not yet)\b/i.test(cleaned)) nonGoals.push(cleaned);
else if (/\b(not a dashboard|not another dashboard|no dashboard swamp|dashboard swamp)\b/i.test(cleaned)) nonGoals.push(cleaned);
else if (/\b(avoid|no auth|no account|no billing|no workspace|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.ranker_hints && typeof item.ranker_hints === 'object' ? item.ranker_hints : {}),
...(item.metric_hints && typeof item.metric_hints === 'object' ? item.metric_hints : {}),
...(item.rankerHints && typeof item.rankerHints === 'object' ? item.rankerHints : {}),
};
const aliases = {
value: ['value', 'impact', 'userImpact', 'user_impact', 'userValueScore', 'user_value_score'],
effort: ['effort', 'buildEffort', 'build_effort', 'complexity'],
confidence: ['confidence', 'certainty'],
urgency: ['urgency', 'timing'],
revenue: ['revenue', 'commercial', 'buyerSignal'],
novelty: ['novelty', 'differentiation', 'originality'],
risk: ['risk', 'assumptionRisk', 'assumption_risk', 'scopeRisk', 'scope_risk'],
};
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,
tables: { ideas: false, milestones: false, activity: false },
};
try {
if (health.appwriteConfigured) {
const tableProbes = [
['ideas', ideasTableId],
['milestones', milestonesTableId],
['activity', activityTableId],
];
for (const [name, tableId] of tableProbes) {
const probe = await withTimeout(
tables.listRows({ databaseId, tableId, queries: [Query.limit(1)] }),
`Appwrite health probe (${name})`
);
rowsFrom(probe);
health.tables[name] = true;
}
health.appwriteReachable = true;
health.tableReachable = Object.values(health.tables).every(Boolean);
health.ok = health.tableReachable;
}
} 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 bridgeEnvelopeFrom(input = {}) {
const body = objectFrom(input);
return objectFrom(
body.rankerInput
|| body.ranker_input
|| body.rankerHandoff
|| body.ranker_handoff
|| body.rankerBridge
|| body.ranker_bridge
|| body.rankReady
|| body.rank_ready
|| body.bridge
|| body.bridgePayload
|| body.bridge_payload
|| body.continuation
|| body.continuationPlan
|| body.continuation_plan
);
}
function featureSetFrom(input = {}) {
const body = objectFrom(input);
const envelope = bridgeEnvelopeFrom(body);
return objectFrom(
body.featureSet
|| body.feature_set
|| body.candidateSet
|| body.candidate_set
|| body.candidateFeatureSet
|| body.candidate_feature_set
|| body.rankReadyFeatureSet
|| body.rank_ready_feature_set
|| body.buildOrderPreview
|| body.build_order_preview
|| envelope.featureSet
|| envelope.feature_set
|| envelope.candidateSet
|| envelope.candidate_set
|| envelope.candidateFeatureSet
|| envelope.candidate_feature_set
|| envelope.rankReadyFeatureSet
|| envelope.rank_ready_feature_set
|| envelope.buildOrderPreview
|| envelope.build_order_preview
);
}
function looksLikeRankPayload(value = {}) {
return Boolean(
value.schema
|| value.featureSet
|| value.feature_set
|| value.candidateSet
|| value.candidate_set
|| value.candidateFeatureSet
|| value.candidate_feature_set
|| value.rankerInput
|| value.ranker_input
|| value.rankerHandoff
|| value.ranker_handoff
|| value.rankerBridge
|| value.ranker_bridge
|| value.rankReady
|| value.rank_ready
|| value.bridge
|| value.bridgePayload
|| value.bridge_payload
|| value.continuation
|| value.continuationPlan
|| value.continuation_plan
|| value.snapshot
|| value.conceptMap
|| value.concept_map
|| value.buildOrder
|| value.build_order
|| value.lenses
|| value.reference_code
|| value.referenceCode
|| value.artifactId
|| value.sourceArtifactId
|| value.source_artifact_id
|| value.snapshotTitle
|| value.snapshot_title
|| value.originalPrompt
|| value.original_prompt
|| value.sourceSummary
|| value.source_summary
|| value.glimpseJson
|| value.glimpse_json
|| value.snapshotJson
|| value.snapshot_json
|| value.fullReadingJson
|| value.full_reading_json
|| value.fullReading
|| value.full_reading
|| value.conceptMapJson
|| value.concept_map_json
|| value.buildOrderPreview
|| value.build_order_preview
|| value.opening_reflection
|| value.restated_idea
|| value.ideaText
|| value.idea_text
|| Array.isArray(value.features)
|| Array.isArray(value.actions)
|| Array.isArray(value.recommendedActions)
|| Array.isArray(value.recommended_actions)
|| Array.isArray(value.suggestedActions)
|| Array.isArray(value.suggested_actions)
|| Array.isArray(value.nextActions)
|| Array.isArray(value.next_actions)
|| Array.isArray(value.nextMoves)
|| Array.isArray(value.next_moves)
|| Array.isArray(value.possibleNextMoves)
|| Array.isArray(value.possible_next_moves)
|| Array.isArray(value.suggestedNextMoves)
|| Array.isArray(value.suggested_next_moves)
|| Array.isArray(value.recommendations)
|| Array.isArray(value.opportunities)
|| Array.isArray(value.candidates)
|| Array.isArray(value.candidateActions)
|| Array.isArray(value.candidate_actions)
|| Array.isArray(value.candidateMoves)
|| Array.isArray(value.candidate_moves)
|| Array.isArray(value.rankReadyActions)
|| Array.isArray(value.rank_ready_actions)
|| Array.isArray(value.doFirst)
|| Array.isArray(value.do_first)
|| Array.isArray(value.continueFirst)
|| Array.isArray(value.continue_first)
|| Array.isArray(value.makeTangible)
|| Array.isArray(value.make_tangible)
|| Array.isArray(value.validateNext)
|| Array.isArray(value.validate_next)
|| Array.isArray(value.evidenceNext)
|| Array.isArray(value.evidence_next)
|| Array.isArray(value.tryNext)
|| Array.isArray(value.try_next)
|| Array.isArray(value.deferred)
|| Array.isArray(value.holdForLater)
|| Array.isArray(value.hold_for_later)
|| Array.isArray(value.parkingLot)
|| Array.isArray(value.parking_lot)
|| Array.isArray(value.setAside)
|| Array.isArray(value.set_aside)
|| Array.isArray(value.threads_to_hold)
|| Array.isArray(value.threadsToHold)
|| Array.isArray(value.actionThreads)
|| Array.isArray(value.action_threads)
|| Array.isArray(value.questions_to_sit_with)
|| Array.isArray(value.questionsToSitWith)
|| Array.isArray(value.evidenceQuestions)
|| Array.isArray(value.evidence_questions)
|| Array.isArray(value.decisionQuestions)
|| Array.isArray(value.decision_questions)
|| Array.isArray(value.questionsToAnswer)
|| Array.isArray(value.questions_to_answer)
|| Array.isArray(value.followupQuestions)
|| Array.isArray(value.followup_questions)
|| Array.isArray(value.openQuestions)
|| Array.isArray(value.open_questions)
);
}
function extractFirstJsonObject(text = '') {
const start = text.indexOf('{');
if (start < 0) return '';
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < text.length; index += 1) {
const char = text[index];
if (escaped) {
escaped = false;
continue;
}
if (char === '\\' && inString) {
escaped = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (char === '{') depth += 1;
if (char === '}') {
depth -= 1;
if (depth === 0) return text.slice(start, index + 1);
}
}
return '';
}
function parseEmbeddedRankPayload(value = '') {
const text = cleanMultiline(value, 12000)
.replace(/^```(?:json)?\s*/i, '')
.replace(/```$/i, '')
.trim();
const jsonText = text.startsWith('{') && text.endsWith('}') ? text : extractFirstJsonObject(text);
if (!jsonText) return null;
try {
const parsed = JSON.parse(jsonText);
return looksLikeRankPayload(parsed) ? parsed : null;
} catch {
return null;
}
}
function expandEmbeddedRankPayload(body = {}) {
const original = objectFrom(body);
for (const key of ['payload', 'rankPayload', 'scattermindPayload', 'conceptMapJson', 'concept_map_json', 'fullReadingJson', 'full_reading_json', 'fullReading', 'full_reading', '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 parseObjectJsonString(value = '') {
if (typeof value !== 'string' || !value.trim()) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
function expandStoredScattermindReading(body = {}) {
const original = objectFrom(body);
const storedSnapshot = original.glimpseJson
|| original.glimpse_json
|| original.snapshotJson
|| original.snapshot_json
|| '';
const parsedSnapshot = (typeof storedSnapshot === 'string'
? parseObjectJsonString(storedSnapshot)
: objectFrom(storedSnapshot)) || {};
const storedReading = original.fullReadingJson
|| original.full_reading_json
|| original.fullReading
|| original.full_reading
|| original.conceptMapJson
|| original.concept_map_json
|| '';
const parsedReading = (typeof storedReading === 'string'
? parseObjectJsonString(storedReading)
: objectFrom(storedReading)) || {};
if ((!parsedReading || !Object.keys(parsedReading).length) && (!parsedSnapshot || !Object.keys(parsedSnapshot).length)) return original;
const explicitSnapshot = objectFrom(original.snapshot || original.glimpse);
const expanded = {
...parsedSnapshot,
...parsedReading,
...original,
snapshot: Object.keys(explicitSnapshot).length ? explicitSnapshot : parsedSnapshot,
lenses: original.lenses || parsedReading.lenses || parsedSnapshot.lenses,
threads_to_hold: original.threads_to_hold || original.threadsToHold || parsedReading.threads_to_hold || parsedReading.threadsToHold || parsedSnapshot.threads_to_hold || parsedSnapshot.threadsToHold,
questions_to_sit_with: original.questions_to_sit_with || original.questionsToSitWith || parsedReading.questions_to_sit_with || parsedReading.questionsToSitWith || parsedSnapshot.questions_to_sit_with || parsedSnapshot.questionsToSitWith,
closing_note: original.closing_note || original.closingNote || parsedReading.closing_note || parsedReading.closingNote || parsedSnapshot.closing_note || parsedSnapshot.closingNote,
reference_code: original.reference_code || original.referenceCode || parsedReading.reference_code || parsedReading.referenceCode || parsedSnapshot.reference_code || parsedSnapshot.referenceCode,
working_name: original.working_name || original.workingName || parsedReading.working_name || parsedReading.workingName || parsedSnapshot.working_name || parsedSnapshot.workingName,
opening_reflection: original.opening_reflection || original.openingReflection || parsedReading.opening_reflection || parsedReading.openingReflection || parsedSnapshot.opening_reflection || parsedSnapshot.openingReflection,
restated_idea: original.restated_idea || original.restatedIdea || parsedReading.restated_idea || parsedReading.restatedIdea || parsedSnapshot.restated_idea || parsedSnapshot.restatedIdea,
ideaText: original.ideaText || original.idea_text || parsedReading.ideaText || parsedReading.idea_text,
context: original.context || parsedReading.context || '',
};
expanded._storedScattermindReading = true;
return expanded;
}
function cleanProvenance(input = {}) {
const envelope = bridgeEnvelopeFrom(input);
const featureSet = featureSetFrom(input);
const artifact = objectFrom(input.artifact || envelope.artifact || featureSet.artifact);
const conceptMap = objectFrom(input.conceptMap || input.concept_map || envelope.conceptMap || envelope.concept_map || featureSet.conceptMap || featureSet.concept_map || artifact.conceptMap || artifact.concept_map);
const snapshot = objectFrom(input.snapshot || envelope.snapshot || featureSet.snapshot || artifact.snapshot || conceptMap.snapshot);
const source = objectFrom(input.source || envelope.source || featureSet.source || artifact.source);
const sourceSummary = input.sourceSummary || input.source_summary || input.opening_reflection || input.restated_idea || envelope.sourceSummary || envelope.source_summary || envelope.opening_reflection || envelope.restated_idea || artifact.sourceSummary || artifact.source_summary || artifact.opening_reflection || snapshot.sourceSummary || snapshot.source_summary || snapshot.restated_idea || conceptMap.sourceSummary || conceptMap.source_summary || conceptMap.opening_reflection || conceptMap.restated_idea || '';
return {
schema: cleanText(input.schema || envelope.schema || featureSet.schema || artifact.schema || input.type || envelope.type || 'prioritix-feature-set-v1', 80),
source: cleanText(input.sourceName || input.source_name || envelope.sourceName || envelope.source_name || featureSet.sourceName || featureSet.source_name || source.name || artifact.sourceName || artifact.source_name || 'Scattermind', 80),
artifactId: cleanText(input.artifactId || input.artifact_id || input.sourceArtifactId || input.source_artifact_id || input.referenceCode || input.reference_code || envelope.artifactId || envelope.artifact_id || envelope.sourceArtifactId || envelope.source_artifact_id || envelope.referenceCode || envelope.reference_code || artifact.id || source.artifactId || source.artifact_id || conceptMap.artifactId || conceptMap.artifact_id || conceptMap.id || conceptMap.referenceCode || conceptMap.reference_code || snapshot.artifactId || snapshot.artifact_id || snapshot.id || '', 120),
snapshotTitle: cleanText(input.snapshotTitle || input.snapshot_title || input.working_name || input.workingName || envelope.snapshotTitle || envelope.snapshot_title || envelope.working_name || envelope.workingName || artifact.snapshotTitle || artifact.snapshot_title || snapshot.title || snapshot.name || conceptMap.snapshotTitle || conceptMap.snapshot_title || conceptMap.working_name || conceptMap.workingName || input.ideaTitle || input.idea_title || envelope.ideaTitle || envelope.idea_title || '', 160),
conceptMapId: cleanText(input.conceptMapId || input.concept_map_id || envelope.conceptMapId || envelope.concept_map_id || artifact.conceptMapId || artifact.concept_map_id || conceptMap.id || conceptMap.artifactId || conceptMap.artifact_id || input.referenceCode || input.reference_code || envelope.referenceCode || envelope.reference_code || conceptMap.referenceCode || conceptMap.reference_code || '', 120),
originalPrompt: cleanMultiline(input.originalPrompt || input.original_prompt || input.initialPrompt || input.initial_prompt || input.ideaText || input.idea_text || input.prompt || envelope.originalPrompt || envelope.original_prompt || envelope.initialPrompt || envelope.initial_prompt || envelope.ideaText || envelope.idea_text || envelope.prompt || artifact.originalPrompt || artifact.original_prompt || source.originalPrompt || source.original_prompt || snapshot.originalPrompt || snapshot.original_prompt || snapshot.prompt || conceptMap.originalPrompt || conceptMap.original_prompt || conceptMap.ideaText || conceptMap.idea_text || input.idea || envelope.idea || '', 1200),
sourceSummary: cleanMultiline(sourceSummary, 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 contextGuardrailText(value = '') {
if (!value || typeof value !== 'object' || Array.isArray(value)) return cleanMultiline(value || '', 3000);
const obj = objectFrom(value);
return cleanMultiline(obj.summary || obj.description || obj.notes || obj.brief || obj.text || '', 3000);
}
function guardrailTextItems(value = [], maxItems = 8) {
return cleanFlexibleTextList(value, maxItems, 260).filter(item => /\b(avoid|no|do not|don't|dont|must not|never|non-goal|non goal|not yet|out of scope|defer|hold for later|set aside|probably noise|park|do not let this become|don't let this become|not a dashboard|dashboard swamp)\b/i.test(item));
}
function inferIdeaRouteFromText(value = '') {
const text = String(value || '').toLowerCase();
if (/\b(game|games|gaming|player|players|playtest|prototype|steam|itch|console|pc|deck|roguelike|roguelite|rpg|shooter|bullet\s*hell|bullet-heaven|survival|enemy|enemies|weapon|weapons|level|arena|controller|joystick|driving|car|vehicle|combat|fps|platformer|metroidvania|puzzle game)\b/.test(text)) return 'game';
if (/\b(service|agency|consulting|freelance|offer|client|local|shop|restaurant|webshop|sales|appointment)\b/.test(text)) return 'service_business';
if (/\b(book|novel|story|comic|film|music|art|creative|writing|podcast|newsletter|course|zine)\b/.test(text)) return 'creative_project';
if (/\b(community|members|audience|creator|content|social|discord|forum|club|fans|followers)\b/.test(text)) return 'content_community';
if (/\b(physical|hardware|device|toy|wearable|material|manufactur|packaging|retail|kitchen|furniture|sensor)\b/.test(text)) return 'physical_product';
if (/\b(internal|ops|admin|backoffice|team tool|employee|staff|inventory|support queue|process)\b/.test(text)) return 'internal_tool';
if (/\b(saas|app|software|dashboard|workflow|api|plugin|extension|tool|users?|accounts?|subscription|crm|automation|webapp|mobile app)\b/.test(text)) return 'saas_app';
return '';
}
function routeGuardrailNonGoals(route = '') {
if (route === 'game') {
return [
'Avoid accounts, dashboards, workspaces, collaboration, CRM, admin panels, onboarding, saved boards, and subscription tiers unless the source explicitly asks for business software.',
];
}
return [];
}
function cleanDecisionContext(input = {}) {
const envelope = bridgeEnvelopeFrom(input);
const featureSet = featureSetFrom(input);
const artifact = objectFrom(input.artifact || envelope.artifact || featureSet.artifact);
const conceptMap = objectFrom(input.conceptMap || input.concept_map || envelope.conceptMap || envelope.concept_map || featureSet.conceptMap || featureSet.concept_map || artifact.conceptMap || artifact.concept_map);
const snapshot = objectFrom(input.snapshot || envelope.snapshot || featureSet.snapshot || artifact.snapshot || conceptMap.snapshot);
const conceptMapLenses = objectFrom(conceptMap.lenses || input.lenses || envelope.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,
envelope.decisionContext,
featureSet.decisionContext,
artifact.decisionContext,
conceptMap.decisionContext,
snapshot.decisionContext,
structuredContext,
envelope.context,
conceptMap.context,
snapshot.context,
featureSet.context,
artifact.context,
];
const textContextGuardrails = guardrailsFromContextText([
contextGuardrailText(input.context || ''),
contextGuardrailText(envelope.context || ''),
contextGuardrailText(featureSet.context || ''),
contextGuardrailText(artifact.context || ''),
contextGuardrailText(snapshot.context || ''),
contextGuardrailText(conceptMap.context || ''),
lensContent(riskLens),
lensContent(constraintsLens),
conceptMap.risk || conceptMap.whatNotToBuildYet || conceptMap.notYet || conceptMap.closing_note || conceptMap.closingNote || '',
snapshot.risk || snapshot.whatNotToBuildYet || snapshot.notYet || snapshot.closing_note || snapshot.closingNote || '',
envelope.closing_note || envelope.closingNote || featureSet.closing_note || featureSet.closingNote || input.closing_note || input.closingNote || '',
...guardrailTextItems(conceptMap.threads_to_hold || conceptMap.threadsToHold || conceptMap.actionThreads || conceptMap.action_threads, 8),
...guardrailTextItems(snapshot.threads_to_hold || snapshot.threadsToHold || snapshot.actionThreads || snapshot.action_threads, 8),
...guardrailTextItems(envelope.threads_to_hold || envelope.threadsToHold || envelope.actionThreads || envelope.action_threads, 8),
...guardrailTextItems(featureSet.threads_to_hold || featureSet.threadsToHold || featureSet.actionThreads || featureSet.action_threads, 8),
...guardrailTextItems(input.threads_to_hold || input.threadsToHold || input.actionThreads || input.action_threads, 8),
].filter(Boolean).join('\n'));
const ideaRoute = cleanText(input.ideaType || input.idea_type || input.category || input.route || envelope.ideaType || envelope.idea_type || featureSet.ideaType || featureSet.idea_type || artifact.ideaType || artifact.idea_type || conceptMap.ideaType || conceptMap.idea_type || snapshot.ideaType || snapshot.idea_type || inferIdeaRouteFromText([
input.idea,
input.ideaText,
input.idea_text,
input.context,
input.opening_reflection,
input.restated_idea,
envelope.idea,
envelope.ideaText,
envelope.context,
conceptMap.opening_reflection,
conceptMap.restated_idea,
snapshot.restated_idea,
lensContent(conceptMapLenses.shape),
lensContent(conceptMapLenses.channel),
].filter(Boolean).join('\n')), 60);
return {
ideaRoute,
targetAudience: cleanText(input.targetAudience || input.target_audience || envelope.targetAudience || envelope.target_audience || featureSet.targetAudience || featureSet.target_audience || snapshot.targetAudience || snapshot.target_audience || firstContextText(contextSources, ['targetAudience', 'target_audience', 'audience', 'who', 'whoItHelps', 'who_it_helps', 'customer', 'users']) || conceptMap.targetAudience || conceptMap.target_audience || lensContent(audienceLens), 180),
constraints: uniqueList([
...cleanFlexibleTextList(input.constraints || envelope.constraints || featureSet.constraints || snapshot.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.non_goals || input.avoid || envelope.nonGoals || envelope.non_goals || envelope.avoid || featureSet.nonGoals || featureSet.non_goals || featureSet.avoid || snapshot.nonGoals || snapshot.non_goals || snapshot.avoid || conceptMap.nonGoals || conceptMap.non_goals || conceptMap.avoid, 8, 180),
...collectContextList(contextSources, ['nonGoals', 'non_goals', 'nonGoal', 'non_goal', 'avoid', 'notYet', 'not_yet', 'doNotBuild', 'do_not_build'], 8),
...textContextGuardrails.nonGoals,
...routeGuardrailNonGoals(ideaRoute),
], 8),
assumptions: uniqueList([
...cleanFlexibleTextList(input.assumptions || envelope.assumptions || featureSet.assumptions || snapshot.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', 'until', 'user', 'users', 'idea', 'ideas', 'build', 'order', 'works', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'manual', 'value', 'layer', 'result', 'sense', 'copyable', 'source', 'traced', 'source-traced', 'evidence', 'thread', 'threads']);
return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8);
}
function isGuardrailAvoidanceMention(lowerText = '', token = '') {
if (!token) return false;
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\b(without|avoid(?:ing)?|no|not|skip|defer|wait(?:ing)?|hold(?:ing)?|before adding|before building)\\b[^.!?]{0,60}\\b${escaped}\\b`, 'i').test(lowerText);
}
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 => {
const singular = token.endsWith('ies') ? `${token.slice(0, -3)}y` : token.replace(/(?:es|s)$/, '');
const forms = [token, singular].filter(form => form.length >= 4);
return forms.some(form => lower.includes(form) && !isGuardrailAvoidanceMention(lower, form));
});
});
}
function normalizeFeatureOption(item, index, fallbackId = 'feature', defaultSourceSection = '', defaultRecommendedLane = '') {
const rawValue = typeof item === 'string' || typeof item === 'number' ? String(item) : '';
const raw = rawValue ? { action: rawValue } : objectFrom(item);
const title = cleanText(raw.title || raw.name || raw.action || raw.move || raw.nextMove || raw.next_move || raw.nextStep || raw.next_step || raw.recommendedNextStep || raw.recommended_next_step || raw.experiment || raw.testName || raw.test_name || raw.hypothesis || raw.label || '', 140);
const proofSteps = cleanTextList(raw.proofSteps || raw.proof_steps || raw.proof || raw.validationSteps || raw.validation_steps || raw.steps || raw.method, 5, 180);
const dependencies = cleanTextList(raw.dependencies || raw.blockedBy, 5, 120);
const evidenceNeeded = cleanText(raw.evidenceNeeded || raw.evidence_needed || raw.evidence || raw.test || raw.evidenceQuestion || raw.evidence_question || raw.questionToAnswer || raw.question_to_answer || raw.question || raw.learningGoal || raw.learning_goal || '', 260);
const userValue = cleanText(raw.userValue || raw.user_value || raw.value || raw.outcome || raw.why, 260);
const risk = cleanText(raw.risk || raw.assumption || raw.unknown || '', 220);
const nextStep = cleanText(raw.nextStep || raw.next_step || raw.nextAction || raw.next_action || raw.firstStep || raw.first_step || raw.manualStep || raw.manual_step || raw.actionToTake || raw.action_to_take || '', 260);
const successSignal = cleanText(raw.successSignal || raw.success_signal || raw.successCriteria || raw.success_criteria || raw.successMetric || raw.success_metric || raw.greenLight || raw.green_light || raw.signalToSee || raw.signal_to_see || '', 260);
const killSignal = cleanText(raw.killSignal || raw.kill_signal || raw.stopSignal || raw.stop_signal || raw.redFlag || raw.red_flag || raw.failureSignal || raw.failure_signal || raw.cutIf || raw.cut_if || '', 260);
const rawLane = cleanText(raw.lane || '', 40);
const laneLooksLikeHint = Boolean(normalizeLaneHint(rawLane));
const sourceSection = cleanText(raw.sourceSection || raw.source_section || raw.section || raw.origin || (!laneLooksLikeHint ? rawLane : '') || defaultSourceSection, 80);
const explicitSourceId = cleanText(raw.sourceId || raw.source_id || raw.sourceArtifactId || raw.source_artifact_id || raw.sourceItemId || raw.source_item_id || raw.traceId || raw.trace_id || '', 120);
const sourceId = explicitSourceId || cleanText(raw.id || (sourceSection ? `${sourceSection}#${index + 1}` : ''), 120);
const sourceTitle = cleanText(raw.sourceTitle || raw.source_title || raw.sourceHeading || raw.source_heading || raw.lensTitle || raw.lens_title || raw.heading || '', 140);
const sourceQuote = cleanMultiline(raw.sourceQuote || raw.source_quote || raw.sourceExcerpt || raw.source_excerpt || raw.evidenceQuote || raw.evidence_quote || raw.quote || raw.originalText || raw.original_text || raw.rawText || raw.raw_text || rawValue, 420);
const recommendedLane = cleanText(raw.recommendedLane || raw.recommended_lane || raw.laneHint || raw.lane_hint || raw.suggestedLane || raw.suggested_lane || (laneLooksLikeHint ? rawLane : '') || defaultRecommendedLane || '', 40).toLowerCase();
const descriptionParts = [
raw.description || raw.brief || (raw.hypothesis && raw.hypothesis !== title ? `Hypothesis: ${raw.hypothesis}` : ''),
userValue && `User value: ${userValue}`,
evidenceNeeded && `Evidence needed: ${evidenceNeeded}`,
risk && `Risk: ${risk}`,
nextStep && `Next step: ${nextStep}`,
successSignal && `Success signal: ${successSignal}`,
killSignal && `Kill signal: ${killSignal}`,
proofSteps.length && `Proof steps: ${proofSteps.join('; ')}`,
dependencies.length && `Dependencies: ${dependencies.join(', ')}`,
].filter(Boolean);
return {
id: cleanText(raw.id || raw.key || `${fallbackId}-${index + 1}`, 80) || `${fallbackId}-${index + 1}`,
title,
description: cleanText(descriptionParts.join(' '), 760),
factors: { userValue, evidenceNeeded, risk, nextStep, successSignal, killSignal, proofSteps, dependencies, recommendedLane, metricHints: cleanMetricHints(raw) },
provenance: {
sourceId,
sourceSection,
sourceTitle,
sourceQuote,
},
};
}
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 buildOrderSectionGroup(buildOrder = {}, baseSection = 'buildOrder') {
const source = objectFrom(buildOrder);
return compactCandidateGroup([
{ items: source.doFirst || source.do_first || source.buildFirst || source.buildNow || source.now || source.continueFirst || source.continue_first || source.makeTangible || source.make_tangible || source.startHere || source.start_here, sourceSection: `${baseSection}.doFirst`, defaultLane: 'do-first' },
{ items: source.validateNext || source.validate_next || source.testNext || source.testManually || source.validation || source.evidenceNext || source.evidence_next || source.tryNext || source.try_next || source.learnNext || source.learn_next, sourceSection: `${baseSection}.validateNext`, defaultLane: 'validate-next' },
{ items: source.defer || source.deferred || source.later || source.afterProof || source.holdForLater || source.hold_for_later || source.notYet || source.not_yet, sourceSection: `${baseSection}.defer`, defaultLane: 'defer' },
{ items: source.park || source.parkingLot || source.parking_lot || source.parked || source.probablyNoise || source.probably_noise || source.noise || source.setAside || source.set_aside || source.outOfScope || source.out_of_scope, sourceSection: `${baseSection}.park`, defaultLane: 'park' },
]);
}
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);
}
const buildOrderLabelSeparator = '\\s*(?::|[-–—])\\s*';
const buildOrderLabelPattern = '(build first|build this first|start here|start with|start by|ship first|ship this first|first week|week one|first-week build order|continue first|make tangible first|make tangible|try next|test first|prove first|evidence next|learn next|test manually|validate next|hold for later|leave out|skip for now|not yet|defer|set aside|out of scope|probably noise|park|do not build yet|don\'t build yet)';
const buildOrderLabelRegex = new RegExp(`^${buildOrderLabelPattern}${buildOrderLabelSeparator}`, 'i');
function sentenceFragments(text = '') {
return cleanMultiline(text, 4000)
.replace(new RegExp(`\\s+${buildOrderLabelPattern}${buildOrderLabelSeparator}`, 'gi'), '\n$1: ')
.split(/\n|;|\s+[•-]\s+/)
.map(part => part.trim())
.filter(Boolean);
}
function titleFromBuildOrderFragment(value = '') {
const cleaned = cleanText(value.replace(buildOrderLabelRegex, ''), 220);
const first = cleaned.split(/\s[-–—:]\s|\.\s/)[0] || cleaned;
return cleanText(first.replace(/[.!?]+$/g, ''), 120);
}
function laneFromBuildOrderLabel(fragment = '') {
if (new RegExp(`^(build first|build this first|start here|start with|start by|ship first|ship this first|first week|week one|first-week build order|continue first|make tangible first|make tangible)${buildOrderLabelSeparator}`, 'i').test(fragment)) return 'do-first';
if (new RegExp(`^(try next|test first|prove first|evidence next|learn next|test manually|validate next)${buildOrderLabelSeparator}`, 'i').test(fragment)) return 'validate-next';
if (new RegExp(`^(hold for later|leave out|skip for now|not yet|defer|do not build yet|don't build yet)${buildOrderLabelSeparator}`, 'i').test(fragment)) return 'defer';
if (new RegExp(`^(set aside|out of scope|probably noise|park)${buildOrderLabelSeparator}`, 'i').test(fragment)) return 'park';
return '';
}
function optionsFromBuildOrderText(text = '', sourceSection = 'concept-map.lenses.channel', sourceTitle = 'Build Order') {
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.'
: lane === 'validate-next'
? 'Collect the smallest real signal before promoting this into the build lane.'
: '',
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,
sourceItemId: `${sourceSection}#${index + 1}`,
sourceTitle,
sourceExcerpt: fragment,
};
}).filter(item => item.action);
}
function proofStepFragments(text = '') {
return cleanMultiline(text, 2600)
.replace(/\b(proof steps?|test manually|validate|evidence to collect|next proof)\s*:/gi, '\n')
.split(/\n|;|\s+[•-]\s+|\.\s+/)
.map(part => part.replace(/^\s*[-*•\d.)]+\s*/, '').trim())
.filter(Boolean)
.filter(part => /\b(run|ask|show|send|test|validate|prototype|mock|observe|interview|collect|measure|prove|disprove|manual|concierge|offer|script|reply|signal)\b/i.test(part))
.filter(part => !/^\s*(avoid|no|do not|don't|defer|probably noise|park|hold for later)\b/i.test(part))
.slice(0, 3);
}
function proofTitleFromFragment(fragment = '') {
const cleaned = cleanText(fragment
.replace(/^(run|ask|show|send|test|validate|prototype|mock|observe|interview|collect|measure)\b\s*/i, match => match.trim()[0].toUpperCase() + match.trim().slice(1).toLowerCase() + ' ')
.replace(/\s+before\s+.*$/i, '')
.replace(/\s+and\s+record\s+.*$/i, ''), 180);
return cleanText(cleaned.split(/\s[-–—:]\s/)[0] || cleaned, 120);
}
function optionsFromProofLensText(text = '', sourceSection = 'concept-map.lenses.question', sourceTitle = 'Proof Steps') {
return proofStepFragments(text).map((fragment, index) => ({
id: `proof-step-${index + 1}`,
action: proofTitleFromFragment(fragment),
why: 'Scattermind named this as proof to collect before promoting more build surface.',
evidence: fragment,
validationSteps: [fragment],
suggestedLane: 'validate-next',
rankerHints: { value: 7, effort: 2, confidence: 6, urgency: 6, risk: 3 },
sourceSection,
sourceItemId: `${sourceSection}#${index + 1}`,
sourceTitle,
sourceExcerpt: fragment,
})).filter(item => item.action);
}
function laneFromActionThread(text = '') {
if (/\b(probably noise|set aside|park|parking lot|do not build|don't build|not worth|distraction)\b/i.test(text)) return 'park';
if (/\b(defer|not yet|later|hold for later|after proof|wait until)\b/i.test(text)) return 'defer';
if (/^(manual|start|ship|build|show|turn one)\b/i.test(text)) return 'do-first';
if (/\b(test|validate|proof|ask|interview|observe|learn|evidence|signal)\b/i.test(text)) return 'validate-next';
return '';
}
function optionsFromActionThreads(items = [], sourceSection = 'concept-map.threadsToHold', sourceTitle = 'Action thread') {
if (!Array.isArray(items)) return [];
return items.slice(0, 8).map((item, index) => {
const raw = typeof item === 'string' || typeof item === 'number' ? String(item) : '';
const objectItem = objectFrom(item);
const text = cleanMultiline(raw || objectItem.text || objectItem.content || objectItem.thread || objectItem.action || objectItem.title || '', 420);
const lane = laneFromActionThread(text);
return {
id: `action-thread-${index + 1}`,
action: titleFromBuildOrderFragment(text),
why: text,
evidence: /\b(evidence|signal|proof|test|validate|ask|observe)\b/i.test(text)
? text
: 'What smallest real-world signal would prove this action deserves the active build slot?',
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,
sourceItemId: `${sourceSection}#${index + 1}`,
sourceTitle: cleanText(objectItem.sourceTitle || objectItem.source_title || sourceTitle, 140),
sourceExcerpt: text,
};
}).filter(item => item.action);
}
function questionActionTitle(text = '') {
const cleaned = cleanText(text.replace(/\?+$/g, ''), 180);
if (!cleaned) return '';
if (/^(can|could|will|would|should|does|do|is|are|what|where|when|who|how|why)\b/i.test(cleaned)) return cleanText(`Answer: ${cleaned}`, 140);
return cleanText(cleaned, 140);
}
function optionsFromQuestionsToSitWith(items = [], sourceSection = 'concept-map.questionsToSitWith', sourceTitle = 'Question to sit with') {
if (!Array.isArray(items)) return [];
return items.slice(0, 8).map((item, index) => {
const raw = typeof item === 'string' || typeof item === 'number' ? String(item) : '';
const objectItem = objectFrom(item);
const question = cleanMultiline(raw || objectItem.question || objectItem.text || objectItem.content || objectItem.title || '', 420);
return {
id: `question-${index + 1}`,
action: questionActionTitle(question),
why: 'Scattermind left this as an open question; Ranker treats it as evidence to collect, not a build feature.',
evidence: question,
validationSteps: cleanTextList(objectItem.validationSteps || objectItem.validation_steps || objectItem.steps || objectItem.proofSteps || objectItem.proof_steps, 4, 180),
suggestedLane: 'validate-next',
rankerHints: { value: 6, effort: 2, confidence: 5, urgency: 5, risk: 3 },
sourceSection,
sourceItemId: `${sourceSection}#${index + 1}`,
sourceTitle: cleanText(objectItem.sourceTitle || objectItem.source_title || sourceTitle, 140),
sourceExcerpt: question,
};
}).filter(item => item.action && item.evidence);
}
function optionsFromSnapshotReading(source = {}, sourceSection = 'snapshot') {
const reading = objectFrom(source);
const lenses = objectFrom(reading.lenses);
const rawShapeLens = lenses.shape || reading.lens || reading.shape;
const shapeLens = objectFrom(rawShapeLens);
const shapeText = cleanMultiline(lensContent(rawShapeLens), 700);
const workingName = cleanText(reading.working_name || reading.workingName || reading.snapshotTitle || reading.snapshot_title || reading.title || reading.name || '', 90);
const restatedIdea = cleanText(reading.restated_idea || reading.restatedIdea || reading.ideaText || reading.idea_text || reading.idea || '', 260);
const questions = cleanFlexibleTextList(reading.questions_to_sit_with || reading.questionsToSitWith || reading.evidenceQuestions || reading.evidence_questions || reading.decisionQuestions || reading.decision_questions || reading.questionsToAnswer || reading.questions_to_answer || reading.followupQuestions || reading.followup_questions || reading.openQuestions || reading.open_questions, 4, 240);
const hasSnapshotShape = Boolean(workingName || restatedIdea || shapeText || questions.length);
if (!hasSnapshotShape || (!shapeText && !restatedIdea && questions.length < 1)) return [];
const firstQuestion = questions[0] || 'What smallest manual proof would show this is worth building first?';
const proofTitle = workingName
? `Manual proof for ${workingName}`
: restatedIdea
? `Manual proof: ${restatedIdea}`
: questionActionTitle(firstQuestion);
const proofExcerpt = shapeText || restatedIdea || firstQuestion;
const proofOption = {
id: 'snapshot-manual-proof',
action: proofTitle,
why: restatedIdea || shapeText || 'The Snapshot clarified a possible direction, but it still needs a small real-world proof before product machinery.',
evidence: firstQuestion,
validationSteps: ['Run one manual version before building supporting UI', 'Ask 3 target users what they would do next'],
suggestedLane: 'do-first',
rankerHints: { value: 8, effort: 2, confidence: 6, urgency: 7, risk: 3 },
sourceSection: `${sourceSection}.lenses.shape`,
sourceItemId: `${sourceSection}.lenses.shape#1`,
sourceTitle: cleanText(shapeLens.title || 'Snapshot shape', 140),
sourceExcerpt: proofExcerpt,
};
const questionOptions = optionsFromQuestionsToSitWith(
questions.slice(0, 3),
`${sourceSection}.questionsToSitWith`,
'Snapshot decision question'
);
const options = [proofOption, ...questionOptions].slice(0, 4);
return options.length >= 2 ? options : [];
}
function firstArraySource(entries = []) {
return entries.find(entry => Array.isArray(entry.items) && entry.items.length > 0) || { items: [], sourceSection: '' };
}
function optionsFromBody(body = {}) {
const envelope = bridgeEnvelopeFrom(body);
const featureSet = featureSetFrom(body);
const artifact = objectFrom(body.artifact || envelope.artifact || featureSet.artifact);
const conceptMap = objectFrom(body.conceptMap || body.concept_map || envelope.conceptMap || envelope.concept_map || featureSet.conceptMap || featureSet.concept_map || artifact.conceptMap || artifact.concept_map);
const snapshot = objectFrom(body.snapshot || envelope.snapshot || featureSet.snapshot || artifact.snapshot || conceptMap.snapshot);
const conceptMapLenses = objectFrom(conceptMap.lenses || body.lenses || envelope.lenses || featureSet.lenses);
const buildOrderLens = objectFrom(conceptMapLenses.channel || conceptMapLenses.buildOrder || conceptMap.buildOrder);
const directCandidateGroup = compactCandidateGroup([
{ items: body.features, sourceSection: 'features' },
{ items: envelope.features, sourceSection: 'ranker-input.features' },
{ items: featureSet.features, sourceSection: 'feature-set.features' },
{ items: body.actions, sourceSection: 'actions' },
{ items: envelope.actions, sourceSection: 'ranker-input.actions' },
{ items: featureSet.actions, sourceSection: 'feature-set.actions' },
{ items: body.nextActions || body.next_actions || body.nextSteps || body.next_steps || body.recommendedNextSteps || body.recommended_next_steps || body.recommendedActions || body.recommended_actions || body.suggestedActions || body.suggested_actions, sourceSection: 'nextActions' },
{ items: envelope.nextActions || envelope.next_actions || envelope.nextSteps || envelope.next_steps || envelope.recommendedNextSteps || envelope.recommended_next_steps || envelope.recommendedActions || envelope.recommended_actions || envelope.suggestedActions || envelope.suggested_actions, sourceSection: 'ranker-input.nextActions' },
{ items: featureSet.nextActions || featureSet.next_actions || featureSet.nextSteps || featureSet.next_steps || featureSet.recommendedNextSteps || featureSet.recommended_next_steps || featureSet.recommendedActions || featureSet.recommended_actions || featureSet.suggestedActions || featureSet.suggested_actions, sourceSection: 'feature-set.nextActions' },
{ items: body.nextMoves || body.next_moves, sourceSection: 'nextMoves' },
{ items: envelope.nextMoves || envelope.next_moves, sourceSection: 'ranker-input.nextMoves' },
{ items: featureSet.nextMoves || featureSet.next_moves, sourceSection: 'feature-set.nextMoves' },
{ items: body.possibleNextMoves || body.possible_next_moves || body.suggestedNextMoves || body.suggested_next_moves || body.recommendations || body.opportunities, sourceSection: 'possibleNextMoves' },
{ items: envelope.possibleNextMoves || envelope.possible_next_moves || envelope.suggestedNextMoves || envelope.suggested_next_moves || envelope.recommendations || envelope.opportunities, sourceSection: 'ranker-input.possibleNextMoves' },
{ items: featureSet.possibleNextMoves || featureSet.possible_next_moves || featureSet.suggestedNextMoves || featureSet.suggested_next_moves || featureSet.recommendations || featureSet.opportunities, sourceSection: 'feature-set.possibleNextMoves' },
{ items: body.candidates, sourceSection: 'candidates' },
{ items: envelope.candidates, sourceSection: 'ranker-input.candidates' },
{ items: featureSet.candidates, sourceSection: 'feature-set.candidates' },
{ items: body.candidateActions || body.candidate_actions || body.candidateMoves || body.candidate_moves || body.rankReadyActions || body.rank_ready_actions, sourceSection: 'candidateActions' },
{ items: envelope.candidateActions || envelope.candidate_actions || envelope.candidateMoves || envelope.candidate_moves || envelope.rankReadyActions || envelope.rank_ready_actions, sourceSection: 'ranker-input.candidateActions' },
{ items: featureSet.candidateActions || featureSet.candidate_actions || featureSet.candidateMoves || featureSet.candidate_moves || featureSet.rankReadyActions || featureSet.rank_ready_actions, sourceSection: 'feature-set.candidateActions' },
{ items: body.doFirst || body.do_first || body.buildFirst || body.build_first || body.buildNow || body.build_now || body.continueFirst || body.continue_first || body.makeTangible || body.make_tangible || body.startHere || body.start_here, sourceSection: 'doFirst', defaultLane: 'do-first' },
{ items: envelope.doFirst || envelope.do_first || envelope.buildFirst || envelope.build_first || envelope.buildNow || envelope.build_now || envelope.continueFirst || envelope.continue_first || envelope.makeTangible || envelope.make_tangible || envelope.startHere || envelope.start_here, sourceSection: 'ranker-input.doFirst', defaultLane: 'do-first' },
{ items: featureSet.doFirst || featureSet.do_first || featureSet.buildFirst || featureSet.build_first || featureSet.buildNow || featureSet.build_now || featureSet.continueFirst || featureSet.continue_first || featureSet.makeTangible || featureSet.make_tangible || featureSet.startHere || featureSet.start_here, sourceSection: 'feature-set.doFirst', defaultLane: 'do-first' },
{ items: body.experiments, sourceSection: 'experiments', defaultLane: 'validate-next' },
{ items: body.validationTests || body.validation_tests, sourceSection: 'experiments', defaultLane: 'validate-next' },
{ items: body.proofTests || body.proof_tests, sourceSection: 'experiments', defaultLane: 'validate-next' },
{ items: envelope.experiments, sourceSection: 'ranker-input.experiments', defaultLane: 'validate-next' },
{ items: envelope.validationTests || envelope.validation_tests, sourceSection: 'ranker-input.experiments', defaultLane: 'validate-next' },
{ items: envelope.proofTests || envelope.proof_tests, sourceSection: 'ranker-input.experiments', defaultLane: 'validate-next' },
{ items: featureSet.experiments, sourceSection: 'feature-set.experiments', defaultLane: 'validate-next' },
{ items: featureSet.validationTests || featureSet.validation_tests, sourceSection: 'feature-set.experiments', defaultLane: 'validate-next' },
{ items: featureSet.proofTests || featureSet.proof_tests, sourceSection: 'feature-set.experiments', defaultLane: 'validate-next' },
{ items: body.validateNext || body.validate_next || body.validate || body.validation || body.evidenceNext || body.evidence_next || body.tryNext || body.try_next || body.learnNext || body.learn_next || body.testManually || body.test_manually, sourceSection: 'validateNext', defaultLane: 'validate-next' },
{ items: body.deferred || body.defer || body.later || body.afterProof || body.after_proof || body.holdForLater || body.hold_for_later || body.notYet || body.not_yet, sourceSection: 'deferred', defaultLane: 'defer' },
{ items: body.parkingLot || body.parking_lot || body.park || body.parked || body.probablyNoise || body.probably_noise || body.setAside || body.set_aside || body.outOfScope || body.out_of_scope, sourceSection: 'parkingLot', defaultLane: 'park' },
{ items: envelope.validateNext || envelope.validate_next || envelope.validate || envelope.validation || envelope.evidenceNext || envelope.evidence_next || envelope.tryNext || envelope.try_next || envelope.learnNext || envelope.learn_next || envelope.testManually || envelope.test_manually, sourceSection: 'ranker-input.validateNext', defaultLane: 'validate-next' },
{ items: envelope.deferred || envelope.defer || envelope.later || envelope.afterProof || envelope.after_proof || envelope.holdForLater || envelope.hold_for_later || envelope.notYet || envelope.not_yet, sourceSection: 'ranker-input.deferred', defaultLane: 'defer' },
{ items: envelope.parkingLot || envelope.parking_lot || envelope.park || envelope.parked || envelope.probablyNoise || envelope.probably_noise || envelope.setAside || envelope.set_aside || envelope.outOfScope || envelope.out_of_scope, sourceSection: 'ranker-input.parkingLot', defaultLane: 'park' },
{ items: featureSet.validateNext || featureSet.validate_next || featureSet.validate || featureSet.validation || featureSet.evidenceNext || featureSet.evidence_next || featureSet.tryNext || featureSet.try_next || featureSet.learnNext || featureSet.learn_next || featureSet.testManually || featureSet.test_manually, sourceSection: 'feature-set.validateNext', defaultLane: 'validate-next' },
{ items: featureSet.deferred || featureSet.defer || featureSet.later || featureSet.afterProof || featureSet.after_proof || featureSet.holdForLater || featureSet.hold_for_later || featureSet.notYet || featureSet.not_yet, sourceSection: 'feature-set.deferred', defaultLane: 'defer' },
{ items: featureSet.parkingLot || featureSet.parking_lot || featureSet.park || featureSet.parked || featureSet.probablyNoise || featureSet.probably_noise || featureSet.setAside || featureSet.set_aside || featureSet.outOfScope || featureSet.out_of_scope, sourceSection: 'feature-set.parkingLot', defaultLane: 'park' },
]);
const conceptMapCandidateGroup = compactCandidateGroup([
{ items: conceptMap.nextActions || conceptMap.next_actions || conceptMap.nextSteps || conceptMap.next_steps || conceptMap.recommendedNextSteps || conceptMap.recommended_next_steps || conceptMap.recommendedActions || conceptMap.recommended_actions || conceptMap.suggestedActions || conceptMap.suggested_actions, sourceSection: 'concept-map.nextActions' },
{ items: conceptMap.nextMoves || conceptMap.next_moves, sourceSection: 'concept-map.nextMoves' },
{ items: conceptMap.possibleNextMoves || conceptMap.possible_next_moves || conceptMap.suggestedNextMoves || conceptMap.suggested_next_moves || conceptMap.recommendations || conceptMap.opportunities, sourceSection: 'concept-map.possibleNextMoves' },
{ items: conceptMap.features, sourceSection: 'concept-map.features' },
{ items: conceptMap.candidates, sourceSection: 'concept-map.candidates' },
{ items: conceptMap.candidateActions || conceptMap.candidate_actions || conceptMap.candidateMoves || conceptMap.candidate_moves || conceptMap.rankReadyActions || conceptMap.rank_ready_actions, sourceSection: 'concept-map.candidateActions' },
{ items: conceptMap.doFirst || conceptMap.do_first || conceptMap.buildFirst || conceptMap.build_first || conceptMap.buildNow || conceptMap.build_now || conceptMap.continueFirst || conceptMap.continue_first || conceptMap.makeTangible || conceptMap.make_tangible || conceptMap.startHere || conceptMap.start_here, sourceSection: 'concept-map.doFirst', defaultLane: 'do-first' },
{ items: conceptMap.validateNext || conceptMap.validate_next || conceptMap.validate || conceptMap.validation || conceptMap.evidenceNext || conceptMap.evidence_next || conceptMap.tryNext || conceptMap.try_next || conceptMap.learnNext || conceptMap.learn_next || conceptMap.testManually || conceptMap.test_manually, sourceSection: 'concept-map.validateNext', defaultLane: 'validate-next' },
{ items: conceptMap.experiments, sourceSection: 'concept-map.experiments', defaultLane: 'validate-next' },
{ items: conceptMap.validationTests || conceptMap.validation_tests, sourceSection: 'concept-map.experiments', defaultLane: 'validate-next' },
{ items: conceptMap.proofTests || conceptMap.proof_tests, sourceSection: 'concept-map.experiments', defaultLane: 'validate-next' },
{ items: conceptMap.deferred || conceptMap.defer || conceptMap.later || conceptMap.afterProof || conceptMap.after_proof || conceptMap.holdForLater || conceptMap.hold_for_later || conceptMap.notYet || conceptMap.not_yet, sourceSection: 'concept-map.deferred', defaultLane: 'defer' },
{ items: conceptMap.parkingLot || conceptMap.parking_lot || conceptMap.park || conceptMap.parked || conceptMap.probablyNoise || conceptMap.probably_noise || conceptMap.setAside || conceptMap.set_aside || conceptMap.outOfScope || conceptMap.out_of_scope, sourceSection: 'concept-map.parkingLot', defaultLane: 'park' },
]);
const snapshotCandidateGroup = compactCandidateGroup([
{ items: snapshot.nextActions || snapshot.next_actions || snapshot.nextSteps || snapshot.next_steps || snapshot.recommendedNextSteps || snapshot.recommended_next_steps || snapshot.recommendedActions || snapshot.recommended_actions || snapshot.suggestedActions || snapshot.suggested_actions, sourceSection: 'snapshot.nextActions' },
{ items: snapshot.nextMoves || snapshot.next_moves, sourceSection: 'snapshot.nextMoves' },
{ items: snapshot.possibleNextMoves || snapshot.possible_next_moves || snapshot.suggestedNextMoves || snapshot.suggested_next_moves || snapshot.recommendations || snapshot.opportunities, sourceSection: 'snapshot.possibleNextMoves' },
{ items: snapshot.actions, sourceSection: 'snapshot.actions' },
{ items: snapshot.features, sourceSection: 'snapshot.features' },
{ items: snapshot.candidates, sourceSection: 'snapshot.candidates' },
{ items: snapshot.candidateActions || snapshot.candidate_actions || snapshot.candidateMoves || snapshot.candidate_moves || snapshot.rankReadyActions || snapshot.rank_ready_actions, sourceSection: 'snapshot.candidateActions' },
{ items: snapshot.doFirst || snapshot.do_first || snapshot.buildFirst || snapshot.build_first || snapshot.buildNow || snapshot.build_now || snapshot.continueFirst || snapshot.continue_first || snapshot.makeTangible || snapshot.make_tangible || snapshot.startHere || snapshot.start_here, sourceSection: 'snapshot.doFirst', defaultLane: 'do-first' },
{ items: snapshot.validateNext || snapshot.validate_next || snapshot.validate || snapshot.validation || snapshot.evidenceNext || snapshot.evidence_next || snapshot.tryNext || snapshot.try_next || snapshot.learnNext || snapshot.learn_next || snapshot.testManually || snapshot.test_manually, sourceSection: 'snapshot.validateNext', defaultLane: 'validate-next' },
{ items: snapshot.experiments, sourceSection: 'snapshot.experiments', defaultLane: 'validate-next' },
{ items: snapshot.validationTests || snapshot.validation_tests, sourceSection: 'snapshot.experiments', defaultLane: 'validate-next' },
{ items: snapshot.proofTests || snapshot.proof_tests, sourceSection: 'snapshot.experiments', defaultLane: 'validate-next' },
{ items: snapshot.deferred || snapshot.defer || snapshot.later || snapshot.afterProof || snapshot.after_proof || snapshot.holdForLater || snapshot.hold_for_later || snapshot.notYet || snapshot.not_yet, sourceSection: 'snapshot.deferred', defaultLane: 'defer' },
{ items: snapshot.parkingLot || snapshot.parking_lot || snapshot.park || snapshot.parked || snapshot.probablyNoise || snapshot.probably_noise || snapshot.setAside || snapshot.set_aside || snapshot.outOfScope || snapshot.out_of_scope, sourceSection: 'snapshot.parkingLot', defaultLane: 'park' },
]);
const groupedCandidates = [
...directCandidateGroup,
...snapshotCandidateGroup,
...conceptMapCandidateGroup,
...buildOrderSectionGroup(body.buildOrder || body.build_order, 'buildOrder'),
...buildOrderSectionGroup(body.buildOrderPreview || body.build_order_preview, 'buildOrderPreview'),
...buildOrderSectionGroup(envelope.buildOrder || envelope.build_order, 'ranker-input.buildOrder'),
...buildOrderSectionGroup(envelope.buildOrderPreview || envelope.build_order_preview, 'ranker-input.buildOrderPreview'),
...buildOrderSectionGroup(featureSet.buildOrder || featureSet.build_order, 'feature-set.buildOrder'),
...buildOrderSectionGroup(featureSet.buildOrderPreview || featureSet.build_order_preview, 'feature-set.buildOrderPreview'),
...buildOrderSectionGroup(snapshot.buildOrder || snapshot.build_order, 'snapshot.buildOrder'),
...buildOrderSectionGroup(snapshot.buildOrderPreview || snapshot.build_order_preview, 'snapshot.buildOrderPreview'),
...buildOrderSectionGroup(conceptMap.buildOrder || conceptMap.build_order, 'concept-map.buildOrder'),
...buildOrderSectionGroup(conceptMap.buildOrderPreview || conceptMap.build_order_preview, 'concept-map.buildOrderPreview'),
];
if (groupedCandidates.length) return normalizeCandidateGroup(groupedCandidates);
const buildOrderText = lensContent(conceptMapLenses.channel)
|| lensContent(conceptMapLenses.buildOrder)
|| lensContent(conceptMap.buildOrder)
|| buildOrderLens.content
|| buildOrderLens.text
|| '';
const buildOrderSourceTitle = cleanText(
objectFrom(conceptMapLenses.channel).title
|| objectFrom(conceptMapLenses.buildOrder).title
|| objectFrom(conceptMap.buildOrder).title
|| buildOrderLens.title
|| 'Build Order',
140
);
const buildOrderOptions = optionsFromBuildOrderText(buildOrderText, 'concept-map.lenses.channel', buildOrderSourceTitle);
if (buildOrderOptions.length) {
const proofLens = objectFrom(conceptMapLenses.question || conceptMapLenses.proof || conceptMapLenses.validation || conceptMapLenses.evidence);
const proofLensText = lensContent(conceptMapLenses.question)
|| lensContent(conceptMapLenses.proof)
|| lensContent(conceptMapLenses.validation)
|| lensContent(conceptMapLenses.evidence)
|| '';
const proofSourceTitle = cleanText(proofLens.title || 'Proof Steps', 140);
const proofOptions = optionsFromProofLensText(proofLensText, 'concept-map.lenses.question', proofSourceTitle);
return normalizeCandidateGroup([
{ items: buildOrderOptions, sourceSection: 'concept-map.lenses.channel' },
...(proofOptions.length ? [{ items: proofOptions, sourceSection: 'concept-map.lenses.question', defaultLane: 'validate-next' }] : []),
]);
}
const actionThreadSource = firstArraySource([
{ items: conceptMap.threads_to_hold || conceptMap.threadsToHold || conceptMap.actionThreads || conceptMap.action_threads, sourceSection: 'concept-map.threadsToHold' },
{ items: snapshot.threads_to_hold || snapshot.threadsToHold || snapshot.actionThreads || snapshot.action_threads, sourceSection: 'snapshot.threadsToHold' },
{ items: envelope.threads_to_hold || envelope.threadsToHold || envelope.actionThreads || envelope.action_threads, sourceSection: 'ranker-input.threadsToHold' },
{ items: featureSet.threads_to_hold || featureSet.threadsToHold || featureSet.actionThreads || featureSet.action_threads, sourceSection: 'feature-set.threadsToHold' },
{ items: body.threads_to_hold || body.threadsToHold || body.actionThreads || body.action_threads, sourceSection: 'threadsToHold' },
]);
const actionThreadOptions = optionsFromActionThreads(
actionThreadSource.items,
actionThreadSource.sourceSection || 'threadsToHold',
'Thread to hold'
);
if (actionThreadOptions.length >= 2) return normalizeCandidateGroup([{ items: actionThreadOptions, sourceSection: actionThreadSource.sourceSection || 'threadsToHold' }]);
const questionSource = firstArraySource([
{ items: conceptMap.questions_to_sit_with || conceptMap.questionsToSitWith || conceptMap.evidenceQuestions || conceptMap.evidence_questions || conceptMap.decisionQuestions || conceptMap.decision_questions || conceptMap.questionsToAnswer || conceptMap.questions_to_answer || conceptMap.followupQuestions || conceptMap.followup_questions || conceptMap.openQuestions || conceptMap.open_questions, sourceSection: 'concept-map.questionsToSitWith' },
{ items: snapshot.questions_to_sit_with || snapshot.questionsToSitWith || snapshot.evidenceQuestions || snapshot.evidence_questions || snapshot.decisionQuestions || snapshot.decision_questions || snapshot.questionsToAnswer || snapshot.questions_to_answer || snapshot.followupQuestions || snapshot.followup_questions || snapshot.openQuestions || snapshot.open_questions, sourceSection: 'snapshot.questionsToSitWith' },
{ items: envelope.questions_to_sit_with || envelope.questionsToSitWith || envelope.evidenceQuestions || envelope.evidence_questions || envelope.decisionQuestions || envelope.decision_questions || envelope.questionsToAnswer || envelope.questions_to_answer || envelope.followupQuestions || envelope.followup_questions || envelope.openQuestions || envelope.open_questions, sourceSection: 'ranker-input.questionsToSitWith' },
{ items: featureSet.questions_to_sit_with || featureSet.questionsToSitWith || featureSet.evidenceQuestions || featureSet.evidence_questions || featureSet.decisionQuestions || featureSet.decision_questions || featureSet.questionsToAnswer || featureSet.questions_to_answer || featureSet.followupQuestions || featureSet.followup_questions || featureSet.openQuestions || featureSet.open_questions, sourceSection: 'feature-set.questionsToSitWith' },
{ items: body.questions_to_sit_with || body.questionsToSitWith || body.evidenceQuestions || body.evidence_questions || body.decisionQuestions || body.decision_questions || body.questionsToAnswer || body.questions_to_answer || body.followupQuestions || body.followup_questions || body.openQuestions || body.open_questions, sourceSection: 'questionsToSitWith' },
]);
const questionOptions = optionsFromQuestionsToSitWith(
questionSource.items,
questionSource.sourceSection || 'questionsToSitWith',
'Question to sit with'
);
if (questionOptions.length >= 2) return normalizeCandidateGroup([{ items: questionOptions, sourceSection: questionSource.sourceSection || 'questionsToSitWith', defaultLane: 'validate-next' }]);
const nestedSnapshotReadingOptions = optionsFromSnapshotReading(snapshot, 'snapshot');
const snapshotReadingOptions = nestedSnapshotReadingOptions.length
? nestedSnapshotReadingOptions
: optionsFromSnapshotReading(body, 'snapshot');
if (snapshotReadingOptions.length >= 2) return normalizeCandidateGroup([{ items: snapshotReadingOptions, sourceSection: 'snapshot', defaultLane: 'validate-next' }]);
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 || envelope.optionsText || featureSet.optionsText || conceptMap.optionsText || body.idea || body.ideaText || envelope.idea || envelope.ideaText || '';
const fallbackSourceSection = body.optionsText || envelope.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|continue-first|make-tangible|start-here)$/.test(hint)) return 'do';
if (/^(validate|validate-next|test|test-next|proof|evidence|evidence-next|try-next|learn-next)$/.test(hint)) return 'test';
if (/^(defer|later|sequence-later|after-proof|hold-for-later|not-yet)$/.test(hint)) return 'defer';
if (/^(park|cut|drop|icebox|not-now|set-aside|out-of-scope)$/.test(hint)) return 'park';
return '';
}
function laneFor(option, rankIndex, total, activeRankIndex = rankIndex, activeTotal = 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. The
// first eligible item should still become Do first even when a hard-railed
// candidate sorts above it, otherwise the handoff can end up with no defended
// first move — exactly the dashboard-swamp failure the bridge exists to avoid.
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 (activeRankIndex === 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 (activeRankIndex < Math.max(2, Math.ceil(activeTotal * 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.factors?.nextStep) return option.factors.nextStep;
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.factors?.successSignal) return option.factors.successSignal;
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.factors?.killSignal) return option.factors.killSignal;
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 activeRanked = ranked.filter(item => ['do', 'test'].includes(item.lane.id));
const top = ranked.find(item => item.lane.id === 'do') || activeRanked[0] || ranked[0];
const second = activeRanked.find(item => item.id !== top?.id) || ranked.find(item => item.id !== top?.id);
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),
sourceTrace: {
sourceSection: top.provenance?.sourceSection || '',
sourceId: top.provenance?.sourceId || '',
sourceTitle: top.provenance?.sourceTitle || '',
sourceQuote: top.provenance?.sourceQuote || '',
},
} : null;
const activeSourceAnchor = [top?.provenance?.sourceSection, top?.provenance?.sourceId || top?.provenance?.sourceTitle].filter(Boolean).join(' · ');
const firstScreen = top ? {
headline: `Build only this first: ${top.title}`,
primaryAction: nextStepFor(top),
proofQuestion: evidenceQuestionFor(top),
why: reasonFor(top),
sourceAnchor: activeSourceAnchor,
holdBack: deferred.slice(0, 3).map(item => ({ title: item.title, lane: item.lane?.label || 'Not now', reason: reasonFor(item) })),
guardrails: (decisionContext?.nonGoals || []).slice(0, 3),
rule: 'One active move. Everything else waits until this proof produces evidence.',
} : null;
const decisionReceipt = top ? {
activeMove: top.title,
activeLane: top.lane?.label || 'Do first',
firstProofStep: nextStepFor(top),
evidenceQuestion: evidenceQuestionFor(top),
doNotStartYet: deferred.slice(0, 3).map(item => item.title),
sourceAnchor: activeSourceAnchor,
handoffRule: 'Only the Do first item is active. Validate one proof, then re-rank before promoting anything else.',
} : 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,
firstScreen,
decisionReceipt,
source: provenance ? {
schema: provenance.schema,
source: provenance.source,
artifactId: provenance.artifactId,
snapshotTitle: provenance.snapshotTitle,
conceptMapId: provenance.conceptMapId,
originalPromptExcerpt: cleanText(provenance.originalPrompt, 260),
sourceSummaryExcerpt: cleanText(provenance.sourceSummary, 260),
} : 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,
successSignal: item.successSignal,
killSignal: item.killSignal,
concern: item.concern,
sourceSection: item.provenance?.sourceSection || '',
sourceId: item.provenance?.sourceId || '',
sourceTitle: item.provenance?.sourceTitle || '',
sourceQuote: item.provenance?.sourceQuote || '',
laneSource: item.lane?.source || 'ranked',
score: item.metrics?.score ?? null,
confidence: item.metrics?.confidence ?? null,
}));
}
function handoffReadinessFor({ ranked = [], provenance = {}, warnings = [], expectsSourceTrace = false }) {
const uniqueWarnings = [...new Set(warnings)];
const activeItems = ranked.filter(item => ['do', 'test'].includes(item.lane?.id));
const guardrailWarnings = uniqueWarnings.filter(item => /^active item .* conflicts with source non-goals/i.test(item));
const sourceWarnings = uniqueWarnings.filter(item => /^missing (source section|source context provenance)/i.test(item));
const evidenceWarnings = uniqueWarnings.filter(item => /^missing evidence needed for active item/i.test(item));
const duplicateWarnings = uniqueWarnings.filter(item => /^duplicate source id/i.test(item));
const missingArtifact = uniqueWarnings.includes('missing source artifact id');
const blockers = [
...guardrailWarnings,
...(expectsSourceTrace ? sourceWarnings : []),
...(expectsSourceTrace ? evidenceWarnings : []),
];
const nextChecks = [];
if (guardrailWarnings.length) nextChecks.push('Resolve source non-goal conflicts before treating any active item as build-ready.');
if (expectsSourceTrace && sourceWarnings.length) nextChecks.push('Attach source section / prompt or summary provenance from the Scattermind artifact.');
if (expectsSourceTrace && evidenceWarnings.length) nextChecks.push('Add evidenceNeeded to every Do first / Validate next candidate before handoff.');
if (missingArtifact) nextChecks.push(expectsSourceTrace ? 'Attach the Scattermind artifact id so the build order can be traced back.' : 'Attach a source artifact id if this came from Scattermind rather than a plain paste.');
if (duplicateWarnings.length) nextChecks.push('Review normalized duplicate IDs before downstream tools store references.');
if (!nextChecks.length && activeItems.length) nextChecks.push('Use the Do first item as the only active build slice, then rerank after evidence changes.');
const status = guardrailWarnings.length
? 'blocked'
: blockers.length
? 'needs-source-context'
: uniqueWarnings.length
? 'usable-with-warnings'
: 'ready';
const labels = {
ready: 'Ready for Ranker handoff',
'usable-with-warnings': 'Usable, but provenance is thin',
'needs-source-context': 'Needs source context before handoff',
blocked: 'Blocked by source guardrails',
};
const summaries = {
ready: `Active order is traceable and evidence-shaped for ${activeItems.length} active item${activeItems.length === 1 ? '' : 's'}.`,
'usable-with-warnings': 'Ranking can be read now, but downstream bridge consumers should review the warnings before storing it as a defended handoff.',
'needs-source-context': 'A Scattermind-shaped artifact is present, but active items are missing trace or evidence fields needed to defend the order later.',
blocked: 'An active item conflicts with source non-goals; do not continue until the guardrail is resolved or the item moves out of the active lanes.',
};
return {
status,
label: labels[status],
summary: summaries[status],
blockers,
nextChecks: nextChecks.slice(0, 5),
activeItemCount: activeItems.length,
warningCount: uniqueWarnings.length,
sourceComplete: Boolean(!expectsSourceTrace || ((provenance?.originalPrompt || provenance?.sourceSummary) && activeItems.every(item => item.provenance?.sourceSection))),
};
}
function activeSliceFor({ ranked = [], provenance = {}, readiness = {} }) {
const active = ranked.find(item => item.lane?.id === 'do') || ranked.find(item => item.lane?.id === 'test') || ranked[0] || null;
const heldBack = ranked.filter(item => item.id !== active?.id && ['test', 'defer', 'park'].includes(item.lane?.id)).slice(0, 4);
if (!active) return null;
return {
schema: 'ranker-active-slice-v1',
item: {
id: active.id,
title: active.title,
lane: active.lane?.id || 'do',
laneLabel: active.lane?.label || 'Do first',
reason: reasonFor(active),
},
proof: {
nextStep: nextStepFor(active),
evidenceQuestion: evidenceQuestionFor(active),
successSignal: successSignalFor(active),
killSignal: killSignalFor(active),
},
source: {
artifactId: provenance?.artifactId || '',
conceptMapId: provenance?.conceptMapId || '',
snapshotTitle: provenance?.snapshotTitle || '',
sourceSection: active.provenance?.sourceSection || '',
sourceId: active.provenance?.sourceId || '',
sourceTitle: active.provenance?.sourceTitle || '',
sourceQuote: active.provenance?.sourceQuote || '',
},
notNow: heldBack.map(item => ({ id: item.id, title: item.title, lane: item.lane?.id || 'defer', reason: reasonFor(item) })),
readinessStatus: readiness?.status || '',
rule: 'Only this active slice is build-ready. Do not promote Validate next / Defer / Park items until evidence changes and Ranker is rerun.',
};
}
function copyableHandoffText({ ranked = [], provenance = {}, decisionContext = {}, readiness = {}, activeSlice = null }) {
const lanes = [
['do', 'Do first'],
['test', 'Validate next'],
['defer', 'Defer'],
['park', 'Park / cut'],
];
const sourceLine = [
provenance?.source || 'Scattermind',
provenance?.snapshotTitle,
provenance?.artifactId,
].filter(Boolean).join(' · ');
const sourceContextLine = provenance?.originalPrompt
? `Original prompt: ${cleanText(provenance.originalPrompt, 240)}`
: provenance?.sourceSummary
? `Source summary: ${cleanText(provenance.sourceSummary, 240)}`
: '';
const contextLines = [
decisionContext?.ideaRoute ? `Idea route: ${decisionContext.ideaRoute}` : '',
decisionContext?.targetAudience ? `Audience: ${decisionContext.targetAudience}` : '',
...(decisionContext?.constraints || []).slice(0, 3).map(item => `Constraint: ${item}`),
...(decisionContext?.nonGoals || []).slice(0, 3).map(item => `Non-goal: ${item}`),
].filter(Boolean);
const itemLines = lanes.flatMap(([laneId, label]) => {
const laneItems = ranked.filter(item => item.lane?.id === laneId).slice(0, laneId === 'do' ? 1 : 3);
return [
`## ${label}`,
...(laneItems.length
? laneItems.map(item => {
const sourceTrace = [item.provenance?.sourceSection, item.provenance?.sourceId].filter(Boolean).join(' · ');
return [
`- ${item.title}`,
` Why: ${reasonFor(item)}.`,
` Next: ${nextStepFor(item)}`,
` Evidence: ${evidenceQuestionFor(item)}`,
sourceTrace ? ` Source: ${sourceTrace}` : '',
item.provenance?.sourceQuote ? ` Quote: ${cleanText(item.provenance.sourceQuote, 180)}` : '',
].filter(Boolean).join('\n');
})
: ['- None']),
'',
];
});
const activeSliceLines = activeSlice ? [
'## Active slice',
`- Move: ${activeSlice.item.title}`,
`- Proof: ${activeSlice.proof.nextStep}`,
`- Evidence question: ${activeSlice.proof.evidenceQuestion}`,
activeSlice.source.sourceSection ? `- Source: ${[activeSlice.source.sourceSection, activeSlice.source.sourceId].filter(Boolean).join(' · ')}` : '',
activeSlice.notNow.length ? `- Do not start yet: ${activeSlice.notNow.map(item => item.title).join('; ')}` : '',
].filter(Boolean).join('\n') : '';
return [
'# Ranker build-order handoff',
sourceLine ? `Source: ${sourceLine}` : '',
sourceContextLine,
readiness?.status ? `Readiness: ${readiness.status}${readiness.summary || ''}` : '',
activeSliceLines,
contextLines.length ? ['## Carried context', ...contextLines.map(item => `- ${item}`)].join('\n') : '',
...itemLines,
'Rule: only the Do first item is active. Re-rank after evidence changes; do not quietly turn this into a workspace/dashboard backlog.',
].filter(Boolean).join('\n\n');
}
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 && !provenance?.sourceSummary) warnings.push('missing source context 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 || '',
sourceTitle: item.provenance?.sourceTitle || '',
sourceQuote: item.provenance?.sourceQuote || '',
originalId: item.provenance?.originalId || '',
idNormalized: Boolean(item.provenance?.idNormalized),
evidenceNeeded: item.factors?.evidenceNeeded || '',
nextStep: item.nextStep || '',
successSignal: item.successSignal || '',
killSignal: item.killSignal || '',
nonGoalConflicts: item.metrics?.nonGoalConflicts || [],
};
});
const uniqueWarnings = [...new Set(warnings)];
const readiness = handoffReadinessFor({ ranked, provenance, warnings: uniqueWarnings, expectsSourceTrace });
const activeSlice = activeSliceFor({ ranked, provenance, readiness });
return {
schema: 'rank-feedback-result-v1',
source: {
schema: provenance?.schema || '',
source: provenance?.source || '',
artifactId: provenance?.artifactId || '',
snapshotTitle: provenance?.snapshotTitle || '',
conceptMapId: provenance?.conceptMapId || '',
originalPromptExcerpt: cleanText(provenance?.originalPrompt || '', 260),
sourceSummaryExcerpt: cleanText(provenance?.sourceSummary || '', 260),
hasOriginalPrompt: Boolean(provenance?.originalPrompt),
hasSourceSummary: Boolean(provenance?.sourceSummary),
requiresSourceTrace: expectsSourceTrace,
},
decisionContext: {
ideaRoute: decisionContext?.ideaRoute || '',
targetAudience: decisionContext?.targetAudience || '',
constraints: decisionContext?.constraints || [],
nonGoals: decisionContext?.nonGoals || [],
assumptions: decisionContext?.assumptions || [],
},
itemTrace,
warnings: uniqueWarnings,
readiness,
activeSlice,
copyableText: copyableHandoffText({ ranked, provenance, decisionContext, readiness, activeSlice }),
};
}
app.post('/api/rank-feedback', (req, res) => {
const body = expandStoredScattermindReading(expandEmbeddedRankPayload(req.body || {}));
const envelope = bridgeEnvelopeFrom(body);
const idea = cleanMultiline(body?.idea || body?.ideaText || body?.idea_text || body?.opening_reflection || body?.restated_idea || envelope.idea || envelope.ideaText || envelope.idea_text || envelope.opening_reflection || envelope.restated_idea || '', 3000);
const context = cleanContextText(body?.context || envelope.context || '');
const modeId = cleanText(body?.mode || envelope.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')}`;
const scoredOptions = 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);
const activeEligibleTotal = scoredOptions.filter(option => {
const hintedLane = normalizeLaneHint(option.factors?.recommendedLane || '');
return !['park', 'defer'].includes(hintedLane) && !option.metrics?.nonGoalConflicts?.length;
}).length;
let activeRankIndex = 0;
options = scoredOptions.map((option, index, arr) => {
const hintedLane = normalizeLaneHint(option.factors?.recommendedLane || '');
const activeEligible = !['park', 'defer'].includes(hintedLane) && !option.metrics?.nonGoalConflicts?.length;
const lane = laneFor(option, index, arr.length, activeEligible ? activeRankIndex : Number.POSITIVE_INFINITY, activeEligibleTotal);
if (activeEligible) activeRankIndex += 1;
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}`));