merge: slice 17 — ConceptCard data source (release-0.3.2)

This commit is contained in:
mvschwarz committed 2026-05-18 11:49:39 -07:00
commit 2e809917e9
6 files changed
+339 -12

No files matched your search

@@ -93,6 +93,11 @@ export interface SliceListEntry {
workflowSpec: WorkflowSpecRef | null;
status: SliceStatus;
rawStatus: string | null;
/** OPR.0.3.2.17 — frontmatter `description` / `summary` from the
* slice's primary doc (README/IMPLEMENTATION-PRD/PROGRESS). Used by
* the storytelling adapter as the ConceptCard.oneLiner for
* `rawStatus === "candidate"` slices. null when absent. */
description: string | null;
qitemCount: number;
hasProofPacket: boolean;
lastActivityAt: string | null;
@@ -299,6 +304,12 @@ export class SliceIndexer {
workflowSpec: parseWorkflowSpecRef(frontmatter["workflow_spec"]),
status,
rawStatus: (frontmatter["status"] as string | undefined) ?? null,
// OPR.0.3.2.17 — surface frontmatter `description` (or `summary`
// fallback) so the storytelling adapter can use it as the
// ConceptCard.oneLiner without a separate detail fetch. The
// detail endpoint stays the source of truth for full body
// content; this is the short-form summary mirror.
description: extractDescription(frontmatter),
qitemCount: qitemIds.length,
hasProofPacket: proofPacket !== null,
lastActivityAt,
@@ -670,6 +681,23 @@ export function parseFrontmatter(content: string): Record<string, unknown> {
* missing, non-string, or doesn't match the `<name>@<version>` shape.
* Exported so the missions route can reuse the same parser on the
* mission README's frontmatter. */
/**
* OPR.0.3.2.17 — extract a short description from slice frontmatter.
* Tries `description` first, then `summary`. Returns null when both
* are absent or non-string. Whitespace trimmed; empty strings become
* null so the adapter's graceful-empty fallback fires.
*/
export function extractDescription(frontmatter: Record<string, unknown>): string | null {
for (const key of ["description", "summary"]) {
const v = frontmatter[key];
if (typeof v === "string") {
const trimmed = v.trim();
if (trimmed.length > 0) return trimmed;
}
}
return null;
}
export function parseWorkflowSpecRef(raw: unknown): WorkflowSpecRef | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
@@ -522,4 +522,49 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
expect(indexer.list()).toHaveLength(2);
});
});
// OPR.0.3.2.17 — SliceListEntry surfaces frontmatter `description`
// so the storytelling adapter can use it as ConceptCard.oneLiner
// for `rawStatus === "candidate"` slices. Mapping: description first,
// summary fallback, null when both absent.
describe("OPR.0.3.2.17 — frontmatter description exposed on SliceListEntry", () => {
it("description: <text> populates SliceListEntry.description", () => {
writeSlice(slicesRoot, "concept-restore", {
"README.md": "---\nstatus: candidate\ndescription: First-class restore packet.\n---\n# Restore",
});
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
const entries = indexer.list();
expect(entries).toHaveLength(1);
expect(entries[0]!.description).toBe("First-class restore packet.");
expect(entries[0]!.rawStatus).toBe("candidate");
});
it("summary: <text> is the fallback when description is absent", () => {
writeSlice(slicesRoot, "concept-with-summary", {
"README.md": "---\nstatus: candidate\nsummary: Falls back to summary.\n---\n# Slice",
});
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
const entries = indexer.list();
expect(entries).toHaveLength(1);
expect(entries[0]!.description).toBe("Falls back to summary.");
});
it("description=null when both description and summary are absent", () => {
writeSlice(slicesRoot, "no-desc", {
"README.md": "---\nstatus: candidate\n---\n# No desc",
});
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
const entries = indexer.list();
expect(entries[0]!.description).toBeNull();
});
it("empty/whitespace-only description value is normalized to null (graceful-empty input)", () => {
writeSlice(slicesRoot, "empty-desc", {
"README.md": "---\nstatus: candidate\ndescription: ' '\n---\n# Slice",
});
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
const entries = indexer.list();
expect(entries[0]!.description).toBeNull();
});
});
});
@@ -501,18 +501,51 @@ export interface AdapterSliceRow {
displayName?: string;
status?: string | null;
lastActivityAt?: string | null;
/**
* OPR.0.3.2.17 — raw frontmatter `status` value (preserved alongside
* the bucketed `status`) so the adapter can route candidate slices
* to ConceptCard without changing the daemon's bucketing logic.
* Filled from SliceListEntry.rawStatus when callers thread sliceRows
* through.
*/
rawStatus?: string | null;
/**
* OPR.0.3.2.17 — frontmatter `description` for the slice. Used as
* ConceptCard.oneLiner when the slice routes via `rawStatus ===
* "candidate"`. Absent values fall back to a stable placeholder so
* the band renders gracefully.
*/
description?: string | null;
}
// Wired card kinds (post 0.3.1 storytelling-adapter wire-up):
// Wired card kinds (post OPR.0.3.2.17 — ConceptCard data source wired):
// - progress ← missions (mapped from useMissionDiscovery rows)
// - shipped ← slices with status shipped/complete/done
// - incident ← slices with status blocked/failed/danger/etc.
// - approval ← FeedCard items with kind === "approval" (from
// useActivityFeed → classifyFeed) — caller threads in
// via the 4th arg.
// Deferred: concept — ConceptCard component is preserved for a 0.3.2
// slice that picks a deliberate data source; until then the adapter
// never emits concept items and the production switch in
// StorytellingFeed has no concept branch.
// - concept ← slices with rawStatus === "candidate" (shaped backlog
// items): title ← displayName; oneLiner ← description;
// drillInHref ← /project/slice/<name>. Capped at 2
// per the curated-band rule (HG-4). Graceful-empty
// when no candidate rows exist (HG-2).
/**
* OPR.0.3.2.17 — predicate for the ConceptCard data source per PRD
* Option A. A slice qualifies as a "shaped backlog candidate" when
* BOTH conditions hold:
* - frontmatter status is "candidate" (case-insensitive)
* - the slice lives under the `backlog` mission
*
* Exported so the predicate is a discrete, testable surface — guard
* BLOCKING qitem-20260518093643 flagged that the original filter
* (rawStatus alone) over-matched any-mission candidate slices.
*/
export function isBacklogCandidateSlice(slice: AdapterSliceRow): boolean {
const status = (slice.rawStatus ?? "").toLowerCase();
if (status !== "candidate") return false;
return slice.missionId === "backlog";
}
export function buildStorytellingFeedItems(
missions: AdapterMissionRow[],
slices: AdapterSliceRow[],
@@ -562,7 +595,42 @@ export function buildStorytellingFeedItems(
const eligibleSlices = (slices ?? []).filter((slice) => {
return !slice.missionId || !hiddenMissionIds.has(slice.missionId);
});
for (const slice of eligibleSlices.slice(0, 3)) {
// OPR.0.3.2.17 — ConceptCard routing.
// PRD Option A: source is specifically shaped backlog candidates at
// `missions/backlog/slices/<slug>/README.md` with `status: candidate`
// — NOT any candidate slice across any mission. The predicate
// requires BOTH conditions; a `status: candidate` slice under a
// different mission (e.g., release-0.3.2) routes through the normal
// status-bucket path, not as a concept item. This keeps the
// ConceptCard band semantically aligned with the operator's
// "shaped backlog" mental model.
//
// Partition candidates out of the normal status-bucket routing so a
// single slice is routed to exactly ONE kind (HG-5: no double-count).
// The curated band caps at 2 (HG-4). Graceful-empty (HG-2): when no
// candidate rows are present this branch contributes zero items.
const candidateSlices = eligibleSlices.filter(isBacklogCandidateSlice);
const nonCandidateSlices = eligibleSlices.filter(
(s) => !isBacklogCandidateSlice(s),
);
for (const slice of candidateSlices.slice(0, 2)) {
const title = slice.displayName || slice.name;
const oneLinerRaw = (slice.description ?? "").trim();
const oneLiner = oneLinerRaw.length > 0
? oneLinerRaw
: "Shaped candidate — open to explore the proposed change.";
items.push({
kind: "concept",
source: {
sliceId: slice.name,
title,
oneLiner,
},
});
}
for (const slice of nonCandidateSlices.slice(0, 3)) {
const oneLiner = slice.lastActivityAt
? `Last activity ${slice.lastActivityAt}`
: `Slice in ${slice.status ?? "unknown"} state`;
@@ -634,13 +702,15 @@ export function StorytellingFeed({
return (
<div data-testid="storytelling-feed" className="flex flex-col gap-4">
{items.map((item, i) => {
// ConceptCard is preserved as a component but is not reachable
// here — the adapter never emits `kind: "concept"`. A 0.3.2
// slice will pick a data source and re-add the branch.
// OPR.0.3.2.17 — ConceptCard branch re-added. Adapter emits
// `kind: "concept"` for shaped backlog candidates
// (rawStatus === "candidate"); the switch routes them to
// ConceptCard. Other 4 kinds unchanged (HG-5).
if (item.kind === "shipped") return <ShippedCard key={i} source={item.source} />;
if (item.kind === "incident") return <IncidentCard key={i} source={item.source} />;
if (item.kind === "progress") return <ProgressCard key={i} source={item.source} onMarkComplete={onMarkMissionComplete} />;
if (item.kind === "approval") return <ApprovalCard key={i} source={item.source} />;
if (item.kind === "concept") return <ConceptCard key={i} source={item.source} />;
return null;
})}
</div>
+5
View File
@@ -18,6 +18,11 @@ export interface SliceListEntry {
railItem: string | null;
status: SliceStatus;
rawStatus: string | null;
/** OPR.0.3.2.17 — short description from slice frontmatter
* (description/summary fallback). Used by the storytelling adapter
* as ConceptCard.oneLiner for `rawStatus === "candidate"` slices.
* null when absent. */
description?: string | null;
qitemCount: number;
hasProofPacket: boolean;
lastActivityAt: string | null;
@@ -178,4 +178,33 @@ describe("StorytellingFeed — composition", () => {
const { getByTestId } = render(<StorytellingFeed items={[]} />);
expect(getByTestId("storytelling-feed-empty").textContent).toContain("No items");
});
// OPR.0.3.2.17 — StorytellingFeed switch carries the concept branch
// (previously stripped at 0.3.1). Renders ConceptCard when adapter
// emits kind:"concept". HG-6 fail-first: if the switch loses the
// concept branch (silent regression), this test FAILS — no concept
// testid is found in the DOM.
it("OPR.0.3.2.17: renders a ConceptCard when items include kind:'concept' (HG-6 fail-first)", () => {
const items: FeedCardItem[] = [
{
kind: "concept",
source: { sliceId: "concept-restore-packet", title: "Restore packet primitive", oneLiner: "Shaped candidate." },
},
];
const { getByTestId } = render(<StorytellingFeed items={items} />);
expect(getByTestId("feed-card-concept-concept-restore-packet")).toBeTruthy();
});
it("OPR.0.3.2.17: ConceptCard renders alongside the existing 4 card kinds (HG-5 no regression)", () => {
const items: FeedCardItem[] = [
{ kind: "shipped", source: { sliceId: "a", title: "A", oneLiner: "x" } },
{ kind: "incident", source: { sliceId: "b", title: "B", oneLiner: "x", status: "info" } },
{ kind: "progress", source: { missionId: "m", title: "M", oneLiner: "x", percent: 50 } },
{ kind: "approval", source: { qitemId: "q1", title: "Approve", oneLiner: "x", drillInHref: "/for-you" } },
{ kind: "concept", source: { sliceId: "c1", title: "Concept", oneLiner: "Shaped." } },
];
const { container } = render(<StorytellingFeed items={items} />);
expect(container.querySelectorAll("[data-testid^='feed-card-']").length).toBeGreaterThanOrEqual(5);
expect(container.querySelector("[data-testid='feed-card-concept-c1']")).toBeTruthy();
});
});
@@ -189,14 +189,164 @@ describe("buildStorytellingFeedItems — production adapter", () => {
expect(items).toEqual([]);
});
it("never emits concept-kind items — ConceptCard is deferred to a 0.3.2 slice", () => {
// OPR.0.3.2.17 — ConceptCard data source (replacing the 0.3.1 deferred
// pin; HG-6 fail-first discriminator).
//
// Source: shaped backlog candidates — SliceListEntry rows where
// rawStatus === "candidate" (case-insensitive).
// Mapping: sliceId ← name; title ← displayName (or name); oneLiner ←
// frontmatter description (passed through as the adapter row's
// `description` field).
// Graceful-empty (HG-2): no candidate rows → no concept items, no
// error; other kinds unaffected.
it("HG-1: rawStatus='candidate' slice emits a ConceptCard via the adapter (fail-first; passes only when concept branch is wired)", () => {
const items = buildStorytellingFeedItems(
[],
[
{
name: "concept-restore-packet",
missionId: "backlog",
displayName: "Restore packet primitive",
status: "draft",
rawStatus: "candidate",
description: "First-class restore packet so seats survive compaction.",
},
],
);
const concepts = items.filter((i) => i.kind === "concept");
expect(concepts).toHaveLength(1);
if (concepts[0]!.kind === "concept") {
expect(concepts[0]!.source.sliceId).toBe("concept-restore-packet");
expect(concepts[0]!.source.title).toBe("Restore packet primitive");
expect(concepts[0]!.source.oneLiner).toBe("First-class restore packet so seats survive compaction.");
}
});
it("HG-1: rawStatus='Candidate' (mixed case) still emits ConceptCard", () => {
const items = buildStorytellingFeedItems(
[],
[{ name: "c1", missionId: "backlog", displayName: "c1", status: "draft", rawStatus: "Candidate", description: "d" }],
);
expect(items.filter((i) => i.kind === "concept")).toHaveLength(1);
});
it("HG-2 graceful-empty: zero candidate rows → no concept items, no error, other kinds still render", () => {
const items = buildStorytellingFeedItems(
[{ name: "m1", path: "missions/m1" }],
[{ name: "s1", status: "shipped" }, { name: "s2", status: "blocked" }],
[
{ name: "s-shipped", status: "shipped" },
{ name: "s-blocked", status: "blocked" },
// no rawStatus='candidate' row anywhere
],
undefined,
[makeApprovalFeedCard({ qitemId: "q1" })],
);
expect(items.some((i) => i.kind === "concept")).toBe(false);
expect(items.filter((i) => i.kind === "concept")).toHaveLength(0);
expect(items.filter((i) => i.kind === "progress")).toHaveLength(1);
expect(items.filter((i) => i.kind === "shipped")).toHaveLength(1);
expect(items.filter((i) => i.kind === "incident")).toHaveLength(1);
expect(items.filter((i) => i.kind === "approval")).toHaveLength(1);
});
it("HG-4 cap: ConceptCard items capped at 2 per the curated-band rule", () => {
const slices = Array.from({ length: 5 }).map((_, i) => ({
name: `cand-${i}`,
missionId: "backlog",
displayName: `Candidate ${i}`,
status: "draft" as const,
rawStatus: "candidate",
description: `desc-${i}`,
}));
const items = buildStorytellingFeedItems([], slices);
expect(items.filter((i) => i.kind === "concept")).toHaveLength(2);
});
it("HG-5 no regression: candidate slices do NOT also emit shipped/incident items (concept routing is exclusive)", () => {
const items = buildStorytellingFeedItems(
[],
[{ name: "c1", missionId: "backlog", displayName: "c1", status: "draft", rawStatus: "candidate", description: "d" }],
);
// Concept-routed rows must not double-count into shipped/incident.
expect(items).toHaveLength(1);
expect(items[0]!.kind).toBe("concept");
});
it("ConceptCardSource oneLiner falls back to a stable placeholder when description is missing (defense; PRD allows graceful)", () => {
const items = buildStorytellingFeedItems(
[],
[{ name: "c1", missionId: "backlog", displayName: "c1", status: "draft", rawStatus: "candidate" }],
);
expect(items).toHaveLength(1);
if (items[0]!.kind === "concept") {
expect(typeof items[0]!.source.oneLiner).toBe("string");
expect(items[0]!.source.oneLiner.length).toBeGreaterThan(0);
}
});
// Guard BLOCKER qitem-20260518093643 — PRD Option A scopes the
// ConceptCard source narrowly: missions/backlog/slices/<slug> with
// `status: candidate`. A `status: candidate` row under any other
// mission (or with missionId === null) must NOT emit a concept item;
// it routes through the normal status-bucket path instead.
it("BLOCKER fix: candidate slice under a NON-backlog mission emits zero ConceptCards (PRD Option A — backlog-only)", () => {
const items = buildStorytellingFeedItems(
[],
[
{ name: "non-backlog-candidate", missionId: "release-0.3.2", displayName: "Non-backlog", status: "draft", rawStatus: "candidate", description: "shaped under release lane" },
],
);
expect(items.filter((i) => i.kind === "concept")).toHaveLength(0);
// Falls through to status-bucket path; status="draft" routes to incident-info.
expect(items.filter((i) => i.kind === "incident")).toHaveLength(1);
});
it("BLOCKER fix: candidate slice with missionId=null emits zero ConceptCards", () => {
const items = buildStorytellingFeedItems(
[],
[
{ name: "orphan-candidate", missionId: null, displayName: "Orphan", status: "draft", rawStatus: "candidate", description: "shaped without mission" },
],
);
expect(items.filter((i) => i.kind === "concept")).toHaveLength(0);
});
it("BLOCKER fix: backlog candidate STILL emits exactly one ConceptCard (positive case preserved)", () => {
const items = buildStorytellingFeedItems(
[],
[
{ name: "backlog-candidate", missionId: "backlog", displayName: "Backlog", status: "draft", rawStatus: "candidate", description: "shaped backlog item" },
],
);
expect(items.filter((i) => i.kind === "concept")).toHaveLength(1);
});
it("BLOCKER fix: mixed input — 2 backlog candidates + 2 non-backlog candidates → 2 ConceptCards (only backlog), non-backlog as incidents", () => {
const items = buildStorytellingFeedItems(
[],
[
{ name: "b1", missionId: "backlog", displayName: "B1", status: "draft", rawStatus: "candidate", description: "d" },
{ name: "b2", missionId: "backlog", displayName: "B2", status: "draft", rawStatus: "candidate", description: "d" },
{ name: "x1", missionId: "release-0.3.2", displayName: "X1", status: "draft", rawStatus: "candidate", description: "d" },
{ name: "x2", missionId: null, displayName: "X2", status: "draft", rawStatus: "candidate", description: "d" },
],
);
expect(items.filter((i) => i.kind === "concept")).toHaveLength(2);
// Non-backlog candidates fall through to incident (status: draft → info).
expect(items.filter((i) => i.kind === "incident")).toHaveLength(2);
});
// Pure-helper unit test — the predicate is exported separately for
// discrete pinning.
it("isBacklogCandidateSlice predicate: backlog+candidate=true; everything else=false", async () => {
const { isBacklogCandidateSlice } = await import("../src/components/feed/cards/storytelling-cards.js");
expect(isBacklogCandidateSlice({ name: "x", missionId: "backlog", rawStatus: "candidate" })).toBe(true);
expect(isBacklogCandidateSlice({ name: "x", missionId: "backlog", rawStatus: "Candidate" })).toBe(true);
expect(isBacklogCandidateSlice({ name: "x", missionId: "release-0.3.2", rawStatus: "candidate" })).toBe(false);
expect(isBacklogCandidateSlice({ name: "x", missionId: null, rawStatus: "candidate" })).toBe(false);
expect(isBacklogCandidateSlice({ name: "x", missionId: "backlog", rawStatus: "shipped" })).toBe(false);
expect(isBacklogCandidateSlice({ name: "x", missionId: "backlog", rawStatus: null })).toBe(false);
expect(isBacklogCandidateSlice({ name: "x", missionId: "backlog" })).toBe(false);
});
it("falls back to FeedCard.id when payload has no qitemId/qitem_id key", () => {