From 470965b8b7b358e7ea54174840a2b6d3dc706fee Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Wed, 27 May 2026 15:23:34 +0200 Subject: [PATCH] Harden Scattermind summary handoff provenance --- README.md | 2 +- scripts/check-rank-feedback.mjs | 33 +++++++++++++++++++++++++++++++++ server.js | 28 +++++++++++++++++++--------- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fbb67bb..9949d39 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Ranker's continuation job is narrow: Candidate items may include optional 1–10 `rankerHints` (`value`, `effort`, `confidence`, `urgency`, `revenue`, `novelty`, `risk`). Ranker blends those explicit Scattermind hints with text heuristics; `effort` is inverted into feasibility. Hints improve the defended order, but `recommendedLane: "defer"` or `"park"` remains a safety rail so dashboard-swamp items do not get promoted by flashy wording. For clean bridge handoff, Scattermind should send `sourceSection` and `evidenceNeeded` on each active next move. Scattermind can also send `targetAudience`, `constraints`, `assumptions`, and `nonGoals` / `avoid` at the top level, in `featureSet`, inside `snapshot.context` or `conceptMap.context`, or as a structured top-level `context` object with `summary`, `targetAudience`, `constraints`, `nonGoals` / `avoid`, and `assumptions`; Ranker merges these sources rather than letting a shallow wrapper context shadow deeper artifact guardrails. Lens-only Concept Maps may additionally send audience / constraints / assumptions / risk lens content, and Ranker will split sentence-style lens notes into readable decision context instead of leaking `[object Object]`. If Scattermind only has a flat context string, Ranker now extracts simple guardrails such as `Solo builder`, `Manual proof`, `Avoid ...`, `No ...`, and `Do not ...` into `input.decisionContext` / `handoff.decisionContext` so early bridge exports still protect against dashboard/auth/billing drift. Ranker returns that decision context in `input.decisionContext` and `handoff.decisionContext`, and penalizes candidates that conflict with source non-goals (for example saved workspaces/auth/billing before the continuation proof). If Scattermind sends duplicate candidate IDs, Ranker keeps the first ID, suffixes later duplicates (`preview-2`), and reports the normalization in `handoff.warnings` / `handoff.itemTrace` so downstream build-order references remain addressable. Ranker also accepts Scattermind's paid Concept Map object directly when it arrives with top-level `reference_code`, `working_name`, `ideaText`, and string-valued `lenses.channel` / `lenses.risk` fields; the reference code becomes source provenance, the working name becomes the source title, and labelled Build Order text is turned into rank-ready candidates without requiring Scattermind to wrap it first. -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. +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` / `handoff.source.originalPromptExcerpt` or, when the original prompt is unavailable, `sourceSummaryExcerpt` carry the source context 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`. diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index 70eb54e..b1d97a8 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -649,6 +649,39 @@ try { assert.equal(softLabelLens.handoff.readiness.status, 'ready'); assert.deepEqual(softLabelLens.handoff.warnings, []); + const summaryOnlyConceptMapResponse = await fetch(`${base}/api/rank-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + reference_code: 'SM-SUMMARY-ONLY', + working_name: 'Tired maker continuation pass', + opening_reflection: 'A tired maker needs one defended first-week build order after Scattermind clarified the idea.', + context: 'Solo builder. Manual proof first. Avoid accounts and saved workspaces before the build order lands.', + mode: 'mvp', + lenses: { + risk: 'Avoid accounts and saved workspaces before the first copyable result works.', + channel: 'First week: Manual source-traced preview - turn the Concept Map into one defended next move. Evidence next: Copyable handoff comprehension check - ask three tired users what they would do next. Hold for later: Visual polish after comprehension proof. Set aside: Saved account workspace with dashboard and billing.', + }, + }), + }); + assert.equal(summaryOnlyConceptMapResponse.status, 200); + const summaryOnlyConceptMap = await summaryOnlyConceptMapResponse.json(); + assert.equal(summaryOnlyConceptMap.input.idea, 'A tired maker needs one defended first-week build order after Scattermind clarified the idea.'); + assert.equal(summaryOnlyConceptMap.input.provenance.artifactId, 'SM-SUMMARY-ONLY'); + assert.equal(summaryOnlyConceptMap.input.provenance.snapshotTitle, 'Tired maker continuation pass'); + assert.match(summaryOnlyConceptMap.input.provenance.sourceSummary, /first-week build order/); + assert.equal(summaryOnlyConceptMap.input.provenance.originalPrompt, ''); + assert.equal(summaryOnlyConceptMap.ranked[0].lane.id, 'do'); + assert.match(summaryOnlyConceptMap.ranked[0].provenance.sourceQuote, /First week: Manual source-traced preview/); + assert.equal(summaryOnlyConceptMap.ranked.find(item => item.id === 'build-order-2').lane.id, 'test'); + assert.equal(summaryOnlyConceptMap.handoff.source.hasOriginalPrompt, false); + assert.equal(summaryOnlyConceptMap.handoff.source.hasSourceSummary, true); + assert.equal(summaryOnlyConceptMap.handoff.readiness.status, 'ready'); + assert.equal(summaryOnlyConceptMap.handoff.readiness.sourceComplete, true); + assert.deepEqual(summaryOnlyConceptMap.handoff.warnings, []); + assert.match(summaryOnlyConceptMap.handoff.copyableText, /Source summary: A tired maker needs one defended first-week build order/); + assert.match(summaryOnlyConceptMap.brief.source.sourceSummaryExcerpt, /first-week build order/); + const softLabelObjectResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/server.js b/server.js index 8948a98..03ea2bd 100644 --- a/server.js +++ b/server.js @@ -594,6 +594,7 @@ function cleanProvenance(input = {}) { const conceptMap = objectFrom(input.conceptMap || input.concept_map || featureSet.conceptMap || featureSet.concept_map || artifact.conceptMap || artifact.concept_map); const snapshot = objectFrom(input.snapshot || featureSet.snapshot || artifact.snapshot || conceptMap.snapshot); const source = objectFrom(input.source || featureSet.source || artifact.source); + const sourceSummary = input.sourceSummary || input.source_summary || input.opening_reflection || input.restated_idea || artifact.sourceSummary || artifact.source_summary || artifact.opening_reflection || snapshot.sourceSummary || snapshot.source_summary || snapshot.restated_idea || conceptMap.sourceSummary || conceptMap.source_summary || conceptMap.opening_reflection || conceptMap.restated_idea || ''; return { schema: cleanText(input.schema || featureSet.schema || artifact.schema || input.type || 'prioritix-feature-set-v1', 80), source: cleanText(input.sourceName || input.source_name || featureSet.sourceName || featureSet.source_name || source.name || artifact.sourceName || artifact.source_name || 'Scattermind', 80), @@ -601,6 +602,7 @@ function cleanProvenance(input = {}) { snapshotTitle: cleanText(input.snapshotTitle || input.snapshot_title || input.working_name || input.workingName || artifact.snapshotTitle || artifact.snapshot_title || snapshot.title || snapshot.name || conceptMap.snapshotTitle || conceptMap.snapshot_title || conceptMap.working_name || conceptMap.workingName || input.ideaTitle || input.idea_title || '', 160), conceptMapId: cleanText(input.conceptMapId || input.concept_map_id || artifact.conceptMapId || artifact.concept_map_id || conceptMap.id || conceptMap.artifactId || conceptMap.artifact_id || input.referenceCode || input.reference_code || conceptMap.referenceCode || conceptMap.reference_code || '', 120), originalPrompt: cleanMultiline(input.originalPrompt || input.original_prompt || input.initialPrompt || input.initial_prompt || input.ideaText || input.idea_text || input.prompt || artifact.originalPrompt || artifact.original_prompt || source.originalPrompt || source.original_prompt || snapshot.originalPrompt || snapshot.original_prompt || snapshot.prompt || conceptMap.originalPrompt || conceptMap.original_prompt || conceptMap.ideaText || conceptMap.idea_text || input.idea || '', 1200), + sourceSummary: cleanMultiline(sourceSummary, 1200), }; } @@ -802,20 +804,20 @@ function normalizeCandidateGroup(group = []) { function sentenceFragments(text = '') { return cleanMultiline(text, 4000) - .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:') + .replace(/\s+(build first|start here|ship first|first week|week one|first-week build order|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|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 cleaned = cleanText(value.replace(/^(build first|start here|ship first|first week|week one|first-week build order|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|continue first|make tangible first|make tangible)\s*:/i.test(fragment)) return 'do-first'; + if (/^(build first|start here|ship first|first week|week one|first-week build order|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'; @@ -1152,6 +1154,7 @@ function createDecisionBrief({ idea, context, mode, ranked, provenance, decision snapshotTitle: provenance.snapshotTitle, conceptMapId: provenance.conceptMapId, originalPromptExcerpt: cleanText(provenance.originalPrompt, 260), + sourceSummaryExcerpt: cleanText(provenance.sourceSummary, 260), } : null, expertReflections: [ { @@ -1226,7 +1229,7 @@ function handoffReadinessFor({ ranked = [], provenance = {}, warnings = [], expe const uniqueWarnings = [...new Set(warnings)]; const activeItems = ranked.filter(item => ['do', 'test'].includes(item.lane?.id)); const guardrailWarnings = uniqueWarnings.filter(item => /^active item .* conflicts with source non-goals/i.test(item)); - const sourceWarnings = uniqueWarnings.filter(item => /^missing (source section|original prompt provenance)/i.test(item)); + const sourceWarnings = uniqueWarnings.filter(item => /^missing (source section|source context provenance)/i.test(item)); const evidenceWarnings = uniqueWarnings.filter(item => /^missing evidence needed for active item/i.test(item)); const duplicateWarnings = uniqueWarnings.filter(item => /^duplicate source id/i.test(item)); const missingArtifact = uniqueWarnings.includes('missing source artifact id'); @@ -1238,7 +1241,7 @@ function handoffReadinessFor({ ranked = [], provenance = {}, warnings = [], expe const nextChecks = []; if (guardrailWarnings.length) nextChecks.push('Resolve source non-goal conflicts before treating any active item as build-ready.'); - if (expectsSourceTrace && sourceWarnings.length) nextChecks.push('Attach source section / prompt provenance from the Scattermind artifact.'); + if (expectsSourceTrace && sourceWarnings.length) nextChecks.push('Attach source section / prompt or summary provenance from the Scattermind artifact.'); if (expectsSourceTrace && evidenceWarnings.length) nextChecks.push('Add evidenceNeeded to every Do first / Validate next candidate before handoff.'); if (missingArtifact) nextChecks.push(expectsSourceTrace ? 'Attach the Scattermind artifact id so the build order can be traced back.' : 'Attach a source artifact id if this came from Scattermind rather than a plain paste.'); if (duplicateWarnings.length) nextChecks.push('Review normalized duplicate IDs before downstream tools store references.'); @@ -1272,7 +1275,7 @@ function handoffReadinessFor({ ranked = [], provenance = {}, warnings = [], expe nextChecks: nextChecks.slice(0, 5), activeItemCount: activeItems.length, warningCount: uniqueWarnings.length, - sourceComplete: Boolean(!expectsSourceTrace || (provenance?.originalPrompt && activeItems.every(item => item.provenance?.sourceSection))), + sourceComplete: Boolean(!expectsSourceTrace || ((provenance?.originalPrompt || provenance?.sourceSummary) && activeItems.every(item => item.provenance?.sourceSection))), }; } @@ -1288,6 +1291,11 @@ function copyableHandoffText({ ranked = [], provenance = {}, decisionContext = { provenance?.snapshotTitle, provenance?.artifactId, ].filter(Boolean).join(' · '); + const sourceContextLine = provenance?.originalPrompt + ? `Original prompt: ${cleanText(provenance.originalPrompt, 240)}` + : provenance?.sourceSummary + ? `Source summary: ${cleanText(provenance.sourceSummary, 240)}` + : ''; const contextLines = [ decisionContext?.targetAudience ? `Audience: ${decisionContext.targetAudience}` : '', ...(decisionContext?.constraints || []).slice(0, 3).map(item => `Constraint: ${item}`), @@ -1316,7 +1324,7 @@ function copyableHandoffText({ ranked = [], provenance = {}, decisionContext = { return [ '# Ranker build-order handoff', sourceLine ? `Source: ${sourceLine}` : '', - provenance?.originalPrompt ? `Original prompt: ${cleanText(provenance.originalPrompt, 240)}` : '', + sourceContextLine, readiness?.status ? `Readiness: ${readiness.status} — ${readiness.summary || ''}` : '', contextLines.length ? ['## Carried context', ...contextLines.map(item => `- ${item}`)].join('\n') : '', ...itemLines, @@ -1328,7 +1336,7 @@ function createHandoffContract({ ranked, provenance, decisionContext }) { const warnings = []; const expectsSourceTrace = Boolean(provenance?.artifactId || provenance?.conceptMapId || provenance?.snapshotTitle); if (!provenance?.artifactId && provenance?.originalPrompt) warnings.push('missing source artifact id'); - if (expectsSourceTrace && !provenance?.originalPrompt) warnings.push('missing original prompt provenance'); + if (expectsSourceTrace && !provenance?.originalPrompt && !provenance?.sourceSummary) warnings.push('missing source context provenance'); const itemTrace = ranked.map(item => { if (expectsSourceTrace && !item.provenance?.sourceSection) warnings.push(`missing source section for ${item.id}`); @@ -1364,7 +1372,9 @@ function createHandoffContract({ ranked, provenance, decisionContext }) { snapshotTitle: provenance?.snapshotTitle || '', conceptMapId: provenance?.conceptMapId || '', originalPromptExcerpt: cleanText(provenance?.originalPrompt || '', 260), + sourceSummaryExcerpt: cleanText(provenance?.sourceSummary || '', 260), hasOriginalPrompt: Boolean(provenance?.originalPrompt), + hasSourceSummary: Boolean(provenance?.sourceSummary), requiresSourceTrace: expectsSourceTrace, }, decisionContext: { @@ -1382,7 +1392,7 @@ function createHandoffContract({ ranked, provenance, decisionContext }) { app.post('/api/rank-feedback', (req, res) => { const body = expandEmbeddedRankPayload(req.body || {}); - const idea = cleanMultiline(body?.idea || '', 3000); + const idea = cleanMultiline(body?.idea || body?.opening_reflection || body?.restated_idea || '', 3000); const context = cleanContextText(body?.context || ''); const modeId = cleanText(body?.mode || 'progress', 40); const mode = judgementModes[modeId] || judgementModes.progress;