diff --git a/apps/web/components/issues/issue-row.tsx b/apps/web/components/issues/issue-row.tsx index a585cb11..2cd18bcf 100644 --- a/apps/web/components/issues/issue-row.tsx +++ b/apps/web/components/issues/issue-row.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { CiStatusLink } from "@/components/ci-status"; +import { WsjfChip } from "@/components/issues/wsjf-chip"; import type { PipelineStory } from "@/lib/pipeline"; import { storyHref } from "@/lib/pipeline"; @@ -210,6 +211,7 @@ export function IssueRow({ {label} ))} + {story.wsjf ? : null} {fmtAgo(story.ghUpdatedAt)} {action()} diff --git a/apps/web/components/issues/wsjf-chip.tsx b/apps/web/components/issues/wsjf-chip.tsx new file mode 100644 index 00000000..772c14ec --- /dev/null +++ b/apps/web/components/issues/wsjf-chip.tsx @@ -0,0 +1,24 @@ +import type { StoryWsjf } from "@/lib/pipeline"; +import { wsjfBreakdown } from "@/lib/pipeline"; + +/** + * The score chip discloses the components behind a story's rank. A native + * details/summary keeps that provenance reachable by keyboard (focus, then + * Enter or Space), touch (tap), and assistive tech (the summary reports its + * expanded state) — a title tooltip alone serves only mouse hover. + */ +export function WsjfChip({ wsjf }: { wsjf: StoryWsjf }) { + return ( +
+ + wsjf {wsjf.score} + +

+ {wsjfBreakdown(wsjf)} +

+
+ ); +} diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index 679199c5..12e37e95 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -92,6 +92,19 @@ export function pipelineStageSummaries(stage: PipelineStage) { .filter((summary) => summary.count > 0); } +export type StoryWsjf = { + value: number; + time: number; + risk: number; + effort: number; + score: number; +}; + +/** The provenance behind a story's position: the components that made the score. */ +export function wsjfBreakdown(wsjf: StoryWsjf) { + return `value ${wsjf.value} · time ${wsjf.time} · risk ${wsjf.risk} · effort ${wsjf.effort}`; +} + /** Stable story identity for projects that mirror more than one repository. */ function storyQuery(story: Pick) { return new URLSearchParams({ repoId: story.repoId, storyType: story.storyType }).toString(); diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index 836e08c5..73936b95 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -331,6 +331,7 @@ function storyDetail(): StoryDetail { ghCreatedAt: "2026-08-01T00:00:00Z", ghUpdatedAt: "2026-08-01T00:00:00Z", closedAt: null, + wsjf: null, prs: [], ciState: null, ciUrl: null, diff --git a/apps/web/test/wsjf-chip.test.ts b/apps/web/test/wsjf-chip.test.ts new file mode 100644 index 00000000..2696d56e --- /dev/null +++ b/apps/web/test/wsjf-chip.test.ts @@ -0,0 +1,23 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { WsjfChip } from "@/components/issues/wsjf-chip"; + +const wsjf = { value: 8, time: 5, risk: 3, effort: 2, score: 8 }; + +describe("WSJF chip", () => { + it("exposes the breakdown through a native disclosure, not a hover-only title", () => { + const html = renderToStaticMarkup(createElement(WsjfChip, { wsjf })); + // A details/summary pair is the interaction: the summary takes keyboard + // focus and toggles on Enter, Space, or tap — no mouse hover required. + expect(html).toMatch(/]*> { + const html = renderToStaticMarkup(createElement(WsjfChip, { wsjf })); + expect(html).toContain('aria-label="WSJF score 8 — show breakdown"'); + }); +}); diff --git a/packages/harness/src/chain.ts b/packages/harness/src/chain.ts index 7c927138..bf7eedf4 100644 --- a/packages/harness/src/chain.ts +++ b/packages/harness/src/chain.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { WsjfSchema } from "./wsjf.js"; export type ChainTypeConfig = { prefix: string; @@ -25,13 +26,6 @@ const SharedFrontmatter = z }) .passthrough(); -const WsjfSchema = z.object({ - value: z.number().int().min(0), - time: z.number().int().min(0), - risk: z.number().int().min(0), - effort: z.number().positive(), -}); - export const productChain: ArtifactChainConfig = { id: "product", name: "Product Owner", diff --git a/packages/harness/src/wsjf.ts b/packages/harness/src/wsjf.ts index 45c3123d..85730d55 100644 --- a/packages/harness/src/wsjf.ts +++ b/packages/harness/src/wsjf.ts @@ -1,9 +1,19 @@ -export type WsjfInput = { - value: number; - time: number; - risk: number; - effort: number; -}; +import { z } from "zod"; + +/** + * The canonical WSJF judgement — the only shape allowed to carry a score. + * Components are non-negative integers and effort is strictly positive; the + * same schema validates PO task frontmatter in chain.ts, so a judgement is + * either canonical everywhere or scored nowhere. + */ +export const WsjfSchema = z.object({ + value: z.number().int().min(0), + time: z.number().int().min(0), + risk: z.number().int().min(0), + effort: z.number().positive(), +}); + +export type WsjfInput = z.infer; export type RankedWsjf = T & { wsjf: WsjfInput & { score: number }; rank: number }; @@ -13,7 +23,13 @@ export function wsjfScore(input: WsjfInput, decimals = 2): number { } const score = (input.value + input.time + input.risk) / input.effort; const factor = 10 ** decimals; - return Math.round(score * factor) / factor; + const rounded = Math.round(score * factor) / factor; + // Canonical components can still overflow — huge numerators or a subnormal + // effort divide to Infinity, which no response schema accepts. + if (!Number.isFinite(rounded)) { + throw new Error("wsjf_score_must_be_finite"); + } + return rounded; } export function withWsjfScore(input: WsjfInput) { @@ -26,3 +42,64 @@ export function rankByWsjf(items: T[]): Array b.wsjf.score - a.wsjf.score) .map((item, index) => ({ ...item, rank: index + 1 })); } + +/** + * Score an untrusted candidate. Returns the canonical components with their + * score, or null when the candidate fails the canonical schema or its score + * is not finite — never an error, and never a value a ranking cannot hold. + */ +export function validateWsjf(candidate: unknown): (WsjfInput & { score: number }) | null { + const parsed = WsjfSchema.safeParse(candidate); + if (!parsed.success) return null; + try { + return withWsjfScore(parsed.data); + } catch { + return null; + } +} + +/** + * The `## Value` section written into a GitHub issue body when a PO task is + * accepted. This is the canonical serialisation; `parseWsjfValueSection` is its + * inverse, so the score survives the round trip through the GitHub mirror. + */ +export function wsjfValueSection(wsjf: unknown): string { + return `## Value + +\`\`\`json +${JSON.stringify(wsjf, null, 2)} +\`\`\``; +} + +const VALUE_HEADING = /^##\s+Value\s*$/m; +const NEXT_HEADING = /^##\s/m; +const FENCED_JSON = /```(?:json)?\s*\n([\s\S]*?)\n\s*```/; + +/** + * Read a WSJF judgement back out of an issue body. Returns the components with + * their score, or null when the body carries no `## Value` section that + * survives the canonical schema — malformed, forged, or overflowing blocks are + * treated as unscored, never as errors. Issue bodies are world-writable, so a + * parsed judgement identifies what the block claims; it is not provenance. + * Anything that ranks stories must bind to the task record Facility itself + * wrote (see the API's pipeline assembly). + */ +export function parseWsjfValueSection( + body: string | null | undefined, +): (WsjfInput & { score: number }) | null { + if (!body) return null; + const heading = VALUE_HEADING.exec(body); + if (!heading) return null; + let section = body.slice(heading.index + heading[0].length); + const next = NEXT_HEADING.exec(section); + if (next) section = section.slice(0, next.index); + const fence = FENCED_JSON.exec(section); + if (!fence?.[1]) return null; + let parsed: unknown; + try { + parsed = JSON.parse(fence[1]); + } catch { + return null; + } + return validateWsjf(parsed); +} diff --git a/packages/harness/test/harness.test.ts b/packages/harness/test/harness.test.ts index bd2f4d3e..40dbb84b 100644 --- a/packages/harness/test/harness.test.ts +++ b/packages/harness/test/harness.test.ts @@ -2,7 +2,13 @@ import { expect, it } from "vitest"; import { productChain, researchChain } from "../src/chain.js"; import { buildHarnessBundle } from "../src/session.js"; import { validate } from "../src/validate.js"; -import { rankByWsjf, wsjfScore } from "../src/wsjf.js"; +import { + parseWsjfValueSection, + rankByWsjf, + validateWsjf, + wsjfScore, + wsjfValueSection, +} from "../src/wsjf.js"; const created = "2026-07-03"; @@ -88,6 +94,84 @@ it("scores and ranks WSJF", () => { ).toBe("b"); }); +it("round-trips a WSJF judgement through the issue-body Value section", () => { + const wsjf = { value: 8, time: 5, risk: 2, effort: 4 }; + const body = `Task body. + +${wsjfValueSection(wsjf)} + +## KB trace + +- task: pot_1 +`; + expect(parseWsjfValueSection(body)).toEqual({ ...wsjf, score: 3.75 }); +}); + +it("treats missing or malformed Value sections as unscored, never as errors", () => { + expect(parseWsjfValueSection(null)).toBeNull(); + expect(parseWsjfValueSection("Plain hand-written issue body.")).toBeNull(); + expect(parseWsjfValueSection("## Value\n\nno fenced block here")).toBeNull(); + expect(parseWsjfValueSection("## Value\n\n```json\nnot json\n```")).toBeNull(); + expect( + parseWsjfValueSection('## Value\n\n```json\n{ "value": 1, "time": 1, "risk": 1 }\n```'), + ).toBeNull(); + expect( + parseWsjfValueSection( + '## Value\n\n```json\n{ "value": 1, "time": 1, "risk": 1, "effort": 0 }\n```', + ), + ).toBeNull(); + // A fence in a later section is not a Value block. + expect( + parseWsjfValueSection( + '## Value\n\nprose only\n\n## KB trace\n\n```json\n{ "value": 1, "time": 1, "risk": 1, "effort": 1 }\n```', + ), + ).toBeNull(); +}); + +it("rejects Value blocks the canonical WSJF schema rejects", () => { + const body = (wsjf: unknown) => `## Value\n\n\`\`\`json\n${JSON.stringify(wsjf)}\n\`\`\``; + // Negative and fractional components are not canonical judgements. + expect(parseWsjfValueSection(body({ value: -8, time: 5, risk: 3, effort: 2 }))).toBeNull(); + expect(parseWsjfValueSection(body({ value: 8, time: 5, risk: 3, effort: -2 }))).toBeNull(); + expect(parseWsjfValueSection(body({ value: 8.5, time: 5, risk: 3, effort: 2 }))).toBeNull(); + // Strings that JSON.parse happily carries are not numbers. + expect(parseWsjfValueSection(body({ value: "8", time: 5, risk: 3, effort: 2 }))).toBeNull(); + expect(parseWsjfValueSection(body([8, 5, 3, 2]))).toBeNull(); +}); + +it("never yields a non-finite score, whatever the block claims", () => { + const body = (wsjf: unknown) => `## Value\n\n\`\`\`json\n${JSON.stringify(wsjf)}\n\`\`\``; + // The canonical schema bounds components to safe integers, so a 1e308 + // numerator never reaches the division. + expect(parseWsjfValueSection(body({ value: 1e308, time: 1e308, risk: 0, effort: 1 }))).toBeNull(); + // Effort is any positive number: a subnormal divides an honest numerator to + // Infinity, so the score itself must also be checked. + expect(parseWsjfValueSection(body({ value: 1, time: 1, risk: 1, effort: 5e-324 }))).toBeNull(); + // JSON has no Infinity literal, but the validator must not depend on that. + expect(validateWsjf({ value: Number.POSITIVE_INFINITY, time: 0, risk: 0, effort: 1 })).toBeNull(); + expect(() => wsjfScore({ value: 1e308, time: 1e308, risk: 0, effort: 1 })).toThrow( + "wsjf_score_must_be_finite", + ); + // A large but finite score survives. + expect(validateWsjf({ value: 1_000_000, time: 0, risk: 0, effort: 0.001 })).toMatchObject({ + score: 1_000_000_000, + }); +}); + +it("validates untrusted judgements with the same schema task frontmatter uses", () => { + expect(validateWsjf({ value: 8, time: 5, risk: 2, effort: 4 })).toEqual({ + value: 8, + time: 5, + risk: 2, + effort: 4, + score: 3.75, + }); + expect(validateWsjf(null)).toBeNull(); + expect(validateWsjf("wsjf")).toBeNull(); + expect(validateWsjf({})).toBeNull(); + expect(validateWsjf({ value: 8, time: 5, risk: 2, effort: 0 })).toBeNull(); +}); + it("builds session recovery bundle text", () => { const bundle = buildHarnessBundle({ chain: researchChain, diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 036685f0..17366a7c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11791,6 +11791,35 @@ "type": "string", "format": "date-time" }, + "wsjf": { + "nullable": true, + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "time": { + "type": "number" + }, + "risk": { + "type": "number" + }, + "effort": { + "type": "number" + }, + "score": { + "type": "number" + } + }, + "required": [ + "value", + "time", + "risk", + "effort", + "score" + ], + "additionalProperties": false + }, "stageState": { "type": "string", "enum": [ @@ -11975,6 +12004,7 @@ "ghCreatedAt", "ghUpdatedAt", "closedAt", + "wsjf", "stageState", "runState", "currentRun", @@ -12520,6 +12550,35 @@ "type": "string", "format": "date-time" }, + "wsjf": { + "nullable": true, + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "time": { + "type": "number" + }, + "risk": { + "type": "number" + }, + "effort": { + "type": "number" + }, + "score": { + "type": "number" + } + }, + "required": [ + "value", + "time", + "risk", + "effort", + "score" + ], + "additionalProperties": false + }, "prs": { "type": "array", "items": { @@ -12755,6 +12814,7 @@ "ghCreatedAt", "ghUpdatedAt", "closedAt", + "wsjf", "prs", "ciState", "ciUrl", diff --git a/packages/sdk/src/schema.d.ts b/packages/sdk/src/schema.d.ts index f5edd1fd..19d7d086 100644 --- a/packages/sdk/src/schema.d.ts +++ b/packages/sdk/src/schema.d.ts @@ -8964,6 +8964,13 @@ export interface operations { ghUpdatedAt: string | null; /** Format: date-time */ closedAt: string | null; + wsjf: { + value: number; + time: number; + risk: number; + effort: number; + score: number; + } | null; /** @enum {string} */ stageState: "ready_to_plan" | "needs_attention" | "in_progress" | "needs_review" | "ready_to_build" | "failed" | "draft_pr" | "checks_running" | "checks_failed" | "awaiting_review" | "shipped_recently"; /** @enum {string|null} */ @@ -9258,6 +9265,13 @@ export interface operations { ghUpdatedAt: string | null; /** Format: date-time */ closedAt: string | null; + wsjf: { + value: number; + time: number; + risk: number; + effort: number; + score: number; + } | null; prs: { number: number; title: string; diff --git a/services/api/src/executors.ts b/services/api/src/executors.ts index 3a9e8555..4d2b09c6 100644 --- a/services/api/src/executors.ts +++ b/services/api/src/executors.ts @@ -32,7 +32,7 @@ import { users, webhookDeliveries, } from "@facility/db"; -import { artifactIdFor, validate } from "@facility/harness"; +import { artifactIdFor, validate, wsjfValueSection } from "@facility/harness"; import { and, desc, eq, gt, inArray, isNull, notInArray, or, sql } from "drizzle-orm"; import { assertBudgetAgentInProject, resolveBudgetScope } from "./budget-scope.js"; import { @@ -2254,11 +2254,7 @@ async function executeTaskCreation( const github = options.github ?? (await githubIssueClientForRepo(db, repo, options)); const issueBody = `${task.bodyMd.trimEnd()} -## Value - -\`\`\`json -${JSON.stringify(task.wsjf, null, 2)} -\`\`\` +${wsjfValueSection(task.wsjf)} ## KB trace diff --git a/services/api/src/pipeline.ts b/services/api/src/pipeline.ts index 64822b44..b321d308 100644 --- a/services/api/src/pipeline.ts +++ b/services/api/src/pipeline.ts @@ -1,5 +1,9 @@ /** Server-owned story assembly and pipeline classification. */ +import { validateWsjf, type WsjfInput } from "@facility/harness"; + +export type StoryWsjf = WsjfInput & { score: number }; + export type PipelineStage = | "backlog" | "planning" @@ -80,6 +84,7 @@ export type PipelineStoryInput = { ghCreatedAt: Date | null; ghUpdatedAt: Date | null; closedAt: Date | null; + wsjf: StoryWsjf | null; linkedRuns: PipelineRun[]; prs: PipelinePullRequest[]; }; @@ -104,6 +109,7 @@ export type PipelineIssueRecord = { assignees: unknown; author: string | null; htmlUrl: string; + bodyMd: string | null; commentsCount: number; ghCreatedAt: Date | null; ghUpdatedAt: Date | null; @@ -132,6 +138,13 @@ export type PipelinePullRequestRecord = { export type PipelineRepoRecord = { id: string; owner: string; name: string }; +/** + * A PO task as Facility recorded it: `wsjf` is the accepted judgement and `gh` + * is the provenance Facility wrote when it created the mirrored issue. This — + * not the world-writable issue body — is what a story's rank binds to. + */ +export type PipelineTaskRecord = { wsjf: unknown; gh: unknown }; + export type PipelineRunRecord = PipelineRun & { gh: unknown }; export type PipelineAssembly = { @@ -147,11 +160,31 @@ export function assemblePipelineStories(input: { pullRequests: PipelinePullRequestRecord[]; repos: PipelineRepoRecord[]; runs: PipelineRunRecord[]; + tasks?: PipelineTaskRecord[]; }): PipelineAssembly { const reposById = new Map(input.repos.map((repo) => [repo.id, repo])); const repoIdByName = new Map( input.repos.map((repo) => [`${repo.owner}/${repo.name}`.toLowerCase(), repo.id]), ); + + // Scores bind to the judgement Facility itself recorded on the PO task, + // located through the `gh` provenance written when the issue was created. + // The `## Value` block mirrored into the issue body is world-writable — + // any issue author can edit it — so it never ranks a story. Records that + // fail the canonical schema, or whose score is not finite, stay unscored. + // Callers pass tasks newest-first; the first canonical record per issue wins. + const wsjfByIssue = new Map(); + for (const task of input.tasks ?? []) { + const gh = objectValue(task.gh); + const fullName = stringValue(gh.repo); + const issueNumber = numberValue(gh.issue_number); + const repoId = fullName ? repoIdByName.get(fullName.toLowerCase()) : null; + if (!repoId || !issueNumber) continue; + const scored = validateWsjf(task.wsjf); + if (!scored) continue; + const key = repoNumberKey(repoId, issueNumber); + if (!wsjfByIssue.has(key)) wsjfByIssue.set(key, scored); + } const stories = new Map(); const issueKeyByRepoNumber = new Map(); const storyKeysByPull = new Map>(); @@ -199,6 +232,7 @@ export function assemblePipelineStories(input: { ghCreatedAt: issue.ghCreatedAt, ghUpdatedAt: issue.ghUpdatedAt, closedAt: issue.closedAt, + wsjf: wsjfByIssue.get(repoNumberKey(issue.repoId, issue.number)) ?? null, linkedRuns: [], prs: [], }); @@ -242,6 +276,7 @@ export function assemblePipelineStories(input: { ghCreatedAt: pull.ghCreatedAt, ghUpdatedAt: pull.ghUpdatedAt, closedAt: pull.mergedAt ?? pull.closedAt, + wsjf: null, linkedRuns: [], prs: [pullRequestOf(pull)], }); @@ -332,17 +367,41 @@ export function classifyPipeline( stages.get("shipped")?.push(placeBase(story, "shipped_recently")); } } - for (const placed of stages.values()) { - placed.sort( - (left, right) => - (right.ghUpdatedAt?.getTime() ?? 0) - (left.ghUpdatedAt?.getTime() ?? 0) || - right.number - left.number || - right.repoId.localeCompare(left.repoId), - ); + for (const [stage, placed] of stages) { + placed.sort(stage === "shipped" ? byRecency : byPriority); } return stages; } +/** Shipped is a log, not a queue: most recently touched on top. */ +function byRecency(left: PlacedPipelineStory, right: PlacedPipelineStory) { + return ( + (right.ghUpdatedAt?.getTime() ?? 0) - (left.ghUpdatedAt?.getTime() ?? 0) || + right.number - left.number || + right.repoId.localeCompare(left.repoId) + ); +} + +/** + * Active stages order by the WSJF judgement Facility recorded on the PO task, + * not by last activity — a comment, a label, or Facility's own acknowledgement + * must not reorder a stage, and neither can an edited issue body. Unscored + * stories sit below scored ones and keep GitHub's own newest-created-first + * grammar. + */ +function byPriority(left: PlacedPipelineStory, right: PlacedPipelineStory) { + if (left.wsjf || right.wsjf) { + if (!right.wsjf) return -1; + if (!left.wsjf) return 1; + if (right.wsjf.score !== left.wsjf.score) return right.wsjf.score - left.wsjf.score; + } + return ( + (right.ghCreatedAt?.getTime() ?? 0) - (left.ghCreatedAt?.getTime() ?? 0) || + right.number - left.number || + right.repoId.localeCompare(left.repoId) + ); +} + function placeOpen( story: PipelineStoryInput, hasOpenProposal: boolean, diff --git a/services/api/src/routes/v1/github.ts b/services/api/src/routes/v1/github.ts index 1eee38f1..6feb7b8a 100644 --- a/services/api/src/routes/v1/github.ts +++ b/services/api/src/routes/v1/github.ts @@ -6,13 +6,14 @@ import { ghPullRequests, githubInstallations, insertAuditEvent, + poTasks, proposals, repos, runEvents, runs, users, } from "@facility/db"; -import { and, desc, eq, gte, inArray, isNull, lt, or, type SQL, sql } from "drizzle-orm"; +import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, or, type SQL, sql } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { assertBuilderPlanDispatch, withBuilderPlanPreflight } from "../../builder-plan-policy.js"; @@ -203,6 +204,13 @@ const PipelinePullRequestSchema = z.object({ mergedAt: DateValue.nullable(), closingIssues: z.array(z.number().int()), }); +const StoryWsjfSchema = z.object({ + value: z.number(), + time: z.number(), + risk: z.number(), + effort: z.number(), + score: z.number(), +}); const PipelineStorySchema = z.object({ key: z.string(), id: z.string(), @@ -221,6 +229,7 @@ const PipelineStorySchema = z.object({ ghCreatedAt: DateValue.nullable(), ghUpdatedAt: DateValue.nullable(), closedAt: DateValue.nullable(), + wsjf: StoryWsjfSchema.nullable(), stageState: PipelineStageStateSchema, runState: z.enum(["live", "failed"]).nullable(), currentRun: z @@ -263,6 +272,7 @@ const StoryDetailSchema = z.object({ ghCreatedAt: DateValue.nullable(), ghUpdatedAt: DateValue.nullable(), closedAt: DateValue.nullable(), + wsjf: StoryWsjfSchema.nullable(), prs: z.array(PipelinePullRequestSchema), ciState: z.enum(["pending", "success", "failure"]).nullable(), ciUrl: z.string().nullable(), @@ -1081,7 +1091,7 @@ async function assembleProjectStories( projectId: string, ) { const shippedSince = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - const [issueRows, repoRows] = await Promise.all([ + const [issueRows, repoRows, taskRows] = await Promise.all([ db .select() .from(ghIssues) @@ -1096,6 +1106,7 @@ async function assembleProjectStories( .select({ id: repos.id, owner: repos.owner, name: repos.name }) .from(repos) .where(and(eq(repos.orgId, orgId), eq(repos.projectId, projectId))), + tasksForAssembly(db, orgId, projectId), ]); const linkedPullConditions: SQL[] = []; for (const repo of repoRows) { @@ -1141,6 +1152,7 @@ async function assembleProjectStories( pullRequests: pullRows, repos: repoRows, runs: runRows, + tasks: taskRows, }); } @@ -1348,9 +1360,23 @@ async function assembleSelectedStories( pullRequests: pullRows, repos: repoRows, runs: runRows, + tasks: await tasksForAssembly(db, orgId, projectId), }); } +/** + * The trusted provenance behind story ranks: PO tasks Facility has mirrored to + * GitHub, newest first so the latest judgement for an issue wins. The issue + * body's `## Value` block is world-writable and never consulted for ordering. + */ +function tasksForAssembly(db: FastifyInstance["facilityDb"], orgId: string, projectId: string) { + return db + .select({ wsjf: poTasks.wsjf, gh: poTasks.gh }) + .from(poTasks) + .where(and(eq(poTasks.orgId, orgId), eq(poTasks.projectId, projectId), isNotNull(poTasks.gh))) + .orderBy(desc(poTasks.updatedAt)); +} + async function runsForAssembly( db: FastifyInstance["facilityDb"], orgId: string, diff --git a/services/api/test/github-platform-lane.test.ts b/services/api/test/github-platform-lane.test.ts index bc2c494d..106d5d18 100644 --- a/services/api/test/github-platform-lane.test.ts +++ b/services/api/test/github-platform-lane.test.ts @@ -15,6 +15,7 @@ import { orgs, outcomes, platformIssues, + poTasks, previewSandboxes, projects, proposalEvents, @@ -2202,6 +2203,84 @@ describe("github platform lane", async () => { expect(closed.json().items.map((item: { number: number }) => item.number)).toContain(3); }); + it("keeps serving the pipeline when issue bodies forge the Value block, ranking only Facility's own judgement", async () => { + const repo = await insertRepo({ owner: `wsjf-${Date.now()}`, name: "repo" }); + const forged = (block: string) => `Body.\n\n## Value\n\n\`\`\`json\n${block}\n\`\`\`\n`; + // Overflow to Infinity: two finite-looking safe-integer-shaped numbers + // whose score no response schema can hold. This used to 500 the endpoint. + await insertIssue(repo.id, 1, "open", "2026-08-01T00:00:00Z", { + bodyMd: forged('{ "value": 1e308, "time": 1e308, "risk": 0, "effort": 1 }'), + ghCreatedAt: new Date("2026-08-01T00:00:00Z"), + }); + // A subnormal effort overflows the division the same way. + await insertIssue(repo.id, 2, "open", "2026-08-02T00:00:00Z", { + bodyMd: forged('{ "value": 1, "time": 1, "risk": 1, "effort": 5e-324 }'), + ghCreatedAt: new Date("2026-08-02T00:00:00Z"), + }); + // Negative components fail the canonical schema. + await insertIssue(repo.id, 3, "open", "2026-08-03T00:00:00Z", { + bodyMd: forged('{ "value": -5, "time": 1, "risk": 1, "effort": 1 }'), + ghCreatedAt: new Date("2026-08-03T00:00:00Z"), + }); + // Self-promotion: a canonical-looking block with no Facility task behind it. + await insertIssue(repo.id, 4, "open", "2026-08-04T00:00:00Z", { + bodyMd: forged('{ "value": 900, "time": 90, "risk": 9, "effort": 1 }'), + ghCreatedAt: new Date("2026-08-04T00:00:00Z"), + }); + // The one story Facility judged — its body later edited to claim 999. + await insertIssue(repo.id, 5, "open", "2026-08-05T00:00:00Z", { + bodyMd: forged('{ "value": 999, "time": 0, "risk": 0, "effort": 1 }'), + ghCreatedAt: new Date("2026-08-05T00:00:00Z"), + }); + await db.insert(poTasks).values({ + id: newId("task"), + orgId, + projectId, + title: "Judged task", + bodyMd: "Task body", + status: "created", + wsjf: { value: 8, time: 5, risk: 3, effort: 2 }, + gh: { + repo: `${repo.owner}/${repo.name}`, + issue_number: 5, + url: `https://github.com/${repo.owner}/${repo.name}/issues/5`, + }, + }); + + const pipeline = await app.inject({ + method: "GET", + url: `/v1/projects/${projectId}/pipeline`, + headers: { cookie }, + }); + expect(pipeline.statusCode, pipeline.body).toBe(200); + const stages = pipeline.json().stages as Array<{ + key: string; + stories: Array<{ key: string; number: number; wsjf: { score: number } | null }>; + }>; + const backlog = (stages.find((stage) => stage.key === "backlog")?.stories ?? []).filter( + (story) => story.key.startsWith(`${repo.id}:`), + ); + + // Only the task-recorded judgement scores, and it outranks every forgery. + expect(backlog.map((story) => [story.number, story.wsjf?.score ?? null])).toEqual([ + [5, 8], + [4, null], + [3, null], + [2, null], + [1, null], + ]); + + // The story detail endpoint serves the same trusted judgement. + const detailQuery = new URLSearchParams({ repoId: repo.id, storyType: "issue" }); + const detail = await app.inject({ + method: "GET", + url: `/v1/projects/${projectId}/stories/5?${detailQuery}`, + headers: { cookie }, + }); + expect(detail.statusCode, detail.body).toBe(200); + expect(detail.json().wsjf).toEqual({ value: 8, time: 5, risk: 3, effort: 2, score: 8 }); + }); + it("serves repository-qualified issue and orphan-PR stories from one pipeline contract", async () => { const number = 80_000; const repoA = await insertRepo({ owner: `stories-a-${Date.now()}`, name: "repo" }); @@ -6187,7 +6266,13 @@ describe("github platform lane", async () => { return insertRepo({ owner, name: "repo", installationId: installation.id }); } - async function insertIssue(repoId: string, number: number, state: string, updatedAt: string) { + async function insertIssue( + repoId: string, + number: number, + state: string, + updatedAt: string, + overrides: Partial = {}, + ) { await db.insert(ghIssues).values({ id: newId("ghi"), orgId, @@ -6200,6 +6285,7 @@ describe("github platform lane", async () => { assignees: [], htmlUrl: `https://github.com/o/r/issues/${number}`, ghUpdatedAt: new Date(updatedAt), + ...overrides, }); } diff --git a/services/api/test/pipeline.test.ts b/services/api/test/pipeline.test.ts index 5a1d1722..3066ffa6 100644 --- a/services/api/test/pipeline.test.ts +++ b/services/api/test/pipeline.test.ts @@ -6,6 +6,7 @@ import { type PipelineIssueRecord, type PipelinePullRequestRecord, type PipelineRunRecord, + type PipelineTaskRecord, } from "../src/pipeline.js"; const NOW = new Date("2026-08-05T12:00:00Z"); @@ -248,6 +249,123 @@ describe("server-owned story pipeline", () => { ]); }); + it("orders active stages by the task-recorded WSJF judgement, immune to activity bumps", () => { + const hour = 60 * 60 * 1000; + const scoredHigh = issue("repo_a", 1, { + bodyMd: valueBody({ value: 8, time: 5, risk: 3, effort: 2 }), + ghUpdatedAt: new Date(NOW.getTime() - 72 * hour), // stale activity must not demote it + }); + const scoredLow = issue("repo_a", 2, { + bodyMd: valueBody({ value: 2, time: 1, risk: 1, effort: 2 }), + ghUpdatedAt: NOW, // touched just now — a comment must not promote it + }); + const unscoredNew = issue("repo_a", 3, { + ghCreatedAt: new Date(NOW.getTime() - 2 * hour), + ghUpdatedAt: new Date(NOW.getTime() - 2 * hour), + }); + const unscoredOldButTouched = issue("repo_a", 4, { + ghCreatedAt: new Date(NOW.getTime() - 48 * hour), + ghUpdatedAt: NOW, // freshly commented, but arrival order still governs unscored + }); + const malformed = issue("repo_a", 5, { + bodyMd: "## Value\n\n```json\nnot json\n```", + ghCreatedAt: new Date(NOW.getTime() - 96 * hour), + ghUpdatedAt: new Date(NOW.getTime() - 96 * hour), + }); + + const assembly = assemblePipelineStories({ + issues: [scoredLow, unscoredOldButTouched, malformed, scoredHigh, unscoredNew], + pullRequests: [], + repos: [{ id: "repo_a", owner: "alice", name: "alpha" }], + runs: [], + tasks: [ + task("alice/alpha", 1, { value: 8, time: 5, risk: 3, effort: 2 }), // score 8 + task("alice/alpha", 2, { value: 2, time: 1, risk: 1, effort: 2 }), // score 2 + ], + }); + const backlog = classifyPipeline(assembly.stories, new Set(), NOW.getTime()).get("backlog"); + + expect(backlog?.map((story) => story.number)).toEqual([1, 2, 3, 4, 5]); + expect(backlog?.[0]?.wsjf).toEqual({ value: 8, time: 5, risk: 3, effort: 2, score: 8 }); + expect(backlog?.slice(2).every((story) => story.wsjf === null)).toBe(true); + }); + + it("never ranks a story from its issue body: only Facility's task record scores", () => { + // The `## Value` block is world-writable. An issue author claiming a + // gigantic score must stay unscored, and an edited block on a mirrored + // issue must lose to the judgement Facility recorded. + const selfPromoted = issue("repo_a", 1, { + bodyMd: valueBody({ value: 9007199254740991, time: 0, risk: 0, effort: 1 }), + }); + const edited = issue("repo_a", 2, { + bodyMd: valueBody({ value: 900, time: 90, risk: 9, effort: 1 }), // forged edit + }); + const honest = issue("repo_a", 3, { + bodyMd: valueBody({ value: 8, time: 5, risk: 3, effort: 2 }), + }); + + const assembly = assemblePipelineStories({ + issues: [selfPromoted, edited, honest], + pullRequests: [], + repos: [{ id: "repo_a", owner: "alice", name: "alpha" }], + runs: [], + tasks: [ + task("alice/alpha", 2, { value: 2, time: 1, risk: 1, effort: 2 }), // score 2 + task("alice/alpha", 3, { value: 8, time: 5, risk: 3, effort: 2 }), // score 8 + ], + }); + const backlog = classifyPipeline(assembly.stories, new Set(), NOW.getTime()).get("backlog"); + + expect(backlog?.map((story) => [story.number, story.wsjf?.score ?? null])).toEqual([ + [3, 8], + [2, 2], + [1, null], + ]); + }); + + it("treats task records that fail the canonical schema or overflow as unscored", () => { + const issues = [1, 2, 3, 4, 5].map((number) => issue("repo_a", number)); + const assembly = assemblePipelineStories({ + issues, + pullRequests: [], + repos: [{ id: "repo_a", owner: "alice", name: "alpha" }], + runs: [], + tasks: [ + task("alice/alpha", 1, { value: -8, time: 5, risk: 3, effort: 2 }), // negative + task("alice/alpha", 2, { value: 1e308, time: 1e308, risk: 0, effort: 1 }), // unsafe ints + task("alice/alpha", 3, { value: 1, time: 1, risk: 1, effort: 5e-324 }), // score → Infinity + { wsjf: "not an object", gh: { repo: "alice/alpha", issue_number: 4 } }, // junk column + { wsjf: { value: 1, time: 1, risk: 1, effort: 1 }, gh: null }, // no provenance + ], + }); + + // Nothing throws, nothing scores, and the endpoint's response schema + // never meets an Infinity. + expect(assembly.stories.every((story) => story.wsjf === null)).toBe(true); + }); + + it("binds a task's judgement through its gh provenance, latest record first", () => { + const assembly = assemblePipelineStories({ + issues: [issue("repo_a", 7), issue("repo_b", 7)], + pullRequests: [], + repos: [ + { id: "repo_a", owner: "alice", name: "alpha" }, + { id: "repo_b", owner: "alice", name: "beta" }, + ], + runs: [], + tasks: [ + task("Alice/Alpha", 7, { value: 6, time: 2, risk: 1, effort: 3 }), // score 3, case-insensitive + task("alice/alpha", 7, { value: 9, time: 9, risk: 9, effort: 1 }), // superseded — newest first + task("alice/other", 7, { value: 9, time: 9, risk: 9, effort: 1 }), // repo outside the project + ], + }); + + const a7 = assembly.stories.find((story) => story.key === "repo_a:issue:7"); + const b7 = assembly.stories.find((story) => story.key === "repo_b:issue:7"); + expect(a7?.wsjf).toEqual({ value: 6, time: 2, risk: 1, effort: 3, score: 3 }); + expect(b7?.wsjf).toBeNull(); + }); + it("ships recent closed issues and merged orphan PRs, but not abandoned PRs", () => { const recent = new Date(NOW.getTime() - 24 * 60 * 60 * 1000); const issueStory = { ...storyWith({}), state: "closed" as const, closedAt: recent, prs: [] }; @@ -286,7 +404,11 @@ describe("server-owned story pipeline", () => { }); }); -function issue(repoId: string, number: number): PipelineIssueRecord { +function issue( + repoId: string, + number: number, + overrides: Partial = {}, +): PipelineIssueRecord { return { id: `ghi_${repoId}_${number}`, repoId, @@ -297,13 +419,42 @@ function issue(repoId: string, number: number): PipelineIssueRecord { assignees: [], author: "octocat", htmlUrl: `https://github.test/${repoId}/issues/${number}`, + bodyMd: null, commentsCount: 0, ghCreatedAt: NOW, ghUpdatedAt: NOW, closedAt: null, + ...overrides, + }; +} + +/** A PO task row as Facility records it when it mirrors an issue to GitHub. */ +function task(repo: string, issueNumber: number, wsjf: unknown): PipelineTaskRecord { + return { + wsjf, + gh: { + repo, + issue_number: issueNumber, + url: `https://github.test/${repo}/issues/${issueNumber}`, + }, }; } +function valueBody(wsjf: { value: number; time: number; risk: number; effort: number }) { + return `Task body. + +## Value + +\`\`\`json +${JSON.stringify(wsjf, null, 2)} +\`\`\` + +## KB trace + +- task: pot_1 +`; +} + function pull( repoId: string, number: number, @@ -372,6 +523,7 @@ function storyWith(ci: { ghCreatedAt: NOW, ghUpdatedAt: NOW, closedAt: null, + wsjf: null, linkedRuns: [] as PipelineRunRecord[], prs: [ {