diff --git a/apps/docs/docs/concepts/knowledge.md b/apps/docs/docs/concepts/knowledge.md index 45009e5a..d2e1774f 100644 --- a/apps/docs/docs/concepts/knowledge.md +++ b/apps/docs/docs/concepts/knowledge.md @@ -20,6 +20,12 @@ Typed, linked, validated at write time: evidence, sketch, acceptance criteria, WSJF score. - **Verification** — the closed loop on shipped work: what actually changed for users. Merged is not verified. +- **Reference** — curated documentation (architecture, conventions, runbooks). + Free; no parent required. +- **Learning** — a durable lesson from delivery or review, stored as `L` in a + Product workspace. Free; no parent required. Research workspaces reuse `L` + for **Literature** notes instead — the prefix is the same, the meaning + follows the space's chain. The platform enforces the chain mechanically — a task without a decision, a verification without a shipped task, a missing backlink: rejected at write diff --git a/apps/web/components/product/nav-tree.tsx b/apps/web/components/product/nav-tree.tsx index 7aa83179..08934bc4 100644 --- a/apps/web/components/product/nav-tree.tsx +++ b/apps/web/components/product/nav-tree.tsx @@ -2,7 +2,13 @@ import { cx } from "@facility/ui"; import { useMemo, useState } from "react"; -import { artifactIdFor, type KbDecision, type KbEntry, type KbSection } from "@/lib/kb"; +import { + artifactIdFor, + type KbChainId, + type KbDecision, + type KbEntry, + type KbSection, +} from "@/lib/kb"; /** * The left page tree: pinned context docs, then Decisions / Documentation / @@ -13,14 +19,23 @@ const EMPTY_HINTS: Record = { D: "no decisions recorded yet — capture the first ADR", R: "no documentation pages yet", S: "no signals yet — paste a transcript into the chat", - L: "no learnings yet — the learning agent files them after runs", }; +function emptyHintFor(sectionKey: string, chain: KbChainId): string { + if (sectionKey === "L") { + return chain === "product" + ? "no learnings yet — the learning agent files them after runs" + : "no literature notes yet"; + } + return EMPTY_HINTS[sectionKey] ?? "nothing here yet"; +} + export function NavTree({ sections, decisions, selected, canWrite, + chain, onSelect, onNew, }: { @@ -28,6 +43,7 @@ export function NavTree({ decisions: KbDecision[]; selected: string; canWrite: boolean; + chain: KbChainId; onSelect: (doc: string) => void; onNew: (type: "R" | "D") => void; }) { @@ -74,7 +90,7 @@ export function NavTree({ > {rows.length === 0 ? (

