Compare commits

...

4 Commits

Author SHA1 Message Date
OpenClaw Bot d01cc6ede7 Guard rank feedback with Scattermind non-goals 2026-05-26 22:53:43 +02:00
OpenClaw Bot 9315130c1f Accept nested Scattermind concept maps 2026-05-26 22:47:18 +02:00
OpenClaw Bot ce2e9a65b7 Accept Scattermind action-set rank feedback 2026-05-26 22:43:17 +02:00
OpenClaw Bot c6b3f2340d Add rank feedback handoff contract 2026-05-26 22:40:21 +02:00
3 changed files with 249 additions and 36 deletions
+2 -2
View File
@@ -45,9 +45,9 @@ Ranker's continuation job is narrow:
`Snapshot / Concept Map → candidate feature/action set → Rank-ready build order`
`POST /api/rank-feedback` accepts a `prioritix-feature-set-v1`-style payload from Scattermind and returns ranked items plus `buildOrder.doFirst / validateNext / defer / park`. Keep this contract action-first; do not use it as a reason to add generic dashboard, auth, billing, or workspace layers before the bridge has proof.
`POST /api/rank-feedback` accepts a `prioritix-feature-set-v1`-style payload from Scattermind and returns ranked items plus `buildOrder.doFirst / validateNext / defer / park`. It accepts candidate arrays as `features`, `actions`, `nextMoves`, or `candidates` either at the top level or under `featureSet`, and it can consume a nested `conceptMap.nextActions / nextMoves` artifact directly, so Scattermind can hand off Concept Map next actions without renaming them into fake software features. It also returns a `handoff` object (`rank-feedback-result-v1`) with source provenance, item trace rows, and contract warnings for missing artifact IDs, source sections, original prompt provenance, or evidence on active items. Keep this contract action-first; do not use it as a reason to add generic dashboard, auth, billing, or workspace layers before the bridge has proof.
Feature items may include optional 110 `rankerHints` (`value`, `effort`, `confidence`, `urgency`, `revenue`, `novelty`, `risk`). Ranker blends those explicit Scattermind hints with text heuristics; `effort` is inverted into feasibility. Hints improve the defended order, but `recommendedLane: "defer"` or `"park"` remains a safety rail so dashboard-swamp items do not get promoted by flashy wording.
Candidate items may include optional 110 `rankerHints` (`value`, `effort`, `confidence`, `urgency`, `revenue`, `novelty`, `risk`). Ranker blends those explicit Scattermind hints with text heuristics; `effort` is inverted into feasibility. Hints improve the defended order, but `recommendedLane: "defer"` or `"park"` remains a safety rail so dashboard-swamp items do not get promoted by flashy wording. For clean bridge handoff, Scattermind should send `sourceSection` and `evidenceNeeded` on each active next move. Scattermind can also send `targetAudience`, `constraints`, `assumptions`, and `nonGoals` / `avoid` at the top level, in `featureSet`, or inside `conceptMap.context`; Ranker returns that decision context in `input.decisionContext` and `handoff.decisionContext`, and penalizes candidates that conflict with source non-goals (for example saved workspaces/auth/billing before the continuation proof).
Recommended payload shape:
+105 -5
View File
@@ -47,10 +47,10 @@ try {
featureSet: {
features: [
{ id: 'bridge-contract', title: 'Snapshot to Ranker feature-set contract', description: 'Convert Concept Map next moves into a rank-ready feature set with provenance.', userValue: 'Preserves the user prompt and source artifact so build order is defensible.', evidenceNeeded: 'Can one generated Concept Map create 3-7 sane next moves with source sections?', proofSteps: ['Run one fixture through /api/rank-feedback'], recommendedLane: 'do-first', sourceSection: 'concept-map.nextMoves' },
{ id: 'build-order-preview', title: 'Build order preview', description: 'Show do first, validate next, defer, and park with reasons.', userValue: 'A tired builder sees the next move without opening a dashboard.', evidenceNeeded: 'Can 3 non-AI-native users understand the first recommended action?', proofSteps: ['Show a static result screen to 3 people'], recommendedLane: 'validate-next' },
{ id: 'workspace', title: 'Accounts and saved workspaces', description: 'Full dashboard with auth, workspace collaboration, team voting, and sync.', dependencies: ['auth', 'teams', 'saved projects', 'sync'], risk: 'Turns the bridge into generic dashboard swamp before the first proof.' },
{ id: 'billing', title: 'Subscription billing layer', description: 'Pricing, checkout, invoices, account plans, and admin controls.', dependencies: ['account model', 'checkout', 'fulfillment'], risk: 'Payment machinery before the continuation value is proven.' },
{ id: 'export', title: 'Exportable decision brief', description: 'Simple brief for sharing the defended build order.', evidenceNeeded: 'Does a plain brief help a user act within 48 hours?', recommendedLane: 'validate-next' },
{ id: 'build-order-preview', title: 'Build order preview', description: 'Show do first, validate next, defer, and park with reasons.', userValue: 'A tired builder sees the next move without opening a dashboard.', evidenceNeeded: 'Can 3 non-AI-native users understand the first recommended action?', proofSteps: ['Show a static result screen to 3 people'], recommendedLane: 'validate-next', sourceSection: 'concept-map.nextMoves' },
{ id: 'workspace', title: 'Accounts and saved workspaces', description: 'Full dashboard with auth, workspace collaboration, team voting, and sync.', dependencies: ['auth', 'teams', 'saved projects', 'sync'], risk: 'Turns the bridge into generic dashboard swamp before the first proof.', sourceSection: 'concept-map.deferred' },
{ id: 'billing', title: 'Subscription billing layer', description: 'Pricing, checkout, invoices, account plans, and admin controls.', dependencies: ['account model', 'checkout', 'fulfillment'], risk: 'Payment machinery before the continuation value is proven.', sourceSection: 'concept-map.deferred' },
{ id: 'export', title: 'Exportable decision brief', description: 'Simple brief for sharing the defended build order.', evidenceNeeded: 'Does a plain brief help a user act within 48 hours?', recommendedLane: 'validate-next', sourceSection: 'concept-map.nextMoves' },
{ id: 'parked-bridge-dashboard', title: 'Saved Snapshot dashboard with provenance', description: 'Looks bridge-adjacent, but Scattermind already marked it as not-now because it needs auth, saved workspaces, and collaboration first.', recommendedLane: 'park', sourceSection: 'concept-map.parkingLot' },
],
},
@@ -75,6 +75,12 @@ try {
assert.ok(data.ranked.find(item => item.id === 'bridge-contract').factors.metricHints.value === undefined);
assert.equal(data.brief.source.artifactId, 'snapshot_123');
assert.match(data.brief.summary, /Source: Tiny shop idea clarity pass · snapshot_123/);
assert.equal(data.handoff.schema, 'rank-feedback-result-v1');
assert.equal(data.handoff.source.artifactId, 'snapshot_123');
assert.equal(data.handoff.source.hasOriginalPrompt, true);
assert.equal(data.handoff.itemTrace.length, data.ranked.length);
assert.equal(data.handoff.itemTrace.find(item => item.id === 'bridge-contract').sourceSection, 'concept-map.nextMoves');
assert.deepEqual(data.handoff.warnings, []);
assert.ok(data.brief.next48Hours.some(item => /Open the source artifact \(snapshot_123\)/i.test(item)));
assert.ok(data.brief.next48Hours.some(item => /Evidence to collect/i.test(item)));
assert.match(data.brief.summary, /nearest follow-up|strongest signal/i);
@@ -104,7 +110,101 @@ try {
assert.ok(hinted.ranked[0].factors.metricHints.value >= 9);
assert.ok(hinted.ranked.find(item => item.id === 'ai-autopilot-roadmap').metrics.risk > hinted.ranked[0].metrics.risk);
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, hintedTop: hinted.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
const actionsResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
schema: 'prioritix-feature-set-v1',
sourceName: 'Scattermind',
artifactId: 'concept_map_actions',
snapshotTitle: 'Workshop idea continuation',
originalPrompt: 'I want to turn a workshop idea into the first useful thing to build.',
idea: 'Scattermind emitted next actions rather than feature objects; Ranker should still defend build order.',
context: 'Candidate action set from Concept Map. Keep it action-first and avoid generic workspace layers.',
mode: 'mvp',
featureSet: {
actions: [
{ id: 'manual-preview', action: 'Manual build-order preview', why: 'A user sees one defended next move before any app machinery exists.', evidence: 'Can two tired users explain what to do next?', validationSteps: ['Create one static preview from the Concept Map'], suggestedLane: 'do-first', sourceSection: 'concept-map.nextActions' },
{ id: 'saved-workspace', action: 'Saved project workspace', why: 'Keep every idea and roadmap in an account dashboard.', dependencies: ['auth', 'database permissions', 'workspace model', 'sync'], risk: 'Dashboard swamp before the continuation proof.', suggestedLane: 'park', sourceSection: 'concept-map.parkingLot' },
{ id: 'text-export', action: 'Copyable decision brief', why: 'Let the user paste the defended order into notes or a chat.', evidence: 'Does a plain text brief help them act within 48 hours?', validationSteps: ['Export the top lane and concerns as text'], suggestedLane: 'validate-next', sourceSection: 'concept-map.nextActions' },
],
},
}),
});
assert.equal(actionsResponse.status, 200);
const actions = await actionsResponse.json();
assert.equal(actions.input.optionCount, 3);
assert.equal(actions.ranked[0].id, 'manual-preview', 'action-shaped Concept Map next moves should be rankable without a features wrapper');
assert.equal(actions.ranked.find(item => item.id === 'manual-preview').provenance.sourceSection, 'concept-map.nextActions');
assert.equal(actions.ranked.find(item => item.id === 'saved-workspace').lane.id, 'park');
assert.deepEqual(actions.handoff.warnings, []);
const nestedConceptResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceName: 'Scattermind',
idea: 'Scattermind produced a Concept Map artifact with nested next actions.',
context: 'Ranker should consume the artifact directly and preserve provenance without asking Scattermind to rename actions as features.',
mode: 'mvp',
conceptMap: {
id: 'concept_map_nested_42',
snapshotTitle: 'Course idea continuation',
originalPrompt: 'I have a course idea and need the first build step.',
nextActions: [
{ id: 'landing-proof', action: 'One-page promise test', why: 'A tired creator can see whether the promise lands before building lessons.', evidence: 'Can 5 target users describe the promised outcome?', validationSteps: ['Write the promise', 'Ask 5 target users'], rankerHints: { value: 9, effort: 2, confidence: 8, urgency: 8, risk: 2 }, suggestedLane: 'do-first' },
{ id: 'lesson-library', action: 'Full lesson library', why: 'Build every module, account dashboard, saved progress, and team workspace.', dependencies: ['auth', 'content system', 'progress tracking', 'workspace'], risk: 'Large platform before promise proof.', suggestedLane: 'park' },
{ id: 'copyable-plan', action: 'Copyable build order brief', why: 'Gives the creator a concrete next step to paste into notes.', evidence: 'Does the brief trigger one real follow-up action?', suggestedLane: 'validate-next' },
],
},
}),
});
assert.equal(nestedConceptResponse.status, 200);
const nestedConcept = await nestedConceptResponse.json();
assert.equal(nestedConcept.input.provenance.artifactId, 'concept_map_nested_42');
assert.equal(nestedConcept.input.provenance.conceptMapId, 'concept_map_nested_42');
assert.equal(nestedConcept.handoff.source.hasOriginalPrompt, true);
assert.equal(nestedConcept.ranked[0].id, 'landing-proof');
assert.equal(nestedConcept.ranked.find(item => item.id === 'landing-proof').provenance.sourceSection, 'concept-map.nextActions');
assert.equal(nestedConcept.ranked.find(item => item.id === 'lesson-library').lane.id, 'park');
assert.deepEqual(nestedConcept.handoff.warnings, []);
const nonGoalResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceName: 'Scattermind',
artifactId: 'concept_map_non_goals',
originalPrompt: 'I clarified a tiny service idea and need the first build order.',
idea: 'Ranker should respect Concept Map non-goals when defending the build order.',
mode: 'mvp',
conceptMap: {
snapshotTitle: 'Non-goal guarded continuation',
context: {
targetAudience: 'Tired non-AI-native solo operator',
constraints: ['No account before first value', 'Manual proof is acceptable'],
nonGoals: ['Avoid saved workspaces', 'No auth dashboard', 'No billing layer before proof'],
},
nextActions: [
{ id: 'workspace-autopilot', action: 'Saved workspace autopilot', why: 'Build accounts, auth dashboard, saved workspaces, and team sync.', evidence: 'None yet', suggestedLane: 'do-first', rankerHints: { value: 10, effort: 2, confidence: 9, urgency: 9, risk: 2 } },
{ id: 'manual-next-move', action: 'Manual next-move build order preview', why: 'Turn one source artifact into a defended first action and rank-ready build order without accounts.', evidence: 'Can 3 users act on the first move?', validationSteps: ['Create one static brief'], suggestedLane: 'do-first', rankerHints: { value: 9, effort: 1, confidence: 9, urgency: 9, risk: 1 } },
{ id: 'copy-brief', action: 'Copyable build-order brief', why: 'Give the user a plain artifact they can paste into notes.', evidence: 'Does copy/paste preserve the next step?', suggestedLane: 'validate-next' },
],
},
}),
});
assert.equal(nonGoalResponse.status, 200);
const nonGoal = await nonGoalResponse.json();
assert.equal(nonGoal.input.decisionContext.targetAudience, 'Tired non-AI-native solo operator');
assert.deepEqual(nonGoal.input.decisionContext.nonGoals, ['Avoid saved workspaces', 'No auth dashboard', 'No billing layer before proof']);
assert.equal(nonGoal.ranked[0].id, 'manual-next-move', 'non-goal conflicts should beat flashy positive hints');
const workspace = nonGoal.ranked.find(item => item.id === 'workspace-autopilot');
assert.ok(workspace.metrics.nonGoalConflicts.length >= 2);
assert.match(workspace.concern, /Source context says not to do this yet/);
assert.deepEqual(nonGoal.handoff.itemTrace.find(item => item.id === 'workspace-autopilot').nonGoalConflicts, workspace.metrics.nonGoalConflicts);
assert.ok(!nonGoal.buildOrder.doFirst.includes('workspace-autopilot'));
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, hintedTop: hinted.ranked[0].id, actionTop: actions.ranked[0].id, nestedConceptTop: nestedConcept.ranked[0].id, nonGoalTop: nonGoal.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
} finally {
server.kill('SIGTERM');
}
+142 -29
View File
@@ -145,6 +145,18 @@ function cleanTextList(value, maxItems = 6, maxText = 180) {
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 cleanMetricHints(item = {}) {
const raw = {
...(item.factors && typeof item.factors === 'object' ? item.factors : {}),
@@ -390,27 +402,60 @@ function parseOptionsFromText(value) {
}).filter(item => item.title);
}
function objectFrom(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
}
function cleanProvenance(input = {}) {
const artifact = input.artifact && typeof input.artifact === 'object' ? input.artifact : {};
const source = input.source && typeof input.source === 'object' ? input.source : {};
const featureSet = objectFrom(input.featureSet);
const artifact = objectFrom(input.artifact || featureSet.artifact);
const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap);
const snapshot = objectFrom(input.snapshot || featureSet.snapshot || artifact.snapshot || conceptMap.snapshot);
const source = objectFrom(input.source || featureSet.source || artifact.source);
return {
schema: cleanText(input.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80),
source: cleanText(input.sourceName || source.name || artifact.sourceName || 'Scattermind', 80),
artifactId: cleanText(input.artifactId || input.sourceArtifactId || artifact.id || source.artifactId || '', 120),
snapshotTitle: cleanText(input.snapshotTitle || artifact.snapshotTitle || input.ideaTitle || '', 160),
conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || '', 120),
originalPrompt: cleanMultiline(input.originalPrompt || input.initialPrompt || artifact.originalPrompt || source.originalPrompt || '', 1200),
schema: cleanText(input.schema || featureSet.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80),
source: cleanText(input.sourceName || featureSet.sourceName || source.name || artifact.sourceName || 'Scattermind', 80),
artifactId: cleanText(input.artifactId || input.sourceArtifactId || artifact.id || source.artifactId || conceptMap.artifactId || conceptMap.id || snapshot.artifactId || snapshot.id || '', 120),
snapshotTitle: cleanText(input.snapshotTitle || artifact.snapshotTitle || snapshot.title || snapshot.name || conceptMap.snapshotTitle || input.ideaTitle || '', 160),
conceptMapId: cleanText(input.conceptMapId || artifact.conceptMapId || conceptMap.id || conceptMap.artifactId || '', 120),
originalPrompt: cleanMultiline(input.originalPrompt || input.initialPrompt || artifact.originalPrompt || source.originalPrompt || snapshot.originalPrompt || snapshot.prompt || conceptMap.originalPrompt || '', 1200),
};
}
function normalizeFeatureOption(item, index, fallbackId = 'feature') {
function cleanDecisionContext(input = {}) {
const featureSet = objectFrom(input.featureSet);
const artifact = objectFrom(input.artifact || featureSet.artifact);
const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap);
const sourceContext = objectFrom(input.decisionContext || featureSet.decisionContext || artifact.decisionContext || conceptMap.decisionContext || conceptMap.context);
return {
targetAudience: cleanText(input.targetAudience || featureSet.targetAudience || sourceContext.targetAudience || conceptMap.targetAudience || '', 180),
constraints: cleanFlexibleTextList(input.constraints || featureSet.constraints || sourceContext.constraints || conceptMap.constraints, 8, 180),
nonGoals: cleanFlexibleTextList(input.nonGoals || input.avoid || featureSet.nonGoals || featureSet.avoid || sourceContext.nonGoals || sourceContext.avoid || conceptMap.nonGoals || conceptMap.avoid, 8, 180),
assumptions: cleanFlexibleTextList(input.assumptions || featureSet.assumptions || sourceContext.assumptions || conceptMap.assumptions, 6, 180),
};
}
function meaningfulTokens(text = '') {
const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'user', 'users', 'idea', 'ideas', 'build', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'value', 'layer']);
return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8);
}
function nonGoalConflicts(optionText, decisionContext = {}) {
const lower = String(optionText || '').toLowerCase();
return (decisionContext.nonGoals || []).filter(nonGoal => {
const tokens = meaningfulTokens(nonGoal);
return tokens.length > 0 && tokens.some(token => lower.includes(token));
});
}
function normalizeFeatureOption(item, index, fallbackId = 'feature', defaultSourceSection = '') {
const title = cleanText(item?.title || item?.name || item?.action || '', 140);
const proofSteps = cleanTextList(item?.proofSteps || item?.proof || item?.validationSteps, 5, 180);
const dependencies = cleanTextList(item?.dependencies || item?.blockedBy, 5, 120);
const evidenceNeeded = cleanText(item?.evidenceNeeded || item?.evidence || item?.test || '', 260);
const userValue = cleanText(item?.userValue || item?.value || item?.outcome || item?.why, 260);
const risk = cleanText(item?.risk || item?.assumption || item?.unknown || '', 220);
const sourceSection = cleanText(item?.sourceSection || item?.section || item?.lane || item?.origin || '', 80);
const sourceSection = cleanText(item?.sourceSection || item?.section || item?.lane || item?.origin || defaultSourceSection, 80);
const recommendedLane = cleanText(item?.recommendedLane || item?.laneHint || item?.suggestedLane || '', 40).toLowerCase();
const descriptionParts = [
item?.description || item?.brief || '',
@@ -432,19 +477,38 @@ function normalizeFeatureOption(item, index, fallbackId = 'feature') {
};
}
function optionsFromBody(body = {}) {
const featureSet = body.featureSet && typeof body.featureSet === 'object' ? body.featureSet : {};
const rawFeatures = Array.isArray(body.features) ? body.features : Array.isArray(featureSet.features) ? featureSet.features : null;
if (rawFeatures) {
return rawFeatures.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index)).filter(item => item.title);
}
if (Array.isArray(body.options)) {
return body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option')).filter(item => item.title);
}
return parseOptionsFromText(body.optionsText || featureSet.optionsText || '');
function candidateArrayFrom(...entries) {
return entries.find(entry => Array.isArray(entry?.items)) || null;
}
function scoreOption(option, mode, context = '') {
function optionsFromBody(body = {}) {
const featureSet = objectFrom(body.featureSet);
const conceptMap = objectFrom(body.conceptMap || featureSet.conceptMap);
const rawCandidates = candidateArrayFrom(
{ items: body.features, sourceSection: 'features' },
{ items: featureSet.features, sourceSection: 'feature-set.features' },
{ items: body.actions, sourceSection: 'actions' },
{ items: featureSet.actions, sourceSection: 'feature-set.actions' },
{ items: body.nextMoves, sourceSection: 'nextMoves' },
{ items: featureSet.nextMoves, sourceSection: 'feature-set.nextMoves' },
{ items: body.candidates, sourceSection: 'candidates' },
{ items: featureSet.candidates, sourceSection: 'feature-set.candidates' },
{ items: conceptMap.nextActions, sourceSection: 'concept-map.nextActions' },
{ items: conceptMap.nextMoves, sourceSection: 'concept-map.nextMoves' },
{ items: conceptMap.features, sourceSection: 'concept-map.features' },
{ items: conceptMap.candidates, sourceSection: 'concept-map.candidates' }
);
if (rawCandidates) {
const fallbackId = rawCandidates.sourceSection.toLowerCase().includes('action') ? 'action' : 'feature';
return rawCandidates.items.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, fallbackId, rawCandidates.sourceSection)).filter(item => item.title);
}
if (Array.isArray(body.options)) {
return body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title);
}
return parseOptionsFromText(body.optionsText || featureSet.optionsText || conceptMap.optionsText || '');
}
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);
@@ -456,16 +520,18 @@ function scoreOption(option, mode, context = '') {
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) ? 0.55 : /validate|test|proof/.test(laneHint) ? 0.25 : /defer|park|cut/.test(laneHint) ? -0.75 : 0;
const lanePenalty = normalizedLaneHint === 'park' ? 18 : normalizedLaneHint === 'defer' ? 9 : 0;
const heuristicMetrics = {
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + bridgeHits * 0.75 + proofHits * 0.2 + hits(context, wordSets.value) * 0.15),
feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 - dependencyPenalty + Math.min(1.1, coreLoopHits * 0.28 + bridgeHits * 0.18 + proofHits * 0.12))),
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),
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;
@@ -481,8 +547,8 @@ function scoreOption(option, mode, context = '') {
const weights = mode.weights;
const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0);
const possible = Object.entries(weights).reduce((sum, [_key, weight]) => sum + Math.abs(weight) * 10, 0);
const score = Math.max(0, Math.min(100, Math.round(((weighted + laneBoost) + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100) - lanePenalty));
return { ...metrics, score };
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 = '') {
@@ -511,6 +577,7 @@ function laneFor(option, rankIndex, total) {
function reasonFor(option) {
const m = option.metrics;
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 (option.factors?.evidenceNeeded && m.confidence >= 6.4) return 'it names the evidence needed, so the next move can be tested instead of guessed';
if (m.feasibility >= 7.2 && m.value >= 6.2) return 'it has high enough value with low enough delivery drag to create fast signal';
@@ -522,6 +589,7 @@ function reasonFor(option) {
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 (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.';
@@ -571,16 +639,58 @@ function createDecisionBrief({ idea, context, mode, ranked, provenance }) {
};
}
function createHandoffContract({ ranked, provenance, decisionContext }) {
const warnings = [];
if (!provenance?.artifactId) warnings.push('missing source artifact id');
if (!provenance?.originalPrompt) warnings.push('missing original prompt provenance');
const itemTrace = ranked.map(item => {
if (!item.provenance?.sourceSection) warnings.push(`missing source section for ${item.id}`);
if (!item.factors?.evidenceNeeded && ['do', 'test'].includes(item.lane?.id)) warnings.push(`missing evidence needed for active item ${item.id}`);
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 || '',
evidenceNeeded: item.factors?.evidenceNeeded || '',
nonGoalConflicts: item.metrics?.nonGoalConflicts || [],
};
});
return {
schema: 'rank-feedback-result-v1',
source: {
schema: provenance?.schema || '',
source: provenance?.source || '',
artifactId: provenance?.artifactId || '',
snapshotTitle: provenance?.snapshotTitle || '',
conceptMapId: provenance?.conceptMapId || '',
hasOriginalPrompt: Boolean(provenance?.originalPrompt),
},
decisionContext: {
targetAudience: decisionContext?.targetAudience || '',
constraints: decisionContext?.constraints || [],
nonGoals: decisionContext?.nonGoals || [],
assumptions: decisionContext?.assumptions || [],
},
itemTrace,
warnings: [...new Set(warnings)],
};
}
app.post('/api/rank-feedback', (req, res) => {
const idea = cleanMultiline(req.body?.idea || '', 3000);
const context = cleanMultiline(req.body?.context || '', 3000);
const modeId = cleanText(req.body?.mode || 'progress', 40);
const mode = judgementModes[modeId] || judgementModes.progress;
const provenance = cleanProvenance(req.body || {});
const decisionContext = cleanDecisionContext(req.body || {});
let options = optionsFromBody(req.body || {});
if (options.length < 2) return res.status(400).json({ error: 'Paste at least two options, features, ideas, or next moves to rank.' });
const bridgeContext = `${idea}\n${context}\n${provenance.snapshotTitle}\n${provenance.schema}`;
options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, bridgeContext) }))
const bridgeContext = `${idea}\n${context}\n${provenance.snapshotTitle}\n${provenance.schema}\n${decisionContext.targetAudience}\n${decisionContext.constraints.join('\n')}\n${decisionContext.assumptions.join('\n')}`;
options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, bridgeContext, decisionContext) }))
.sort((a, b) => b.metrics.score - a.metrics.score || b.metrics.value - a.metrics.value || a.metrics.risk - b.metrics.risk)
.map((option, index, arr) => {
const lane = laneFor(option, index, arr.length);
@@ -591,12 +701,15 @@ app.post('/api/rank-feedback', (req, res) => {
concern: concernFor(rankedOption),
};
});
const brief = createDecisionBrief({ idea, context, mode, ranked: options, provenance });
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 },
input: { idea, context, optionCount: options.length, provenance, decisionContext },
ranked: options,
brief: createDecisionBrief({ idea, context, mode, ranked: options, provenance }),
brief,
handoff,
buildOrder: {
doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id),
validateNext: options.filter(item => item.lane.id === 'test').map(item => item.id),