Support Scattermind question-only rank handoffs

This commit is contained in:
OpenClaw Bot
2026-05-27 16:27:12 +02:00
parent ca186f2a01
commit a225586296
3 changed files with 77 additions and 5 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ Candidate trace note: candidate-level `sourceItemId` / `traceId`, `sourceTitle`
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 and direct bridge/envelope sections 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` or candidate source trace. 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 and direct bridge/envelope sections 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` or candidate source trace.
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. Handoff `activeSlice` (`ranker-active-slice-v1`) is the compact machine-readable continuation unit: one active item, its proof/evidence/success/kill signals, source anchor, held-back items, readiness status, and the rule that only this slice is build-ready. For tired first-screen users, `brief.decisionReceipt` repeats the one active move, first proof step, evidence question, held-back items, source anchor, and the handoff rule that only Do first is active; use it as the compact result strip before showing the full lane board. 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. 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. Handoff `activeSlice` (`ranker-active-slice-v1`) is the compact machine-readable continuation unit: one active item, its proof/evidence/success/kill signals, source anchor, held-back items, readiness status, and the rule that only this slice is build-ready. For tired first-screen users, `brief.decisionReceipt` repeats the one active move, first proof step, evidence question, held-back items, source anchor, and the handoff rule that only Do first is active; use it as the compact result strip before showing the full lane board. 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. If a Concept Map only carries `questions_to_sit_with` / `questionsToSitWith` / `openQuestions` and no explicit build-order lanes or action threads, Ranker converts those questions into Validate-next evidence actions with source trace instead of pretending they are software features.
Recommended payload shape: Recommended payload shape:
+32 -1
View File
@@ -1254,6 +1254,37 @@ try {
assert.equal(threadsFallback.handoff.readiness.status, 'ready'); assert.equal(threadsFallback.handoff.readiness.status, 'ready');
assert.deepEqual(threadsFallback.handoff.warnings, []); assert.deepEqual(threadsFallback.handoff.warnings, []);
const questionsFallbackResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reference_code: 'SM-QUESTIONS-1',
working_name: 'Question-only Concept Map',
ideaText: 'Scattermind produced questions rather than a labelled build order; Ranker should turn those into evidence actions, not fake features.',
context: 'Tired solo operator. Avoid dashboards and saved workspaces before evidence.',
conceptMap: {
questions_to_sit_with: [
'Can one tired user act on the first continuation move without opening a workspace?',
{ question: 'Would a copyable handoff preserve enough source context to trust the order?', validationSteps: ['Paste the handoff into notes and ask one user what they would do next.'] },
'Should billing wait until one defended build-order handoff creates real follow-up?'
],
},
mode: 'validation',
}),
});
assert.equal(questionsFallbackResponse.status, 200);
const questionsFallback = await questionsFallbackResponse.json();
assert.equal(questionsFallback.input.provenance.artifactId, 'SM-QUESTIONS-1');
assert.equal(questionsFallback.input.optionCount, 3);
assert.equal(questionsFallback.ranked[0].id, 'question-2');
assert.equal(questionsFallback.ranked[0].lane.id, 'do');
assert.equal(questionsFallback.ranked[0].provenance.sourceSection, 'concept-map.questionsToSitWith');
assert.match(questionsFallback.ranked[0].title, /^Answer:/);
assert.match(questionsFallback.ranked.find(item => item.id === 'question-1').factors.evidenceNeeded, /without opening a workspace/);
assert.equal(questionsFallback.ranked.find(item => item.id === 'question-1').provenance.sourceSection, 'concept-map.questionsToSitWith');
assert.equal(questionsFallback.handoff.readiness.status, 'ready');
assert.deepEqual(questionsFallback.handoff.warnings, []);
const storedScattermindRowResponse = await fetch(`${base}/api/rank-feedback`, { const storedScattermindRowResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -1290,7 +1321,7 @@ try {
assert.equal(storedScattermindRow.handoff.readiness.status, 'ready'); assert.equal(storedScattermindRow.handoff.readiness.status, 'ready');
assert.deepEqual(storedScattermindRow.handoff.warnings, []); assert.deepEqual(storedScattermindRow.handoff.warnings, []);
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, hintedTop: hinted.ranked[0].id, actionTop: actions.ranked[0].id, nestedConceptTop: nestedConcept.ranked[0].id, nonGoalTop: nonGoal.ranked[0].id, structuredContextTop: structuredContext.ranked[0].id, lensOnlyTop: lensOnly.ranked[0].id, scattermindPaidShapeTop: scattermindPaidShape.ranked[0].id, mergedContextTop: mergedContext.ranked[0].id, embeddedJsonTop: embeddedJson.ranked[0].id, fencedJsonTop: fencedJson.ranked[0].id, embeddedSnapshotTop: embeddedSnapshot.ranked[0].id, sourceExcerptTop: sourceExcerpt.ranked[0].id, snakeCaseBridgeTop: snakeCaseBridge.ranked[0].id, nextStepsAliasTop: nextStepsAlias.ranked[0].id, summaryGuardrailTop: summaryGuardrail.ranked[0].id, bridgeEnvelopeTop: bridgeEnvelope.ranked[0].id, directEnvelopeSectionsTop: directEnvelopeSections.ranked[0].id, softDirectLaneAliasesTop: softDirectLaneAliases.ranked[0].id, threadsFallbackTop: threadsFallback.ranked[0].id, storedScattermindRowTop: storedScattermindRow.ranked[0].id, duplicateIds: duplicateIds.ranked.map(item => item.id), readiness: data.handoff.readiness.status, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2)); console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, hintedTop: hinted.ranked[0].id, actionTop: actions.ranked[0].id, nestedConceptTop: nestedConcept.ranked[0].id, nonGoalTop: nonGoal.ranked[0].id, structuredContextTop: structuredContext.ranked[0].id, lensOnlyTop: lensOnly.ranked[0].id, scattermindPaidShapeTop: scattermindPaidShape.ranked[0].id, mergedContextTop: mergedContext.ranked[0].id, embeddedJsonTop: embeddedJson.ranked[0].id, fencedJsonTop: fencedJson.ranked[0].id, embeddedSnapshotTop: embeddedSnapshot.ranked[0].id, sourceExcerptTop: sourceExcerpt.ranked[0].id, snakeCaseBridgeTop: snakeCaseBridge.ranked[0].id, nextStepsAliasTop: nextStepsAlias.ranked[0].id, summaryGuardrailTop: summaryGuardrail.ranked[0].id, bridgeEnvelopeTop: bridgeEnvelope.ranked[0].id, directEnvelopeSectionsTop: directEnvelopeSections.ranked[0].id, softDirectLaneAliasesTop: softDirectLaneAliases.ranked[0].id, threadsFallbackTop: threadsFallback.ranked[0].id, questionsFallbackTop: questionsFallback.ranked[0].id, storedScattermindRowTop: storedScattermindRow.ranked[0].id, duplicateIds: duplicateIds.ranked.map(item => item.id), readiness: data.handoff.readiness.status, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
} finally { } finally {
server.kill('SIGTERM'); server.kill('SIGTERM');
} }
+44 -3
View File
@@ -825,18 +825,24 @@ function cleanContextText(value = '') {
} }
function meaningfulTokens(text = '') { 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']); 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']);
return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8); return [...new Set(String(text).toLowerCase().match(/[a-z0-9][a-z0-9-]{3,}/g) || [])].filter(token => !stop.has(token)).slice(0, 8);
} }
function isGuardrailAvoidanceMention(lowerText = '', token = '') {
if (!token) return false;
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\b(without|avoid(?:ing)?|no|not|skip|defer|wait(?:ing)?|hold(?:ing)?|before adding|before building)\\b[^.!?]{0,60}\\b${escaped}\\b`, 'i').test(lowerText);
}
function nonGoalConflicts(optionText, decisionContext = {}) { function nonGoalConflicts(optionText, decisionContext = {}) {
const lower = String(optionText || '').toLowerCase(); const lower = String(optionText || '').toLowerCase();
return (decisionContext.nonGoals || []).filter(nonGoal => { return (decisionContext.nonGoals || []).filter(nonGoal => {
const tokens = meaningfulTokens(nonGoal); const tokens = meaningfulTokens(nonGoal);
return tokens.length > 0 && tokens.some(token => { return tokens.length > 0 && tokens.some(token => {
if (lower.includes(token)) return true;
const singular = token.endsWith('ies') ? `${token.slice(0, -3)}y` : token.replace(/(?:es|s)$/, ''); const singular = token.endsWith('ies') ? `${token.slice(0, -3)}y` : token.replace(/(?:es|s)$/, '');
return singular.length >= 4 && lower.includes(singular); const forms = [token, singular].filter(form => form.length >= 4);
return forms.some(form => lower.includes(form) && !isGuardrailAvoidanceMention(lower, form));
}); });
}); });
} }
@@ -1015,6 +1021,35 @@ function optionsFromActionThreads(items = [], sourceSection = 'concept-map.threa
}).filter(item => item.action); }).filter(item => item.action);
} }
function questionActionTitle(text = '') {
const cleaned = cleanText(text.replace(/\?+$/g, ''), 180);
if (!cleaned) return '';
if (/^(can|could|will|would|should|does|do|is|are|what|where|when|who|how|why)\b/i.test(cleaned)) return cleanText(`Answer: ${cleaned}`, 140);
return cleanText(cleaned, 140);
}
function optionsFromQuestionsToSitWith(items = [], sourceSection = 'concept-map.questionsToSitWith', sourceTitle = 'Question to sit with') {
if (!Array.isArray(items)) return [];
return items.slice(0, 8).map((item, index) => {
const raw = typeof item === 'string' || typeof item === 'number' ? String(item) : '';
const objectItem = objectFrom(item);
const question = cleanMultiline(raw || objectItem.question || objectItem.text || objectItem.content || objectItem.title || '', 420);
return {
id: `question-${index + 1}`,
action: questionActionTitle(question),
why: 'Scattermind left this as an open question; Ranker treats it as evidence to collect, not a build feature.',
evidence: question,
validationSteps: cleanTextList(objectItem.validationSteps || objectItem.validation_steps || objectItem.steps || objectItem.proofSteps || objectItem.proof_steps, 4, 180),
suggestedLane: 'validate-next',
rankerHints: { value: 6, effort: 2, confidence: 5, urgency: 5, risk: 3 },
sourceSection,
sourceItemId: `${sourceSection}#${index + 1}`,
sourceTitle: cleanText(objectItem.sourceTitle || objectItem.source_title || sourceTitle, 140),
sourceExcerpt: question,
};
}).filter(item => item.action && item.evidence);
}
function optionsFromBody(body = {}) { function optionsFromBody(body = {}) {
const envelope = bridgeEnvelopeFrom(body); const envelope = bridgeEnvelopeFrom(body);
const featureSet = featureSetFrom(body); const featureSet = featureSetFrom(body);
@@ -1121,6 +1156,12 @@ function optionsFromBody(body = {}) {
'Thread to hold' 'Thread to hold'
); );
if (actionThreadOptions.length >= 2) return normalizeCandidateGroup([{ items: actionThreadOptions, sourceSection: 'concept-map.threadsToHold' }]); if (actionThreadOptions.length >= 2) return normalizeCandidateGroup([{ items: actionThreadOptions, sourceSection: 'concept-map.threadsToHold' }]);
const questionOptions = optionsFromQuestionsToSitWith(
body.questions_to_sit_with || body.questionsToSitWith || body.openQuestions || body.open_questions || conceptMap.questions_to_sit_with || conceptMap.questionsToSitWith || conceptMap.openQuestions || conceptMap.open_questions,
conceptMap.questions_to_sit_with || conceptMap.questionsToSitWith || conceptMap.openQuestions || conceptMap.open_questions ? 'concept-map.questionsToSitWith' : 'questionsToSitWith',
'Question to sit with'
);
if (questionOptions.length >= 2) return normalizeCandidateGroup([{ items: questionOptions, sourceSection: 'concept-map.questionsToSitWith', defaultLane: 'validate-next' }]);
if (Array.isArray(body.options)) { if (Array.isArray(body.options)) {
return normalizeOptionIds(body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title)); return normalizeOptionIds(body.options.slice(0, 24).map((item, index) => normalizeFeatureOption(item, index, 'option', 'options')).filter(item => item.title));
} }