From 2a8542b0f29c2be4d24e157193a9d3d2ffb15a6d Mon Sep 17 00:00:00 2001 From: dadachi Date: Sat, 23 May 2026 07:13:36 +0900 Subject: [PATCH] feat(planner): add --rename and --slug naming overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ROADMAP "Optional explicit naming overrides" feature (flag-based, no interactive prompts — scriptable + CI/MCP-safe). --rename From=To (repeatable): overrides one of the planner's domain rename targets. Keyed on the substrate token (Shop/Shopkeeper/ItemTag); semantics are "change a planned target" — overrides matching no planned rename are reported and dropped, never silently added. --slug=: overrides the planner's slug, which drives the output dir, DB prefix, env-bridge token, and the Pascal project name (NativeAppTemplate -> VetClinic) across all three platforms. Invalid kebab-case is reported and ignored. Both apply right after the planner in dispatch() so every downstream consumer (workers, reviewer, judge, report) sees the final plan, and both are exposed on the CLI and the MCP generate_app tool. - src/rename-overrides.ts: pure parseRenamePair + applyRenameOverrides - src/slug.ts: isValidSlug (mirrors the planner's slug contract) - src/dispatch.ts: DispatchOptions.renameOverrides + slug, applied + traced - src/index.ts: --rename / --slug parsing, confirmations, warnings - src/mcp.ts: renameOverrides + slug params on generate_app - tests: 13 new (parse, merge, slug validation, end-to-end dispatch) - README + ROADMAP updated Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 7 +++ ROADMAP.md | 2 + src/dispatch.ts | 48 +++++++++++++++- src/index.ts | 52 +++++++++++++++-- src/mcp.ts | 19 ++++++- src/rename-overrides.ts | 56 ++++++++++++++++++ src/slug.ts | 8 +++ tests/smoke.test.ts | 122 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 src/rename-overrides.ts 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 {