diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index b03e21b..15fda3f 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -125,6 +125,34 @@ try { assert.equal(messyIdeaOnly.handoff.readiness.status, 'usable-with-warnings'); assert.ok(messyIdeaOnly.handoff.readiness.nextChecks.some(item => /source artifact id if this came from Scattermind/i.test(item))); + const hardRailResponse = 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_hard_rails', + snapshotTitle: 'Continuation engine guardrail pass', + originalPrompt: 'Clarify the continuation engine without turning it into a dashboard.', + idea: 'Scattermind clarified that Ranker should defend build order after a Concept Map.', + context: 'Solo builder. Avoid dashboards, accounts, saved workspaces, billing, and collaboration before proof.', + mode: 'mvp', + featureSet: { + features: [ + { id: 'workspace-autopilot', title: 'Saved workspace autopilot', description: 'A dashboard with accounts, saved workspaces, billing, collaboration, and AI-generated roadmaps.', evidenceNeeded: 'Would teams pay for this later?', rankerHints: { value: 10, effort: 1, confidence: 10, urgency: 10, risk: 1 }, sourceSection: 'concept-map.parkingLot' }, + { id: 'manual-source-preview', title: 'Manual source-traced build order preview', description: 'Take one Concept Map and produce a defended do-first / validate-next / defer / park result with source quotes.', evidenceNeeded: 'Can one tired non-AI-native user explain why the first move wins?', rankerHints: { value: 8, effort: 3, confidence: 7, urgency: 7, risk: 3 }, sourceSection: 'concept-map.nextActions' }, + { id: 'copyable-brief', title: 'Copyable bridge brief', description: 'Let the user copy the defended order and provenance into notes.', evidenceNeeded: 'Does the copied brief preserve the reason and source trace?', recommendedLane: 'validate-next', sourceSection: 'concept-map.nextActions' }, + ], + }, + }), + }); + assert.equal(hardRailResponse.status, 200); + const hardRail = await hardRailResponse.json(); + assert.equal(hardRail.ranked.find(item => item.id === 'workspace-autopilot').lane.source, 'source-non-goal'); + assert.equal(hardRail.buildOrder.doFirst[0], 'manual-source-preview', 'first eligible bridge item should become Do first even when a hard-railed candidate scores loudly'); + assert.equal(hardRail.brief.quickGlance.topPick, 'Manual source-traced build order preview'); + assert.equal(hardRail.handoff.readiness.status, 'ready'); + const hintedResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/server.js b/server.js index 659014f..751006d 100644 --- a/server.js +++ b/server.js @@ -928,19 +928,22 @@ function normalizeLaneHint(value = '') { return ''; } -function laneFor(option, rankIndex, total) { +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. + // 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 (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' }; + 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 (rankIndex < Math.max(2, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' }; + 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' }; } @@ -1045,8 +1048,9 @@ function whatWouldChangeRanking(top, second, risky) { } function createDecisionBrief({ idea, context, mode, ranked, provenance, decisionContext }) { - const top = ranked[0]; - const second = ranked[1]; + 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(' · '); @@ -1271,23 +1275,31 @@ app.post('/api/rank-feedback', (req, res) => { let options = optionsFromBody(body || {}); if (options.length < 2) return res.status(400).json({ error: 'Paste at least two options, features, ideas, or next moves to rank.' }); const bridgeContext = `${idea}\n${context}\n${provenance.snapshotTitle}\n${provenance.schema}\n${decisionContext.targetAudience}\n${decisionContext.constraints.join('\n')}\n${decisionContext.assumptions.join('\n')}`; - options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, bridgeContext, decisionContext) })) - .sort((a, b) => b.metrics.score - a.metrics.score || b.metrics.value - a.metrics.value || a.metrics.risk - b.metrics.risk) - .map((option, index, arr) => { - const lane = laneFor(option, index, arr.length); - const rankedOption = { ...option, rank: index + 1, lane }; - return { - ...rankedOption, - reason: reasonFor(rankedOption), - concern: concernFor(rankedOption), - nextStep: nextStepFor(rankedOption), - evidenceQuestion: evidenceQuestionFor(rankedOption), - successSignal: successSignalFor(rankedOption), - killSignal: killSignalFor(rankedOption), - scoreDrivers: scoreDriversFor(rankedOption), - scoringNotes: scoringNotesFor(rankedOption), - }; - }); + const 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({