Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions published/current/fpf-index/snapshot.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
4 changes: 2 additions & 2 deletions published/current/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
33 changes: 31 additions & 2 deletions src/runtime/answer-projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getPartCDraftsByCluster,
selectBestAnchors,
} from './compiler.js';
import { hasProductRoleFeedbackIntent } from './route-intent-signals.js';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import the PRODUCT_ROLE_FEEDBACK_PACKET_IDS constant.

Suggested change
import { hasProductRoleFeedbackIntent } from './route-intent-signals.js';
import { hasProductRoleFeedbackIntent, PRODUCT_ROLE_FEEDBACK_PACKET_IDS } from './route-intent-signals.js';

import {
unique,
} from './text.js';
Expand Down Expand Up @@ -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]),
)
Comment on lines +100 to +102
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the PRODUCT_ROLE_FEEDBACK_PACKET_IDS constant instead of hardcoding the ID list.

Suggested change
? ['E.12', 'A.1.1', 'A.15', 'A.2.2', 'A.2.3'].filter(
(id) => Boolean(snapshot.compiledNodes[id]),
)
? PRODUCT_ROLE_FEEDBACK_PACKET_IDS.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,
Expand All @@ -115,20 +123,29 @@ 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(' -> ')}.`
: '',
route.optionalIds.length > 0
? `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)}.`,
Comment on lines +147 to +148
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid redundant intent detection (which involves string normalization and multiple substring checks), pass the already calculated productRoleFeedbackPacket boolean to the helper functions instead of the raw question string.

Suggested change
`Acceptance check: ${routeAcceptanceCheck(routeNodeId, route, question)}.`,
`Next move: ${routeNextMove(routeNodeId, route, question)}.`,
`Acceptance check: ${routeAcceptanceCheck(routeNodeId, route, productRoleFeedbackPacket)}.`,
`Next move: ${routeNextMove(routeNodeId, route, productRoleFeedbackPacket)}.`,

route.routeSurfaces.length > 0
? `Route-bearing surfaces: ${route.routeSurfaces.join(', ')}.`
: '',
Expand Down Expand Up @@ -179,7 +196,11 @@ export function buildRouteAnswer(
function routeAcceptanceCheck(
routeNodeId: string,
route: Snapshot['routeGraph']['nodes'][string],
question: string,
): string {
if (isProductRoleFeedbackPacket(routeNodeId, question)) {
Comment on lines 196 to +201
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the signature to accept the pre-calculated boolean to avoid redundant intent detection.

Suggested change
function routeAcceptanceCheck(
routeNodeId: string,
route: Snapshot['routeGraph']['nodes'][string],
question: string,
): string {
if (isProductRoleFeedbackPacket(routeNodeId, question)) {
function routeAcceptanceCheck(
routeNodeId: string,
route: Snapshot['routeGraph']['nodes'][string],
isProductRoleFeedback: boolean,
): string {
if (isProductRoleFeedback) {

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';
Expand All @@ -197,7 +218,11 @@ function routeAcceptanceCheck(
function routeNextMove(
routeNodeId: string,
route: Snapshot['routeGraph']['nodes'][string],
question: string,
): string {
if (isProductRoleFeedbackPacket(routeNodeId, question)) {
Comment on lines 218 to +223
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the signature to accept the pre-calculated boolean to avoid redundant intent detection.

Suggested change
function routeNextMove(
routeNodeId: string,
route: Snapshot['routeGraph']['nodes'][string],
question: string,
): string {
if (isProductRoleFeedbackPacket(routeNodeId, question)) {
function routeNextMove(
routeNodeId: string,
route: Snapshot['routeGraph']['nodes'][string],
isProductRoleFeedback: boolean,
): string {
if (isProductRoleFeedback) {

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';
Expand All @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/runtime/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment on lines +38 to 40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import the newly defined PRODUCT_ROLE_FEEDBACK_PACKET_IDS constant.

Suggested change
PRODUCT_ROLE_FEEDBACK_OUTPUT_SIGNALS,
PRODUCT_ROLE_FEEDBACK_ROLE_SIGNALS,
} from './route-intent-signals.js';
PRODUCT_ROLE_FEEDBACK_OUTPUT_SIGNALS,
PRODUCT_ROLE_FEEDBACK_PACKET_IDS,
PRODUCT_ROLE_FEEDBACK_ROLE_SIGNALS,
} from './route-intent-signals.js';

import { buildValidation } from './validation-runner.js';
import type {
Expand Down Expand Up @@ -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,
);
Comment on lines +255 to +257
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the PRODUCT_ROLE_FEEDBACK_PACKET_IDS constant instead of hardcoding the ID list.

Suggested change
const productRoleFeedbackNodeIds = ['E.12', 'A.1.1', 'A.15', 'A.2.2', 'A.2.3'].filter(
(id) => id in patternNodes || id in routeNodes,
);
const productRoleFeedbackNodeIds = PRODUCT_ROLE_FEEDBACK_PACKET_IDS.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]],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seed rule anyOf broader than projection intent check

Low Severity

The product-role-feedback heuristic seed rule's anyOf combines both PRODUCT_ROLE_FEEDBACK_OUTPUT_SIGNALS and AGENT_WORKFLOW_BOUNDED_RETRIEVAL_SIGNALS, but hasProductRoleFeedbackIntent only checks PRODUCT_ROLE_FEEDBACK_OUTPUT_SIGNALS. A query matching a role signal plus a bounded-retrieval signal (e.g. "work packet") but no output signal will trigger the seed (boosting route:project-alignment to 92), yet isProductRoleFeedbackPacket returns false, so the product-role feedback packet IDs, constraints, acceptance check, and next-move guidance are all silently omitted.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b0d1384. Configure here.

seedNodeIds: productRoleFeedbackNodeIds,
seedScore: 20,
seedOrigin: 'route_expansion',
initialNodeIds: [],
routeId: alignmentRoute.id,
routeScore: 92,
});
rules.push({
name: 'vocabulary-alignment',
allOf: [['vocabulary']],
Expand Down
34 changes: 34 additions & 0 deletions src/runtime/route-intent-signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Comment on lines +88 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The list of IDs for the product-role feedback packet is hardcoded in multiple locations. Consider defining it as a shared constant here to improve maintainability and ensure consistency across the runtime and compiler.

] as const;

export const PRODUCT_ROLE_FEEDBACK_PACKET_IDS = ['E.12', 'A.1.1', 'A.15', 'A.2.2', 'A.2.3'] as const;

export const WRITING_OR_REVIEWING_PATTERN_SIGNALS = [
'spec writer',
'spec writing',
Expand All @@ -79,3 +98,18 @@ 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),
);
Comment on lines +104 to +106
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize product-feedback intent checks like seed-rule matching

hasProductRoleFeedbackIntent uses raw substring checks, but route seeding for the same rule uses token-based matching in matchesSeedRule (src/runtime/candidate-seeder.ts), so equivalent phrasings can diverge. For example, a query containing hyphenated product-feedback (or non-adjacent role tokens) can still satisfy the seed rule and route to route:project-alignment, but this function returns false and skips the product-role packet projection/acceptance guidance. This reintroduces inconsistent behavior for prompts this change is meant to standardize.

Useful? React with 👍 / 👎.

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;
}
52 changes: 52 additions & 0 deletions tests/fpf-spec-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,58 @@ 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 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,
Expand Down
62 changes: 62 additions & 0 deletions tests/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,68 @@ 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,
);
}

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 () => {
Expand Down
Loading
Loading