Accept free Scattermind snapshot handoffs

This commit is contained in:
OpenClaw Bot
2026-05-27 16:33:49 +02:00
parent a225586296
commit 6a34916697
3 changed files with 79 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`; 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. 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; 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 source-traced manual proof plus evidence-question candidates instead of rejecting the Snapshot for having fewer than two explicit next moves. 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
@@ -1285,6 +1285,37 @@ try {
assert.equal(questionsFallback.handoff.readiness.status, 'ready'); assert.equal(questionsFallback.handoff.readiness.status, 'ready');
assert.deepEqual(questionsFallback.handoff.warnings, []); assert.deepEqual(questionsFallback.handoff.warnings, []);
const freeSnapshotResponse = await fetch(`${base}/api/rank-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
working_name: 'Free Snapshot Bridge',
restated_idea: 'A free Scattermind Snapshot clarified a rough service idea, but it has not produced a paid Build Order yet.',
lenses: {
shape: {
title: 'Product Shape',
content: 'Start with a manual promise test for one service buyer. Do not build accounts, saved workspaces, or a dashboard before someone reacts to the offer.',
},
},
questions_to_sit_with: ['Will one target buyer ask for the manual promise after seeing the short explanation?'],
reference_code: 'SM-FREE-SNAPSHOT-1',
tags: ['manual proof', 'buyer signal'],
mode: 'mvp',
}),
});
assert.equal(freeSnapshotResponse.status, 200);
const freeSnapshot = await freeSnapshotResponse.json();
assert.equal(freeSnapshot.input.provenance.artifactId, 'SM-FREE-SNAPSHOT-1');
assert.equal(freeSnapshot.input.provenance.snapshotTitle, 'Free Snapshot Bridge');
assert.match(freeSnapshot.input.provenance.sourceSummary, /rough service idea/);
assert.equal(freeSnapshot.input.optionCount, 2);
assert.equal(freeSnapshot.ranked[0].id, 'snapshot-manual-proof');
assert.equal(freeSnapshot.ranked[0].provenance.sourceSection, 'snapshot.lenses.shape');
assert.match(freeSnapshot.ranked[0].factors.evidenceNeeded, /target buyer/);
assert.equal(freeSnapshot.ranked.find(item => item.id === 'question-1').lane.id, 'test');
assert.equal(freeSnapshot.handoff.readiness.status, 'ready');
assert.deepEqual(freeSnapshot.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' },
@@ -1321,7 +1352,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, 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)); 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, 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');
} }
+46
View File
@@ -1050,6 +1050,47 @@ function optionsFromQuestionsToSitWith(items = [], sourceSection = 'concept-map.
}).filter(item => item.action && item.evidence); }).filter(item => item.action && item.evidence);
} }
function optionsFromSnapshotReading(source = {}, sourceSection = 'snapshot') {
const reading = objectFrom(source);
const lenses = objectFrom(reading.lenses);
const rawShapeLens = lenses.shape || reading.lens || reading.shape;
const shapeLens = objectFrom(rawShapeLens);
const shapeText = cleanMultiline(lensContent(rawShapeLens), 700);
const workingName = cleanText(reading.working_name || reading.workingName || reading.snapshotTitle || reading.snapshot_title || reading.title || reading.name || '', 90);
const restatedIdea = cleanText(reading.restated_idea || reading.restatedIdea || reading.ideaText || reading.idea_text || reading.idea || '', 260);
const questions = cleanFlexibleTextList(reading.questions_to_sit_with || reading.questionsToSitWith || reading.openQuestions || reading.open_questions, 4, 240);
const hasSnapshotShape = Boolean(workingName || restatedIdea || shapeText || questions.length);
if (!hasSnapshotShape || (!shapeText && !restatedIdea && questions.length < 1)) return [];
const firstQuestion = questions[0] || 'What smallest manual proof would show this is worth building first?';
const proofTitle = workingName
? `Manual proof for ${workingName}`
: restatedIdea
? `Manual proof: ${restatedIdea}`
: questionActionTitle(firstQuestion);
const proofExcerpt = shapeText || restatedIdea || firstQuestion;
const proofOption = {
id: 'snapshot-manual-proof',
action: proofTitle,
why: restatedIdea || shapeText || 'The Snapshot clarified a possible direction, but it still needs a small real-world proof before product machinery.',
evidence: firstQuestion,
validationSteps: ['Run one manual version before building supporting UI', 'Ask 3 target users what they would do next'],
suggestedLane: 'do-first',
rankerHints: { value: 8, effort: 2, confidence: 6, urgency: 7, risk: 3 },
sourceSection: `${sourceSection}.lenses.shape`,
sourceItemId: `${sourceSection}.lenses.shape#1`,
sourceTitle: cleanText(shapeLens.title || 'Snapshot shape', 140),
sourceExcerpt: proofExcerpt,
};
const questionOptions = optionsFromQuestionsToSitWith(
questions.slice(0, 3),
`${sourceSection}.questionsToSitWith`,
'Snapshot decision question'
);
const options = [proofOption, ...questionOptions].slice(0, 4);
return options.length >= 2 ? options : [];
}
function optionsFromBody(body = {}) { function optionsFromBody(body = {}) {
const envelope = bridgeEnvelopeFrom(body); const envelope = bridgeEnvelopeFrom(body);
const featureSet = featureSetFrom(body); const featureSet = featureSetFrom(body);
@@ -1162,6 +1203,11 @@ function optionsFromBody(body = {}) {
'Question to sit with' 'Question to sit with'
); );
if (questionOptions.length >= 2) return normalizeCandidateGroup([{ items: questionOptions, sourceSection: 'concept-map.questionsToSitWith', defaultLane: 'validate-next' }]); if (questionOptions.length >= 2) return normalizeCandidateGroup([{ items: questionOptions, sourceSection: 'concept-map.questionsToSitWith', defaultLane: 'validate-next' }]);
const nestedSnapshotReadingOptions = optionsFromSnapshotReading(snapshot, 'snapshot');
const snapshotReadingOptions = nestedSnapshotReadingOptions.length
? nestedSnapshotReadingOptions
: optionsFromSnapshotReading(body, 'snapshot');
if (snapshotReadingOptions.length >= 2) return normalizeCandidateGroup([{ items: snapshotReadingOptions, sourceSection: 'snapshot', 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));
} }