From 8bea868fb26f8f4ebc679b75ffe465e51af2ecc7 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Wed, 27 May 2026 16:45:56 +0200 Subject: [PATCH] Harden Scattermind stored row paste handoff --- README.md | 2 +- public/app.js | 4 +++- scripts/check-rank-feedback.mjs | 35 +++++++++++++++++++++++++++++++++ server.js | 8 +++++++- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 90450cf..c301f75 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 or schema-light structured context summaries (`context.summary`, `snapshot.context.summary`, `conceptMap.context.summary`, etc.), Ranker now extracts simple guardrails such as `Solo builder`, `Manual proof`, `Avoid ...`, `No ...`, `Non-goal: ...`, `Not yet ...`, 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. Ranker also accepts the current Scattermind storage-row shape with `referenceCode`, `ideaText`, `context`, and string-valued `fullReadingJson` / `full_reading_json`; it expands the saved paid Concept Map before ranking so operators do not have to hand-copy lenses out of Appwrite 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. +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. Ranker also accepts the current Scattermind storage-row shape with `referenceCode`, `ideaText`, `context`, and string-valued `fullReadingJson` / `full_reading_json`; it expands the saved paid Concept Map before ranking so operators do not have to hand-copy lenses out of Appwrite rows. The public paste form mirrors this: a prose-wrapped/fenced Appwrite row paste stays intact for the API instead of unwrapping the stringified reading into nonsense fields. 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 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. diff --git a/public/app.js b/public/app.js index 23fcb58..10a2fd6 100644 --- a/public/app.js +++ b/public/app.js @@ -64,6 +64,7 @@ function parsePastedJsonPayload(value) { parsed.schema || parsed.featureSet || parsed.feature_set || parsed.candidateSet || parsed.candidate_set || parsed.candidateFeatureSet || parsed.candidate_feature_set || parsed.rankReadyFeatureSet || parsed.rank_ready_feature_set || parsed.snapshot || parsed.conceptMap || parsed.concept_map || parsed.buildOrder || parsed.build_order || parsed.lenses || parsed.payload || parsed.rankPayload || parsed.scattermindPayload || parsed.conceptMapJson || parsed.rankerInput || parsed.ranker_input || parsed.rankerHandoff || parsed.ranker_handoff || parsed.rankReady || parsed.rank_ready || parsed.bridge || parsed.bridgePayload || parsed.bridge_payload + || parsed.concept_map_json || parsed.fullReadingJson || parsed.full_reading_json || parsed.fullReading || parsed.full_reading || parsed.reference_code || parsed.referenceCode || parsed.artifactId || parsed.sourceArtifactId || parsed.source_artifact_id || parsed.ideaText || parsed.idea_text || parsed.originalPrompt || parsed.original_prompt || parsed.sourceSummary || parsed.source_summary || parsed.opening_reflection || parsed.restated_idea || Array.isArray(parsed.features) || Array.isArray(parsed.actions) || Array.isArray(parsed.candidates) @@ -117,7 +118,8 @@ function payloadFromForm(formPayload) { const optionsJson = parsePastedJsonPayload(formPayload.optionsText); const embedded = ideaJson || optionsJson; if (!embedded) return formPayload; - const unwrapped = embedded.payload || embedded.rankPayload || embedded.scattermindPayload || embedded.conceptMapJson || embedded; + const unwrapCandidate = embedded.payload || embedded.rankPayload || embedded.scattermindPayload || embedded; + const unwrapped = unwrapCandidate && typeof unwrapCandidate === 'object' && !Array.isArray(unwrapCandidate) ? unwrapCandidate : embedded; const merged = { ...unwrapped }; if (!merged.mode && formPayload.mode) merged.mode = formPayload.mode; if (String(formPayload.context || '').trim() && !merged.context) merged.context = formPayload.context; diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index 87c6a0f..c3b0865 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -255,6 +255,41 @@ try { assert.equal(nestedConcept.ranked.find(item => item.id === 'lesson-library').lane.id, 'park'); assert.deepEqual(nestedConcept.handoff.warnings, []); + const storedRowPayload = { + referenceCode: 'CM-STORED-77', + working_name: 'Saved paid Concept Map row', + ideaText: 'I sell practical maker workshops and need the first continuation move.', + context: 'Solo operator. Manual proof first. Avoid account dashboards before buyer evidence.', + fullReadingJson: JSON.stringify({ + opening_reflection: 'A maker wants the next build step, not another planning surface.', + lenses: { + channel: 'Continue first: Concierge build-order preview from one workshop idea. Evidence next: Ask 3 makers whether the preview changes what they would build this week. Hold for later: Polished saved workspace after proof. Set aside: Account dashboard and team voting before evidence.', + }, + threads_to_hold: ['Start by manually turning one workshop concept into a build order preview', 'Validate with three makers before adding persistence'], + }), + }; + const storedRowPasteResponse = await fetch(`${base}/api/rank-feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + idea: `Saved Scattermind row pasted from Appwrite:\n\n\`\`\`json\n${JSON.stringify(storedRowPayload)}\n\`\`\``, + mode: 'mvp', + }), + }); + assert.equal(storedRowPasteResponse.status, 200); + const storedRowPaste = await storedRowPasteResponse.json(); + assert.equal(storedRowPaste.input.embeddedPayloadSource, 'idea'); + assert.equal(storedRowPaste.input.provenance.artifactId, 'CM-STORED-77'); + assert.equal(storedRowPaste.input.provenance.snapshotTitle, 'Saved paid Concept Map row'); + assert.match(storedRowPaste.input.provenance.originalPrompt, /maker workshops/); + assert.equal(storedRowPaste.ranked[0].id, storedRowPaste.buildOrder.doFirst[0]); + assert.match(storedRowPaste.brief.quickGlance.topPick, /Concierge build-order preview/i); + assert.match(storedRowPaste.brief.quickGlance.sourceTrace.sourceSection, /concept-map\.lenses\.channel/); + assert.equal(storedRowPaste.handoff.source.hasOriginalPrompt, true); + assert.equal(storedRowPaste.handoff.readiness.status, 'ready'); + assert.match(storedRowPaste.handoff.copyableText, /CM-STORED-77/); + assert.equal(storedRowPaste.ranked.find(item => /Account dashboard/i.test(item.title)).lane.id, 'park'); + const snapshotOnlyResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/server.js b/server.js index 0b3b581..0037b1a 100644 --- a/server.js +++ b/server.js @@ -559,6 +559,12 @@ function looksLikeRankPayload(value = {}) { || value.original_prompt || value.sourceSummary || value.source_summary + || value.fullReadingJson + || value.full_reading_json + || value.fullReading + || value.full_reading + || value.conceptMapJson + || value.concept_map_json || value.opening_reflection || value.restated_idea || value.ideaText @@ -651,7 +657,7 @@ function parseEmbeddedRankPayload(value = '') { function expandEmbeddedRankPayload(body = {}) { const original = objectFrom(body); - for (const key of ['payload', 'rankPayload', 'scattermindPayload', 'conceptMapJson', 'idea', 'ideaText', 'optionsText']) { + for (const key of ['payload', 'rankPayload', 'scattermindPayload', 'conceptMapJson', 'concept_map_json', 'fullReadingJson', 'full_reading_json', 'fullReading', 'full_reading', 'idea', 'ideaText', 'optionsText']) { const embedded = typeof original[key] === 'string' ? parseEmbeddedRankPayload(original[key]) : looksLikeRankPayload(original[key])