From 9dbcd7770b465b4b0ca243e0d267d10398624bf6 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Wed, 27 May 2026 00:51:03 +0200 Subject: [PATCH] Add rank handoff readiness gate --- README.md | 2 +- scripts/check-rank-feedback.mjs | 14 +++++++- server.js | 58 ++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 261a9ea..d8de98a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Candidate items may include optional 1–10 `rankerHints` (`value`, `effort`, `c 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. 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. -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. 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. 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. Recommended payload shape: diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index ff4aea9..828b51d 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -99,6 +99,9 @@ try { assert.match(data.brief.summary, /nearest follow-up|strongest signal/i); assert.ok(data.brief.whatWouldChangeRanking.some(item => /evidence fails|re-run the order/i.test(item))); assert.ok(Array.isArray(data.brief.assumptions)); + assert.equal(data.handoff.readiness.status, 'ready'); + assert.equal(data.handoff.readiness.sourceComplete, true); + assert.ok(data.handoff.readiness.nextChecks.some(item => /Do first item/i.test(item))); const messyIdeaOnlyResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', @@ -119,6 +122,8 @@ try { assert.equal(messyIdeaOnly.handoff.source.requiresSourceTrace, false); assert.ok(!messyIdeaOnly.handoff.warnings.some(item => /missing source section|missing original prompt/.test(item))); assert.ok(messyIdeaOnly.handoff.warnings.includes('missing source artifact id')); + assert.equal(messyIdeaOnly.handoff.readiness.status, 'usable-with-warnings'); + assert.ok(messyIdeaOnly.handoff.readiness.nextChecks.some(item => /source artifact id if this came from Scattermind/i.test(item))); const hintedResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', @@ -398,6 +403,8 @@ try { assert.equal(duplicateIds.handoff.itemTrace.find(item => item.id === 'preview-2').idNormalized, true); assert.ok(duplicateIds.handoff.warnings.some(item => /duplicate source id preview normalized to preview-2/.test(item))); assert.ok(Object.values(duplicateIds.buildOrder).flat().includes('preview-2')); + assert.equal(duplicateIds.handoff.readiness.status, 'usable-with-warnings'); + assert.ok(duplicateIds.handoff.readiness.nextChecks.some(item => /duplicate IDs/i.test(item))); const structuredContextResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', @@ -593,6 +600,9 @@ try { assert.equal(objectBuildOrder.ranked.find(item => item.id === 'workspace-dashboard').lane.id, 'park'); assert.equal(objectBuildOrder.ranked.find(item => item.id === 'workspace-dashboard').lane.source, 'hint'); assert.ok(objectBuildOrder.handoff.warnings.includes('missing evidence needed for active item feature-1')); + assert.equal(objectBuildOrder.handoff.readiness.status, 'needs-source-context'); + assert.ok(objectBuildOrder.handoff.readiness.blockers.includes('missing evidence needed for active item feature-1')); + assert.ok(objectBuildOrder.handoff.readiness.nextChecks.some(item => /evidenceNeeded/i.test(item))); const embeddedJsonResponse = await fetch(`${base}/api/rank-feedback`, { method: 'POST', @@ -680,8 +690,10 @@ try { assert.equal(sourceExcerpt.handoff.itemTrace.find(item => item.id === 'copyable-brief').sourceTitle, 'Build Order'); assert.match(sourceExcerpt.handoff.itemTrace.find(item => item.id === 'saved-workspace').sourceQuote, /Probably noise/); assert.deepEqual(sourceExcerpt.handoff.warnings, []); + assert.equal(sourceExcerpt.handoff.readiness.status, 'ready'); + assert.equal(sourceExcerpt.handoff.readiness.activeItemCount, 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, sourceExcerptTop: sourceExcerpt.ranked[0].id, duplicateIds: duplicateIds.ranked.map(item => item.id), 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, sourceExcerptTop: sourceExcerpt.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'); } diff --git a/server.js b/server.js index 204951d..04b1dc3 100644 --- a/server.js +++ b/server.js @@ -1141,6 +1141,60 @@ function compactBuildItems(items = []) { })); } +function handoffReadinessFor({ ranked = [], provenance = {}, warnings = [], expectsSourceTrace = false }) { + 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 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'); + const blockers = [ + ...guardrailWarnings, + ...(expectsSourceTrace ? sourceWarnings : []), + ...(expectsSourceTrace ? evidenceWarnings : []), + ]; + 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 && 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.'); + if (!nextChecks.length && activeItems.length) nextChecks.push('Use the Do first item as the only active build slice, then rerank after evidence changes.'); + + const status = guardrailWarnings.length + ? 'blocked' + : blockers.length + ? 'needs-source-context' + : uniqueWarnings.length + ? 'usable-with-warnings' + : 'ready'; + const labels = { + ready: 'Ready for Ranker handoff', + 'usable-with-warnings': 'Usable, but provenance is thin', + 'needs-source-context': 'Needs source context before handoff', + blocked: 'Blocked by source guardrails', + }; + const summaries = { + ready: `Active order is traceable and evidence-shaped for ${activeItems.length} active item${activeItems.length === 1 ? '' : 's'}.`, + 'usable-with-warnings': 'Ranking can be read now, but downstream bridge consumers should review the warnings before storing it as a defended handoff.', + 'needs-source-context': 'A Scattermind-shaped artifact is present, but active items are missing trace or evidence fields needed to defend the order later.', + blocked: 'An active item conflicts with source non-goals; do not continue until the guardrail is resolved or the item moves out of the active lanes.', + }; + + return { + status, + label: labels[status], + summary: summaries[status], + blockers, + nextChecks: nextChecks.slice(0, 5), + activeItemCount: activeItems.length, + warningCount: uniqueWarnings.length, + sourceComplete: Boolean(!expectsSourceTrace || (provenance?.originalPrompt && activeItems.every(item => item.provenance?.sourceSection))), + }; +} + function createHandoffContract({ ranked, provenance, decisionContext }) { const warnings = []; const expectsSourceTrace = Boolean(provenance?.artifactId || provenance?.conceptMapId || provenance?.snapshotTitle); @@ -1169,6 +1223,7 @@ function createHandoffContract({ ranked, provenance, decisionContext }) { nonGoalConflicts: item.metrics?.nonGoalConflicts || [], }; }); + const uniqueWarnings = [...new Set(warnings)]; return { schema: 'rank-feedback-result-v1', @@ -1189,7 +1244,8 @@ function createHandoffContract({ ranked, provenance, decisionContext }) { assumptions: decisionContext?.assumptions || [], }, itemTrace, - warnings: [...new Set(warnings)], + warnings: uniqueWarnings, + readiness: handoffReadinessFor({ ranked, provenance, warnings: uniqueWarnings, expectsSourceTrace }), }; }