Strengthen rank feedback decision brief

This commit is contained in:
OpenClaw Bot
2026-05-26 23:57:48 +02:00
parent c13f16c0e7
commit 25c7c08543
6 changed files with 315 additions and 47 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ Ranker's continuation job is narrow:
`Snapshot / Concept Map → candidate feature/action set → Rank-ready build order` `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`. It accepts candidate arrays as `features`, `actions`, `nextMoves`, or `candidates` either at the top level or under `featureSet`, and it can consume a nested Concept Map directly, so Scattermind can hand off `conceptMap.nextActions / nextMoves` without renaming them into fake software features. Sectioned Concept Maps may also include `validateNext`, `deferred`, and `parkingLot`; Ranker combines those sections into one build-order pass while preserving `sourceSection` and treating deferred/parked sections as lane hints. Empty wrapper arrays are ignored rather than allowed to shadow a real nested Concept Map action set, and non-empty normalized wrappers are merged with Concept Map validation/deferred/parking sections rather than dropping that context. That keeps partially-normalized Scattermind exports rankable without losing the source lane contract. It also returns a `brief` with source, next-48-hour actions, carried-forward assumptions/constraints/non-goals, and `whatWouldChangeRanking` checks, plus 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. If Scattermind sends the current paid Concept Map shape as lenses rather than arrays, Ranker can parse `conceptMap.lenses.channel.content` / `buildOrder` labels (`Build first`, `Test manually`, `Defer`, `Probably noise`) into rank-ready candidates and can read guardrails from the risk lens. 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 Concept Map directly, so Scattermind can hand off `conceptMap.nextActions / nextMoves` without renaming them into fake software features. Sectioned Concept Maps may also include `validateNext`, `deferred`, and `parkingLot`; Ranker combines those sections into one build-order pass while preserving `sourceSection` and treating deferred/parked sections as lane hints. Empty wrapper arrays are ignored rather than allowed to shadow a real nested Concept Map action set, and non-empty normalized wrappers are merged with Concept Map validation/deferred/parking sections rather than dropping that context. That keeps partially-normalized Scattermind exports rankable without losing the source lane contract. It also returns a `brief` with source, next-48-hour actions, carried-forward assumptions/constraints/non-goals, and `whatWouldChangeRanking` checks, lane-level `buildOrderDetails` with each item's reason, next step, evidence question, source section, lane source, score, and confidence, plus 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. If Scattermind sends the current paid Concept Map shape as lenses rather than arrays, Ranker can parse `conceptMap.lenses.channel.content` / `buildOrder` labels (`Build first`, `Test manually`, `Defer`, `Probably noise`) into rank-ready candidates and can read guardrails from the risk lens. 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.
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`, inside `conceptMap.context`, or as a structured top-level `context` object with `summary`, `targetAudience`, `constraints`, `nonGoals` / `avoid`, and `assumptions`; Ranker turns that structured context into readable scoring text instead of leaking `[object Object]`. If Scattermind only has a flat context string, Ranker now extracts simple guardrails such as `Solo builder`, `Manual proof`, `Avoid ...`, `No ...`, and `Do not ...` into `input.decisionContext` / `handoff.decisionContext` so early bridge exports still protect against dashboard/auth/billing drift. 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). If Scattermind sends duplicate candidate IDs, Ranker keeps the first ID, suffixes later duplicates (`preview-2`), and reports the normalization in `handoff.warnings` / `handoff.itemTrace` so downstream build-order references remain addressable. 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`, inside `conceptMap.context`, or as a structured top-level `context` object with `summary`, `targetAudience`, `constraints`, `nonGoals` / `avoid`, and `assumptions`; Ranker turns that structured context into readable scoring text instead of leaking `[object Object]`. If Scattermind only has a flat context string, Ranker now extracts simple guardrails such as `Solo builder`, `Manual proof`, `Avoid ...`, `No ...`, and `Do not ...` into `input.decisionContext` / `handoff.decisionContext` so early bridge exports still protect against dashboard/auth/billing drift. 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). If Scattermind sends duplicate candidate IDs, Ranker keeps the first ID, suffixes later duplicates (`preview-2`), and reports the normalization in `handoff.warnings` / `handoff.itemTrace` so downstream build-order references remain addressable.
+133 -22
View File
@@ -1,5 +1,5 @@
const sample = { const sample = {
idea: 'Scattermind clarified a messy course idea. Now I need the first build order, not a dashboard.', idea: 'Scattermind clarified a messy course idea. Now I need feedback on the feature/functionality order, not a dashboard.',
optionsText: `- Manual build-order preview from one Concept Map optionsText: `- Manual build-order preview from one Concept Map
- Copyable decision brief with Do first / Validate next / Defer / Park - Copyable decision brief with Do first / Validate next / Defer / Park
- Evidence questions beside each next move - Evidence questions beside each next move
@@ -11,9 +11,17 @@ const sample = {
mode: 'mvp', mode: 'mvp',
}; };
const laneMeta = {
do: { title: 'Do first', note: 'The active first move. Give it oxygen before the backlog starts breeding.' },
test: { title: 'Validate next', note: 'Strong candidates, but they need evidence before build time.' },
defer: { title: 'Defer', note: 'Useful later. Dangerous if it steals attention now.' },
park: { title: 'Park / cut', note: 'Not in the active decision. Reopen only if new evidence earns it.' },
};
const form = document.querySelector('#rankForm'); const form = document.querySelector('#rankForm');
const results = document.querySelector('#results'); const results = document.querySelector('#results');
const toastEl = document.querySelector('#toast'); const toastEl = document.querySelector('#toast');
let lastResult = null;
function escapeHtml(value) { function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char])); return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
@@ -43,7 +51,7 @@ function laneClass(lane) {
return `lane-${lane?.id || 'defer'}`; return `lane-${lane?.id || 'defer'}`;
} }
function renderMetrics(metrics) { function renderMetrics(metrics = {}) {
const items = [ const items = [
['Value', metrics.value], ['Value', metrics.value],
['Feasible', metrics.feasibility], ['Feasible', metrics.feasibility],
@@ -56,45 +64,147 @@ function renderMetrics(metrics) {
`).join('')}</div>`; `).join('')}</div>`;
} }
function renderPills(items = []) {
if (!items.length) return '';
return `<div class="signal-pills">${items.map((item) => `<span>${escapeHtml(item)}</span>`).join('')}</div>`;
}
function renderRankCard(item) {
return `
<article class="rank-card ${laneClass(item.lane)}">
<div class="rank-topline">
<span class="rank-number">#${item.rank}</span>
<span class="lane-pill">${escapeHtml(item.lane?.label || 'Ranked')}</span>
<strong>${item.metrics.score}</strong>
</div>
<h3>${escapeHtml(item.title)}</h3>
${item.description && item.description !== item.title ? `<p class="item-description">${escapeHtml(item.description)}</p>` : ''}
${renderPills(item.scoreDrivers || [])}
<p><b>Why:</b> ${escapeHtml(item.reason)}.</p>
<p><b>Concern:</b> ${escapeHtml(item.concern)}</p>
<div class="action-strip">
<div><span>Next step</span><p>${escapeHtml(item.nextStep || 'Run the smallest proof step.')}</p></div>
<div><span>Evidence question</span><p>${escapeHtml(item.evidenceQuestion || 'What proof would change this ranking?')}</p></div>
<div><span>Success / kill signal</span><p>${escapeHtml(item.successSignal || '')} ${escapeHtml(item.killSignal ? `Kill if: ${item.killSignal}` : '')}</p></div>
</div>
${renderMetrics(item.metrics)}
${renderPills(item.scoringNotes || [])}
</article>
`;
}
function renderLane(ranked, laneId) {
const items = ranked.filter((item) => item.lane?.id === laneId);
const meta = laneMeta[laneId];
return `
<section class="lane-column ${items.length ? '' : 'empty-lane'}">
<div class="lane-head">
<span>${escapeHtml(meta.title)}</span>
<b>${items.length}</b>
</div>
<p>${escapeHtml(meta.note)}</p>
<div class="lane-stack">
${items.length ? items.map(renderRankCard).join('') : '<em>No items landed here.</em>'}
</div>
</section>
`;
}
function markdownBrief(data) {
const brief = data.brief || {};
const glance = brief.quickGlance || {};
const ranked = data.ranked || [];
const lanes = ['do', 'test', 'defer', 'park'];
const laneTitles = { do: 'Do first', test: 'Validate next', defer: 'Defer', park: 'Park / cut' };
return [
`# ${brief.headline || 'Ranked feedback map'}`,
'',
brief.summary || '',
'',
'## Quick judgement',
`- Top pick: ${glance.topPick || 'n/a'}`,
`- Why it wins: ${glance.whyThisWins || 'n/a'}`,
`- Next action: ${glance.nextAction || 'n/a'}`,
`- Evidence question: ${glance.evidenceQuestion || 'n/a'}`,
`- Biggest trap: ${glance.biggestTrap || 'n/a'}`,
`- Confidence: ${data.rankConfidence?.level || 'n/a'}${data.rankConfidence?.reason || ''}`,
'',
...lanes.flatMap((lane) => {
const items = ranked.filter((item) => item.lane?.id === lane);
return [`## ${laneTitles[lane]}`, ...(items.length ? items.map((item) => `- #${item.rank} ${item.title}: ${item.reason}. Next: ${item.nextStep}`) : ['- None']) , ''];
}),
'## Next 48 hours',
...(brief.next48Hours || []).map((step) => `- ${step}`),
].join('\n');
}
async function copyText(text, label) {
try {
await navigator.clipboard.writeText(text);
toast(`${label} copied.`);
} catch {
toast('Clipboard blocked. Browser being a tiny tyrant.');
}
}
function attachResultActions(data) {
document.querySelector('#copyBrief')?.addEventListener('click', () => copyText(markdownBrief(data), 'Decision brief'));
document.querySelector('#copyActions')?.addEventListener('click', () => copyText((data.brief?.next48Hours || []).map((step, index) => `${index + 1}. ${step}`).join('\n'), '48h actions'));
document.querySelector('#copyJson')?.addEventListener('click', () => copyText(JSON.stringify({ brief: data.brief, ranked: data.ranked, buildOrder: data.buildOrderDetails, handoff: data.handoff }, null, 2), 'JSON handoff'));
}
function renderResults(data) { function renderResults(data) {
lastResult = data;
const ranked = data.ranked || []; const ranked = data.ranked || [];
const brief = data.brief || {}; const brief = data.brief || {};
const glance = brief.quickGlance || {};
results.hidden = false; results.hidden = false;
results.innerHTML = ` results.innerHTML = `
<div class="results-head"> <div class="results-head memo-head">
<p class="eyebrow">${escapeHtml(data.mode?.label || 'Ranked feedback')}</p> <p class="eyebrow">${escapeHtml(data.mode?.label || 'Ranked feedback')} · first-pass judgement memo</p>
<h2>${escapeHtml(brief.headline || 'Ranked feedback map')}</h2> <h2>${escapeHtml(brief.headline || 'Ranked feedback map')}</h2>
<p>${escapeHtml(brief.summary || '')}</p> <p>${escapeHtml(brief.summary || '')}</p>
<div class="memo-stamps"><span>Not an oracle</span><span>${escapeHtml(data.rankConfidence?.level || 'first pass')} confidence</span><span>${ranked.length} items judged</span></div>
</div> </div>
<div class="ranked-list"> <section class="quick-glance" aria-label="Quick judgement">
${ranked.map((item) => ` <div><span>Do this first</span><strong>${escapeHtml(glance.topPick || 'Add options')}</strong></div>
<article class="rank-card ${laneClass(item.lane)}"> <div><span>Why it wins</span><p>${escapeHtml(glance.whyThisWins || 'The list needs more comparable options.')}</p></div>
<div class="rank-topline"> <div><span>Proof to collect</span><p>${escapeHtml(glance.evidenceQuestion || 'Name the evidence that would change the ranking.')}</p></div>
<span class="rank-number">#${item.rank}</span> <div><span>Trap to avoid</span><p>${escapeHtml(glance.biggestTrap || brief.caution || 'Do not treat first-pass judgement as final truth.')}</p></div>
<span class="lane-pill">${escapeHtml(item.lane?.label || 'Ranked')}</span> </section>
<strong>${item.metrics.score}</strong>
</div> <div class="result-actions" aria-label="Copy result actions">
<h3>${escapeHtml(item.title)}</h3> <button class="button ghost" type="button" id="copyBrief">Copy decision brief</button>
${item.description && item.description !== item.title ? `<p class="item-description">${escapeHtml(item.description)}</p>` : ''} <button class="button ghost" type="button" id="copyActions">Copy 48h actions</button>
<p><b>Why:</b> ${escapeHtml(item.reason)}.</p> <button class="button ghost" type="button" id="copyJson">Copy JSON handoff</button>
<p><b>Concern:</b> ${escapeHtml(item.concern)}</p>
${renderMetrics(item.metrics)}
</article>
`).join('')}
</div> </div>
<div class="brief-grid"> <div class="lane-board">
${['do', 'test', 'defer', 'park'].map((lane) => renderLane(ranked, lane)).join('')}
</div>
<div class="brief-grid reflection-room">
<article class="brief-card next-card"> <article class="brief-card next-card">
<span>Next 48 hours</span> <span>Next 48 hours</span>
<ol>${(brief.next48Hours || []).map((step) => `<li>${escapeHtml(step)}</li>`).join('')}</ol> <ol>${(brief.next48Hours || []).map((step) => `<li>${escapeHtml(step)}</li>`).join('')}</ol>
</article> </article>
<article class="brief-card next-card">
<span>Rank confidence</span>
<p><b>${escapeHtml(data.rankConfidence?.level || 'First pass')}</b> — ${escapeHtml(data.rankConfidence?.reason || brief.caution || '')}</p>
</article>
${(brief.whatWouldChangeRanking || []).length ? ` ${(brief.whatWouldChangeRanking || []).length ? `
<article class="brief-card next-card"> <article class="brief-card next-card">
<span>What would change the order</span> <span>What would change the order</span>
<ol>${brief.whatWouldChangeRanking.map((change) => `<li>${escapeHtml(change)}</li>`).join('')}</ol> <ol>${brief.whatWouldChangeRanking.map((change) => `<li>${escapeHtml(change)}</li>`).join('')}</ol>
</article> </article>
` : ''} ` : ''}
${(data.closeCalls || []).length ? `
<article class="brief-card next-card">
<span>Close calls</span>
<ol>${data.closeCalls.map((call) => `<li>${escapeHtml(call.note)}</li>`).join('')}</ol>
</article>
` : ''}
${(brief.assumptions || []).length ? ` ${(brief.assumptions || []).length ? `
<article class="brief-card"> <article class="brief-card">
<span>Context carried forward</span> <span>Context carried forward</span>
@@ -102,7 +212,7 @@ function renderResults(data) {
</article> </article>
` : ''} ` : ''}
${(brief.expertReflections || []).map((reflection) => ` ${(brief.expertReflections || []).map((reflection) => `
<article class="brief-card"> <article class="brief-card expert-card">
<span>${escapeHtml(reflection.lens)}</span> <span>${escapeHtml(reflection.lens)}</span>
<p>${escapeHtml(reflection.text)}</p> <p>${escapeHtml(reflection.text)}</p>
</article> </article>
@@ -110,6 +220,7 @@ function renderResults(data) {
</div> </div>
<p class="caution">${escapeHtml(brief.caution || 'First-pass judgement, not an oracle.')}</p> <p class="caution">${escapeHtml(brief.caution || 'First-pass judgement, not an oracle.')}</p>
`; `;
attachResultActions(data);
results.scrollIntoView({ behavior: 'smooth', block: 'start' }); results.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
@@ -118,7 +229,7 @@ async function createFeedbackMap(event) {
const submit = form.querySelector('button[type="submit"]'); const submit = form.querySelector('button[type="submit"]');
const payload = Object.fromEntries(new FormData(form).entries()); const payload = Object.fromEntries(new FormData(form).entries());
submit.disabled = true; submit.disabled = true;
submit.textContent = 'Ranking…'; submit.textContent = 'Judging…';
try { try {
const response = await fetch('/api/rank-feedback', { const response = await fetch('/api/rank-feedback', {
method: 'POST', method: 'POST',
+18 -16
View File
@@ -3,10 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#101626" /> <meta name="theme-color" content="#f3eee4" />
<meta name="rank-version" content="2.1.0-editorial-decision-room" /> <meta name="rank-version" content="2.2.0-feedback-front-door" />
<title>Ranker — ranked feedback maps for messy decisions</title> <title>Ranker — feedback front door for messy decisions</title>
<link rel="stylesheet" href="/styles.css?v=2.1.0-editorial-decision-room" /> <link rel="stylesheet" href="/styles.css?v=2.2.0-feedback-front-door" />
</head> </head>
<body> <body>
<main class="page-shell"> <main class="page-shell">
@@ -22,9 +22,9 @@
<div class="hero-grid" id="top"> <div class="hero-grid" id="top">
<div class="hero-copy"> <div class="hero-copy">
<p class="eyebrow">Decision feedback for scatterminds, builders, and structured people</p> <p class="eyebrow">Feedback intake for ideas, features, offers, and next moves</p>
<h1 id="hero-title">Dump your options. See what deserves attention first.</h1> <h1 id="hero-title">Submit the mess. Get a defended first move.</h1>
<p class="lede">Ranker turns a messy list of ideas, features, offers, roadmap items, or next moves into a ranked feedback map: what to test, build, defer, or drop — and why.</p> <p class="lede">Ranker is the front door to useful feedback: paste an idea plus the features or possible moves around it, choose the judgement lens, and get a decision brief with reasons, risks, expert reflections, and next steps.</p>
<div class="hero-actions"> <div class="hero-actions">
<a class="button primary" href="#try">Rank a messy list</a> <a class="button primary" href="#try">Rank a messy list</a>
<button class="button ghost" type="button" id="loadSampleTop">Load sample</button> <button class="button ghost" type="button" id="loadSampleTop">Load sample</button>
@@ -57,19 +57,19 @@
<section class="decision-tool" id="try" aria-labelledby="try-title"> <section class="decision-tool" id="try" aria-labelledby="try-title">
<div class="tool-intro"> <div class="tool-intro">
<p class="eyebrow">MVP · no account · no dashboard swamp</p> <p class="eyebrow">MVP · feedback front door · no account · no dashboard swamp</p>
<h2 id="try-title">Rank the pile</h2> <h2 id="try-title">Send a decision brief to the room</h2>
<p>Paste one idea plus the possible features, directions, offers, or next steps. Pick what the ranking should care about. Ranker gives a first-pass decision brief.</p> <p>Describe what you want feedback on, then list the possible features, directions, offers, or next steps. Ranker gives you the quick judgement first, then the deeper reflections.</p>
</div> </div>
<form class="rank-form" id="rankForm"> <form class="rank-form" id="rankForm">
<label> <label>
<span>Main idea or context</span> <span>What do you want feedback on?</span>
<textarea name="idea" rows="4" placeholder="Example: Im building a tool that helps freelancers package their services and decide what to sell first."></textarea> <textarea name="idea" rows="4" placeholder="Example: Im building a tool that helps freelancers package their services and decide what to sell first."></textarea>
</label> </label>
<label> <label>
<span>Options to rank <b>required</b></span> <span>Possible moves / features / functionality <b>required</b></span>
<textarea name="optionsText" rows="9" required placeholder="One per line. Bullets, rambling, half-thoughts are fine. <textarea name="optionsText" rows="9" required placeholder="One per line. Bullets, rambling, half-thoughts are fine.
- Offer critique - Offer critique
- Pricing calculator - Pricing calculator
@@ -80,16 +80,18 @@
</label> </label>
<fieldset class="mode-picker"> <fieldset class="mode-picker">
<legend>What should the ranking care about?</legend> <legend>What should the judgement optimize for?</legend>
<label><input type="radio" name="mode" value="progress" checked /> <span>Fastest useful progress</span></label> <label><input type="radio" name="mode" value="progress" checked /> <span>Fastest useful progress</span></label>
<label><input type="radio" name="mode" value="mvp" /> <span>Best MVP order</span></label> <label><input type="radio" name="mode" value="mvp" /> <span>Best MVP order</span></label>
<label><input type="radio" name="mode" value="validation" /> <span>Fastest validation</span></label>
<label><input type="radio" name="mode" value="revenue" /> <span>Revenue potential</span></label> <label><input type="radio" name="mode" value="revenue" /> <span>Revenue potential</span></label>
<label><input type="radio" name="mode" value="risk" /> <span>Risk reduction</span></label> <label><input type="radio" name="mode" value="risk" /> <span>Safest proof order</span></label>
<label><input type="radio" name="mode" value="learning" /> <span>Biggest assumption test</span></label>
<label><input type="radio" name="mode" value="originality" /> <span>Most original</span></label> <label><input type="radio" name="mode" value="originality" /> <span>Most original</span></label>
</fieldset> </fieldset>
<label> <label>
<span>Extra constraints <em>optional</em></span> <span>Constraints the feedback should respect <em>optional</em></span>
<input name="context" placeholder="Example: one week, solo builder, no paid ads, must be useful on mobile" /> <input name="context" placeholder="Example: one week, solo builder, no paid ads, must be useful on mobile" />
</label> </label>
@@ -123,6 +125,6 @@
</main> </main>
<div class="toast" id="toast" hidden></div> <div class="toast" id="toast" hidden></div>
<script src="/app.js?v=2.1.0-editorial-decision-room" type="module"></script> <script src="/app.js?v=2.2.0-feedback-front-door" type="module"></script>
</body> </body>
</html> </html>
+14 -1
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -65,6 +65,12 @@ try {
assert.equal(data.ranked.length, 6); assert.equal(data.ranked.length, 6);
assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']); assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']);
assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]); assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]);
assert.equal(data.buildOrderDetails.doFirst[0].id, data.buildOrder.doFirst[0]);
assert.equal(data.buildOrderDetails.doFirst[0].sourceSection, 'concept-map.nextMoves');
assert.equal(data.buildOrderDetails.doFirst[0].laneSource, 'ranked');
assert.equal(typeof data.buildOrderDetails.doFirst[0].score, 'number');
assert.equal(typeof data.buildOrderDetails.doFirst[0].confidence, 'number');
assert.match(data.buildOrderDetails.doFirst[0].nextStep, /manual proof/i);
assert.notEqual(data.ranked[0].id, 'workspace', 'dashboard swamp must not win the bridge fixture'); assert.notEqual(data.ranked[0].id, 'workspace', 'dashboard swamp must not win the bridge fixture');
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'workspace').lane.id)); assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'workspace').lane.id));
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'billing').lane.id)); assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'billing').lane.id));
+143 -7
View File
@@ -393,9 +393,19 @@ const judgementModes = {
next: 'Validate willingness to pay before polishing delivery. A paid manual version beats a beautiful unpaid feature.', next: 'Validate willingness to pay before polishing delivery. A paid manual version beats a beautiful unpaid feature.',
}, },
risk: { risk: {
label: 'Risk reduction', 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 }, weights: { value: 0.95, feasibility: 1.0, confidence: 1.45, urgency: 0.8, revenue: 0.45, novelty: 0.25, risk: -1.85 },
next: 'Start where uncertainty is highest and evidence is cheapest. Do not build around an untested assumption.', 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: { originality: {
label: 'Most original / differentiated', label: 'Most original / differentiated',
@@ -423,11 +433,11 @@ function parseOptionsFromText(value) {
const lines = text.split('\n').map(line => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim()).filter(Boolean); const lines = text.split('\n').map(line => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim()).filter(Boolean);
const optionLines = lines.length >= 2 ? lines : text.split(/[;|]/).map(part => part.trim()).filter(Boolean); const optionLines = lines.length >= 2 ? lines : text.split(/[;|]/).map(part => part.trim()).filter(Boolean);
return optionLines.slice(0, 24).map((line, index) => { return optionLines.slice(0, 24).map((line, index) => {
const [rawTitle, ...rest] = line.split(/\s[-–—:]\s/); const [rawTitle, ...rest] = line.split(/\s*[-–—:]\s+/);
return { return {
id: `option-${index + 1}`, id: `option-${index + 1}`,
title: cleanText(rawTitle || line, 140), title: cleanText(rawTitle || line, 140),
description: cleanText(rest.join(' — ') || line, 420), description: cleanText(rest.join(' — '), 420),
}; };
}).filter(item => item.title); }).filter(item => item.title);
} }
@@ -664,7 +674,7 @@ function scoreOption(option, mode, context = '', decisionContext = {}) {
const normalizedLaneHint = normalizeLaneHint(laneHint); const normalizedLaneHint = normalizeLaneHint(laneHint);
const conflicts = nonGoalConflicts(`${option.title} ${option.description}`, decisionContext); const conflicts = nonGoalConflicts(`${option.title} ${option.description}`, decisionContext);
const nonGoalPenalty = Math.min(14, conflicts.length * 7); 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 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 lanePenalty = normalizedLaneHint === 'park' ? 18 : normalizedLaneHint === 'defer' ? 9 : 0;
const heuristicMetrics = { 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), 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),
@@ -719,14 +729,30 @@ function laneFor(option, rankIndex, total) {
return { id: 'defer', label: 'Defer', action: 'Sequence after proof' }; 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) { function reasonFor(option) {
const m = option.metrics; 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 (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.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 (option.factors?.evidenceNeeded && m.confidence >= 6.4) return 'it names the evidence needed, so the next move can be tested instead of guessed';
if (m.feasibility >= 7.2 && m.value >= 6.2) return 'it has high enough value with low enough delivery drag to create fast signal';
if (m.revenue >= 6.4) return 'it has a clearer buyer or money signal than the rest of the list'; if (m.revenue >= 6.4) return 'it has a clearer buyer or money signal than the rest of the list';
if (m.risk >= 6.5) return 'it is interesting, but carries assumption risk that should be tested before build'; if (m.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'; 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'; return 'it has the best balanced tradeoff across value, effort, confidence, and timing';
} }
@@ -735,6 +761,7 @@ function concernFor(option) {
const m = option.metrics; const m = option.metrics;
if (m.nonGoalConflicts?.length) return `Source context says not to do this yet: ${m.nonGoalConflicts.join('; ')}.`; 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?.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.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.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.confidence <= 4.5) return 'Evidence looks thin. Treat this as a question, not a roadmap item.';
@@ -742,6 +769,51 @@ function concernFor(option) {
return 'The main risk is sequencing: do it only if it supports the first useful proof.'; return 'The main risk is sequencing: do it only if it supports the first useful proof.';
} }
function evidenceQuestionFor(option) {
if (!option) return '';
if (option.factors?.evidenceNeeded) return option.factors.evidenceNeeded;
if (option.lane?.id === 'park') return `What would make “${option.title}” worth reopening later?`;
if (option.metrics?.revenue >= 6.4) return `Will a real buyer ask for or pay for “${option.title}” before it is polished?`;
if (option.metrics?.risk >= 6.2) return `What is the cheapest test that could disprove “${option.title}”?`;
return `Can 35 target users understand and act on “${option.title}” without extra explanation?`;
}
function nextStepFor(option) {
if (!option) return '';
if (option.lane?.id === 'do') return `Run one manual proof of “${option.title}” before building supporting machinery.`;
if (option.lane?.id === 'test') return `Design the smallest evidence test for “${option.title}” and collect signal from real users.`;
if (option.lane?.id === 'defer') return `Keep “${option.title}” sequenced after the active proof; do not parallel-build it.`;
return `Park “${option.title}” unless new evidence changes the decision.`;
}
function successSignalFor(option) {
if (!option) return '';
if (option.metrics?.revenue >= 6.4) return 'A real prospect asks for the outcome, accepts a price, or requests the next step.';
if (option.lane?.id === 'do') return 'At least 2 of 3 real users can name why this should be first and what they would do next.';
if (option.lane?.id === 'test') return 'The test produces a clear yes/no learning, not polite interest.';
return 'New evidence makes this more urgent than the current active lane.';
}
function killSignalFor(option) {
if (!option) return '';
if (option.metrics?.nonGoalConflicts?.length) return 'It still conflicts with the source guardrails after review.';
if (option.metrics?.feasibility <= 4.5) return 'The proof slice needs platform work before any user signal exists.';
return 'People understand the idea but do not take, request, or value the next step.';
}
function scoringNotesFor(option) {
const notes = [];
const m = option.metrics || {};
if (option.factors?.evidenceNeeded) notes.push('Boosted because it names evidence to collect.');
if (m.nonGoalConflicts?.length) notes.push('Penalized because it conflicts with source guardrails.');
if (m.feasibility >= 7) notes.push('Boosted for low delivery drag.');
if (m.value >= 7) notes.push('Boosted for user value.');
if (m.revenue >= 6.5) notes.push('Boosted for buyer signal.');
if (m.risk >= 6.5) notes.push('Flagged as assumption-heavy.');
if ((option.factors?.dependencies || []).length >= 2) notes.push('Penalized for dependencies.');
return notes.slice(0, 4);
}
function whatWouldChangeRanking(top, second, risky) { function whatWouldChangeRanking(top, second, risky) {
if (!top) return ['Add at least two concrete next moves with evidence needed.']; if (!top) return ['Add at least two concrete next moves with evidence needed.'];
const changes = []; const changes = [];
@@ -760,6 +832,15 @@ function createDecisionBrief({ idea, context, mode, ranked, provenance, decision
const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3); const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3);
const sourceLabel = [provenance?.snapshotTitle, provenance?.artifactId].filter(Boolean).join(' · '); 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 theme = top ? `The strongest signal is “${top.title}” because ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.';
const quickGlance = top ? {
topPick: top.title,
topLane: top.lane?.label || '',
whyThisWins: reasonFor(top),
nextAction: nextStepFor(top),
evidenceQuestion: evidenceQuestionFor(top),
biggestTrap: concernFor(top),
doNotBuildYet: deferred.slice(0, 2).map(item => item.title),
} : null;
const assumptions = [ const assumptions = [
...(decisionContext?.assumptions || []), ...(decisionContext?.assumptions || []),
...(decisionContext?.constraints || []).map(item => `Constraint: ${item}`), ...(decisionContext?.constraints || []).map(item => `Constraint: ${item}`),
@@ -768,6 +849,7 @@ function createDecisionBrief({ idea, context, mode, ranked, provenance, decision
return { return {
headline: top ? `Start with ${top.title}` : 'Add options to get a ranked feedback map', 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}.` : ''}`, summary: `${theme}${second ? `${second.title}” is the nearest follow-up, not a parallel first step.` : ''}${sourceLabel ? ` Source: ${sourceLabel}.` : ''}`,
quickGlance,
source: provenance ? { source: provenance ? {
schema: provenance.schema, schema: provenance.schema,
source: provenance.source, source: provenance.source,
@@ -801,6 +883,45 @@ function createDecisionBrief({ idea, context, mode, ranked, provenance, decision
}; };
} }
function rankConfidenceFor(ranked = []) {
if (ranked.length < 2) return { level: 'low', reason: 'There are not enough comparable options for a confident ranking.' };
const gap = Math.abs((ranked[0].metrics?.score || 0) - (ranked[1].metrics?.score || 0));
if (gap <= 3) return { level: 'close call', reason: `The top two options are only ${gap} points apart, so treat the winner as a sequencing bet, not a law.` };
if (gap <= 8) return { level: 'medium', reason: `The top option leads by ${gap} points, but the follow-up is still worth rechecking after one proof cycle.` };
return { level: 'strong', reason: `The top option leads by ${gap} points and has the clearest first-proof profile.` };
}
function closeCallsFor(ranked = []) {
const calls = [];
for (let index = 0; index < ranked.length - 1; index += 1) {
const current = ranked[index];
const next = ranked[index + 1];
const gap = Math.abs((current.metrics?.score || 0) - (next.metrics?.score || 0));
if (gap <= 5) calls.push({
pair: [current.title, next.title],
gap,
note: `${current.title}” barely beats “${next.title}”; rerank if new evidence changes effort, confidence, or buyer signal.`,
});
}
return calls.slice(0, 3);
}
function compactBuildItems(items = []) {
return items.map(item => ({
id: item.id,
title: item.title,
reason: item.reason,
nextStep: item.nextStep,
evidenceQuestion: item.evidenceQuestion,
concern: item.concern,
sourceSection: item.provenance?.sourceSection || '',
sourceId: item.provenance?.sourceId || '',
laneSource: item.lane?.source || 'ranked',
score: item.metrics?.score ?? null,
confidence: item.metrics?.confidence ?? null,
}));
}
function createHandoffContract({ ranked, provenance, decisionContext }) { function createHandoffContract({ ranked, provenance, decisionContext }) {
const warnings = []; const warnings = [];
if (!provenance?.artifactId) warnings.push('missing source artifact id'); if (!provenance?.artifactId) warnings.push('missing source artifact id');
@@ -864,6 +985,12 @@ app.post('/api/rank-feedback', (req, res) => {
...rankedOption, ...rankedOption,
reason: reasonFor(rankedOption), reason: reasonFor(rankedOption),
concern: concernFor(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 brief = createDecisionBrief({ idea, context, mode, ranked: options, provenance, decisionContext });
@@ -874,13 +1001,22 @@ app.post('/api/rank-feedback', (req, res) => {
input: { idea, context, optionCount: options.length, provenance, decisionContext }, input: { idea, context, optionCount: options.length, provenance, decisionContext },
ranked: options, ranked: options,
brief, brief,
rankConfidence: rankConfidenceFor(options),
closeCalls: closeCallsFor(options),
handoff, handoff,
availableModes: Object.entries(judgementModes).map(([id, item]) => ({ id, label: item.label })),
buildOrder: { buildOrder: {
doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id), doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id),
validateNext: options.filter(item => item.lane.id === 'test').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), defer: options.filter(item => item.lane.id === 'defer').map(item => item.id),
park: options.filter(item => item.lane.id === 'park').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')),
},
}); });
}); });