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
167 changes: 95 additions & 72 deletions packages/core/src/cli/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof createInterface> | null,
activeModelEntry: IModelEntry
): Promise<void> {
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. */
Expand Down Expand Up @@ -694,78 +780,15 @@ export async function repl(args: ICliArgs): Promise<number> {
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;
}
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/loop/planning/boringstack-planning.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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<string>
): Promise<string | null> {
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<string> = (p) => readFile(p, "utf-8")
): Promise<boolean> {
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,
};
}
25 changes: 25 additions & 0 deletions packages/core/src/loop/planning/plan-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/** 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;
};
Loading