- {EMPTY_HINTS[section.key] ?? "nothing here yet"} + {emptyHintFor(section.key, chain)}

) : ( rows.map((entry) => ( diff --git a/apps/web/components/product/new-entry.tsx b/apps/web/components/product/new-entry.tsx index 1be29b84..8f79ffd9 100644 --- a/apps/web/components/product/new-entry.tsx +++ b/apps/web/components/product/new-entry.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { useMemo, useRef, useState } from "react"; import { CrepeEditor } from "@/components/product/crepe-editor"; import { ValidationReportPanel } from "@/components/product/validation-report"; -import { artifactIdFor, type KbEntry, TYPE_LABELS } from "@/lib/kb"; +import { artifactIdFor, type KbChainId, type KbEntry, typeLabelsFor } from "@/lib/kb"; import { createEntry, createEntryDry, type DryRunResult } from "@/lib/kb-client"; /** @@ -16,6 +16,7 @@ import { createEntry, createEntryDry, type DryRunResult } from "@/lib/kb-client" export function NewEntry({ projectId, type, + chain, entries, onCreated, onCancel, @@ -23,6 +24,7 @@ export function NewEntry({ projectId: string; /** Section-scoped: R (documentation) or D (decision). */ type: "R" | "D"; + chain: KbChainId; entries: KbEntry[]; onCreated: (artifactId: string) => void; onCancel: () => void; @@ -139,7 +141,7 @@ export function NewEntry({ {candidate.slug.replaceAll("-", " ")} - {TYPE_LABELS[candidate.type] ?? candidate.type} + {typeLabelsFor(chain)[candidate.type] ?? candidate.type} ); diff --git a/apps/web/components/product/workspace.tsx b/apps/web/components/product/workspace.tsx index 4041ef73..cef66662 100644 --- a/apps/web/components/product/workspace.tsx +++ b/apps/web/components/product/workspace.tsx @@ -11,6 +11,7 @@ import { NavTree } from "@/components/product/nav-tree"; import { NewEntry } from "@/components/product/new-entry"; import { artifactIdFor, + chainIdFromConfig, fmtStamp, groupSections, type KbDecision, @@ -24,9 +25,10 @@ import { fetchNeighborhood, type Neighborhood, patchEntry, saveSpace } from "@/l /** * The Product workspace: page tree on the left, the unified artifact page on - * the right. Every artifact — decisions, docs, signals, learnings, and the - * charter/active context docs — renders through the same template. Selection - * is URL state (?doc=D001) so artifact links deep-link and survive refreshes. + * the right. Every artifact — decisions, docs, signals, L pages (learnings on + * the product chain, literature on research), and the charter/active context + * docs — renders through the same template. Selection is URL state (?doc=D001) + * so artifact links deep-link and survive refreshes. */ export function ProductWorkspace({ projectId, @@ -54,7 +56,8 @@ export function ProductWorkspace({ return map; }, [entries]); - const sections = useMemo(() => groupSections(entries), [entries]); + const chain = chainIdFromConfig(space.config); + const sections = useMemo(() => groupSections(entries, chain), [entries, chain]); const doc = searchParams.get("doc") ?? "active"; const isPin = doc === "charter" || doc === "active"; const entry = isPin ? null : (byArtifactId.get(doc) ?? null); @@ -96,6 +99,7 @@ export function ProductWorkspace({ { setCreating(null); @@ -181,7 +185,7 @@ export function ProductWorkspace({ key={entry.id} docKey={entry.id} meta={{ - typeLabel: typeLabelFor(entry.type), + typeLabel: typeLabelFor(entry.type, chain), artifactId: artifactIdFor(entry), status: entry.status, createdAt: entry.createdAt ?? null, @@ -216,6 +220,7 @@ export function ProductWorkspace({ decisions={decisions} selected={creating ? "" : doc} canWrite={canWrite} + chain={chain} onSelect={navigate} onNew={(type) => setCreating(type)} /> diff --git a/apps/web/lib/kb.test.ts b/apps/web/lib/kb.test.ts new file mode 100644 index 00000000..d76595ef --- /dev/null +++ b/apps/web/lib/kb.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { chainIdFromConfig, groupSections, type KbEntry, typeLabelFor, typeLabelsFor } from "./kb"; + +const learning: KbEntry = { + id: "kb_l001", + type: "L", + number: 1, + slug: "session-lesson", + frontmatter: { id: "L001", type: "L" }, + bodyMd: "body", + status: null, + supersedes: null, +}; + +describe("KB type labels", () => { + it("follows the selected chain for L", () => { + expect(chainIdFromConfig({ chain: "product" })).toBe("product"); + expect(chainIdFromConfig({ chain: "research" })).toBe("research"); + expect(chainIdFromConfig({})).toBe("research"); + + expect(typeLabelFor("L", "product")).toBe("learning"); + expect(typeLabelFor("L", "research")).toBe("literature"); + expect(typeLabelsFor("product").L).toBe("learnings"); + expect(typeLabelsFor("research").L).toBe("literature"); + + expect(groupSections([learning], "product").find((section) => section.key === "L")?.label).toBe( + "learnings", + ); + expect( + groupSections([learning], "research").find((section) => section.key === "L")?.label, + ).toBe("literature"); + }); +}); diff --git a/apps/web/lib/kb.ts b/apps/web/lib/kb.ts index fd8d253b..1ecb8504 100644 --- a/apps/web/lib/kb.ts +++ b/apps/web/lib/kb.ts @@ -21,12 +21,15 @@ export type KbEntry = { export type KbSpace = { charterMd: string; activeMd: string; + config?: unknown; createdAt?: string; updatedAt?: string; charterUpdatedAt?: string | null; activeUpdatedAt?: string | null; }; +export type KbChainId = "product" | "research"; + export type KbDecision = KbEntry & { artifactId: string; supersededBy: string | null; @@ -72,7 +75,13 @@ const TYPE_SINGULAR: Record = { SR: "status report", }; -export function typeLabelFor(type: string): string { +export function typeLabelsFor(chain: KbChainId): Record { + if (chain === "research") return { ...TYPE_LABELS, L: "literature" }; + return TYPE_LABELS; +} + +export function typeLabelFor(type: string, chain: KbChainId = "product"): string { + if (type === "L" && chain === "research") return "literature"; return TYPE_SINGULAR[type] ?? type.toLowerCase(); } @@ -106,6 +115,24 @@ export function artifactIdFor(entry: Pick) : {}; + const explicit = value.chain ?? value.harnessChain; + if (explicit === "product" || explicit === "product-chain") return "product"; + if (explicit === "research" || explicit === "research-chain") return "research"; + const artifactTypes = Array.isArray(value.artifact_types) ? value.artifact_types : []; + const prefixes = new Set( + artifactTypes + .map((item) => + item && typeof item === "object" ? (item as Record).prefix : undefined, + ) + .filter((item): item is string => typeof item === "string"), + ); + if (["S", "D", "T", "V"].some((prefix) => prefixes.has(prefix))) return "product"; + return "research"; +} + const FRONTMATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/; /** Display-only: YAML frontmatter is metadata, not prose — never render it raw. */ @@ -139,8 +166,10 @@ const PRIMARY_ORDER = ["D", "R", "S", "L"] as const; * Learnings as first-class sections; every remaining type (pipeline T/V, * research chain, …) lands in one "additional resources" freeform group — * which also hosts the charter/active context docs in the nav. + * + * `L` is "learnings" on the product chain and "literature" on research. */ -export function groupSections(entries: KbEntry[]): KbSection[] { +export function groupSections(entries: KbEntry[], chain: KbChainId = "product"): KbSection[] { const byType = new Map(); for (const entry of entries) { byType.set(entry.type, [...(byType.get(entry.type) ?? []), entry]); @@ -152,7 +181,7 @@ export function groupSections(entries: KbEntry[]): KbSection[] { D: "decisions (ADRs)", R: "documentation", S: "signals", - L: "learnings", + L: chain === "research" ? "literature" : "learnings", }; const sections: KbSection[] = []; for (const type of PRIMARY_ORDER) { diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 656e3b1b..bdc444b2 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -951,6 +951,8 @@ function productChainSeed() { D: { name: "Decision", parentTypes: ["S"] }, T: { name: "Task", parentTypes: ["D"] }, V: { name: "Verification", parentTypes: ["T"] }, + R: { name: "Reference", parentTypes: [] }, + L: { name: "Learning", parentTypes: [] }, }, }; } diff --git a/packages/db/test/db.test.ts b/packages/db/test/db.test.ts index c58b9c7a..cf5561c1 100644 --- a/packages/db/test/db.test.ts +++ b/packages/db/test/db.test.ts @@ -516,6 +516,22 @@ describe("db", async () => { ]), ); + const productChainItem = bundledItems.find((item) => item.name === "product-chain"); + if (!productChainItem) throw new Error("product-chain fixture missing"); + const productChainVersion = ( + await db + .select() + .from(schema.registryVersions) + .where(eq(schema.registryVersions.itemId, productChainItem.id)) + .limit(1) + )[0]; + expect(JSON.parse(productChainVersion?.content ?? "{}")).toMatchObject({ + id: "product", + types: { + L: { name: "Learning", parentTypes: [] }, + }, + }); + const learningItem = bundledItems.find((item) => item.name === "learning-agent"); if (!learningItem) throw new Error("learning-agent fixture missing"); await db diff --git a/packages/harness/contracts/po-agent.md b/packages/harness/contracts/po-agent.md index 953c4485..864af7d5 100644 --- a/packages/harness/contracts/po-agent.md +++ b/packages/harness/contracts/po-agent.md @@ -43,6 +43,13 @@ its parent will be rejected: - **Verification (V)** — the closed loop on a shipped Task: PR/deploy reference, what actually changed for users, metric movement or its absence. A merged PR is not a verified outcome; say which one you have. + +Free types (no parent required): + +- **Reference (R)** — curated documentation that should stay current: + architecture, conventions, runbooks. +- **Learning (L)** — a durable lesson from delivery or review. In Product + workspaces `L` is Learning, not research Literature. diff --git a/packages/harness/src/chain.ts b/packages/harness/src/chain.ts index bf7eedf4..a51a58d3 100644 --- a/packages/harness/src/chain.ts +++ b/packages/harness/src/chain.ts @@ -85,6 +85,14 @@ export const productChain: ArtifactChainConfig = { area: z.string().optional(), }).passthrough(), }, + L: { + prefix: "L", + name: "Learning", + parentTypes: [], + schema: SharedFrontmatter.extend({ + type: z.literal("L"), + }).passthrough(), + }, }, }; diff --git a/packages/harness/test/harness.test.ts b/packages/harness/test/harness.test.ts index 40dbb84b..1e5df46e 100644 --- a/packages/harness/test/harness.test.ts +++ b/packages/harness/test/harness.test.ts @@ -84,6 +84,26 @@ it("validates product task frontmatter", () => { expect(report.errors.map((error) => error.code)).toContain("parent_required"); }); +it("accepts product learnings", () => { + const learning = entry({ type: "L", id: "l" }); + const report = validate({ + space: activeSpace({ config: { chain: "product" } }), + chain: productChain, + entries: [learning], + links: [], + entryId: "l", + validateSpecials: false, + }); + + expect(report.ok).toBe(true); + expect(report.errors.map((error) => error.code)).not.toContain("unknown_artifact_type"); +}); + +it("names L as Learning on product and Literature on research", () => { + expect(productChain.types.L?.name).toBe("Learning"); + expect(researchChain.types.L?.name).toBe("Literature"); +}); + it("scores and ranks WSJF", () => { expect(wsjfScore({ value: 8, time: 5, risk: 2, effort: 4 })).toBe(3.75); expect( diff --git a/services/api/test/api.test.ts b/services/api/test/api.test.ts index 02caad40..8f10afe1 100644 --- a/services/api/test/api.test.ts +++ b/services/api/test/api.test.ts @@ -20,6 +20,7 @@ import { inboundEvents, insertAuditEvent, integrations, + kbEntries, kbSpaces, llmRequests, migrate, @@ -3992,6 +3993,70 @@ describe("api", async () => { expect(linkedAfter).not.toContain(first.json().id); }); + it("edits existing product learning entries", async () => { + const learningProject = ( + await db + .insert(projects) + .values({ + id: newId("proj"), + orgId, + name: "Product Learning KB", + slug: `product-learning-kb-${Date.now()}`, + settings: {}, + }) + .returning() + )[0]; + expect(learningProject).toBeTruthy(); + const space = await app.inject({ + method: "PUT", + url: `/v1/projects/${learningProject?.id}/kb/space`, + headers: { cookie }, + payload: { config: { chain: "product" } }, + }); + expect(space.statusCode, space.body).toBe(200); + const learningSpace = ( + await db + .select() + .from(kbSpaces) + .where(and(eq(kbSpaces.orgId, orgId), eq(kbSpaces.projectId, learningProject?.id ?? ""))) + .limit(1) + )[0]; + expect(learningSpace).toBeTruthy(); + const learning = ( + await db + .insert(kbEntries) + .values({ + id: newId("kb"), + orgId, + spaceId: learningSpace?.id ?? "", + type: "L", + number: 1, + slug: "agent-learning", + frontmatter: { + id: "L001", + aliases: ["L001"], + type: "L", + created: "2026-07-03", + tags: [], + }, + bodyMd: "Agent-written learning.\n\n## Links\n\n- [[L001]]\n", + }) + .returning() + )[0]; + expect(learning).toBeTruthy(); + + const patched = await app.inject({ + method: "PATCH", + url: `/v1/kb/entries/${learning?.id}`, + headers: { cookie }, + payload: { bodyMd: "Human-edited learning." }, + }); + + expect(patched.statusCode, patched.body).toBe(200); + expect(patched.json().error?.code).not.toBe("unknown_artifact_type"); + expect(patched.json().bodyMd).toContain("Human-edited learning."); + }); + it("returns null for a project whose KB space has not been created", async () => { const legacyProject = ( await db