Accept soft Scattermind bridge lane labels

This commit is contained in:
OpenClaw Bot
2026-05-27 01:32:17 +02:00
parent bf29b7ab95
commit b5e791ae33
3 changed files with 78 additions and 16 deletions
+2
View File
@@ -51,6 +51,8 @@ Candidate items may include optional 110 `rankerHints` (`value`, `effort`, `c
Candidate trace note: candidate-level `sourceItemId` / `traceId`, `sourceTitle` / `lensTitle`, and `sourceExcerpt` / `sourceQuote` are preserved in ranked items, `buildOrderDetails`, and `handoff.itemTrace`. Lens-only Build Order text is also split into deterministic `concept-map.lenses.channel#N` source IDs with the original labelled sentence carried as `sourceQuote`, so pasted paid Concept Maps remain traceable even without explicit candidate objects. String items in laned Build Order arrays now also receive deterministic section-local source IDs such as `concept-map.buildOrder.validateNext#1` and carry the original string as `sourceQuote`, so simple Scattermind exports stay addressable downstream instead of becoming anonymous `feature-1` rows. The decision `brief.quickGlance.sourceTrace` now repeats the winning item's source section/id/title/quote, and both `brief.source.originalPromptExcerpt` and `handoff.source.originalPromptExcerpt` carry a short prompt excerpt so a downstream Scattermind handoff can show why the build order exists without digging through `input.provenance`. Scattermind should use these when a next move came from a specific Concept Map lens sentence, so Ranker can defend not just what wins but where the judgement came from.
Soft Scattermind labels are accepted at the bridge boundary so Scattermind does not need to use harsh verdict copy in its own product surface. Lens text can say `Continue first`, `Make tangible`, `Try next`, `Evidence next`, `Hold for later`, or `Set aside`; Build Order objects can use matching camel/snake-case keys such as `continueFirst`, `evidenceNext`, `holdForLater`, and `setAside`. Ranker maps those to `doFirst / validateNext / defer / park` while preserving the softer original label in `sourceQuote`.
Lane safety note: explicit Scattermind `defer` / `park` hints are hard rails, not mild suggestions. Source `nonGoals` / `avoid` guardrails are also hard enough to keep conflicting candidates out of Do first / Validate next even when their local scoring hints look attractive; the result will mark the lane source as `source-non-goal` so the handoff can explain that the candidate needs guardrail resolution before active work. Handoff `source.requiresSourceTrace` is true only when a real source artifact/title is present; plain idea-only ranking still warns about a missing artifact ID when it carries prompt provenance, but it does not spam source-section/evidence warnings meant for Scattermind artifacts. Handoff `readiness` now gives downstream bridge consumers a deterministic gate: `ready`, `usable-with-warnings`, `needs-source-context`, or `blocked`, with blockers and next checks for missing evidence, source trace, duplicate IDs, or active source-non-goal conflicts. For low-friction handoff, `/api/rank-feedback` also detects a raw Scattermind/Concept Map JSON object pasted into `idea`, `ideaText`, `optionsText`, or wrapper keys such as `payload`; it expands that object before ranking and reports `input.embeddedPayloadSource` so the public form can accept copy/paste exports without a custom import screen.
Recommended payload shape:
+54
View File
@@ -618,6 +618,60 @@ try {
assert.ok(scattermindPaidShape.ranked.find(item => item.id === 'build-order-4').metrics.nonGoalConflicts.length >= 1);
assert.deepEqual(scattermindPaidShape.handoff.warnings, []);
const softLabelLensResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reference_code: 'SM-SOFT-LABELS',
working_name: 'Gentle Scattermind continuation labels',
ideaText: 'Scattermind avoids verdict language but still needs to hand Ranker a continuation order.',
context: 'Solo builder. Manual proof before machinery. Avoid accounts and saved workspaces.',
mode: 'mvp',
lenses: {
risk: 'Avoid accounts and saved workspaces until one copyable result makes sense.',
channel: 'Continue first: One source-traced build order preview - make the next move tangible without a dashboard. Try next: Copyable decision brief - ask three tired users what they would do next. Hold for later: Visual polish after the rough brief works. Set aside: Saved workspace with accounts, auth, billing, and collaboration.',
},
}),
});
assert.equal(softLabelLensResponse.status, 200);
const softLabelLens = await softLabelLensResponse.json();
assert.equal(softLabelLens.input.optionCount, 4);
assert.equal(softLabelLens.ranked[0].id, 'build-order-1');
assert.equal(softLabelLens.ranked[0].lane.id, 'do');
assert.match(softLabelLens.ranked[0].provenance.sourceQuote, /Continue first: One source-traced build order preview/);
assert.equal(softLabelLens.ranked.find(item => item.id === 'build-order-2').lane.id, 'test');
assert.equal(softLabelLens.ranked.find(item => item.id === 'build-order-3').lane.id, 'defer');
assert.equal(softLabelLens.ranked.find(item => item.id === 'build-order-4').lane.id, 'park');
assert.equal(softLabelLens.handoff.readiness.status, 'ready');
assert.deepEqual(softLabelLens.handoff.warnings, []);
const softLabelObjectResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceName: 'Scattermind',
artifactId: 'concept_map_soft_object_labels',
originalPrompt: 'Scattermind exported soft continuation sections instead of verdict-named lanes.',
idea: 'Ranker should accept softer object keys without forcing Scattermind to say build/kill.',
mode: 'mvp',
conceptMap: {
buildOrder: {
continueFirst: [{ id: 'manual-preview', move: 'Manual continuation preview', evidenceQuestion: 'Can one user explain the next move?' }],
evidenceNext: ['Copyable brief comprehension check'],
holdForLater: ['Visual system polish'],
setAside: ['Account workspace dashboard'],
},
},
}),
});
assert.equal(softLabelObjectResponse.status, 200);
const softLabelObject = await softLabelObjectResponse.json();
assert.equal(softLabelObject.input.optionCount, 4);
assert.equal(softLabelObject.buildOrder.doFirst[0], 'manual-preview');
assert.equal(softLabelObject.ranked.find(item => item.id === 'feature-1').lane.id, 'test');
assert.equal(softLabelObject.ranked.find(item => item.title === 'Visual system polish').lane.id, 'defer');
assert.equal(softLabelObject.ranked.find(item => item.title === 'Account workspace dashboard').lane.id, 'park');
const mergedContextResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+22 -16
View File
@@ -682,7 +682,7 @@ function cleanContextText(value = '') {
}
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', 'manual', 'value', 'layer']);
const stop = new Set(['the', 'and', 'with', 'from', 'that', 'this', 'into', 'before', 'after', 'until', 'user', 'users', 'idea', 'ideas', 'build', 'first', 'avoid', 'without', 'more', 'full', 'proof', 'manual', 'value', 'layer', 'result', 'sense', 'copyable']);
return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8);
}
@@ -769,10 +769,10 @@ function compactCandidateGroup(group = []) {
function buildOrderSectionGroup(buildOrder = {}, baseSection = 'buildOrder') {
const source = objectFrom(buildOrder);
return compactCandidateGroup([
{ items: source.doFirst || source.do_first || source.buildFirst || source.buildNow || source.now, sourceSection: `${baseSection}.doFirst`, defaultLane: 'do-first' },
{ items: source.validateNext || source.validate_next || source.testNext || source.testManually || source.validation, sourceSection: `${baseSection}.validateNext`, defaultLane: 'validate-next' },
{ items: source.defer || source.deferred || source.later || source.afterProof, sourceSection: `${baseSection}.defer`, defaultLane: 'defer' },
{ items: source.park || source.parkingLot || source.parking_lot || source.parked || source.probablyNoise || source.probably_noise || source.noise, sourceSection: `${baseSection}.park`, defaultLane: 'park' },
{ items: source.doFirst || source.do_first || source.buildFirst || source.buildNow || source.now || source.continueFirst || source.continue_first || source.makeTangible || source.make_tangible || source.startHere || source.start_here, sourceSection: `${baseSection}.doFirst`, defaultLane: 'do-first' },
{ items: source.validateNext || source.validate_next || source.testNext || source.testManually || source.validation || source.evidenceNext || source.evidence_next || source.tryNext || source.try_next || source.learnNext || source.learn_next, sourceSection: `${baseSection}.validateNext`, defaultLane: 'validate-next' },
{ items: source.defer || source.deferred || source.later || source.afterProof || source.holdForLater || source.hold_for_later || source.notYet || source.not_yet, sourceSection: `${baseSection}.defer`, defaultLane: 'defer' },
{ items: source.park || source.parkingLot || source.parking_lot || source.parked || source.probablyNoise || source.probably_noise || source.noise || source.setAside || source.set_aside || source.outOfScope || source.out_of_scope, sourceSection: `${baseSection}.park`, defaultLane: 'park' },
]);
}
@@ -786,23 +786,23 @@ function normalizeCandidateGroup(group = []) {
function sentenceFragments(text = '') {
return cleanMultiline(text, 4000)
.replace(/\s+(build first|start here|ship first|test manually|validate next|defer|probably noise|park|do not build yet|don't build yet)\s*:/gi, '\n$1:')
.replace(/\s+(build first|start here|ship first|continue first|make tangible first|make tangible|try next|evidence next|learn next|test manually|validate next|hold for later|not yet|defer|set aside|out of scope|probably noise|park|do not build yet|don't build yet)\s*:/gi, '\n$1:')
.split(/\n|;|\s+[•-]\s+/)
.map(part => part.trim())
.filter(Boolean);
}
function titleFromBuildOrderFragment(value = '') {
const cleaned = cleanText(value.replace(/^(build first|start here|ship first|test manually|validate next|defer|probably noise|park|do not build yet|don't build yet)\s*:\s*/i, ''), 220);
const cleaned = cleanText(value.replace(/^(build first|start here|ship first|continue first|make tangible first|make tangible|try next|evidence next|learn next|test manually|validate next|hold for later|not yet|defer|set aside|out of scope|probably noise|park|do not build yet|don't build yet)\s*:\s*/i, ''), 220);
const first = cleaned.split(/\s[-–—:]\s|\.\s/)[0] || cleaned;
return cleanText(first, 120);
}
function laneFromBuildOrderLabel(fragment = '') {
if (/^(build first|start here|ship first)\s*:/i.test(fragment)) return 'do-first';
if (/^(test manually|validate next)\s*:/i.test(fragment)) return 'validate-next';
if (/^(defer|do not build yet|don't build yet)\s*:/i.test(fragment)) return 'defer';
if (/^(probably noise|park)\s*:/i.test(fragment)) return 'park';
if (/^(build first|start here|ship first|continue first|make tangible first|make tangible)\s*:/i.test(fragment)) return 'do-first';
if (/^(try next|evidence next|learn next|test manually|validate next)\s*:/i.test(fragment)) return 'validate-next';
if (/^(hold for later|not yet|defer|do not build yet|don't build yet)\s*:/i.test(fragment)) return 'defer';
if (/^(set aside|out of scope|probably noise|park)\s*:/i.test(fragment)) return 'park';
return '';
}
@@ -815,7 +815,13 @@ function optionsFromBuildOrderText(text = '', sourceSection = 'concept-map.lense
id: `build-order-${index + 1}`,
action: titleFromBuildOrderFragment(fragment),
why: fragment.replace(/^\s*[^:]{1,40}:\s*/, '').trim(),
evidence: /test|validate|proof|prove|signal|ask|show/i.test(fragment) ? fragment : (lane === 'do-first' ? 'Prove this first move manually before adding product machinery.' : ''),
evidence: /test|validate|proof|prove|signal|ask|show/i.test(fragment)
? fragment
: lane === 'do-first'
? 'Prove this first move manually before adding product machinery.'
: lane === 'validate-next'
? 'Collect the smallest real signal before promoting this into the build lane.'
: '',
suggestedLane: lane,
rankerHints: lane === 'do-first'
? { value: 8, effort: 2, confidence: 7, urgency: 7, risk: 2 }
@@ -965,10 +971,10 @@ function scoreOption(option, mode, context = '', decisionContext = {}) {
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';
if (/^(do|do-first|first|build|build-now|now|continue-first|make-tangible|start-here)$/.test(hint)) return 'do';
if (/^(validate|validate-next|test|test-next|proof|evidence|evidence-next|try-next|learn-next)$/.test(hint)) return 'test';
if (/^(defer|later|sequence-later|after-proof|hold-for-later|not-yet)$/.test(hint)) return 'defer';
if (/^(park|cut|drop|icebox|not-now|set-aside|out-of-scope)$/.test(hint)) return 'park';
return '';
}