From e463f4bc2a3241c5a6095511746b72bb929a57f7 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Wed, 27 May 2026 18:46:15 +0200 Subject: [PATCH] Carry Scattermind thread guardrails into Ranker --- scripts/check-rank-feedback.mjs | 32 ++++++++++++++++++++++++++++++++ server.js | 16 +++++++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index a8d9e09..323fc78 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -198,6 +198,38 @@ try { assert.equal(softGuardrail.buildOrder.doFirst[0], 'decision-strip'); assert.ok(!/dashboard/i.test(softGuardrail.brief.quickGlance.topPick)); + const threadGuardrailResponse = await fetch(`${base}/api/rank-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceName: 'Scattermind', + artifactId: 'concept_map_thread_guardrails', + originalPrompt: 'Clarify a service-package idea and decide what should happen next.', + conceptMap: { + working_name: 'Service package first proof', + opening_reflection: 'The Concept Map says the next move is a small proof, not account machinery.', + lenses: { + channel: { + title: 'Build Order', + content: 'Build first: Saved workspace dashboard for every package and client. Build first: Manual package teardown preview. Test manually: Copyable decision brief. Defer: Client portal later.', + }, + }, + threads_to_hold: [ + 'Do not let this become accounts, saved dashboards, client portals, or a workspace system before the manual proof works.', + ], + questions_to_sit_with: ['Will one freelancer act on the manual package teardown?'], + closing_note: 'Keep the first 48 hours manual and evidence-led.', + reference_code: 'SM-THREAD', + }, + }), + }); + assert.equal(threadGuardrailResponse.status, 200); + const threadGuardrail = await threadGuardrailResponse.json(); + assert.ok(threadGuardrail.input.decisionContext.nonGoals.some(item => /Do not let this become accounts/i.test(item)), 'Concept Map threads_to_hold guardrails should be carried into decision context'); + assert.equal(threadGuardrail.ranked.find(item => /Saved workspace dashboard/i.test(item.title)).lane.source, 'source-non-goal'); + assert.equal(threadGuardrail.buildOrder.doFirst[0], 'build-order-2', 'thread-level guardrail should demote a labelled Build first dashboard and promote the first eligible manual move'); + assert.match(threadGuardrail.brief.decisionReceipt.sourceAnchor, /concept-map\.lenses\.channel/); + const hintedResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/server.js b/server.js index 9db171d..d30c242 100644 --- a/server.js +++ b/server.js @@ -817,6 +817,10 @@ function contextGuardrailText(value = '') { return cleanMultiline(obj.summary || obj.description || obj.notes || obj.brief || obj.text || '', 3000); } +function guardrailTextItems(value = [], maxItems = 8) { + return cleanFlexibleTextList(value, maxItems, 260).filter(item => /\b(avoid|no|do not|don't|dont|must not|never|non-goal|non goal|not yet|out of scope|defer|hold for later|set aside|probably noise|park|do not let this become|don't let this become|not a dashboard|dashboard swamp)\b/i.test(item)); +} + function cleanDecisionContext(input = {}) { const envelope = bridgeEnvelopeFrom(input); const featureSet = featureSetFrom(input); @@ -852,8 +856,14 @@ function cleanDecisionContext(input = {}) { contextGuardrailText(conceptMap.context || ''), lensContent(riskLens), lensContent(constraintsLens), - conceptMap.risk || conceptMap.whatNotToBuildYet || conceptMap.notYet || '', - snapshot.risk || snapshot.whatNotToBuildYet || snapshot.notYet || '', + conceptMap.risk || conceptMap.whatNotToBuildYet || conceptMap.notYet || conceptMap.closing_note || conceptMap.closingNote || '', + snapshot.risk || snapshot.whatNotToBuildYet || snapshot.notYet || snapshot.closing_note || snapshot.closingNote || '', + envelope.closing_note || envelope.closingNote || featureSet.closing_note || featureSet.closingNote || input.closing_note || input.closingNote || '', + ...guardrailTextItems(conceptMap.threads_to_hold || conceptMap.threadsToHold || conceptMap.actionThreads || conceptMap.action_threads, 8), + ...guardrailTextItems(snapshot.threads_to_hold || snapshot.threadsToHold || snapshot.actionThreads || snapshot.action_threads, 8), + ...guardrailTextItems(envelope.threads_to_hold || envelope.threadsToHold || envelope.actionThreads || envelope.action_threads, 8), + ...guardrailTextItems(featureSet.threads_to_hold || featureSet.threadsToHold || featureSet.actionThreads || featureSet.action_threads, 8), + ...guardrailTextItems(input.threads_to_hold || input.threadsToHold || input.actionThreads || input.action_threads, 8), ].filter(Boolean).join('\n')); return { targetAudience: cleanText(input.targetAudience || input.target_audience || envelope.targetAudience || envelope.target_audience || featureSet.targetAudience || featureSet.target_audience || snapshot.targetAudience || snapshot.target_audience || firstContextText(contextSources, ['targetAudience', 'target_audience', 'audience', 'who', 'whoItHelps', 'who_it_helps', 'customer', 'users']) || conceptMap.targetAudience || conceptMap.target_audience || lensContent(audienceLens), 180), @@ -889,7 +899,7 @@ function cleanContextText(value = '') { } function meaningfulTokens(text = '') { - const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'until', 'user', 'users', 'idea', 'ideas', 'build', 'order', 'works', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'manual', 'value', 'layer', 'result', 'sense', 'copyable', 'source', 'traced', 'source-traced', 'evidence']); + const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'until', 'user', 'users', 'idea', 'ideas', 'build', 'order', 'works', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'manual', 'value', 'layer', 'result', 'sense', 'copyable', 'source', 'traced', 'source-traced', 'evidence', 'thread', 'threads']); return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8); }