From f4bb937302e27240e8ccc035cd80cff872d09473 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Tue, 26 May 2026 22:27:19 +0200 Subject: [PATCH] Honor Scattermind lane hints in rank feedback --- scripts/check-rank-feedback.mjs | 8 ++++++- server.js | 37 ++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index 3e237a9..fa1c9fc 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -51,6 +51,7 @@ try { { 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: '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' }, ], }, }), @@ -61,14 +62,19 @@ try { assert.equal(data.input.provenance.artifactId, 'snapshot_123'); assert.equal(data.input.provenance.source, 'Scattermind'); assert.match(data.input.provenance.originalPrompt, /tiny shop idea/); - assert.equal(data.ranked.length, 5); + assert.equal(data.ranked.length, 6); assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']); assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]); 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 === 'billing').lane.id)); + assert.equal(data.ranked.find(item => item.id === 'parked-bridge-dashboard').lane.id, 'park', 'explicit Scattermind park hints must stay out of the active proof slice'); + assert.equal(data.ranked.find(item => item.id === 'parked-bridge-dashboard').lane.source, 'hint'); assert.equal(data.ranked.find(item => item.id === 'bridge-contract').provenance.sourceSection, 'concept-map.nextMoves'); assert.match(data.ranked.find(item => item.id === 'bridge-contract').factors.evidenceNeeded, /Concept Map/); + assert.equal(data.brief.source.artifactId, 'snapshot_123'); + assert.match(data.brief.summary, /Source: Tiny shop idea clarity pass · snapshot_123/); + 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); console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2)); diff --git a/server.js b/server.js index 98166c7..63bf9db 100644 --- a/server.js +++ b/server.js @@ -428,7 +428,9 @@ function scoreOption(option, mode, context = '') { 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 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 metrics = { 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))), @@ -441,12 +443,29 @@ 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))); + 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 }; } +function normalizeLaneHint(value = '') { + const hint = cleanText(value, 40).toLowerCase().replace(/_/g, '-'); + if (/^(do|do-first|first|build|build-now|now)$/.test(hint)) return 'do'; + if (/^(validate|validate-next|test|test-next|proof|evidence)$/.test(hint)) return 'test'; + if (/^(defer|later|sequence-later|after-proof)$/.test(hint)) return 'defer'; + if (/^(park|cut|drop|icebox|not-now)$/.test(hint)) return 'park'; + return ''; +} + function laneFor(option, rankIndex, total) { + const hintedLane = normalizeLaneHint(option.factors?.recommendedLane || ''); + // Ranker should defend build order, not blindly obey Scattermind. Positive + // hints can nudge scoring, but explicit negative hints are safety rails: + // if the source already marked something as defer/park, never promote it + // into the active proof slice just because keyword scoring liked it. + if (hintedLane === 'park') return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan', source: 'hint' }; + if (hintedLane === 'defer') return { id: 'defer', label: 'Defer', action: 'Sequence after proof', source: 'hint' }; if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' }; + if (hintedLane === 'test') return { id: 'test', label: 'Validate next', action: 'Find evidence', source: 'hint' }; if (rankIndex < Math.max(2, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' }; if (rankIndex >= Math.max(2, Math.floor(total * 0.72))) return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan' }; return { id: 'defer', label: 'Defer', action: 'Sequence after proof' }; @@ -473,15 +492,23 @@ function concernFor(option) { return 'The main risk is sequencing: do it only if it supports the first useful proof.'; } -function createDecisionBrief({ idea, context, mode, ranked }) { +function createDecisionBrief({ idea, context, mode, ranked, provenance }) { const top = ranked[0]; const second = ranked[1]; const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0]; const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3); + const sourceLabel = [provenance?.snapshotTitle, provenance?.artifactId].filter(Boolean).join(' · '); const theme = top ? `The strongest signal is “${top.title}” because it has ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.'; 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.` : ''}`, + summary: `${theme}${second ? ` “${second.title}” is the nearest follow-up, not a parallel first step.` : ''}${sourceLabel ? ` Source: ${sourceLabel}.` : ''}`, + source: provenance ? { + schema: provenance.schema, + source: provenance.source, + artifactId: provenance.artifactId, + snapshotTitle: provenance.snapshotTitle, + conceptMapId: provenance.conceptMapId, + } : null, expertReflections: [ { lens: 'Product expert', @@ -497,7 +524,7 @@ function createDecisionBrief({ idea, context, mode, ranked }) { }, ], next48Hours: top ? [ - `Write a one-paragraph test for “${top.title}”.`, + 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 3–5 real people or run it manually once.', `Do not touch ${deferred[0] ? `“${deferred[0].title}”` : 'the parked ideas'} until the first signal is real.`, @@ -531,7 +558,7 @@ app.post('/api/rank-feedback', (req, res) => { mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label }, input: { idea, context, optionCount: options.length, provenance }, ranked: options, - brief: createDecisionBrief({ idea, context, mode, ranked: options }), + brief: createDecisionBrief({ idea, context, mode, ranked: options, provenance }), 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),