Skip to content
Merged
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
6 changes: 6 additions & 0 deletions apps/docs/docs/concepts/knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions apps/web/components/product/nav-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand All @@ -13,21 +19,31 @@ const EMPTY_HINTS: Record<string, string> = {
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,
}: {
sections: KbSection[];
decisions: KbDecision[];
selected: string;
canWrite: boolean;
chain: KbChainId;
onSelect: (doc: string) => void;
onNew: (type: "R" | "D") => void;
}) {
Expand Down Expand Up @@ -74,7 +90,7 @@ export function NavTree({
>
{rows.length === 0 ? (
<p className="px-2 py-1.5 text-[11.5px] italic text-(--dim)">
{EMPTY_HINTS[section.key] ?? "nothing here yet"}
{emptyHintFor(section.key, chain)}
</p>
) : (
rows.map((entry) => (
Expand Down
6 changes: 4 additions & 2 deletions apps/web/components/product/new-entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -16,13 +16,15 @@ import { createEntry, createEntryDry, type DryRunResult } from "@/lib/kb-client"
export function NewEntry({
projectId,
type,
chain,
entries,
onCreated,
onCancel,
}: {
projectId: string;
/** Section-scoped: R (documentation) or D (decision). */
type: "R" | "D";
chain: KbChainId;
entries: KbEntry[];
onCreated: (artifactId: string) => void;
onCancel: () => void;
Expand Down Expand Up @@ -139,7 +141,7 @@ export function NewEntry({
{candidate.slug.replaceAll("-", " ")}
</span>
<span className="ml-auto text-[10px] text-(--dim)">
{TYPE_LABELS[candidate.type] ?? candidate.type}
{typeLabelsFor(chain)[candidate.type] ?? candidate.type}
</span>
</label>
);
Expand Down
15 changes: 10 additions & 5 deletions apps/web/components/product/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -96,6 +99,7 @@ export function ProductWorkspace({
<NewEntry
projectId={projectId}
type={creating}
chain={chain}
entries={entries}
onCreated={(artifactId) => {
setCreating(null);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -216,6 +220,7 @@ export function ProductWorkspace({
decisions={decisions}
selected={creating ? "" : doc}
canWrite={canWrite}
chain={chain}
onSelect={navigate}
onNew={(type) => setCreating(type)}
/>
Expand Down
33 changes: 33 additions & 0 deletions apps/web/lib/kb.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
35 changes: 32 additions & 3 deletions apps/web/lib/kb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,7 +75,13 @@ const TYPE_SINGULAR: Record<string, string> = {
SR: "status report",
};

export function typeLabelFor(type: string): string {
export function typeLabelsFor(chain: KbChainId): Record<string, string> {
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();
}

Expand Down Expand Up @@ -106,6 +115,24 @@ export function artifactIdFor(entry: Pick<KbEntry, "type" | "number" | "frontmat
return `${entry.type}${String(entry.number).padStart(3, "0")}`;
}

/** Mirror of packages/harness/src/chain.ts chainFromConfig — keep in sync. */
export function chainIdFromConfig(config: unknown): KbChainId {
const value = config && typeof config === "object" ? (config as Record<string, unknown>) : {};
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<string, unknown>).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. */
Expand Down Expand Up @@ -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<string, KbEntry[]>();
for (const entry of entries) {
byType.set(entry.type, [...(byType.get(entry.type) ?? []), entry]);
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
},
};
}
Expand Down
16 changes: 16 additions & 0 deletions packages/db/test/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/harness/contracts/po-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</artifact_model>

<how_you_work>
Expand Down
8 changes: 8 additions & 0 deletions packages/harness/src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
},
};

Expand Down
20 changes: 20 additions & 0 deletions packages/harness/test/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading