Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 9 additions & 1 deletion apps/web/components/issues/issue-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { CiStatusLink } from "@/components/ci-status";
import type { PipelineStory } from "@/lib/pipeline";
import { storyHref } from "@/lib/pipeline";
import { storyHref, wsjfBreakdown } from "@/lib/pipeline";

function fmtAgo(iso: string | null) {
if (!iso) return "—";
Expand Down Expand Up @@ -194,6 +194,14 @@ export function IssueRow({
{label}
</span>
))}
{story.wsjf ? (
<span
title={wsjfBreakdown(story.wsjf)}
className="border border-(--line) px-1.5 py-0.5 font-mono text-[10px] text-(--mut)"
>
wsjf {story.wsjf.score}
</span>
) : null}
<span className="font-mono text-[10.5px] text-(--dim)">{fmtAgo(story.ghUpdatedAt)}</span>
{action()}
</div>
Expand Down
13 changes: 13 additions & 0 deletions apps/web/lib/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PipelineStory, "repoId" | "storyType">) {
return new URLSearchParams({ repoId: story.repoId, storyType: story.storyType }).toString();
Expand Down
1 change: 1 addition & 0 deletions apps/web/test/pipeline-story.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions packages/harness/src/wsjf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,58 @@ export function rankByWsjf<T extends { wsjf: WsjfInput }>(items: T[]): Array<Ran
.sort((a, b) => b.wsjf.score - a.wsjf.score)
.map((item, index) => ({ ...item, rank: index + 1 }));
}

/**
* 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 parseable `## Value` section —
* malformed blocks are treated as unscored, never as errors.
*/
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;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const record = parsed as Record<string, unknown>;
const value = finiteNumber(record.value);
const time = finiteNumber(record.time);
const risk = finiteNumber(record.risk);
const effort = finiteNumber(record.effort);
if (value === null || time === null || risk === null || effort === null || effort <= 0) {
return null;
}
return withWsjfScore({ value, time, risk, effort });
}

function finiteNumber(candidate: unknown): number | null {
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : null;
}
36 changes: 35 additions & 1 deletion packages/harness/test/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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, wsjfScore, wsjfValueSection } from "../src/wsjf.js";

const created = "2026-07-03";

Expand Down Expand Up @@ -88,6 +88,40 @@ 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("builds session recovery bundle text", () => {
const bundle = buildHarnessBundle({
chain: researchChain,
Expand Down
60 changes: 60 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -11710,6 +11710,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": [
Expand Down Expand Up @@ -11894,6 +11923,7 @@
"ghCreatedAt",
"ghUpdatedAt",
"closedAt",
"wsjf",
"stageState",
"runState",
"currentRun",
Expand Down Expand Up @@ -12439,6 +12469,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": {
Expand Down Expand Up @@ -12674,6 +12733,7 @@
"ghCreatedAt",
"ghUpdatedAt",
"closedAt",
"wsjf",
"prs",
"ciState",
"ciUrl",
Expand Down
14 changes: 14 additions & 0 deletions packages/sdk/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8945,6 +8945,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} */
Expand Down Expand Up @@ -9239,6 +9246,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;
Expand Down
8 changes: 2 additions & 6 deletions services/api/src/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1863,11 +1863,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

Expand Down
45 changes: 38 additions & 7 deletions services/api/src/pipeline.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/** Server-owned story assembly and pipeline classification. */

import { parseWsjfValueSection, type WsjfInput } from "@facility/harness";

export type StoryWsjf = WsjfInput & { score: number };

export type PipelineStage =
| "backlog"
| "planning"
Expand Down Expand Up @@ -80,6 +84,7 @@ export type PipelineStoryInput = {
ghCreatedAt: Date | null;
ghUpdatedAt: Date | null;
closedAt: Date | null;
wsjf: StoryWsjf | null;
linkedRuns: PipelineRun[];
prs: PipelinePullRequest[];
};
Expand All @@ -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;
Expand Down Expand Up @@ -199,6 +205,7 @@ export function assemblePipelineStories(input: {
ghCreatedAt: issue.ghCreatedAt,
ghUpdatedAt: issue.ghUpdatedAt,
closedAt: issue.closedAt,
wsjf: parseWsjfValueSection(issue.bodyMd),
linkedRuns: [],
prs: [],
});
Expand Down Expand Up @@ -242,6 +249,7 @@ export function assemblePipelineStories(input: {
ghCreatedAt: pull.ghCreatedAt,
ghUpdatedAt: pull.ghUpdatedAt,
closedAt: pull.mergedAt ?? pull.closedAt,
wsjf: null,
linkedRuns: [],
prs: [pullRequestOf(pull)],
});
Expand Down Expand Up @@ -332,17 +340,40 @@ 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 mirrored in the issue body, not by
* last activity — a comment, a label, or Facility's own acknowledgement must
* not reorder a stage. 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,
Expand Down
Loading