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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<kebab>` | planner's pick | Override the project slug. Renames the Pascal project name (`NativeAppTemplate → VetClinic`) across all three platforms, and sets the output dir `out/<slug>/`, 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 |
Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<kebab>` 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.
Expand Down
48 changes: 45 additions & 3 deletions src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<DispatchResult> {
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 (<PRODUCT>_API_*) so the agent's auto-
Expand Down Expand Up @@ -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;
Expand Down
52 changes: 47 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,19 +37,29 @@ 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<void> {
const parsed = parseArgs(process.argv.slice(2));
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;
Expand All @@ -44,7 +68,25 @@ export async function main(spec?: string): Promise<void> {
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 ===');
Expand Down
19 changes: 17 additions & 2 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
56 changes: 56 additions & 0 deletions src/rename-overrides.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
8 changes: 8 additions & 0 deletions src/slug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading