diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 4a2e55b9..91eed378 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -43,6 +43,10 @@ import { type ILoopEvent, } from "../loop"; import { runPlanning } from "../loop/planning/run-planning"; +import { + isBoringstackProject, + boringstackPlanConstraints, +} from "../loop/planning/boringstack-planning"; import { loadApprovedPlan } from "../loop/planning/plan-store"; import { loadRecipes } from "../config/recipes"; import { loadAgentSpecs } from "../config/agent-specs"; @@ -133,6 +137,88 @@ export function isApproval(line: string): boolean { return /^(approve|approved|ok|okay|yes|y|go|lgtm)\.?$/i.test(line.trim()); } +/** Map a human's plan-review reply to a decision. Pure + exported so the + * approve/cancel/revise semantics are unit-tested without the interactive prompt. */ +export function parseReviewResponse( + response: string +): + | { action: "approve" } + | { action: "cancel" } + | { action: "revise"; note: string } { + const trimmed = response.trim().toLowerCase(); + + if (trimmed === "approve" || trimmed === "a" || trimmed === "y") { + return { action: "approve" }; + } + + if (trimmed === "cancel" || trimmed === "c" || trimmed === "n") { + return { action: "cancel" }; + } + + return { action: "revise", note: response }; +} + +/** The greenfield BoringStack planning flow (description → proposed plan → human + * approve/revise/cancel → approved plan on disk). Extracted from the line handler + * to keep its cognitive complexity down; the stack detection + planner constraints + * live in the tested boringstack-planning module, and this only glues them to the + * interactive prompt. */ +async function runGreenfieldPlanning( + dir: string, + description: string, + echo: (s: string) => void, + rl: ReturnType | null, + activeModelEntry: IModelEntry +): Promise { + echo("▸ planning your product first...\n"); + + const plannerResolved = await resolveCapabilityModel("planner"); + const plannerProvider = makeProvider( + plannerResolved?.entry ?? activeModelEntry + ); + + const result = await runPlanning(dir, { + planner: plannerProvider, + // We only reach here when looksLikeBoringstack is true, so the BoringStack + // reserved-slice rule always applies (no gap) and every drop is surfaced. + constraints: boringstackPlanConstraints((dropped) => { + echo( + `▸ dropped auth slice(s) BoringStack already provides: ${dropped.join(", ")}\n` + ); + }), + describe: async () => { + await Promise.resolve(); + + return { description }; + }, + review: async (plan) => { + await Promise.resolve(); + + echo( + `\nProposed plan:\n${JSON.stringify(plan, null, 2)}\n` + + `\nApprove this plan? (approve/revise/cancel)\n` + ); + + if (rl === null) { + echo( + "(editor mode: plan review not interactive — run planning again with --plan)\n" + ); + + return { action: "cancel" }; + } + + return parseReviewResponse(await rl.question("> ")); + }, + out: echo, + }); + + echo( + result === "approved" + ? "✓ plan approved — ready to build\n" + : "planning cancelled\n" + ); +} + /** Narrow approval — GENERAL plan mode, where the model asks clarifying * questions: a "yes" may ANSWER a question, so only unambiguous approval * words exit the mode and start implementing. */ @@ -694,78 +780,15 @@ export async function repl(args: ICliArgs): Promise { return; } - // GREENFIELD BORINGSTACK INTERCEPTION: if the project is a fresh boringstack - // clone with no approved plan, route the message into planning instead of build. - const appsDirPath = `${args.dir}/apps/api`; - let needsPlanningFirst = false; - - try { - const stats = await Bun.file(appsDirPath).stat(); - - if (stats.isDirectory()) { - const approvedPlan = await loadApprovedPlan(args.dir); - - if (approvedPlan === null) { - needsPlanningFirst = true; - } - } - } catch { - // File system error — treat as non-boringstack - } - - if (needsPlanningFirst) { - echo("▸ planning your product first...\n"); - - const plannerResolved = await resolveCapabilityModel("planner"); - const plannerEntry = plannerResolved?.entry ?? activeModelEntry; - const plannerProvider = makeProvider(plannerEntry); - - const planningResult = await runPlanning(args.dir, { - planner: plannerProvider, - describe: async () => { - await Promise.resolve(); - - return { - description: line, - }; - }, - review: async (plan) => { - await Promise.resolve(); - - echo( - `\nProposed plan:\n${JSON.stringify(plan, null, 2)}\n` + - `\nApprove this plan? (approve/revise/cancel)\n` - ); - - if (rl === null) { - echo( - "(editor mode: plan review not interactive — run planning again with --plan)\n" - ); - - return { action: "cancel" }; - } - - const response = await rl.question("> "); - const trimmed = response.trim().toLowerCase(); - - if (trimmed === "approve" || trimmed === "a" || trimmed === "y") { - return { action: "approve" }; - } - - if (trimmed === "cancel" || trimmed === "c" || trimmed === "n") { - return { action: "cancel" }; - } - - return { action: "revise", note: response }; - }, - out: echo, - }); - - if (planningResult === "approved") { - echo("✓ plan approved — ready to build\n"); - } else { - echo("planning cancelled\n"); - } + // GREENFIELD BORINGSTACK INTERCEPTION: a fresh boringstack project with no + // approved plan routes into planning first. Detection + planner constraints + // share ONE structural signal (looksLikeBoringstack) so there is no gap where a + // project is planned as boringstack but not given the reserved-slice rule. + if ( + (await isBoringstackProject(args.dir)) && + (await loadApprovedPlan(args.dir)) === null + ) { + await runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry); return; } diff --git a/packages/core/src/loop/planning/boringstack-planning.ts b/packages/core/src/loop/planning/boringstack-planning.ts new file mode 100644 index 00000000..64dff8d1 --- /dev/null +++ b/packages/core/src/loop/planning/boringstack-planning.ts @@ -0,0 +1,82 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../lib/guards"; +import type { IPlanConstraints } from "./plan-types"; + +/** STACK-SPECIFIC planner guidance for BoringStack (kept OUT of the generic + * planner). Appended to the system prompt only for a BoringStack project. The + * starter ships auth, so a slice that rebuilds it duplicates the built-in surface + * and traps the build (its locale keys/routes never wire up → the gate loops on + * "unused" keys). */ +export const BORINGSTACK_PLANNER_GUIDANCE = `This build targets the BoringStack starter, which ALREADY PROVIDES authentication out of the box: sign-up, log-in, log-out, the users table, sessions, and per-user ownership all exist. Do NOT propose a slice that REBUILDS that auth surface — no User, Auth, Login, SignUp, or Logout entity. Treat "a user" as an existing actor your entities belong to (via a relationship like "belongs to a User"), never an entity to build. But DO propose the product's own domain entities normally — including any that happen to share a word with an auth concept when they are genuinely part of the product domain (a billing Account, a therapy Session, a social Profile are real features, not the auth surface).`; + +/** PURE auth/identity terms BoringStack already ships. DELIBERATELY NARROW: only + * terms that are ~never a legitimate standalone product-domain entity in an + * auth-shipping stack. Ambiguous nouns that ARE real domains elsewhere — Account + * (billing), Session (therapy), Profile (social), Credential (certification) — are + * NOT reserved, so those products keep their features. Singular + plural; + * compared lowercased. */ +export const BORINGSTACK_RESERVED_ENTITY_IDS: ReadonlySet = new Set([ + "user", + "users", + "auth", + "authentication", + "login", + "logins", + "signin", + "signins", + "signup", + "signups", + "logout", + "logouts", +]); + +/** Read the recorded archetype from a project's `.tsforge/scaffold.json`, or null + * if absent/unreadable/malformed. `read` is injectable for tests. */ +async function readArchetype( + dir: string, + read: (path: string) => Promise +): Promise { + try { + const raw: unknown = JSON.parse( + await read(join(dir, ".tsforge", "scaffold.json")) + ); + + return isRecord(raw) && typeof raw.archetype === "string" + ? raw.archetype + : null; + } catch { + return null; + } +} + +/** + * Detect a BoringStack project by its AUTHORITATIVE scaffold receipt + * (`.tsforge/scaffold.json` archetype === "boringstack"), NOT by a directory + * topology a generic monorepo could share (apps/api + apps/ui + infra/compose is + * not proof). Only a tsforge-scaffolded BoringStack writes that receipt, so this + * has no false positives. Used for BOTH the planning interception AND the planner + * constraints — one signal, so there is no gap where a project is planned as + * BoringStack but not given the reserved-slice rule. `read` is injectable for tests. + */ +export async function isBoringstackProject( + dir: string, + read: (path: string) => Promise = (p) => readFile(p, "utf-8") +): Promise { + return (await readArchetype(dir, read)) === "boringstack"; +} + +/** + * The planner constraints for a BoringStack project: the auth guidance + reserved + * pure-auth entity stripping. `onStripped` is REQUIRED — the drop is always + * surfaced (no silent truncation); the caller wires it to its own output sink. + */ +export function boringstackPlanConstraints( + onStripped: (droppedEntityIds: readonly string[]) => void +): IPlanConstraints { + return { + guidance: BORINGSTACK_PLANNER_GUIDANCE, + reservedEntities: BORINGSTACK_RESERVED_ENTITY_IDS, + onStripped, + }; +} diff --git a/packages/core/src/loop/planning/plan-types.ts b/packages/core/src/loop/planning/plan-types.ts index b844dba1..979991b7 100644 --- a/packages/core/src/loop/planning/plan-types.ts +++ b/packages/core/src/loop/planning/plan-types.ts @@ -33,3 +33,28 @@ export interface IProductPlan { readonly product: string; // one-paragraph purpose readonly slices: readonly ISlice[]; } + +/** OPT-IN, stack-specific planning constraints for proposePlan. Absent → the + * planner is fully stack-agnostic (no extra guidance, no slice stripping). A + * stack that ships features (e.g. BoringStack auth) supplies these so the planner + * doesn't propose slices the stack already provides. + * + * FAIL-CLOSED against silent truncation: this is a UNION, so `reservedEntities` + * can ONLY be set together with an `onStripped` reporter. It is a COMPILE error to + * request stripping without a way to surface the drops — the type makes a silent + * truncation unrepresentable, not merely discouraged. */ +export type IPlanConstraints = + | { + /** Appended to the generic system prompt (e.g. "this stack already ships auth"). */ + readonly guidance?: string; + readonly reservedEntities?: undefined; + readonly onStripped?: undefined; + } + | { + readonly guidance?: string; + /** Entity ids to strip from the result (lowercased match). */ + readonly reservedEntities: ReadonlySet; + /** Called with the entity ids stripped from a proposed plan — REQUIRED + * whenever `reservedEntities` is set, so a drop is never silent. */ + readonly onStripped: (droppedEntityIds: readonly string[]) => void; + }; diff --git a/packages/core/src/loop/planning/propose-plan.ts b/packages/core/src/loop/planning/propose-plan.ts index 7d0431de..bbb3b9b4 100644 --- a/packages/core/src/loop/planning/propose-plan.ts +++ b/packages/core/src/loop/planning/propose-plan.ts @@ -1,7 +1,7 @@ import type { IProvider } from "../../inference"; import { extractJson } from "../../lib/json"; import { isProductPlan } from "./plan-store"; -import type { IProductPlan } from "./plan-types"; +import type { IProductPlan, IPlanConstraints } from "./plan-types"; /** * A complete, valid example plan shown to the model to pin the exact output @@ -101,43 +101,101 @@ export function parsePlanJson(raw: string): IProductPlan | null { } } +/** Drop slices whose entity id is in `reserved` (compared lowercased). Caller- + * supplied so the rule is STACK-SPECIFIC, not baked into the generic planner. May + * return a plan with ZERO slices — an all-reserved response is mis-scoped, and the + * caller turns an empty result into null (a FINITE planning failure), strictly + * better than re-emitting the reserved slices into an infinite build loop. */ +export function stripReservedSlices( + plan: IProductPlan, + reserved: ReadonlySet +): IProductPlan { + return { + ...plan, + slices: plan.slices.filter( + (slice) => !reserved.has(slice.entity.id.toLowerCase()) + ), + }; +} + /** * Ask the model to propose a structured product plan from a description. * Returns null when the model's response can't be parsed into a usable plan. * Retries once at higher temperature (0 → 0.7) on parse failure. + * + * `constraints` is OPT-IN and stack-specific: with none (the default) the planner + * is generic — no extra prompt guidance and NO slice stripping, so a plain build + * keeps a `User` entity if it needs one. A BoringStack build passes its guidance + + * reserved set; reserved slices are then stripped, and if that leaves NO slices + * (all-reserved, mis-scoped) the result is null — a finite planning failure, never + * a plan that re-emits the reserved-slice trap. */ export async function proposePlan( deps: { planner: IProvider }, - input: { description: string; mockups?: readonly string[] } + input: { description: string; mockups?: readonly string[] }, + constraints: IPlanConstraints = {} ): Promise { + const system = + constraints.guidance === undefined + ? PLANNER_SYSTEM + : `${PLANNER_SYSTEM}\n\n${constraints.guidance}`; const userMessage = input.mockups !== undefined && input.mockups.length > 0 ? `Product description: ${input.description}\n\nMockup refs: ${input.mockups.join(", ")}` : `Product description: ${input.description}`; + const usable = (parsed: IProductPlan | null): IProductPlan | null => { + if (parsed === null) { + return null; + } + + const { reservedEntities, onStripped } = constraints; + + if (reservedEntities === undefined) { + return parsed; // stack-agnostic: never strip + } + + // Compute drops directly from the reserved-set membership (not object identity), + // so this stays correct even if stripReservedSlices is later rewritten to copy. + const droppedIds = parsed.slices + .map((s) => s.entity.id) + .filter((id) => reservedEntities.has(id.toLowerCase())); + + if (droppedIds.length > 0) { + onStripped(droppedIds); // REQUIRED by the type — a drop is never silent + } + + const stripped = stripReservedSlices(parsed, reservedEntities); + + return stripped.slices.length > 0 ? stripped : null; + }; + // First attempt: temperature 0 (deterministic) const res1 = await deps.planner.complete( [ - { role: "system", content: PLANNER_SYSTEM }, + { role: "system", content: system }, { role: "user", content: userMessage }, ], { temperature: 0 } ); - const parsed1 = parsePlanJson(res1.content); + // A first attempt that fails to parse OR strips to zero usable slices both fall + // through to the higher-temperature retry — a fresh attempt may yield real domain + // slices. Only when the retry also yields nothing usable is the result null. + const first = usable(parsePlanJson(res1.content)); - if (parsed1 !== null) { - return parsed1; + if (first !== null) { + return first; } // Retry: temperature 0.7 (more creative/forgiving) const res2 = await deps.planner.complete( [ - { role: "system", content: PLANNER_SYSTEM }, + { role: "system", content: system }, { role: "user", content: userMessage }, ], { temperature: 0.7 } ); - return parsePlanJson(res2.content); + return usable(parsePlanJson(res2.content)); } diff --git a/packages/core/src/loop/planning/run-planning.ts b/packages/core/src/loop/planning/run-planning.ts index 9605fc74..68ea38b1 100644 --- a/packages/core/src/loop/planning/run-planning.ts +++ b/packages/core/src/loop/planning/run-planning.ts @@ -1,10 +1,14 @@ import { proposePlan } from "./propose-plan"; import { writePlan } from "./plan-store"; -import type { IProductPlan } from "./plan-types"; +import type { IProductPlan, IPlanConstraints } from "./plan-types"; import type { IProvider } from "../../inference"; export interface IPlanningDeps { planner: IProvider; + /** OPT-IN stack-specific planning constraints (guidance + reserved entities). + * Omitted → the planner is stack-agnostic. The BoringStack path supplies the + * BoringStack constants; a plain build passes nothing. */ + constraints?: IPlanConstraints; describe: () => Promise<{ description: string; mockups?: readonly string[] }>; review: ( plan: IProductPlan @@ -25,7 +29,11 @@ export async function runPlanning( let currentInput = await deps.describe(); while (revisionCount < maxRevisions) { - const plan = await proposePlan({ planner: deps.planner }, currentInput); + const plan = await proposePlan( + { planner: deps.planner }, + currentInput, + deps.constraints ?? {} + ); if (plan === null) { deps.out("Failed to propose a plan. Please try again."); diff --git a/packages/core/tests/boringstack-planning.test.ts b/packages/core/tests/boringstack-planning.test.ts new file mode 100644 index 00000000..c1e9ef05 --- /dev/null +++ b/packages/core/tests/boringstack-planning.test.ts @@ -0,0 +1,67 @@ +import { test, expect, describe } from "bun:test"; +import { + isBoringstackProject, + boringstackPlanConstraints, + BORINGSTACK_PLANNER_GUIDANCE, + BORINGSTACK_RESERVED_ENTITY_IDS, +} from "../src/loop/planning/boringstack-planning"; + +describe("isBoringstackProject (authoritative scaffold-receipt detection)", () => { + test("true only when the receipt records archetype boringstack", async () => { + const read = async (): Promise => + JSON.stringify({ archetype: "boringstack", source: "x" }); + + expect(await isBoringstackProject("/proj", read)).toBe(true); + }); + + test("false for a DIFFERENT archetype (e.g. astro)", async () => { + const read = async (): Promise => + JSON.stringify({ archetype: "astro" }); + + expect(await isBoringstackProject("/proj", read)).toBe(false); + }); + + test("false when there is no receipt — a generic monorepo is NOT boringstack", async () => { + // A random apps/api + apps/ui + infra/compose repo has no tsforge receipt, so + // it is never force-planned as boringstack (no false positive, no lost User). + const read = async (): Promise => { + throw new Error("ENOENT"); + }; + + expect(await isBoringstackProject("/proj", read)).toBe(false); + }); + + test("false when the receipt is malformed JSON", async () => { + const read = async (): Promise => "not json"; + + expect(await isBoringstackProject("/proj", read)).toBe(false); + }); +}); + +describe("boringstackPlanConstraints", () => { + test("carries the BoringStack guidance + reserved set", () => { + const c = boringstackPlanConstraints(() => undefined); + + expect(c.guidance).toBe(BORINGSTACK_PLANNER_GUIDANCE); + expect(c.reservedEntities).toBe(BORINGSTACK_RESERVED_ENTITY_IDS); + }); + + test("plumbs the onStripped reporter through (drops are never silent)", () => { + const seen: string[][] = []; + const c = boringstackPlanConstraints((ids) => seen.push([...ids])); + + c.onStripped?.(["User", "Login"]); + + expect(seen).toEqual([["User", "Login"]]); + }); + + test("the reserved set is pure-auth only — keeps ambiguous domain nouns", () => { + for (const kept of ["account", "session", "profile", "credential"]) { + expect(BORINGSTACK_RESERVED_ENTITY_IDS.has(kept)).toBe(false); + } + + for (const reserved of ["user", "users", "auth", "login", "signup"]) { + expect(BORINGSTACK_RESERVED_ENTITY_IDS.has(reserved)).toBe(true); + } + }); +}); diff --git a/packages/core/tests/propose-plan.test.ts b/packages/core/tests/propose-plan.test.ts index 783f885e..7eb24ed4 100644 --- a/packages/core/tests/propose-plan.test.ts +++ b/packages/core/tests/propose-plan.test.ts @@ -2,36 +2,42 @@ import { test, expect } from "bun:test"; import { proposePlan, parsePlanJson, + stripReservedSlices, PLANNER_EXAMPLE, } from "../src/loop/planning/propose-plan"; +import { + BORINGSTACK_PLANNER_GUIDANCE, + BORINGSTACK_RESERVED_ENTITY_IDS, +} from "../src/loop/planning/boringstack-planning"; +import type { IProductPlan, ISlice } from "../src/loop/planning/plan-types"; import { isProductPlan } from "../src/loop/planning/plan-store"; import type { IProvider } from "../src/inference"; +const bookmarkSlice = { + entity: { + id: "Bookmark", + desc: "a link", + fields: [{ name: "url", type: "string" }], + relationships: [], + rules: [], + }, + ui: { + screens: ["list"], + action: "save → list", + shows: ["url"], + nav: "Bookmarks", + }, + verification: { + mustRemainTrue: ["auth"], + mustNotHappen: ["no url"], + acceptanceCheck: "bun test", + }, +} satisfies ISlice; + const mockPlan = { product: "A bookmarking app.", - slices: [ - { - entity: { - id: "Bookmark", - desc: "a link", - fields: [{ name: "url", type: "string" }], - relationships: [], - rules: [], - }, - ui: { - screens: ["list"], - action: "save → list", - shows: ["url"], - nav: "Bookmarks", - }, - verification: { - mustRemainTrue: ["auth"], - mustNotHappen: ["no url"], - acceptanceCheck: "bun test", - }, - }, - ], -}; + slices: [bookmarkSlice], +} satisfies IProductPlan; test("proposePlan turns a product description into a structured plan", async () => { const planner: IProvider = { @@ -149,6 +155,230 @@ test("parsePlanJson rejects invalid plan shape", () => { expect(result).toBeNull(); }); +function authSlice(id: string): ISlice { + return { + entity: { + id, + desc: "an identity concept", + fields: [{ name: "email", type: "string" }], + relationships: [], + rules: [], + }, + ui: { + screens: ["form"], + action: "sign up / log in", + shows: ["email"], + nav: id, + }, + verification: { + mustRemainTrue: ["auth"], + mustNotHappen: ["no email"], + acceptanceCheck: "bun test", + }, + }; +} + +/** Build a planner that returns exactly the given slices. */ +function plannerOf(slices: ISlice[]): IProvider { + return { + complete: async () => ({ + content: JSON.stringify({ product: "p", slices }), + toolCalls: [], + }), + }; +} + +// The BoringStack opt-in a stack-aware caller passes; the generic planner passes +// nothing (see the stack-agnostic test below). onStripped is REQUIRED by the type +// whenever reservedEntities is set — a drop can never be silent. +const BS = { + guidance: BORINGSTACK_PLANNER_GUIDANCE, + reservedEntities: BORINGSTACK_RESERVED_ENTITY_IDS, + onStripped: () => undefined, +}; + +test("stripReservedSlices drops a reserved slice but keeps real ones", () => { + const stripped = stripReservedSlices( + { product: "p", slices: [authSlice("User"), bookmarkSlice] }, + BORINGSTACK_RESERVED_ENTITY_IDS + ); + + expect(stripped.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); +}); + +test("stripReservedSlices drops case + plural variants (User, users, SignUp, LogIn)", () => { + for (const id of ["User", "users", "SignUp", "LogIn", "AUTH"]) { + const stripped = stripReservedSlices( + { product: "p", slices: [authSlice(id), bookmarkSlice] }, + BORINGSTACK_RESERVED_ENTITY_IDS + ); + + expect(stripped.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); + } +}); + +test("the BoringStack reserved set KEEPS ambiguous domain entities (Account/Session/Profile/Credential)", () => { + // These are real product domains elsewhere (billing, therapy, social, + // certification) — reserving them would silently delete required features. + for (const id of ["Account", "Session", "Profile", "Credential"]) { + const stripped = stripReservedSlices( + { product: "p", slices: [authSlice(id)] }, + BORINGSTACK_RESERVED_ENTITY_IDS + ); + + expect(stripped.slices.map((s) => s.entity.id)).toEqual([id]); + } +}); + +test("STACK-AGNOSTIC: with NO constraints, proposePlan does NOT strip a User slice", async () => { + // The generic planner must never assume a stack ships auth. A plain build that + // legitimately needs a User entity keeps it — the bug that leaked BoringStack + // assumptions into the core planner. + const plan = await proposePlan( + { planner: plannerOf([authSlice("User"), bookmarkSlice]) }, + { description: "an app with real users" } + ); + + expect(plan?.slices.map((s) => s.entity.id)).toEqual(["User", "Bookmark"]); +}); + +test("STACK-AGNOSTIC: the base system prompt says nothing about auth being provided", async () => { + let system = ""; + const planner: IProvider = { + complete: async (msgs) => { + system = msgs.find((m) => m.role === "system")?.content ?? ""; + + return { content: JSON.stringify(mockPlan), toolCalls: [] }; + }, + }; + + await proposePlan({ planner }, { description: "x" }); // no constraints + + expect(system).not.toContain("ALREADY PROVIDES authentication"); + expect(system).not.toContain("auth surface"); +}); + +test("stripping is SURFACED via onStripped, not silent", async () => { + const dropped: string[][] = []; + + await proposePlan( + { planner: plannerOf([authSlice("User"), bookmarkSlice]) }, + { description: "bookmarks" }, + { ...BS, onStripped: (ids) => dropped.push([...ids]) } + ); + + expect(dropped).toEqual([["User"]]); +}); + +test("BoringStack opt-in strips a redundant User slice (the live bookmark-app collision)", async () => { + const plan = await proposePlan( + { planner: plannerOf([authSlice("User"), bookmarkSlice]) }, + { description: "bookmarks" }, + BS + ); + + expect(plan?.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); +}); + +test("BoringStack opt-in appends its auth guidance to the system prompt", async () => { + let system = ""; + const planner: IProvider = { + complete: async (msgs) => { + system = msgs.find((m) => m.role === "system")?.content ?? ""; + + return { content: JSON.stringify(mockPlan), toolCalls: [] }; + }, + }; + + await proposePlan({ planner }, { description: "x" }, BS); + + expect(system).toContain("BoringStack"); + expect(system).toContain("ALREADY PROVIDES authentication"); +}); + +test("BoringStack opt-in: an all-auth plan on BOTH attempts strips to NULL (finite failure)", async () => { + let calls = 0; + const planner: IProvider = { + complete: async () => { + calls += 1; + + return { + content: JSON.stringify({ + product: "p", + slices: [authSlice("User"), authSlice("Login")], + }), + toolCalls: [], + }; + }, + }; + + const plan = await proposePlan({ planner }, { description: "just auth" }, BS); + + expect(plan).toBeNull(); + // An all-auth first attempt is retried once (a fresh try may yield real slices). + expect(calls).toBe(2); +}); + +test("BoringStack opt-in: an all-auth first attempt RETRIES and recovers a real second plan", async () => { + let calls = 0; + const planner: IProvider = { + complete: async () => { + calls += 1; + + return calls === 1 + ? { + content: JSON.stringify({ + product: "p", + slices: [authSlice("User")], + }), + toolCalls: [], + } + : { + content: JSON.stringify({ product: "p", slices: [bookmarkSlice] }), + toolCalls: [], + }; + }, + }; + + const plan = await proposePlan({ planner }, { description: "bookmarks" }, BS); + + expect(calls).toBe(2); + expect(plan?.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); +}); + +test("BoringStack opt-in: stripping also applies on the temperature-0.7 retry path", async () => { + let call = 0; + const planner: IProvider = { + complete: async () => { + call += 1; + + // First attempt: unparseable → forces the retry. Retry: User + Bookmark. + return call === 1 + ? { content: "not json", toolCalls: [] } + : { + content: JSON.stringify({ + product: "p", + slices: [authSlice("User"), bookmarkSlice], + }), + toolCalls: [], + }; + }, + }; + + const plan = await proposePlan({ planner }, { description: "bookmarks" }, BS); + + expect(call).toBe(2); + expect(plan?.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); +}); + +test("PLANNER_EXAMPLE proposes no reserved identity entity", () => { + // The worked example must model good behaviour: no User/Auth/Session slice. + const ids = PLANNER_EXAMPLE.slices.map((s) => s.entity.id.toLowerCase()); + + expect(ids).not.toContain("user"); + expect(ids).not.toContain("auth"); +}); + test("PLANNER_EXAMPLE (the shape shown to the model) is itself a valid plan", () => { // The prompt teaches the model by example. If a future edit breaks the // example's shape, the contract we advertise diverges from what the parser diff --git a/packages/core/tests/repl-planning.test.ts b/packages/core/tests/repl-planning.test.ts new file mode 100644 index 00000000..702673c9 --- /dev/null +++ b/packages/core/tests/repl-planning.test.ts @@ -0,0 +1,20 @@ +import { test, expect } from "bun:test"; +import { parseReviewResponse } from "../src/cli/repl"; + +test("parseReviewResponse: approve words → approve", () => { + for (const s of ["approve", "a", "y", " APPROVE ", "Approve"]) { + expect(parseReviewResponse(s)).toEqual({ action: "approve" }); + } +}); + +test("parseReviewResponse: cancel words → cancel", () => { + for (const s of ["cancel", "c", "n", " Cancel "]) { + expect(parseReviewResponse(s)).toEqual({ action: "cancel" }); + } +}); + +test("parseReviewResponse: anything else → revise, preserving the raw note", () => { + const note = "make the note field required"; + + expect(parseReviewResponse(note)).toEqual({ action: "revise", note }); +}); diff --git a/packages/core/tests/run-planning.test.ts b/packages/core/tests/run-planning.test.ts index fad40776..0254f9b7 100644 --- a/packages/core/tests/run-planning.test.ts +++ b/packages/core/tests/run-planning.test.ts @@ -157,3 +157,93 @@ test("runPlanning cancels when proposePlan returns null", async () => { await rm(dir, { recursive: true, force: true }); } }); + +test("runPlanning forwards constraints (guidance) to the planner", async () => { + const dir = await mkdtemp(join(tmpdir(), "plan-")); + + try { + let system = ""; + const capturingPlanner: IProvider = { + complete: async (msgs) => { + system = msgs.find((m) => m.role === "system")?.content ?? ""; + + return { content: JSON.stringify(mockPlan), toolCalls: [] }; + }, + }; + + const deps: IPlanningDeps = { + planner: capturingPlanner, + constraints: { guidance: "STACK-MARKER-XYZ" }, + describe: async () => ({ description: "a bookmarking app" }), + review: async () => ({ action: "approve" as const }), + out: () => {}, + }; + + expect(await runPlanning(dir, deps)).toBe("approved"); + // The passthrough is real: the constraint's guidance reached the planner. + expect(system).toContain("STACK-MARKER-XYZ"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("runPlanning forwards reservedEntities + onStripped (a reserved slice is dropped AND surfaced)", async () => { + const dir = await mkdtemp(join(tmpdir(), "plan-")); + + try { + // Planner returns a reserved "User" slice plus the real Bookmark slice. + const planner: IProvider = { + complete: async () => ({ + content: JSON.stringify({ + product: "p", + slices: [ + { + entity: { + id: "User", + desc: "auth", + fields: [{ name: "email", type: "string" }], + relationships: [], + rules: [], + }, + ui: { + screens: ["form"], + action: "log in", + shows: ["email"], + nav: "User", + }, + verification: { + mustRemainTrue: ["auth"], + mustNotHappen: ["x"], + acceptanceCheck: "bun test", + }, + }, + ...mockPlan.slices, + ], + }), + toolCalls: [], + }), + }; + + const dropped: string[][] = []; + const deps: IPlanningDeps = { + planner, + constraints: { + reservedEntities: new Set(["user"]), + onStripped: (ids) => dropped.push([...ids]), + }, + describe: async () => ({ description: "bookmarks" }), + review: async () => ({ action: "approve" as const }), + out: () => {}, + }; + + expect(await runPlanning(dir, deps)).toBe("approved"); + // The reserved slice was dropped from the written plan… + const written = await readPlan(dir); + + expect(written?.plan.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); + // …AND the drop was surfaced through the reporter (never silent). + expect(dropped).toEqual([["User"]]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +});