Add route guardrails to Ranker handoff

This commit is contained in:
OpenClaw Bot
2026-05-27 19:09:29 +02:00
parent 39287ea2e3
commit bff9b1e7c3
3 changed files with 77 additions and 2 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`, with either colon or reader-friendly dash separators (`Continue first: …`, `Continue first — …`, `Evidence next - …`). 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. Ranker also accepts softer continuation envelopes named `rankerBridge`, `continuation`, or `continuationPlan`, candidate arrays named `possibleNextMoves`, `suggestedNextMoves`, `recommendations`, or `opportunities`, laned `buildOrderPreview` / `build_order_preview` objects, and evidence-question fallback arrays (`evidenceQuestions` / `evidence_questions`, `decisionQuestions`, `questionsToAnswer`, `followupQuestions`) so Scattermind can pass a paid Concept Map preview without renaming it into software-feature language.
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; soft guardrail language such as “this is not a dashboard” or “keep auth/billing/workspaces out until proof” is promoted into non-goals, not merely background context. 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. Exact free Snapshot JSON (`working_name`, `restated_idea`, `lenses.shape`, `questions_to_sit_with`, `reference_code`) is rankable too: Ranker derives a manual proof active slice plus evidence questions, carrying the Snapshot reference code/title into provenance so a Snapshot-only handoff does not need a paid Concept Map before it can produce a useful build order. 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.
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; soft guardrail language such as “this is not a dashboard” or “keep auth/billing/workspaces out until proof” is promoted into non-goals, not merely background context. Ranker now also infers a light `ideaRoute` from the Scattermind source text and carries it in `input.decisionContext` / `handoff.decisionContext`; for game concepts it automatically adds anti-SaaS non-goals so account/dashboard/workspace/subscription candidates cannot win a playable-prototype build order just because they were phrased loudly. 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. Exact free Snapshot JSON (`working_name`, `restated_idea`, `lenses.shape`, `questions_to_sit_with`, `reference_code`) is rankable too: Ranker derives a manual proof active slice plus evidence questions, carrying the Snapshot reference code/title into provenance so a Snapshot-only handoff does not need a paid Concept Map before it can produce a useful build order. 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:
+35 -1
View File
@@ -1728,7 +1728,41 @@ try {
assert.equal(buildOrderPreview.handoff.readiness.status, 'ready');
assert.deepEqual(buildOrderPreview.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, questionsFallbackTop: questionsFallback.ranked[0].id, freeSnapshotTop: freeSnapshot.ranked[0].id, storedScattermindRowTop: storedScattermindRow.ranked[0].id, candidateActionsAliasTop: candidateActionsAlias.ranked[0].id, rankReadyActionsEnvelopeTop: rankReadyActionsEnvelope.ranked[0].id, continuationEnvelopeTop: continuationEnvelope.ranked[0].id, buildOrderPreviewTop: buildOrderPreview.ranked[0].id, duplicateIds: duplicateIds.ranked.map(item => item.id), readiness: data.handoff.readiness.status, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
const gameRouteGuardrailResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceName: 'Scattermind',
referenceCode: 'SM-GAME-ROUTE-1',
ideaText: 'A vehicle bullet-heaven survival game where the first proof is whether driving feel and enemy pressure are fun in a tiny arena.',
mode: 'mvp',
fullReadingJson: JSON.stringify({
working_name: 'Vehicle Hell Prototype',
opening_reflection: 'This is a game concept. The useful continuation is a playable prototype, not product-account machinery.',
lenses: {
shape: { title: 'Recommended Direction', content: 'Start with a five-minute playable arena: steering, enemy pressure, one weapon, one upgrade. The first proof is feel, not menus.' },
channel: { title: 'Build Order', content: 'Build first: Five-minute playable driving arena - one car, one enemy pattern, one weapon, and one win/loss loop. Test manually: Playtest the steering feel with 3 players. Defer: Progression upgrades after the core loop feels good. Build first: Player account dashboard with saved builds, collaboration, onboarding, and subscription tiers.' },
question: { title: 'Proof Steps', content: 'Ask players to play for five minutes and say when movement stops feeling fun.' },
},
threads_to_hold: ['Success signal: players replay once without being asked.', 'Failure signal: steering fights bullet-heaven readability.'],
questions_to_sit_with: ['Does the driving feel make dodging more fun or just harder?'],
closing_note: 'Ship the five-minute playable arena first.',
reference_code: 'SM-GAME-ROUTE-1',
}),
}),
});
assert.equal(gameRouteGuardrailResponse.status, 200);
const gameRouteGuardrail = await gameRouteGuardrailResponse.json();
assert.equal(gameRouteGuardrail.input.decisionContext.ideaRoute, 'game');
assert.ok(gameRouteGuardrail.input.decisionContext.nonGoals.some(item => /accounts, dashboards, workspaces/i.test(item)), 'game route should add anti-SaaS guardrails even if Scattermind did not spell them out as avoid text');
assert.equal(gameRouteGuardrail.ranked[0].id, 'build-order-1');
assert.equal(gameRouteGuardrail.ranked.find(item => /Player account dashboard/i.test(item.title)).lane.source, 'source-non-goal');
assert.equal(gameRouteGuardrail.handoff.decisionContext.ideaRoute, 'game');
assert.match(gameRouteGuardrail.handoff.copyableText, /Idea route: game/);
assert.equal(gameRouteGuardrail.handoff.readiness.status, 'ready');
assert.deepEqual(gameRouteGuardrail.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, questionsFallbackTop: questionsFallback.ranked[0].id, freeSnapshotTop: freeSnapshot.ranked[0].id, storedScattermindRowTop: storedScattermindRow.ranked[0].id, candidateActionsAliasTop: candidateActionsAlias.ranked[0].id, rankReadyActionsEnvelopeTop: rankReadyActionsEnvelope.ranked[0].id, continuationEnvelopeTop: continuationEnvelope.ranked[0].id, buildOrderPreviewTop: buildOrderPreview.ranked[0].id, gameRouteGuardrailTop: gameRouteGuardrail.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 {
server.kill('SIGTERM');
}
+41
View File
@@ -829,6 +829,27 @@ 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 inferIdeaRouteFromText(value = '') {
const text = String(value || '').toLowerCase();
if (/\b(game|games|gaming|player|players|playtest|prototype|steam|itch|console|pc|deck|roguelike|roguelite|rpg|shooter|bullet\s*hell|bullet-heaven|survival|enemy|enemies|weapon|weapons|level|arena|controller|joystick|driving|car|vehicle|combat|fps|platformer|metroidvania|puzzle game)\b/.test(text)) return 'game';
if (/\b(service|agency|consulting|freelance|offer|client|local|shop|restaurant|webshop|sales|appointment)\b/.test(text)) return 'service_business';
if (/\b(book|novel|story|comic|film|music|art|creative|writing|podcast|newsletter|course|zine)\b/.test(text)) return 'creative_project';
if (/\b(community|members|audience|creator|content|social|discord|forum|club|fans|followers)\b/.test(text)) return 'content_community';
if (/\b(physical|hardware|device|toy|wearable|material|manufactur|packaging|retail|kitchen|furniture|sensor)\b/.test(text)) return 'physical_product';
if (/\b(internal|ops|admin|backoffice|team tool|employee|staff|inventory|support queue|process)\b/.test(text)) return 'internal_tool';
if (/\b(saas|app|software|dashboard|workflow|api|plugin|extension|tool|users?|accounts?|subscription|crm|automation|webapp|mobile app)\b/.test(text)) return 'saas_app';
return '';
}
function routeGuardrailNonGoals(route = '') {
if (route === 'game') {
return [
'Avoid accounts, dashboards, workspaces, collaboration, CRM, admin panels, onboarding, saved boards, and subscription tiers unless the source explicitly asks for business software.',
];
}
return [];
}
function cleanDecisionContext(input = {}) {
const envelope = bridgeEnvelopeFrom(input);
const featureSet = featureSetFrom(input);
@@ -873,7 +894,24 @@ function cleanDecisionContext(input = {}) {
...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'));
const ideaRoute = cleanText(input.ideaType || input.idea_type || input.category || input.route || envelope.ideaType || envelope.idea_type || featureSet.ideaType || featureSet.idea_type || artifact.ideaType || artifact.idea_type || conceptMap.ideaType || conceptMap.idea_type || snapshot.ideaType || snapshot.idea_type || inferIdeaRouteFromText([
input.idea,
input.ideaText,
input.idea_text,
input.context,
input.opening_reflection,
input.restated_idea,
envelope.idea,
envelope.ideaText,
envelope.context,
conceptMap.opening_reflection,
conceptMap.restated_idea,
snapshot.restated_idea,
lensContent(conceptMapLenses.shape),
lensContent(conceptMapLenses.channel),
].filter(Boolean).join('\n')), 60);
return {
ideaRoute,
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),
constraints: uniqueList([
...cleanFlexibleTextList(input.constraints || envelope.constraints || featureSet.constraints || snapshot.constraints || conceptMap.constraints, 8, 180),
@@ -885,6 +923,7 @@ function cleanDecisionContext(input = {}) {
...cleanFlexibleTextList(input.nonGoals || input.non_goals || input.avoid || envelope.nonGoals || envelope.non_goals || envelope.avoid || featureSet.nonGoals || featureSet.non_goals || featureSet.avoid || snapshot.nonGoals || snapshot.non_goals || snapshot.avoid || conceptMap.nonGoals || conceptMap.non_goals || conceptMap.avoid, 8, 180),
...collectContextList(contextSources, ['nonGoals', 'non_goals', 'nonGoal', 'non_goal', 'avoid', 'notYet', 'not_yet', 'doNotBuild', 'do_not_build'], 8),
...textContextGuardrails.nonGoals,
...routeGuardrailNonGoals(ideaRoute),
], 8),
assumptions: uniqueList([
...cleanFlexibleTextList(input.assumptions || envelope.assumptions || featureSet.assumptions || snapshot.assumptions || conceptMap.assumptions, 6, 180),
@@ -1786,6 +1825,7 @@ function copyableHandoffText({ ranked = [], provenance = {}, decisionContext = {
? `Source summary: ${cleanText(provenance.sourceSummary, 240)}`
: '';
const contextLines = [
decisionContext?.ideaRoute ? `Idea route: ${decisionContext.ideaRoute}` : '',
decisionContext?.targetAudience ? `Audience: ${decisionContext.targetAudience}` : '',
...(decisionContext?.constraints || []).slice(0, 3).map(item => `Constraint: ${item}`),
...(decisionContext?.nonGoals || []).slice(0, 3).map(item => `Non-goal: ${item}`),
@@ -1877,6 +1917,7 @@ function createHandoffContract({ ranked, provenance, decisionContext }) {
requiresSourceTrace: expectsSourceTrace,
},
decisionContext: {
ideaRoute: decisionContext?.ideaRoute || '',
targetAudience: decisionContext?.targetAudience || '',
constraints: decisionContext?.constraints || [],
nonGoals: decisionContext?.nonGoals || [],