From b0d13848007ff39a6e28b56f3d989cae2959f19e Mon Sep 17 00:00:00 2001 From: Stanislau Niadbailau Date: Thu, 14 May 2026 10:12:00 -0400 Subject: [PATCH 1/2] fix(mcp): route product feedback prompts to packet --- published/current/fpf-index/snapshot.json | 49 ++++++++++++++++++++++- published/current/manifest.json | 4 +- src/runtime/answer-projector.ts | 33 ++++++++++++++- src/runtime/compiler.ts | 16 ++++++++ src/runtime/route-intent-signals.ts | 30 ++++++++++++++ tests/fpf-spec-runtime.test.ts | 24 +++++++++++ tests/mcp-server.test.ts | 28 +++++++++++++ tests/query-contracts.test.ts | 39 ++++++++++++++++++ 8 files changed, 217 insertions(+), 6 deletions(-) diff --git a/published/current/fpf-index/snapshot.json b/published/current/fpf-index/snapshot.json index 5733d62..2be8627 100644 --- a/published/current/fpf-index/snapshot.json +++ b/published/current/fpf-index/snapshot.json @@ -1,8 +1,8 @@ { "sourceHash": "sha256:500fcc0babd0db1184a4cb5b4487188af4f54911ae3ec99943d5902bb6229a50", "sourcePath": "published/current/FPF-Spec.md", - "builtAt": "2026-05-13T08:00:45.022Z", - "compilerFingerprint": "sha256:64da4d22faf54820c33710eafe9cebac7012ecdda4ae9d2d99bb5ec4fd7f4de7", + "builtAt": "2026-05-14T14:09:51.168Z", + "compilerFingerprint": "sha256:9cd395da915eb3ce5276fa51b3189840434a9bb6294035d08fa38b9881e95fc5", "compilerMode": "local_vectorless", "indexRoots": [ "heading:first-principles-framework-fpf-core-conceptual-specification:1", @@ -1119120,6 +1119120,51 @@ "routeId": "route:project-alignment", "routeScore": 88 }, + { + "name": "product-role-feedback", + "allOf": [ + [ + "product maintainer", + "product feedback", + "product-role feedback", + "product role feedback", + "role feedback", + "role-feedback", + "dogfood" + ] + ], + "anyOf": [ + [ + "adoption improvement", + "live product smoke", + "discussion-ready", + "discussion ready", + "severity", + "validation path", + "work packet", + "bounded context", + "whole spec", + "whole fpf", + "full spec", + "full fpf", + "without pasting", + "do not paste", + "instead of pasting" + ] + ], + "seedNodeIds": [ + "E.12", + "A.1.1", + "A.15", + "A.2.2", + "A.2.3" + ], + "seedScore": 20, + "seedOrigin": "route_expansion", + "initialNodeIds": [], + "routeId": "route:project-alignment", + "routeScore": 92 + }, { "name": "vocabulary-alignment", "allOf": [ diff --git a/published/current/manifest.json b/published/current/manifest.json index 2b8bb8d..d501206 100644 --- a/published/current/manifest.json +++ b/published/current/manifest.json @@ -1,12 +1,12 @@ { "channel": "latest-published", "sourceHash": "sha256:500fcc0babd0db1184a4cb5b4487188af4f54911ae3ec99943d5902bb6229a50", - "compilerFingerprint": "sha256:64da4d22faf54820c33710eafe9cebac7012ecdda4ae9d2d99bb5ec4fd7f4de7", + "compilerFingerprint": "sha256:9cd395da915eb3ce5276fa51b3189840434a9bb6294035d08fa38b9881e95fc5", "upstreamRef": "ee40821c9c49c642dfedb18d6915388dbec4e7df", "upstreamRepoUrl": "https://github.com/ailev/FPF", "upstreamCommittedAt": "2026-05-12T16:57:41Z", "specPath": "published/current/FPF-Spec.md", "snapshotPath": "published/current/fpf-index/snapshot.json", "specBytes": 6566931, - "publishedAt": "2026-05-13T08:00:55.400Z" + "publishedAt": "2026-05-14T14:09:56.488Z" } diff --git a/src/runtime/answer-projector.ts b/src/runtime/answer-projector.ts index fc8cf36..f7e6189 100644 --- a/src/runtime/answer-projector.ts +++ b/src/runtime/answer-projector.ts @@ -3,6 +3,7 @@ import { getPartCDraftsByCluster, selectBestAnchors, } from './compiler.js'; +import { hasProductRoleFeedbackIntent } from './route-intent-signals.js'; import { unique, } from './text.js'; @@ -94,12 +95,19 @@ export function buildRouteAnswer( rebuilt: boolean, ): QueryResult { const route = snapshot.routeGraph.nodes[routeNodeId]!; + const productRoleFeedbackPacket = isProductRoleFeedbackPacket(routeNodeId, question); + const supplementalIds = productRoleFeedbackPacket + ? ['E.12', 'A.1.1', 'A.15', 'A.2.2', 'A.2.3'].filter( + (id) => Boolean(snapshot.compiledNodes[id]), + ) + : []; // Include routeSurfaces alongside the ordered/optional/landing lists. // Some routes (e.g. boundary-unpacking-claim-routing) only declare // surfaces; without this fallback the structured `ids` response would // be just the route ID, hiding the patterns the answer prose names. const ids = unique([ routeNodeId, + ...supplementalIds, ...route.orderedIds, ...route.optionalIds, ...route.landingIds, @@ -115,11 +123,20 @@ export function buildRouteAnswer( route.firstHonestBurden ? `First honest burden: ${route.firstHonestBurden}.` : 'Choose this route only when the stated burden matches the present problem.', + ...(productRoleFeedbackPacket + ? [ + 'Use the product-role feedback packet: E.12 plus A.1.1, A.15, A.2.2, and A.2.3 before escalating to broader spec-writing guidance.', + 'Do not paste the whole FPF, and do not load E.8/E.19 unless the feedback is actually about writing or revising FPF pattern text.', + ] + : []), ...(route.constraints ?? []), ]; const answer = [ `${route.id} (${route.name}) is the matched first-practical route.`, route.firstHonestBurden ? `Burden: ${route.firstHonestBurden}.` : '', + productRoleFeedbackPacket + ? `Product-role feedback packet IDs: ${supplementalIds.join(', ')}.` + : '', route.orderedIds.length > 0 ? `Ordered entry IDs: ${route.orderedIds.join(' -> ')}.` : '', @@ -127,8 +144,8 @@ export function buildRouteAnswer( ? `Conditional additions: ${route.optionalIds.join(', ')}.` : '', route.landingIds.length > 0 ? `Landing surface: ${route.landingIds.join(', ')}.` : '', - `Acceptance check: ${routeAcceptanceCheck(routeNodeId, route)}.`, - `Next move: ${routeNextMove(routeNodeId, route)}.`, + `Acceptance check: ${routeAcceptanceCheck(routeNodeId, route, question)}.`, + `Next move: ${routeNextMove(routeNodeId, route, question)}.`, route.routeSurfaces.length > 0 ? `Route-bearing surfaces: ${route.routeSurfaces.join(', ')}.` : '', @@ -179,7 +196,11 @@ export function buildRouteAnswer( function routeAcceptanceCheck( routeNodeId: string, route: Snapshot['routeGraph']['nodes'][string], + question: string, ): string { + if (isProductRoleFeedbackPacket(routeNodeId, question)) { + return 'the role/job can be replayed by another person, the feedback points at exact evidence, and the output lands as one focused PR, issue, discussion, or no-new-feedback checkpoint'; + } switch (routeNodeId) { case 'route:project-alignment': return 'a shared kickoff packet names the bounded context, actor roles, role/method/work split, first work-plan item, evidence to collect, and UTS-ready terms'; @@ -197,7 +218,11 @@ function routeAcceptanceCheck( function routeNextMove( routeNodeId: string, route: Snapshot['routeGraph']['nodes'][string], + question: string, ): string { + if (isProductRoleFeedbackPacket(routeNodeId, question)) { + return 'name the persona/job and surface first, use E.12 with A.1.1, A.15, A.2.2, and A.2.3 to capture one adoption friction, then stop at one proposed improvement with severity and validation path'; + } switch (routeNodeId) { case 'route:project-alignment': return 'read A.1.1 and A.15 first, draft the kickoff worksheet, then add A.15.2/A.15.3 only when the plan/run split must be made explicit'; @@ -212,6 +237,10 @@ function routeNextMove( } } +function isProductRoleFeedbackPacket(routeNodeId: string, question: string): boolean { + return routeNodeId === 'route:project-alignment' && hasProductRoleFeedbackIntent(question); +} + export function buildPatternAnswer( question: string, mode: AnswerMode, diff --git a/src/runtime/compiler.ts b/src/runtime/compiler.ts index 3ddaf8a..3623bf8 100644 --- a/src/runtime/compiler.ts +++ b/src/runtime/compiler.ts @@ -35,6 +35,8 @@ import { AGENT_WORKFLOW_JOB_SIGNALS, BOUNDARY_BURDEN_SIGNALS, BOUNDARY_REVIEW_RULE_JOB_SIGNALS, + PRODUCT_ROLE_FEEDBACK_OUTPUT_SIGNALS, + PRODUCT_ROLE_FEEDBACK_ROLE_SIGNALS, } from './route-intent-signals.js'; import { buildValidation } from './validation-runner.js'; import type { @@ -250,6 +252,20 @@ function buildHeuristicSeedRules( routeId: alignmentRoute.id, routeScore: 88, }); + const productRoleFeedbackNodeIds = ['E.12', 'A.1.1', 'A.15', 'A.2.2', 'A.2.3'].filter( + (id) => id in patternNodes || id in routeNodes, + ); + rules.push({ + name: 'product-role-feedback', + allOf: [[...PRODUCT_ROLE_FEEDBACK_ROLE_SIGNALS]], + anyOf: [[...PRODUCT_ROLE_FEEDBACK_OUTPUT_SIGNALS, ...AGENT_WORKFLOW_BOUNDED_RETRIEVAL_SIGNALS]], + seedNodeIds: productRoleFeedbackNodeIds, + seedScore: 20, + seedOrigin: 'route_expansion', + initialNodeIds: [], + routeId: alignmentRoute.id, + routeScore: 92, + }); rules.push({ name: 'vocabulary-alignment', allOf: [['vocabulary']], diff --git a/src/runtime/route-intent-signals.ts b/src/runtime/route-intent-signals.ts index 14c4529..2e6426b 100644 --- a/src/runtime/route-intent-signals.ts +++ b/src/runtime/route-intent-signals.ts @@ -68,6 +68,25 @@ export const AGENT_WORKFLOW_BOUNDED_RETRIEVAL_SIGNALS = [ 'instead of pasting', ] as const; +export const PRODUCT_ROLE_FEEDBACK_ROLE_SIGNALS = [ + 'product maintainer', + 'product feedback', + 'product-role feedback', + 'product role feedback', + 'role feedback', + 'role-feedback', + 'dogfood', +] as const; + +export const PRODUCT_ROLE_FEEDBACK_OUTPUT_SIGNALS = [ + 'adoption improvement', + 'live product smoke', + 'discussion-ready', + 'discussion ready', + 'severity', + 'validation path', +] as const; + export const WRITING_OR_REVIEWING_PATTERN_SIGNALS = [ 'spec writer', 'spec writing', @@ -79,3 +98,14 @@ export const WRITING_OR_REVIEWING_PATTERN_SIGNALS = [ export function hasBoundaryReviewNegation(normalizedQuestion: string): boolean { return BOUNDARY_REVIEW_NEGATIONS.some((phrase) => normalizedQuestion.includes(phrase)); } + +export function hasProductRoleFeedbackIntent(question: string): boolean { + const normalizedQuestion = question.toLowerCase(); + const hasRoleSignal = PRODUCT_ROLE_FEEDBACK_ROLE_SIGNALS.some((phrase) => + normalizedQuestion.includes(phrase), + ); + const hasOutputSignal = PRODUCT_ROLE_FEEDBACK_OUTPUT_SIGNALS.some((phrase) => + normalizedQuestion.includes(phrase), + ); + return hasRoleSignal && hasOutputSignal; +} diff --git a/tests/fpf-spec-runtime.test.ts b/tests/fpf-spec-runtime.test.ts index ba39419..ff88570 100644 --- a/tests/fpf-spec-runtime.test.ts +++ b/tests/fpf-spec-runtime.test.ts @@ -210,6 +210,30 @@ describe('FpfRuntime', () => { expect(agentWorkflow.answer.length).toBeGreaterThan(0); expect(agentWorkflow.citations.length).toBeGreaterThan(0); + const productMaintainer = await runtime.query( + 'As a product maintainer, turn live product smoke evidence plus recurring role-feedback discussions into one adoption improvement, severity, and validation path without reading or pasting the full FPF.', + 'compact', + ); + if (routeIds.has('route:project-alignment')) { + expect(productMaintainer.status).toBe('ok'); + expect(productMaintainer.ids).toEqual( + expect.arrayContaining([ + 'route:project-alignment', + 'E.12', + 'A.1.1', + 'A.15', + 'A.2.2', + 'A.2.3', + ]), + ); + expect(productMaintainer.answer).toContain('Product-role feedback packet IDs'); + } else { + expect(['ok', 'ambiguous']).toContain(productMaintainer.status); + expect(productMaintainer.ids.every((id) => !id.startsWith('route:'))).toBe(true); + } + expect(productMaintainer.answer.length).toBeGreaterThan(0); + expect(productMaintainer.citations.length).toBeGreaterThan(0); + const unavailableSynthesizer = new FpfRuntime({ artifactDir: resolve(tempRoot, 'deterministic-artifacts'), sourcePath, diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index cd4be8d..6e626bc 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -467,6 +467,34 @@ describe('direct MCP server', () => { true, ); } + + const productMaintainerQuery = await harness.request('tools/call', { + name: 'query_fpf_spec', + arguments: { + mode: 'compact', + question: + 'As a product maintainer, turn live product smoke evidence plus recurring role-feedback discussions into one adoption improvement, severity, and validation path without reading or pasting the full FPF.', + }, + }); + const productMaintainerPayload = asToolPayload(productMaintainerQuery); + expect(productMaintainerPayload.status).toBe('ok'); + if (routeIds.has('route:project-alignment')) { + expect(productMaintainerPayload.ids).toEqual( + expect.arrayContaining([ + 'route:project-alignment', + 'E.12', + 'A.1.1', + 'A.15', + 'A.2.2', + 'A.2.3', + ]), + ); + expect(productMaintainerPayload.answer).toContain('Product-role feedback packet IDs'); + } else { + expect((productMaintainerPayload.ids as string[]).every((id) => !id.startsWith('route:'))).toBe( + true, + ); + } }); it('defaults to public tools when FPF_MCP_SURFACE is unset', async () => { diff --git a/tests/query-contracts.test.ts b/tests/query-contracts.test.ts index 57e5c67..b8c544a 100644 --- a/tests/query-contracts.test.ts +++ b/tests/query-contracts.test.ts @@ -487,6 +487,18 @@ describe('Query / Ranker stage', () => { expect(ranking.initialNodeIds).toEqual(['route:project-alignment']); }); + it('selects project alignment for product-role feedback maintainer prompts', async () => { + const snapshot = await getSnapshotWithRouteFixtures(); + const question = + 'As a product maintainer, turn live product smoke evidence plus recurring role-feedback discussions into one adoption improvement, severity, and validation path without reading or pasting the full FPF.'; + const normalized = normalizeQuery(question, snapshot); + const seeding = seedCandidates(normalized, snapshot); + const ranking = rankCandidates(question, seeding.candidateMap, snapshot); + + expect(ranking.routeWins).toBe(true); + expect(ranking.initialNodeIds).toEqual(['route:project-alignment']); + }); + it('honors negative API-contract disambiguation for project review prompts', async () => { const snapshot = await getSnapshotWithRouteFixtures(); const question = @@ -705,6 +717,33 @@ describe('Query / Projection stability stage', () => { expect(result.answer).toContain('Next move:'); }); + it('projects the product-role feedback packet on maintainer prompts', async () => { + const snapshot = await getSnapshotWithRouteFixtures(); + const question = + 'As a product maintainer, turn live product smoke evidence plus recurring role-feedback discussions into one adoption improvement, severity, and validation path without reading or pasting the full FPF.'; + const trace = assembleTrace(question, 'compact', snapshot); + + expect(trace.routeWins).toBe(true); + expect(trace.selectedNodeIds[0]).toBe('route:project-alignment'); + + const result = buildRouteAnswer( + question, + 'compact', + 'route:project-alignment', + trace, + snapshot, + false, + ); + + expect(result.ids).toEqual( + expect.arrayContaining(['route:project-alignment', 'E.12', 'A.1.1', 'A.15', 'A.2.2', 'A.2.3']), + ); + expect(result.answer).toContain('Product-role feedback packet IDs'); + expect(result.constraints).toContain( + 'Do not paste the whole FPF, and do not load E.8/E.19 unless the feedback is actually about writing or revising FPF pattern text.', + ); + }); + it('uses a compact route trace shortcut for adoption kickoff queries', async () => { const snapshot = await getSnapshotWithRouteFixtures(); const engine = new QueryEngine(snapshot, false); From 9a2fe17ee811bfc2530d347c9b188acec91f3729 Mon Sep 17 00:00:00 2001 From: Stanislau Niadbailau Date: Thu, 21 May 2026 10:08:14 -0400 Subject: [PATCH 2/2] fix(mcp): align product feedback packet intent gate --- src/runtime/route-intent-signals.ts | 12 ++++++---- tests/fpf-spec-runtime.test.ts | 28 ++++++++++++++++++++++++ tests/mcp-server.test.ts | 34 +++++++++++++++++++++++++++++ tests/query-contracts.test.ts | 33 ++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/runtime/route-intent-signals.ts b/src/runtime/route-intent-signals.ts index 2e6426b..8752fbb 100644 --- a/src/runtime/route-intent-signals.ts +++ b/src/runtime/route-intent-signals.ts @@ -104,8 +104,12 @@ export function hasProductRoleFeedbackIntent(question: string): boolean { const hasRoleSignal = PRODUCT_ROLE_FEEDBACK_ROLE_SIGNALS.some((phrase) => normalizedQuestion.includes(phrase), ); - const hasOutputSignal = PRODUCT_ROLE_FEEDBACK_OUTPUT_SIGNALS.some((phrase) => - normalizedQuestion.includes(phrase), - ); - return hasRoleSignal && hasOutputSignal; + const hasQualifyingSignal = + PRODUCT_ROLE_FEEDBACK_OUTPUT_SIGNALS.some((phrase) => + normalizedQuestion.includes(phrase), + ) || + AGENT_WORKFLOW_BOUNDED_RETRIEVAL_SIGNALS.some((phrase) => + normalizedQuestion.includes(phrase), + ); + return hasRoleSignal && hasQualifyingSignal; } diff --git a/tests/fpf-spec-runtime.test.ts b/tests/fpf-spec-runtime.test.ts index ff88570..b8dbf9b 100644 --- a/tests/fpf-spec-runtime.test.ts +++ b/tests/fpf-spec-runtime.test.ts @@ -234,6 +234,34 @@ describe('FpfRuntime', () => { expect(productMaintainer.answer.length).toBeGreaterThan(0); expect(productMaintainer.citations.length).toBeGreaterThan(0); + const boundedProductMaintainer = await runtime.query( + 'As a product maintainer, build a work packet without pasting the whole FPF.', + 'compact', + ); + if (routeIds.has('route:project-alignment')) { + expect(boundedProductMaintainer.status).toBe('ok'); + expect(boundedProductMaintainer.ids).toEqual( + expect.arrayContaining([ + 'route:project-alignment', + 'E.12', + 'A.1.1', + 'A.15', + 'A.2.2', + 'A.2.3', + ]), + ); + expect(boundedProductMaintainer.answer).toContain( + 'Product-role feedback packet IDs', + ); + } else { + expect(['ok', 'ambiguous']).toContain(boundedProductMaintainer.status); + expect( + boundedProductMaintainer.ids.every((id) => !id.startsWith('route:')), + ).toBe(true); + } + expect(boundedProductMaintainer.answer.length).toBeGreaterThan(0); + expect(boundedProductMaintainer.citations.length).toBeGreaterThan(0); + const unavailableSynthesizer = new FpfRuntime({ artifactDir: resolve(tempRoot, 'deterministic-artifacts'), sourcePath, diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 6e626bc..9203d02 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -495,6 +495,40 @@ describe('direct MCP server', () => { true, ); } + + const boundedProductMaintainerQuery = await harness.request('tools/call', { + name: 'query_fpf_spec', + arguments: { + mode: 'compact', + question: + 'As a product maintainer, build a work packet without pasting the whole FPF.', + }, + }); + const boundedProductMaintainerPayload = asToolPayload( + boundedProductMaintainerQuery, + ); + expect(boundedProductMaintainerPayload.status).toBe('ok'); + if (routeIds.has('route:project-alignment')) { + expect(boundedProductMaintainerPayload.ids).toEqual( + expect.arrayContaining([ + 'route:project-alignment', + 'E.12', + 'A.1.1', + 'A.15', + 'A.2.2', + 'A.2.3', + ]), + ); + expect(boundedProductMaintainerPayload.answer).toContain( + 'Product-role feedback packet IDs', + ); + } else { + expect( + (boundedProductMaintainerPayload.ids as string[]).every( + (id) => !id.startsWith('route:'), + ), + ).toBe(true); + } }); it('defaults to public tools when FPF_MCP_SURFACE is unset', async () => { diff --git a/tests/query-contracts.test.ts b/tests/query-contracts.test.ts index b8c544a..7258195 100644 --- a/tests/query-contracts.test.ts +++ b/tests/query-contracts.test.ts @@ -744,6 +744,39 @@ describe('Query / Projection stability stage', () => { ); }); + it('projects the product-role feedback packet on bounded-retrieval maintainer prompts', async () => { + const snapshot = await getSnapshotWithRouteFixtures(); + const question = + 'As a product maintainer, build a work packet without pasting the whole FPF.'; + const trace = assembleTrace(question, 'compact', snapshot); + + expect(trace.routeWins).toBe(true); + expect(trace.selectedNodeIds[0]).toBe('route:project-alignment'); + + const result = buildRouteAnswer( + question, + 'compact', + 'route:project-alignment', + trace, + snapshot, + false, + ); + + expect(result.ids).toEqual( + expect.arrayContaining([ + 'route:project-alignment', + 'E.12', + 'A.1.1', + 'A.15', + 'A.2.2', + 'A.2.3', + ]), + ); + expect(result.answer).toContain('Product-role feedback packet IDs'); + expect(result.answer).toContain('Acceptance check:'); + expect(result.answer).toContain('Next move:'); + }); + it('uses a compact route trace shortcut for adoption kickoff queries', async () => { const snapshot = await getSnapshotWithRouteFixtures(); const engine = new QueryEngine(snapshot, false);