diff --git a/README.md b/README.md index 5451c91..1a08266 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,13 @@ The CLI **exits non-zero when validation fails**, so a shell `&&` chain or CI st npx nativeapptemplate-agent "a walk-in clinic queue" && echo "validation passed" ``` +Naming flags: + +| Flag | Default | Effect | +|---|---|---| +| `--slug=` | planner's pick | Override the project slug. Renames the Pascal project name (`NativeAppTemplate → VetClinic`) across all three platforms, and sets the output dir `out//`, the DB prefix, and the env-bridge token. Must be kebab-case; invalid values are reported and skipped. Example: `--slug=vet-clinic` | +| `--rename From=To` | planner's pick | Override one of the planner's domain rename targets (repeatable). `From` is a substrate token (`Shop`, `Shopkeeper`, `ItemTag`); the planner fills in everything else. An override that matches no planned rename is reported and skipped. Example: `--rename Shopkeeper=Vet --rename Shop=Clinic` | + Report flags: | Flag | Default | Effect | diff --git a/ROADMAP.md b/ROADMAP.md index 0896daa..8bdace7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -65,6 +65,8 @@ Features considered but deliberately deferred until after Layer 3 (vision judge) ### Optional explicit naming overrides +**Status: `--rename` and `--slug` shipped.** Both overrides are implemented (`src/rename-overrides.ts` + `isValidSlug` in `src/slug.ts`; `--rename From=To` and `--slug=` on the CLI, `renameOverrides` + `slug` on the MCP `generate_app` tool and `dispatch()`). `--rename` semantics are "change a planned target" — an override keys on the substrate token and replaces the planner's chosen target, and overrides matching no planned rename are reported and dropped rather than silently added. `--slug` replaces the planner's slug, which drives the output dir, DB prefix, env-bridge token, and the Pascal project name across all three platforms; invalid kebab-case is reported and ignored. `displayName` override remains deferred. + Today the planner is the sole source of slug, displayName, and rename plan (`Shop → Clinic`, `Shopkeeper → Vet`, `ItemTag → Patient`, etc.) — derived from a one-sentence natural-language spec. Output is good but not deterministic across runs (model evolution, prompt sensitivity), and there's no escape hatch when the planner picks a less-natural noun (e.g. forced into a non-substrate-reserved alternate). Proposed shape — flag-based optional overrides on top of natural language. The planner still parses the spec and proposes a full DomainSpec; CLI flags merge in afterward, taking precedence on conflicts. Anything not specified falls through to the planner's pick. diff --git a/src/dispatch.ts b/src/dispatch.ts index 03b5e6f..85ed1ee 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -16,7 +16,9 @@ import { runRepair } from "./agents/repair.js"; import { runLayer1 } from "./validation/layer1.js"; import { runLayer2, type Layer2Mode } from "./validation/layer2.js"; import type { RepairAttempt, RunReport } from "./report/model.js"; -import type { JudgeResult, Platform, PlatformDetail, WorkerResult } from "./agents/types.js"; +import type { JudgeResult, Platform, PlatformDetail, RenamePair, WorkerResult } from "./agents/types.js"; +import { applyRenameOverrides, type OverrideOutcome } from "./rename-overrides.js"; +import { isValidSlug, slugToPascal } from "./slug.js"; export type DispatchReportOptions = { enabled?: boolean; @@ -27,16 +29,56 @@ export type DispatchReportOptions = { export type DispatchOptions = { report?: DispatchReportOptions; + // Manual rename overrides merged onto the planner's plan (CLI --rename). + // See src/rename-overrides.ts. + renameOverrides?: readonly RenamePair[]; + // Manual slug override (CLI --slug). Replaces the planner's slug, which + // drives the output dir, DB prefix, env-bridge token, and the Pascal + // project name (NativeAppTemplate -> slugToPascal(slug)) across all three + // platforms. Ignored if not a valid kebab-case slug. + slug?: string; }; export type DispatchResult = JudgeResult & { report: RunReport; reportPaths: ReportPaths; + renameOverrideOutcomes: readonly OverrideOutcome[]; }; export async function dispatch(spec: string, options: DispatchOptions = {}): Promise { const startedAt = Date.now(); - const domain = await runPlanner(spec); + let domain = await runPlanner(spec); + + // Manual slug override (CLI --slug). Drives output dir, DB prefix, + // env-bridge token, and the Pascal project name — so it must land before + // the env-bridge and workers read domain.slug. Invalid slugs are traced and + // ignored rather than corrupting paths/identifiers downstream. + const slugOverride = options.slug; + if (slugOverride !== undefined && slugOverride !== domain.slug) { + if (isValidSlug(slugOverride)) { + trace("dispatch", `slug override: ${domain.slug} -> ${slugOverride} (project name -> ${slugToPascal(slugOverride)})`); + domain = { ...domain, slug: slugOverride }; + } else { + trace("dispatch", `slug override ignored: "${slugOverride}" is not a valid kebab-case slug`); + } + } + + // Manual overrides take precedence over the planner's noun choices, but only + // for renames the planner actually scheduled — unmatched overrides are traced + // and dropped, not silently added. Apply before workers/reviewer/judge/report + // so every downstream consumer sees the final plan. + const renameOverrides = options.renameOverrides ?? []; + let renameOverrideOutcomes: readonly OverrideOutcome[] = []; + if (renameOverrides.length > 0) { + const merged = applyRenameOverrides(domain.renamePlan, renameOverrides); + renameOverrideOutcomes = merged.outcomes; + for (const o of merged.outcomes) { + if (o.kind === "changed") trace("dispatch", `rename override: ${o.from} ${o.was}->${o.to} (overrode planner's pick)`); + else if (o.kind === "noop") trace("dispatch", `rename override: ${o.from}=${o.to} already the planned target — no change`); + else trace("dispatch", `rename override ignored: no planned rename for "${o.from}" (got ${o.from}=${o.to})`); + } + domain = { ...domain, renamePlan: merged.plan }; + } // Mirror the substrate's NATIVEAPPTEMPLATE_API_* config to the // renamed product equivalents (_API_*) so the agent's auto- @@ -233,7 +275,7 @@ export async function dispatch(spec: string, options: DispatchOptions = {}): Pro if (written.length > 0) trace("dispatch", `report: wrote ${written.join(", ")}`); } - return { ...judge, report, reportPaths }; + return { ...judge, report, reportPaths, renameOverrideOutcomes }; } // NATIVEAPPTEMPLATE_REPAIR control: unset / "0" / "off" / "false" → disabled; diff --git a/src/index.ts b/src/index.ts index abe97d4..76368f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,17 +4,31 @@ import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { dispatch, type DispatchReportOptions } from "./dispatch.js"; import { loadDotenvIfPresent } from "./env.js"; +import { parseRenamePair } from "./rename-overrides.js"; +import { isValidSlug, slugToPascal } from "./slug.js"; +import type { RenamePair } from "./agents/types.js"; loadDotenvIfPresent(); -export type ParsedArgs = { spec: string; report: DispatchReportOptions; open: boolean; exitZero: boolean }; +export type ParsedArgs = { + spec: string; + report: DispatchReportOptions; + open: boolean; + exitZero: boolean; + renameOverrides: RenamePair[]; + slug?: string; +}; export function parseArgs(argv: readonly string[]): ParsedArgs { const specParts: string[] = []; const report: DispatchReportOptions = {}; + const renameOverrides: RenamePair[] = []; let open = false; let exitZero = false; - for (const arg of argv) { + let slug: string | undefined; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === undefined) continue; if (arg === "--no-report") report.enabled = false; else if (arg === "--report-open") open = true; else if (arg === "--exit-zero") exitZero = true; @@ -23,11 +37,21 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { if (value === "html" || value === "json" || value === "both") report.format = value; } else if (arg.startsWith("--report-embed=")) { report.embed = arg.slice("--report-embed=".length) !== "false"; + } else if (arg === "--rename" || arg.startsWith("--rename=")) { + // Accept both `--rename From=To` (per ROADMAP) and `--rename=From=To`. + const raw = arg === "--rename" ? argv[++i] : arg.slice("--rename=".length); + const pair = parseRenamePair(raw); + if (pair) renameOverrides.push(pair); + else console.error(`warning: ignoring malformed --rename "${raw ?? ""}" (expected From=To, e.g. --rename Shop=Clinic)`); + } else if (arg === "--slug" || arg.startsWith("--slug=")) { + const raw = (arg === "--slug" ? argv[++i] : arg.slice("--slug=".length))?.trim(); + if (raw && isValidSlug(raw)) slug = raw; + else console.error(`warning: ignoring invalid --slug "${raw ?? ""}" (expected kebab-case, e.g. --slug=vet-clinic)`); } else { specParts.push(arg); } } - return { spec: specParts.join(" ").trim(), report, open, exitZero }; + return { spec: specParts.join(" ").trim(), report, open, exitZero, renameOverrides, ...(slug !== undefined ? { slug } : {}) }; } export async function main(spec?: string): Promise { @@ -35,7 +59,7 @@ export async function main(spec?: string): Promise { const input = (spec ?? parsed.spec).trim(); if (!input) { console.error( - 'Usage: nativeapptemplate-agent "your spec here" [--no-report] [--report-format=html|json|both] [--report-embed=true|false] [--report-open] [--exit-zero]', + 'Usage: nativeapptemplate-agent "your spec here" [--slug=kebab-name] [--rename From=To]... [--no-report] [--report-format=html|json|both] [--report-embed=true|false] [--report-open] [--exit-zero]', ); process.exitCode = 1; return; @@ -44,7 +68,25 @@ export async function main(spec?: string): Promise { console.log(`nativeapptemplate-agent: received spec: ${input}`); console.log('(tail tmp/trace/*.log in a tiled view via scripts/demo-tmux.sh)'); - const result = await dispatch(input, { report: parsed.report }); + const result = await dispatch(input, { + report: parsed.report, + renameOverrides: parsed.renameOverrides, + ...(parsed.slug !== undefined ? { slug: parsed.slug } : {}), + }); + + if (parsed.slug !== undefined) { + const finalSlug = result.report.meta.slug; + console.log(`project: ${slugToPascal(finalSlug)} (slug ${finalSlug}, output out/${finalSlug}/)`); + } + + for (const o of result.renameOverrideOutcomes) { + if (o.kind === "changed") console.log(`override: ${o.from} → ${o.to} (overrode planner's "${o.was}")`); + else if (o.kind === "noop") console.log(`override: ${o.from} → ${o.to} (already the planner's pick)`); + else { + const sources = result.report.domain.renamePlan.map((p) => p.from).join(", "); + console.error(`warning: --rename ${o.from}=${o.to} matched no planned rename — skipped (renamable: ${sources || "none"})`); + } + } console.log(''); console.log('=== run complete ==='); diff --git a/src/mcp.ts b/src/mcp.ts index 35c997d..b716362 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -34,10 +34,25 @@ export function createMcpServer(): McpServer { .describe( 'Natural-language SaaS spec, e.g. "a walk-in queue for a barbershop"', ), + renameOverrides: z + .array(z.object({ from: z.string().min(1), to: z.string().min(1) })) + .optional() + .describe( + 'Optional manual rename overrides keyed on the substrate token (from = "Shop" | "Shopkeeper" | "ItemTag"), each replacing the planner\'s chosen target. Overrides that match no planned rename are ignored.', + ), + slug: z + .string() + .optional() + .describe( + 'Optional kebab-case slug override (e.g. "vet-clinic"). Replaces the planner\'s slug, which sets the output directory, DB prefix, and the Pascal project name across all three platforms. Ignored if not valid kebab-case.', + ), }, }, - async ({ spec }) => { - const result = await dispatch(spec); + async ({ spec, renameOverrides, slug }) => { + const result = await dispatch(spec, { + ...(renameOverrides ? { renameOverrides } : {}), + ...(slug !== undefined ? { slug } : {}), + }); return { content: [{ type: "text", text: result.summary }], structuredContent: { diff --git a/src/rename-overrides.ts b/src/rename-overrides.ts new file mode 100644 index 0000000..313d862 --- /dev/null +++ b/src/rename-overrides.ts @@ -0,0 +1,56 @@ +import type { RenamePair } from "./agents/types.js"; + +// Manual rename overrides (ROADMAP §"Optional explicit naming overrides"). +// The planner is the sole source of the rename plan; these let a human veto a +// single noun choice without taking over the whole DomainSpec. Flag-based, not +// interactive — ROADMAP keeps the CLI scriptable and CI/MCP-safe (no TTY). + +export type OverrideOutcome = + | { kind: "changed"; from: string; was: string; to: string } + | { kind: "noop"; from: string; to: string } + | { kind: "unmatched"; from: string; to: string }; + +export type ApplyOverridesResult = { + plan: RenamePair[]; + outcomes: OverrideOutcome[]; +}; + +// Parse a single `--rename From=To` value. Splits on the first `=` only; +// returns null for malformed input (missing flag value, empty side) so the +// caller can warn and skip rather than push a junk pair into the plan. +export function parseRenamePair(raw: string | undefined): RenamePair | null { + if (raw === undefined) return null; + const eq = raw.indexOf("="); + if (eq <= 0) return null; + const from = raw.slice(0, eq).trim(); + const to = raw.slice(eq + 1).trim(); + if (!from || !to) return null; + return { from, to }; +} + +// Merge manual overrides onto the planner's rename plan. Semantics are +// "change a planned target": an override keys on the substrate token (`from`) +// and replaces the planner's chosen target (`to`). Overrides whose `from` +// matches no planned rename are reported as `unmatched` and dropped — we don't +// silently invent new renames. Later overrides for the same `from` win. +export function applyRenameOverrides( + plan: readonly RenamePair[], + overrides: readonly RenamePair[], +): ApplyOverridesResult { + const merged: RenamePair[] = plan.map((p) => ({ ...p })); + const outcomes: OverrideOutcome[] = []; + for (const override of overrides) { + const target = merged.find((p) => p.from === override.from); + if (!target) { + outcomes.push({ kind: "unmatched", from: override.from, to: override.to }); + continue; + } + if (target.to === override.to) { + outcomes.push({ kind: "noop", from: override.from, to: override.to }); + continue; + } + outcomes.push({ kind: "changed", from: override.from, was: target.to, to: override.to }); + target.to = override.to; + } + return { plan: merged, outcomes }; +} diff --git a/src/slug.ts b/src/slug.ts index 373434f..ca6d6a0 100644 --- a/src/slug.ts +++ b/src/slug.ts @@ -9,3 +9,11 @@ export function slugToPascal(slug: string): string { export function slugToSnake(slug: string): string { return slug.replace(/-/g, "_"); } + +// Kebab-case, filesystem-safe, leading alphanumeric. Mirrors the planner's +// DOMAIN_TOOL slug pattern so a manual --slug override obeys the same contract +// the planner does (it drives the output dir, DB prefix, and Pascal project +// name via slugToPascal). +export function isValidSlug(slug: string): boolean { + return /^[a-z0-9][a-z0-9-]*$/.test(slug); +} diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 09589b2..678031b 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -1325,6 +1325,128 @@ test("parseArgs ignores an invalid --report-format value", async () => { assert.equal(parsed.report.format, undefined); }); +// --- manual rename overrides (src/rename-overrides.ts, --rename flag) --- + +test("parseArgs collects repeatable --rename pairs (space form) without polluting the spec", async () => { + const { parseArgs } = await import("../src/index.js"); + const parsed = parseArgs(["a", "vet", "clinic", "--rename", "Shop=Clinic", "--rename", "Shopkeeper=Vet"]); + assert.equal(parsed.spec, "a vet clinic"); + assert.deepEqual(parsed.renameOverrides, [ + { from: "Shop", to: "Clinic" }, + { from: "Shopkeeper", to: "Vet" }, + ]); +}); + +test("parseArgs also accepts the --rename=From=To form", async () => { + const { parseArgs } = await import("../src/index.js"); + const parsed = parseArgs(["spec", "--rename=Shopkeeper=Vet"]); + assert.deepEqual(parsed.renameOverrides, [{ from: "Shopkeeper", to: "Vet" }]); +}); + +test("parseArgs skips a malformed --rename value and keeps the spec clean", async () => { + const { parseArgs } = await import("../src/index.js"); + const parsed = parseArgs(["spec", "--rename", "Shop"]); + assert.equal(parsed.spec, "spec"); + assert.deepEqual(parsed.renameOverrides, []); +}); + +test("parseRenamePair splits on the first = and rejects empty sides", async () => { + const { parseRenamePair } = await import("../src/rename-overrides.js"); + assert.deepEqual(parseRenamePair("Shop=Clinic"), { from: "Shop", to: "Clinic" }); + assert.deepEqual(parseRenamePair(" Shop = Clinic "), { from: "Shop", to: "Clinic" }); + assert.equal(parseRenamePair("Shop"), null); + assert.equal(parseRenamePair("=Clinic"), null); + assert.equal(parseRenamePair("Shop="), null); + assert.equal(parseRenamePair(undefined), null); +}); + +test("applyRenameOverrides changes a planned target and leaves the rest", async () => { + const { applyRenameOverrides } = await import("../src/rename-overrides.js"); + const plan = [ + { from: "Shop", to: "Clinic" }, + { from: "Shopkeeper", to: "Vet" }, + ]; + const { plan: merged, outcomes } = applyRenameOverrides(plan, [{ from: "Shopkeeper", to: "Provider" }]); + assert.deepEqual(merged, [ + { from: "Shop", to: "Clinic" }, + { from: "Shopkeeper", to: "Provider" }, + ]); + assert.deepEqual(outcomes, [{ kind: "changed", from: "Shopkeeper", was: "Vet", to: "Provider" }]); + // Original plan is not mutated. + assert.equal(plan[1]?.to, "Vet"); +}); + +test("applyRenameOverrides reports unmatched + noop overrides distinctly", async () => { + const { applyRenameOverrides } = await import("../src/rename-overrides.js"); + const plan = [{ from: "Shop", to: "Clinic" }]; + const { plan: merged, outcomes } = applyRenameOverrides(plan, [ + { from: "Shop", to: "Clinic" }, // already the target → noop + { from: "ItemTag", to: "Patient" }, // no planned rename → unmatched, dropped + ]); + assert.deepEqual(merged, [{ from: "Shop", to: "Clinic" }]); + assert.deepEqual(outcomes, [ + { kind: "noop", from: "Shop", to: "Clinic" }, + { kind: "unmatched", from: "ItemTag", to: "Patient" }, + ]); +}); + +test("dispatch applies a rename override end-to-end and surfaces the outcome (stub pipeline)", async () => { + const result = await dispatch("a walk-in clinic queue for small veterinary practices", { + renameOverrides: [{ from: "Shopkeeper", to: "Provider" }], + }); + assert.equal(result.overallPass, true); + assert.deepEqual(result.renameOverrideOutcomes, [ + { kind: "changed", from: "Shopkeeper", was: "Vet", to: "Provider" }, + ]); + // The override flows into the plan the report renders from. + assert.ok( + result.report.domain.renamePlan.some((p) => p.from === "Shopkeeper" && p.to === "Provider"), + "overridden pair present in report rename plan", + ); +}); + +test("dispatch with no overrides leaves renameOverrideOutcomes empty (stub pipeline)", async () => { + const result = await dispatch("a walk-in clinic queue for vets"); + assert.deepEqual(result.renameOverrideOutcomes, []); +}); + +// --- project-name (slug) override (--slug, src/slug.ts isValidSlug) --- + +test("isValidSlug accepts kebab-case and rejects everything else", async () => { + const { isValidSlug } = await import("../src/slug.js"); + assert.equal(isValidSlug("vet-clinic"), true); + assert.equal(isValidSlug("clinic-queue-2"), true); + assert.equal(isValidSlug("abc"), true); + assert.equal(isValidSlug("VetClinic"), false); // uppercase + assert.equal(isValidSlug("vet clinic"), false); // space + assert.equal(isValidSlug("-vet"), false); // leading dash + assert.equal(isValidSlug("vet_clinic"), false); // underscore + assert.equal(isValidSlug(""), false); +}); + +test("parseArgs captures a valid --slug (both = and space forms) and drops invalid ones", async () => { + const { parseArgs } = await import("../src/index.js"); + assert.equal(parseArgs(["spec", "--slug=vet-clinic"]).slug, "vet-clinic"); + assert.equal(parseArgs(["spec", "--slug", "vet-clinic"]).slug, "vet-clinic"); + // Invalid slug → dropped (undefined), spec preserved. + const bad = parseArgs(["spec", "--slug=Vet Clinic"]); + assert.equal(bad.slug, undefined); + assert.equal(bad.spec, "spec"); +}); + +test("dispatch applies a valid --slug override and rewrites the project name (stub pipeline)", async () => { + const result = await dispatch("a walk-in clinic queue for vets", { slug: "vet-clinic" }); + assert.equal(result.overallPass, true); + // The override drives the report meta.slug (output dir + Pascal name follow). + assert.equal(result.report.meta.slug, "vet-clinic"); +}); + +test("dispatch ignores an invalid slug override and keeps the planner's slug (stub pipeline)", async () => { + const result = await dispatch("a walk-in clinic queue for vets", { slug: "Not A Slug" }); + // Stub planner's slug is clinic-queue; the invalid override must not stick. + assert.equal(result.report.meta.slug, "clinic-queue"); +}); + // --- self-repair loop (src/repair-loop.ts) --- function platDetail(platform: Platform, l1: boolean, l2: boolean, l3?: boolean): PlatformDetail {