diff --git a/README.md b/README.md index 72ca91c..c58ecf6 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Ranker's continuation job is narrow: `POST /api/rank-feedback` accepts a `prioritix-feature-set-v1`-style payload from Scattermind and returns ranked items plus `buildOrder.doFirst / validateNext / defer / park`. It accepts candidate arrays as `features`, `actions`, `nextMoves`, or `candidates` either at the top level or under `featureSet`, and it can consume a nested Concept Map directly, so Scattermind can hand off `conceptMap.nextActions / nextMoves` without renaming them into fake software features. Sectioned Concept Maps may also include `validateNext`, `deferred`, and `parkingLot`; Ranker combines those sections into one build-order pass while preserving `sourceSection` and treating deferred/parked sections as lane hints. Empty wrapper arrays are ignored rather than allowed to shadow a real nested Concept Map action set, and non-empty normalized wrappers are merged with Concept Map validation/deferred/parking sections rather than dropping that context. That keeps partially-normalized Scattermind exports rankable without losing the source lane contract. It also returns a `brief` with source, next-48-hour actions, carried-forward assumptions/constraints/non-goals, and `whatWouldChangeRanking` checks, plus a `handoff` object (`rank-feedback-result-v1`) with source provenance, item trace rows, and contract warnings for missing artifact IDs, source sections, original prompt provenance, or evidence on active items. Keep this contract action-first; do not use it as a reason to add generic dashboard, auth, billing, or workspace layers before the bridge has proof. -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 `conceptMap.context`, or as a structured top-level `context` object with `summary`, `targetAudience`, `constraints`, `nonGoals` / `avoid`, and `assumptions`; Ranker turns that structured context into readable scoring text instead of leaking `[object Object]`. 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. +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 `conceptMap.context`, or as a structured top-level `context` object with `summary`, `targetAudience`, `constraints`, `nonGoals` / `avoid`, and `assumptions`; Ranker turns that structured context into readable scoring text 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. 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. diff --git a/scripts/check-rank-feedback.mjs b/scripts/check-rank-feedback.mjs index 57d5531..0f1caf9 100644 --- a/scripts/check-rank-feedback.mjs +++ b/scripts/check-rank-feedback.mjs @@ -80,6 +80,10 @@ try { assert.equal(data.handoff.source.hasOriginalPrompt, true); assert.equal(data.handoff.itemTrace.length, data.ranked.length); assert.equal(data.handoff.itemTrace.find(item => item.id === 'bridge-contract').sourceSection, 'concept-map.nextMoves'); + assert.ok(data.input.decisionContext.constraints.includes('Solo builder')); + assert.ok(data.input.decisionContext.nonGoals.includes('Avoid accounts, workspaces, and team voting')); + assert.deepEqual(data.handoff.decisionContext.nonGoals, ['Avoid accounts, workspaces, and team voting']); + assert.ok(data.ranked.find(item => item.id === 'workspace').metrics.nonGoalConflicts.length >= 1, 'flat text avoid guardrails should protect against workspace candidates'); assert.deepEqual(data.handoff.warnings, []); assert.ok(data.brief.next48Hours.some(item => /Open the source artifact \(snapshot_123\)/i.test(item))); assert.ok(data.brief.next48Hours.some(item => /Evidence to collect/i.test(item))); diff --git a/server.js b/server.js index 8995db9..17f4a99 100644 --- a/server.js +++ b/server.js @@ -157,6 +157,36 @@ function cleanFlexibleTextList(value, maxItems = 8, maxText = 180) { .slice(0, maxItems); } +function uniqueList(items = [], maxItems = 8) { + const seen = new Set(); + return items.filter(item => { + const cleaned = cleanText(item, 180); + const key = cleaned.toLowerCase(); + if (!cleaned || seen.has(key)) return false; + seen.add(key); + return true; + }).slice(0, maxItems); +} + +function contextSentences(value = '') { + return cleanMultiline(value, 3000) + .split(/\n|;|\.|\|/) + .map(item => item.replace(/^\s*[-*•\d.)]+\s*/, '').trim()) + .filter(Boolean); +} + +function guardrailsFromContextText(value = '') { + const nonGoals = []; + const constraints = []; + for (const sentence of contextSentences(value)) { + const cleaned = cleanText(sentence, 180); + if (!cleaned) continue; + if (/^(avoid|no|do not|don't|dont|must not|never)\b/i.test(cleaned)) nonGoals.push(cleaned); + else if (/\b(avoid|no auth|no account|no billing|no workspace|not a dashboard|without accounts|before proof|manual proof|solo builder|constraint)\b/i.test(cleaned)) constraints.push(cleaned); + } + return { nonGoals: uniqueList(nonGoals), constraints: uniqueList(constraints) }; +} + function cleanMetricHints(item = {}) { const raw = { ...(item.factors && typeof item.factors === 'object' ? item.factors : {}), @@ -436,10 +466,17 @@ function cleanDecisionContext(input = {}) { const conceptMap = objectFrom(input.conceptMap || featureSet.conceptMap || artifact.conceptMap); const structuredContext = objectFrom(input.context); const sourceContext = firstObject(input.decisionContext, featureSet.decisionContext, artifact.decisionContext, conceptMap.decisionContext, structuredContext, conceptMap.context); + const textContextGuardrails = guardrailsFromContextText(typeof input.context === 'string' ? input.context : ''); return { targetAudience: cleanText(input.targetAudience || featureSet.targetAudience || sourceContext.targetAudience || conceptMap.targetAudience || '', 180), - constraints: cleanFlexibleTextList(input.constraints || featureSet.constraints || sourceContext.constraints || conceptMap.constraints, 8, 180), - nonGoals: cleanFlexibleTextList(input.nonGoals || input.avoid || featureSet.nonGoals || featureSet.avoid || sourceContext.nonGoals || sourceContext.avoid || conceptMap.nonGoals || conceptMap.avoid, 8, 180), + constraints: uniqueList([ + ...cleanFlexibleTextList(input.constraints || featureSet.constraints || sourceContext.constraints || conceptMap.constraints, 8, 180), + ...textContextGuardrails.constraints, + ], 8), + nonGoals: uniqueList([ + ...cleanFlexibleTextList(input.nonGoals || input.avoid || featureSet.nonGoals || featureSet.avoid || sourceContext.nonGoals || sourceContext.avoid || conceptMap.nonGoals || conceptMap.avoid, 8, 180), + ...textContextGuardrails.nonGoals, + ], 8), assumptions: cleanFlexibleTextList(input.assumptions || featureSet.assumptions || sourceContext.assumptions || conceptMap.assumptions, 6, 180), }; }